Skip to content

Commit c85ddbb

Browse files
committed
WIP: Use pdf-core
1 parent 8a61615 commit c85ddbb

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

45 files changed

+979
-1407
lines changed

CHANGELOG.md

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,21 @@
11
# Changelog
22

3-
## [0.5.7] - Unreleased
3+
## [0.6.0] - Unreleased
4+
5+
### Changed
6+
7+
- Replaced `pdf-lib` with `@ralfstx/pdf-core` as the underlying PDF
8+
generation library. This results in faster PDF generation and a
9+
smaller bundle size. It also opens up new possibilities for new
10+
features such as font shaping.
11+
12+
### Breaking
13+
14+
- Text height is now based on the OS/2 typographic metrics
15+
(`sTypoAscender` / `sTypoDescender`) instead of the hhea table values.
16+
This results in tighter line spacing for fonts whose hhea values
17+
include extra spacing that was effectively double-counted with the
18+
`lineHeight` multiplier.
419

520
## [0.5.6] - 2025-01-19
621

package-lock.json

Lines changed: 27 additions & 52 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,16 +25,15 @@
2525
"npm": ">=10"
2626
},
2727
"scripts": {
28-
"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/",
28+
"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/",
2929
"lint": "eslint . --max-warnings=0 && prettier --check .",
3030
"test": "vitest run test",
3131
"format": "prettier -w .",
3232
"fix": "eslint . --fix && prettier -w .",
3333
"examples": "./examples/run-all-examples.sh"
3434
},
3535
"dependencies": {
36-
"@pdf-lib/fontkit": "^1.1.1",
37-
"pdf-lib": "^1.17.1"
36+
"@ralfstx/pdf-core": "file:../pdf-core"
3837
},
3938
"devDependencies": {
4039
"@types/node": "^25.0.3",

src/api/PdfMaker.test.ts

Lines changed: 1 addition & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
import { readFile } from 'node:fs/promises';
22
import { join } from 'node:path';
33

4-
import { beforeEach, describe, expect, it, vi } from 'vitest';
4+
import { beforeEach, describe, expect, it } from 'vitest';
55

6-
import { image, text } from './layout.ts';
76
import { PdfMaker } from './PdfMaker.ts';
87

98
describe('makePdf', () => {
@@ -31,25 +30,4 @@ describe('makePdf', () => {
3130
const string = Buffer.from(pdf.buffer).toString();
3231
expect(string).toMatch(/[^\n]\n$/);
3332
});
34-
35-
it('includes a trailer ID in the document', async () => {
36-
const pdf = await pdfMaker.makePdf({ content: [{}] });
37-
38-
const string = Buffer.from(pdf.buffer).toString();
39-
expect(string).toMatch(/\/ID \[ <[0-9A-F]{64}> <[0-9A-F]{64}> \]/);
40-
});
41-
42-
it('creates consistent results across runs', async () => {
43-
// ensure same timestamps in generated PDF
44-
vi.useFakeTimers();
45-
// include fonts and images to ensure they can be reused
46-
const content = [text('Test'), image('file:/torus.png')];
47-
48-
const pdf1 = await pdfMaker.makePdf({ content });
49-
const pdf2 = await pdfMaker.makePdf({ content });
50-
51-
const pdfStr1 = Buffer.from(pdf1.buffer).toString();
52-
const pdfStr2 = Buffer.from(pdf2.buffer).toString();
53-
expect(pdfStr1).toEqual(pdfStr2);
54-
});
5533
});

src/binary-data.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,11 @@ describe('parseBinaryData', () => {
1414
});
1515

1616
it('returns Uint8Array for base64-encoded string', () => {
17-
expect(parseBinaryData('Abc=`')).toEqual(data);
17+
expect(parseBinaryData('AbcA')).toEqual(data);
1818
});
1919

2020
it('returns Uint8Array for data URL', () => {
21-
expect(parseBinaryData('data:image/jpeg;base64,Abc=`')).toEqual(data);
21+
expect(parseBinaryData('data:image/jpeg;base64,AbcA')).toEqual(data);
2222
});
2323

2424
it('throws for arrays', () => {

src/binary-data.ts

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
import { decodeFromBase64DataUri } from 'pdf-lib';
2-
1+
import { decodeBase64 } from './base64.ts';
32
import { typeError } from './types.ts';
43

54
export function parseBinaryData(input: unknown): Uint8Array {
@@ -8,3 +7,32 @@ export function parseBinaryData(input: unknown): Uint8Array {
87
if (typeof input === 'string') return decodeFromBase64DataUri(input);
98
throw typeError('Uint8Array, ArrayBuffer, or base64-encoded string', input);
109
}
10+
11+
// Taken from pdf-lib
12+
13+
// This regex is designed to be as flexible as possible. It will parse certain
14+
// invalid data URIs.
15+
const DATA_URI_PREFIX_REGEX = /^(data)?:?([\w/+]+)?;?(charset=[\w-]+|base64)?.*,/i;
16+
17+
/**
18+
* If the `dataUri` input is a data URI, then the data URI prefix must not be
19+
* longer than 100 characters, or this function will fail to decode it.
20+
*
21+
* @param dataUri a base64 data URI or plain base64 string
22+
* @returns a Uint8Array containing the decoded input
23+
*/
24+
export const decodeFromBase64DataUri = (dataUri: string): Uint8Array => {
25+
const trimmedUri = dataUri.trim();
26+
27+
const prefix = trimmedUri.substring(0, 100);
28+
const res = prefix.match(DATA_URI_PREFIX_REGEX);
29+
30+
// Assume it's not a data URI - just a plain base64 string
31+
if (!res) return decodeBase64(trimmedUri);
32+
33+
// Remove the data URI prefix and parse the remainder as a base64 string
34+
const [fullMatch] = res;
35+
const data = trimmedUri.substring(fullMatch.length);
36+
37+
return decodeBase64(data);
38+
};

src/colors.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import type { ContentStream } from '@ralfstx/pdf-core';
2+
3+
export type ColorType = 'Grayscale' | 'RGB' | 'CMYK';
4+
5+
export type Grayscale = {
6+
type: 'Grayscale';
7+
gray: number;
8+
};
9+
10+
export type RGB = {
11+
type: 'RGB';
12+
red: number;
13+
green: number;
14+
blue: number;
15+
};
16+
17+
export type CMYK = {
18+
type: 'CMYK';
19+
cyan: number;
20+
magenta: number;
21+
yellow: number;
22+
key: number;
23+
};
24+
25+
export type Color = Grayscale | RGB | CMYK;
26+
27+
export const grayscale = (gray: number): Grayscale => {
28+
assertRange(gray, 'gray', 0.0, 1.0);
29+
return { type: 'Grayscale', gray };
30+
};
31+
32+
export const rgb = (red: number, green: number, blue: number): RGB => {
33+
assertRange(red, 'red', 0, 1);
34+
assertRange(green, 'green', 0, 1);
35+
assertRange(blue, 'blue', 0, 1);
36+
return { type: 'RGB', red, green, blue };
37+
};
38+
39+
export const cmyk = (cyan: number, magenta: number, yellow: number, key: number): CMYK => {
40+
assertRange(cyan, 'cyan', 0, 1);
41+
assertRange(magenta, 'magenta', 0, 1);
42+
assertRange(yellow, 'yellow', 0, 1);
43+
assertRange(key, 'key', 0, 1);
44+
return { type: 'CMYK', cyan, magenta, yellow, key };
45+
};
46+
47+
export function setFillingColor(cs: ContentStream, color: Color): void {
48+
if (color.type === 'Grayscale') {
49+
cs.setFillGray(color.gray);
50+
} else if (color.type === 'RGB') {
51+
cs.setFillRGB(color.red, color.green, color.blue);
52+
} else if (color.type === 'CMYK') {
53+
cs.setFillCMYK(color.cyan, color.magenta, color.yellow, color.key);
54+
} else throw new Error(`Invalid color: ${JSON.stringify(color)}`);
55+
}
56+
57+
export function setStrokingColor(cs: ContentStream, color: Color): void {
58+
if (color.type === 'Grayscale') {
59+
cs.setStrokeGray(color.gray);
60+
} else if (color.type === 'RGB') {
61+
cs.setStrokeRGB(color.red, color.green, color.blue);
62+
} else if (color.type === 'CMYK') {
63+
cs.setStrokeCMYK(color.cyan, color.magenta, color.yellow, color.key);
64+
} else throw new Error(`Invalid color: ${JSON.stringify(color)}`);
65+
}
66+
67+
function assertRange(value: number, valueName: string, min: number, max: number) {
68+
if (typeof value !== 'number' || value < min || value > max) {
69+
throw new Error(`${valueName} must be a number between ${min} and ${max}, got: ${value}`);
70+
}
71+
}

src/font-metrics.ts

Lines changed: 12 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,16 @@
1-
import type { Font } from '@pdf-lib/fontkit';
1+
import type { PDFFont } from '@ralfstx/pdf-core';
22

3-
export function getTextWidth(text: string, font: Font, fontSize: number): number {
4-
const { glyphs } = font.layout(text);
5-
const scale = 1000 / font.unitsPerEm;
6-
let totalWidth = 0;
7-
for (let idx = 0, len = glyphs.length; idx < len; idx++) {
8-
totalWidth += glyphs[idx].advanceWidth * scale;
9-
}
10-
return (totalWidth * fontSize) / 1000;
3+
export function getTextWidth(text: string, font: PDFFont, fontSize: number): number {
4+
const glyphs = font.shapeText(text, { defaultFeatures: false });
5+
return glyphs.reduce(
6+
(sum, glyph) => sum + (glyph.advance + (glyph.advanceAdjust ?? 0)) * (fontSize / 1000),
7+
0,
8+
);
119
}
1210

13-
export function getTextHeight(font: Font, fontSize: number): number {
14-
const { ascent, descent, bbox } = font;
15-
const scale = 1000 / font.unitsPerEm;
16-
const yTop = (ascent || bbox.maxY) * scale;
17-
const yBottom = (descent || bbox.minY) * scale;
18-
const height = yTop - yBottom;
19-
return (height / 1000) * fontSize;
11+
export function getTextHeight(font: PDFFont, fontSize: number): number {
12+
const ascent = font.ascent;
13+
const descent = font.descent;
14+
const height = ascent - descent;
15+
return (height * fontSize) / 1000;
2016
}

0 commit comments

Comments
 (0)