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
|
||||
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
|
||||
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"
|
||||
|
||||
- name: Should update?
|
||||
@@ -53,13 +53,13 @@ jobs:
|
||||
|
||||
- name: Clone our viewer fork
|
||||
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
|
||||
if: steps.gate.outputs.run == 'true'
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Install upstream deps
|
||||
@@ -127,7 +127,7 @@ jobs:
|
||||
|
||||
- name: Cleanup upstream clone
|
||||
if: steps.gate.outputs.run == 'true'
|
||||
run: rm -rf ./_upstream
|
||||
run: rm -rf ./_fork
|
||||
|
||||
- name: Create Pull Request
|
||||
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 }}"
|
||||
title: "build(deps): bump bentopdf-viewer from ${{ steps.current-version.outputs.version }} to ${{ steps.upstream-version.outputs.version }}"
|
||||
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
|
||||
- Refresh package-lock.json
|
||||
branch: chore/update-bentopdf-viewer
|
||||
|
||||
Generated
+1
-1
@@ -4272,7 +4272,7 @@
|
||||
"node_modules/bentopdf-viewer": {
|
||||
"version": "2.9.1",
|
||||
"resolved": "file:vendor/bentopdf-viewer/bentopdf-viewer-2.9.1.tgz",
|
||||
"integrity": "sha512-sfzgEpggMof2srTALARXtDJ0ZCzyEIClPsl8J0O4oHBWfuuT0LWvDMWAHaWuBXXIHa307RfboeK3ORy1cJO9Hw==",
|
||||
"integrity": "sha512-WCKztFUzEuWpqvLu8UeXN1aEWgUUNHmV0K+d1PPS9DwCXEoc0d8K0qO59fMHxim3Xr2jaD7i8gECW6iiEcmBBA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"preact": "^10.17.0",
|
||||
|
||||
+1
-1
@@ -90,12 +90,12 @@
|
||||
"@types/node-forge": "^1.3.14",
|
||||
"@types/papaparse": "^5.5.2",
|
||||
"archiver": "^7.0.1",
|
||||
"bentopdf-viewer": "file:vendor/bentopdf-viewer/bentopdf-viewer-2.9.1.tgz",
|
||||
"blob-stream": "^0.1.3",
|
||||
"bwip-js": "^4.8.0",
|
||||
"cropperjs": "^1.6.2",
|
||||
"diff": "^8.0.3",
|
||||
"dompurify": "^3.4.12",
|
||||
"bentopdf-viewer": "file:vendor/bentopdf-viewer/bentopdf-viewer-2.9.1.tgz",
|
||||
"fast-xml-parser": "^5.7.1",
|
||||
"heic2any": "^0.0.4",
|
||||
"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 { batchDecryptIfNeeded } from '../utils/password-prompt.js';
|
||||
import { getEditorDisabledCategories } from '../utils/disabled-tools.js';
|
||||
import { editorFontFallback } from '../config/editor-fonts.js';
|
||||
|
||||
const embedPdfWasmUrl = new URL(
|
||||
'bentopdf-viewer/dist/pdfium.wasm',
|
||||
@@ -12,7 +13,47 @@ const embedPdfWasmUrl = new URL(
|
||||
).href;
|
||||
|
||||
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 docManagerPlugin: DocManagerPlugin | null = null;
|
||||
@@ -143,6 +184,7 @@ async function handleFiles(files: FileList) {
|
||||
target: pdfContainer,
|
||||
worker: true,
|
||||
wasmUrl: embedPdfWasmUrl,
|
||||
fontFallback: editorFontFallback,
|
||||
export: {
|
||||
defaultFileName: firstFile.name,
|
||||
},
|
||||
@@ -218,7 +260,31 @@ async function handleFiles(files: FileList) {
|
||||
try {
|
||||
const exportPlugin = registry.getPlugin('export').provides();
|
||||
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);
|
||||
} catch (err) {
|
||||
console.error('Error downloading PDF:', err);
|
||||
|
||||
@@ -13,3 +13,48 @@ export interface DocManagerPlugin {
|
||||
closeDocument: (id: string) => void;
|
||||
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