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,