Skip to content

Commit

Permalink
♻️ Decouple layout from PDF lib types
Browse files Browse the repository at this point in the history
The layout phase relied on the `pdf-lib` types `PDFFont` and `PDFImage`.
These types can only be created by embedding a font or an image into the
PDF. In preparation of loading font and image resources on demand, this
commit decouples the layout process from these `pdf-lib` types and uses
only `Font` and `Image` during layout.

Before layout, fonts are now only *loaded*, i.e. the `fontkit` font is
being created. To calculate text sizes, the layout now relies only on
`fontkit` instead of a `PDFFont` instance. The fonts are embedded into
the PDF only in the render phase.

Likewise, images are loaded before layout and embedded into the PDF in
the render phase. They need to be loaded in advance to get their size.
  • Loading branch information
ralfstx committed Jun 18, 2023
1 parent 707771a commit cadc751
Show file tree
Hide file tree
Showing 23 changed files with 269 additions and 191 deletions.
27 changes: 16 additions & 11 deletions src/document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,45 +2,50 @@ import fontkit from '@pdf-lib/fontkit';
import { PDFDict, PDFDocument, PDFHexString, PDFName } from 'pdf-lib';

import { Size } from './box.js';
import { embedFonts, Font } from './fonts.js';
import { embedImages, Image } from './images.js';
import { embedFonts, Font, loadFonts } from './fonts.js';
import { embedImages, Image, loadImages } from './images.js';
import { applyOrientation, paperSizes } from './page-sizes.js';
import { DocumentDefinition, Metadata } from './read-document.js';

export type Document = {
fonts: Font[];
images: Image[];
pageSize: Size;
pdfDoc: PDFDocument;
guides?: boolean;
};

export async function createDocument(def: DocumentDefinition): Promise<Document> {
const fonts = loadFonts(def.fonts ?? []);
const images = await loadImages(def.images ?? []);
const pageSize = applyOrientation(def.pageSize ?? paperSizes.A4, def.pageOrientation);
const guides = !!def.dev?.guides;
return { fonts, images, pageSize, guides };
}

export async function renderDocument(def: DocumentDefinition, doc: Document): Promise<PDFDocument> {
const pdfDoc = await PDFDocument.create();
pdfDoc.registerFontkit(fontkit);
const fonts = await embedFonts(def.fonts ?? [], pdfDoc);
const images = await embedImages(def.images ?? [], pdfDoc);
const pageSize = applyOrientation(def.pageSize ?? paperSizes.A4, def.pageOrientation);
await embedFonts(doc.fonts ?? [], pdfDoc);
await embedImages(doc.images ?? [], pdfDoc);
setMetadata(pdfDoc, def.info);
if (def.customData) {
setCustomData(def.customData, pdfDoc);
}
const guides = !!def.dev?.guides;
return { fonts, images, pageSize, pdfDoc, guides };
return pdfDoc;
}

export async function finishDocument(def: DocumentDefinition, doc: Document) {
export async function finishDocument(def: DocumentDefinition, pdfDoc: PDFDocument) {
const idInfo = {
creator: 'pdfmkr',
time: new Date().toISOString(),
info: def.info ?? null,
};
const fileId = await sha256Hex(JSON.stringify(idInfo));
doc.pdfDoc.context.trailerInfo.ID = doc.pdfDoc.context.obj([
pdfDoc.context.trailerInfo.ID = pdfDoc.context.obj([
PDFHexString.of(fileId.toUpperCase()),
PDFHexString.of(fileId.toUpperCase()),
]);
const data = await doc.pdfDoc.save();
const data = await pdfDoc.save();
// add trailing newline
return new Uint8Array([...data, 10]);
}
Expand Down
20 changes: 20 additions & 0 deletions src/font-metrics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { Font } from '@pdf-lib/fontkit';

export function getTextWidth(text: string, font: Font, fontSize: number): number {
const { glyphs } = font.layout(text);
const scale = 1000 / font.unitsPerEm;
let totalWidth = 0;
for (let idx = 0, len = glyphs.length; idx < len; idx++) {
totalWidth += glyphs[idx].advanceWidth * scale;
}
return (totalWidth * fontSize) / 1000;
}

export function getTextHeight(font: Font, fontSize: number): number {
const { ascent, descent, bbox } = font;
const scale = 1000 / font.unitsPerEm;
const yTop = (ascent || bbox.maxY) * scale;
const yBottom = (descent || bbox.minY) * scale;
const height = yTop - yBottom;
return (height / 1000) * fontSize;
}
42 changes: 29 additions & 13 deletions src/fonts.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { PDFDocument, PDFFont } from 'pdf-lib';
import fontkit from '@pdf-lib/fontkit';
import { CustomFontSubsetEmbedder, PDFDocument, PDFFont, PDFRef, toUint8Array } from 'pdf-lib';

import { parseBinaryData } from './binary-data.js';
import {
Expand All @@ -22,7 +23,9 @@ export type Font = {
name: string;
italic?: boolean;
bold?: boolean;
pdfFont: PDFFont;
data: Uint8Array;
fkFont: fontkit.Font;
pdfRef?: PDFRef;
};

export type FontSelector = {
Expand All @@ -47,20 +50,33 @@ export function readFont(input: unknown): Partial<FontDef> {
}) as FontDef;
}

export async function embedFonts(fontDefs: FontDef[], doc: PDFDocument): Promise<Font[]> {
return await Promise.all(
fontDefs.map(async (def) => {
const pdfFont = await doc.embedFont(def.data, { subset: true }).catch((error) => {
throw new Error(`Could not embed font "${def.name}": ${error.message ?? error}`);
});
return pickDefined({ name: def.name, italic: def.italic, bold: def.bold, pdfFont });
})
);
export function loadFonts(fontDefs: FontDef[]): Font[] {
return fontDefs.map((def) => {
const data = toUint8Array(def.data);
const fkFont = fontkit.create(data);
return pickDefined({
name: def.name,
italic: def.italic,
bold: def.bold,
fkFont,
data,
});
});
}

export async function embedFonts(fonts: Font[], pdfDoc: PDFDocument): Promise<void> {
for (const font of fonts) {
const ref = pdfDoc.context.nextRef();
font.pdfRef = ref;
const embedder = new (CustomFontSubsetEmbedder as any)(font.fkFont, font.data);
const pdfFont = PDFFont.of(ref, pdfDoc, embedder);
(pdfDoc as any).fonts.push(pdfFont);
}
}

export function selectFont(fonts: Font[], attrs: FontSelector): PDFFont {
export function selectFont(fonts: Font[], attrs: FontSelector): Font {
const { fontFamily, italic, bold } = attrs;
const font = fonts.find((font) => match(font, { fontFamily, italic, bold }))?.pdfFont;
const font = fonts.find((font) => match(font, { fontFamily, italic, bold }));
if (!font) {
const style = italic ? (bold ? 'bold italic' : 'italic') : bold ? 'bold' : 'normal';
throw new Error(`No font found for "${fontFamily} ${style}"`);
Expand Down
33 changes: 25 additions & 8 deletions src/images.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { PDFDocument, PDFImage } from 'pdf-lib';
import { JpegEmbedder, PDFDocument, PDFRef, toUint8Array } from 'pdf-lib';

import { parseBinaryData } from './binary-data.js';
import { pickDefined, readAs, readObject, required } from './types.js';
import { readAs, readObject, required } from './types.js';

export type ImageDef = {
name: string;
Expand All @@ -10,7 +10,10 @@ export type ImageDef = {

export type Image = {
name: string;
pdfImage: PDFImage;
width: number;
height: number;
data: Uint8Array;
pdfRef?: PDFRef;
};

export function readImages(input: unknown): ImageDef[] {
Expand All @@ -24,13 +27,27 @@ function readImage(input: unknown): { data: Uint8Array } {
return readObject(input, { data: required(parseBinaryData) }) as { data: Uint8Array };
}

export async function embedImages(imageDefs: ImageDef[], doc: PDFDocument): Promise<Image[]> {
export async function loadImages(imageDefs: ImageDef[]): Promise<Image[]> {
return await Promise.all(
imageDefs.map(async (def) => {
const pdfImage = await doc.embedJpg(def.data).catch((error) => {
throw new Error(`Could not embed image "${def.name}": ${error.message ?? error}`);
});
return pickDefined({ name: def.name, pdfImage });
const data = toUint8Array(def.data);
const { width, height } = await JpegEmbedder.for(data);
return { name: def.name, width, height, data };
})
);
}

export async function embedImages(images: Image[], pdfDoc: PDFDocument): Promise<void> {
await Promise.all(
images.map(async (image) => {
try {
const pdfImage = await pdfDoc.embedJpg(image.data);
image.pdfRef = pdfImage.ref;
} catch (error) {
throw new Error(
`Could not embed image "${image.name}": ${(error as Error)?.message ?? error}`
);
}
})
);
}
9 changes: 4 additions & 5 deletions src/layout-image.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { PDFImage } from 'pdf-lib';

import { Box, Pos, Size } from './box.js';
import { Document } from './document.js';
import { Image } from './images.js';
import { LayoutContent, RenderObject } from './layout.js';
import { ImageBlock } from './read-block.js';

Expand All @@ -11,11 +10,11 @@ export type ImageObject = {
y: number;
width: number;
height: number;
image: PDFImage;
image: Image;
};

export function layoutImageContent(block: ImageBlock, box: Box, doc: Document): LayoutContent {
const image = doc.images.find((image) => image.name === block.image)?.pdfImage;
const image = doc.images.find((image) => image.name === block.image);
if (!image) throw new Error(`Unknown image: ${block.image}`);
const hasFixedWidth = block.width != null;
const hasFixedHeight = block.height != null;
Expand Down Expand Up @@ -50,6 +49,6 @@ function align(box: Box, size: Size, alignment?: string): Pos {
return { x: box.x + xShift, y: box.y + yShift };
}

function createImageObject(image: PDFImage, pos: Pos, size: Size): ImageObject {
function createImageObject(image: Image, pos: Pos, size: Size): ImageObject {
return { type: 'image', image, x: pos.x, y: pos.y, width: size.width, height: size.height };
}
8 changes: 3 additions & 5 deletions src/layout-text.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { PDFFont } from 'pdf-lib';

import { Box, Pos, Size } from './box.js';
import { Alignment } from './content.js';
import { Document } from './document.js';
import { Font } from './fonts.js';
import { createRowGuides } from './guides.js';
import {
LayoutContent,
Expand Down Expand Up @@ -123,9 +122,8 @@ function layoutTextRow(segments: TextSegment[], box: Box, textAlign?: Alignment)
return { row, objects, remainder };
}

function getDescent(font: PDFFont, fontSize: number) {
const fontkitFont = (font as any).embedder.font;
return Math.abs(((fontkitFont.descent ?? 0) * fontSize) / fontkitFont.unitsPerEm);
function getDescent(font: Font, fontSize: number) {
return Math.abs(((font.fkFont.descent ?? 0) * fontSize) / font.fkFont.unitsPerEm);
}

/**
Expand Down
5 changes: 2 additions & 3 deletions src/layout.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { PDFFont } from 'pdf-lib';

import { Box, parseEdges, subtractEdges, ZERO_EDGES } from './box.js';
import { Color } from './colors.js';
import { Document } from './document.js';
import { Font } from './fonts.js';
import { createFrameGuides } from './guides.js';
import { layoutColumnsContent } from './layout-columns.js';
import { ImageObject, layoutImageContent } from './layout-image.js';
Expand Down Expand Up @@ -66,7 +65,7 @@ export type TextRowObject = {

export type TextSegmentObject = {
text: string;
font: PDFFont;
font: Font;
fontSize: number;
color?: Color;
rise?: number;
Expand Down
7 changes: 4 additions & 3 deletions src/make-pdf.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { DocumentDefinition } from './content.js';
import { createDocument, finishDocument } from './document.js';
import { createDocument, finishDocument, renderDocument } from './document.js';
import { layoutPages } from './layout.js';
import { readDocumentDefinition } from './read-document.js';
import { renderPage } from './render-page.js';
Expand All @@ -17,6 +17,7 @@ export async function makePdf(definition: DocumentDefinition): Promise<Uint8Arra
const def = readAs(definition, 'definition', readDocumentDefinition);
const doc = await createDocument(def);
const pages = layoutPages(def, doc);
pages.forEach((page) => renderPage(page, doc));
return await finishDocument(def, doc);
const pdfDoc = await renderDocument(def, doc);
pages.forEach((page) => renderPage(page, pdfDoc));
return await finishDocument(def, pdfDoc);
}
18 changes: 11 additions & 7 deletions src/page.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { Color, PDFFont, PDFImage, PDFName, PDFPage } from 'pdf-lib';
import { Color, PDFName, PDFPage } from 'pdf-lib';

import { Size } from './box.js';
import { Font } from './fonts.js';
import { Image } from './images.js';
import { Frame } from './layout.js';

export type TextState = {
Expand All @@ -23,20 +25,22 @@ export type Page = {
extGStates?: { [ref: string]: PDFName };
};

export function getPageFont(page: Page, font: PDFFont): PDFName {
export function getPageFont(page: Page, font: Font): PDFName {
if (!font.pdfRef) throw new Error('Font not initialized: ' + font.name);
page.fonts ??= {};
const key = font.ref.toString();
const key = font.pdfRef.toString();
if (!(key in page.fonts)) {
page.fonts[key] = (page.pdfPage as any).node.newFontDictionary(font.name, font.ref);
page.fonts[key] = (page.pdfPage as any).node.newFontDictionary(font.name, font.pdfRef);
}
return page.fonts[key];
}

export function getPageImage(page: Page, image: PDFImage): PDFName {
export function getPageImage(page: Page, image: Image): PDFName {
if (!image.pdfRef) throw new Error('Image not initialized: ' + image.name);
page.images ??= {};
const key = image.ref.toString();
const key = image.pdfRef.toString();
if (!(key in page.images)) {
page.images[key] = (page.pdfPage as any).node.newXObject('Image', image.ref);
page.images[key] = (page.pdfPage as any).node.newXObject('Image', image.pdfRef);
}
return page.images[key];
}
Expand Down
7 changes: 4 additions & 3 deletions src/render-page.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import { PDFDocument } from 'pdf-lib';

import { Pos } from './box.js';
import { Document } from './document.js';
import { Frame } from './layout.js';
import { Page } from './page.js';
import { renderAnchor, renderLink } from './render-annotations.js';
import { renderGraphics } from './render-graphics.js';
import { renderImage } from './render-image.js';
import { renderText } from './render-text.js';

export function renderPage(page: Page, doc: Document) {
page.pdfPage = doc.pdfDoc.addPage([page.size.width, page.size.height]);
export function renderPage(page: Page, pdfDoc: PDFDocument) {
page.pdfPage = pdfDoc.addPage([page.size.width, page.size.height]);
page.header && renderFrame(page.header, page);
renderFrame(page.content, page);
page.footer && renderFrame(page.footer, page);
Expand Down
6 changes: 5 additions & 1 deletion src/render-text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
Color,
endText,
PDFContentStream,
PDFFont,
PDFName,
PDFOperator,
rgb,
Expand All @@ -28,8 +29,11 @@ export function renderText(object: TextObject, page: Page, base: Pos) {
object.rows?.forEach((row) => {
contentStream.push(setTextMatrix(1, 0, 0, 1, x + row.x, y - row.y - row.baseline));
row.segments?.forEach((seg) => {
const pdfFont = (page.pdfPage as any)?.doc?.fonts?.find(
(font: PDFFont) => font.ref === seg.font.pdfRef
);
const fontKey = getPageFont(page, seg.font);
const encodedText = seg.font.encodeText(seg.text);
const encodedText = pdfFont.encodeText(seg.text);
const operators = compact([
setTextColorOp(state, seg.color),
setTextFontAndSizeOp(state, fontKey, seg.fontSize),
Expand Down

0 comments on commit cadc751

Please sign in to comment.