mirror of
https://github.com/alam00000/bentopdf.git
synced 2026-08-17 21:25:47 +02:00
feat: update BentoPDF viewer to version 2.9.1 and add font embedding support
This commit is contained in:
@@ -24,10 +24,10 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
echo "version=$CUR" >> "$GITHUB_OUTPUT"
|
echo "version=$CUR" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Read latest commit from our viewer fork (v2 branch)
|
- name: Read latest commit from our viewer fork
|
||||||
id: upstream-version
|
id: upstream-version
|
||||||
run: |
|
run: |
|
||||||
LATEST=$(git ls-remote https://github.com/alam00000/bentopdf-pdfium-viewer refs/heads/v2 | cut -c1-12)
|
LATEST=$(git ls-remote https://github.com/alam00000/bentopdf-pdfium-viewer refs/heads/main | cut -c1-12)
|
||||||
echo "version=$LATEST" >> "$GITHUB_OUTPUT"
|
echo "version=$LATEST" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Should update?
|
- name: Should update?
|
||||||
@@ -53,13 +53,13 @@ jobs:
|
|||||||
|
|
||||||
- name: Clone our viewer fork
|
- name: Clone our viewer fork
|
||||||
if: steps.gate.outputs.run == 'true'
|
if: steps.gate.outputs.run == 'true'
|
||||||
run: git clone --depth 1 --branch v2 https://github.com/alam00000/bentopdf-pdfium-viewer ./_fork/bentopdf-pdfium-viewer
|
run: git clone --depth 1 --branch main https://github.com/alam00000/bentopdf-pdfium-viewer ./_fork/bentopdf-pdfium-viewer
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
if: steps.gate.outputs.run == 'true'
|
if: steps.gate.outputs.run == 'true'
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 22
|
||||||
cache: npm
|
cache: npm
|
||||||
|
|
||||||
- name: Install upstream deps
|
- name: Install upstream deps
|
||||||
@@ -127,7 +127,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Cleanup upstream clone
|
- name: Cleanup upstream clone
|
||||||
if: steps.gate.outputs.run == 'true'
|
if: steps.gate.outputs.run == 'true'
|
||||||
run: rm -rf ./_upstream
|
run: rm -rf ./_fork
|
||||||
|
|
||||||
- name: Create Pull Request
|
- name: Create Pull Request
|
||||||
if: steps.gate.outputs.run == 'true'
|
if: steps.gate.outputs.run == 'true'
|
||||||
@@ -136,7 +136,7 @@ jobs:
|
|||||||
commit-message: "build(deps): bump bentopdf-viewer from ${{ steps.current-version.outputs.version }} to ${{ steps.upstream-version.outputs.version }}"
|
commit-message: "build(deps): bump bentopdf-viewer from ${{ steps.current-version.outputs.version }} to ${{ steps.upstream-version.outputs.version }}"
|
||||||
title: "build(deps): bump bentopdf-viewer from ${{ steps.current-version.outputs.version }} to ${{ steps.upstream-version.outputs.version }}"
|
title: "build(deps): bump bentopdf-viewer from ${{ steps.current-version.outputs.version }} to ${{ steps.upstream-version.outputs.version }}"
|
||||||
body: |
|
body: |
|
||||||
- Build snippet from upstream embed-pdf-viewer via `npm run build:snippet`
|
- Build snippet from the BentoPDF viewer fork via `pnpm run build:snippet`
|
||||||
- Pack tarball into `vendor/bentopdf-viewer/` and point dependency to it
|
- Pack tarball into `vendor/bentopdf-viewer/` and point dependency to it
|
||||||
- Refresh package-lock.json
|
- Refresh package-lock.json
|
||||||
branch: chore/update-bentopdf-viewer
|
branch: chore/update-bentopdf-viewer
|
||||||
|
|||||||
Generated
+1
-1
@@ -4272,7 +4272,7 @@
|
|||||||
"node_modules/bentopdf-viewer": {
|
"node_modules/bentopdf-viewer": {
|
||||||
"version": "2.9.1",
|
"version": "2.9.1",
|
||||||
"resolved": "file:vendor/bentopdf-viewer/bentopdf-viewer-2.9.1.tgz",
|
"resolved": "file:vendor/bentopdf-viewer/bentopdf-viewer-2.9.1.tgz",
|
||||||
"integrity": "sha512-sfzgEpggMof2srTALARXtDJ0ZCzyEIClPsl8J0O4oHBWfuuT0LWvDMWAHaWuBXXIHa307RfboeK3ORy1cJO9Hw==",
|
"integrity": "sha512-WCKztFUzEuWpqvLu8UeXN1aEWgUUNHmV0K+d1PPS9DwCXEoc0d8K0qO59fMHxim3Xr2jaD7i8gECW6iiEcmBBA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"preact": "^10.17.0",
|
"preact": "^10.17.0",
|
||||||
|
|||||||
+1
-1
@@ -90,12 +90,12 @@
|
|||||||
"@types/node-forge": "^1.3.14",
|
"@types/node-forge": "^1.3.14",
|
||||||
"@types/papaparse": "^5.5.2",
|
"@types/papaparse": "^5.5.2",
|
||||||
"archiver": "^7.0.1",
|
"archiver": "^7.0.1",
|
||||||
|
"bentopdf-viewer": "file:vendor/bentopdf-viewer/bentopdf-viewer-2.9.1.tgz",
|
||||||
"blob-stream": "^0.1.3",
|
"blob-stream": "^0.1.3",
|
||||||
"bwip-js": "^4.8.0",
|
"bwip-js": "^4.8.0",
|
||||||
"cropperjs": "^1.6.2",
|
"cropperjs": "^1.6.2",
|
||||||
"diff": "^8.0.3",
|
"diff": "^8.0.3",
|
||||||
"dompurify": "^3.4.12",
|
"dompurify": "^3.4.12",
|
||||||
"bentopdf-viewer": "file:vendor/bentopdf-viewer/bentopdf-viewer-2.9.1.tgz",
|
|
||||||
"fast-xml-parser": "^5.7.1",
|
"fast-xml-parser": "^5.7.1",
|
||||||
"heic2any": "^0.0.4",
|
"heic2any": "^0.0.4",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
const FONT_CDN =
|
||||||
|
import.meta.env.VITE_EMBEDPDF_FONTS_URL ||
|
||||||
|
'https://cdn.jsdelivr.net/npm/@embedpdf';
|
||||||
|
|
||||||
|
const CHARSET = {
|
||||||
|
ANSI: 0,
|
||||||
|
DEFAULT: 1,
|
||||||
|
SHIFTJIS: 128,
|
||||||
|
HANGEUL: 129,
|
||||||
|
GB2312: 134,
|
||||||
|
CHINESEBIG5: 136,
|
||||||
|
GREEK: 161,
|
||||||
|
TURKISH: 162,
|
||||||
|
VIETNAMESE: 163,
|
||||||
|
HEBREW: 177,
|
||||||
|
ARABIC: 178,
|
||||||
|
BALTIC: 186,
|
||||||
|
RUSSIAN: 204,
|
||||||
|
THAI: 222,
|
||||||
|
EASTEUROPE: 238,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const PACK = {
|
||||||
|
jp: 'fonts-jp@1.0.0/fonts/NotoSansJP-Regular.otf',
|
||||||
|
kr: 'fonts-kr@1.0.0/fonts/NotoSansKR-Regular.otf',
|
||||||
|
sc: 'fonts-sc@1.0.0/fonts/NotoSansHans-Regular.otf',
|
||||||
|
tc: 'fonts-tc@1.0.0/fonts/NotoSansHant-Regular.otf',
|
||||||
|
arabic: 'fonts-arabic@1.0.0/fonts/NotoNaskhArabic-Regular.ttf',
|
||||||
|
hebrew: 'fonts-hebrew@1.0.0/fonts/NotoSansHebrew-Regular.ttf',
|
||||||
|
latin: 'fonts-latin@1.0.0/fonts/NotoSans-Regular.ttf',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const editorFontFallback = {
|
||||||
|
baseUrl: FONT_CDN,
|
||||||
|
fonts: {
|
||||||
|
[CHARSET.SHIFTJIS]: PACK.jp,
|
||||||
|
[CHARSET.HANGEUL]: PACK.kr,
|
||||||
|
[CHARSET.GB2312]: PACK.sc,
|
||||||
|
[CHARSET.CHINESEBIG5]: PACK.tc,
|
||||||
|
[CHARSET.ARABIC]: PACK.arabic,
|
||||||
|
[CHARSET.HEBREW]: PACK.hebrew,
|
||||||
|
[CHARSET.GREEK]: PACK.latin,
|
||||||
|
[CHARSET.TURKISH]: PACK.latin,
|
||||||
|
[CHARSET.VIETNAMESE]: PACK.latin,
|
||||||
|
[CHARSET.BALTIC]: PACK.latin,
|
||||||
|
[CHARSET.RUSSIAN]: PACK.latin,
|
||||||
|
[CHARSET.EASTEUROPE]: PACK.latin,
|
||||||
|
[CHARSET.THAI]: PACK.latin,
|
||||||
|
[CHARSET.ANSI]: PACK.latin,
|
||||||
|
[CHARSET.DEFAULT]: PACK.latin,
|
||||||
|
},
|
||||||
|
defaultFont: PACK.latin,
|
||||||
|
};
|
||||||
@@ -5,6 +5,7 @@ import { formatBytes, downloadFile } from '../utils/helpers.js';
|
|||||||
import { makeUniqueFileKey } from '../utils/deduplicate-filename.js';
|
import { makeUniqueFileKey } from '../utils/deduplicate-filename.js';
|
||||||
import { batchDecryptIfNeeded } from '../utils/password-prompt.js';
|
import { batchDecryptIfNeeded } from '../utils/password-prompt.js';
|
||||||
import { getEditorDisabledCategories } from '../utils/disabled-tools.js';
|
import { getEditorDisabledCategories } from '../utils/disabled-tools.js';
|
||||||
|
import { editorFontFallback } from '../config/editor-fonts.js';
|
||||||
|
|
||||||
const embedPdfWasmUrl = new URL(
|
const embedPdfWasmUrl = new URL(
|
||||||
'bentopdf-viewer/dist/pdfium.wasm',
|
'bentopdf-viewer/dist/pdfium.wasm',
|
||||||
@@ -12,7 +13,47 @@ const embedPdfWasmUrl = new URL(
|
|||||||
).href;
|
).href;
|
||||||
|
|
||||||
import type { EmbedPdfContainer } from 'bentopdf-viewer';
|
import type { EmbedPdfContainer } from 'bentopdf-viewer';
|
||||||
import type { DocManagerPlugin } from '@/types';
|
import type {
|
||||||
|
AnnotationPluginLite,
|
||||||
|
DocManagerPlugin,
|
||||||
|
FreeTextSystemFontAnnotation,
|
||||||
|
} from '@/types';
|
||||||
|
|
||||||
|
const FREETEXT_SUBTYPE = 3;
|
||||||
|
|
||||||
|
function collectSystemFontFreeTexts(
|
||||||
|
annotationPlugin: AnnotationPluginLite | null
|
||||||
|
): FreeTextSystemFontAnnotation[] {
|
||||||
|
if (!annotationPlugin) return [];
|
||||||
|
try {
|
||||||
|
const state = annotationPlugin.getState();
|
||||||
|
const out: FreeTextSystemFontAnnotation[] = [];
|
||||||
|
for (const tracked of Object.values(state.byUid)) {
|
||||||
|
const obj = tracked.object;
|
||||||
|
if (obj.type !== FREETEXT_SUBTYPE) continue;
|
||||||
|
if (obj.intent === 'FreeTextCallout') continue;
|
||||||
|
if (!obj.fontPostScriptName || !obj.fontPostScriptName.trim()) continue;
|
||||||
|
if (!obj.id || obj.pageIndex == null || !obj.rect) continue;
|
||||||
|
if ((obj.rotation ?? 0) !== 0) continue;
|
||||||
|
out.push({
|
||||||
|
id: obj.id,
|
||||||
|
pageIndex: obj.pageIndex,
|
||||||
|
contents: obj.contents ?? '',
|
||||||
|
fontSize: obj.fontSize ?? 12,
|
||||||
|
fontColor: obj.fontColor ?? '#000000',
|
||||||
|
textAlign: obj.textAlign ?? 0,
|
||||||
|
verticalAlign: obj.verticalAlign ?? 0,
|
||||||
|
opacity: obj.opacity ?? 1,
|
||||||
|
backgroundColor: obj.color ?? obj.backgroundColor,
|
||||||
|
rect: obj.rect,
|
||||||
|
fontPostScriptName: obj.fontPostScriptName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let viewerInstance: EmbedPdfContainer | null = null;
|
let viewerInstance: EmbedPdfContainer | null = null;
|
||||||
let docManagerPlugin: DocManagerPlugin | null = null;
|
let docManagerPlugin: DocManagerPlugin | null = null;
|
||||||
@@ -143,6 +184,7 @@ async function handleFiles(files: FileList) {
|
|||||||
target: pdfContainer,
|
target: pdfContainer,
|
||||||
worker: true,
|
worker: true,
|
||||||
wasmUrl: embedPdfWasmUrl,
|
wasmUrl: embedPdfWasmUrl,
|
||||||
|
fontFallback: editorFontFallback,
|
||||||
export: {
|
export: {
|
||||||
defaultFileName: firstFile.name,
|
defaultFileName: firstFile.name,
|
||||||
},
|
},
|
||||||
@@ -218,7 +260,31 @@ async function handleFiles(files: FileList) {
|
|||||||
try {
|
try {
|
||||||
const exportPlugin = registry.getPlugin('export').provides();
|
const exportPlugin = registry.getPlugin('export').provides();
|
||||||
const arrayBuffer = await exportPlugin.saveAsCopy().toPromise();
|
const arrayBuffer = await exportPlugin.saveAsCopy().toPromise();
|
||||||
const blob = new Blob([arrayBuffer], { type: 'application/pdf' });
|
let outBytes = new Uint8Array(arrayBuffer);
|
||||||
|
let annotationPlugin: AnnotationPluginLite | null = null;
|
||||||
|
try {
|
||||||
|
annotationPlugin = registry
|
||||||
|
.getPlugin('annotation')
|
||||||
|
.provides() as unknown as AnnotationPluginLite;
|
||||||
|
} catch {
|
||||||
|
annotationPlugin = null;
|
||||||
|
}
|
||||||
|
const customFontAnnots = collectSystemFontFreeTexts(annotationPlugin);
|
||||||
|
if (customFontAnnots.length > 0) {
|
||||||
|
try {
|
||||||
|
const { embedFreeTextSystemFonts } =
|
||||||
|
await import('../utils/freetext-font-embed.js');
|
||||||
|
outBytes = await embedFreeTextSystemFonts(
|
||||||
|
outBytes,
|
||||||
|
customFontAnnots
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Font embed pass failed:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const blob = new Blob([new Uint8Array(outBytes)], {
|
||||||
|
type: 'application/pdf',
|
||||||
|
});
|
||||||
downloadFile(blob, currentFileName);
|
downloadFile(blob, currentFileName);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error downloading PDF:', err);
|
console.error('Error downloading PDF:', err);
|
||||||
|
|||||||
@@ -13,3 +13,48 @@ export interface DocManagerPlugin {
|
|||||||
closeDocument: (id: string) => void;
|
closeDocument: (id: string) => void;
|
||||||
saveAsCopy: (id: string) => Promise<Uint8Array>;
|
saveAsCopy: (id: string) => Promise<Uint8Array>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EditorAnnotationRect {
|
||||||
|
origin: { x: number; y: number };
|
||||||
|
size: { width: number; height: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EditorAnnotationObjectLite {
|
||||||
|
id?: string;
|
||||||
|
pageIndex?: number;
|
||||||
|
type?: number;
|
||||||
|
intent?: string;
|
||||||
|
contents?: string;
|
||||||
|
fontSize?: number;
|
||||||
|
fontColor?: string;
|
||||||
|
textAlign?: number;
|
||||||
|
verticalAlign?: number;
|
||||||
|
opacity?: number;
|
||||||
|
color?: string;
|
||||||
|
backgroundColor?: string;
|
||||||
|
rect?: EditorAnnotationRect;
|
||||||
|
rotation?: number;
|
||||||
|
fontFamilyName?: string;
|
||||||
|
fontPostScriptName?: string;
|
||||||
|
fontFaceName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnnotationPluginLite {
|
||||||
|
getState: () => {
|
||||||
|
byUid: Record<string, { object: EditorAnnotationObjectLite }>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FreeTextSystemFontAnnotation {
|
||||||
|
id: string;
|
||||||
|
pageIndex: number;
|
||||||
|
contents: string;
|
||||||
|
fontSize: number;
|
||||||
|
fontColor: string;
|
||||||
|
textAlign: number;
|
||||||
|
verticalAlign: number;
|
||||||
|
opacity: number;
|
||||||
|
backgroundColor?: string;
|
||||||
|
rect: EditorAnnotationRect;
|
||||||
|
fontPostScriptName: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,445 @@
|
|||||||
|
import {
|
||||||
|
PDFDocument,
|
||||||
|
PDFDict,
|
||||||
|
PDFName,
|
||||||
|
PDFString,
|
||||||
|
PDFHexString,
|
||||||
|
PDFFont,
|
||||||
|
PDFOperator,
|
||||||
|
beginText,
|
||||||
|
endText,
|
||||||
|
moveText,
|
||||||
|
setFontAndSize,
|
||||||
|
setFillingRgbColor,
|
||||||
|
showText,
|
||||||
|
pushGraphicsState,
|
||||||
|
popGraphicsState,
|
||||||
|
rectangle,
|
||||||
|
fill,
|
||||||
|
} from 'pdf-lib';
|
||||||
|
import fontkit from '@pdf-lib/fontkit';
|
||||||
|
import type { FreeTextSystemFontAnnotation } from '@/types';
|
||||||
|
|
||||||
|
interface LocalFontQueryResult {
|
||||||
|
postscriptName: string;
|
||||||
|
blob: () => Promise<Blob>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type QueryLocalFonts = (opts?: {
|
||||||
|
postscriptNames?: string[];
|
||||||
|
}) => Promise<LocalFontQueryResult[]>;
|
||||||
|
|
||||||
|
export function extractTtcFace(
|
||||||
|
bytes: Uint8Array,
|
||||||
|
postscriptName: string
|
||||||
|
): Uint8Array {
|
||||||
|
const u16 = (o: number) => ((bytes[o] ?? 0) << 8) | (bytes[o + 1] ?? 0);
|
||||||
|
const u32 = (o: number) =>
|
||||||
|
(((bytes[o] ?? 0) << 24) |
|
||||||
|
((bytes[o + 1] ?? 0) << 16) |
|
||||||
|
((bytes[o + 2] ?? 0) << 8) |
|
||||||
|
(bytes[o + 3] ?? 0)) >>>
|
||||||
|
0;
|
||||||
|
if (bytes.length < 16 || u32(0) !== 0x74746366) return bytes;
|
||||||
|
|
||||||
|
const numFonts = u32(8);
|
||||||
|
if (numFonts === 0 || numFonts > 64) return bytes;
|
||||||
|
const want = postscriptName.replace(/[\s-]/g, '').toLowerCase();
|
||||||
|
|
||||||
|
const nameOf = (faceOff: number, nameId: number): string => {
|
||||||
|
const numTables = u16(faceOff + 4);
|
||||||
|
for (let i = 0; i < numTables; i++) {
|
||||||
|
const rec = faceOff + 12 + i * 16;
|
||||||
|
const tag = String.fromCharCode(
|
||||||
|
bytes[rec] ?? 0,
|
||||||
|
bytes[rec + 1] ?? 0,
|
||||||
|
bytes[rec + 2] ?? 0,
|
||||||
|
bytes[rec + 3] ?? 0
|
||||||
|
);
|
||||||
|
if (tag !== 'name') continue;
|
||||||
|
const nt = u32(rec + 8);
|
||||||
|
const count = u16(nt + 2);
|
||||||
|
const strBase = nt + u16(nt + 4);
|
||||||
|
for (let r = 0; r < count; r++) {
|
||||||
|
const nr = nt + 6 + r * 12;
|
||||||
|
if (u16(nr + 6) !== nameId) continue;
|
||||||
|
const platform = u16(nr);
|
||||||
|
const len = u16(nr + 8);
|
||||||
|
const off = strBase + u16(nr + 10);
|
||||||
|
if (platform === 3 || platform === 0) {
|
||||||
|
let out = '';
|
||||||
|
for (let k = 0; k + 1 < len; k += 2)
|
||||||
|
out += String.fromCharCode(u16(off + k));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
let out = '';
|
||||||
|
for (let k = 0; k < len; k++)
|
||||||
|
out += String.fromCharCode(bytes[off + k] ?? 0);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
let best = u32(12);
|
||||||
|
let bestScore = -1;
|
||||||
|
for (let f = 0; f < numFonts; f++) {
|
||||||
|
const off = u32(12 + f * 4);
|
||||||
|
if (off + 12 > bytes.length) continue;
|
||||||
|
const ps = nameOf(off, 6).replace(/[\s-]/g, '').toLowerCase();
|
||||||
|
const fam = nameOf(off, 1).replace(/[\s-]/g, '').toLowerCase();
|
||||||
|
let score = 0;
|
||||||
|
if (want && ps === want) score = 4;
|
||||||
|
else if (want && fam && (want.startsWith(fam) || fam.startsWith(want)))
|
||||||
|
score = 2;
|
||||||
|
if (score > bestScore) {
|
||||||
|
bestScore = score;
|
||||||
|
best = off;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const numTables = u16(best + 4);
|
||||||
|
if (numTables === 0 || numTables > 64) return bytes;
|
||||||
|
const records: Array<{ tag: string; sum: number; off: number; len: number }> =
|
||||||
|
[];
|
||||||
|
for (let i = 0; i < numTables; i++) {
|
||||||
|
const rec = best + 12 + i * 16;
|
||||||
|
records.push({
|
||||||
|
tag: String.fromCharCode(
|
||||||
|
bytes[rec] ?? 0,
|
||||||
|
bytes[rec + 1] ?? 0,
|
||||||
|
bytes[rec + 2] ?? 0,
|
||||||
|
bytes[rec + 3] ?? 0
|
||||||
|
),
|
||||||
|
sum: u32(rec + 4),
|
||||||
|
off: u32(rec + 8),
|
||||||
|
len: u32(rec + 12),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const headerSize = 12 + numTables * 16;
|
||||||
|
let total = headerSize;
|
||||||
|
const placed = new Map<string, number>();
|
||||||
|
for (const r of records) {
|
||||||
|
const key = r.off + ':' + r.len;
|
||||||
|
if (!placed.has(key)) {
|
||||||
|
placed.set(key, total);
|
||||||
|
total += (r.len + 3) & ~3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const out = new Uint8Array(total);
|
||||||
|
const w16 = (o: number, v: number) => {
|
||||||
|
out[o] = (v >> 8) & 0xff;
|
||||||
|
out[o + 1] = v & 0xff;
|
||||||
|
};
|
||||||
|
const w32 = (o: number, v: number) => {
|
||||||
|
out[o] = (v >>> 24) & 0xff;
|
||||||
|
out[o + 1] = (v >>> 16) & 0xff;
|
||||||
|
out[o + 2] = (v >>> 8) & 0xff;
|
||||||
|
out[o + 3] = v & 0xff;
|
||||||
|
};
|
||||||
|
w32(0, u32(best));
|
||||||
|
w16(4, numTables);
|
||||||
|
let pow2 = 1;
|
||||||
|
let entSel = 0;
|
||||||
|
while (pow2 * 2 <= numTables) {
|
||||||
|
pow2 *= 2;
|
||||||
|
entSel++;
|
||||||
|
}
|
||||||
|
w16(6, pow2 * 16);
|
||||||
|
w16(8, entSel);
|
||||||
|
w16(10, (numTables - pow2) * 16);
|
||||||
|
records.forEach((r, i) => {
|
||||||
|
const rec = 12 + i * 16;
|
||||||
|
for (let k = 0; k < 4; k++) out[rec + k] = r.tag.charCodeAt(k);
|
||||||
|
w32(rec + 4, r.sum);
|
||||||
|
const newOff = placed.get(r.off + ':' + r.len);
|
||||||
|
if (newOff === undefined) return;
|
||||||
|
w32(rec + 8, newOff);
|
||||||
|
w32(rec + 12, r.len);
|
||||||
|
out.set(bytes.subarray(r.off, r.off + r.len), newOff);
|
||||||
|
if (r.tag === 'head' && r.len >= 12) w32(newOff + 8, 0);
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchFontBytes(
|
||||||
|
postscriptName: string
|
||||||
|
): Promise<Uint8Array | null> {
|
||||||
|
const qlf = (window as unknown as { queryLocalFonts?: QueryLocalFonts })
|
||||||
|
.queryLocalFonts;
|
||||||
|
if (typeof qlf !== 'function') return null;
|
||||||
|
try {
|
||||||
|
const list = await qlf({ postscriptNames: [postscriptName] });
|
||||||
|
if (list.length === 0) return null;
|
||||||
|
const blob = await list[0].blob();
|
||||||
|
return extractTtcFace(
|
||||||
|
new Uint8Array(await blob.arrayBuffer()),
|
||||||
|
postscriptName
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function subsetPrefix(postscriptName: string): string {
|
||||||
|
let h = 0x811c9dc5;
|
||||||
|
for (const c of postscriptName) {
|
||||||
|
h = Math.imul(h ^ c.charCodeAt(0), 0x01000193);
|
||||||
|
}
|
||||||
|
let prefix = '';
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
prefix += String.fromCharCode(65 + (((h >>> (i * 4)) & 0x0f) % 26));
|
||||||
|
h = Math.imul(h, 0x01000193);
|
||||||
|
}
|
||||||
|
return prefix;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hexToRgb(hex: string): { r: number; g: number; b: number } {
|
||||||
|
const m = hex.match(/^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i);
|
||||||
|
return m
|
||||||
|
? {
|
||||||
|
r: parseInt(m[1], 16) / 255,
|
||||||
|
g: parseInt(m[2], 16) / 255,
|
||||||
|
b: parseInt(m[3], 16) / 255,
|
||||||
|
}
|
||||||
|
: { r: 0, g: 0, b: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EmbeddedFontMetrics {
|
||||||
|
ascent: number;
|
||||||
|
descent: number;
|
||||||
|
unitsPerEm: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fontMetrics(font: PDFFont): EmbeddedFontMetrics {
|
||||||
|
const carrier = font as unknown as {
|
||||||
|
embedder?: {
|
||||||
|
font?: { ascent?: number; descent?: number; unitsPerEm?: number };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const f = carrier.embedder?.font;
|
||||||
|
const unitsPerEm =
|
||||||
|
typeof f?.unitsPerEm === 'number' && f.unitsPerEm > 0 ? f.unitsPerEm : 1000;
|
||||||
|
const ascent =
|
||||||
|
typeof f?.ascent === 'number' && f.ascent > 0 ? f.ascent : unitsPerEm * 0.8;
|
||||||
|
const descent =
|
||||||
|
typeof f?.descent === 'number' && f.descent <= 0
|
||||||
|
? f.descent
|
||||||
|
: -unitsPerEm * 0.2;
|
||||||
|
return { ascent, descent, unitsPerEm };
|
||||||
|
}
|
||||||
|
|
||||||
|
function breakLongToken(
|
||||||
|
font: PDFFont,
|
||||||
|
token: string,
|
||||||
|
size: number,
|
||||||
|
maxWidth: number,
|
||||||
|
out: string[]
|
||||||
|
): string {
|
||||||
|
let cur = '';
|
||||||
|
for (const ch of token) {
|
||||||
|
const candidate = cur + ch;
|
||||||
|
if (cur !== '' && font.widthOfTextAtSize(candidate, size) > maxWidth) {
|
||||||
|
out.push(cur);
|
||||||
|
cur = ch;
|
||||||
|
} else {
|
||||||
|
cur = candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cur;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrapLine(
|
||||||
|
font: PDFFont,
|
||||||
|
text: string,
|
||||||
|
size: number,
|
||||||
|
maxWidth: number
|
||||||
|
): string[] {
|
||||||
|
if (!text) return [''];
|
||||||
|
const tokens = text.match(/\s+|\S+/g) ?? [];
|
||||||
|
const out: string[] = [];
|
||||||
|
let cur = '';
|
||||||
|
for (const token of tokens) {
|
||||||
|
if (/^\s+$/.test(token)) {
|
||||||
|
cur += token;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const candidate = cur + token;
|
||||||
|
if (font.widthOfTextAtSize(candidate.trimEnd(), size) <= maxWidth) {
|
||||||
|
cur = candidate;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (cur.trim() !== '') {
|
||||||
|
out.push(cur.trimEnd());
|
||||||
|
}
|
||||||
|
if (font.widthOfTextAtSize(token, size) <= maxWidth) {
|
||||||
|
cur = token;
|
||||||
|
} else {
|
||||||
|
cur = breakLongToken(font, token, size, maxWidth, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push(cur.trimEnd());
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findFreeTextDict(
|
||||||
|
doc: PDFDocument,
|
||||||
|
pageIndex: number,
|
||||||
|
id: string
|
||||||
|
): PDFDict | null {
|
||||||
|
if (pageIndex < 0 || pageIndex >= doc.getPageCount()) return null;
|
||||||
|
const annots = doc.getPage(pageIndex).node.Annots();
|
||||||
|
if (!annots) return null;
|
||||||
|
for (let i = 0; i < annots.size(); i++) {
|
||||||
|
const obj = annots.lookup(i);
|
||||||
|
if (!(obj instanceof PDFDict)) continue;
|
||||||
|
const subtype = obj.lookup(PDFName.of('Subtype'));
|
||||||
|
if (!(subtype instanceof PDFName) || subtype.decodeText() !== 'FreeText') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const nm = obj.lookup(PDFName.of('NM'));
|
||||||
|
const nmStr =
|
||||||
|
nm instanceof PDFString
|
||||||
|
? nm.asString()
|
||||||
|
: nm instanceof PDFHexString
|
||||||
|
? nm.decodeText()
|
||||||
|
: '';
|
||||||
|
if (nmStr === id) return obj;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bakeAppearance(
|
||||||
|
doc: PDFDocument,
|
||||||
|
dict: PDFDict,
|
||||||
|
annot: FreeTextSystemFontAnnotation,
|
||||||
|
font: PDFFont
|
||||||
|
): void {
|
||||||
|
const w = annot.rect.size.width;
|
||||||
|
const h = annot.rect.size.height;
|
||||||
|
if (!(w > 0) || !(h > 0)) return;
|
||||||
|
const size = annot.fontSize > 0 ? annot.fontSize : 12;
|
||||||
|
const lineHeight = size * 1.18;
|
||||||
|
const metrics = fontMetrics(font);
|
||||||
|
const scalePerUnit = size / metrics.unitsPerEm;
|
||||||
|
const maxWidth = Math.max(w, size);
|
||||||
|
|
||||||
|
const lines: string[] = [];
|
||||||
|
for (const paragraph of (annot.contents ?? '').split(/\r\n?|\n/)) {
|
||||||
|
lines.push(...wrapLine(font, paragraph, size, maxWidth));
|
||||||
|
}
|
||||||
|
|
||||||
|
const blockHeight = lines.length * lineHeight;
|
||||||
|
const vOffset =
|
||||||
|
annot.verticalAlign === 1
|
||||||
|
? Math.max(0, (h - blockHeight) / 2)
|
||||||
|
: annot.verticalAlign === 2
|
||||||
|
? Math.max(0, h - blockHeight)
|
||||||
|
: 0;
|
||||||
|
const contentHeight = (metrics.ascent - metrics.descent) * scalePerUnit;
|
||||||
|
const halfLeading = (lineHeight - contentHeight) / 2;
|
||||||
|
const firstBaselineFromTop =
|
||||||
|
vOffset + halfLeading + metrics.ascent * scalePerUnit;
|
||||||
|
|
||||||
|
const color = hexToRgb(annot.fontColor || '#000000');
|
||||||
|
const bg =
|
||||||
|
annot.backgroundColor &&
|
||||||
|
/^#?[a-f\d]{6}$/i.test(annot.backgroundColor.trim())
|
||||||
|
? hexToRgb(annot.backgroundColor.trim())
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const ops: PDFOperator[] = [pushGraphicsState()];
|
||||||
|
if (bg) {
|
||||||
|
ops.push(
|
||||||
|
setFillingRgbColor(bg.r, bg.g, bg.b),
|
||||||
|
rectangle(0, 0, w, h),
|
||||||
|
fill()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ops.push(
|
||||||
|
beginText(),
|
||||||
|
setFontAndSize('F0', size),
|
||||||
|
setFillingRgbColor(color.r, color.g, color.b)
|
||||||
|
);
|
||||||
|
let prevX = 0;
|
||||||
|
let prevY = 0;
|
||||||
|
lines.forEach((line, i) => {
|
||||||
|
if (!line) return;
|
||||||
|
const lineWidth = font.widthOfTextAtSize(line, size);
|
||||||
|
const x =
|
||||||
|
annot.textAlign === 1
|
||||||
|
? Math.max(0, (w - lineWidth) / 2)
|
||||||
|
: annot.textAlign === 2
|
||||||
|
? Math.max(0, w - lineWidth)
|
||||||
|
: 0;
|
||||||
|
const y = h - (firstBaselineFromTop + i * lineHeight);
|
||||||
|
ops.push(moveText(x - prevX, y - prevY), showText(font.encodeText(line)));
|
||||||
|
prevX = x;
|
||||||
|
prevY = y;
|
||||||
|
});
|
||||||
|
ops.push(endText(), popGraphicsState());
|
||||||
|
|
||||||
|
const xobj = doc.context.formXObject(ops, {
|
||||||
|
BBox: [0, 0, w, h],
|
||||||
|
Resources: { Font: { F0: font.ref } },
|
||||||
|
});
|
||||||
|
const ref = doc.context.register(xobj);
|
||||||
|
dict.set(PDFName.of('AP'), doc.context.obj({ N: ref }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function embedFreeTextSystemFonts(
|
||||||
|
bytes: Uint8Array,
|
||||||
|
annotations: FreeTextSystemFontAnnotation[]
|
||||||
|
): Promise<Uint8Array> {
|
||||||
|
const candidates = annotations.filter(
|
||||||
|
(a) => a.fontPostScriptName.trim() !== ''
|
||||||
|
);
|
||||||
|
if (candidates.length === 0) return bytes;
|
||||||
|
|
||||||
|
const doc = await PDFDocument.load(bytes, {
|
||||||
|
ignoreEncryption: true,
|
||||||
|
updateMetadata: false,
|
||||||
|
});
|
||||||
|
doc.registerFontkit(fontkit);
|
||||||
|
|
||||||
|
const fontCache = new Map<string, PDFFont | null>();
|
||||||
|
const resolveFont = async (
|
||||||
|
postscriptName: string
|
||||||
|
): Promise<PDFFont | null> => {
|
||||||
|
const cached = fontCache.get(postscriptName);
|
||||||
|
if (cached !== undefined) return cached;
|
||||||
|
const fontBytes = await fetchFontBytes(postscriptName);
|
||||||
|
if (!fontBytes) {
|
||||||
|
fontCache.set(postscriptName, null);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const font = await doc.embedFont(fontBytes, {
|
||||||
|
subset: true,
|
||||||
|
customName: `${subsetPrefix(postscriptName)}+${postscriptName}`,
|
||||||
|
});
|
||||||
|
fontCache.set(postscriptName, font);
|
||||||
|
return font;
|
||||||
|
} catch {
|
||||||
|
fontCache.set(postscriptName, null);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let touched = false;
|
||||||
|
for (const annot of candidates) {
|
||||||
|
try {
|
||||||
|
const font = await resolveFont(annot.fontPostScriptName);
|
||||||
|
if (!font) continue;
|
||||||
|
const dict = findFreeTextDict(doc, annot.pageIndex, annot.id);
|
||||||
|
if (!dict) continue;
|
||||||
|
bakeAppearance(doc, dict, annot, font);
|
||||||
|
touched = true;
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!touched) return bytes;
|
||||||
|
return doc.save();
|
||||||
|
}
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
2.9.1
|
66b57252
|
||||||
|
|||||||
Binary file not shown.
Reference in New Issue
Block a user