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 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.

Fonts are now only *loaded*, i.e. the fontkit font is being created
before layout. The layout relies on fontkit to calculate sizes instead
of the `PdfFont` instance. In the render phase, the fonts are embedded
into the PDF.

Likewise, images are loaded before layout to get their size and embedded
into the PDF in the render phase. During render, the PDF ref is attached
to the `Font` and `Image` instances.
  • Loading branch information
ralfstx committed Jun 17, 2023
1 parent 707771a commit fadb7c2
Show file tree
Hide file tree
Showing 22 changed files with 264 additions and 182 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
29 changes: 29 additions & 0 deletions src/font-metrics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// This code was borrowed from `pdf-lib`
// https://github.com/Hopding/pdf-lib/blob/v1.17.1/src/core/embedders/CustomFontEmbedder.ts

import { Font, TypeFeatures } from '@pdf-lib/fontkit';

export function FontMetrics(font: Font, fontFeatures?: TypeFeatures) {
const scale = 1000 / font.unitsPerEm;

return { font, widthOfTextAtSize, heightAtSize };

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

function heightAtSize(size: number, options: { descender?: boolean } = {}): number {
const { descender = true } = options;
const { ascent, descent, bbox } = font;
const yTop = (ascent || bbox.maxY) * scale;
const yBottom = (descent || bbox.minY) * scale;
let height = yTop - yBottom;
if (!descender) height -= Math.abs(descent) || 0;
return (height / 1000) * size;
}
}
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 fadb7c2

Please sign in to comment.