Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -172,6 +173,11 @@
"validator": "^9.4.1",
"video.js": "^7.8.2"
},
"peerDependenciesMeta": {
"pdfmake": {
"optional": true
}
},
"jest": {
"collectCoverageFrom": [
"src/**/*.{js,jsx,mjs}"
Expand Down
32 changes: 32 additions & 0 deletions src/utils/pdf/__fixtures__/fake-pdfmake.js
Original file line number Diff line number Diff line change
@@ -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;
55 changes: 55 additions & 0 deletions src/utils/pdf/__tests__/create-document.test.js
Original file line number Diff line number Diff line change
@@ -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();
});
});
42 changes: 42 additions & 0 deletions src/utils/pdf/__tests__/download-blob.test.js
Original file line number Diff line number Diff line change
@@ -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 <a download=filename> 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);
});
});
32 changes: 32 additions & 0 deletions src/utils/pdf/__tests__/image-data-url.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
54 changes: 54 additions & 0 deletions src/utils/pdf/__tests__/nodes.test.js
Original file line number Diff line number Diff line change
@@ -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('<rect');
expect(b.svg).toContain('rx="8.5"'); // clamped to h/2 (17/2) → full pill
expect(b.svg).toContain('fill="#0a7a2f"');
expect(b.svg).toContain('text-anchor="middle"');
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 <all>', { color: '#fff', textColor: '"x"' });
expect(b.svg).toContain('>AI &amp; ML &lt;all&gt;<');
expect(b.svg).not.toMatch(/>AI & ML/); // raw ampersand would be invalid SVG
expect(b.svg).toContain('fill="&quot;x&quot;"');
});

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]);
});
});
47 changes: 47 additions & 0 deletions src/utils/pdf/__tests__/resolve-font.test.js
Original file line number Diff line number Diff line change
@@ -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();
});
});
55 changes: 55 additions & 0 deletions src/utils/pdf/create-document.js
Original file line number Diff line number Diff line change
@@ -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;
36 changes: 36 additions & 0 deletions src/utils/pdf/download-blob.js
Original file line number Diff line number Diff line change
@@ -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 <a download> 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;
Loading
Loading