@open-nav/invoicing 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/LICENSE +29 -0
  2. package/README.md +190 -0
  3. package/dist/color.d.ts +31 -0
  4. package/dist/color.d.ts.map +1 -0
  5. package/dist/color.js +150 -0
  6. package/dist/color.js.map +1 -0
  7. package/dist/export.d.ts +89 -0
  8. package/dist/export.d.ts.map +1 -0
  9. package/dist/export.js +115 -0
  10. package/dist/export.js.map +1 -0
  11. package/dist/format.d.ts +23 -0
  12. package/dist/format.d.ts.map +1 -0
  13. package/dist/format.js +52 -0
  14. package/dist/format.js.map +1 -0
  15. package/dist/html.d.ts +43 -0
  16. package/dist/html.d.ts.map +1 -0
  17. package/dist/html.js +355 -0
  18. package/dist/html.js.map +1 -0
  19. package/dist/index.d.ts +10 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +10 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/labels.d.ts +8 -0
  24. package/dist/labels.d.ts.map +1 -0
  25. package/dist/labels.js +199 -0
  26. package/dist/labels.js.map +1 -0
  27. package/dist/markings.d.ts +28 -0
  28. package/dist/markings.d.ts.map +1 -0
  29. package/dist/markings.js +69 -0
  30. package/dist/markings.js.map +1 -0
  31. package/dist/pdf-native.d.ts +32 -0
  32. package/dist/pdf-native.d.ts.map +1 -0
  33. package/dist/pdf-native.js +601 -0
  34. package/dist/pdf-native.js.map +1 -0
  35. package/dist/pdf.d.ts +92 -0
  36. package/dist/pdf.d.ts.map +1 -0
  37. package/dist/pdf.js +203 -0
  38. package/dist/pdf.js.map +1 -0
  39. package/dist/theme.d.ts +85 -0
  40. package/dist/theme.d.ts.map +1 -0
  41. package/dist/theme.js +210 -0
  42. package/dist/theme.js.map +1 -0
  43. package/package.json +54 -0
  44. package/src/color.ts +170 -0
  45. package/src/export.ts +204 -0
  46. package/src/format.ts +66 -0
  47. package/src/html.ts +446 -0
  48. package/src/index.ts +9 -0
  49. package/src/labels.ts +222 -0
  50. package/src/markings.ts +102 -0
  51. package/src/pdf-native.ts +754 -0
  52. package/src/pdf.ts +302 -0
  53. package/src/theme.ts +305 -0
package/src/pdf.ts ADDED
@@ -0,0 +1,302 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import type { InvoiceData } from '@open-nav/core';
6
+ import { renderInvoiceHtml } from './html.js';
7
+ import { renderInvoicePdfNative, type NativePdfOptions } from './pdf-native.js';
8
+
9
+ /**
10
+ * PDF output, by either of two engines.
11
+ *
12
+ * `native` is the default and needs nothing installed: pdfmake lays the
13
+ * document out and pdfkit writes the PDF, using the Roboto files pdfmake
14
+ * bundles. Roboto covers Latin Extended-A, so `ő` and `ű` embed correctly —
15
+ * which was the whole difficulty, since the PDF core fonts cannot represent
16
+ * them and a font therefore has to be embedded.
17
+ *
18
+ * `browser` renders the HTML document and converts it with Chrome, Chromium
19
+ * or Edge. It follows the HTML exactly, at the cost of needing a browser.
20
+ *
21
+ * They are two layouts, not one: the native engine follows the same theme but
22
+ * builds the page from pdfmake's document model rather than CSS, so the two
23
+ * will not match pixel for pixel.
24
+ */
25
+
26
+ export class PdfConversionError extends Error {}
27
+
28
+ export interface PdfOptions extends NativePdfOptions {
29
+ /**
30
+ * Which engine to use.
31
+ *
32
+ * `native` (the default) needs nothing installed. `browser` follows the
33
+ * HTML document exactly but requires Chrome, Chromium or Edge.
34
+ */
35
+ engine?: 'native' | 'browser';
36
+ /** Browser executable. Auto-detected when omitted. `browser` engine only. */
37
+ browserPath?: string;
38
+ /** Extra arguments passed to the browser. */
39
+ browserArgs?: string[];
40
+ /**
41
+ * Run the browser with its sandbox. On by default.
42
+ *
43
+ * The sandbox is what contains a malicious document, and invoice data is
44
+ * input. It is left on even though that means conversion fails when running
45
+ * as root — in a container or CI, pass `sandbox: false` deliberately. The
46
+ * error message says so, rather than quietly weakening the default for
47
+ * everybody.
48
+ */
49
+ sandbox?: boolean;
50
+ /** Give up after this long. Defaults to 30 000 ms. */
51
+ timeoutMs?: number;
52
+ /** Environment consulted when detecting a browser. Injectable for tests. */
53
+ env?: Record<string, string | undefined>;
54
+ /**
55
+ * Absolute locations probed when detecting a browser, in place of the usual
56
+ * install paths. Injectable for tests: those paths are the host's, so
57
+ * without this a test cannot describe a machine that has no browser.
58
+ */
59
+ searchPaths?: readonly string[];
60
+ /**
61
+ * Convert HTML to PDF yourself, instead of spawning a browser.
62
+ *
63
+ * ```ts
64
+ * import { chromium } from 'playwright';
65
+ * const convert = async (html: string) => {
66
+ * const browser = await chromium.launch();
67
+ * const page = await browser.newPage();
68
+ * await page.setContent(html, { waitUntil: 'load' });
69
+ * const pdf = await page.pdf({ printBackground: true });
70
+ * await browser.close();
71
+ * return pdf;
72
+ * };
73
+ * ```
74
+ */
75
+ convert?: (html: string) => Promise<Buffer>;
76
+ }
77
+
78
+ /**
79
+ * Render an invoice straight to PDF bytes.
80
+ *
81
+ * Uses the native engine unless `engine: 'browser'` is asked for, or a
82
+ * `convert` function is supplied — passing a converter is itself a request to
83
+ * go through HTML.
84
+ */
85
+ export async function renderInvoicePdf(
86
+ document: InvoiceData,
87
+ options: PdfOptions = {},
88
+ ): Promise<Buffer> {
89
+ const engine = options.engine ?? (options.convert || options.browserPath ? 'browser' : 'native');
90
+ if (engine === 'native') return renderInvoicePdfNative(document, options);
91
+ return htmlToPdf(renderInvoiceHtml(document, options), options);
92
+ }
93
+
94
+ /** Convert a rendered document to PDF bytes. */
95
+ export async function htmlToPdf(html: string, options: PdfOptions = {}): Promise<Buffer> {
96
+ if (options.convert) return options.convert(html);
97
+
98
+ const browser = options.browserPath ?? findBrowser(options.env, options.searchPaths);
99
+ if (!browser) {
100
+ throw new PdfConversionError(
101
+ 'No browser found to convert the document to PDF.\n' +
102
+ 'Install Chrome, Chromium or Edge, or set one of OPEN_NAV_BROWSER, ' +
103
+ 'CHROME_PATH or PUPPETEER_EXECUTABLE_PATH, or pass browserPath, ' +
104
+ 'or supply your own convert() — see PdfOptions.',
105
+ );
106
+ }
107
+ return spawnConversion(html, browser, options);
108
+ }
109
+
110
+ async function spawnConversion(
111
+ html: string,
112
+ browser: string,
113
+ options: PdfOptions,
114
+ ): Promise<Buffer> {
115
+ const workDir = mkdtempSync(join(tmpdir(), 'open-nav-pdf-'));
116
+ const inputPath = join(workDir, 'invoice.html');
117
+ const outputPath = join(workDir, 'invoice.pdf');
118
+
119
+ try {
120
+ writeFileSync(inputPath, html, 'utf8');
121
+
122
+ const args = [
123
+ '--headless',
124
+ '--disable-gpu',
125
+ '--no-pdf-header-footer',
126
+ // Its own profile, so concurrent conversions do not fight over one.
127
+ `--user-data-dir=${join(workDir, 'profile')}`,
128
+ ...(options.sandbox === false ? ['--no-sandbox'] : []),
129
+ ...(options.browserArgs ?? []),
130
+ `--print-to-pdf=${outputPath}`,
131
+ `file://${inputPath}`,
132
+ ];
133
+
134
+ const { code, stderr } = await run(browser, args, options.timeoutMs ?? 30_000);
135
+
136
+ if (!existsSync(outputPath)) {
137
+ throw new PdfConversionError(explainFailure(browser, code, stderr, options));
138
+ }
139
+ const pdf = readFileSync(outputPath);
140
+ if (!pdf.subarray(0, 5).equals(Buffer.from('%PDF-'))) {
141
+ throw new PdfConversionError(
142
+ `${browser} produced ${pdf.length} bytes that are not a PDF.\n${stderr.trim()}`,
143
+ );
144
+ }
145
+ return pdf;
146
+ } finally {
147
+ rmSync(workDir, { recursive: true, force: true });
148
+ }
149
+ }
150
+
151
+ /** Turn a browser failure into something the caller can act on. */
152
+ function explainFailure(
153
+ browser: string,
154
+ code: number | null,
155
+ stderr: string,
156
+ options: PdfOptions,
157
+ ): string {
158
+ if (/without --no-sandbox is not supported/i.test(stderr)) {
159
+ return (
160
+ `${browser} refuses to run as root with its sandbox enabled.\n` +
161
+ 'Pass sandbox: false (or --no-sandbox on the CLI) if you accept that, ' +
162
+ 'which is usual in a container, or run as a non-root user.'
163
+ );
164
+ }
165
+ if (options.sandbox === false && /sandbox/i.test(stderr)) {
166
+ return `${browser} could not start even with the sandbox disabled.\n${stderr.trim()}`;
167
+ }
168
+ return `${browser} exited with code ${code ?? 'unknown'} and produced no PDF.\n${stderr.trim()}`;
169
+ }
170
+
171
+ function run(
172
+ command: string,
173
+ args: string[],
174
+ timeoutMs: number,
175
+ ): Promise<{ code: number | null; stderr: string }> {
176
+ return new Promise((resolve, reject) => {
177
+ const child = spawn(command, args, { stdio: ['ignore', 'ignore', 'pipe'] });
178
+ let stderr = '';
179
+ child.stderr?.on('data', (chunk: Buffer) => {
180
+ stderr += chunk.toString();
181
+ });
182
+
183
+ const timer = setTimeout(() => {
184
+ child.kill('SIGKILL');
185
+ reject(
186
+ new PdfConversionError(
187
+ `${command} did not finish within ${timeoutMs}ms. ` +
188
+ 'A logo fetched over the network is the usual cause; inline it instead.',
189
+ ),
190
+ );
191
+ }, timeoutMs);
192
+
193
+ child.on('error', (error) => {
194
+ clearTimeout(timer);
195
+ reject(new PdfConversionError(`Could not run ${command}: ${error.message}`));
196
+ });
197
+ child.on('close', (code) => {
198
+ clearTimeout(timer);
199
+ resolve({ code, stderr });
200
+ });
201
+ });
202
+ }
203
+
204
+ /** Environment variables that name a browser, in the order they are honoured. */
205
+ const BROWSER_ENV_VARS = ['OPEN_NAV_BROWSER', 'CHROME_PATH', 'PUPPETEER_EXECUTABLE_PATH'] as const;
206
+
207
+ const EXECUTABLE_NAMES = [
208
+ 'google-chrome-stable',
209
+ 'google-chrome',
210
+ 'chromium-browser',
211
+ 'chromium',
212
+ 'microsoft-edge',
213
+ 'chrome',
214
+ ];
215
+
216
+ const WELL_KNOWN_PATHS = [
217
+ // macOS
218
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
219
+ '/Applications/Chromium.app/Contents/MacOS/Chromium',
220
+ '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
221
+ // Linux
222
+ '/usr/bin/google-chrome-stable',
223
+ '/usr/bin/google-chrome',
224
+ '/usr/bin/chromium-browser',
225
+ '/usr/bin/chromium',
226
+ '/usr/bin/microsoft-edge',
227
+ '/snap/bin/chromium',
228
+ // Windows
229
+ 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
230
+ 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
231
+ 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
232
+ ];
233
+
234
+ /**
235
+ * Locate a browser able to print to PDF.
236
+ *
237
+ * Order: an explicit environment variable, a Playwright install, the usual
238
+ * install locations, then `PATH`.
239
+ *
240
+ * `searchPaths` replaces that third step. It exists because the usual
241
+ * locations are absolute paths on the host, so no value of `env` can describe
242
+ * a machine without a browser — which is exactly what the failure path has to
243
+ * be tested against.
244
+ */
245
+ export function findBrowser(
246
+ env: Record<string, string | undefined> = process.env,
247
+ searchPaths: readonly string[] = WELL_KNOWN_PATHS,
248
+ ): string | undefined {
249
+ for (const name of BROWSER_ENV_VARS) {
250
+ const candidate = env[name];
251
+ if (candidate && existsSync(candidate)) return candidate;
252
+ }
253
+
254
+ const fromPlaywright = findPlaywrightChromium(env['PLAYWRIGHT_BROWSERS_PATH']);
255
+ if (fromPlaywright) return fromPlaywright;
256
+
257
+ for (const candidate of searchPaths) {
258
+ if (existsSync(candidate)) return candidate;
259
+ }
260
+
261
+ const pathEntries = (env['PATH'] ?? '').split(process.platform === 'win32' ? ';' : ':');
262
+ for (const directory of pathEntries) {
263
+ if (!directory) continue;
264
+ for (const name of EXECUTABLE_NAMES) {
265
+ const candidate = join(directory, name);
266
+ if (existsSync(candidate)) return candidate;
267
+ }
268
+ }
269
+
270
+ return undefined;
271
+ }
272
+
273
+ /**
274
+ * Playwright keeps its browsers in a versioned directory, so the exact path
275
+ * is not predictable and has to be discovered.
276
+ */
277
+ function findPlaywrightChromium(browsersPath: string | undefined): string | undefined {
278
+ if (!browsersPath || !existsSync(browsersPath)) return undefined;
279
+
280
+ const candidates: string[] = [];
281
+ let entries: string[];
282
+ try {
283
+ entries = readdirSync(browsersPath);
284
+ } catch {
285
+ return undefined;
286
+ }
287
+
288
+ for (const entry of entries) {
289
+ if (!entry.startsWith('chromium')) continue;
290
+ candidates.push(
291
+ join(browsersPath, entry, 'chrome-linux', 'chrome'),
292
+ join(browsersPath, entry, 'chrome-mac', 'Chromium.app', 'Contents', 'MacOS', 'Chromium'),
293
+ join(browsersPath, entry, 'chrome-win', 'chrome.exe'),
294
+ );
295
+ }
296
+
297
+ // Prefer a full build over the headless shell: the shell cannot print.
298
+ candidates.sort(
299
+ (left, right) => Number(left.includes('headless')) - Number(right.includes('headless')),
300
+ );
301
+ return candidates.find((candidate) => existsSync(candidate));
302
+ }
package/src/theme.ts ADDED
@@ -0,0 +1,305 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { dirname, extname, resolve } from 'node:path';
3
+
4
+ /**
5
+ * Visual configuration for the invoice document.
6
+ *
7
+ * Every field is optional; what is left out falls back to a restrained
8
+ * default that prints well in black and white. Values that end up inside the
9
+ * stylesheet are validated rather than interpolated blindly — a theme is
10
+ * configuration, but configuration is still input.
11
+ */
12
+ export interface InvoiceTheme {
13
+ /** Accent for headings, rules and the total. */
14
+ accentColor?: string;
15
+ /** Body text colour. */
16
+ inkColor?: string;
17
+ /** Secondary text: labels, references, the footer. */
18
+ mutedColor?: string;
19
+ /** Background of the markings panel. */
20
+ panelColor?: string;
21
+ /** Rules and borders. */
22
+ borderColor?: string;
23
+
24
+ /** CSS font stack. Give a real fallback chain; a PDF converter needs it. */
25
+ fontFamily?: string;
26
+ /** Base size, as a CSS length. Everything else scales from it. */
27
+ baseFontSize?: string;
28
+
29
+ /** Page size for `@page`, e.g. `A4` or `Letter`. */
30
+ pageSize?: string;
31
+ /** Page margin for `@page`, e.g. `16mm 14mm`. */
32
+ pageMargin?: string;
33
+
34
+ /** Logo shown in the header. Use `embedImage` to inline a local file. */
35
+ logo?: InvoiceLogo;
36
+
37
+ /** Extra lines in the supplier block: phone, email, web, registration. */
38
+ issuerContact?: string[];
39
+ /** Lines printed in the footer, e.g. payment terms or company details. */
40
+ footerLines?: string[];
41
+
42
+ /** Tint alternate table rows. Off by default; it prints heavier. */
43
+ zebraRows?: boolean;
44
+ /** Show the "rendered from reported data" note. Defaults to true. */
45
+ provenanceNote?: boolean;
46
+
47
+ /** Appended verbatim after the generated stylesheet. The escape hatch. */
48
+ customCss?: string;
49
+ }
50
+
51
+ export interface InvoiceLogo {
52
+ /**
53
+ * Image source: a `data:` URI, or an absolute URL.
54
+ *
55
+ * Prefer a data URI. An external URL leaves the document dependent on the
56
+ * network, which defeats archiving it as a single file and can quietly
57
+ * produce a logo-less PDF.
58
+ */
59
+ src: string;
60
+ /** Rendered width as a CSS length. Height follows the aspect ratio. */
61
+ width?: string;
62
+ /** Alternative text, for accessibility and for a failed image. */
63
+ alt?: string;
64
+ }
65
+
66
+ export interface ResolvedTheme extends Required<Omit<InvoiceTheme, 'logo' | 'customCss'>> {
67
+ logo?: InvoiceLogo;
68
+ customCss?: string;
69
+ }
70
+
71
+ export const DEFAULT_THEME: ResolvedTheme = {
72
+ accentColor: '#16181d',
73
+ inkColor: '#16181d',
74
+ mutedColor: '#5b6270',
75
+ panelColor: '#f4f5f8',
76
+ borderColor: '#c9cdd6',
77
+ fontFamily: '"Helvetica Neue", Arial, "Liberation Sans", sans-serif',
78
+ baseFontSize: '10pt',
79
+ pageSize: 'A4',
80
+ pageMargin: '16mm 14mm',
81
+ issuerContact: [],
82
+ footerLines: [],
83
+ zebraRows: false,
84
+ provenanceNote: true,
85
+ };
86
+
87
+ export class ThemeError extends Error {}
88
+
89
+ /** Colours we accept: hex, rgb(), rgba(), hsl(), hsla(), or a CSS keyword. */
90
+ const COLOR = /^(#[0-9a-fA-F]{3,8}|(rgb|hsl)a?\([0-9.,%\s/deg]+\)|[a-zA-Z]{3,20})$/;
91
+ /** Lengths and simple length lists, e.g. `10pt` or `16mm 14mm`. */
92
+ const LENGTH_LIST =
93
+ /^[0-9.]+(px|pt|pc|mm|cm|in|em|rem|%)?( +[0-9.]+(px|pt|pc|mm|cm|in|em|rem|%)?){0,3}$/;
94
+ /** Page sizes: a keyword, optionally with an orientation, or two lengths. */
95
+ const PAGE_SIZE =
96
+ /^([A-Za-z][A-Za-z0-9]{0,10}( +(portrait|landscape))?|[0-9.]+[a-z]{2} +[0-9.]+[a-z]{2})$/;
97
+ /**
98
+ * A font stack: names, quotes, commas, spaces, hyphens.
99
+ *
100
+ * Parentheses are excluded deliberately. No font name contains one, and
101
+ * allowing them would admit `url(...)`, which is how a stylesheet is made to
102
+ * fetch something.
103
+ */
104
+ const FONT_STACK = /^[-A-Za-z0-9 ,."']+$/;
105
+
106
+ function check(value: string, pattern: RegExp, field: string, expected: string): string {
107
+ const trimmed = value.trim();
108
+ if (!pattern.test(trimmed)) {
109
+ throw new ThemeError(
110
+ `theme.${field}: ${JSON.stringify(value)} is not ${expected}. ` +
111
+ `Values reach the stylesheet, so they are checked rather than trusted.`,
112
+ );
113
+ }
114
+ return trimmed;
115
+ }
116
+
117
+ /** Fill in the defaults, validating everything that lands in the CSS. */
118
+ export function resolveTheme(theme: InvoiceTheme = {}): ResolvedTheme {
119
+ const merged: ResolvedTheme = { ...DEFAULT_THEME, ...stripUndefined(theme) };
120
+
121
+ for (const field of [
122
+ 'accentColor',
123
+ 'inkColor',
124
+ 'mutedColor',
125
+ 'panelColor',
126
+ 'borderColor',
127
+ ] as const) {
128
+ merged[field] = check(merged[field], COLOR, field, 'a CSS colour');
129
+ }
130
+ merged.baseFontSize = check(merged.baseFontSize, LENGTH_LIST, 'baseFontSize', 'a CSS length');
131
+ merged.pageMargin = check(merged.pageMargin, LENGTH_LIST, 'pageMargin', 'a CSS length');
132
+ merged.pageSize = check(merged.pageSize, PAGE_SIZE, 'pageSize', 'a CSS page size');
133
+ merged.fontFamily = check(merged.fontFamily, FONT_STACK, 'fontFamily', 'a CSS font stack');
134
+
135
+ if (merged.logo) {
136
+ const src = merged.logo.src.trim();
137
+ if (!/^(data:image\/[a-z+.-]+;base64,[A-Za-z0-9+/=]+|https?:\/\/\S+)$/.test(src)) {
138
+ throw new ThemeError(
139
+ 'theme.logo.src must be a data: image URI or an http(s) URL. ' +
140
+ 'Use embedImage() to inline a local file.',
141
+ );
142
+ }
143
+ merged.logo = {
144
+ src,
145
+ ...(merged.logo.width
146
+ ? { width: check(merged.logo.width, LENGTH_LIST, 'logo.width', 'a CSS length') }
147
+ : {}),
148
+ ...(merged.logo.alt ? { alt: merged.logo.alt } : {}),
149
+ };
150
+ }
151
+
152
+ return merged;
153
+ }
154
+
155
+ function stripUndefined<T extends object>(value: T): Partial<T> {
156
+ return Object.fromEntries(
157
+ Object.entries(value).filter(([, entry]) => entry !== undefined),
158
+ ) as Partial<T>;
159
+ }
160
+
161
+ const MIME_TYPES: Record<string, string> = {
162
+ '.png': 'image/png',
163
+ '.jpg': 'image/jpeg',
164
+ '.jpeg': 'image/jpeg',
165
+ '.gif': 'image/gif',
166
+ '.svg': 'image/svg+xml',
167
+ '.webp': 'image/webp',
168
+ };
169
+
170
+ /** Largest logo we inline, before base64 expansion. */
171
+ export const MAX_LOGO_BYTES = 2 * 1024 * 1024;
172
+
173
+ /**
174
+ * Read an image and return it as a `data:` URI.
175
+ *
176
+ * Inlining keeps the document self-contained, which is what makes it archive
177
+ * as one file and print the same offline.
178
+ */
179
+ export function embedImage(filePath: string, contents?: Buffer): string {
180
+ const extension = extname(filePath).toLowerCase();
181
+ const mime = MIME_TYPES[extension];
182
+ if (!mime) {
183
+ throw new ThemeError(
184
+ `Unsupported image type ${extension || filePath}. Supported: ${Object.keys(MIME_TYPES).join(', ')}`,
185
+ );
186
+ }
187
+ const bytes = contents ?? readFileSync(filePath);
188
+ if (bytes.length > MAX_LOGO_BYTES) {
189
+ throw new ThemeError(
190
+ `${filePath} is ${Math.round(bytes.length / 1024)} kB; the limit is ${MAX_LOGO_BYTES / 1024} kB. ` +
191
+ `A logo this large bloats every document it appears in.`,
192
+ );
193
+ }
194
+ return `data:${mime};base64,${bytes.toString('base64')}`;
195
+ }
196
+
197
+ /**
198
+ * Load a theme from a JSON file.
199
+ *
200
+ * A `logoFile` field is read relative to the theme file and inlined, so a
201
+ * theme can be checked in next to its logo and stay portable.
202
+ */
203
+ export function loadTheme(
204
+ filePath: string,
205
+ io: { readFile?: (path: string) => string; readBinary?: (path: string) => Buffer } = {},
206
+ ): InvoiceTheme {
207
+ const readFile = io.readFile ?? ((path: string) => readFileSync(path, 'utf8'));
208
+
209
+ let parsed: InvoiceTheme & { logoFile?: string };
210
+ try {
211
+ parsed = JSON.parse(readFile(filePath)) as InvoiceTheme & { logoFile?: string };
212
+ } catch (cause) {
213
+ throw new ThemeError(`${filePath} is not valid JSON: ${(cause as Error).message}`);
214
+ }
215
+
216
+ const { logoFile, ...theme } = parsed;
217
+ if (logoFile) {
218
+ const imagePath = resolve(dirname(filePath), logoFile);
219
+ const readBinary = io.readBinary ?? ((path: string) => readFileSync(path));
220
+ theme.logo = {
221
+ ...theme.logo,
222
+ src: embedImage(imagePath, readBinary(imagePath)),
223
+ };
224
+ }
225
+ return theme;
226
+ }
227
+
228
+ /** Build the document stylesheet from a resolved theme. */
229
+ export function buildStyles(theme: ResolvedTheme): string {
230
+ return `<style>
231
+ @page { size: ${theme.pageSize}; margin: ${theme.pageMargin}; }
232
+ :root {
233
+ --accent: ${theme.accentColor};
234
+ --ink: ${theme.inkColor};
235
+ --muted: ${theme.mutedColor};
236
+ --rule: ${theme.borderColor};
237
+ --panel: ${theme.panelColor};
238
+ }
239
+ * { box-sizing: border-box; }
240
+ body {
241
+ margin: 0;
242
+ color: var(--ink);
243
+ font: ${theme.baseFontSize}/1.45 ${theme.fontFamily};
244
+ -webkit-print-color-adjust: exact;
245
+ print-color-adjust: exact;
246
+ }
247
+ .invoice { max-width: 190mm; margin: 0 auto; padding: 8mm 0; }
248
+ .page-break { page-break-after: always; }
249
+
250
+ header { display: flex; justify-content: space-between; align-items: flex-start; gap: 12mm; }
251
+ .brand { display: flex; flex-direction: column; gap: 3mm; }
252
+ .logo { display: block; max-width: 70mm; max-height: 26mm; }
253
+ h1 { margin: 0; font-size: 2em; letter-spacing: 0.08em; font-weight: 700; color: var(--accent); }
254
+ .subtitle { color: var(--muted); font-size: 0.9em; margin-top: 2mm; }
255
+ .meta { text-align: right; font-size: 0.95em; min-width: 64mm; }
256
+ .meta div { margin-bottom: 1mm; }
257
+ .meta .value { font-weight: 700; white-space: nowrap; }
258
+
259
+ .parties { display: flex; gap: 6mm; margin: 7mm 0; }
260
+ .party { flex: 1; border: 1px solid var(--rule); border-radius: 2mm; padding: 4mm; }
261
+ .party h2 {
262
+ margin: 0 0 2mm; font-size: 0.8em; text-transform: uppercase;
263
+ letter-spacing: 0.1em; color: var(--muted); font-weight: 700;
264
+ }
265
+ .party .name { font-weight: 700; font-size: 1.1em; margin-bottom: 1mm; }
266
+ .party .address { font-size: 0.9em; margin-bottom: 1.5mm; }
267
+ .party dl { margin: 2mm 0 0; display: grid; grid-template-columns: auto 1fr; gap: 0.6mm 3mm; font-size: 0.9em; }
268
+ .party dt { color: var(--muted); }
269
+ .party dd { margin: 0; }
270
+ .party .contact { margin-top: 2mm; font-size: 0.9em; color: var(--muted); }
271
+
272
+ table { width: 100%; border-collapse: collapse; font-size: 0.9em; }
273
+ thead th {
274
+ text-align: left; font-size: 0.8em; text-transform: uppercase; letter-spacing: 0.06em;
275
+ color: var(--muted); border-bottom: 1px solid var(--rule); padding: 2mm 1.5mm;
276
+ }
277
+ tbody td { padding: 2mm 1.5mm; border-bottom: 1px solid var(--rule); vertical-align: top; }
278
+ ${theme.zebraRows ? 'tbody tr:nth-child(even) td { background: var(--panel); }' : ''}
279
+ .num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
280
+
281
+ .totals { display: flex; justify-content: flex-end; margin-top: 5mm; }
282
+ .totals table { width: auto; min-width: 80mm; }
283
+ .totals td { padding: 1.5mm 2mm; border: none; background: none; }
284
+ .totals .grand td {
285
+ border-top: 1.5px solid var(--accent); color: var(--accent);
286
+ font-weight: 700; font-size: 1.15em; padding-top: 2.5mm;
287
+ }
288
+
289
+ .vat-summary { margin-top: 6mm; }
290
+ .vat-summary h2, .markings h2 {
291
+ font-size: 0.8em; text-transform: uppercase; letter-spacing: 0.1em;
292
+ color: var(--muted); margin: 0 0 2mm; font-weight: 700;
293
+ }
294
+ .markings { margin-top: 6mm; background: var(--panel); border-radius: 2mm; padding: 4mm; }
295
+ .markings ul { margin: 0; padding-left: 4mm; }
296
+ .markings li { margin-bottom: 1mm; }
297
+ .markings .reference { color: var(--muted); font-size: 0.85em; }
298
+ .note { margin-top: 5mm; font-size: 0.9em; }
299
+ footer {
300
+ margin-top: 8mm; color: var(--muted); font-size: 0.8em;
301
+ border-top: 1px solid var(--rule); padding-top: 2mm;
302
+ }
303
+ footer div { margin-bottom: 0.8mm; }
304
+ ${theme.customCss ? `\n /* customCss */\n${theme.customCss}\n` : ''}</style>`;
305
+ }