From fadb7c273af8ec1023972038fe0b4d6524f57829 Mon Sep 17 00:00:00 2001 From: Ralf Sternberg Date: Sun, 28 May 2023 18:45:46 +0200 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Decouple=20layout=20from?= =?UTF-8?q?=20PDF=20lib=20types?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/document.ts | 27 +++++++++++-------- src/font-metrics.ts | 29 ++++++++++++++++++++ src/fonts.ts | 42 ++++++++++++++++++++--------- src/images.ts | 33 +++++++++++++++++------ src/layout-image.ts | 9 +++---- src/layout-text.ts | 8 +++--- src/layout.ts | 5 ++-- src/make-pdf.ts | 7 ++--- src/page.ts | 18 ++++++++----- src/render-page.ts | 7 ++--- src/render-text.ts | 6 ++++- src/text.ts | 12 +++++---- test/document.test.ts | 24 ++++++++++++----- test/fonts.test.ts | 57 +++++++-------------------------------- test/images.test.ts | 46 ++++++++++++++++--------------- test/layout-text.test.ts | 10 +++---- test/page.test.ts | 11 ++++---- test/render-image.test.ts | 6 ++--- test/render-page.test.ts | 20 +++++++------- test/render-text.test.ts | 7 ++--- test/test-utils.ts | 56 ++++++++++++++++++++++++++++---------- test/text.test.ts | 6 ++--- 22 files changed, 264 insertions(+), 182 deletions(-) create mode 100644 src/font-metrics.ts diff --git a/src/document.ts b/src/document.ts index 33dcb64..0936746 100644 --- a/src/document.ts +++ b/src/document.ts @@ -2,8 +2,8 @@ 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'; @@ -11,36 +11,41 @@ export type Document = { fonts: Font[]; images: Image[]; pageSize: Size; - pdfDoc: PDFDocument; guides?: boolean; }; export async function createDocument(def: DocumentDefinition): Promise { + 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 { 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]); } diff --git a/src/font-metrics.ts b/src/font-metrics.ts new file mode 100644 index 0000000..d917593 --- /dev/null +++ b/src/font-metrics.ts @@ -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; + } +} diff --git a/src/fonts.ts b/src/fonts.ts index 05c14ef..e450af5 100644 --- a/src/fonts.ts +++ b/src/fonts.ts @@ -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 { @@ -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 = { @@ -47,20 +50,33 @@ export function readFont(input: unknown): Partial { }) as FontDef; } -export async function embedFonts(fontDefs: FontDef[], doc: PDFDocument): Promise { - 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 { + 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}"`); diff --git a/src/images.ts b/src/images.ts index 9fc862d..94fc011 100644 --- a/src/images.ts +++ b/src/images.ts @@ -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; @@ -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[] { @@ -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 { +export async function loadImages(imageDefs: ImageDef[]): Promise { 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 { + 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}` + ); + } }) ); } diff --git a/src/layout-image.ts b/src/layout-image.ts index 6b3bcfb..ed734dd 100644 --- a/src/layout-image.ts +++ b/src/layout-image.ts @@ -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'; @@ -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; @@ -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 }; } diff --git a/src/layout-text.ts b/src/layout-text.ts index 32bd590..d4312d6 100644 --- a/src/layout-text.ts +++ b/src/layout-text.ts @@ -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, @@ -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); } /** diff --git a/src/layout.ts b/src/layout.ts index bf491f5..b86ccd5 100644 --- a/src/layout.ts +++ b/src/layout.ts @@ -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'; @@ -66,7 +65,7 @@ export type TextRowObject = { export type TextSegmentObject = { text: string; - font: PDFFont; + font: Font; fontSize: number; color?: Color; rise?: number; diff --git a/src/make-pdf.ts b/src/make-pdf.ts index 87238cd..9f6bff8 100644 --- a/src/make-pdf.ts +++ b/src/make-pdf.ts @@ -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'; @@ -17,6 +17,7 @@ export async function makePdf(definition: DocumentDefinition): Promise 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); } diff --git a/src/page.ts b/src/page.ts index bb1b271..1616320 100644 --- a/src/page.ts +++ b/src/page.ts @@ -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 = { @@ -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]; } diff --git a/src/render-page.ts b/src/render-page.ts index ea52dd1..4eb9635 100644 --- a/src/render-page.ts +++ b/src/render-page.ts @@ -1,5 +1,6 @@ +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'; @@ -7,8 +8,8 @@ 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); diff --git a/src/render-text.ts b/src/render-text.ts index f38e2db..125aedc 100644 --- a/src/render-text.ts +++ b/src/render-text.ts @@ -3,6 +3,7 @@ import { Color, endText, PDFContentStream, + PDFFont, PDFName, PDFOperator, rgb, @@ -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), diff --git a/src/text.ts b/src/text.ts index 8171769..fa9d4ec 100644 --- a/src/text.ts +++ b/src/text.ts @@ -1,6 +1,5 @@ -import { PDFFont } from 'pdf-lib'; - import { Color } from './colors.js'; +import { FontMetrics } from './font-metrics.js'; import { Font, selectFont } from './fonts.js'; import { TextSpan } from './read-block.js'; @@ -12,7 +11,7 @@ export type TextSegment = { width: number; height: number; lineHeight: number; - font: PDFFont; + font: Font; fontSize: number; fontFamily: string; italic?: boolean; @@ -38,12 +37,15 @@ export function extractTextSegments(textSpans: TextSpan[], fonts: Font[]): TextS letterSpacing, } = attrs; const font = selectFont(fonts, attrs); - const height = font.heightAtSize(fontSize); + const height = FontMetrics(font.fkFont).heightAtSize(fontSize); + return splitChunks(text).map( (text) => ({ text, - width: font.widthOfTextAtSize(text, fontSize) + text.length * (letterSpacing ?? 0), + width: + FontMetrics(font.fkFont).widthOfTextAtSize(text, fontSize) + + text.length * (letterSpacing ?? 0), height, lineHeight, font, diff --git a/test/document.test.ts b/test/document.test.ts index 95c0c36..2dd8191 100644 --- a/test/document.test.ts +++ b/test/document.test.ts @@ -1,10 +1,20 @@ -import { describe, expect, it } from '@jest/globals'; +import { beforeEach, describe, expect, it } from '@jest/globals'; import { PDFDict, PDFHexString, PDFName, PDFStream, PDFString } from 'pdf-lib'; -import { createDocument } from '../src/document.js'; +import { Document, renderDocument } from '../src/document.js'; describe('document', () => { - describe('createDocument', () => { + let doc: Document; + + beforeEach(() => { + doc = { + fonts: [], + images: [], + pageSize: { width: 100, height: 200 }, + }; + }); + + describe('renderDocument', () => { it('renders all info attributes', async () => { const def = { content: [], @@ -23,9 +33,9 @@ describe('document', () => { }, }; - const doc = await createDocument(def); + const pdfDoc = await renderDocument(def, doc); - const infoDict = doc.pdfDoc.context.lookup(doc.pdfDoc.context.trailerInfo.Info) as PDFDict; + const infoDict = pdfDoc.context.lookup(pdfDoc.context.trailerInfo.Info) as PDFDict; const getInfo = (name: string) => infoDict.get(PDFName.of(name)); expect(infoDict).toBeInstanceOf(PDFDict); expect(getInfo('Title')).toEqual(PDFHexString.fromText('test-title')); @@ -48,9 +58,9 @@ describe('document', () => { }, }; - const doc = await createDocument(def); + const pdfDoc = await renderDocument(def, doc); - const lookup = (name: string) => doc.pdfDoc.catalog.lookup(PDFName.of(name)) as PDFStream; + const lookup = (name: string) => pdfDoc.catalog.lookup(PDFName.of(name)) as PDFStream; expect(lookup('XXFoo').getContentsString()).toBe('Foo'); expect(lookup('XXBar').getContents()).toEqual(Uint8Array.of(1, 2, 3)); }); diff --git a/test/fonts.test.ts b/test/fonts.test.ts index 09c31e5..25843dc 100644 --- a/test/fonts.test.ts +++ b/test/fonts.test.ts @@ -1,7 +1,6 @@ -import { beforeEach, describe, expect, it, jest } from '@jest/globals'; -import { PDFDocument, PDFFont } from 'pdf-lib'; +import { beforeEach, describe, expect, it } from '@jest/globals'; -import { embedFonts, Font, readFonts, selectFont } from '../src/fonts.js'; +import { Font, loadFonts, readFonts, selectFont } from '../src/fonts.js'; import { fakeFont } from './test-utils.js'; describe('fonts', () => { @@ -64,57 +63,21 @@ describe('fonts', () => { }); }); - describe('embedFont', () => { + describe('loadFont', () => { it('returns an empty array for empty fonts definition', async () => { - const fonts = await embedFonts([], {} as any); + const fonts = loadFonts([]); expect(fonts).toEqual([]); }); - - it('embeds fonts in PDF document and returns fonts array', async () => { - const embedFont = jest.fn().mockImplementation((font) => Promise.resolve(`PDF_${font}`)); - const doc = { embedFont } as any; - const fontsDef = [ - { name: 'Test', data: 'Test_Sans_Normal' }, - { name: 'Test', data: 'Test_Sans_Italic', italic: true }, - { name: 'Test', data: 'Test_Sans_Bold', bold: true }, - { name: 'Test', data: 'Test_Sans_BoldItalic', italic: true, bold: true }, - { name: 'Other', data: 'Other_Normal' }, - ]; - - const fonts = await embedFonts(fontsDef, doc); - - expect(fonts).toEqual([ - { name: 'Test', pdfFont: 'PDF_Test_Sans_Normal' }, - { name: 'Test', pdfFont: 'PDF_Test_Sans_Italic', italic: true }, - { name: 'Test', pdfFont: 'PDF_Test_Sans_Bold', bold: true }, - { name: 'Test', pdfFont: 'PDF_Test_Sans_BoldItalic', italic: true, bold: true }, - { name: 'Other', pdfFont: 'PDF_Other_Normal' }, - ]); - }); - - it('throws when embedding fails', async () => { - const embedFont = (data: any) => - data === 'Bad_Data' ? Promise.reject('Bad font') : Promise.resolve(data); - const doc = { embedFont } as PDFDocument; - const fontsDef = [ - { name: 'Good', data: 'Good_Data' }, - { name: 'Bad', data: 'Bad_Data' }, - ]; - - const promise = embedFonts(fontsDef, doc); - - await expect(promise).rejects.toThrowError('Could not embed font "Bad": Bad font'); - }); }); describe('selectFont', () => { let fonts: Font[]; - let normalFont: PDFFont; - let italicFont: PDFFont; - let boldFont: PDFFont; - let italicBoldFont: PDFFont; - let otherFont: PDFFont; + let normalFont: Font; + let italicFont: Font; + let boldFont: Font; + let italicBoldFont: Font; + let otherFont: Font; beforeEach(() => { fonts = [ @@ -124,7 +87,7 @@ describe('fonts', () => { fakeFont('Test', { italic: true, bold: true }), fakeFont('Other'), ]; - [normalFont, italicFont, boldFont, italicBoldFont, otherFont] = fonts.map((f) => f.pdfFont); + [normalFont, italicFont, boldFont, italicBoldFont, otherFont] = fonts; }); it('selects different font variants', () => { diff --git a/test/images.test.ts b/test/images.test.ts index 803f920..3a99b04 100644 --- a/test/images.test.ts +++ b/test/images.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it, jest } from '@jest/globals'; +import { PDFRef } from 'pdf-lib'; -import { embedImages, readImages } from '../src/images.js'; +import { embedImages, Image, loadImages, readImages } from '../src/images.js'; +import { mkData } from './test-utils.js'; describe('images', () => { describe('readImages', () => { @@ -43,45 +45,47 @@ describe('images', () => { }); }); - describe('embedImages', () => { + describe('loadImages', () => { it('returns an empty array for empty images definition', async () => { - const images = await embedImages([], {} as any); + const images = await loadImages([]); expect(images).toEqual([]); }); + }); - it('embeds images in PDF document and returns images array', async () => { - const embedJpg = jest.fn().mockImplementation((data) => Promise.resolve({ data })); + describe('embedImages', () => { + it('embeds images in PDF document and attaches ref', async () => { + let n = 1; + const embedJpg = jest.fn().mockImplementation(() => Promise.resolve({ ref: PDFRef.of(n++) })); const doc = { embedJpg } as any; - const imageDefs = [ - { name: 'foo', data: mkData('Foo') }, - { name: 'bar', data: mkData('Bar') }, + const images: Image[] = [ + { name: 'foo', data: mkData('Foo'), width: 100, height: 200 }, + { name: 'bar', data: mkData('Bar'), width: 100, height: 200 }, ]; - const images = await embedImages(imageDefs, doc); + await embedImages(images, doc); - expect(images).toEqual([ - { name: 'foo', pdfImage: { data: mkData('Foo') } }, - { name: 'bar', pdfImage: { data: mkData('Bar') } }, - ]); + expect(images[0].pdfRef?.toString()).toEqual('1 0 R'); + expect(images[1].pdfRef?.toString()).toEqual('2 0 R'); }); it('throws when embedding fails', async () => { - const embedJpg = (data: any) => - data === 'Bad_Data' ? Promise.reject('Bad image') : Promise.resolve({ data }); + const embedJpg = (data: Uint8Array) => + str(data) === 'Bad' ? Promise.reject('Bad image') : Promise.resolve({ data }); + const doc = { embedJpg } as any; - const imagesDef = [ - { name: 'good', data: 'Good_Data' }, - { name: 'bad', data: 'Bad_Data' }, + const images = [ + { name: 'good', data: mkData('Good'), width: 100, height: 200 }, + { name: 'bad', data: mkData('Bad'), width: 100, height: 200 }, ]; - const promise = embedImages(imagesDef, doc); + const promise = embedImages(images, doc); await expect(promise).rejects.toThrowError('Could not embed image "bad": Bad image'); }); }); }); -function mkData(value: string) { - return new Uint8Array(value.split('').map((c) => c.charCodeAt(0))); +function str(data: Uint8Array): string { + return String.fromCharCode(...data); } diff --git a/test/layout-text.test.ts b/test/layout-text.test.ts index 0d3c4e4..0b9592c 100644 --- a/test/layout-text.test.ts +++ b/test/layout-text.test.ts @@ -50,7 +50,7 @@ describe('layout', () => { rows: [ { ...{ x: 20, y: 30, width: 90, height: 12, baseline: 9 }, - segments: [{ font: doc.fonts[0].pdfFont, fontSize: 10, text: 'Test text' }], + segments: [{ font: doc.fonts[0], fontSize: 10, text: 'Test text' }], }, ], }, @@ -75,9 +75,9 @@ describe('layout', () => { { ...{ x: 20, y: 30, width: 270, height: 18, baseline: 13.5 }, segments: [ - { font: doc.fonts[0].pdfFont, fontSize: 5, text: 'Text one' }, - { font: doc.fonts[0].pdfFont, fontSize: 10, text: 'Text two' }, - { font: doc.fonts[0].pdfFont, fontSize: 15, text: 'Text three' }, + { font: doc.fonts[0], fontSize: 5, text: 'Text one' }, + { font: doc.fonts[0], fontSize: 10, text: 'Text two' }, + { font: doc.fonts[0], fontSize: 15, text: 'Text three' }, ], }, ], @@ -169,7 +169,7 @@ describe('layout', () => { expect((frame.objects?.[0] as any).rows[0].segments).toEqual([ { - font: doc.fonts[0].pdfFont, + font: doc.fonts[0], fontSize: 10, text: 'foo', color: { type: 'RGB', blue: 1, green: 0.5, red: 0 }, diff --git a/test/page.test.ts b/test/page.test.ts index 94de35e..4531df9 100644 --- a/test/page.test.ts +++ b/test/page.test.ts @@ -1,8 +1,9 @@ import { beforeEach, describe, expect, it } from '@jest/globals'; -import { PDFFont, PDFPage } from 'pdf-lib'; +import { PDFPage } from 'pdf-lib'; +import { Font } from '../src/fonts.js'; import { getExtGraphicsState, getPageFont, Page } from '../src/page.js'; -import { fakePdfFont, fakePdfPage } from './test-utils.js'; +import { fakeFont, fakePdfPage } from './test-utils.js'; describe('page', () => { let page: Page, pdfPage: PDFPage; @@ -13,11 +14,11 @@ describe('page', () => { }); describe('getPageFont', () => { - let fontA: PDFFont, fontB: PDFFont; + let fontA: Font, fontB: Font; beforeEach(() => { - fontA = fakePdfFont('fontA'); - fontB = fakePdfFont('fontB'); + fontA = fakeFont('fontA', { doc: pdfPage.doc }); + fontB = fakeFont('fontB', { doc: pdfPage.doc }); }); it('returns same font for same input', () => { diff --git a/test/render-image.test.ts b/test/render-image.test.ts index 7a63fb0..168ce1b 100644 --- a/test/render-image.test.ts +++ b/test/render-image.test.ts @@ -1,20 +1,20 @@ import { beforeEach, describe, expect, it } from '@jest/globals'; -import { PDFImage } from 'pdf-lib'; import { Size } from '../src/box.js'; +import { Image } from '../src/images.js'; import { ImageObject } from '../src/layout-image.js'; import { Page } from '../src/page.js'; import { renderImage } from '../src/render-image.js'; import { fakePdfPage, getContentStream } from './test-utils.js'; describe('render-image', () => { - let page: Page, size: Size, image: PDFImage; + let page: Page, size: Size, image: Image; beforeEach(() => { size = { width: 500, height: 800 }; const pdfPage = fakePdfPage(); page = { size, pdfPage } as Page; - image = { ref: 23 } as unknown as PDFImage; + image = { pdfRef: 23 } as unknown as Image; }); describe('renderImage', () => { diff --git a/test/render-page.test.ts b/test/render-page.test.ts index 9f0074a..61ce449 100644 --- a/test/render-page.test.ts +++ b/test/render-page.test.ts @@ -1,12 +1,12 @@ import { beforeEach, describe, expect, it, jest } from '@jest/globals'; -import { PDFArray, PDFDict, PDFFont, PDFName, PDFPage, PDFRef } from 'pdf-lib'; +import { PDFArray, PDFDict, PDFDocument, PDFName, PDFPage, PDFRef } from 'pdf-lib'; import { Size } from '../src/box.js'; -import { Document } from '../src/document.js'; +import { Font } from '../src/fonts.js'; import { Frame } from '../src/layout.js'; import { Page } from '../src/page.js'; import { renderFrame, renderPage } from '../src/render-page.js'; -import { fakePdfFont, fakePdfPage, getContentStream } from './test-utils.js'; +import { fakeFont, fakePdfPage, getContentStream } from './test-utils.js'; describe('render-page', () => { let pdfPage: PDFPage; @@ -16,11 +16,11 @@ describe('render-page', () => { }); describe('renderPage', () => { - let size: Size, doc: Document; + let size: Size, pdfDoc: PDFDocument; beforeEach(() => { size = { width: 300, height: 400 }; - doc = { pdfDoc: { addPage: jest.fn().mockReturnValue(pdfPage) } as any } as Document; + pdfDoc = { addPage: jest.fn().mockReturnValue(pdfPage) } as unknown as PDFDocument; }); it('renders content', () => { @@ -32,7 +32,7 @@ describe('render-page', () => { }; const page = { size, content }; - renderPage(page, doc); + renderPage(page, pdfDoc); expect(getContentStream(page).join()).toEqual('q,1 0 0 -1 50 350 cm,q,0 0 280 300 re,S,Q,Q'); }); @@ -47,7 +47,7 @@ describe('render-page', () => { }; const page = { size, content, header }; - renderPage(page, doc); + renderPage(page, pdfDoc); expect(getContentStream(page).join()).toEqual('q,1 0 0 -1 50 380 cm,q,0 0 280 30 re,S,Q,Q'); }); @@ -62,19 +62,19 @@ describe('render-page', () => { }; const page = { size, content, footer }; - renderPage(page, doc); + renderPage(page, pdfDoc); expect(getContentStream(page).join()).toEqual('q,1 0 0 -1 50 50 cm,q,0 0 280 30 re,S,Q,Q'); }); }); describe('renderFrame', () => { - let page: Page, size: Size, font: PDFFont; + let page: Page, size: Size, font: Font; beforeEach(() => { size = { width: 500, height: 800 }; page = { size, pdfPage } as Page; - font = fakePdfFont('Test'); + font = fakeFont('Test', { doc: pdfPage.doc }); }); it('renders text objects', () => { diff --git a/test/render-text.test.ts b/test/render-text.test.ts index 106f280..20ff56b 100644 --- a/test/render-text.test.ts +++ b/test/render-text.test.ts @@ -1,19 +1,20 @@ import { beforeEach, describe, expect, it } from '@jest/globals'; import { Size } from '../src/box.js'; +import { Font } from '../src/fonts.js'; import { TextObject } from '../src/layout.js'; import { Page } from '../src/page.js'; import { renderText } from '../src/render-text.js'; -import { fakePdfFont, fakePdfPage, getContentStream } from './test-utils.js'; +import { fakeFont, fakePdfPage, getContentStream } from './test-utils.js'; describe('render-text', () => { - let page: Page, size: Size; - const font = fakePdfFont('fontA'); + let page: Page, size: Size, font: Font; beforeEach(() => { size = { width: 500, height: 800 }; const pdfPage = fakePdfPage(); page = { size, pdfPage } as Page; + font = fakeFont('fontA', { doc: pdfPage.doc }); }); describe('renderText', () => { diff --git a/test/test-utils.ts b/test/test-utils.ts index 5d90b83..dddadde 100644 --- a/test/test-utils.ts +++ b/test/test-utils.ts @@ -1,22 +1,43 @@ -import { PDFContext, PDFFont, PDFName, PDFPage, PDFRef } from 'pdf-lib'; +import { PDFContext, PDFDocument, PDFFont, PDFName, PDFPage, PDFRef } from 'pdf-lib'; import { Font } from '../src/fonts.js'; import { Image } from '../src/images.js'; import { Page } from '../src/page.js'; -export function fakeFont(name: string, opts: { italic?: boolean; bold?: boolean } = {}): Font { - return { +export function fakeFont( + name: string, + opts: { italic?: boolean; bold?: boolean; doc?: PDFDocument } = {} +): Font { + const key = `${name}${opts?.italic ? '-italic' : ''}${opts?.bold ? '-bold' : ''}`; + const font: Font = { name, italic: opts?.italic, - bold: opts.bold, - pdfFont: fakePdfFont(`${name}${opts?.italic ? '-italic' : ''}${opts?.bold ? '-bold' : ''}`), - } as any; + bold: opts?.bold, + data: mkData(key), + fkFont: fakeFkFont(key), + }; + if (opts.doc) { + const pdfFont = fakePdfFont(name, font.fkFont); + (opts.doc as any).fonts.push(pdfFont); + font.pdfRef = pdfFont.ref; + } + return font; } export function fakeImage(name: string, width: number, height: number): Image { return { name, - pdfImage: { width, height }, + width, + height, + } as any; +} + +export function fakePdfFont(name: string, fkFont: fontkit.Font): PDFFont { + return { + name, + ref: PDFRef.of(name.split('').reduce((a, c) => a ^ c.charCodeAt(0), 0)), + embedder: { font: fkFont }, + encodeText: (text: string) => text, } as any; } @@ -26,14 +47,17 @@ export function fakeImage(name: string, width: number, height: number): Image { * a length of `10 * 5 = 50`. * Likewise, the descent is set to amount to `0.2 * fontSize`. */ -export function fakePdfFont(name: string): PDFFont { +export function fakeFkFont(name: string): fontkit.Font { return { name, - ref: PDFRef.of(name.split('').reduce((a, c) => a ^ c.charCodeAt(0), 0)), - widthOfTextAtSize: (text: string, fontSize: number) => text.length * fontSize, - heightAtSize: (fontSize: number) => fontSize, - embedder: { font: { descent: -200, unitsPerEm: 1000 } }, - encodeText: (text: string) => text, + unitsPerEm: 1000, + maxY: 800, + descent: -200, + ascent: 800, + bbox: { minY: -200, maxY: 800 }, + layout: (text: string) => ({ + glyphs: text.split('').map((c) => ({ advanceWidth: 1000, id: c.charCodeAt(0) })), + }), } as any; } @@ -47,7 +71,7 @@ export function fakePdfPage(): PDFPage { PDFName.of(`${type}-${ref}-${counter++}`); (node as any).newExtGState = (type: string) => PDFName.of(`${type}-${counter++}`); return { - doc: { context, catalog: context.obj({}) }, + doc: { context, catalog: context.obj({}), fonts: [] }, ref: PDFRef.of(1), getContentStream: () => contentStream, node, @@ -66,3 +90,7 @@ export function getContentStream(page: Page) { const contentStream = (page.pdfPage as any).getContentStream(); return contentStream.map((o: any) => o.toString()); } + +export function mkData(value: string) { + return new Uint8Array(value.split('').map((c) => c.charCodeAt(0))); +} diff --git a/test/text.test.ts b/test/text.test.ts index d239358..e77b93f 100644 --- a/test/text.test.ts +++ b/test/text.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it } from '@jest/globals'; -import { PDFFont, rgb } from 'pdf-lib'; +import { rgb } from 'pdf-lib'; import { Font } from '../src/fonts.js'; import { @@ -15,11 +15,11 @@ import { fakeFont } from './test-utils.js'; const { objectContaining } = expect; describe('text', () => { - let fonts: Font[], normalFont: PDFFont; + let fonts: Font[], normalFont: Font; beforeEach(() => { fonts = [fakeFont('Test'), fakeFont('Test', { italic: true })]; - [normalFont] = fonts.map((f) => f.pdfFont); + [normalFont] = fonts; }); describe('extractTextSegments', () => {