Files
bentopdf/src/js/utils/freetext-flatten.ts
T
alam00000 356128c60d feat: burn FreeText annotations into the page content stream
Exported annotations stayed as live FreeText objects, so viewers that
ignore appearance streams showed nothing and the text was not part of
the page. Flattening draws them into the content stream with an
embedded subset of the font, falling back to the old embed path for
anything the flattener cannot place.
2026-08-21 12:49:25 +05:30

307 lines
8.4 KiB
TypeScript

import type { FreeTextSystemFontAnnotation } from '@/types';
import { needsFontEmbedding } from './freetext-script.js';
export const FPDF_ANNOT_FREETEXT = 3;
export function hexToRgba(hex: string): number {
const m = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex ?? '');
if (!m) return 0x000000ff;
const r = parseInt(m[1], 16);
const g = parseInt(m[2], 16);
const b = parseInt(m[3], 16);
return ((r << 24) | (g << 16) | (b << 8) | 0xff) >>> 0;
}
export type FlattenInput = FreeTextSystemFontAnnotation;
interface EngineModule {
_malloc: (n: number) => number;
_free: (p: number) => void;
HEAPF32: Float32Array;
HEAPU16: Uint16Array;
_FPDFPage_GetAnnotCount: (page: number) => number;
_FPDFPage_GetAnnot: (page: number, i: number) => number;
_FPDFPage_RemoveAnnot: (page: number, i: number) => number;
_FPDFAnnot_GetSubtype: (annot: number) => number;
_FPDFAnnot_GetRect: (annot: number, out: number) => number;
_FPDFAnnot_GetStringValue: (
annot: number,
key: number,
buf: number,
len: number
) => number;
stringToUTF8: (s: string, p: number, n: number) => void;
lengthBytesUTF8: (s: string) => number;
}
interface EngineLike {
M: EngineModule;
page: number;
pageCount: number;
open: (b: Uint8Array) => void;
loadPage: (i: number) => void;
addParagraph: (
x: number,
yTop: number,
width: number,
runs: unknown[],
fmt: unknown
) => unknown;
saveSpliced: (opts?: unknown) => Promise<Uint8Array | null>;
}
function readAnnotString(M: EngineModule, annot: number, key: string): string {
const kn = M.lengthBytesUTF8(key) + 1;
const kp = M._malloc(kn);
M.stringToUTF8(key, kp, kn);
const need = M._FPDFAnnot_GetStringValue(annot, kp, 0, 0);
if (need <= 2) {
M._free(kp);
return '';
}
const bp = M._malloc(need);
M._FPDFAnnot_GetStringValue(annot, kp, bp, need);
let out = '';
for (let i = 0; i + 1 < need; i += 2) {
const c = M.HEAPU16[(bp + i) >> 1];
if (!c) break;
out += String.fromCharCode(c);
}
M._free(bp);
M._free(kp);
return out;
}
interface LocalFontRecord {
family: string;
style: string;
postscriptName: string;
blob: () => Promise<Blob>;
}
async function primeScriptFonts(
mod: {
PdfEngine: { localFonts: Map<string, Uint8Array> };
scriptFallbackFamilies: (cps: number[]) => string[];
},
texts: string[]
): Promise<void> {
const qlf = (
globalThis as unknown as {
queryLocalFonts?: () => Promise<LocalFontRecord[]>;
}
).queryLocalFonts;
if (typeof qlf !== 'function') return;
const cps = new Set<number>();
for (const t of texts) {
for (const ch of t) {
if (!needsFontEmbedding(ch)) continue;
const cp = ch.codePointAt(0);
if (cp) cps.add(cp);
}
}
if (cps.size === 0) return;
let wanted: string[];
try {
wanted = mod.scriptFallbackFamilies([...cps]);
} catch (err) {
console.warn('flatten: script fallback lookup failed', err);
return;
}
if (wanted.length === 0) return;
let list: LocalFontRecord[];
try {
list = await qlf();
} catch (err) {
console.warn('flatten: local font access denied', err);
return;
}
const byFamily = new Map<string, LocalFontRecord>();
for (const f of list) {
const key = f.family.toLowerCase();
const cur = byFamily.get(key);
const plain = /^(regular|book|normal)$/i.test(f.style ?? '');
if (!cur || plain) byFamily.set(key, f);
}
const MAX_FACE_BYTES = 40 << 20;
for (const family of wanted) {
if (mod.PdfEngine.localFonts.has(family)) continue;
const rec = byFamily.get(family.toLowerCase());
if (!rec) continue;
try {
const blob = await rec.blob();
if (blob.size === 0 || blob.size > MAX_FACE_BYTES) continue;
const bytes = new Uint8Array(await blob.arrayBuffer());
mod.PdfEngine.localFonts.set(family, bytes);
mod.PdfEngine.localFonts.set(family + '|00', bytes);
} catch (err) {
console.warn('flatten: font unavailable', family, err);
}
}
}
function buildRuns(
contents: string,
style: FreeTextSystemFontAnnotation | undefined
) {
const size = style && style.fontSize > 0 ? style.fontSize : 12;
return [
{
text: contents,
family: 'Helvetica',
size,
rgba: hexToRgba(style?.fontColor ?? '#000000'),
bold: false,
italic: false,
underline: false,
strike: false,
script: 0,
renderMode: 0,
strokeRgba: 0,
strokeWidth: 1,
hScale: 1,
rise: 0,
sourceIndex: -1,
},
];
}
const FLAT_FMT = {
align: 0,
lineSpacing: 1.18,
charSpacing: 0,
paraSpacing: 0,
wordSpacing: 0,
firstIndent: 0,
hangIndent: 0,
dir: 0,
listLevel: 0,
};
export async function flattenFreeTextToPageText(
bytes: Uint8Array,
annotations: FreeTextSystemFontAnnotation[],
pageCountHint = 0
): Promise<{ bytes: Uint8Array; flattened: number }> {
if (annotations.length === 0 && pageCountHint <= 0) {
return { bytes, flattened: 0 };
}
const mod = (await import('../editcore/core.js')) as unknown as {
PdfEngine: {
create: () => Promise<EngineLike>;
localFonts: Map<string, Uint8Array>;
};
scriptFallbackFamilies: (cps: number[]) => string[];
};
const eng = await mod.PdfEngine.create();
try {
eng.open(bytes);
} catch (err) {
console.error('flatten: could not open document', err);
return { bytes, flattened: 0 };
}
const styleById = new Map<string, FreeTextSystemFontAnnotation>();
const pages = new Set<number>();
for (const a of annotations) {
styleById.set(a.id, a);
pages.add(a.pageIndex);
}
for (let i = 0; i < eng.pageCount; i++) pages.add(i);
const M = eng.M;
const rectPtr = M._malloc(16);
let flattened = 0;
const found: string[] = [];
for (const pageIndex of [...pages].sort((x, y) => x - y)) {
if (pageIndex < 0 || pageIndex >= eng.pageCount) continue;
try {
eng.loadPage(pageIndex);
} catch (err) {
console.warn('flatten: could not scan page', pageIndex, err);
continue;
}
const scanPage = eng.page;
for (let i = M._FPDFPage_GetAnnotCount(scanPage) - 1; i >= 0; i--) {
const annot = M._FPDFPage_GetAnnot(scanPage, i);
if (!annot) continue;
if (M._FPDFAnnot_GetSubtype(annot) !== FPDF_ANNOT_FREETEXT) continue;
const style = styleById.get(readAnnotString(M, annot, 'NM'));
const text = style?.contents?.trim()
? style.contents
: readAnnotString(M, annot, 'Contents');
if (text.trim()) found.push(text);
}
}
if (found.length === 0) {
M._free(rectPtr);
return { bytes, flattened: 0 };
}
await primeScriptFonts(mod, found);
for (const pageIndex of [...pages].sort((x, y) => x - y)) {
if (pageIndex < 0 || pageIndex >= eng.pageCount) continue;
try {
eng.loadPage(pageIndex);
} catch (err) {
console.warn('flatten: could not load page', pageIndex, err);
continue;
}
const page = eng.page;
for (let i = M._FPDFPage_GetAnnotCount(page) - 1; i >= 0; i--) {
const annot = M._FPDFPage_GetAnnot(page, i);
if (!annot) continue;
if (M._FPDFAnnot_GetSubtype(annot) !== FPDF_ANNOT_FREETEXT) continue;
const name = readAnnotString(M, annot, 'NM');
const style = styleById.get(name);
const contents = style?.contents?.trim()
? style.contents
: readAnnotString(M, annot, 'Contents');
if (!contents.trim()) continue;
if (!M._FPDFAnnot_GetRect(annot, rectPtr)) continue;
const left = M.HEAPF32[rectPtr >> 2];
const bottom = M.HEAPF32[(rectPtr + 4) >> 2];
const right = M.HEAPF32[(rectPtr + 8) >> 2];
const top = M.HEAPF32[(rectPtr + 12) >> 2];
const width = Math.abs(right - left);
if (!(width > 1)) continue;
let created: unknown;
try {
created = eng.addParagraph(
Math.min(left, right),
Math.max(bottom, top),
width,
buildRuns(contents, style),
{ ...FLAT_FMT, align: (style?.textAlign ?? 0) | 0 }
);
} catch (err) {
console.warn('flatten: addParagraph failed', name, err);
created = null;
}
if (!created) continue;
M._FPDFPage_RemoveAnnot(page, i);
flattened++;
}
}
M._free(rectPtr);
if (flattened === 0) return { bytes, flattened: 0 };
try {
const out = await eng.saveSpliced();
if (out && out.length > 0) return { bytes: out, flattened };
} catch (err) {
console.error('flatten: save failed', err);
}
return { bytes, flattened: 0 };
}