Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added fixtures/fonts/ttf/DejaVuSans.ttf
Binary file not shown.
1 change: 1 addition & 0 deletions src/api/pdf-fonts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ export class PDFFonts {
const ref = this.ctx.registry.allocateRef();

this.embeddedFonts.set(font, ref);
font.setRef(ref);

return font;
}
Expand Down
16 changes: 1 addition & 15 deletions src/api/pdf-form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/**
Expand Down
14 changes: 1 addition & 13 deletions src/document/forms/button-appearance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
}
14 changes: 1 addition & 13 deletions src/document/forms/choice-appearance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
}
14 changes: 1 addition & 13 deletions src/document/forms/text-appearance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
Expand Down
30 changes: 30 additions & 0 deletions src/fonts/embedded-font.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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.
*
Expand Down
108 changes: 108 additions & 0 deletions src/tests/issues/issue-18-flatten-embedded-fonts.test.ts
Original file line number Diff line number Diff line change
@@ -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<PDF["getObject"]>;

/**
* 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;
}