feat: update BentoPDF viewer to version 2.9.1 and add font embedding support

This commit is contained in:
alam00000
2026-08-17 13:42:56 +05:30
parent 1d2cd3f805
commit 78cb827ec7
9 changed files with 620 additions and 11 deletions
+6 -6
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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",
+53
View File
@@ -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,
};
+68 -2
View File
@@ -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);
+45
View File
@@ -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;
}
+445
View File
@@ -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
View File
@@ -1 +1 @@
2.9.1
66b57252
Binary file not shown.