diff --git a/CHANGELOG.md b/CHANGELOG.md index d2c1a97c6..dfa5638a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ### Unreleased +- Fix the Node build throwing once a bundler inlines it into an application bundle, `Cannot find module '#standard-fonts/Helvetica'` on the first document from an ESM bundle and `Invalid URL` while importing a CommonJS one, because the standard font metrics and the PDF/A ICC profile were resolved relative to pdfkit's own package directory at import time. Both are now resolved on first use, a standard font that cannot be loaded names itself and the fix in its error, and `registerStdFonts` is exported from the Node build as it already was from the browser build, so a bundle can register the fonts it uses from `pdfkit/standard-fonts/`. The Node build now requires the standard fonts by a path relative to the built file, which replaces the `#standard-fonts/*` import mapping and lets an ESM bundle load them from a copy of `js/standard-fonts` next to it. Fixes #1801 - Fix `doc.list()` drawing the label of a `numbered` or `lettered` item with the line wrapper's own options object, which threw `unsupported number: NaN` for `align` `center` and `right`, applied a word spacing derived from the previous line to the label for `align` `justify`, and sized the underline, strike, link and goTo of every label after the first from the previous item's text - Add a `hidden` option to form annotation methods, for a field that should start hidden (e.g. one an interactive action reveals later) instead of the usual default of visible and printable - Fix annotations placed under `doc.rotate()` marking the wrong area, because `_convertRect` derived each corner's y from the already transformed x and mapped only two of the four corners, so the rectangle a viewer makes interactive did not follow the rotated content. Fixes #1153 diff --git a/README.md b/README.md index b1d10a09b..df2c518e3 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,41 @@ doc.end(); complex documents with a very small amount of code. For more, see the `demo` folder and the [PDFKit programming guide](http://pdfkit.org/docs/getting_started.html). +## Bundling for Node + +The Node build loads the metrics of the 14 standard fonts on first use from +`standard-fonts/` next to the built file, and PDF/A output reads the sRGB ICC +profile from `data/` next to it. Both are resolved relative to the built file +inside pdfkit's package directory, which a bundle that inlines pdfkit (esbuild, +rollup, webpack, the AWS CDK's `NodejsFunction`, ...) does not sit in. Pick one +of: + +- Mark `pdfkit` as external in the bundler and ship `node_modules/pdfkit` next + to the bundle. File tracers such as `@vercel/nft` pick up every file pdfkit + needs. +- Copy pdfkit's `js/standard-fonts` directory next to an ESM bundle, which then + loads the fonts from there. A CommonJS bundle has no `import.meta.url` to + resolve against, so this route is not open to it. +- Register the standard fonts the document uses before creating it, the same + way as in the browser: + + ```javascript + import { PDFDocument, registerStdFonts } from 'pdfkit'; + import Helvetica from 'pdfkit/standard-fonts/Helvetica'; + + registerStdFonts(Helvetica); + const doc = new PDFDocument(); + ``` + + Using a standard font that is neither registered nor loadable throws an + error naming the font. Fonts read from the file system or passed as data are + not affected. + +PDF/A output additionally reads `data/sRGB_IEC61966_2_1.icc` relative to the +bundle's `import.meta.url`: copy pdfkit's `js/data` directory next to an ESM +bundle, or register the profile under that URL with `registerFile`. A CommonJS +bundle has no `import.meta.url` and says so when PDF/A output is requested. + ## Browser Usage There are three ways to use PDFKit in the browser: diff --git a/lib/document.browser.js b/lib/document.browser.js index 6d0e2cfb0..0811c13ce 100644 --- a/lib/document.browser.js +++ b/lib/document.browser.js @@ -3,10 +3,10 @@ import LineWrapper from './line_wrapper'; import { registerStdFonts } from './font/standard_fonts'; import { registerFile } from '#fs'; import { fromBase64 } from './binary'; -import { ICC_PROFILE_PATH } from './mixins/pdfa'; +import { getIccProfilePath } from './mixins/pdfa'; import iccProfileBase64 from './mixins/data/sRGB_IEC61966_2_1.icc'; -registerFile(ICC_PROFILE_PATH, fromBase64(iccProfileBase64)); +registerFile(getIccProfilePath(), fromBase64(iccProfileBase64)); export { PDFDocument, LineWrapper, registerStdFonts, registerFile }; export default PDFDocument; diff --git a/lib/document.node.js b/lib/document.node.js index 213ae1e7f..a212e732d 100644 --- a/lib/document.node.js +++ b/lib/document.node.js @@ -1,28 +1,66 @@ import { createRequire } from 'module'; import PDFDocument from './document'; import LineWrapper from './line_wrapper'; -import { registerStdFontLoaders } from './font/standard_fonts'; +import { + registerStdFontLoaders, + registerStdFonts, +} from './font/standard_fonts'; import { registerFile } from '#fs'; -const require = createRequire(import.meta.url); +// The standard fonts are required by a path relative to the built file, so they +// resolve next to `js/pdfkit.js` and `js/pdfkit.node.mjs`. A bundler that +// folds this module into its own output leaves `import.meta.url` undefined +// (CommonJS output) or pointing at a bundle with no `standard-fonts/` directory +// next to it (ESM output). Neither may fail before a document actually asks for +// a standard font, so the lookup falls back to a base createRequire accepts and +// a failed lookup explains itself. +const require = createRequire(import.meta.url ?? 'file:///'); -registerStdFontLoaders({ - Courier: () => require('#standard-fonts/Courier'), - 'Courier-Bold': () => require('#standard-fonts/CourierBold'), - 'Courier-BoldOblique': () => require('#standard-fonts/CourierBoldOblique'), - 'Courier-Oblique': () => require('#standard-fonts/CourierOblique'), - Helvetica: () => require('#standard-fonts/Helvetica'), - 'Helvetica-Bold': () => require('#standard-fonts/HelveticaBold'), +const loaders = { + Courier: () => require('./standard-fonts/Courier.cjs'), + 'Courier-Bold': () => require('./standard-fonts/CourierBold.cjs'), + 'Courier-BoldOblique': () => + require('./standard-fonts/CourierBoldOblique.cjs'), + 'Courier-Oblique': () => require('./standard-fonts/CourierOblique.cjs'), + Helvetica: () => require('./standard-fonts/Helvetica.cjs'), + 'Helvetica-Bold': () => require('./standard-fonts/HelveticaBold.cjs'), 'Helvetica-BoldOblique': () => - require('#standard-fonts/HelveticaBoldOblique'), - 'Helvetica-Oblique': () => require('#standard-fonts/HelveticaOblique'), - Symbol: () => require('#standard-fonts/Symbol'), - 'Times-Bold': () => require('#standard-fonts/TimesBold'), - 'Times-BoldItalic': () => require('#standard-fonts/TimesBoldItalic'), - 'Times-Italic': () => require('#standard-fonts/TimesItalic'), - 'Times-Roman': () => require('#standard-fonts/TimesRoman'), - ZapfDingbats: () => require('#standard-fonts/ZapfDingbats'), -}); + require('./standard-fonts/HelveticaBoldOblique.cjs'), + 'Helvetica-Oblique': () => require('./standard-fonts/HelveticaOblique.cjs'), + Symbol: () => require('./standard-fonts/Symbol.cjs'), + 'Times-Bold': () => require('./standard-fonts/TimesBold.cjs'), + 'Times-BoldItalic': () => require('./standard-fonts/TimesBoldItalic.cjs'), + 'Times-Italic': () => require('./standard-fonts/TimesItalic.cjs'), + 'Times-Roman': () => require('./standard-fonts/TimesRoman.cjs'), + ZapfDingbats: () => require('./standard-fonts/ZapfDingbats.cjs'), +}; -export { PDFDocument, LineWrapper, registerFile }; +registerStdFontLoaders( + Object.fromEntries( + Object.entries(loaders).map(([name, load]) => [ + name, + () => { + try { + return load(); + } catch (error) { + if (error?.code !== 'MODULE_NOT_FOUND') { + throw error; + } + + throw new Error( + `Cannot load the standard font "${name}" from pdfkit's package ` + + 'directory, which is not available once pdfkit is bundled ' + + 'into another file. Either mark pdfkit as external in the ' + + 'bundler, or import the fonts the document uses from ' + + '"pdfkit/standard-fonts/" and pass them to ' + + 'registerStdFonts() before creating the document.', + { cause: error }, + ); + } + }, + ]), + ), +); + +export { PDFDocument, LineWrapper, registerStdFonts, registerFile }; export default PDFDocument; diff --git a/lib/mixins/pdfa.js b/lib/mixins/pdfa.js index ff7632d25..4c106d9a7 100644 --- a/lib/mixins/pdfa.js +++ b/lib/mixins/pdfa.js @@ -1,12 +1,28 @@ import fs from '#fs'; -export const ICC_PROFILE_PATH = new URL( - './data/sRGB_IEC61966_2_1.icc', - import.meta.url, -).href; - +let iccProfilePath; let iccProfile; +// Resolved on first use rather than at import time: a bundler that folds this +// module into a CommonJS output leaves `import.meta.url` undefined, and a +// document that never produces PDF/A output has no reason to fail over it. +export const getIccProfilePath = () => { + if (iccProfilePath === undefined) { + if (import.meta.url == null) { + throw new Error( + 'pdfkit cannot locate its sRGB ICC profile because import.meta.url ' + + 'is not available in this build. Bundle pdfkit as ESM or mark it ' + + 'as external in the bundler.', + ); + } + + iccProfilePath = new URL('./data/sRGB_IEC61966_2_1.icc', import.meta.url) + .href; + } + + return iccProfilePath; +}; + export default { initPDFA(pSubset) { if (pSubset.charAt(pSubset.length - 3) === '-') { @@ -28,7 +44,7 @@ export default { _addColorOutputIntent() { if (!iccProfile) { - iccProfile = fs.readFileSync(ICC_PROFILE_PATH); + iccProfile = fs.readFileSync(getIccProfilePath()); } const colorProfileRef = this.ref({ diff --git a/package.json b/package.json index dd170a6ec..ceae4b284 100644 --- a/package.json +++ b/package.json @@ -153,8 +153,7 @@ "#stream": { "node": "./lib/stream/node.js", "default": "./lib/stream/browser.js" - }, - "#standard-fonts/*": "./js/standard-fonts/*.cjs" + } }, "engine": [ "node >= v20.0.0" diff --git a/rollup.config.mjs b/rollup.config.mjs index aa843aeda..be3811b23 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -86,7 +86,7 @@ export default [ interop: 'default', exports: 'named', footer: - 'module.exports = exports.default;\nmodule.exports.PDFDocument = exports.PDFDocument;\nmodule.exports.LineWrapper = exports.LineWrapper;\nmodule.exports.registerFile = exports.registerFile;', + 'module.exports = exports.default;\nmodule.exports.PDFDocument = exports.PDFDocument;\nmodule.exports.LineWrapper = exports.LineWrapper;\nmodule.exports.registerStdFonts = exports.registerStdFonts;\nmodule.exports.registerFile = exports.registerFile;', }, { file: 'js/pdfkit.node.mjs', diff --git a/tests/package-resolution.cjs b/tests/package-resolution.cjs index ac2ba29db..c2fe71f17 100644 --- a/tests/package-resolution.cjs +++ b/tests/package-resolution.cjs @@ -64,9 +64,11 @@ assert.equal(typeof PDFDocument, 'function'); assert.equal(PDFDocument.name, 'PDFDocument'); assert.equal(PDFDocument.PDFDocument, PDFDocument); assert.equal(typeof PDFDocument.LineWrapper, 'function'); +assert.equal(typeof PDFDocument.registerStdFonts, 'function'); assert.equal(typeof PDFDocument.registerFile, 'function'); -const { LineWrapper, registerFile } = require('pdfkit'); +const { LineWrapper, registerStdFonts, registerFile } = require('pdfkit'); assert.equal(LineWrapper, PDFDocument.LineWrapper); +assert.equal(registerStdFonts, PDFDocument.registerStdFonts); assert.equal(registerFile, PDFDocument.registerFile); assert.equal(typeof outputHelpers.toBlob, 'function'); assert.equal(typeof outputHelpers.toBytes, 'function'); @@ -87,6 +89,7 @@ assert.equal(BrowserPDFDocument.registerFile, undefined); assert.equal(typeof nodeEsModule.default, 'function'); assert.equal(nodeEsModule.PDFDocument, nodeEsModule.default); assert.equal(typeof nodeEsModule.LineWrapper, 'function'); + assert.equal(typeof nodeEsModule.registerStdFonts, 'function'); assert.equal(typeof nodeEsModule.registerFile, 'function'); const browserModule = await import(browserEsBundleUrl); diff --git a/tests/package-resolution.mjs b/tests/package-resolution.mjs index 8887976a2..0a47c64c6 100644 --- a/tests/package-resolution.mjs +++ b/tests/package-resolution.mjs @@ -1,11 +1,14 @@ import assert from 'node:assert/strict'; import { createRequire } from 'node:module'; import { fileURLToPath } from 'node:url'; -import PDFDocument, { LineWrapper, registerFile } from 'pdfkit'; +import PDFDocument, { + LineWrapper, + registerStdFonts, + registerFile, +} from 'pdfkit'; import { toBytes } from 'pdfkit/output'; const require = createRequire(import.meta.url); -const packageJson = require('../package.json'); assert.equal( import.meta.resolve('pdfkit'), @@ -15,8 +18,10 @@ assert.equal( assert.equal(typeof PDFDocument, 'function'); assert.equal(PDFDocument.name, 'PDFDocument'); assert.equal(typeof LineWrapper, 'function'); +assert.equal(typeof registerStdFonts, 'function'); assert.equal(typeof registerFile, 'function'); assert.equal(PDFDocument.LineWrapper, undefined); +assert.equal(PDFDocument.registerStdFonts, undefined); assert.equal(PDFDocument.registerFile, undefined); const loadedStandardFontModules = () => @@ -41,29 +46,6 @@ assert.equal( false, ); -// The node ESM build reaches the standard fonts through createRequire, so the -// require condition is the only one ever taken at runtime. Bundlers and file -// tracers walk this same file as ESM and resolve `#standard-fonts/*` under the -// import condition instead: if the two point at different files, the tracer packs -// the modules that are never loaded, omits the ones that are, and the bundle -// throws `Cannot find module` on the first document. Keep every standard font -// resolving to the one file the runtime uses, under both conditions. -const standardFonts = Object.keys(packageJson.exports) - .filter((entry) => entry.startsWith('./standard-fonts/')) - .map((entry) => entry.slice('./standard-fonts/'.length)); - -assert.equal(standardFonts.length, 14); - -for (const font of standardFonts) { - const expected = new URL(`../js/standard-fonts/${font}.cjs`, import.meta.url) - .href; - assert.equal(import.meta.resolve(`#standard-fonts/${font}`), expected); - assert.equal( - require.resolve(`#standard-fonts/${font}`), - fileURLToPath(expected), - ); -} - const fileDocument = new PDFDocument(); const fileOutput = toBytes(fileDocument); fileDocument.font( diff --git a/tests/unit/bundling.spec.js b/tests/unit/bundling.spec.js new file mode 100644 index 000000000..3fb481aa9 --- /dev/null +++ b/tests/unit/bundling.spec.js @@ -0,0 +1,148 @@ +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'fs'; +import { createRequire } from 'module'; +import path from 'path'; +import { fileURLToPath, pathToFileURL } from 'url'; +import { nodeResolve } from '@rollup/plugin-node-resolve'; +import { rollup } from 'rollup'; +import Helvetica from '../../lib/font/generated/Helvetica'; + +const rootDir = fileURLToPath(new URL('../..', import.meta.url)); +const iccProfile = readFileSync( + path.join(rootDir, 'lib/mixins/data/sRGB_IEC61966_2_1.icc'), +); + +const render = (document) => + new Promise((resolve, reject) => { + const chunks = []; + document.on('data', (chunk) => chunks.push(chunk)); + document.on('end', () => resolve(Buffer.concat(chunks))); + document.on('error', reject); + document.end(); + }); + +// Bundles the Node build the way an application bundler does: every module of +// pdfkit folded into one file that lives in a directory of its own, so neither +// the `standard-fonts/` nor the `data/` directory of the pdfkit package sits +// next to it. The bundle sits under node_modules so bare +// imports of pdfkit's dependencies still resolve and Vitest leaves it to Node. +describe('bundled node build', () => { + let bundleDir; + let esmBundle; + let cjsBundle; + + beforeAll(async () => { + const cacheDir = path.join(rootDir, 'node_modules/.cache'); + mkdirSync(cacheDir, { recursive: true }); + bundleDir = mkdtempSync(path.join(cacheDir, 'pdfkit-bundle-')); + writeFileSync(path.join(bundleDir, 'package.json'), '{}\n'); + + const bundle = await rollup({ + input: path.join(rootDir, 'lib/document.node.js'), + external: (id) => + !id.startsWith('.') && !id.startsWith('#') && !path.isAbsolute(id), + plugins: [nodeResolve({ exportConditions: ['node'] })], + onwarn: () => {}, + }); + + esmBundle = path.join(bundleDir, 'pdfkit.mjs'); + await bundle.write({ file: esmBundle, format: 'es' }); + + // A CommonJS output has no `import.meta`; esbuild substitutes an empty + // object, so every `import.meta.url` reads as undefined. + cjsBundle = path.join(bundleDir, 'pdfkit.cjs'); + await bundle.write({ + file: cjsBundle, + format: 'cjs', + exports: 'named', + plugins: [ + { name: 'no-import-meta', resolveImportMeta: () => 'undefined' }, + ], + }); + + await bundle.close(); + }); + + afterAll(() => { + rmSync(bundleDir, { recursive: true, force: true }); + }); + + describe.each([ + ['ESM', () => import(pathToFileURL(esmBundle).href)], + ['CommonJS', () => createRequire(import.meta.url)(cjsBundle)], + ])('as %s', (format, load) => { + let pdfkit; + + beforeAll(async () => { + pdfkit = await load(); + }); + + test('loads without touching the standard fonts', () => { + expect(typeof pdfkit.PDFDocument).toBe('function'); + expect(typeof pdfkit.registerStdFonts).toBe('function'); + expect(typeof pdfkit.registerFile).toBe('function'); + }); + + test('explains a standard font it cannot find', () => { + let error; + try { + new pdfkit.PDFDocument(); + } catch (caught) { + error = caught; + } + + expect(error.message).toMatch( + /^Cannot load the standard font "Helvetica" .*registerStdFonts\(\)/s, + ); + expect(error.cause.code).toBe('MODULE_NOT_FOUND'); + }); + + test('renders with a registered standard font', async () => { + pdfkit.registerStdFonts(Helvetica); + + const document = new pdfkit.PDFDocument(); + document.text('Standard fonts can be registered in a bundle'); + const output = await render(document); + + expect(output.subarray(0, 5).toString()).toBe('%PDF-'); + }); + + test('rejects a font that is still unregistered', () => { + const document = new pdfkit.PDFDocument(); + + expect(() => document.font('Courier')).toThrow( + /^Cannot load the standard font "Courier" /, + ); + }); + + if (format === 'ESM') { + test('reads the ICC profile relative to the bundle', async () => { + const document = new pdfkit.PDFDocument({ subset: 'PDF/A-1' }); + await expect(render(document)).rejects.toThrow(/ENOENT/); + + pdfkit.registerFile( + new URL('./data/sRGB_IEC61966_2_1.icc', pathToFileURL(esmBundle)) + .href, + new Uint8Array(iccProfile), + ); + const registered = new pdfkit.PDFDocument({ subset: 'PDF/A-1' }); + const output = await render(registered); + + expect(output.subarray(0, 5).toString()).toBe('%PDF-'); + }); + } else { + test('explains a missing import.meta.url when PDF/A needs the profile', async () => { + const document = new pdfkit.PDFDocument({ subset: 'PDF/A-1' }); + + await expect(render(document)).rejects.toThrow( + /import\.meta\.url is not available/, + ); + }); + } + }); +});