Skip to content

Commit d27b372

Browse files
authored
fix(forms): use real font refs in appearance streams (#18) (#21)
Appearance stream generators were creating incomplete stub font dictionaries (Type/Subtype/BaseFont/Encoding only) instead of referencing the complete Type0 font objects built at save time. After flattening, PDF viewers couldn't render the glyph IDs because DescendantFonts, ToUnicode, and FontDescriptor were missing. EmbeddedFont now stores its pre-allocated PdfRef (set by PDFFonts.embed), and appearance builders reference it directly instead of creating stubs.
1 parent 7e67601 commit d27b372

File tree

8 files changed

+143
-54
lines changed

8 files changed

+143
-54
lines changed

fixtures/fonts/ttf/DejaVuSans.ttf

724 KB
Binary file not shown.

src/api/pdf-fonts.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ export class PDFFonts {
8484
const ref = this.ctx.registry.allocateRef();
8585

8686
this.embeddedFonts.set(font, ref);
87+
font.setRef(ref);
8788

8889
return font;
8990
}

src/api/pdf-form.ts

Lines changed: 1 addition & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1148,21 +1148,7 @@ export class PDFForm {
11481148
* Register an embedded font in the form's default resources.
11491149
*/
11501150
private registerFontInFormResources(font: EmbeddedFont): void {
1151-
// Prepare the font if not already done
1152-
const ctx = this._ctx;
1153-
1154-
// Get or create font reference from PDFFonts
1155-
const fontRef = ctx.registry.register(
1156-
PdfDict.of({
1157-
Type: PdfName.of("Font"),
1158-
Subtype: PdfName.of("Type0"),
1159-
BaseFont: PdfName.of(font.baseFontName),
1160-
Encoding: PdfName.of("Identity-H"),
1161-
}),
1162-
);
1163-
1164-
// Add to AcroForm resources
1165-
this._acroForm.addFontToResources(fontRef);
1151+
this._acroForm.addFontToResources(font.ref);
11661152
}
11671153

11681154
/**

src/document/forms/button-appearance.ts

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -416,8 +416,7 @@ function buildResources(ctx: ButtonAppearanceContext, font: FormFont, fontName:
416416
const cleanName = fontName.startsWith("/") ? fontName.slice(1) : fontName;
417417

418418
if (isEmbeddedFont(font)) {
419-
const fontRef = ctx.registry.register(buildEmbeddedFontDict(font));
420-
fonts.set(cleanName, fontRef);
419+
fonts.set(cleanName, font.ref);
421420
} else if (isExistingFont(font) && font.ref) {
422421
fonts.set(cleanName, font.ref);
423422
} else {
@@ -434,14 +433,3 @@ function buildResources(ctx: ButtonAppearanceContext, font: FormFont, fontName:
434433

435434
return resources;
436435
}
437-
438-
function buildEmbeddedFontDict(font: EmbeddedFont): PdfDict {
439-
const dict = new PdfDict();
440-
441-
dict.set("Type", PdfName.of("Font"));
442-
dict.set("Subtype", PdfName.of("Type0"));
443-
dict.set("BaseFont", PdfName.of(font.baseFontName));
444-
dict.set("Encoding", PdfName.of("Identity-H"));
445-
446-
return dict;
447-
}

src/document/forms/choice-appearance.ts

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -392,8 +392,7 @@ function buildResources(ctx: ChoiceAppearanceContext, font: FormFont, fontName:
392392
const cleanName = fontName.startsWith("/") ? fontName.slice(1) : fontName;
393393

394394
if (isEmbeddedFont(font)) {
395-
const fontRef = ctx.registry.register(buildEmbeddedFontDict(font));
396-
fonts.set(cleanName, fontRef);
395+
fonts.set(cleanName, font.ref);
397396
} else if (isExistingFont(font) && font.ref) {
398397
fonts.set(cleanName, font.ref);
399398
} else {
@@ -410,14 +409,3 @@ function buildResources(ctx: ChoiceAppearanceContext, font: FormFont, fontName:
410409

411410
return resources;
412411
}
413-
414-
function buildEmbeddedFontDict(font: EmbeddedFont): PdfDict {
415-
const dict = new PdfDict();
416-
417-
dict.set("Type", PdfName.of("Font"));
418-
dict.set("Subtype", PdfName.of("Type0"));
419-
dict.set("BaseFont", PdfName.of(font.baseFontName));
420-
dict.set("Encoding", PdfName.of("Identity-H"));
421-
422-
return dict;
423-
}

src/document/forms/text-appearance.ts

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -685,8 +685,7 @@ function buildResources(ctx: TextAppearanceContext, font: FormFont, fontName: st
685685
const cleanName = fontName.startsWith("/") ? fontName.slice(1) : fontName;
686686

687687
if (isEmbeddedFont(font)) {
688-
const fontRef = ctx.registry.register(buildEmbeddedFontDict(font));
689-
fonts.set(cleanName, fontRef);
688+
fonts.set(cleanName, font.ref);
690689
} else if (isExistingFont(font) && font.ref) {
691690
fonts.set(cleanName, font.ref);
692691
} else {
@@ -704,17 +703,6 @@ function buildResources(ctx: TextAppearanceContext, font: FormFont, fontName: st
704703
return resources;
705704
}
706705

707-
function buildEmbeddedFontDict(font: EmbeddedFont): PdfDict {
708-
const dict = new PdfDict();
709-
710-
dict.set("Type", PdfName.of("Font"));
711-
dict.set("Subtype", PdfName.of("Type0"));
712-
dict.set("BaseFont", PdfName.of(font.baseFontName));
713-
dict.set("Encoding", PdfName.of("Identity-H"));
714-
715-
return dict;
716-
}
717-
718706
function calculateAppearanceMatrix(
719707
width: number,
720708
height: number,

src/fonts/embedded-font.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
*/
1010

1111
import type { TrueTypeFont } from "#src/fontbox/ttf/truetype-font.ts";
12+
import type { PdfRef } from "#src/objects/pdf-ref.ts";
1213

1314
import { parseFontProgram } from "./embedded-parser.ts";
1415
import { FontDescriptor } from "./font-descriptor.ts";
@@ -69,6 +70,9 @@ export class EmbeddedFont extends PdfFont {
6970
/** Whether this font is used in a form field (prevents subsetting) */
7071
private _usedInForm = false;
7172

73+
/** Pre-allocated PDF reference (set by PDFFonts.embed()) */
74+
private _ref: PdfRef | null = null;
75+
7276
/** Cached descriptor */
7377
private _descriptor: FontDescriptor | null = null;
7478

@@ -333,6 +337,32 @@ export class EmbeddedFont extends PdfFont {
333337
this._subsetTag = null;
334338
}
335339

340+
/**
341+
* Get the pre-allocated PDF reference for this font.
342+
*
343+
* Set by `PDFFonts.embed()`. At save time, the actual font objects
344+
* (Type0 dict, CIDFont, FontDescriptor, font program, ToUnicode)
345+
* are created and registered at this ref.
346+
*
347+
* @throws {Error} if the font was not embedded via `pdf.embedFont()`
348+
*/
349+
get ref(): PdfRef {
350+
if (!this._ref) {
351+
throw new Error("Font has no PDF reference. Use pdf.embedFont() to embed fonts.");
352+
}
353+
354+
return this._ref;
355+
}
356+
357+
/**
358+
* Set the pre-allocated PDF reference.
359+
*
360+
* @internal Called by PDFFonts.embed()
361+
*/
362+
setRef(ref: PdfRef): void {
363+
this._ref = ref;
364+
}
365+
336366
/**
337367
* Mark this font as used in a form field.
338368
*
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
/**
2+
* Regression test for issue #18:
3+
* "Form flatten with embedded fonts produces empty/incorrect output"
4+
*
5+
* The bug: appearance streams referenced incomplete stub font dicts
6+
* (missing DescendantFonts, ToUnicode, FontDescriptor, font data).
7+
* After flattening, viewers couldn't render the glyph IDs.
8+
*
9+
* The fix: EmbeddedFont stores its pre-allocated PdfRef (set by
10+
* PDFFonts.embed), so appearance streams reference the complete
11+
* font object that gets built at save time.
12+
*
13+
* @see https://github.com/LibPDF-js/core/issues/18
14+
*/
15+
16+
import { PDF } from "#src/api/pdf";
17+
import { PdfDict } from "#src/objects/pdf-dict";
18+
import { PdfName } from "#src/objects/pdf-name";
19+
import { PdfRef } from "#src/objects/pdf-ref";
20+
import { PdfStream } from "#src/objects/pdf-stream";
21+
import { loadFixture, saveTestOutput } from "#src/test-utils";
22+
import { describe, expect, it } from "vitest";
23+
24+
describe("Issue #18: Flatten with embedded fonts", () => {
25+
it("produces complete font objects after flatten + save", async () => {
26+
// Set up: embed DejaVu Sans, fill fields with diacritics, flatten, save
27+
const pdf = await PDF.load(await loadFixture("forms", "sample_form.pdf"));
28+
const font = pdf.embedFont(await loadFixture("fonts", "ttf/DejaVuSans.ttf"));
29+
const form = pdf.getForm()!;
30+
31+
const acroForm = form.acroForm();
32+
acroForm.setDefaultFont(font);
33+
acroForm.setDefaultFontSize(12);
34+
35+
const textFields = form.getTextFields().filter(f => !f.isReadOnly());
36+
textFields[0]?.setValue("Ján Novák");
37+
textFields[1]?.setValue("Žilina");
38+
textFields[2]?.setValue("čšťňľ áéíóú");
39+
40+
// Save control (no flatten) for visual comparison
41+
const controlPath = await saveTestOutput("issues/issue-18-no-flatten.pdf", await pdf.save());
42+
console.log(` -> Control (no flatten): ${controlPath}`);
43+
44+
// Flatten and save
45+
form.flatten();
46+
const savedBytes = await pdf.save();
47+
const flattenedPath = await saveTestOutput("issues/issue-18-flattened.pdf", savedBytes);
48+
console.log(` -> Flattened: ${flattenedPath}`);
49+
50+
// Reload and find Type0 fonts in the flattened XObjects
51+
const reloaded = await PDF.load(savedBytes);
52+
const resolve = (ref: PdfRef) => reloaded.getObject(ref);
53+
const page = reloaded.getPage(0)!;
54+
const xobjects = page.getResources().getDict("XObject", resolve);
55+
expect(xobjects).toBeDefined();
56+
57+
const type0Fonts = findType0Fonts(xobjects!, resolve);
58+
expect(type0Fonts.length).toBeGreaterThan(0);
59+
60+
// Each Type0 font must have DescendantFonts and ToUnicode —
61+
// these were missing in the bug (stub dict only had Type/Subtype/BaseFont/Encoding)
62+
for (const fontDict of type0Fonts) {
63+
expect(fontDict.get("DescendantFonts", resolve)).toBeDefined();
64+
expect(fontDict.get("ToUnicode", resolve)).toBeDefined();
65+
}
66+
});
67+
});
68+
69+
type Resolve = (ref: PdfRef) => ReturnType<PDF["getObject"]>;
70+
71+
/**
72+
* Walk FlatField XObjects and collect their Type0 font dicts.
73+
*/
74+
function findType0Fonts(xobjects: PdfDict, resolve: Resolve): PdfDict[] {
75+
const results: PdfDict[] = [];
76+
77+
for (const [key, value] of xobjects) {
78+
if (!key.value.startsWith("FlatField")) {
79+
continue;
80+
}
81+
82+
const xobj = value instanceof PdfRef ? resolve(value) : value;
83+
84+
if (!(xobj instanceof PdfStream)) {
85+
continue;
86+
}
87+
88+
const fonts = xobj.getDict("Resources", resolve)?.getDict("Font", resolve);
89+
90+
if (!fonts) {
91+
continue;
92+
}
93+
94+
for (const [, fontValue] of fonts) {
95+
const fontObj = fontValue instanceof PdfRef ? resolve(fontValue) : fontValue;
96+
97+
if (!(fontObj instanceof PdfDict)) {
98+
continue;
99+
}
100+
101+
if (fontObj.getName("Subtype", resolve)?.value === "Type0") {
102+
results.push(fontObj);
103+
}
104+
}
105+
}
106+
107+
return results;
108+
}

0 commit comments

Comments
 (0)