diff --git a/package.json b/package.json index 0932c260..634a33e1 100644 --- a/package.json +++ b/package.json @@ -146,6 +146,7 @@ "lodash": "^4.17.14", "moment": "^2.22.2", "moment-timezone": "^0.5.21", + "pdfmake": "^0.3.11", "prop-types": "^15.8.1", "react": "^17.0.0", "react-beautiful-dnd": "^13.1.1", @@ -172,6 +173,11 @@ "validator": "^9.4.1", "video.js": "^7.8.2" }, + "peerDependenciesMeta": { + "pdfmake": { + "optional": true + } + }, "jest": { "collectCoverageFrom": [ "src/**/*.{js,jsx,mjs}" diff --git a/src/utils/pdf/__fixtures__/fake-pdfmake.js b/src/utils/pdf/__fixtures__/fake-pdfmake.js new file mode 100644 index 00000000..28540b4d --- /dev/null +++ b/src/utils/pdf/__fixtures__/fake-pdfmake.js @@ -0,0 +1,32 @@ +/** + * A promise-based fake of pdfmake 0.3.x, matching the REAL contract: + * createPdf(dd).getBlob() / .getBase64() / .open() / .print() / .download() + * are all async (return promises) and take NO callbacks. The old 0.1 callback + * form is gone. + * + * Every pdf test that exercises createDocument must build its fake from here, so + * a hand-rolled fake can't drift back to the callback API and false-green (which + * is exactly what once hid a real "never downloads" bug — the unit test passed + * while the browser did nothing). + * + * Lives in __fixtures__ (not __tests__) so jest doesn't collect it as a suite. + * + * @returns {{ pdfMake: object, handle: object }} + */ +export const createFakePdfMake = () => { + const handle = { + getBlob: jest.fn(() => Promise.resolve(new Blob(['%PDF-1.3'], { type: 'application/pdf' }))), + getBase64: jest.fn(() => Promise.resolve('BASE64')), + open: jest.fn(() => Promise.resolve()), + print: jest.fn(() => Promise.resolve()), + download: jest.fn(() => Promise.resolve()), + }; + const pdfMake = { + createPdf: jest.fn(() => handle), + addFontContainer: jest.fn(), + addFonts: jest.fn(), + }; + return { pdfMake, handle }; +}; + +export default createFakePdfMake; diff --git a/src/utils/pdf/__tests__/create-document.test.js b/src/utils/pdf/__tests__/create-document.test.js new file mode 100644 index 00000000..f2c656d5 --- /dev/null +++ b/src/utils/pdf/__tests__/create-document.test.js @@ -0,0 +1,55 @@ +/** + * @jest-environment jsdom + */ +import { createDocument } from '../create-document'; +import { createFakePdfMake } from '../__fixtures__/fake-pdfmake'; + +describe('createDocument', () => { + beforeEach(() => { + global.URL.createObjectURL = jest.fn(() => 'blob:mock'); + global.URL.revokeObjectURL = jest.fn(); + }); + afterEach(() => jest.restoreAllMocks()); + + it('resolves font, runs the template with it, and sets defaultStyle.font', async () => { + const { pdfMake } = createFakePdfMake(); + const template = jest.fn((data, ctx) => ({ content: [{ text: data.t }], _ctxFont: ctx.font })); + + const api = await createDocument({ pdfMake, font: 'Helvetica', template, data: { t: 'hi' } }); + + expect(template).toHaveBeenCalledWith({ t: 'hi' }, { font: 'Helvetica' }); + const doc = pdfMake.createPdf.mock.calls[0][0]; + expect(doc.defaultStyle.font).toBe('Helvetica'); + expect(typeof api.download).toBe('function'); + expect(typeof api.open).toBe('function'); + expect(typeof api.print).toBe('function'); + }); + + it('exposes getBlob / getBase64 as promises', async () => { + const { pdfMake } = createFakePdfMake(); + const api = await createDocument({ pdfMake, template: () => ({ content: [] }), data: {} }); + await expect(api.getBase64()).resolves.toBe('BASE64'); + await expect(api.getBlob()).resolves.toBeInstanceOf(Blob); + }); + + it('download() pulls a blob and open()/print() delegate to the handle', async () => { + const { pdfMake, handle } = createFakePdfMake(); + const api = await createDocument({ pdfMake, template: () => ({ content: [] }), data: {} }); + await api.download('receipt.pdf'); + expect(handle.getBlob).toHaveBeenCalled(); + await api.open(); + expect(handle.open).toHaveBeenCalled(); + await api.print(); + expect(handle.print).toHaveBeenCalled(); + }); + + it('calls onError and rejects when the template throws', async () => { + const { pdfMake } = createFakePdfMake(); + const onError = jest.fn(); + const boom = () => { throw new Error('boom'); }; + await expect( + createDocument({ pdfMake, template: boom, data: {}, onError }), + ).rejects.toThrow('boom'); + expect(onError).toHaveBeenCalled(); + }); +}); diff --git a/src/utils/pdf/__tests__/download-blob.test.js b/src/utils/pdf/__tests__/download-blob.test.js new file mode 100644 index 00000000..313c4212 --- /dev/null +++ b/src/utils/pdf/__tests__/download-blob.test.js @@ -0,0 +1,42 @@ +/** + * @jest-environment jsdom + */ +import { downloadBlob } from '../download-blob'; + +describe('downloadBlob', () => { + let anchor; + let clicked; + + beforeEach(() => { + global.URL.createObjectURL = jest.fn(() => 'blob:mock'); + global.URL.revokeObjectURL = jest.fn(); + anchor = null; + clicked = null; + const realCreate = document.createElement.bind(document); + jest.spyOn(document, 'createElement').mockImplementation((tag) => { + const el = realCreate(tag); + if (tag === 'a') { + anchor = el; + jest.spyOn(el, 'click').mockImplementation(function () { + clicked = { download: this.download, href: this.href, inLightDom: document.body.contains(this) }; + }); + } + return el; + }); + }); + + afterEach(() => jest.restoreAllMocks()); + + it('clicks a light-DOM pointing at the blob URL', () => { + downloadBlob(new Blob(['%PDF']), 'file.pdf'); + expect(global.URL.createObjectURL).toHaveBeenCalledTimes(1); + expect(clicked.download).toBe('file.pdf'); + expect(clicked.href).toContain('blob:mock'); + expect(clicked.inLightDom).toBe(true); + }); + + it('defers cleanup (anchor not removed synchronously with the click)', () => { + downloadBlob(new Blob(['%PDF']), 'file.pdf'); + expect(document.body.contains(anchor)).toBe(true); + }); +}); diff --git a/src/utils/pdf/__tests__/image-data-url.test.js b/src/utils/pdf/__tests__/image-data-url.test.js new file mode 100644 index 00000000..e604dd38 --- /dev/null +++ b/src/utils/pdf/__tests__/image-data-url.test.js @@ -0,0 +1,32 @@ +/** + * @jest-environment jsdom + */ +import { imageDataUrl } from '../image-data-url'; + +describe('imageDataUrl', () => { + afterEach(() => jest.restoreAllMocks()); + + it('returns a falsy src as null (no fetch)', async () => { + await expect(imageDataUrl(null)).resolves.toBeNull(); + }); + + it('passes an existing data: URL through unchanged (no fetch)', async () => { + global.fetch = jest.fn(); + const dataUrl = 'data:image/png;base64,AAAA'; + await expect(imageDataUrl(dataUrl)).resolves.toBe(dataUrl); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it('fetches an http(s) URL and resolves to a data: URL, cached', async () => { + global.fetch = jest.fn(() => + Promise.resolve({ ok: true, blob: () => Promise.resolve(new Blob(['abc'], { type: 'image/png' })) }), + ); + const first = await imageDataUrl('https://cdn.example.com/logo.png'); + expect(typeof first).toBe('string'); + expect(first.indexOf('data:')).toBe(0); + // second call hits the cache — no extra fetch + const second = await imageDataUrl('https://cdn.example.com/logo.png'); + expect(second).toBe(first); + expect(global.fetch).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/utils/pdf/__tests__/nodes.test.js b/src/utils/pdf/__tests__/nodes.test.js new file mode 100644 index 00000000..ceafc4a3 --- /dev/null +++ b/src/utils/pdf/__tests__/nodes.test.js @@ -0,0 +1,54 @@ +import { field, badge } from '../nodes'; + +describe('field', () => { + it('returns a [label, value] pair with an opinionated default (bold value)', () => { + const [label, value] = field('Document Number', 'ORD-1'); + expect(label).toMatchObject({ text: 'Document Number', fontSize: 8, color: '#777777' }); + expect(value).toMatchObject({ text: 'ORD-1', fontSize: 10, bold: true }); + }); + + it('honors opts (bold, gap, labelColor) and spreads rest onto the value node', () => { + const [label, value] = field('VENUE:', 'Main Hall', { bold: false, gap: 0, labelColor: '#999', alignment: 'center' }); + expect(label.color).toBe('#999'); + expect(value).toMatchObject({ text: 'Main Hall', bold: false, margin: [0, 0, 0, 0], alignment: 'center' }); + }); + + it('coerces nullish label/value to empty strings', () => { + const [label, value] = field(null, undefined); + expect(label.text).toBe(''); + expect(value.text).toBe(''); + }); +}); + +describe('badge', () => { + it('defaults to a full pill (radius clamped to half-height) with the label centered', () => { + const b = badge('PAID', { color: '#0a7a2f' }); + expect(b.svg).toContain('PAID<'); + expect(typeof b.width).toBe('number'); + }); + + it('radius behaves like CSS border-radius: 0 = square, in-between = rounded, clamped = pill', () => { + expect(badge('DEVOPS', { radius: 0, color: '#F6F6F6', textColor: '#4A4A4A', fontSize: 6 }).svg).toContain('rx="0"'); + expect(badge('T', { radius: 3 }).svg).toContain('rx="3"'); + expect(badge('T', { radius: 999 }).svg).toContain('rx="8.5"'); // clamped to h/2 + const chip = badge('DEVOPS', { radius: 0, textColor: '#4A4A4A', fontSize: 6 }); + expect(chip.svg).toContain('fill="#4A4A4A"'); + expect(chip.svg).toContain('font-size="6"'); + }); + + it('XML-escapes the label and colors so live data cannot break the SVG', () => { + const b = badge('AI & ML ', { color: '#fff', textColor: '"x"' }); + expect(b.svg).toContain('>AI & ML <all><'); + expect(b.svg).not.toMatch(/>AI & ML/); // raw ampersand would be invalid SVG + expect(b.svg).toContain('fill=""x""'); + }); + + it('spreads rest onto the returned node (e.g. an alignment margin)', () => { + const b = badge('PAID', { color: '#0a7a2f', margin: [0, -2, 0, 0] }); + expect(b.margin).toEqual([0, -2, 0, 0]); + }); +}); diff --git a/src/utils/pdf/__tests__/resolve-font.test.js b/src/utils/pdf/__tests__/resolve-font.test.js new file mode 100644 index 00000000..52296213 --- /dev/null +++ b/src/utils/pdf/__tests__/resolve-font.test.js @@ -0,0 +1,47 @@ +/** + * @jest-environment jsdom + */ +import { resolveFont } from '../resolve-font'; + +const fakePdfMake = () => ({ addFontContainer: jest.fn() }); + +describe('resolveFont', () => { + afterEach(() => jest.restoreAllMocks()); + + it('falls back to Helvetica for none / standard, registering nothing', async () => { + const pdfMake = fakePdfMake(); + await expect(resolveFont(pdfMake, undefined)).resolves.toBe('Helvetica'); + await expect(resolveFont(pdfMake, 'Helvetica')).resolves.toBe('Helvetica'); + await expect(resolveFont(pdfMake, { family: 'Helvetica' })).resolves.toBe('Helvetica'); + expect(pdfMake.addFontContainer).not.toHaveBeenCalled(); + }); + + it('registers an imported/pre-baked container without fetching', async () => { + global.fetch = jest.fn(); + const pdfMake = fakePdfMake(); + const font = { family: 'Imported', vfs: { 'Imported-normal.ttf': 'QUFB' }, fonts: { Imported: { normal: 'Imported-normal.ttf' } } }; + await expect(resolveFont(pdfMake, font)).resolves.toBe('Imported'); + expect(global.fetch).not.toHaveBeenCalled(); + expect(pdfMake.addFontContainer).toHaveBeenCalledWith({ vfs: font.vfs, fonts: font.fonts }); + }); + + it('fetches + base64s a URL font and registers a container', async () => { + global.fetch = jest.fn(() => Promise.resolve({ ok: true, arrayBuffer: () => Promise.resolve(new Uint8Array([65, 66, 67]).buffer) })); + const pdfMake = fakePdfMake(); + const fam = await resolveFont(pdfMake, { family: 'Brand', urls: { normal: 'https://x/n.ttf', bold: 'https://x/b.ttf' } }); + expect(fam).toBe('Brand'); + expect(global.fetch).toHaveBeenCalledTimes(2); + const container = pdfMake.addFontContainer.mock.calls[0][0]; + expect(container.vfs['Brand-normal.ttf']).toBe('QUJD'); // base64 of [65,66,67] + expect(container.fonts.Brand.normal).toBe('Brand-normal.ttf'); + expect(container.fonts.Brand.bold).toBe('Brand-bold.ttf'); + }); + + it('falls back to Helvetica when the URL font fails to fetch', async () => { + global.fetch = jest.fn(() => Promise.resolve({ ok: false, status: 403 })); + const pdfMake = fakePdfMake(); + const fam = await resolveFont(pdfMake, { family: 'Broken', urls: { normal: 'https://x/nope.ttf' } }); + expect(fam).toBe('Helvetica'); + expect(pdfMake.addFontContainer).not.toHaveBeenCalled(); + }); +}); diff --git a/src/utils/pdf/create-document.js b/src/utils/pdf/create-document.js new file mode 100644 index 00000000..fa80951f --- /dev/null +++ b/src/utils/pdf/create-document.js @@ -0,0 +1,55 @@ +import { resolveFont } from './resolve-font'; +import { downloadBlob } from './download-blob'; + +/** + * Build a PDF from a pure template and return the output verbs. + * + * The consumer passes its own (externalized, shared) `pdfMake` instance so there + * is one VFS. The `template` is a pure `(data, { font }) -> docDefinition`; the + * resolved family is applied to `defaultStyle.font`. Images referenced by the + * template must already be base64 `data:` URLs (see imageDataUrl) — pdfmake's + * browser build won't fetch remote images. + * + * @param {object} params.pdfMake consumer's pdfmake instance + * @param {*} params.font FontSpec (see resolveFont); omit for Helvetica + * @param {Function} params.template (data, { font }) => docDefinition + * @param {*} params.data payload for the template + * @param {Function} [params.onError] called on any build/fetch/layout failure + * @returns {Promise<{download, open, print, getBlob, getBase64}>} + */ +export const createDocument = async ({ pdfMake, font, template, data, onError }) => { + let handle; + try { + const family = await resolveFont(pdfMake, font); + const doc = template(data, { font: family }); + doc.defaultStyle = { font: family, ...(doc.defaultStyle || {}) }; + handle = pdfMake.createPdf(doc); + } catch (err) { + if (onError) onError(err); + throw err; + } + + // pdfmake 0.3.x getBlob/getBase64/open/print/download are all async (promise- + // based) — no callbacks. Wrap each so onError sees any failure. + const run = (fn) => async (...args) => { + try { + return await fn(...args); + } catch (err) { + if (onError) onError(err); + throw err; + } + }; + + return { + download: run(async (filename) => { + const blob = await handle.getBlob(); + downloadBlob(blob, filename); + }), + open: run(() => handle.open()), + print: run(() => handle.print()), + getBlob: run(() => handle.getBlob()), + getBase64: run(() => handle.getBase64()), + }; +}; + +export default createDocument; diff --git a/src/utils/pdf/download-blob.js b/src/utils/pdf/download-blob.js new file mode 100644 index 00000000..91fb4086 --- /dev/null +++ b/src/utils/pdf/download-blob.js @@ -0,0 +1,36 @@ +/** + * Trigger a browser download of a Blob under a chosen filename. + * + * The anchor is created in the light DOM (document.body). Widgets that render + * inside a shadow root can't use an there — Chrome ignores the + * `download` filename for a shadow-tree anchor and saves the blob UUID with no + * extension. Attaching to the top-level document.body makes Chrome honor the + * filename. Call synchronously from a user gesture so the download keeps it. + * + * @param {Blob} blob + * @param {string} filename + */ +export const downloadBlob = (blob, filename) => { + try { + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.rel = 'noopener'; + a.style.display = 'none'; + document.body.appendChild(a); + a.click(); + // Defer cleanup so the browser reads the blob + download attribute before + // the anchor is removed. Removing it synchronously can make Chrome fall + // back to the blob-UUID filename. + setTimeout(() => { + if (a.parentNode) a.parentNode.removeChild(a); + URL.revokeObjectURL(url); + }, 1000); + } catch (err) { + // eslint-disable-next-line no-console + console.error('[pdf] download failed', err); + } +}; + +export default downloadBlob; diff --git a/src/utils/pdf/image-data-url.js b/src/utils/pdf/image-data-url.js new file mode 100644 index 00000000..78cce72b --- /dev/null +++ b/src/utils/pdf/image-data-url.js @@ -0,0 +1,39 @@ +/** + * Resolve an image source to a base64 `data:` URL for pdfmake. + * + * pdfmake's browser build won't fetch image URLs itself (getBlob hangs on a + * remote image), so any http(s) logo must be fetched and base64'd first. A value + * that is already a `data:` URL (e.g. a bundled/imported asset) is returned + * unchanged. Results are cached (and in-flight deduped) by source. + * + * @param {string} src - http(s) URL or an existing data: URL + * @returns {Promise} data: URL, or null for a falsy src + */ +const cache = new Map(); + +const blobToDataUrl = (blob) => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result); + reader.onerror = () => reject(reader.error || new Error('FileReader failed')); + reader.readAsDataURL(blob); + }); + +export const imageDataUrl = (src) => { + if (!src) return Promise.resolve(null); + if (src.indexOf('data:') === 0) return Promise.resolve(src); + if (cache.has(src)) return cache.get(src); + + const p = fetch(src) + .then((res) => { + if (!res.ok) throw new Error(`image fetch failed: ${res.status}`); + return res.blob(); + }) + .then(blobToDataUrl); + + cache.set(src, p); + p.catch(() => cache.delete(src)); // don't cache failures + return p; +}; + +export default imageDataUrl; diff --git a/src/utils/pdf/index.js b/src/utils/pdf/index.js new file mode 100644 index 00000000..59cdc560 --- /dev/null +++ b/src/utils/pdf/index.js @@ -0,0 +1,5 @@ +export { createDocument } from './create-document'; +export { resolveFont } from './resolve-font'; +export { imageDataUrl } from './image-data-url'; +export { downloadBlob } from './download-blob'; +export { field, badge } from './nodes'; diff --git a/src/utils/pdf/nodes.js b/src/utils/pdf/nodes.js new file mode 100644 index 00000000..d4a77534 --- /dev/null +++ b/src/utils/pdf/nodes.js @@ -0,0 +1,72 @@ +/** + * Shared pdfmake node builders for the PDF toolkit. Pure functions that return + * plain pdfmake docDefinition nodes (no pdfMake instance needed) — so callers + * can spread-override, compose, or post-process the result. Each carries an + * opinionated default and a trailing `opts` with a `...rest` passthrough onto + * the returned node for one-off overrides. + */ + +/** + * A label-over-value pair. Returns `[labelNode, valueNode]` so a caller can + * spread it into a stack (several fields in a column) or wrap it in one + * (`{ stack: field(...) }`). + * + * @param {string} label + * @param {string|number} value + * @param {object} [opts] - { bold=true, gap=8, labelColor } + rest spread onto the value node + */ +export const field = (label, value, opts = {}) => { + const { bold = true, gap = 8, labelColor = '#777777', ...rest } = opts; + return [ + { + text: label == null ? '' : String(label), + fontSize: 8, + color: labelColor, + characterSpacing: 0.4, + margin: [0, 0, 0, 2], + }, + { text: value == null ? '' : String(value), fontSize: 10, bold, margin: [0, 0, 0, gap], ...rest }, + ]; +}; + +/** + * A colored badge with a centered label, drawn as an inline SVG (rounded rect + + * `text-anchor="middle"`). SVG is used because pdfmake `canvas` can't hold text + * and a `background:` can't be rounded; the SVG centers the label natively. + * Always returns a standalone `{ svg, width }` node. Height and horizontal + * padding derive from `fontSize`; the label is Helvetica-bold (always + * registered) regardless of the body font. + * + * @param {string} text + * @param {object} [opts] - { color, textColor='#fff', fontSize=9, radius, padX } + rest spread onto the node. + * `radius` behaves like CSS `border-radius`: it is clamped to half the height, + * so a large value (or the default) gives a full pill, `0` gives a square chip, + * and anything between gives rounded corners. + */ +// Escape the five XML-significant characters so live data (tag names like +// "AI & ML") can't break the SVG markup — an unescaped "&" yields invalid SVG +// that pdfmake's parser drops. Safe for both text content and attribute values. +const escapeXml = (s) => + String(s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + +export const badge = (text, opts = {}) => { + const { color = '#0a7a2f', textColor = '#ffffff', fontSize = 9, radius = 999, padX, ...rest } = opts; + const label = text == null ? '' : String(text); + const h = Math.round(fontSize * 1.9); + const rx = Math.max(0, Math.min(radius, h / 2)); // CSS-like border-radius, clamped to a full pill + const px = padX != null ? padX : Math.round(fontSize * 1.2); + const w = Math.ceil(label.length * fontSize * 0.62) + px * 2; // size from the raw length, not the escaped one + const y = h / 2 + fontSize * 0.34; // baseline that vertically centers the label + const svg = + `` + + `` + + `${escapeXml(label)}` + + ``; + return { svg, width: w, ...rest }; +}; diff --git a/src/utils/pdf/resolve-font.js b/src/utils/pdf/resolve-font.js new file mode 100644 index 00000000..df72be72 --- /dev/null +++ b/src/utils/pdf/resolve-font.js @@ -0,0 +1,90 @@ +/** + * Resolve a FontSpec into a registered pdfmake font family. + * + * pdfmake's browser build won't fetch font URLs itself (getBlob hangs), so a + * URL font must be fetched + base64'd and registered via addFontContainer. This + * toolkit bundles no font: the consumer provides one, and anything unusable + * falls back to `'Helvetica'` (a standard-14 font the consumer must have + * registered on its pdfMake, e.g. via pdfmake/build/standard-fonts/Helvetica). + * + * FontSpec — one of: + * 'Helvetica' standard; nothing registered here + * { family, vfs, fonts } imported/pre-baked base64 container (no fetch) + * { family, urls: { normal, bold } } runtime-dynamic; we fetch + base64 + cache + * + * @param {object} pdfMake - the consumer's pdfmake instance + * @param {string|object} font - FontSpec + * @returns {Promise} the font family to use as defaultStyle.font + */ +const HELVETICA = 'Helvetica'; + +// Cache resolved URL containers so repeated prints don't refetch. Keyed by the +// url set; stores the in-flight/resolved container promise. +const containerCache = new Map(); + +const arrayBufferToBase64 = (buffer) => { + let binary = ''; + const bytes = new Uint8Array(buffer); + const CHUNK = 0x8000; + for (let i = 0; i < bytes.length; i += CHUNK) { + binary += String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK)); + } + return btoa(binary); +}; + +const fetchFontBase64 = (url) => + fetch(url).then((res) => { + if (!res.ok) throw new Error(`font fetch failed: ${res.status}`); + return res.arrayBuffer(); + }).then(arrayBufferToBase64); + +// Build a pdfmake font container { vfs, fonts } from a set of weight -> URL. +const containerFromUrls = async (family, urls) => { + const vfs = {}; + const fonts = { [family]: {} }; + await Promise.all( + Object.keys(urls).map(async (weight) => { + const file = `${family}-${weight}.ttf`; + vfs[file] = await fetchFontBase64(urls[weight]); + fonts[family][weight] = file; + }), + ); + return { vfs, fonts }; +}; + +export const resolveFont = async (pdfMake, font) => { + if (!font || font === HELVETICA || font.family === HELVETICA) return HELVETICA; + + // Imported / pre-baked container — no fetch. + if (font.family && font.vfs && font.fonts) { + pdfMake.addFontContainer({ vfs: font.vfs, fonts: font.fonts }); + return font.family; + } + + // Runtime-dynamic URLs — fetch + base64 (cached), fall back on failure. + if (font.family && font.urls) { + const key = font.family + '|' + JSON.stringify(font.urls); + if (!containerCache.has(key)) { + containerCache.set( + key, + containerFromUrls(font.family, font.urls).catch((err) => { + containerCache.delete(key); + throw err; + }), + ); + } + try { + const container = await containerCache.get(key); + pdfMake.addFontContainer(container); + return font.family; + } catch (err) { + // eslint-disable-next-line no-console + console.warn('[pdf] custom font failed, using Helvetica:', err && err.message); + return HELVETICA; + } + } + + return HELVETICA; +}; + +export default resolveFont; diff --git a/webpack.common.js b/webpack.common.js index 5719e74b..25080880 100644 --- a/webpack.common.js +++ b/webpack.common.js @@ -177,6 +177,7 @@ module.exports = { 'utils/use-event-callback': './src/utils/use-event-callback.js', 'utils/external-store': './src/utils/external-store.js', 'utils/theme': './src/components/mui/MuiBaseCustomTheme.js', + 'utils/pdf': './src/utils/pdf/index.js', }, output: { path: path.resolve(__dirname, 'lib'),