diff --git a/fixtures/fonts/ttf/DejaVuSans.ttf b/fixtures/fonts/ttf/DejaVuSans.ttf new file mode 100644 index 0000000..9d40c32 Binary files /dev/null and b/fixtures/fonts/ttf/DejaVuSans.ttf differ diff --git a/src/api/pdf-fonts.ts b/src/api/pdf-fonts.ts index 7bd9457..64d2af0 100644 --- a/src/api/pdf-fonts.ts +++ b/src/api/pdf-fonts.ts @@ -84,6 +84,7 @@ export class PDFFonts { const ref = this.ctx.registry.allocateRef(); this.embeddedFonts.set(font, ref); + font.setRef(ref); return font; } diff --git a/src/api/pdf-form.ts b/src/api/pdf-form.ts index 700fa6f..0f02c39 100644 --- a/src/api/pdf-form.ts +++ b/src/api/pdf-form.ts @@ -1148,21 +1148,7 @@ export class PDFForm { * Register an embedded font in the form's default resources. */ private registerFontInFormResources(font: EmbeddedFont): void { - // Prepare the font if not already done - const ctx = this._ctx; - - // Get or create font reference from PDFFonts - const fontRef = ctx.registry.register( - PdfDict.of({ - Type: PdfName.of("Font"), - Subtype: PdfName.of("Type0"), - BaseFont: PdfName.of(font.baseFontName), - Encoding: PdfName.of("Identity-H"), - }), - ); - - // Add to AcroForm resources - this._acroForm.addFontToResources(fontRef); + this._acroForm.addFontToResources(font.ref); } /** diff --git a/src/document/forms/button-appearance.ts b/src/document/forms/button-appearance.ts index 51d5d25..bae3834 100644 --- a/src/document/forms/button-appearance.ts +++ b/src/document/forms/button-appearance.ts @@ -416,8 +416,7 @@ function buildResources(ctx: ButtonAppearanceContext, font: FormFont, fontName: const cleanName = fontName.startsWith("/") ? fontName.slice(1) : fontName; if (isEmbeddedFont(font)) { - const fontRef = ctx.registry.register(buildEmbeddedFontDict(font)); - fonts.set(cleanName, fontRef); + fonts.set(cleanName, font.ref); } else if (isExistingFont(font) && font.ref) { fonts.set(cleanName, font.ref); } else { @@ -434,14 +433,3 @@ function buildResources(ctx: ButtonAppearanceContext, font: FormFont, fontName: return resources; } - -function buildEmbeddedFontDict(font: EmbeddedFont): PdfDict { - const dict = new PdfDict(); - - dict.set("Type", PdfName.of("Font")); - dict.set("Subtype", PdfName.of("Type0")); - dict.set("BaseFont", PdfName.of(font.baseFontName)); - dict.set("Encoding", PdfName.of("Identity-H")); - - return dict; -} diff --git a/src/document/forms/choice-appearance.ts b/src/document/forms/choice-appearance.ts index 2988209..6c339f0 100644 --- a/src/document/forms/choice-appearance.ts +++ b/src/document/forms/choice-appearance.ts @@ -392,8 +392,7 @@ function buildResources(ctx: ChoiceAppearanceContext, font: FormFont, fontName: const cleanName = fontName.startsWith("/") ? fontName.slice(1) : fontName; if (isEmbeddedFont(font)) { - const fontRef = ctx.registry.register(buildEmbeddedFontDict(font)); - fonts.set(cleanName, fontRef); + fonts.set(cleanName, font.ref); } else if (isExistingFont(font) && font.ref) { fonts.set(cleanName, font.ref); } else { @@ -410,14 +409,3 @@ function buildResources(ctx: ChoiceAppearanceContext, font: FormFont, fontName: return resources; } - -function buildEmbeddedFontDict(font: EmbeddedFont): PdfDict { - const dict = new PdfDict(); - - dict.set("Type", PdfName.of("Font")); - dict.set("Subtype", PdfName.of("Type0")); - dict.set("BaseFont", PdfName.of(font.baseFontName)); - dict.set("Encoding", PdfName.of("Identity-H")); - - return dict; -} diff --git a/src/document/forms/text-appearance.ts b/src/document/forms/text-appearance.ts index f4fc3e1..905c3e4 100644 --- a/src/document/forms/text-appearance.ts +++ b/src/document/forms/text-appearance.ts @@ -685,8 +685,7 @@ function buildResources(ctx: TextAppearanceContext, font: FormFont, fontName: st const cleanName = fontName.startsWith("/") ? fontName.slice(1) : fontName; if (isEmbeddedFont(font)) { - const fontRef = ctx.registry.register(buildEmbeddedFontDict(font)); - fonts.set(cleanName, fontRef); + fonts.set(cleanName, font.ref); } else if (isExistingFont(font) && font.ref) { fonts.set(cleanName, font.ref); } else { @@ -704,17 +703,6 @@ function buildResources(ctx: TextAppearanceContext, font: FormFont, fontName: st return resources; } -function buildEmbeddedFontDict(font: EmbeddedFont): PdfDict { - const dict = new PdfDict(); - - dict.set("Type", PdfName.of("Font")); - dict.set("Subtype", PdfName.of("Type0")); - dict.set("BaseFont", PdfName.of(font.baseFontName)); - dict.set("Encoding", PdfName.of("Identity-H")); - - return dict; -} - function calculateAppearanceMatrix( width: number, height: number, diff --git a/src/fonts/embedded-font.ts b/src/fonts/embedded-font.ts index 416eab0..104d123 100644 --- a/src/fonts/embedded-font.ts +++ b/src/fonts/embedded-font.ts @@ -9,6 +9,7 @@ */ import type { TrueTypeFont } from "#src/fontbox/ttf/truetype-font.ts"; +import type { PdfRef } from "#src/objects/pdf-ref.ts"; import { parseFontProgram } from "./embedded-parser.ts"; import { FontDescriptor } from "./font-descriptor.ts"; @@ -69,6 +70,9 @@ export class EmbeddedFont extends PdfFont { /** Whether this font is used in a form field (prevents subsetting) */ private _usedInForm = false; + /** Pre-allocated PDF reference (set by PDFFonts.embed()) */ + private _ref: PdfRef | null = null; + /** Cached descriptor */ private _descriptor: FontDescriptor | null = null; @@ -333,6 +337,32 @@ export class EmbeddedFont extends PdfFont { this._subsetTag = null; } + /** + * Get the pre-allocated PDF reference for this font. + * + * Set by `PDFFonts.embed()`. At save time, the actual font objects + * (Type0 dict, CIDFont, FontDescriptor, font program, ToUnicode) + * are created and registered at this ref. + * + * @throws {Error} if the font was not embedded via `pdf.embedFont()` + */ + get ref(): PdfRef { + if (!this._ref) { + throw new Error("Font has no PDF reference. Use pdf.embedFont() to embed fonts."); + } + + return this._ref; + } + + /** + * Set the pre-allocated PDF reference. + * + * @internal Called by PDFFonts.embed() + */ + setRef(ref: PdfRef): void { + this._ref = ref; + } + /** * Mark this font as used in a form field. * diff --git a/src/tests/issues/issue-18-flatten-embedded-fonts.test.ts b/src/tests/issues/issue-18-flatten-embedded-fonts.test.ts new file mode 100644 index 0000000..cf85a13 --- /dev/null +++ b/src/tests/issues/issue-18-flatten-embedded-fonts.test.ts @@ -0,0 +1,108 @@ +/** + * Regression test for issue #18: + * "Form flatten with embedded fonts produces empty/incorrect output" + * + * The bug: appearance streams referenced incomplete stub font dicts + * (missing DescendantFonts, ToUnicode, FontDescriptor, font data). + * After flattening, viewers couldn't render the glyph IDs. + * + * The fix: EmbeddedFont stores its pre-allocated PdfRef (set by + * PDFFonts.embed), so appearance streams reference the complete + * font object that gets built at save time. + * + * @see https://github.com/LibPDF-js/core/issues/18 + */ + +import { PDF } from "#src/api/pdf"; +import { PdfDict } from "#src/objects/pdf-dict"; +import { PdfName } from "#src/objects/pdf-name"; +import { PdfRef } from "#src/objects/pdf-ref"; +import { PdfStream } from "#src/objects/pdf-stream"; +import { loadFixture, saveTestOutput } from "#src/test-utils"; +import { describe, expect, it } from "vitest"; + +describe("Issue #18: Flatten with embedded fonts", () => { + it("produces complete font objects after flatten + save", async () => { + // Set up: embed DejaVu Sans, fill fields with diacritics, flatten, save + const pdf = await PDF.load(await loadFixture("forms", "sample_form.pdf")); + const font = pdf.embedFont(await loadFixture("fonts", "ttf/DejaVuSans.ttf")); + const form = pdf.getForm()!; + + const acroForm = form.acroForm(); + acroForm.setDefaultFont(font); + acroForm.setDefaultFontSize(12); + + const textFields = form.getTextFields().filter(f => !f.isReadOnly()); + textFields[0]?.setValue("Ján Novák"); + textFields[1]?.setValue("Žilina"); + textFields[2]?.setValue("čšťňľ áéíóú"); + + // Save control (no flatten) for visual comparison + const controlPath = await saveTestOutput("issues/issue-18-no-flatten.pdf", await pdf.save()); + console.log(` -> Control (no flatten): ${controlPath}`); + + // Flatten and save + form.flatten(); + const savedBytes = await pdf.save(); + const flattenedPath = await saveTestOutput("issues/issue-18-flattened.pdf", savedBytes); + console.log(` -> Flattened: ${flattenedPath}`); + + // Reload and find Type0 fonts in the flattened XObjects + const reloaded = await PDF.load(savedBytes); + const resolve = (ref: PdfRef) => reloaded.getObject(ref); + const page = reloaded.getPage(0)!; + const xobjects = page.getResources().getDict("XObject", resolve); + expect(xobjects).toBeDefined(); + + const type0Fonts = findType0Fonts(xobjects!, resolve); + expect(type0Fonts.length).toBeGreaterThan(0); + + // Each Type0 font must have DescendantFonts and ToUnicode — + // these were missing in the bug (stub dict only had Type/Subtype/BaseFont/Encoding) + for (const fontDict of type0Fonts) { + expect(fontDict.get("DescendantFonts", resolve)).toBeDefined(); + expect(fontDict.get("ToUnicode", resolve)).toBeDefined(); + } + }); +}); + +type Resolve = (ref: PdfRef) => ReturnType; + +/** + * Walk FlatField XObjects and collect their Type0 font dicts. + */ +function findType0Fonts(xobjects: PdfDict, resolve: Resolve): PdfDict[] { + const results: PdfDict[] = []; + + for (const [key, value] of xobjects) { + if (!key.value.startsWith("FlatField")) { + continue; + } + + const xobj = value instanceof PdfRef ? resolve(value) : value; + + if (!(xobj instanceof PdfStream)) { + continue; + } + + const fonts = xobj.getDict("Resources", resolve)?.getDict("Font", resolve); + + if (!fonts) { + continue; + } + + for (const [, fontValue] of fonts) { + const fontObj = fontValue instanceof PdfRef ? resolve(fontValue) : fontValue; + + if (!(fontObj instanceof PdfDict)) { + continue; + } + + if (fontObj.getName("Subtype", resolve)?.value === "Type0") { + results.push(fontObj); + } + } + } + + return results; +}