From 22a526b3870bf09bd64b7313090651bf4e8548a4 Mon Sep 17 00:00:00 2001 From: Ralf Sternberg Date: Sat, 14 Feb 2026 17:01:47 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=92=A5=20Replace=20pdf-lib=20with=20pdf-c?= =?UTF-8?q?ore,=20bump=20to=200.6.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This change replaces `pdf-lib` and `fontkit` with `@ralfstx/pdf-core` as the underlying PDF generation library. This results in faster PDF generation, smaller bundle size, and opens up new possibilities such as font shaping. Loading font and image data from base64-encoded strings was a feature of `pdf-lib`. Reading encoded data should better be done outside of the library, so we drop this feature and require `Uint8Array`. With `pdf-core`, we can directly use the OS/2 typographic metrics for font metrics instead of relying on the hhea table values. This is the correct approach but it results in tighter line spacing for fonts whose hhea values include extra spacing that was effectively double-counted with the `lineHeight` multiplier. Links and anchors are supported by `pdf-core`, so the custom rendering code is removed. Parsing JPEG and PNG data is also handled by `pdf-core`, so the related code is removed. Rendering functions are simplified to use the `ContentStream` API directly. The version is bumped to 0.6.0 to account for the breaking changes in the API. Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 20 ++- README.md | 7 +- eslint.config.js | 4 +- package-lock.json | 67 +++---- package.json | 7 +- src/api/PdfMaker.test.ts | 24 +-- src/api/document.ts | 14 +- src/binary-data.test.ts | 36 ++-- src/binary-data.ts | 8 +- src/colors.ts | 33 ++++ src/font-metrics.ts | 28 ++- src/font-store.test.ts | 215 +++++++++++------------ src/font-store.ts | 39 ++--- src/fonts.ts | 36 +--- src/guides.ts | 3 +- src/image-store.test.ts | 20 ++- src/image-store.ts | 36 +++- src/images.test.ts | 26 +-- src/images.ts | 32 +--- src/images/jpeg.test.ts | 34 ---- src/images/jpeg.ts | 81 --------- src/images/png.test.ts | 59 ------- src/images/png.ts | 91 ---------- src/layout/layout-rows.test.ts | 2 +- src/layout/layout-text.test.ts | 2 +- src/layout/layout-text.ts | 2 +- src/layout/layout.test.ts | 2 + src/layout/layout.ts | 10 +- src/page.test.ts | 61 ------- src/page.ts | 45 +---- src/read-color.ts | 3 +- src/read-document.ts | 2 +- src/read-graphics.test.ts | 2 +- src/render/render-annotations.ts | 57 ------ src/render/render-document.test.ts | 90 +++++----- src/render/render-document.ts | 99 ++++------- src/render/render-graphics.test.ts | 272 ++++++++++++++--------------- src/render/render-graphics.ts | 197 ++++++++++----------- src/render/render-image.test.ts | 17 +- src/render/render-image.ts | 23 +-- src/render/render-page.test.ts | 122 ++++++------- src/render/render-page.ts | 27 ++- src/render/render-text.test.ts | 147 +++++++++------- src/render/render-text.ts | 71 ++++---- src/svg-paths.test.ts | 13 +- src/svg-paths.ts | 63 ++++--- src/test/test-utils.ts | 145 ++++++++------- src/text.test.ts | 2 +- src/text.ts | 4 +- 49 files changed, 935 insertions(+), 1465 deletions(-) create mode 100644 src/colors.ts delete mode 100644 src/images/jpeg.test.ts delete mode 100644 src/images/jpeg.ts delete mode 100644 src/images/png.test.ts delete mode 100644 src/images/png.ts delete mode 100644 src/page.test.ts delete mode 100644 src/render/render-annotations.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 18e9c29..df38dea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,24 @@ # Changelog -## [0.5.7] - Unreleased +## [0.6.0] - Unreleased + +### Changed + +- Replaced `pdf-lib` with `@ralfstx/pdf-core` as the underlying PDF + generation library. This results in faster PDF generation and a + smaller bundle size. It also opens up new possibilities for new + features such as font shaping. + +### Breaking + +- Font and image data must now be provided as `Uint8Array`. + Base64-encoded strings and `ArrayBuffer`s are no longer accepted. + +- Text height is now based on the OS/2 typographic metrics + (`sTypoAscender` / `sTypoDescender`) instead of the hhea table values. + This results in tighter line spacing for fonts whose hhea values + include extra spacing that was effectively double-counted with the + `lineHeight` multiplier. ## [0.5.6] - 2025-01-19 diff --git a/README.md b/README.md index b118439..c4fe505 100644 --- a/README.md +++ b/README.md @@ -668,10 +668,9 @@ const document = { ## Thanks -This project is inspired by [pdfmake] and builds on [pdf-lib] and -[fontkit]. It would not exist without the great work and the profound -knowledge contributed by the authors of those projects. +This project is inspired by [pdfmake] and [pdf-lib]. It would not exist +without the great work and the profound knowledge contributed by the +authors of those projects. [pdfmake]: https://github.com/bpampuch/pdfmake [pdf-lib]: https://github.com/Hopding/pdf-lib -[fontkit]: https://github.com/Hopding/fontkit diff --git a/eslint.config.js b/eslint.config.js index 940a663..4ea2430 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -66,11 +66,9 @@ export default tseslint.config( '@typescript-eslint/parameter-properties': 'error', '@typescript-eslint/consistent-type-imports': ['error', { prefer: 'type-imports' }], - // TODO: revisit when we got rid of pdf-lib internals + // TODO: remove when we got rid of any in the code base '@typescript-eslint/no-unsafe-assignment': ['off'], - '@typescript-eslint/no-unsafe-call': ['off'], '@typescript-eslint/no-unsafe-member-access': ['off'], - '@typescript-eslint/no-unsafe-return': ['off'], 'simple-import-sort/imports': 'error', 'import/extensions': ['error', 'ignorePackages', { js: 'never', ts: 'always' }], diff --git a/package-lock.json b/package-lock.json index 2a5fb23..b2658ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,15 @@ { "name": "pdfmkr", - "version": "0.5.7", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pdfmkr", - "version": "0.5.7", + "version": "0.6.0", "license": "MIT", "dependencies": { - "@pdf-lib/fontkit": "^1.1.1", - "pdf-lib": "^1.17.1" + "@ralfstx/pdf-core": "^0.1.0" }, "devDependencies": { "@types/node": "^25.0.3", @@ -756,31 +755,23 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@pdf-lib/fontkit": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@pdf-lib/fontkit/-/fontkit-1.1.1.tgz", - "integrity": "sha512-KjMd7grNapIWS/Dm0gvfHEilSyAmeLvrEGVcqLGi0VYebuqqzTbgF29efCx7tvx+IEbG3zQciRSWl3GkUSvjZg==", - "license": "MIT", - "dependencies": { - "pako": "^1.0.6" - } - }, - "node_modules/@pdf-lib/standard-fonts": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz", - "integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==", - "license": "MIT", - "dependencies": { - "pako": "^1.0.6" - } + "node_modules/@ralfstx/opentype": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@ralfstx/opentype/-/opentype-0.2.0.tgz", + "integrity": "sha512-SoQLF2N5FADAGUM61EaWfz95fBd0fT/4fvjg8Zp+ZtbCUOejoY9JLybNzmjHh/KgPR2kpPaGK9Rd38qT+qn+9g==", + "license": "MIT" }, - "node_modules/@pdf-lib/upng": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz", - "integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==", + "node_modules/@ralfstx/pdf-core": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@ralfstx/pdf-core/-/pdf-core-0.1.0.tgz", + "integrity": "sha512-xvmAOI4h3OGOrlHi3E0vY13dZ6Ra78a9b7a/tBWcih5E0a0qVaceehCR246J/RU8D9DwhIxa6e5VQEMzM62FbQ==", "license": "MIT", "dependencies": { - "pako": "^1.0.10" + "@ralfstx/opentype": "^0.2.0", + "pako": "^2.1.0" + }, + "engines": { + "node": ">=18" } }, "node_modules/@rollup/rollup-android-arm-eabi": { @@ -3881,9 +3872,9 @@ } }, "node_modules/pako": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", - "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", + "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", "license": "(MIT AND Zlib)" }, "node_modules/parent-module": { @@ -3933,18 +3924,6 @@ "dev": true, "license": "MIT" }, - "node_modules/pdf-lib": { - "version": "1.17.1", - "resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz", - "integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==", - "license": "MIT", - "dependencies": { - "@pdf-lib/standard-fonts": "^1.0.0", - "@pdf-lib/upng": "^1.0.1", - "pako": "^1.0.11", - "tslib": "^1.11.1" - } - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -4594,12 +4573,6 @@ "strip-bom": "^3.0.0" } }, - "node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "license": "0BSD" - }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/package.json b/package.json index 543cc66..de7daf7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pdfmkr", - "version": "0.5.7", + "version": "0.6.0", "description": "Generate PDF documents from JavaScript objects", "license": "MIT", "repository": { @@ -25,7 +25,7 @@ "npm": ">=10" }, "scripts": { - "build": "rm -rf build/ dist/ && tsc && esbuild src/index.ts --bundle --sourcemap --platform=browser --target=es2022 --outdir=dist --format=esm --external:pdf-lib --external:@pdf-lib/fontkit && cp -a build/index.d.ts build/api/ dist/", + "build": "rm -rf build/ dist/ && tsc && esbuild src/index.ts --bundle --sourcemap --platform=browser --target=es2022 --outdir=dist --format=esm --external:@ralfstx/pdf-core && cp -a build/index.d.ts build/api/ dist/", "lint": "eslint . --max-warnings=0 && prettier --check .", "test": "vitest run test", "format": "prettier -w .", @@ -33,8 +33,7 @@ "examples": "./examples/run-all-examples.sh" }, "dependencies": { - "@pdf-lib/fontkit": "^1.1.1", - "pdf-lib": "^1.17.1" + "@ralfstx/pdf-core": "^0.1.0" }, "devDependencies": { "@types/node": "^25.0.3", diff --git a/src/api/PdfMaker.test.ts b/src/api/PdfMaker.test.ts index f2556e9..d2eb497 100644 --- a/src/api/PdfMaker.test.ts +++ b/src/api/PdfMaker.test.ts @@ -1,9 +1,8 @@ import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it } from 'vitest'; -import { image, text } from './layout.ts'; import { PdfMaker } from './PdfMaker.ts'; describe('makePdf', () => { @@ -31,25 +30,4 @@ describe('makePdf', () => { const string = Buffer.from(pdf.buffer).toString(); expect(string).toMatch(/[^\n]\n$/); }); - - it('includes a trailer ID in the document', async () => { - const pdf = await pdfMaker.makePdf({ content: [{}] }); - - const string = Buffer.from(pdf.buffer).toString(); - expect(string).toMatch(/\/ID \[ <[0-9A-F]{64}> <[0-9A-F]{64}> \]/); - }); - - it('creates consistent results across runs', async () => { - // ensure same timestamps in generated PDF - vi.useFakeTimers(); - // include fonts and images to ensure they can be reused - const content = [text('Test'), image('file:/torus.png')]; - - const pdf1 = await pdfMaker.makePdf({ content }); - const pdf2 = await pdfMaker.makePdf({ content }); - - const pdfStr1 = Buffer.from(pdf1.buffer).toString(); - const pdfStr2 = Buffer.from(pdf2.buffer).toString(); - expect(pdfStr1).toEqual(pdfStr2); - }); }); diff --git a/src/api/document.ts b/src/api/document.ts index 969811d..98fc616 100644 --- a/src/api/document.ts +++ b/src/api/document.ts @@ -238,14 +238,12 @@ export type FontsDefinition = { [name: string]: FontDefinition[] }; */ export type FontDefinition = { /** - * The font data, as a Uint8Array, ArrayBuffer, or a base64-encoded - * string. + * The font data as a Uint8Array. * - * Supports TrueType (`.ttf`), OpenType (`.otf`), WOFF, WOFF2, - * TrueType Collection (`.ttc`), and Datafork TrueType (`.dfont`) font - * files (see https://github.com/Hopding/fontkit). + * Supports TrueType font files (`.ttf`) and OpenType (`.otf`) font + * files with TrueType outlines. */ - data: string | Uint8Array | ArrayBuffer; + data: Uint8Array; /** * Whether this is a bold font. @@ -272,12 +270,12 @@ export type ImagesDefinition = { [name: string]: ImageDefinition }; */ export type ImageDefinition = { /** - * The image data, as a Uint8Array, ArrayBuffer, or a base64-encoded string. + * The image data as a Uint8Array. * Supported image formats are PNG and JPEG. * * @deprecated Use URLs to include images. */ - data: string | Uint8Array | ArrayBuffer; + data: Uint8Array; }; /** diff --git a/src/binary-data.test.ts b/src/binary-data.test.ts index ee2f0bc..9aa531e 100644 --- a/src/binary-data.test.ts +++ b/src/binary-data.test.ts @@ -1,38 +1,28 @@ import { describe, expect, it } from 'vitest'; -import { parseBinaryData } from './binary-data.ts'; +import { readBinaryData } from './binary-data.ts'; -const data = Uint8Array.of(1, 183, 0); - -describe('parseBinaryData', () => { +describe('readBinaryData', () => { it('returns original Uint8Array', () => { - expect(parseBinaryData(data)).toBe(data); - }); + const data = Uint8Array.of(1, 183, 0); - it('returns Uint8Array for ArrayBuffer', () => { - expect(parseBinaryData(data.buffer)).toEqual(data); + expect(readBinaryData(data)).toBe(data); }); - it('returns Uint8Array for base64-encoded string', () => { - expect(parseBinaryData('Abc=`')).toEqual(data); - }); + it('throws for ArrayBuffer', () => { + const buffer = Uint8Array.of(1, 183, 0).buffer; - it('returns Uint8Array for data URL', () => { - expect(parseBinaryData('data:image/jpeg;base64,Abc=`')).toEqual(data); + expect(() => readBinaryData(buffer)).toThrow( + new TypeError('Expected Uint8Array, got: ArrayBuffer [1, 183, 0]'), + ); }); - it('throws for arrays', () => { - expect(() => parseBinaryData([1, 2, 3])).toThrow( - new TypeError('Expected Uint8Array, ArrayBuffer, or base64-encoded string, got: [1, 2, 3]'), - ); + it('throws for strings', () => { + expect(() => readBinaryData('AbcA')).toThrow(new TypeError("Expected Uint8Array, got: 'AbcA'")); }); it('throws for other types', () => { - expect(() => parseBinaryData(23)).toThrow( - new TypeError('Expected Uint8Array, ArrayBuffer, or base64-encoded string, got: 23'), - ); - expect(() => parseBinaryData(null)).toThrow( - new TypeError('Expected Uint8Array, ArrayBuffer, or base64-encoded string, got: null'), - ); + expect(() => readBinaryData(23)).toThrow(new TypeError('Expected Uint8Array, got: 23')); + expect(() => readBinaryData(null)).toThrow(new TypeError('Expected Uint8Array, got: null')); }); }); diff --git a/src/binary-data.ts b/src/binary-data.ts index 19bdd8f..0af3b4b 100644 --- a/src/binary-data.ts +++ b/src/binary-data.ts @@ -1,10 +1,6 @@ -import { decodeFromBase64DataUri } from 'pdf-lib'; - import { typeError } from './types.ts'; -export function parseBinaryData(input: unknown): Uint8Array { +export function readBinaryData(input: unknown): Uint8Array { if (input instanceof Uint8Array) return input; - if (input instanceof ArrayBuffer) return new Uint8Array(input); - if (typeof input === 'string') return decodeFromBase64DataUri(input); - throw typeError('Uint8Array, ArrayBuffer, or base64-encoded string', input); + throw typeError('Uint8Array', input); } diff --git a/src/colors.ts b/src/colors.ts new file mode 100644 index 0000000..a11e4c3 --- /dev/null +++ b/src/colors.ts @@ -0,0 +1,33 @@ +import type { ContentStream } from '@ralfstx/pdf-core'; + +export type Color = { + type: 'RGB'; + red: number; + green: number; + blue: number; +}; + +export const rgb = (red: number, green: number, blue: number): Color => { + assertRange(red, 'red', 0, 1); + assertRange(green, 'green', 0, 1); + assertRange(blue, 'blue', 0, 1); + return { type: 'RGB', red, green, blue }; +}; + +export function setFillingColor(cs: ContentStream, color: Color): void { + if (color.type === 'RGB') { + cs.setFillRGB(color.red, color.green, color.blue); + } else throw new Error(`Invalid color: ${JSON.stringify(color)}`); +} + +export function setStrokingColor(cs: ContentStream, color: Color): void { + if (color.type === 'RGB') { + cs.setStrokeRGB(color.red, color.green, color.blue); + } else throw new Error(`Invalid color: ${JSON.stringify(color)}`); +} + +function assertRange(value: number, valueName: string, min: number, max: number) { + if (typeof value !== 'number' || value < min || value > max) { + throw new Error(`${valueName} must be a number between ${min} and ${max}, got: ${value}`); + } +} diff --git a/src/font-metrics.ts b/src/font-metrics.ts index a8c7115..2d15b42 100644 --- a/src/font-metrics.ts +++ b/src/font-metrics.ts @@ -1,20 +1,16 @@ -import type { Font } from '@pdf-lib/fontkit'; +import type { PDFFont } from '@ralfstx/pdf-core'; -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 getTextWidth(text: string, font: PDFFont, fontSize: number): number { + const glyphs = font.shapeText(text, { defaultFeatures: false }); + return glyphs.reduce( + (sum, glyph) => sum + (glyph.advance + (glyph.advanceAdjust ?? 0)) * (fontSize / 1000), + 0, + ); } -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; +export function getTextHeight(font: PDFFont, fontSize: number): number { + const ascent = font.ascent; + const descent = font.descent; + const height = ascent - descent; + return (height * fontSize) / 1000; } diff --git a/src/font-store.test.ts b/src/font-store.test.ts index a4ec0e4..0f29846 100644 --- a/src/font-store.test.ts +++ b/src/font-store.test.ts @@ -1,13 +1,43 @@ import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; -import fontkit from '@pdf-lib/fontkit'; +import type { PDFEmbeddedFont } from '@ralfstx/pdf-core'; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { FontStore } from './font-store.ts'; import type { FontDef } from './fonts.ts'; import { mkData } from './test/test-utils.ts'; +vi.mock('@ralfstx/pdf-core', async (importOriginal) => { + // eslint-disable-next-line @typescript-eslint/consistent-type-imports + const original = await importOriginal(); + return { + ...original, + PDFEmbeddedFont: class MockPDFEmbeddedFont extends original.PDFEmbeddedFont { + constructor(data: Uint8Array) { + // For fake test data (small buffers), return mock values + // For real font data (larger buffers), use the real implementation + if (data.length < 100) { + // Reverse of mkData (new Uint8Array(value.split('').map((c) => c.charCodeAt(0)))) + const key = String.fromCharCode(...data); + + // Skip the parent constructor for fake data by using a proxy approach + return { + fontName: 'MockFont:' + key, + familyName: 'MockFamily', + style: 'normal', + weight: 400, + ascent: 800, + descent: -200, + lineGap: 0, + } as PDFEmbeddedFont; + } + super(data); + } + }, + }; +}); + describe('FontStore', () => { let normalFont: FontDef; let italicFont: FontDef; @@ -17,40 +47,52 @@ describe('FontStore', () => { let obliqueBoldFont: FontDef; let otherFont: FontDef; - beforeEach(() => { - normalFont = fakeFontDef('Test'); - italicFont = fakeFontDef('Test', { style: 'italic' }); - obliqueFont = fakeFontDef('Test', { style: 'oblique' }); - boldFont = fakeFontDef('Test', { weight: 700 }); - italicBoldFont = fakeFontDef('Test', { style: 'italic', weight: 700 }); - obliqueBoldFont = fakeFontDef('Test', { style: 'oblique', weight: 700 }); - otherFont = fakeFontDef('Other'); - }); + describe('registerFont', () => { + let robotoRegular: Uint8Array; + let robotoLightItalic: Uint8Array; - describe('selectFont', () => { - let store: FontStore; + beforeAll(async () => { + robotoRegular = await readFile( + join(__dirname, 'test/resources/fonts/roboto/Roboto-Regular.ttf'), + ); + robotoLightItalic = await readFile( + join(__dirname, 'test/resources/fonts/roboto/Roboto-LightItalic.ttf'), + ); + }); - beforeEach(() => { - store = new FontStore([ - normalFont, - italicFont, - obliqueFont, - boldFont, - italicBoldFont, - obliqueBoldFont, - otherFont, - ]); - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - vi.spyOn(fontkit, 'create').mockReturnValue({ fake: true } as any); + it('registers font with extracted config', async () => { + const store = new FontStore(); + store.registerFont(robotoRegular); + store.registerFont(robotoLightItalic); + + const selected1 = await store.selectFont({ fontFamily: 'Roboto' }); + const selected2 = await store.selectFont({ fontFamily: 'Roboto Light', fontStyle: 'italic' }); + + expect(selected1.pdfFont.fontName).toBe('Roboto'); + expect(selected2.pdfFont.fontName).toBe('Roboto Light Italic'); }); - afterEach(() => { - vi.restoreAllMocks(); + it('registers font with custom config', async () => { + const store = new FontStore(); + store.registerFont(robotoRegular, { family: 'Custom Name', weight: 'bold' }); + store.registerFont(robotoLightItalic, { + family: 'Custom Name', + weight: 400, + style: 'normal', + }); + + const selected1 = await store.selectFont({ fontFamily: 'Custom Name' }); + const selected2 = await store.selectFont({ fontFamily: 'Custom Name', fontWeight: 'bold' }); + + expect(selected1.pdfFont.fontName).toBe('Roboto Light Italic'); + expect(selected2.pdfFont.fontName).toBe('Roboto'); }); + }); + + describe('selectFont', () => { + let store: FontStore; beforeEach(() => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - vi.spyOn(fontkit, 'create').mockReturnValue({ fake: true } as any); normalFont = fakeFontDef('Test'); italicFont = fakeFontDef('Test', { style: 'italic' }); obliqueFont = fakeFontDef('Test', { style: 'oblique' }); @@ -107,38 +149,34 @@ describe('FontStore', () => { it('selects different font variants', async () => { const fontFamily = 'Test'; - await expect(store.selectFont({ fontFamily })).resolves.toEqual( - expect.objectContaining({ data: normalFont.data }), - ); - await expect(store.selectFont({ fontFamily, fontWeight: 'bold' })).resolves.toEqual( - expect.objectContaining({ data: boldFont.data }), - ); - await expect(store.selectFont({ fontFamily, fontStyle: 'italic' })).resolves.toEqual( - expect.objectContaining({ data: italicFont.data }), - ); - await expect( - store.selectFont({ fontFamily, fontStyle: 'italic', fontWeight: 'bold' }), - ).resolves.toEqual(expect.objectContaining({ data: italicBoldFont.data })); + const font1 = await store.selectFont({ fontFamily }); + const font2 = await store.selectFont({ fontFamily, fontWeight: 'bold' }); + const font3 = await store.selectFont({ fontFamily, fontStyle: 'italic' }); + const font4 = await store.selectFont({ fontFamily, fontStyle: 'italic', fontWeight: 'bold' }); + + expect(font1.pdfFont.fontName).toBe('MockFont:Test:normal:400'); + expect(font2.pdfFont.fontName).toBe('MockFont:Test:normal:700'); + expect(font3.pdfFont.fontName).toBe('MockFont:Test:italic:400'); + expect(font4.pdfFont.fontName).toBe('MockFont:Test:italic:700'); }); it('selects first matching font if no family specified', async () => { - await expect(store.selectFont({})).resolves.toEqual( - expect.objectContaining({ data: normalFont.data }), - ); - await expect(store.selectFont({ fontWeight: 'bold' })).resolves.toEqual( - expect.objectContaining({ data: boldFont.data }), - ); - await expect(store.selectFont({ fontStyle: 'italic' })).resolves.toEqual( - expect.objectContaining({ data: italicFont.data }), - ); - await expect(store.selectFont({ fontStyle: 'italic', fontWeight: 'bold' })).resolves.toEqual( - expect.objectContaining({ data: italicBoldFont.data }), - ); + const font1 = await store.selectFont({}); + expect(font1.pdfFont.fontName).toBe('MockFont:Test:normal:400'); + + const font2 = await store.selectFont({ fontWeight: 'bold' }); + expect(font2.pdfFont.fontName).toBe('MockFont:Test:normal:700'); + + const font3 = await store.selectFont({ fontStyle: 'italic' }); + expect(font3.pdfFont.fontName).toBe('MockFont:Test:italic:400'); + + const font4 = await store.selectFont({ fontStyle: 'italic', fontWeight: 'bold' }); + expect(font4.pdfFont.fontName).toBe('MockFont:Test:italic:700'); }); it('selects font with matching font family', async () => { await expect(store.selectFont({ fontFamily: 'Other' })).resolves.toEqual( - expect.objectContaining({ data: otherFont.data }), + expect.objectContaining({ name: 'MockFont:Other:normal:400' }), ); }); @@ -146,24 +184,26 @@ describe('FontStore', () => { store = new FontStore([normalFont, obliqueFont, boldFont, obliqueBoldFont]); await expect(store.selectFont({ fontFamily: 'Test', fontStyle: 'italic' })).resolves.toEqual( - expect.objectContaining({ data: obliqueFont.data }), + expect.objectContaining({ name: 'MockFont:Test:oblique:400' }), ); }); it('falls back to italic when no oblique font can be found', async () => { store = new FontStore([normalFont, italicFont, boldFont, italicBoldFont]); - await expect(store.selectFont({ fontFamily: 'Test', fontStyle: 'italic' })).resolves.toEqual( - expect.objectContaining({ data: italicFont.data }), + const font = await store.selectFont({ fontFamily: 'Test', fontStyle: 'italic' }); + + expect(font.pdfFont).toEqual( + expect.objectContaining({ fontName: 'MockFont:Test:italic:400' }), ); }); it('falls back when no matching font weight can be found', async () => { await expect(store.selectFont({ fontFamily: 'Other', fontWeight: 'bold' })).resolves.toEqual( - expect.objectContaining({ data: otherFont.data }), + expect.objectContaining({ name: 'MockFont:Other:normal:400' }), ); await expect(store.selectFont({ fontFamily: 'Other', fontWeight: 200 })).resolves.toEqual( - expect.objectContaining({ data: otherFont.data }), + expect.objectContaining({ name: 'MockFont:Other:normal:400' }), ); }); @@ -177,19 +217,6 @@ describe('FontStore', () => { ); }); - it('creates fontkit font object', async () => { - const font = await store.selectFont({ fontFamily: 'Test' }); - - expect(font).toEqual({ - key: 'Test:normal:normal', - name: 'Test', - style: 'normal', - weight: 400, - data: normalFont.data, - fkFont: { fake: true }, - }); - }); - it('returns same font object for concurrent calls', async () => { const [font1, font2] = await Promise.all([ store.selectFont({ fontFamily: 'Test' }), @@ -199,55 +226,11 @@ describe('FontStore', () => { expect(font1).toBe(font2); }); }); - - describe('registerFont', () => { - let store: FontStore; - let robotoRegular: Uint8Array; - let robotoLightItalic: Uint8Array; - - beforeAll(async () => { - robotoRegular = await readFile( - join(__dirname, 'test/resources/fonts/roboto/Roboto-Regular.ttf'), - ); - robotoLightItalic = await readFile( - join(__dirname, 'test/resources/fonts/roboto/Roboto-LightItalic.ttf'), - ); - }); - - it('registers font with extracted config', async () => { - store = new FontStore(); - - store.registerFont(robotoRegular); - store.registerFont(robotoLightItalic); - - const selected1 = await store.selectFont({ fontFamily: 'Roboto' }); - const selected2 = await store.selectFont({ fontFamily: 'Roboto Light', fontStyle: 'italic' }); - expect(selected1.data).toBe(robotoRegular); - expect(selected2.data).toBe(robotoLightItalic); - }); - - it('registers font with custom config', async () => { - store = new FontStore(); - - store.registerFont(robotoRegular, { family: 'Custom Name', weight: 'bold' }); - store.registerFont(robotoLightItalic, { - family: 'Custom Name', - weight: 400, - style: 'normal', - }); - - const selected1 = await store.selectFont({ fontFamily: 'Custom Name' }); - const selected2 = await store.selectFont({ fontFamily: 'Custom Name', fontWeight: 'bold' }); - - expect(selected1.data).toBe(robotoLightItalic); - expect(selected2.data).toBe(robotoRegular); - }); - }); }); function fakeFontDef(family: string, options?: Partial): FontDef { const style = options?.style ?? 'normal'; const weight = options?.weight ?? 400; - const data = options?.data ?? mkData([family, style, weight].join('_')); + const data = options?.data ?? mkData([family, style, weight].join(':')); return { family, style, weight, data }; } diff --git a/src/font-store.ts b/src/font-store.ts index e1d00c0..31123da 100644 --- a/src/font-store.ts +++ b/src/font-store.ts @@ -1,8 +1,7 @@ -import fontkit from '@pdf-lib/fontkit'; +import { PDFEmbeddedFont } from '@ralfstx/pdf-core'; import type { FontConfig } from './api/PdfMaker.ts'; -import type { FontStyle, FontWeight } from './api/text.ts'; -import { parseBinaryData } from './binary-data.ts'; +import type { FontWeight } from './api/text.ts'; import type { Font, FontDef, FontSelector } from './fonts.ts'; import { weightToNumber } from './fonts.ts'; import { pickDefined } from './types.ts'; @@ -16,12 +15,12 @@ export class FontStore { } registerFont(data: Uint8Array, config?: FontConfig): void { - const fkFont = fontkit.create(data); - const family = config?.family ?? fkFont.familyName ?? 'Unknown'; - const style = config?.style ?? extractStyle(fkFont); - const weight = weightToNumber(config?.weight ?? extractWeight(fkFont)); - this.#fontDefs.push({ family, style, weight, data, fkFont }); - this.#fontCache = {}; + const pdfFont = new PDFEmbeddedFont(data); + const family = config?.family ?? pdfFont.familyName; + const style = config?.style ?? pdfFont.style; + const weight = weightToNumber(config?.weight ?? pdfFont.weight); + this.#fontDefs.push({ family, style, weight, data }); + this.#fontCache = {}; // Invalidate cache } async selectFont(selector: FontSelector): Promise { @@ -40,23 +39,21 @@ export class FontStore { } _loadFont(selector: FontSelector, key: string): Promise { - const selectedFont = selectFont(this.#fontDefs, selector); - const data = parseBinaryData(selectedFont.data); - const fkFont = selectedFont.fkFont ?? fontkit.create(data); + const selectedFontDef = selectFontDef(this.#fontDefs, selector); + const pdfFont = new PDFEmbeddedFont(selectedFontDef.data); return Promise.resolve( pickDefined({ key, - name: fkFont.fullName ?? fkFont.postscriptName ?? selectedFont.family, - data, + name: pdfFont.fontName ?? selectedFontDef.family, // TODO ?? pdfFont.postscriptName style: selector.fontStyle ?? 'normal', weight: weightToNumber(selector.fontWeight ?? 400), - fkFont, + pdfFont, }), ); } } -function selectFont(fontDefs: FontDef[], selector: FontSelector): FontDef { +function selectFontDef(fontDefs: FontDef[], selector: FontSelector): FontDef { if (!fontDefs.length) { throw new Error('No fonts defined'); } @@ -124,13 +121,3 @@ function selectFontForWeight(fonts: FontDef[], weight: FontWeight): FontDef | un } throw new Error(`Could not find font for weight ${weight}`); } - -function extractStyle(font: fontkit.Font): FontStyle { - if (font.italicAngle === 0) return 'normal'; - if ((font.fullName ?? font.postscriptName)?.toLowerCase().includes('oblique')) return 'oblique'; - return 'italic'; -} - -function extractWeight(font: fontkit.Font): number { - return (font['OS/2'] as any)?.usWeightClass ?? 400; -} diff --git a/src/fonts.ts b/src/fonts.ts index c7ed859..a2c6f55 100644 --- a/src/fonts.ts +++ b/src/fonts.ts @@ -1,9 +1,7 @@ -import type fontkit from '@pdf-lib/fontkit'; -import type { PDFDocument, PDFRef } from 'pdf-lib'; -import { CustomFontSubsetEmbedder, PDFFont } from 'pdf-lib'; +import type { PDFFont } from '@ralfstx/pdf-core'; import type { FontStyle, FontWeight } from './api/text.ts'; -import { parseBinaryData } from './binary-data.ts'; +import { readBinaryData } from './binary-data.ts'; import { printValue } from './print-value.ts'; import { optional, readAs, readBoolean, readObject, required, types } from './types.ts'; @@ -14,8 +12,7 @@ export type FontDef = { family: string; style: FontStyle; weight: number; - data: string | Uint8Array | ArrayBuffer; - fkFont?: fontkit.Font; + data: Uint8Array; }; export type Font = { @@ -23,8 +20,7 @@ export type Font = { name: string; style: FontStyle; weight: number; - data: Uint8Array; - fkFont: fontkit.Font; + pdfFont: PDFFont; }; export type FontSelector = { @@ -45,7 +41,7 @@ export function readFont(input: unknown): Partial { const obj = readObject(input, { italic: optional((value) => readBoolean(value) || undefined), bold: optional((value) => readBoolean(value) || undefined), - data: required(parseBinaryData), + data: required(readBinaryData), }); return { style: obj.italic ? 'italic' : 'normal', @@ -54,28 +50,6 @@ export function readFont(input: unknown): Partial { } as FontDef; } -export function registerFont(font: Font, pdfDoc: PDFDocument): PDFRef { - // eslint-disable-next-line no-multi-assign - const registeredFonts = ((pdfDoc as any)._pdfmkr_registeredFonts ??= {}); - if (font.key in registeredFonts) return registeredFonts[font.key]; - const ref = pdfDoc.context.nextRef(); - const embedder = new (CustomFontSubsetEmbedder as any)(font.fkFont, font.data); - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - const pdfFont = PDFFont.of(ref, pdfDoc, embedder); - (pdfDoc as any).fonts.push(pdfFont); - registeredFonts[font.key] = ref; - return ref; -} - -export function findRegisteredFont(font: Font, pdfDoc: PDFDocument): PDFFont | undefined { - // eslint-disable-next-line no-multi-assign - const registeredFonts = ((pdfDoc as any)._pdfmkr_registeredFonts ??= {}); - const ref = registeredFonts[font.key]; - if (ref) { - return (pdfDoc as any).fonts?.find((font: PDFFont) => font.ref === ref); - } -} - export function weightToNumber(weight: FontWeight): number { if (weight === 'normal') { return 400; diff --git a/src/guides.ts b/src/guides.ts index 966e2c2..4f8eeca 100644 --- a/src/guides.ts +++ b/src/guides.ts @@ -1,6 +1,5 @@ -import { rgb } from 'pdf-lib'; - import { ZERO_EDGES } from './box.ts'; +import { rgb } from './colors.ts'; import type { CircleObject, Frame, diff --git a/src/image-store.test.ts b/src/image-store.test.ts index ef68253..8878f7c 100644 --- a/src/image-store.test.ts +++ b/src/image-store.test.ts @@ -48,8 +48,8 @@ describe('ImageStore', () => { const torus = await store.selectImage('torus'); const liberty = await store.selectImage('liberty'); - expect(torus).toEqual(expect.objectContaining({ url: 'torus', data: torusPng })); - expect(liberty).toEqual(expect.objectContaining({ url: 'liberty', data: libertyJpg })); + expect(torus).toEqual(expect.objectContaining({ url: 'torus', format: 'png' })); + expect(liberty).toEqual(expect.objectContaining({ url: 'liberty', format: 'jpeg' })); }); it('loads image from file system (deprecated)', async () => { @@ -57,7 +57,7 @@ describe('ImageStore', () => { const image = await store.selectImage(torusPath); - expect(image).toEqual(expect.objectContaining({ url: torusPath, data: torusPng })); + expect(image).toEqual(expect.objectContaining({ url: torusPath, format: 'png' })); }); it('loads image from file URL', async () => { @@ -65,7 +65,7 @@ describe('ImageStore', () => { const image = await store.selectImage(fileUrl); - expect(image).toEqual(expect.objectContaining({ url: fileUrl, data: torusPng })); + expect(image).toEqual(expect.objectContaining({ url: fileUrl, format: 'png' })); }); it('loads image from data URL', async () => { @@ -73,7 +73,7 @@ describe('ImageStore', () => { const image = await store.selectImage(dataUrl); - expect(image).toEqual(expect.objectContaining({ url: dataUrl, data: torusPng })); + expect(image).toEqual(expect.objectContaining({ url: dataUrl, format: 'png' })); }); it('loads image from http URL', async () => { @@ -81,7 +81,7 @@ describe('ImageStore', () => { const image = await store.selectImage(httpUrl); - expect(image).toEqual(expect.objectContaining({ url: httpUrl, data: torusPng })); + expect(image).toEqual(expect.objectContaining({ url: httpUrl, format: 'png' })); }); it('reads format, width and height from JPEG image', async () => { @@ -101,11 +101,13 @@ describe('ImageStore', () => { }); it('loads image only once for one URL', async () => { - const torusUrl = 'file:/test/resources/torus.png'; + const store = new ImageStore(); + store.setResourceRoot(baseDir); + const url = 'file:/test/resources/liberty.jpg'; - await Promise.all([store.selectImage(torusUrl), store.selectImage(torusUrl)]); + const [image1, image2] = await Promise.all([store.selectImage(url), store.selectImage(url)]); - expect(globalThis.fetch).toHaveBeenCalledTimes(1); + expect(image1).toBe(image2); }); it('returns same image object for concurrent calls', async () => { diff --git a/src/image-store.ts b/src/image-store.ts index 87b10d5..e7bc237 100644 --- a/src/image-store.ts +++ b/src/image-store.ts @@ -1,9 +1,8 @@ -import { parseBinaryData } from './binary-data.ts'; +import { PDFImage } from '@ralfstx/pdf-core'; + import { createDataLoader, type DataLoader } from './data-loader.ts'; import { readRelativeFile } from './fs.ts'; import type { Image, ImageDef, ImageFormat } from './images.ts'; -import { isJpeg, readJpegInfo } from './images/jpeg.ts'; -import { isPng, readPngInfo } from './images/png.ts'; export class ImageStore { readonly #images: ImageDef[]; @@ -26,14 +25,23 @@ export class ImageStore { async loadImage(url: string): Promise { const data = await this.loadImageData(url); const format = determineImageFormat(data); - const { width, height } = format === 'png' ? readPngInfo(data) : readJpegInfo(data); - return { url, format, data, width, height }; + if (format === 'jpeg') { + const pdfImage = PDFImage.fromJpeg(data); + const { width, height } = pdfImage; + return { url, format, width, height, pdfImage }; + } + if (format === 'png') { + const pdfImage = PDFImage.fromPng(data); + const { width, height } = pdfImage; + return { url, format, width, height, pdfImage }; + } + throw new Error(`Unsupported image format: ${format}`); } async loadImageData(url: string): Promise { const imageDef = this.#images.find((image) => image.name === url); if (imageDef) { - return parseBinaryData(imageDef.data); + return imageDef.data; } const urlSchema = /^(\w+):/.exec(url)?.[1]; @@ -58,3 +66,19 @@ function determineImageFormat(data: Uint8Array): ImageFormat { if (isJpeg(data)) return 'jpeg'; throw new Error('Unknown image format'); } + +function isJpeg(data: Uint8Array): boolean { + return data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff; +} + +function isPng(data: Uint8Array): boolean { + // check PNG signature + return hasBytes(data, 0, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); +} + +function hasBytes(data: Uint8Array, offset: number, bytes: number[]) { + for (let i = 0; i < bytes.length; i++) { + if (data[offset + i] !== bytes[i]) return false; + } + return true; +} diff --git a/src/images.test.ts b/src/images.test.ts index 83d00cb..2edd27c 100644 --- a/src/images.test.ts +++ b/src/images.test.ts @@ -1,11 +1,7 @@ -import { readFile } from 'node:fs/promises'; -import { join } from 'node:path'; - import { describe, expect, it } from 'vitest'; -import type { Image } from './images.ts'; -import { readImages, registerImage } from './images.ts'; -import { fakePDFDocument, mkData } from './test/test-utils.ts'; +import { readImages } from './images.ts'; +import { mkData } from './test/test-utils.ts'; describe('readImages', () => { it('returns an empty array for missing images definition', () => { @@ -45,22 +41,6 @@ describe('readImages', () => { it('throws on invalid image data', () => { const fn = () => readImages({ foo: { data: 23 } }); - expect(fn).toThrow( - new TypeError( - 'Invalid value for "foo/data": Expected Uint8Array, ArrayBuffer, or base64-encoded string, got: 23', - ), - ); - }); -}); - -describe('registerImage', () => { - it('embeds image in PDF document and attaches ref', async () => { - const doc = fakePDFDocument(); - const data = await readFile(join(__dirname, './test/resources/liberty.jpg')); - const image: Image = { url: 'foo', format: 'jpeg', data, width: 100, height: 200 }; - - const pdfRef = registerImage(image, doc); - - expect(pdfRef.toString()).toEqual('1 0 R'); + expect(fn).toThrow(new TypeError('Invalid value for "foo/data": Expected Uint8Array, got: 23')); }); }); diff --git a/src/images.ts b/src/images.ts index 270d479..599a39a 100644 --- a/src/images.ts +++ b/src/images.ts @@ -1,7 +1,6 @@ -import type { PDFDocument, PDFRef } from 'pdf-lib'; -import { JpegEmbedder, PngEmbedder } from 'pdf-lib'; +import type { PDFImage } from '@ralfstx/pdf-core'; -import { parseBinaryData } from './binary-data.ts'; +import { readBinaryData } from './binary-data.ts'; import { optional, readAs, readObject, required, types } from './types.ts'; const imageFormats = ['jpeg', 'png']; @@ -9,7 +8,7 @@ export type ImageFormat = (typeof imageFormats)[number]; export type ImageDef = { name: string; - data: string | Uint8Array | ArrayBuffer; + data: Uint8Array; format: ImageFormat; }; @@ -17,8 +16,8 @@ export type Image = { url: string; width: number; height: number; - data: Uint8Array; format: ImageFormat; + pdfImage: PDFImage; }; export function readImages(input: unknown): ImageDef[] { @@ -30,28 +29,7 @@ export function readImages(input: unknown): ImageDef[] { function readImage(input: unknown) { return readObject(input, { - data: required(parseBinaryData), + data: required(readBinaryData), format: optional(types.string({ enum: imageFormats })), }) as { data: Uint8Array; format?: ImageFormat }; } - -export function registerImage(image: Image, pdfDoc: PDFDocument): PDFRef { - // eslint-disable-next-line no-multi-assign - const registeredImages = ((pdfDoc as any)._pdfmkr_registeredImages ??= {}); - if (image.url in registeredImages) return registeredImages[image.url]; - const ref = pdfDoc.context.nextRef(); - (pdfDoc as any).images.push({ - async embed() { - try { - const embedder = await (image.format === 'png' - ? PngEmbedder.for(image.data) - : JpegEmbedder.for(image.data)); - await embedder.embedIntoContext(pdfDoc.context, ref); - } catch (error) { - throw new Error(`Could not embed image "${image.url}"`, { cause: error }); - } - }, - }); - registeredImages[image.url] = ref; - return ref; -} diff --git a/src/images/jpeg.test.ts b/src/images/jpeg.test.ts deleted file mode 100644 index bd69cef..0000000 --- a/src/images/jpeg.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { readFile } from 'node:fs/promises'; -import { join } from 'node:path'; - -import { describe, expect, it } from 'vitest'; - -import { isJpeg, readJpegInfo } from './jpeg.ts'; - -describe('isJpeg', () => { - it('returns true for JPEG header', () => { - const data = new Uint8Array([0xff, 0xd8, 0xff]); - - expect(isJpeg(data)).toBe(true); - }); - - it('returns false for other data', () => { - expect(isJpeg(new Uint8Array())).toBe(false); - expect(isJpeg(new Uint8Array([1, 2, 3]))).toBe(false); - }); -}); - -describe('readJpegInfo', () => { - it('returns info', async () => { - const libertyJpg = await readFile(join(__dirname, '../test/resources/liberty.jpg')); - - const info = readJpegInfo(libertyJpg); - - expect(info).toEqual({ - width: 160, - height: 240, - bitDepth: 8, - colorSpace: 'rgb', - }); - }); -}); diff --git a/src/images/jpeg.ts b/src/images/jpeg.ts deleted file mode 100644 index 646cb0a..0000000 --- a/src/images/jpeg.ts +++ /dev/null @@ -1,81 +0,0 @@ -export type JpegInfo = { - /** - * Image width in pixel. - */ - width: number; - /** - * Image height in pixel. - */ - height: number; - /** - * Bit depth per channel. - */ - bitDepth: number; - /** - * Color space. - */ - colorSpace: 'grayscale' | 'rgb' | 'cmyk'; -}; - -/** - * Determines if the given data is the beginning of a JPEG file. - */ -export function isJpeg(data: Uint8Array) { - return data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff; -} - -/** - * Analyzes JPEG data and returns info on the file. - */ -export function readJpegInfo(data: Uint8Array): JpegInfo { - if (!isJpeg(data)) { - throw new Error('Invalid JPEG data'); - } - let pos = 0; - const len = data.length; - let info: JpegInfo | undefined; - while (pos < len - 1) { - if (data[pos++] !== 0xff) { - continue; - } - const type = data[pos++]; - if (type === 0x00) { - // padding byte - continue; - } - if (type >= 0xd0 && type <= 0xd9) { - // these types have no body - continue; - } - const length = readUint16BE(data, pos); - pos += 2; - - // Frame header types: 0xc0 .. 0xcf except 0xc4, 0xc8, 0xcc - if (type >= 0xc0 && type <= 0xcf && type !== 0xc4 && type !== 0xc8 && type !== 0xcc) { - const bitDepth = data[pos]; - const height = readUint16BE(data, pos + 1); - const width = readUint16BE(data, pos + 3); - const colorSpace = getColorSpace(data[pos + 5]); - info = { width, height, bitDepth, colorSpace }; - } - - pos += length - 2; - } - - if (!info) { - throw new Error('Invalid JPEG data'); - } - - return info; -} - -function getColorSpace(colorSpace: number): 'rgb' | 'grayscale' | 'cmyk' { - if (colorSpace === 1) return 'grayscale'; - if (colorSpace === 3) return 'rgb'; - if (colorSpace === 4) return 'cmyk'; // Adobe extension - throw new Error('Invalid color space'); -} - -function readUint16BE(buffer: Uint8Array, offset: number) { - return (buffer[offset] << 8) | buffer[offset + 1]; -} diff --git a/src/images/png.test.ts b/src/images/png.test.ts deleted file mode 100644 index 04acf16..0000000 --- a/src/images/png.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { readFile } from 'node:fs/promises'; -import { join } from 'node:path'; - -import { describe, expect, it } from 'vitest'; - -import { isPng, readPngInfo } from './png.ts'; - -describe('isPng', () => { - it('returns true if PNG header found', () => { - const info = isPng(new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])); - - expect(info).toBe(true); - }); - - it('returns false for other data', () => { - expect(isPng(new Uint8Array())).toBe(false); - expect(isPng(new Uint8Array([1, 2, 3, 4, 5]))).toBe(false); - }); -}); - -describe('readPngInfo', () => { - it('returns info', async () => { - const torusPng = await readFile(join(__dirname, '../test/resources/torus.png')); - - const info = readPngInfo(torusPng); - - expect(info).toEqual({ - width: 256, - height: 192, - bitDepth: 8, - colorSpace: 'rgb', - hasAlpha: true, - isIndexed: false, - isInterlaced: false, - }); - }); - - it('throws if PNG signature is missing', () => { - const data = new Uint8Array(40); - data.set([0x49, 0x48, 0x44, 0x52], 12); // IHDR chunk - - expect(() => readPngInfo(data)).toThrow(new Error('Invalid PNG data')); - }); - - it('throws if IHDR chunk is missing', () => { - const data = new Uint8Array(40); - data.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); // PNG signature - - expect(() => readPngInfo(data)).toThrow(new Error('Invalid PNG data')); - }); - - it('throws if too short', () => { - const data = new Uint8Array(32); - data.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); // PNG signature - data.set([0x49, 0x48, 0x44, 0x52], 12); // IHDR chunk - - expect(() => readPngInfo(data)).toThrow(new Error('Invalid PNG data')); - }); -}); diff --git a/src/images/png.ts b/src/images/png.ts deleted file mode 100644 index 1c422fe..0000000 --- a/src/images/png.ts +++ /dev/null @@ -1,91 +0,0 @@ -export type PngInfo = { - /** - * Image width in pixel. - */ - width: number; - /** - * Image height in pixel. - */ - height: number; - /** - * Bit depth per channel. - */ - bitDepth: number; - /** - * Color space. - */ - colorSpace: 'grayscale' | 'rgb'; - /** - * True if the image has an alpha channel. - */ - hasAlpha: boolean; - /** - * True if the image has indexed colors. - */ - isIndexed: boolean; - /** - * True if the image is interlaced. - */ - isInterlaced: boolean; -}; - -export function isPng(data: Uint8Array) { - // check PNG signature - return hasBytes(data, 0, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); -} - -/** - * Analyzes PNG data. Requires only the first 32 bytes of the file. - * @param data PNG data - * @returns PNG info - */ -export function readPngInfo(data: Uint8Array): PngInfo { - if (!isPng(data)) { - throw new Error('Invalid PNG data'); - } - // read IHDR chunk - if (data[12] !== 0x49 || data[13] !== 0x48 || data[14] !== 0x44 || data[15] !== 0x52) { - throw new Error('Invalid PNG data'); - } - if (data.length < 33) { - throw new Error('Invalid PNG data'); - } - const width = readUint32BE(data, 16); - const height = readUint32BE(data, 20); - const bitDepth = data[24]; - const colorType = data[25]; - const interlacing = data[28]; - return { - width, - height, - bitDepth, - colorSpace: getColorSpace(colorType), - hasAlpha: colorType === 4 || colorType === 6, - isIndexed: colorType === 3, - isInterlaced: interlacing === 1, - }; -} - -// 0: grayscale -// 2: RGB -// 3: RGB indexed -// 4: grayscale with alpha channel -// 6: RGB with alpha channel -function getColorSpace(value: number): 'rgb' | 'grayscale' { - if (value === 0 || value === 4) return 'grayscale'; - if (value === 2 || value === 3 || value === 6) return 'rgb'; - throw new Error('Invalid color space'); -} - -function readUint32BE(data: Uint8Array, offset: number) { - return ( - (data[offset] << 24) | (data[offset + 1] << 16) | (data[offset + 2] << 8) | data[offset + 3] - ); -} - -function hasBytes(data: Uint8Array, offset: number, bytes: number[]) { - for (let i = 0; i < bytes.length; i++) { - if (data[offset + i] !== bytes[i]) return false; - } - return true; -} diff --git a/src/layout/layout-rows.test.ts b/src/layout/layout-rows.test.ts index c339803..5a34bed 100644 --- a/src/layout/layout-rows.test.ts +++ b/src/layout/layout-rows.test.ts @@ -119,7 +119,7 @@ describe('layout-rows', () => { ); }; const renderedIds = (frame?: Pick) => - frame?.children?.map((c) => (c.objects?.find((o) => o.type === 'anchor') as any)?.name); + frame?.children?.map((c) => c.objects?.find((o) => o.type === 'anchor')?.name); const box = { x: 20, y: 30, width: 400, height: 700 }; it('creates page break after last fitting block', async () => { diff --git a/src/layout/layout-text.test.ts b/src/layout/layout-text.test.ts index bddefd3..18a07bb 100644 --- a/src/layout/layout-text.test.ts +++ b/src/layout/layout-text.test.ts @@ -1,7 +1,7 @@ -import { rgb } from 'pdf-lib'; import { beforeEach, describe, expect, it } from 'vitest'; import type { Box } from '../box.ts'; +import { rgb } from '../colors.ts'; import { FontStore } from '../font-store.ts'; import type { Font, FontSelector } from '../fonts.ts'; import type { MakerCtx } from '../maker-ctx.ts'; diff --git a/src/layout/layout-text.ts b/src/layout/layout-text.ts index ef5034e..310e2b5 100644 --- a/src/layout/layout-text.ts +++ b/src/layout/layout-text.ts @@ -137,7 +137,7 @@ function layoutTextRow(segments: TextSegment[], box: Box) { } function getDescent(font: Font, fontSize: number) { - return Math.abs(((font.fkFont.descent ?? 0) * fontSize) / font.fkFont.unitsPerEm); + return Math.abs((font.pdfFont.descent * fontSize) / 1000); } /** diff --git a/src/layout/layout.test.ts b/src/layout/layout.test.ts index dee8222..5921d26 100644 --- a/src/layout/layout.test.ts +++ b/src/layout/layout.test.ts @@ -71,6 +71,7 @@ describe('layout', () => { { size: { width: 595.28, height: 841.89 }, content: expect.objectContaining({ children: [] }), + pdfPage: expect.anything(), }, ]); }); @@ -84,6 +85,7 @@ describe('layout', () => { { size: { width: 595.28, height: 841.89 }, content: expect.objectContaining({ children: [expect.anything()] }), + pdfPage: expect.anything(), }, ]); }); diff --git a/src/layout/layout.ts b/src/layout/layout.ts index a5f76c8..84a89e9 100644 --- a/src/layout/layout.ts +++ b/src/layout/layout.ts @@ -1,3 +1,5 @@ +import { PDFPage } from '@ralfstx/pdf-core'; + import { paperSizes } from '../api/sizes.ts'; import type { Box, Size } from '../box.ts'; import { parseEdges, subtractEdges, ZERO_EDGES } from '../box.ts'; @@ -86,7 +88,13 @@ export async function layoutPages(def: DocumentDefinition, ctx: MakerCtx): Promi frame.objects = [createFrameGuides(frame, { margin: pageMargin, isPage: true })]; } remainingBlocks = remainder; - pages.push({ size: pageSize, content: frame, header, footer }); + pages.push({ + size: pageSize, + content: frame, + header, + footer, + pdfPage: new PDFPage(pageInfo.pageSize.width, pageInfo.pageSize.height), + }); }; while (remainingBlocks?.length) { diff --git a/src/page.test.ts b/src/page.test.ts deleted file mode 100644 index de40b7e..0000000 --- a/src/page.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import type { PDFPage } from 'pdf-lib'; -import { beforeEach, describe, expect, it } from 'vitest'; - -import type { Font } from './fonts.ts'; -import type { Page } from './page.ts'; -import { addPageFont, getExtGraphicsState } from './page.ts'; -import { fakeFont, fakePDFPage } from './test/test-utils.ts'; - -describe('page', () => { - let page: Page; - let pdfPage: PDFPage; - - beforeEach(() => { - pdfPage = fakePDFPage(); - page = { pdfPage } as Page; - }); - - describe('addPageFont', () => { - let fontA: Font; - let fontB: Font; - - beforeEach(() => { - fontA = fakeFont('fontA', { doc: pdfPage.doc }); - fontB = fakeFont('fontB', { doc: pdfPage.doc }); - }); - - it('returns same font for same input', () => { - const font1 = addPageFont(page, fontA); - const font2 = addPageFont(page, fontA); - - expect(font1.toString()).toBe('/fontA-1'); - expect(font2).toEqual(font1); - }); - - it('returns different fonts for different inputs', () => { - const font1 = addPageFont(page, fontA); - const font2 = addPageFont(page, fontB); - - expect(font1.toString()).toBe('/fontA-1'); - expect(font2).not.toEqual(font1); - }); - }); - - describe('getExtGraphicsState', () => { - it('returns same graphics state for same input', () => { - const name1 = getExtGraphicsState(page, { ca: 0.1, CA: 0.2 }); - const name2 = getExtGraphicsState(page, { ca: 0.1, CA: 0.2 }); - - expect(name1.toString()).toBe('/GS-1'); - expect(name2).toEqual(name2); - }); - - it('returns different graphics states for different inputs', () => { - const name1 = getExtGraphicsState(page, { ca: 0.1, CA: 0.2 }); - const name2 = getExtGraphicsState(page, { ca: 0.2, CA: 0.1 }); - - expect(name1.toString()).toBe('/GS-1'); - expect(name2).not.toEqual(name1); - }); - }); -}); diff --git a/src/page.ts b/src/page.ts index a65b414..49bdc00 100644 --- a/src/page.ts +++ b/src/page.ts @@ -1,11 +1,8 @@ -import type { Color, PDFName, PDFPage } from 'pdf-lib'; +import { type PDFPage } from '@ralfstx/pdf-core'; import type { Size } from './box.ts'; -import type { Font } from './fonts.ts'; -import { registerFont } from './fonts.ts'; +import type { Color } from './colors.ts'; import type { Frame } from './frame.ts'; -import type { Image } from './images.ts'; -import { registerImage } from './images.ts'; export type TextState = { color?: Color; @@ -20,42 +17,6 @@ export type Page = { content: Frame; header?: Frame; footer?: Frame; - pdfPage?: PDFPage; - fonts?: { [ref: string]: PDFName }; - images?: { [ref: string]: PDFName }; + pdfPage: PDFPage; textState?: TextState; - extGStates?: { [ref: string]: PDFName }; }; - -export function addPageFont(page: Page, font: Font): PDFName { - if (!page.pdfPage) throw new Error('Page not initialized'); - page.fonts ??= {}; - if (!(font.key in page.fonts)) { - const pdfRef = registerFont(font, page.pdfPage.doc); - page.fonts[font.key] = (page.pdfPage as any).node.newFontDictionary(font.name, pdfRef); - } - return page.fonts[font.key]; -} - -export function addPageImage(page: Page, image: Image): PDFName { - if (!page.pdfPage) throw new Error('Page not initialized'); - page.images ??= {}; - if (!(image.url in page.images)) { - const pdfRef = registerImage(image, page.pdfPage.doc); - page.images[image.url] = (page.pdfPage as any).node.newXObject('Image', pdfRef); - } - return page.images[image.url]; -} - -type ExtGraphicsParams = { ca: number; CA: number }; - -export function getExtGraphicsState(page: Page, params: ExtGraphicsParams): PDFName { - if (!page.pdfPage) throw new Error('Page not initialized'); - page.extGStates ??= {}; - const key = `CA:${params.CA},ca:${params.ca}`; - if (!(key in page.extGStates)) { - const dict = page.pdfPage.doc.context.obj({ Type: 'ExtGState', ...params }); - page.extGStates[key] = (page.pdfPage as any).node.newExtGState('GS', dict); - } - return page.extGStates[key]; -} diff --git a/src/read-color.ts b/src/read-color.ts index 84d31b3..9e50b37 100644 --- a/src/read-color.ts +++ b/src/read-color.ts @@ -1,6 +1,5 @@ -import { type Color, rgb } from 'pdf-lib'; - import { namedColors } from './api/colors.ts'; +import { type Color, rgb } from './colors.ts'; import { isObject, typeError } from './types.ts'; export { type Color }; diff --git a/src/read-document.ts b/src/read-document.ts index f8050fa..1d9aad7 100644 --- a/src/read-document.ts +++ b/src/read-document.ts @@ -1,4 +1,4 @@ -import type { PDFDocument } from 'pdf-lib'; +import type { PDFDocument } from '@ralfstx/pdf-core'; import type { FileRelationShip } from './api/document.ts'; import type { BoxEdges, Size } from './box.ts'; diff --git a/src/read-graphics.test.ts b/src/read-graphics.test.ts index 58ad1cf..86f8be8 100644 --- a/src/read-graphics.test.ts +++ b/src/read-graphics.test.ts @@ -1,6 +1,6 @@ -import { rgb } from 'pdf-lib'; import { describe, expect, it } from 'vitest'; +import { rgb } from './colors.ts'; import { readShape } from './read-graphics.ts'; import { p } from './test/test-utils.ts'; diff --git a/src/render/render-annotations.ts b/src/render/render-annotations.ts deleted file mode 100644 index 30c78ec..0000000 --- a/src/render/render-annotations.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { PDFArray, PDFDict, PDFPage } from 'pdf-lib'; -import { PDFName, PDFString } from 'pdf-lib'; - -import type { Box, Pos } from '../box.ts'; -import type { AnchorObject, LinkObject } from '../frame.ts'; -import type { Page } from '../page.ts'; - -export function renderAnchor(obj: AnchorObject, page: Page, base: Pos) { - const x = base.x + obj.x; - const y = page.size.height - base.y - obj.y; - if (!page.pdfPage) throw new Error('Page not initialized'); - createNamedDest(page.pdfPage, obj.name, { x, y }); -} - -export function renderLink(obj: LinkObject, page: Page, base: Pos) { - const { width, height, url } = obj; - const x = base.x + obj.x; - const y = page.size.height - base.y - obj.y - height; - if (!page.pdfPage) throw new Error('Page not initialized'); - createLinkAnnotation(page.pdfPage, { x, y, width, height }, url); -} - -function createLinkAnnotation(page: PDFPage, box: Box, uri: string) { - const annots = getOrCreate(page.node, 'Annots', () => []) as PDFArray; - const annot = page.doc.context.obj({ - Type: 'Annot', - Subtype: 'Link', - Rect: [box.x, box.y, box.x + box.width, box.y + box.height], - ...(uri.startsWith('#') - ? { A: { Type: 'Action', S: 'GoTo', D: PDFString.of(uri.slice(1)) } } - : { A: { Type: 'Action', S: 'URI', URI: PDFString.of(uri) } }), - C: page.doc.context.obj([]), - F: 4, // required for PDF/A - }); - annots.push(page.doc.context.register(annot)); -} - -function createNamedDest(page: PDFPage, name: string, pos: Pos) { - const names = getOrCreate(page.doc.catalog, 'Names', () => ({})) as PDFDict; - const dests = getOrCreate(names, 'Dests', () => ({})) as PDFDict; - const destNames = getOrCreate(dests, 'Names', () => []) as PDFArray; - for (let i = 0; i < destNames.size(); i += 2) { - if ((destNames.get(i) as PDFString).asString() === name) { - throw new Error(`Duplicate id ${name}`); - } - } - destNames.push(PDFString.of(name)); - destNames.push(page.doc.context.obj([page.ref, 'XYZ', pos.x, pos.y, null])); -} - -function getOrCreate(dict: PDFDict, name: string, creatorFn: () => unknown): unknown { - if (!dict.has(PDFName.of(name))) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - dict.set(PDFName.of(name), dict.context.obj(creatorFn() as any)); - } - return dict.get(PDFName.of(name)); -} diff --git a/src/render/render-document.test.ts b/src/render/render-document.test.ts index b1d6c69..5cb6b41 100644 --- a/src/render/render-document.test.ts +++ b/src/render/render-document.test.ts @@ -1,10 +1,22 @@ -import type { PDFArray, PDFStream } from 'pdf-lib'; -import { PDFDict, PDFDocument, PDFHexString, PDFName, PDFString } from 'pdf-lib'; -import { describe, expect, it } from 'vitest'; +import type { PDFDocument } from '@ralfstx/pdf-core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { renderDocument } from './render-document.ts'; +const noObjectStreams = { useObjectStreams: false } as const; + describe('renderDocument', () => { + beforeEach(() => { + vi.stubEnv('TZ', 'UTC'); + vi.useFakeTimers(); + vi.setSystemTime('2025-01-01T00:00:00.000Z'); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.useRealTimers(); + }); + it('renders all info properties', async () => { const def = { content: [], @@ -13,7 +25,7 @@ describe('renderDocument', () => { subject: 'test-subject', keywords: ['foo', 'bar'], author: 'test-author', - creationDate: new Date(23), + creationDate: new Date('2000-01-01T00:00:00.000Z'), creator: 'test-creator', producer: 'test-producer', custom: { @@ -23,33 +35,21 @@ describe('renderDocument', () => { }, }; - const pdfData = await renderDocument(def, []); - - const pdfDoc = await PDFDocument.load(pdfData, { updateMetadata: false }); - 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')); - expect(getInfo('Subject')).toEqual(PDFHexString.fromText('test-subject')); - expect(getInfo('Keywords')).toEqual(PDFHexString.fromText('foo bar')); - expect(getInfo('Author')).toEqual(PDFHexString.fromText('test-author')); - expect(getInfo('Producer')).toEqual(PDFHexString.fromText('test-producer')); - expect(getInfo('Creator')).toEqual(PDFHexString.fromText('test-creator')); - expect(getInfo('CreationDate')).toEqual(PDFString.fromDate(new Date(23))); - expect(getInfo('foo')).toEqual(PDFHexString.fromText('foo-value')); - expect(getInfo('bar')).toEqual(PDFHexString.fromText('bar-value')); - }); - - it('generates file ID', async () => { - const def = { content: [] }; - - const pdfData = await renderDocument(def, []); + const pdfData = await renderDocument(def, [], noObjectStreams); + const dataString = new TextDecoder().decode(pdfData); - const pdfDoc = await PDFDocument.load(pdfData, { updateMetadata: false }); - const fileId = pdfDoc.context.lookup(pdfDoc.context.trailerInfo.ID) as PDFArray; - expect(fileId.size()).toBe(2); - expect(fileId.get(0).toString()).toMatch(/^<[0-9A-F]{64}>$/); - expect(fileId.get(1).toString()).toMatch(/^<[0-9A-F]{64}>$/); + expect(dataString).toMatch(/\/Title /); + expect(dataString).toMatch(/\/Author /); + expect(dataString).toMatch(/\/Subject /); + expect(dataString).toMatch(/\/Keywords /); + expect(dataString).toMatch(/\/Creator /); + expect(dataString).toMatch( + /\/Producer /, + ); + expect(dataString).toMatch(/\/CreationDate \(D:20000101000000Z\)/); + expect(dataString).toMatch(/\/ModDate \(D:20250101000000Z\)/); + expect(dataString).toMatch(/\/foo /); + expect(dataString).toMatch(/\/bar /); }); it('renders custom data', async () => { @@ -61,28 +61,32 @@ describe('renderDocument', () => { }, }; - const pdfData = await renderDocument(def, []); + const pdfData = await renderDocument(def, [], noObjectStreams); + const dataString = new TextDecoder().decode(pdfData); - const pdfDoc = await PDFDocument.load(pdfData, { updateMetadata: false }); - 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)); + expect(dataString).toMatch(/\/XXFoo \d+ \d+ R/); + expect(dataString).toMatch(/\/XXBar \d+ \d+ R/); + const fooObject = dataString.match(/\/XXFoo (\d+ \d+) R/)![1]; + const barObject = dataString.match(/\/XXBar (\d+ \d+) R/)![1]; + const streamRegex = (obj: string) => + new RegExp(`${obj} obj\\n<<\\n\\s*/Length\\s+\\d+\\n>>\\nstream\\n(.*?)\\nendstream`, 'm'); + const fooStreamMatch = dataString.match(streamRegex(fooObject)); + const barStreamMatch = dataString.match(streamRegex(barObject)); + expect(fooStreamMatch![1]).toBe('Foo'); + expect(barStreamMatch![1]).toBe('\x01\x02\x03'); }); it('calls custom render hook', async () => { const def = { content: [], - onRenderDocument: async (pdfDoc: PDFDocument) => { - pdfDoc.setTitle('Test Title'); - await new Promise((resolve) => setTimeout(resolve, 10)); + onRenderDocument: (pdfDoc: PDFDocument) => { + pdfDoc.setInfo({ title: 'test-title' }); }, }; - const pdfData = await renderDocument(def, []); + const pdfData = await renderDocument(def, [], noObjectStreams); + const dataString = new TextDecoder().decode(pdfData); - const pdfDoc = await PDFDocument.load(pdfData, { updateMetadata: false }); - const infoDict = pdfDoc.context.lookup(pdfDoc.context.trailerInfo.Info) as PDFDict; - const getInfo = (name: string) => infoDict.get(PDFName.of(name)); - expect(getInfo('Title')).toEqual(PDFHexString.fromText('Test Title')); + expect(dataString).toMatch(/\/Title /); }); }); diff --git a/src/render/render-document.ts b/src/render/render-document.ts index 217829c..2a72fcd 100644 --- a/src/render/render-document.ts +++ b/src/render/render-document.ts @@ -1,12 +1,16 @@ -import type { AFRelationship, PDFDict } from 'pdf-lib'; -import { PDFDocument, PDFHexString, PDFName } from 'pdf-lib'; +import type { PDFContext, PDFDict, WriteOptions } from '@ralfstx/pdf-core'; +import { PDFDocument, PDFStream } from '@ralfstx/pdf-core'; import type { Page } from '../page.ts'; import type { DocumentDefinition, Metadata } from '../read-document.ts'; import { renderPage } from './render-page.ts'; -export async function renderDocument(def: DocumentDefinition, pages: Page[]): Promise { - const pdfDoc = await PDFDocument.create({ updateMetadata: false }); +export async function renderDocument( + def: DocumentDefinition, + pages: Page[], + writeOptions?: WriteOptions, +): Promise { + const pdfDoc = new PDFDocument(); setMetadata(pdfDoc, def.info); if (def.customData) { setCustomData(def.customData, pdfDoc); @@ -14,82 +18,49 @@ export async function renderDocument(def: DocumentDefinition, pages: Page[]): Pr pages.forEach((page) => renderPage(page, pdfDoc)); for (const file of def.embeddedFiles ?? []) { - await pdfDoc.attach(file.content, file.fileName, { + pdfDoc.addEmbeddedFile({ + content: file.content, + fileName: file.fileName, mimeType: file.mimeType, description: file.description, creationDate: file.creationDate, - modificationDate: file.modificationDate, - afRelationship: file.relationship as AFRelationship | undefined, + modDate: file.modificationDate, + afRelationship: file.relationship, }); } await def.onRenderDocument?.(pdfDoc); - const idInfo = { - creator: 'pdfmkr', - time: new Date().toISOString(), - info: def.info ?? null, - }; - const fileId = await sha256Hex(JSON.stringify(idInfo)); - pdfDoc.context.trailerInfo.ID = pdfDoc.context.obj([ - PDFHexString.of(fileId.toUpperCase()), - PDFHexString.of(fileId.toUpperCase()), - ]); - const data = await pdfDoc.save(); - return appendNewline(data); + return pdfDoc.write(writeOptions); } function setMetadata(doc: PDFDocument, info?: Metadata) { const now = new Date(); - doc.setCreationDate(now); - doc.setModificationDate(now); - if (info?.title) { - doc.setTitle(info.title); - } - if (info?.subject) { - doc.setSubject(info.subject); - } - if (info?.keywords) { - doc.setKeywords(info.keywords); - } - if (info?.author) { - doc.setAuthor(info.author); - } - if (info?.creationDate) { - doc.setCreationDate(info.creationDate); - } - if (info?.creator) { - doc.setCreator(info.creator); - } - if (info?.producer) { - doc.setProducer(info.producer); - } - if (info?.custom) { - const dict = (doc as any).getInfoDict() as PDFDict; - for (const [key, value] of Object.entries(info.custom)) { - dict.set(PDFName.of(key), PDFHexString.fromText(value)); - } + if (info !== undefined) { + doc.setInfo({ + creationDate: info.creationDate ?? now, + modDate: now, + title: info.title, + subject: info.subject, + keywords: info.keywords?.join(', '), + author: info.author, + creator: info.creator, + producer: info.producer, + ...info.custom, + }); } } function setCustomData(data: Record, doc: PDFDocument) { for (const [key, value] of Object.entries(data)) { - const stream = doc.context.stream(value); - const ref = doc.context.register(stream); - doc.catalog.set(PDFName.of(key), ref); + doc.unsafeOnRender((renderContext) => { + const stream = PDFStream.of( + typeof value === 'string' ? new TextEncoder().encode(value) : value, + ); + const context = renderContext.context as PDFContext; + const catalog = renderContext.catalog as PDFDict; + const ref = context.addObject(stream); + catalog.set(key, ref); + }); } } - -async function sha256Hex(input: string): Promise { - const buffer = new TextEncoder().encode(input); - const hashBuffer = await crypto.subtle.digest('SHA-256', buffer); - const hashArray = Array.from(new Uint8Array(hashBuffer)); - return hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); -} - -function appendNewline(data: Uint8Array): Uint8Array { - const result = new Uint8Array(data.length + 1); - result.set(data, 0); - result[data.length] = 10; - return result; -} diff --git a/src/render/render-graphics.test.ts b/src/render/render-graphics.test.ts index e939e2c..c99e746 100644 --- a/src/render/render-graphics.test.ts +++ b/src/render/render-graphics.test.ts @@ -1,10 +1,11 @@ -import { rgb } from 'pdf-lib'; +import { PDFPage } from '@ralfstx/pdf-core'; import { beforeEach, describe, expect, it } from 'vitest'; import type { Size } from '../box.ts'; +import { rgb } from '../colors.ts'; import type { CircleObject, LineObject, PathObject, PolylineObject, RectObject } from '../frame.ts'; import type { Page } from '../page.ts'; -import { fakePDFPage, getContentStream, p } from '../test/test-utils.ts'; +import { getContentStream, p } from '../test/test-utils.ts'; import { renderGraphics } from './render-graphics.ts'; describe('renderGraphics', () => { @@ -16,7 +17,7 @@ describe('renderGraphics', () => { beforeEach(() => { size = { width: 500, height: 800 }; - const pdfPage = fakePDFPage(); + const pdfPage = new PDFPage(size.width, size.height); page = { size, pdfPage } as Page; }); @@ -26,7 +27,7 @@ describe('renderGraphics', () => { renderGraphics({ type: 'graphics', shapes: [rect] }, page, pos); - expect(getContentStream(page)).toEqual([...head, '1 2 3 4 re', 'S', ...tail]); + expect(getContentStream(page)).toEqual([...head, '1 2 3 4 re', 'S', ...tail].join('\n')); }); it('renders rect with fillColor', () => { @@ -37,7 +38,9 @@ describe('renderGraphics', () => { renderGraphics({ type: 'graphics', shapes: [rect] }, page, pos); - expect(getContentStream(page)).toEqual([...head, '1 0 0 rg', '1 2 3 4 re', 'f', ...tail]); + expect(getContentStream(page)).toEqual( + [...head, '1 0 0 rg', '1 2 3 4 re', 'f', ...tail].join('\n'), + ); }); it('renders rect with lineColor', () => { @@ -48,7 +51,9 @@ describe('renderGraphics', () => { renderGraphics({ type: 'graphics', shapes: [rect] }, page, pos); - expect(getContentStream(page)).toEqual([...head, '1 0 0 RG', '1 2 3 4 re', 'S', ...tail]); + expect(getContentStream(page)).toEqual( + [...head, '1 0 0 RG', '1 2 3 4 re', 'S', ...tail].join('\n'), + ); }); it('renders rect with all properties', () => { @@ -65,18 +70,20 @@ describe('renderGraphics', () => { renderGraphics({ type: 'graphics', shapes: [rect] }, page, pos); - expect(getContentStream(page)).toEqual([ - ...head, - '/GS-1 gs', - '0 0 1 rg', - '1 0 0 RG', - '1 w', - '1 j', - '[1 2] 0 d', - '1 2 3 4 re', - 'B', - ...tail, - ]); + expect(getContentStream(page)).toEqual( + [ + ...head, + '/gs:CA:0.5,ca:0.5 gs', + '0 0 1 rg', + '1 0 0 RG', + '1 w', + '1 j', + '[1 2] 0 d', + '1 2 3 4 re', + 'B', + ...tail, + ].join('\n'), + ); }); }); @@ -86,16 +93,18 @@ describe('renderGraphics', () => { renderGraphics({ type: 'graphics', shapes: [circle] }, page, pos); - expect(getContentStream(page)).toEqual([ - ...head, - '-2 2 m', - '-2 0.3431457505076194 -0.6568542494923806 -1 1 -1 c', - '2.6568542494923806 -1 4 0.3431457505076194 4 2 c', - '4 3.6568542494923806 2.6568542494923806 5 1 5 c', - '-0.6568542494923806 5 -2 3.6568542494923806 -2 2 c', - 'S', - ...tail, - ]); + expect(getContentStream(page)).toEqual( + [ + ...head, + '-2 2 m', + '-2 0.34314575 -0.65685425 -1 1 -1 c', + '2.6568542 -1 4 0.34314575 4 2 c', + '4 3.6568542 2.6568542 5 1 5 c', + '-0.65685425 5 -2 3.6568542 -2 2 c', + 'S', + ...tail, + ].join('\n'), + ); }); it('renders circle with all properties', () => { @@ -111,21 +120,23 @@ describe('renderGraphics', () => { renderGraphics({ type: 'graphics', shapes: [circle] }, page, pos); - expect(getContentStream(page)).toEqual([ - ...head, - '/GS-1 gs', - '0 0 1 rg', - '1 0 0 RG', - '1 w', - '[1 2] 0 d', - '-2 2 m', - '-2 0.3431457505076194 -0.6568542494923806 -1 1 -1 c', - '2.6568542494923806 -1 4 0.3431457505076194 4 2 c', - '4 3.6568542494923806 2.6568542494923806 5 1 5 c', - '-0.6568542494923806 5 -2 3.6568542494923806 -2 2 c', - 'B', - ...tail, - ]); + expect(getContentStream(page)).toEqual( + [ + ...head, + '/gs:CA:0.5,ca:0.5 gs', + '0 0 1 rg', + '1 0 0 RG', + '1 w', + '[1 2] 0 d', + '-2 2 m', + '-2 0.34314575 -0.65685425 -1 1 -1 c', + '2.6568542 -1 4 0.34314575 4 2 c', + '4 3.6568542 2.6568542 5 1 5 c', + '-0.65685425 5 -2 3.6568542 -2 2 c', + 'B', + ...tail, + ].join('\n'), + ); }); }); @@ -135,7 +146,7 @@ describe('renderGraphics', () => { renderGraphics({ type: 'graphics', shapes: [line] }, page, pos); - expect(getContentStream(page)).toEqual([...head, '1 2 m', '3 4 l', 'S', ...tail]); + expect(getContentStream(page)).toEqual([...head, '1 2 m', '3 4 l', 'S', ...tail].join('\n')); }); it('renders line with all properties', () => { @@ -150,18 +161,20 @@ describe('renderGraphics', () => { renderGraphics({ type: 'graphics', shapes: [line] }, page, pos); - expect(getContentStream(page)).toEqual([ - ...head, - '/GS-1 gs', - '1 0 0 RG', - '1 w', - '1 J', - '[1 2] 0 d', - '1 2 m', - '3 4 l', - 'S', - ...tail, - ]); + expect(getContentStream(page)).toEqual( + [ + ...head, + '/gs:CA:0.5,ca:1 gs', + '1 0 0 RG', + '1 w', + '1 J', + '[1 2] 0 d', + '1 2 m', + '3 4 l', + 'S', + ...tail, + ].join('\n'), + ); }); }); @@ -171,7 +184,7 @@ describe('renderGraphics', () => { renderGraphics({ type: 'graphics', shapes: [polyline] }, page, pos); - expect(getContentStream(page)).toEqual([...head, '1 2 m', '3 4 l', 'S', ...tail]); + expect(getContentStream(page)).toEqual([...head, '1 2 m', '3 4 l', 'S', ...tail].join('\n')); }); it('renders polyline with closePath', () => { @@ -183,7 +196,9 @@ describe('renderGraphics', () => { renderGraphics({ type: 'graphics', shapes: [polyline] }, page, pos); - expect(getContentStream(page)).toEqual([...head, '1 2 m', '3 4 l', 'h', 'S', ...tail]); + expect(getContentStream(page)).toEqual( + [...head, '1 2 m', '3 4 l', 'h', 'S', ...tail].join('\n'), + ); }); it('renders polyline with all properties', () => { @@ -199,19 +214,21 @@ describe('renderGraphics', () => { renderGraphics({ type: 'graphics', shapes: [polyline] }, page, pos); - expect(getContentStream(page)).toEqual([ - ...head, - '0 0 1 rg', - '1 0 0 RG', - '1 w', - '1 J', - '1 j', - '[1 2] 0 d', - '1 2 m', - '3 4 l', - 'B', - ...tail, - ]); + expect(getContentStream(page)).toEqual( + [ + ...head, + '0 0 1 rg', + '1 0 0 RG', + '1 w', + '1 J', + '1 j', + '[1 2] 0 d', + '1 2 m', + '3 4 l', + 'B', + ...tail, + ].join('\n'), + ); }); }); @@ -227,7 +244,9 @@ describe('renderGraphics', () => { renderGraphics({ type: 'graphics', shapes: [path] }, page, pos); - expect(getContentStream(page)).toEqual([...head, '0 20 m', '20 0 l', 'S', ...tail]); + expect(getContentStream(page)).toEqual( + [...head, '0 20 m', '20 0 l', 'S', ...tail].join('\n'), + ); }); it('renders curve', () => { @@ -242,14 +261,9 @@ describe('renderGraphics', () => { renderGraphics({ type: 'graphics', shapes: [path] }, page, pos); - expect(getContentStream(page)).toEqual([ - ...head, - '0 20 m', - '20 0 40 20 v', - '60 40 80 20 v', - 'S', - ...tail, - ]); + expect(getContentStream(page)).toEqual( + [...head, '0 20 m', '20 0 40 20 v', '60 40 80 20 v', 'S', ...tail].join('\n'), + ); }); it('renders arc', () => { @@ -263,14 +277,16 @@ describe('renderGraphics', () => { renderGraphics({ type: 'graphics', shapes: [path] }, page, pos); - expect(getContentStream(page)).toEqual([ - ...head, - '10 20 m', - expect.stringMatching(/^14\.\d+ 12\.\d+ 21\.\d+ 11\.\d+ 27\.\d+ 16\.\d+ c$/), - expect.stringMatching(/^33\.\d+ 22\.\d+ 34\.\d+ 32\.\d+ 30 40 c$/), - 'S', - ...tail, - ]); + expect(getContentStream(page)).toEqual( + [ + ...head, + '10 20 m', + '14.142136 12.636203 21.977152 11.143819 27.5 16.666667 c', + '33.022847 22.189514 34.142135 32.636203 30 40 c', + 'S', + ...tail, + ].join('\n'), + ); }); }); @@ -280,17 +296,9 @@ describe('renderGraphics', () => { renderGraphics({ type: 'graphics', shapes: [line, rect] }, page, pos); - expect(getContentStream(page)).toEqual([ - ...head, - '1 2 m', - '3 4 l', - 'S', - 'Q', - 'q', - '1 2 3 4 re', - 'S', - ...tail, - ]); + expect(getContentStream(page)).toEqual( + [...head, '1 2 m', '3 4 l', 'S', 'Q', 'q', '1 2 3 4 re', 'S', ...tail].join('\n'), + ); }); describe('transformations', () => { @@ -303,13 +311,9 @@ describe('renderGraphics', () => { renderGraphics({ type: 'graphics', shapes: [shape] }, page, pos); - expect(getContentStream(page)).toEqual([ - ...head, - '1 0 0 1 1 2 cm', - '1 2 3 4 re', - 'S', - ...tail, - ]); + expect(getContentStream(page)).toEqual( + [...head, '1 0 0 1 1 2 cm', '1 2 3 4 re', 'S', ...tail].join('\n'), + ); }); it('supports scale', () => { @@ -320,13 +324,9 @@ describe('renderGraphics', () => { renderGraphics({ type: 'graphics', shapes: [shape] }, page, pos); - expect(getContentStream(page)).toEqual([ - ...head, - '3 0 0 4 0 0 cm', - '1 2 3 4 re', - 'S', - ...tail, - ]); + expect(getContentStream(page)).toEqual( + [...head, '3 0 0 4 0 0 cm', '1 2 3 4 re', 'S', ...tail].join('\n'), + ); }); it('supports rotate', () => { @@ -337,13 +337,15 @@ describe('renderGraphics', () => { renderGraphics({ type: 'graphics', shapes: [shape] }, page, pos); - expect(getContentStream(page)).toEqual([ - ...head, - '0.996195 0.087156 -0.087156 0.996195 0.632922 -0.496297 cm', - '1 2 3 4 re', - 'S', - ...tail, - ]); + expect(getContentStream(page)).toEqual( + [ + ...head, + '0.996195 0.087156 -0.087156 0.996195 0.632922 -0.496297 cm', + '1 2 3 4 re', + 'S', + ...tail, + ].join('\n'), + ); }); it('supports skew', () => { @@ -354,13 +356,9 @@ describe('renderGraphics', () => { renderGraphics({ type: 'graphics', shapes: [shape] }, page, pos); - expect(getContentStream(page)).toEqual([ - ...head, - '1 0.158384 0.140541 1 0 0 cm', - '1 2 3 4 re', - 'S', - ...tail, - ]); + expect(getContentStream(page)).toEqual( + [...head, '1 0.158384 0.140541 1 0 0 cm', '1 2 3 4 re', 'S', ...tail].join('\n'), + ); }); it('supports matrix', () => { @@ -371,13 +369,9 @@ describe('renderGraphics', () => { renderGraphics({ type: 'graphics', shapes: [shape] }, page, pos); - expect(getContentStream(page)).toEqual([ - ...head, - '1 2 3 4 5 6 cm', - '1 2 3 4 re', - 'S', - ...tail, - ]); + expect(getContentStream(page)).toEqual( + [...head, '1 2 3 4 5 6 cm', '1 2 3 4 re', 'S', ...tail].join('\n'), + ); }); it('supports multiple transformations', () => { @@ -390,13 +384,9 @@ describe('renderGraphics', () => { renderGraphics({ type: 'graphics', shapes: [shape] }, page, pos); - expect(getContentStream(page)).toEqual([ - ...head, - '3 0.633538 0.421623 4 1 2 cm', - '1 2 3 4 re', - 'S', - ...tail, - ]); + expect(getContentStream(page)).toEqual( + [...head, '3 0.633538 0.421623 4 1 2 cm', '1 2 3 4 re', 'S', ...tail].join('\n'), + ); }); }); }); diff --git a/src/render/render-graphics.ts b/src/render/render-graphics.ts index e57fa61..b130d24 100644 --- a/src/render/render-graphics.ts +++ b/src/render/render-graphics.ts @@ -1,30 +1,9 @@ -import type { PDFName, PDFOperatorNames } from 'pdf-lib'; -import { - appendBezierCurve, - asPDFNumber, - closePath, - concatTransformationMatrix, - fill, - fillAndStroke, - LineCapStyle, - LineJoinStyle, - lineTo, - moveTo, - PDFOperator, - popGraphicsState, - pushGraphicsState, - setDashPattern, - setFillingColor, - setGraphicsState, - setLineCap, - setLineJoin, - setLineWidth, - setStrokingColor, - stroke, -} from 'pdf-lib'; +import type { ContentStream } from '@ralfstx/pdf-core'; +import { ExtGState } from '@ralfstx/pdf-core'; import type { LineCap, LineJoin } from '../api/graphics.ts'; import type { Pos } from '../box.ts'; +import { setFillingColor, setStrokingColor } from '../colors.ts'; import type { CircleObject, FillAttrs, @@ -37,123 +16,125 @@ import type { Shape, } from '../frame.ts'; import type { Page } from '../page.ts'; -import { getExtGraphicsState } from '../page.ts'; -import { svgPathToPdfOps } from '../svg-paths.ts'; -import { compact, multiplyMatrices, round } from '../utils.ts'; +import { drawSvgPath } from '../svg-paths.ts'; +import { multiplyMatrices, round } from '../utils.ts'; // See https://stackoverflow.com/a/27863181/247159 const KAPPA = (4 * (Math.sqrt(2) - 1)) / 3; export function renderGraphics(object: GraphicsObject, page: Page, base: Pos) { const pos = tr(base, page); - const contentStream = (page.pdfPage as any).getContentStream(); - contentStream.push(pushGraphicsState(), concatTransformationMatrix(1, 0, 0, -1, pos.x, pos.y)); + const cs = page.pdfPage.contentStream; + cs.saveGraphicsState().applyTransformMatrix(1, 0, 0, -1, pos.x, pos.y); object.shapes.forEach((shape) => { - contentStream.push(pushGraphicsState(), ...setStyleAttrs(shape, page)); + cs.saveGraphicsState(); + setStyleAttrs(cs, shape); if (shape.type === 'rect') { - contentStream.push(...drawRect(shape)); + drawRect(cs, shape); } if (shape.type === 'circle') { - contentStream.push(...drawCircle(shape)); + drawCircle(cs, shape); } if (shape.type === 'line') { - contentStream.push(...drawLine(shape)); + drawLine(cs, shape); } if (shape.type === 'polyline') { - contentStream.push(...drawPolyLine(shape)); + drawPolyLine(cs, shape); } if (shape.type === 'path') { - contentStream.push(...drawPath(shape)); + drawPath(cs, shape); } - contentStream.push(popGraphicsState()); + cs.restoreGraphicsState(); }); - contentStream.push(popGraphicsState()); + cs.restoreGraphicsState(); } -function drawRect(obj: RectObject): PDFOperator[] { - return compact([createRect(obj.x, obj.y, obj.width, obj.height), fillAndStrokePath(obj)]); +function drawRect(cs: ContentStream, obj: RectObject): void { + cs.rect(obj.x, obj.y, obj.width, obj.height); + fillAndStrokePath(cs, obj); } -function createRect(x: number, y: number, width: number, height: number) { - return PDFOperator.of('re' as PDFOperatorNames, [ - asPDFNumber(x), - asPDFNumber(y), - asPDFNumber(width), - asPDFNumber(height), - ]); -} - -function drawCircle(obj: CircleObject): PDFOperator[] { +function drawCircle(cs: ContentStream, obj: CircleObject): void { const { cx, cy, r } = obj; const o = r * KAPPA; - return compact([ - moveTo(cx - r, cy), - appendBezierCurve(cx - r, cy - o, cx - o, cy - r, cx, cy - r), - appendBezierCurve(cx + o, cy - r, cx + r, cy - o, cx + r, cy), - appendBezierCurve(cx + r, cy + o, cx + o, cy + r, cx, cy + r), - appendBezierCurve(cx - o, cy + r, cx - r, cy + o, cx - r, cy), - fillAndStrokePath(obj), - ]); + + cs.moveTo(cx - r, cy); + cs.curveTo(cx - r, cy - o, cx - o, cy - r, cx, cy - r); + cs.curveTo(cx + o, cy - r, cx + r, cy - o, cx + r, cy); + cs.curveTo(cx + r, cy + o, cx + o, cy + r, cx, cy + r); + cs.curveTo(cx - o, cy + r, cx - r, cy + o, cx - r, cy); + fillAndStrokePath(cs, obj); } -function drawLine(obj: LineObject): PDFOperator[] { - return compact([moveTo(obj.x1, obj.y1), lineTo(obj.x2, obj.y2), stroke()]); +function drawLine(cs: ContentStream, obj: LineObject): void { + cs.moveTo(obj.x1, obj.y1); + cs.lineTo(obj.x2, obj.y2); + cs.stroke(); } -function drawPolyLine(obj: PolylineObject): PDFOperator[] { - return compact([ - ...pathOperations(obj.points), - obj.closePath && closePath(), - fillAndStrokePath(obj), - ]); +function drawPolyLine(cs: ContentStream, obj: PolylineObject): void { + pathOperations(cs, obj.points); + if (obj.closePath) cs.closePath(); + fillAndStrokePath(cs, obj); } -function drawPath(obj: PathObject): PDFOperator[] { - return compact([...svgPathToPdfOps(obj.commands), fillAndStrokePath(obj)]); +function drawPath(cs: ContentStream, obj: PathObject) { + drawSvgPath(cs, obj.commands); + fillAndStrokePath(cs, obj); } -function pathOperations(points: { x: number; y: number }[]): PDFOperator[] { - return points.reduce((a: PDFOperator[], p) => [...a, (a.length ? lineTo : moveTo)(p.x, p.y)], []); +function pathOperations(cs: ContentStream, points: { x: number; y: number }[]): void { + let started = false; + for (const p of points) { + if (!started) { + cs.moveTo(p.x, p.y); + started = true; + } else { + cs.lineTo(p.x, p.y); + } + } } -function fillAndStrokePath(obj: LineAttrs & FillAttrs): PDFOperator { +function fillAndStrokePath(cs: ContentStream, obj: LineAttrs & FillAttrs) { const hasFill = !!obj.fillColor; const hasStroke = !!obj.lineColor || !!obj.lineWidth; - if (hasFill && hasStroke) return fillAndStroke(); - if (hasFill) return fill(); - return stroke(); // fall back to stroke to avoid invisible shapes + if (hasFill && hasStroke) return cs.fillAndStroke(); + if (hasFill) return cs.fill(); + return cs.stroke(); // fall back to stroke to avoid invisible shapes } -const lineCapTr = { - butt: LineCapStyle.Butt, - round: LineCapStyle.Round, - square: LineCapStyle.Projecting, -}; -const trLineCap = (lineCap: LineCap) => lineCapTr[lineCap]; - -const lineJoinTr = { - miter: LineJoinStyle.Miter, - round: LineJoinStyle.Round, - bevel: LineJoinStyle.Bevel, -}; -const trLineJoin = (lineJoin: LineJoin) => lineJoinTr[lineJoin]; - -function setStyleAttrs(shape: Shape, page: Page): PDFOperator[] { - const extGraphicsState = getExtGraphicsStateForShape(page, shape); +export const LineCapStyle = { + butt: 0, + round: 1, + square: 2, +} as const; + +const trLineCap = (lineCap: LineCap) => LineCapStyle[lineCap]; + +export const LineJoinStyle = { + miter: 0, + round: 1, + bevel: 2, +} as const; + +const trLineJoin = (lineJoin: LineJoin) => LineJoinStyle[lineJoin]; + +function setStyleAttrs(cs: ContentStream, shape: Shape): void { + const extGraphicsState = getExtGraphicsStateForShape(shape); const attrs = shape as LineAttrs & FillAttrs; - return compact([ - createMatrix(shape), - extGraphicsState && setGraphicsState(extGraphicsState), - attrs.fillColor !== undefined && setFillingColor(attrs.fillColor), - attrs.lineColor !== undefined && setStrokingColor(attrs.lineColor), - attrs.lineWidth !== undefined && setLineWidth(attrs.lineWidth), - attrs.lineCap !== undefined && setLineCap(trLineCap(attrs.lineCap)), - attrs.lineJoin !== undefined && setLineJoin(trLineJoin(attrs.lineJoin)), - attrs.lineDash !== undefined && setDashPattern(attrs.lineDash, 0), - ]); + + createMatrix(cs, shape); + + if (extGraphicsState !== undefined) cs.setGraphicsState(extGraphicsState); + if (attrs.fillColor !== undefined) setFillingColor(cs, attrs.fillColor); + if (attrs.lineColor !== undefined) setStrokingColor(cs, attrs.lineColor); + if (attrs.lineWidth !== undefined) cs.setLineWidth(attrs.lineWidth); + if (attrs.lineCap !== undefined) cs.setLineCap(trLineCap(attrs.lineCap)); + if (attrs.lineJoin !== undefined) cs.setLineJoin(trLineJoin(attrs.lineJoin)); + if (attrs.lineDash !== undefined) cs.setDashPattern(attrs.lineDash, 0); } -function createMatrix(shape: Shape) { +function createMatrix(cs: ContentStream, shape: Shape) { const matrices = []; if ('translate' in shape && (shape.translate?.x || shape.translate?.y)) { const x = shape.translate.x ?? 0; @@ -187,7 +168,7 @@ function createMatrix(shape: Shape) { } if (matrices.length) { const [a, b, c, d, e, f] = matrices.reduce(multiplyMatrices, [1, 0, 0, 1, 0, 0]); - return concatTransformationMatrix(round(a), round(b), round(c), round(d), round(e), round(f)); + cs.applyTransformMatrix(round(a), round(b), round(c), round(d), round(e), round(f)); } } @@ -195,16 +176,16 @@ function tr(pos: Pos, page: Page): Pos { return { x: pos.x, y: page.size.height - pos.y }; } -function getExtGraphicsStateForShape( - page: Page, - shape: { lineOpacity?: number; fillOpacity?: number }, -): PDFName | undefined { +function getExtGraphicsStateForShape(shape: { + lineOpacity?: number; + fillOpacity?: number; +}): ExtGState | undefined { const { lineOpacity, fillOpacity } = shape; if (lineOpacity != null || fillOpacity != null) { const graphicsParams = { - CA: lineOpacity ?? 1, - ca: fillOpacity ?? 1, + strokeOpacity: lineOpacity ?? 1, + fillOpacity: fillOpacity ?? 1, }; - return getExtGraphicsState(page, graphicsParams); + return new ExtGState(graphicsParams); } } diff --git a/src/render/render-image.test.ts b/src/render/render-image.test.ts index aa31a99..bff1230 100644 --- a/src/render/render-image.test.ts +++ b/src/render/render-image.test.ts @@ -1,10 +1,11 @@ +import { PDFPage } from '@ralfstx/pdf-core'; import { beforeEach, describe, expect, it } from 'vitest'; import type { Size } from '../box.ts'; import type { ImageObject } from '../frame.ts'; import type { Image } from '../images.ts'; import type { Page } from '../page.ts'; -import { fakePDFPage, getContentStream } from '../test/test-utils.ts'; +import { fakeImage, getContentStream } from '../test/test-utils.ts'; import { renderImage } from './render-image.ts'; describe('renderImage', () => { @@ -15,9 +16,9 @@ describe('renderImage', () => { beforeEach(() => { size = { width: 500, height: 800 }; - const pdfPage = fakePDFPage(); + const pdfPage = new PDFPage(size.width, size.height); page = { size, pdfPage } as Page; - image = { url: 'test-url' } as unknown as Image; + image = fakeImage('test-image.jpg', 100, 150); }); it('renders single image object', () => { @@ -25,12 +26,8 @@ describe('renderImage', () => { renderImage(obj, page, pos); - expect(getContentStream(page)).toEqual([ - 'q', - '1 0 0 1 11 738 cm', - '30 0 0 40 0 0 cm', - '/Image-1-0-1 Do', - 'Q', - ]); + expect(getContentStream(page)).toEqual( + ['q', '1 0 0 1 11 738 cm', '30 0 0 40 0 0 cm', '/image:100x150-43348634 Do', 'Q'].join('\n'), + ); }); }); diff --git a/src/render/render-image.ts b/src/render/render-image.ts index 02fc32d..eda24c9 100644 --- a/src/render/render-image.ts +++ b/src/render/render-image.ts @@ -1,25 +1,16 @@ -import type { PDFContentStream } from 'pdf-lib'; -import { drawObject, popGraphicsState, pushGraphicsState, scale, translate } from 'pdf-lib'; - import type { Pos } from '../box.ts'; import type { ImageObject } from '../frame.ts'; import type { Page } from '../page.ts'; -import { addPageImage } from '../page.ts'; -import { compact } from '../utils.ts'; export function renderImage(object: ImageObject, page: Page, base: Pos) { const x = base.x + object.x; const y = page.size.height - base.y - object.y - object.height; const { width, height } = object; - const contentStream: PDFContentStream = (page.pdfPage as any).getContentStream(); - const name = addPageImage(page, object.image); - contentStream.push( - ...compact([ - pushGraphicsState(), - translate(x, y), - scale(width, height), - drawObject(name), - popGraphicsState(), - ]), - ); + + page.pdfPage.contentStream + .saveGraphicsState() + .translate(x, y) + .scale(width, height) + .drawImage(object.image.pdfImage) + .restoreGraphicsState(); } diff --git a/src/render/render-page.test.ts b/src/render/render-page.test.ts index 29de025..c82cc36 100644 --- a/src/render/render-page.test.ts +++ b/src/render/render-page.test.ts @@ -1,19 +1,19 @@ -import type { PDFDocument, PDFPage } from 'pdf-lib'; -import { PDFArray, PDFDict, PDFName, PDFRef } from 'pdf-lib'; +import type { PDFDocument } from '@ralfstx/pdf-core'; +import { PDFPage } from '@ralfstx/pdf-core'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { Size } from '../box.ts'; import type { Font } from '../fonts.ts'; import type { Frame } from '../frame.ts'; import type { Page } from '../page.ts'; -import { fakeFont, fakePDFPage, getContentStream } from '../test/test-utils.ts'; +import { fakeFont, getContentStream } from '../test/test-utils.ts'; import { renderFrame, renderPage } from './render-page.ts'; describe('render-page', () => { let pdfPage: PDFPage; beforeEach(() => { - pdfPage = fakePDFPage(); + pdfPage = new PDFPage(300, 400); }); describe('renderPage', () => { @@ -22,7 +22,7 @@ describe('render-page', () => { beforeEach(() => { size = { width: 300, height: 400 }; - pdfDoc = { addPage: vi.fn().mockReturnValue(pdfPage) } as unknown as PDFDocument; + pdfDoc = { addPage: vi.fn() } as unknown as PDFDocument; }); it('renders content', () => { @@ -32,11 +32,13 @@ describe('render-page', () => { { type: 'graphics', shapes: [{ type: 'rect', x: 0, y: 0, width: 280, height: 300 }] }, ], }; - const page = { size, content }; + const page = { size, content, pdfPage }; 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'); + expect(getContentStream(page)).toEqual( + ['q', '1 0 0 -1 50 350 cm', 'q', '0 0 280 300 re', 'S', 'Q', 'Q'].join('\n'), + ); }); it('renders header', () => { @@ -47,11 +49,13 @@ describe('render-page', () => { { type: 'graphics', shapes: [{ type: 'rect', x: 0, y: 0, width: 280, height: 30 }] }, ], }; - const page = { size, content, header }; + const page = { size, content, header, pdfPage }; 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'); + expect(getContentStream(page)).toEqual( + ['q', '1 0 0 -1 50 380 cm', 'q', '0 0 280 30 re', 'S', 'Q', 'Q'].join('\n'), + ); }); it('renders footer', () => { @@ -62,11 +66,13 @@ describe('render-page', () => { { type: 'graphics', shapes: [{ type: 'rect', x: 0, y: 0, width: 280, height: 30 }] }, ], }; - const page = { size, content, footer }; + const page = { size, content, footer, pdfPage }; 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'); + expect(getContentStream(page)).toEqual( + ['q', '1 0 0 -1 50 50 cm', 'q', '0 0 280 30 re', 'S', 'Q', 'Q'].join('\n'), + ); }); }); @@ -78,7 +84,7 @@ describe('render-page', () => { beforeEach(() => { size = { width: 500, height: 800 }; page = { size, pdfPage } as Page; - font = fakeFont('Test', { doc: pdfPage.doc }); + font = fakeFont('Test'); }); it('renders text objects', () => { @@ -103,8 +109,15 @@ describe('render-page', () => { renderFrame(frame, page); - expect(getContentStream(page).join()).toEqual( - 'BT,1 0 0 1 15 762 Tm,0 0 0 rg,/Test-1 12 Tf,Test text Tj,ET', + expect(getContentStream(page)).toEqual( + [ + 'BT', + '1 0 0 1 15 762 Tm', + '0 0 0 rg', + '/Test-normal-400 12 Tf', + '[<005400650073007400200074006500780074>] TJ', + 'ET', + ].join('\n'), ); }); @@ -136,8 +149,15 @@ describe('render-page', () => { renderFrame(frame, page); // text rendered at (25, 750) + (10, 20) - expect(getContentStream(page).join()).toEqual( - 'BT,1 0 0 1 25 742 Tm,0 0 0 rg,/Test-1 12 Tf,Test text Tj,ET', + expect(getContentStream(page)).toEqual( + [ + 'BT', + '1 0 0 1 25 742 Tm', + '0 0 0 rg', + '/Test-normal-400 12 Tf', + '[<005400650073007400200074006500780074>] TJ', + 'ET', + ].join('\n'), ); }); @@ -149,12 +169,7 @@ describe('render-page', () => { renderFrame(frame, page); - const names = (pdfPage.doc.catalog as any) - .get(PDFName.of('Names')) - .get(PDFName.of('Dests')) - .get(PDFName.of('Names')); - expect(names).toBeInstanceOf(PDFArray); - expect(names.toString()).toEqual('[ (test-dest) [ 1 0 R /XYZ 11 778 null ] ]'); + expect([...page.pdfPage.destinations()]).toEqual([{ name: 'test-dest', x: 11, y: 778 }]); }); it('renders link objects', () => { @@ -165,28 +180,16 @@ describe('render-page', () => { renderFrame(frame, page); - const pageAnnotations = pdfPage.node.get(PDFName.of('Annots')) as PDFArray; - expect(pageAnnotations).toBeInstanceOf(PDFArray); - expect(pageAnnotations.size()).toBe(1); - const ref = pageAnnotations.get(0); - expect(ref).toBeInstanceOf(PDFRef); - const annotation = (pdfPage.doc.context as any).indirectObjects.get(ref); - expect(annotation.toString()).toEqual( - [ - '<<', - '/Type /Annot', - '/Subtype /Link', - '/Rect [ 11 758 91 778 ]', // [10 + 1, 800 - 20 - 20 - 2, 11 + 80, 800 - 20 - 2] - '/A <<', - '/Type /Action', - '/S /URI', - '/URI (test-url)', - '>>', - '/C [ ]', - '/F 4', - '>>', - ].join('\n'), - ); + expect([...page.pdfPage.links()]).toEqual([ + { + height: 20, + type: 'url', + url: 'test-url', + width: 80, + x: 11, + y: 758, + }, + ]); }); it('renders internal link objects', () => { @@ -197,27 +200,16 @@ describe('render-page', () => { renderFrame(frame, page); - const pageAnnotations = pdfPage.node.get(PDFName.of('Annots')) as PDFArray; - expect(pageAnnotations).toBeInstanceOf(PDFArray); - expect(pageAnnotations.size()).toBe(1); - const annotation = pdfPage.doc.context.lookup(pageAnnotations.get(0)) as PDFDict; - expect(annotation).toBeInstanceOf(PDFDict); - expect(annotation.toString()).toEqual( - [ - '<<', - '/Type /Annot', - '/Subtype /Link', - '/Rect [ 11 758 91 778 ]', // [10 + 1, 800 - 20 - 20 - 2, 11 + 80, 800 - 20 - 2] - '/A <<', - '/Type /Action', - '/S /GoTo', - '/D (test-id)', - '>>', - '/C [ ]', - '/F 4', - '>>', - ].join('\n'), - ); + expect([...page.pdfPage.links()]).toEqual([ + { + type: 'destination', + x: 11, + y: 758, + width: 80, + height: 20, + destination: 'test-id', + }, + ]); }); }); }); diff --git a/src/render/render-page.ts b/src/render/render-page.ts index 47d4e8b..8254846 100644 --- a/src/render/render-page.ts +++ b/src/render/render-page.ts @@ -1,15 +1,14 @@ -import type { PDFDocument } from 'pdf-lib'; +import type { PDFDocument } from '@ralfstx/pdf-core'; import type { Pos } from '../box.ts'; -import type { Frame } from '../frame.ts'; +import type { AnchorObject, Frame, LinkObject } from '../frame.ts'; import type { Page } from '../page.ts'; -import { renderAnchor, renderLink } from './render-annotations.ts'; import { renderGraphics } from './render-graphics.ts'; import { renderImage } from './render-image.ts'; import { renderText } from './render-text.ts'; export function renderPage(page: Page, pdfDoc: PDFDocument) { - page.pdfPage = pdfDoc.addPage([page.size.width, page.size.height]); + pdfDoc.addPage(page.pdfPage); if (page.header) renderFrame(page.header, page); renderFrame(page.content, page); if (page.footer) renderFrame(page.footer, page); @@ -38,3 +37,23 @@ export function renderFrame(frame: Frame, page: Page, base?: Pos) { renderFrame(frame, page, topLeft); }); } + +function renderAnchor(obj: AnchorObject, page: Page, base: Pos) { + const name = obj.name; + const x = base.x + obj.x; + const y = page.size.height - base.y - obj.y; + page.pdfPage.addDestination({ name, x, y }); +} + +function renderLink(obj: LinkObject, page: Page, base: Pos) { + const { width, height, url } = obj; + const x = base.x + obj.x; + const y = page.size.height - base.y - obj.y - height; + if (url.startsWith('#')) { + // Internal link + page.pdfPage.addLink({ type: 'destination', x, y, width, height, destination: url.slice(1) }); + } else { + // External link + page.pdfPage.addLink({ type: 'url', x, y, width, height, url }); + } +} diff --git a/src/render/render-text.test.ts b/src/render/render-text.test.ts index b5c7739..c5c2670 100644 --- a/src/render/render-text.test.ts +++ b/src/render/render-text.test.ts @@ -1,10 +1,11 @@ +import { PDFPage } from '@ralfstx/pdf-core'; import { beforeEach, describe, expect, it } from 'vitest'; import type { Size } from '../box.ts'; import type { Font } from '../fonts.ts'; import type { TextObject } from '../frame.ts'; import type { Page } from '../page.ts'; -import { fakeFont, fakePDFPage, getContentStream } from '../test/test-utils.ts'; +import { fakeFont, getContentStream } from '../test/test-utils.ts'; import { renderText } from './render-text.ts'; describe('render-text', () => { @@ -14,9 +15,9 @@ describe('render-text', () => { beforeEach(() => { size = { width: 500, height: 800 }; - const pdfPage = fakePDFPage(); + const pdfPage = new PDFPage(size.width, size.height); page = { size, pdfPage } as Page; - font = fakeFont('fontA', { doc: pdfPage.doc }); + font = fakeFont('fontA'); }); describe('renderText', () => { @@ -31,14 +32,16 @@ describe('render-text', () => { renderText(obj, page, pos); - expect(getContentStream(page)).toEqual([ - 'BT', - '1 0 0 1 11 770 Tm', - '0 0 0 rg', - '/fontA-1 10 Tf', - 'foo Tj', - 'ET', - ]); + expect(getContentStream(page)).toEqual( + [ + 'BT', + '1 0 0 1 11 770 Tm', + '0 0 0 rg', + '/fontA-normal-400 10 Tf', + '[<0066006F006F>] TJ', + 'ET', + ].join('\n'), + ); }); it('renders text rise', () => { @@ -53,19 +56,21 @@ describe('render-text', () => { renderText(obj, page, pos); - expect(getContentStream(page)).toEqual([ - 'BT', - '1 0 0 1 11 770 Tm', - '0 0 0 rg', - '/fontA-1 10 Tf', - 'aaa Tj', - '3 Ts', // set text rise - 'bbb Tj', - 'ccc Tj', - '0 Ts', // reset text rise - 'ddd Tj', - 'ET', - ]); + expect(getContentStream(page)).toEqual( + [ + 'BT', + '1 0 0 1 11 770 Tm', + '0 0 0 rg', + '/fontA-normal-400 10 Tf', + '[<006100610061>] TJ', + '3 Ts', // set text rise + '[<006200620062>] TJ', + '[<006300630063>] TJ', + '0 Ts', // reset text rise + '[<006400640064>] TJ', + 'ET', + ].join('\n'), + ); }); it('renders letter spacing', () => { @@ -82,19 +87,21 @@ describe('render-text', () => { renderText(obj, page, pos); - expect(getContentStream(page)).toEqual([ - 'BT', - '1 0 0 1 11 770 Tm', - '0 0 0 rg', - '/fontA-1 10 Tf', - 'aaa Tj', - '3 Tc', // set letter spacing - 'bbb Tj', - 'ccc Tj', - '0 Tc', // reset letter spacing - 'ddd Tj', - 'ET', - ]); + expect(getContentStream(page)).toEqual( + [ + 'BT', + '1 0 0 1 11 770 Tm', + '0 0 0 rg', + '/fontA-normal-400 10 Tf', + '[<006100610061>] TJ', + '3 Tc', // set letter spacing + '[<006200620062>] TJ', + '[<006300630063>] TJ', + '0 Tc', // reset letter spacing + '[<006400640064>] TJ', + 'ET', + ].join('\n'), + ); }); it('maintains text state throughout page', () => { @@ -118,24 +125,28 @@ describe('render-text', () => { renderText(obj2, page, pos); renderText(obj3, page, pos); - expect(getContentStream(page)).toEqual([ - 'BT', - '1 0 0 1 11 770 Tm', - '0 0 0 rg', - '/fontA-1 10 Tf', - '3 Ts', // set text rise - 'aaa Tj', - 'ET', - 'BT', - '1 0 0 1 11 770 Tm', - 'bbb Tj', - 'ET', - 'BT', - '1 0 0 1 13 768 Tm', - '0 Ts', // reset text rise - 'ccc Tj', - 'ET', - ]); + expect(getContentStream(page)).toEqual( + [ + 'BT', + '1 0 0 1 11 770 Tm', + '0 0 0 rg', + '/fontA-normal-400 10 Tf', + '3 Ts', // set text rise + '[<006100610061>] TJ', + 'ET', + 'BT', + '1 0 0 1 11 770 Tm', + '/fontA-normal-400 10 Tf', + '[<006200620062>] TJ', + 'ET', + 'BT', + '1 0 0 1 13 768 Tm', + '/fontA-normal-400 10 Tf', + '0 Ts', // reset text rise + '[<006300630063>] TJ', + 'ET', + ].join('\n'), + ); }); it('renders multiple rows with multiple text segments', () => { @@ -151,18 +162,20 @@ describe('render-text', () => { renderText(obj, page, pos); - expect(getContentStream(page)).toEqual([ - 'BT', - '1 0 0 1 11 770 Tm', - '0 0 0 rg', - '/fontA-1 10 Tf', - 'foo Tj', - 'bar Tj', - '1 0 0 1 11 754 Tm', - 'foo Tj', - 'bar Tj', - 'ET', - ]); + expect(getContentStream(page)).toEqual( + [ + 'BT', + '1 0 0 1 11 770 Tm', + '0 0 0 rg', + '/fontA-normal-400 10 Tf', + '[<0066006F006F>] TJ', + '[<006200610072>] TJ', + '1 0 0 1 11 754 Tm', + '[<0066006F006F>] TJ', + '[<006200610072>] TJ', + 'ET', + ].join('\n'), + ); }); }); }); diff --git a/src/render/render-text.ts b/src/render/render-text.ts index ebda674..50d9496 100644 --- a/src/render/render-text.ts +++ b/src/render/render-text.ts @@ -1,80 +1,69 @@ -import type { Color, PDFContentStream, PDFName, PDFOperator } from 'pdf-lib'; -import { - beginText, - endText, - rgb, - setCharacterSpacing, - setFillingColor, - setFontAndSize, - setTextMatrix, - setTextRise, - showText, -} from 'pdf-lib'; +import type { ContentStream, PDFFont } from '@ralfstx/pdf-core'; import type { Pos } from '../box.ts'; -import { findRegisteredFont } from '../fonts.ts'; +import { type Color, rgb, setFillingColor } from '../colors.ts'; import type { TextObject } from '../frame.ts'; import type { Page, TextState } from '../page.ts'; -import { addPageFont } from '../page.ts'; -import { compact } from '../utils.ts'; export function renderText(object: TextObject, page: Page, base: Pos) { - const contentStream: PDFContentStream = (page.pdfPage as any).getContentStream(); page.textState ??= {}; const state = page.textState; const x = base.x; const y = page.size.height - base.y; - contentStream.push(beginText()); + const cs = page.pdfPage.contentStream; + cs.beginText(); + // Reset font state so that the font is always set after beginText() + state.font = undefined; + state.size = undefined; object.rows?.forEach((row) => { - contentStream.push(setTextMatrix(1, 0, 0, 1, x + row.x, y - row.y - row.baseline)); + cs.setTextMatrix(1, 0, 0, 1, x + row.x, y - row.y - row.baseline); row.segments?.forEach((seg) => { - const fontKey = addPageFont(page, seg.font); - const pdfFont = findRegisteredFont(seg.font, page.pdfPage!.doc)!; - const encodedText = pdfFont.encodeText(seg.text); - const operators = compact([ - setTextColorOp(state, seg.color), - setTextFontAndSizeOp(state, fontKey, seg.fontSize), - setTextRiseOp(state, seg.rise), - setLetterSpacingOp(state, seg.letterSpacing), - showText(encodedText), - ]); - contentStream.push(...operators); + const pdfFont = seg.font.pdfFont; + if (!pdfFont) throw new Error('PDF font not initialized'); + const glyphRun = pdfFont.shapeText(seg.text, { defaultFeatures: false }); + + setTextColorOp(cs, state, seg.color); + setTextFontAndSizeOp(cs, state, pdfFont, seg.fontSize); + setTextRiseOp(cs, state, seg.rise); + setLetterSpacingOp(cs, state, seg.letterSpacing); + cs.showPositionedText(glyphRun); }); }); - contentStream.push(endText()); + cs.endText(); } -function setTextColorOp(state: TextState, color?: Color): PDFOperator | undefined { +function setTextColorOp(cs: ContentStream, state: TextState, color?: Color): void { const effectiveColor = color ?? rgb(0, 0, 0); if (!equalsColor(state.color, effectiveColor)) { state.color = effectiveColor; - return setFillingColor(effectiveColor); + setFillingColor(cs, effectiveColor); } } function setTextFontAndSizeOp( + cs: ContentStream, state: TextState, - font: PDFName, + font: PDFFont, size: number, -): PDFOperator | undefined { - if (state.font !== font?.toString() || state.size !== size) { - state.font = font?.toString(); +): void { + if (state.font !== font?.key || state.size !== size) { + state.font = font?.key; state.size = size; - return setFontAndSize(font, size); + cs.setFontAndSize(font, size); } } -function setTextRiseOp(state: TextState, rise?: number): PDFOperator | undefined { +function setTextRiseOp(cs: ContentStream, state: TextState, rise?: number): void { if ((state.rise ?? 0) !== (rise ?? 0)) { state.rise = rise; - return setTextRise(rise ?? 0); + cs.setTextRise(rise ?? 0); } } -function setLetterSpacingOp(state: TextState, charSpace?: number): PDFOperator | undefined { +function setLetterSpacingOp(cs: ContentStream, state: TextState, charSpace?: number): void { if ((state.charSpace ?? 0) !== (charSpace ?? 0)) { state.charSpace = charSpace; - return setCharacterSpacing(charSpace ?? 0); + cs.setCharacterSpacing(charSpace ?? 0); } } diff --git a/src/svg-paths.test.ts b/src/svg-paths.test.ts index cd81220..7468581 100644 --- a/src/svg-paths.test.ts +++ b/src/svg-paths.test.ts @@ -1,6 +1,7 @@ +import { ContentStream } from '@ralfstx/pdf-core'; import { describe, expect, it } from 'vitest'; -import { parseSvgPath, svgPathToPdfOps, tokenizeSvgPath } from './svg-paths.ts'; +import { drawSvgPath, parseSvgPath, tokenizeSvgPath } from './svg-paths.ts'; describe('tokenize', () => { it('returns empty list of tokens', () => { @@ -125,8 +126,14 @@ describe('parseSvgPath', () => { }); }); -describe('svgPathToPdfOps', () => { - const pdfOps = (path: string) => svgPathToPdfOps(parseSvgPath(path)).map(String); +describe('drawSvgPath', () => { + const pdfOps = (path: string) => { + const cs = new ContentStream(); + drawSvgPath(cs, parseSvgPath(path)); + return cs.instructions.map((instr) => + [...instr.operands, instr.operator].map(String).join(' '), + ); + }; it('creates ops for M', () => { expect(pdfOps('M 1 2')).toEqual(['1 2 m']); diff --git a/src/svg-paths.ts b/src/svg-paths.ts index f815f3f..a4b8dc6 100644 --- a/src/svg-paths.ts +++ b/src/svg-paths.ts @@ -1,5 +1,4 @@ -import type { PDFOperator } from 'pdf-lib'; -import { appendBezierCurve, appendQuadraticCurve, closePath, lineTo, moveTo } from 'pdf-lib'; +import type { ContentStream } from '@ralfstx/pdf-core'; import { arcToSegments, segmentToBezier } from './arcs.ts'; @@ -105,7 +104,7 @@ export function parseSvgPath(path: string) { return commands; } -export function svgPathToPdfOps(commands: PathCommand[]): PDFOperator[] { +export function drawSvgPath(cs: ContentStream, commands: PathCommand[]): void { let cx = 0; let cy = 0; let px = 0; @@ -116,13 +115,13 @@ export function svgPathToPdfOps(commands: PathCommand[]): PDFOperator[] { cx = x; cy = y; lastCurve = undefined; - return [moveTo(cx, cy)]; + cs.moveTo(cx, cy); }; const opLineTo = (x: number, y: number) => { cx = x; cy = y; lastCurve = undefined; - return [lineTo(cx, cy)]; + cs.lineTo(cx, cy); }; const opBezierCurve = (x1: number, y1: number, x2: number, y2: number, x: number, y: number) => { cx = x; @@ -130,7 +129,7 @@ export function svgPathToPdfOps(commands: PathCommand[]): PDFOperator[] { px = x2; py = y2; lastCurve = 'b'; - return [appendBezierCurve(x1, y1, x2, y2, cx, cy)]; + cs.curveTo(x1, y1, x2, y2, cx, cy); }; const opQuadraticCurve = (x1: number, y1: number, x: number, y: number) => { cx = x; @@ -138,84 +137,84 @@ export function svgPathToPdfOps(commands: PathCommand[]): PDFOperator[] { px = x1; py = y1; lastCurve = 'q'; - return [appendQuadraticCurve(x1, y1, cx, cy)]; + cs.smoothCurveToFinal(x1, y1, cx, cy); }; const opArc = (rx: number, ry: number, a: number, l: number, s: number, x: number, y: number) => { const segments = arcToSegments(cx, cy, rx, ry, a, l, s, x, y); cx = x; cy = y; lastCurve = undefined; - return segments.flatMap((seg) => appendBezierCurve(...segmentToBezier(seg))); + segments.forEach((seg) => cs.curveTo(...segmentToBezier(seg))); }; const opClosePath = () => { lastCurve = undefined; - return [closePath()]; + cs.closePath(); }; const mirrorCx = (type: 'b' | 'q') => (lastCurve === type ? 2 * cx - px : cx); const mirrorCy = (type: 'b' | 'q') => (lastCurve === type ? 2 * cy - py : cy); - const ops: Record PDFOperator[]> = { + const ops: Record void> = { M: ([x, y]: number[]) => { - return opMoveTo(x, y); + opMoveTo(x, y); }, m: ([dx, dy]: number[]) => { - return opMoveTo(cx + dx, cy + dy); + opMoveTo(cx + dx, cy + dy); }, L: ([x, y]: number[]) => { - return opLineTo(x, y); + opLineTo(x, y); }, l: ([dx, dy]: number[]) => { - return opLineTo(cx + dx, cy + dy); + opLineTo(cx + dx, cy + dy); }, H: ([x]: number[]) => { - return opLineTo(x, cy); + opLineTo(x, cy); }, h: ([dx]: number[]) => { - return opLineTo(cx + dx, cy); + opLineTo(cx + dx, cy); }, V: ([y]: number[]) => { - return opLineTo(cx, y); + opLineTo(cx, y); }, v: ([dy]: number[]) => { - return opLineTo(cx, cy + dy); + opLineTo(cx, cy + dy); }, C: ([x1, y1, x2, y2, x, y]: number[]) => { - return opBezierCurve(x1, y1, x2, y2, x, y); + opBezierCurve(x1, y1, x2, y2, x, y); }, c: ([dx1, dy1, dx2, dy2, dx, dy]: number[]) => { - return opBezierCurve(cx + dx1, cy + dy1, cx + dx2, cy + dy2, cx + dx, cy + dy); + opBezierCurve(cx + dx1, cy + dy1, cx + dx2, cy + dy2, cx + dx, cy + dy); }, S: ([x2, y2, x, y]: number[]) => { - return opBezierCurve(mirrorCx('b'), mirrorCy('b'), x2, y2, x, y); + opBezierCurve(mirrorCx('b'), mirrorCy('b'), x2, y2, x, y); }, s: ([dx2, dy2, dx, dy]: number[]) => { - return opBezierCurve(mirrorCx('b'), mirrorCy('b'), cx + dx2, cy + dy2, cx + dx, cy + dy); + opBezierCurve(mirrorCx('b'), mirrorCy('b'), cx + dx2, cy + dy2, cx + dx, cy + dy); }, Q: ([x1, y1, x, y]: number[]) => { - return opQuadraticCurve(x1, y1, x, y); + opQuadraticCurve(x1, y1, x, y); }, q: ([dx1, dy1, dx, dy]: number[]) => { - return opQuadraticCurve(cx + dx1, cy + dy1, cx + dx, cy + dy); + opQuadraticCurve(cx + dx1, cy + dy1, cx + dx, cy + dy); }, T: ([x, y]: number[]) => { - return opQuadraticCurve(mirrorCx('q'), mirrorCy('q'), x, y); + opQuadraticCurve(mirrorCx('q'), mirrorCy('q'), x, y); }, t: ([dx, dy]: number[]) => { - return opQuadraticCurve(mirrorCx('q'), mirrorCy('q'), cx + dx, cy + dy); + opQuadraticCurve(mirrorCx('q'), mirrorCy('q'), cx + dx, cy + dy); }, A: ([rx, ry, angle, largeArc, sweep, x, y]: number[]) => { - return opArc(rx, ry, angle, largeArc, sweep, x, y); + opArc(rx, ry, angle, largeArc, sweep, x, y); }, a: ([rx, ry, angle, largeArc, sweep, dx, dy]: number[]) => { - return opArc(rx, ry, angle, largeArc, sweep, cx + dx, cy + dy); + opArc(rx, ry, angle, largeArc, sweep, cx + dx, cy + dy); }, Z: () => { - return opClosePath(); + opClosePath(); }, z: () => { - return opClosePath(); + opClosePath(); }, - } as Record PDFOperator[]>; + } as Record void>; - return commands.flatMap(({ op, params }) => ops[op](params)); + commands.forEach(({ op, params }) => ops[op](params)); } diff --git a/src/test/test-utils.ts b/src/test/test-utils.ts index a7f62ca..8363d34 100644 --- a/src/test/test-utils.ts +++ b/src/test/test-utils.ts @@ -1,5 +1,5 @@ -import type { PDFDocument, PDFFont, PDFPage } from 'pdf-lib'; -import { PDFContext, PDFName, PDFRef } from 'pdf-lib'; +import type { PDFFont } from '@ralfstx/pdf-core'; +import { PDFImage, PDFRef } from '@ralfstx/pdf-core'; import type { Font } from '../fonts.ts'; import { weightToNumber } from '../fonts.ts'; @@ -8,44 +8,76 @@ import type { Image } from '../images.ts'; import type { Page } from '../page.ts'; import type { TextAttrs, TextSpan } from '../read-block.ts'; -export function fakeFont( - name: string, - opts: Partial> & { doc?: PDFDocument } = {}, -): Font { +export function fakeFont(name: string, opts?: Partial>): Font { const key = `${name}-${opts?.style ?? 'normal'}-${opts?.weight ?? 400}`; const font: Font = { key, name, style: opts?.style ?? 'normal', weight: weightToNumber(opts?.weight ?? 'normal'), - data: opts.data ?? mkData(key), - fkFont: fakeFkFont(key), + pdfFont: fakePdfFont(key), }; - if (opts.doc) { - const pdfFont = fakePdfFont(name, font.fkFont); - (opts.doc as any).fonts.push(pdfFont); - (opts.doc as any)._pdfmkr_registeredFonts ??= {}; - (opts.doc as any)._pdfmkr_registeredFonts[font.key] = pdfFont.ref; - } return font; } export function fakeImage(name: string, width: number, height: number): Image { + const data = createTestJpeg(width, height); return { name, width, height, - data: mkData(name), - } as any; + format: 'jpeg', + url: 'test', + pdfImage: PDFImage.fromJpeg(data), + } as Image; } -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; +/** + * Creates a minimal JPEG structure with the specified dimensions and + * color components for testing header parsing. Note: This does not + * include DHT (Huffman tables) or DQT (quantization tables), so it + * cannot be decoded as an actual image. + */ +export function createTestJpeg( + width: number, + height: number, + components: 1 | 3 | 4 = 3, +): Uint8Array { + const bytes: number[] = []; + + // SOI (Start of Image) + bytes.push(0xff, 0xd8); + + // APP0 (JFIF marker) - optional but common + bytes.push(0xff, 0xe0); // APP0 marker + bytes.push(0x00, 0x10); // Length (16 bytes) + bytes.push(0x4a, 0x46, 0x49, 0x46, 0x00); // "JFIF\0" + bytes.push(0x01, 0x01); // Version 1.1 + bytes.push(0x00); // Aspect ratio units (0 = no units) + bytes.push(0x00, 0x01); // X density + bytes.push(0x00, 0x01); // Y density + bytes.push(0x00, 0x00); // Thumbnail dimensions + + // SOF0 (Start of Frame - Baseline DCT) + bytes.push(0xff, 0xc0); // SOF0 marker + const sofLength = 8 + components * 3; + bytes.push((sofLength >> 8) & 0xff, sofLength & 0xff); // Length + bytes.push(0x08); // Bits per component (8) + bytes.push((height >> 8) & 0xff, height & 0xff); // Height + bytes.push((width >> 8) & 0xff, width & 0xff); // Width + bytes.push(components); // Number of components + + // Component specifications + for (let i = 1; i <= components; i++) { + bytes.push(i); // Component ID + bytes.push(0x11); // Sampling factors (1x1) + bytes.push(0x00); // Quantization table selector + } + + // EOI (End of Image) + bytes.push(0xff, 0xd9); + + return new Uint8Array(bytes); } /** @@ -54,43 +86,24 @@ export function fakePdfFont(name: string, fkFont: fontkit.Font): PDFFont { * a length of `10 * 5 = 50`. * Likewise, the descent is set to amount to `0.2 * fontSize`. */ -export function fakeFkFont(name: string): fontkit.Font { +export function fakePdfFont(key: string): PDFFont { return { - name, - unitsPerEm: 1000, - maxY: 800, - descent: -200, + key, + fontName: 'FakeFont:' + key, + familyName: 'FakeFamily', + style: 'normal', + weight: 400, ascent: 800, - bbox: { minY: -200, maxY: 800 }, - layout: (text: string) => ({ - glyphs: text.split('').map((c) => ({ advanceWidth: 1000, id: c.charCodeAt(0) })), - }), - } as any; -} - -export function fakePDFDocument(): PDFDocument { - const context = PDFContext.create(); - const catalog = context.obj({}); - const doc = { context, catalog, fonts: [], images: [] }; - return doc as any; -} - -export function fakePDFPage(document?: PDFDocument): PDFPage { - const doc = document ?? fakePDFDocument(); - const node = doc.context.obj({}); - const contentStream: any[] = []; - let counter = 1; - (node as any).newFontDictionary = (name: string) => PDFName.of(`${name}-${counter++}`); - let xObjectCounter = 1; - (node as any).newXObject = (tag: string, ref: PDFRef) => - PDFName.of(`${tag}-${ref.objectNumber}-${ref.generationNumber}-${xObjectCounter++}`); - (node as any).newExtGState = (type: string) => PDFName.of(`${type}-${counter++}`); - return { - doc, - ref: PDFRef.of(1), - getContentStream: () => contentStream, - node, - } as unknown as PDFPage; + descent: -200, + lineGap: 0, + shapeText: (text: string) => + [...text].map((c) => ({ + glyphId: c.charCodeAt(0), + codePoints: [c.codePointAt(0)!], + advance: 1000, + })), + register: () => PDFRef.of(23), + }; } export function extractTextRows(frame: Partial) { @@ -121,8 +134,20 @@ export function p(x: number, y: number) { } export function getContentStream(page: Page) { - const contentStream = (page.pdfPage as any).getContentStream(); - return contentStream.map((o: any) => o.toString()); + return page.pdfPage.contentStream.instructions + .map((instruction) => { + return [ + ...instruction.operands.map((operand) => + operand !== undefined + ? 'key' in operand + ? '/' + operand.key + : operand.toString() + : 'undefined', + ), + instruction.operator, + ].join(' '); + }) + .join('\n'); } export function mkData(value: string) { diff --git a/src/text.test.ts b/src/text.test.ts index 1bf624c..3feee9c 100644 --- a/src/text.test.ts +++ b/src/text.test.ts @@ -1,6 +1,6 @@ -import { rgb } from 'pdf-lib'; import { beforeEach, describe, expect, it } from 'vitest'; +import { rgb } from './colors.ts'; import { FontStore } from './font-store.ts'; import type { Font } from './fonts.ts'; import { fakeFont } from './test/test-utils.ts'; diff --git a/src/text.ts b/src/text.ts index 307df81..3a42af3 100644 --- a/src/text.ts +++ b/src/text.ts @@ -43,13 +43,13 @@ export async function extractTextSegments( letterSpacing, } = attrs; const font = await fontStore.selectFont({ fontFamily, fontStyle, fontWeight }); - const height = getTextHeight(font.fkFont, fontSize); + const height = getTextHeight(font.pdfFont, fontSize); return splitChunks(text).map( (text) => ({ text, - width: getTextWidth(text, font.fkFont, fontSize) + text.length * (letterSpacing ?? 0), + width: getTextWidth(text, font.pdfFont, fontSize) + text.length * (letterSpacing ?? 0), height, lineHeight, font,