@beltype/cli 0.1.3 → 0.1.4

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.
package/README.md CHANGED
@@ -1,12 +1,25 @@
1
1
  # beltype
2
2
 
3
- The BelType command line. It prints a finished HTML document to PDF: Cicero cuts the document into pages in the browser, and Caster prints the pages Cicero presented. Chromium's own print is never called, so the run needs only Playwright's bare headless shell.
3
+ The BelType command line. It prints a finished HTML document to PDF: Cicero cuts the document into pages in the browser, and Caster prints the pages Cicero presented. There are two printers: Caster, the default, and Chromium's own print (`--printer chromium`), which prints the same pages Cicero presented. Under Caster the run needs only Playwright's bare headless shell.
4
4
 
5
5
  ```bash
6
6
  beltype print paper/index.html # writes paper/index.pdf
7
7
  beltype print paper/index.html -o paper.pdf
8
+ beltype print paper/index.html --printer chromium --check --trace trace.json
8
9
  ```
9
10
 
11
+ ## Flags
12
+
13
+ One shape across BelType: every package declares a table of name, accepted values and default (`@beltype/common/flags`), and `--flag <owner>.<name>=<value>`, repeatable, sets a row of any of them. `beltype print --help` lists the tables. A value outside a row, or a name no table holds, is refused by name. The defaults reproduce the previous behaviour exactly.
14
+
15
+ ```bash
16
+ beltype print paper/index.html --flag caster.shaper=fontkit --flag cicero.composed=true
17
+ ```
18
+
19
+ - `--printer caster|chromium`: who prints the pages Cicero presented. Chromium prints them one sheet to a page; it refuses, naming `--printer caster`, when the pages are not all one size.
20
+ - `--trace <file.json>`: where the run's time went. `process` is node's clock around each step (launch, context, load, cut, print, transfer, close, write), `cicero` is Cicero's own `performance.measure` entries, `caster` is Caster's report (`ms` per stage and `timing`; `null` under Chromium), with the versions and the resolved flags. It is written even when the run refuses, with the step that refused.
21
+ - `--check`: the proof that the PDF is the screen. Each presented page is screenshotted at 96 dpi, the PDF is rasterised at 96 dpi and the pairs are diffed into `<name>.check/` (`screen-N.png`, `print-N.png`, `diff-N.png`, `check.json`). It reports a share of differing pixels per page and a mean, and never fails the run. It tolerates one pixel of slip, because the screen's pages sit at fractional heights (A4 is 1122.52 px) and Skia and mupdf shade letter edges differently.
22
+
10
23
  The document is plain HTML, CSS and the files beside it. The run answers every request from the document's folder, on a made-up `.invalid` address, and refuses anything else by name: a document that asks for a file it does not have, or for anything on a network, prints nothing. Fonts must be declared with `@font-face` and their files, because Caster embeds every face it draws.
11
24
 
12
25
  ## The sample
@@ -46,6 +59,6 @@ The companions come from the organization's repositories as git dependencies, ea
46
59
  ```bash
47
60
  pnpm install
48
61
  pnpm run check # tsc over src and test, strict
49
- pnpm test # prints the specimen and reads the file back with mupdf
62
+ pnpm test # prints the specimen under each printer, and reads the files back with mupdf
50
63
  pnpm run build # dist/, which the beltype command runs
51
64
  ```
@@ -0,0 +1,60 @@
1
+ /**
2
+ * `--check`: the proof that the PDF is the screen.
3
+ *
4
+ * @remarks
5
+ * Each page Cicero presented is screenshotted at device scale 1 (96 dpi), the PDF is rasterised at 96 dpi with mupdf, and the pairs are diffed (`compare.ts`). Output goes beside the PDF, in `<name>.check/`: `screen-N.png`, `print-N.png`, `diff-N.png` and `check.json`. It reports and never fails; a threshold waits until the first runs show where the noise floor is. `mupdf` is imported here, dynamically, so a plain print never loads it.
6
+ *
7
+ * @packageDocumentation
8
+ */
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ /** Each `beltype-page` clipped to its page box. Playwright's locators pierce the root's open shadow tree. */
12
+ export async function screenshotPages(page) {
13
+ const pages = await page.locator('beltype-page').all();
14
+ const shots = [];
15
+ for (const p of pages)
16
+ shots.push(await p.screenshot({ type: 'png' }));
17
+ return shots;
18
+ }
19
+ /** The PNG's pixels as RGB, three bytes a pixel. */
20
+ async function rasterOfPng(png) {
21
+ const mupdf = await import('mupdf');
22
+ const pix = new mupdf.Image(png).toPixmap();
23
+ const width = pix.getWidth();
24
+ const height = pix.getHeight();
25
+ const n = pix.getNumberOfComponents();
26
+ const stride = pix.getStride();
27
+ const src = pix.getPixels();
28
+ const rgb = new Uint8Array(width * height * 3);
29
+ for (let y = 0; y < height; y++)
30
+ for (let x = 0; x < width; x++)
31
+ for (let c = 0; c < 3; c++)
32
+ rgb[(y * width + x) * 3 + c] = src[y * stride + x * n + c];
33
+ return { width, height, rgb };
34
+ }
35
+ /** Rasterise `pdf`, diff it page by page against `screens`, and write the evidence into `dir`. */
36
+ export async function checkPages(screens, pdf, dir, printer) {
37
+ const { rasterize, diffPages, THRESHOLD } = await import('./compare.js');
38
+ const printed = rasterize(pdf);
39
+ const screen = await Promise.all(screens.map(rasterOfPng));
40
+ const diff = diffPages(screen, printed.pages);
41
+ fs.rmSync(dir, { recursive: true, force: true });
42
+ fs.mkdirSync(dir, { recursive: true });
43
+ const pages = diff.pages.map((d, i) => {
44
+ const n = i + 1;
45
+ fs.writeFileSync(path.join(dir, `screen-${n}.png`), screens[i]);
46
+ fs.writeFileSync(path.join(dir, `print-${n}.png`), printed.pngs[i]);
47
+ fs.writeFileSync(path.join(dir, `diff-${n}.png`), d.image);
48
+ return { page: n, share: d.share, sameSize: d.sameSize, screen: { width: screen[i].width, height: screen[i].height }, print: { width: printed.pages[i].width, height: printed.pages[i].height } };
49
+ });
50
+ const result = { dir, printer, threshold: THRESHOLD, screenPages: screens.length, printPages: printed.pages.length, unmatched: diff.unmatched, mean: diff.mean, pages };
51
+ fs.writeFileSync(path.join(dir, 'check.json'), JSON.stringify(result, null, 2));
52
+ return result;
53
+ }
54
+ /** One line a page, and the mean. */
55
+ export function checkLines(r) {
56
+ return [
57
+ ...r.pages.map((p) => `check page ${p.page}: ${(p.share * 100).toFixed(2)}% differ${p.sameSize ? '' : ` (sizes differ: screen ${p.screen.width}x${p.screen.height}, print ${p.print.width}x${p.print.height})`}`),
58
+ `check mean: ${(r.mean * 100).toFixed(2)}% over ${r.pages.length} pages${r.unmatched ? `; ${r.unmatched} page(s) unmatched (screen ${r.screenPages}, print ${r.printPages})` : ''}; ${r.dir}`
59
+ ];
60
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The print sheet `--printer chromium` adds after Cicero's cut.
3
+ *
4
+ * @remarks
5
+ * Chromium prints the pages Cicero presented, one sheet to a page. The sheet sets the page box to the presented pages' one size, breaks after each page, and turns off the screen surround's gap and shadow (`@media screen` in Cicero's root; `page.pdf()` prints under print media, so they are off anyway and the sheet says so).
6
+ *
7
+ * @packageDocumentation
8
+ */
9
+ import { BuildError } from '@beltype/common/errors';
10
+ /** @throws {BuildError} when the presented pages are not all one size: named pages of different sizes need per-size `@page` rules, a later round. */
11
+ export function chromiumSheet(sizes) {
12
+ const first = sizes[0];
13
+ if (!first)
14
+ throw new BuildError('beltype print: --printer chromium found no page Cicero presented.');
15
+ if (sizes.some((s) => Math.abs(s.w - first.w) > 0.01 || Math.abs(s.h - first.h) > 0.01)) {
16
+ throw new BuildError('beltype print: the presented pages are not all one size, which --printer chromium does not print yet; use --printer caster.');
17
+ }
18
+ return {
19
+ page: `@page { size: ${first.w}px ${first.h}px; margin: 0 }`,
20
+ shadow: 'beltype-page { break-after: page; margin: 0; box-shadow: none }'
21
+ };
22
+ }
package/dist/src/cli.js CHANGED
@@ -1,33 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * The `beltype` command.
3
+ * The `beltype` executable: turns on Node's compile cache, then runs `main.ts`.
4
4
  *
5
5
  * @remarks
6
- * One verb so far: `beltype print <file.html> [-o <file.pdf>]`. The output defaults to the input's name with `.pdf`, beside it. A flag the parser does not know is refused, never ignored.
6
+ * The cache has to be on before the modules it covers are loaded, and a static import is loaded before the importing file's own statements run, so the command itself lives in `main.ts` and is imported here once the cache is enabled. The cache is Node's own, in the operating system's temporary directory (`node-compile-cache`), and holds compiled code only: a few megabytes, rebuilt if it is deleted.
7
7
  *
8
8
  * @packageDocumentation
9
9
  */
10
- import { parseArgs } from 'node:util';
11
- import { BelTypeError } from '@beltype/common/errors';
12
- import { print } from './print.js';
13
- const USAGE = 'usage: beltype print <file.html> [-o <file.pdf>]';
14
- async function main(argv) {
15
- const { values, positionals } = parseArgs({ args: argv, allowPositionals: true, options: { output: { type: 'string', short: 'o' }, help: { type: 'boolean', short: 'h' } } });
16
- const [verb, input, ...rest] = positionals;
17
- if (values.help || !verb) {
18
- console.log(USAGE);
19
- return values.help ? 0 : 1;
20
- }
21
- if (verb !== 'print' || !input || rest.length) {
22
- console.error(verb !== 'print' ? `beltype: no verb '${verb}'\n${USAGE}` : USAGE);
23
- return 1;
24
- }
25
- const output = values.output ?? input.replace(/\.html?$/i, '') + '.pdf';
26
- const result = await print(input, output);
27
- console.log(`${result.output}: ${result.pages} pages, ${result.words} words, ${(result.ms / 1000).toFixed(1)} s`);
28
- return 0;
29
- }
30
- main(process.argv.slice(2)).then((code) => process.exit(code), (error) => {
31
- console.error(error instanceof BelTypeError ? error.message : error);
32
- process.exit(1);
33
- });
10
+ import { enableCompileCache } from 'node:module';
11
+ enableCompileCache();
12
+ await import('./main.js');
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Rasterize a PDF and measure how far two renderings are apart.
3
+ *
4
+ * @remarks
5
+ * Pages are drawn at 96 dpi, RGB, on white. A pixel differs when its largest channel difference exceeds {@link THRESHOLD} of 255 from every pixel within {@link NEIGHBOURHOOD} of it in the other raster. Pages of different sizes are compared over their common area and flagged. The numbers say how far apart two tools are, never which is right.
6
+ *
7
+ * Copied from the testbench's `src/compare.ts`, which keeps its own; one shared home waits until a third user appears.
8
+ *
9
+ * @packageDocumentation
10
+ */
11
+ import * as mupdf from 'mupdf';
12
+ export const THRESHOLD = 32;
13
+ /**
14
+ * How far a pixel may slip, in pixels, and still count as the same. A pixel differs only when nothing within this reach in the other raster is within {@link THRESHOLD} of it, and the same the other way round.
15
+ *
16
+ * @remarks
17
+ * The screen's pages sit at fractional heights on Cicero's stack (A4 is 1122.52 px), so the screenshot slips 0 to 1 px against mupdf's exact origin; and Skia and mupdf shade letter edges differently. Without the reach every glyph edge below the slip reads as a difference. This is the reftests' `fuzzy` idea (Web Platform Tests, Mozilla).
18
+ */
19
+ export const NEIGHBOURHOOD = 1;
20
+ /** Draw every page of a PDF. */
21
+ export function rasterize(pdf, dpi = 96) {
22
+ const doc = mupdf.Document.openDocument(pdf, 'application/pdf');
23
+ const pages = [];
24
+ const pngs = [];
25
+ for (let i = 0; i < doc.countPages(); i++) {
26
+ const pix = doc.loadPage(i).toPixmap(mupdf.Matrix.scale(dpi / 72, dpi / 72), mupdf.ColorSpace.DeviceRGB, false);
27
+ const width = pix.getWidth();
28
+ const height = pix.getHeight();
29
+ const n = pix.getNumberOfComponents();
30
+ const stride = pix.getStride();
31
+ const src = pix.getPixels();
32
+ const rgb = new Uint8Array(width * height * 3);
33
+ for (let y = 0; y < height; y++)
34
+ for (let x = 0; x < width; x++)
35
+ for (let c = 0; c < 3; c++)
36
+ rgb[(y * width + x) * 3 + c] = src[y * stride + x * n + c];
37
+ pages.push({ width, height, rgb });
38
+ pngs.push(pix.asPNG());
39
+ }
40
+ return { pages, pngs };
41
+ }
42
+ /** Whether the pixel at (x, y) of `from` is within the threshold of any pixel of `to` within {@link NEIGHBOURHOOD} of (x, y), clipped at the edges. */
43
+ function matched(from, to, x, y, width, height) {
44
+ const a = (y * from.width + x) * 3;
45
+ const r = from.rgb[a], g = from.rgb[a + 1], bl = from.rgb[a + 2];
46
+ for (let dy = -NEIGHBOURHOOD; dy <= NEIGHBOURHOOD; dy++) {
47
+ const yy = y + dy;
48
+ if (yy < 0 || yy >= height)
49
+ continue;
50
+ for (let dx = -NEIGHBOURHOOD; dx <= NEIGHBOURHOOD; dx++) {
51
+ const xx = x + dx;
52
+ if (xx < 0 || xx >= width)
53
+ continue;
54
+ const b = (yy * to.width + xx) * 3;
55
+ if (Math.abs(r - to.rgb[b]) <= THRESHOLD && Math.abs(g - to.rgb[b + 1]) <= THRESHOLD && Math.abs(bl - to.rgb[b + 2]) <= THRESHOLD)
56
+ return true;
57
+ }
58
+ }
59
+ return false;
60
+ }
61
+ /** Compare two pages over their common area; the picture is that area, the reference faded and the pixels that still differ after the slip red. */
62
+ export function diffPage(ref, other) {
63
+ const width = Math.min(ref.width, other.width);
64
+ const height = Math.min(ref.height, other.height);
65
+ const out = new Uint8Array(width * height * 3);
66
+ let differing = 0;
67
+ for (let y = 0; y < height; y++) {
68
+ for (let x = 0; x < width; x++) {
69
+ const a = (y * ref.width + x) * 3;
70
+ const o = (y * width + x) * 3;
71
+ if (!matched(other, ref, x, y, width, height) || !matched(ref, other, x, y, width, height)) {
72
+ differing++;
73
+ out[o] = 255;
74
+ out[o + 1] = 0;
75
+ out[o + 2] = 0;
76
+ }
77
+ else
78
+ for (let c = 0; c < 3; c++)
79
+ out[o + c] = 255 - Math.round((255 - ref.rgb[a + c]) * 0.3);
80
+ }
81
+ }
82
+ const pix = new mupdf.Pixmap(mupdf.ColorSpace.DeviceRGB, [0, 0, width, height], false);
83
+ pix.getPixels().set(out);
84
+ return { share: width * height ? differing / (width * height) : 0, sameSize: ref.width === other.width && ref.height === other.height, width, height, image: pix.asPNG() };
85
+ }
86
+ /** Compare two page lists page by page. */
87
+ export function diffPages(ref, other) {
88
+ const n = Math.min(ref.length, other.length);
89
+ const pages = Array.from({ length: n }, (_, i) => diffPage(ref[i], other[i]));
90
+ return { pages, unmatched: Math.abs(ref.length - other.length), mean: n ? pages.reduce((s, p) => s + p.share, 0) / n : 0 };
91
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The command line's flags: its own table, and the way `--flag <owner>.<name>=<value>` reaches every package's table.
3
+ *
4
+ * @remarks
5
+ * Each package declares its flags with `defineFlags` from `@beltype/common/flags`; the command line reads the tables and parses a string against the row it names. `--trace <file>` takes a free string, which a {@link FlagSpec} row does not express, so it stays a plain `parseArgs` option beside this table.
6
+ *
7
+ * @packageDocumentation
8
+ */
9
+ import { defineFlags } from '@beltype/common/flags';
10
+ import { ValidationError } from '@beltype/common/errors';
11
+ export const cliFlags = defineFlags('beltype', {
12
+ /** Default `'caster'`: Cicero cuts and Caster prints. `'chromium'` has Chromium print the pages Cicero presented, which is what `check` compares Caster against. */
13
+ printer: { values: ['caster', 'chromium'], default: 'caster' },
14
+ /** Default `false`: no proof. `true` screenshots each presented page, rasterises the PDF and diffs the pairs into `<name>.check/`. */
15
+ check: { values: [false, true], default: false }
16
+ });
17
+ /**
18
+ * The tables of the packages, each loaded the first time a run needs it.
19
+ *
20
+ * @remarks
21
+ * `import('@beltype/caster')` loads every module of Caster, which costs about 40 ms in Node, and a plain print does not read its table: the page resolves Caster's defaults itself. So the table is imported when a `--flag caster.*` names it, for `--help`, and for the trace's `flags` field.
22
+ */
23
+ const LOADERS = {
24
+ caster: async () => (await import('@beltype/caster')).casterFlags,
25
+ cicero: async () => (await import('@beltype/cicero/flags')).ciceroFlags,
26
+ beltype: async () => cliFlags
27
+ };
28
+ const OWNERS = Object.keys(LOADERS);
29
+ /** The table of one owner. */
30
+ export function tableOf(owner) {
31
+ return LOADERS[owner]();
32
+ }
33
+ /** Read `owner.name=value` strings (the repeated `--flag`) against the tables, loading only the tables they name. @throws {ValidationError} on a malformed string or an unknown owner; {FlagError} on an unknown name or a bad value. */
34
+ export async function parseFlagArgs(args) {
35
+ const given = { caster: {}, cicero: {}, beltype: {} };
36
+ for (const arg of args) {
37
+ const m = /^([^.=]+)\.([^=]+)=(.*)$/s.exec(arg);
38
+ if (!m)
39
+ throw new ValidationError(`beltype: --flag takes <owner>.<name>=<value>; got "${arg}".`);
40
+ const [, owner, name, raw] = m;
41
+ if (!Object.hasOwn(LOADERS, owner))
42
+ throw new ValidationError(`beltype: no flag owner '${owner}'. The owners are ${OWNERS.join(', ')}.`);
43
+ given[owner][name] = (await tableOf(owner)).parse(name, raw);
44
+ }
45
+ return given;
46
+ }
47
+ /** Every table, one row a flag: owner, name, values and default. */
48
+ export async function flagsHelp() {
49
+ const tables = await Promise.all(OWNERS.map((owner) => tableOf(owner)));
50
+ const rows = tables.flatMap((t) => Object.entries(t.table).map(([name, spec]) => [`${t.owner}.${name}`, spec.values === null ? 'a positive number' : spec.values.map(String).join(' | '), String(spec.default)]));
51
+ const width = Math.max(...rows.map((r) => r[0].length));
52
+ const valuesWidth = Math.max(...rows.map((r) => r[1].length));
53
+ return `${rows.map(([flag, values, def]) => ` ${flag.padEnd(width)} ${values.padEnd(valuesWidth)} default ${def}`).join('\n')}`;
54
+ }
@@ -6,8 +6,12 @@
6
6
  *
7
7
  * @packageDocumentation
8
8
  */
9
- /** Caster's `prepare()` makes markers real elements before the cut, then Cicero's client paginates the whole document. */
10
- export async function cutInPage() {
9
+ /**
10
+ * Caster's `prepare()` makes markers real elements before the cut, then Cicero's client paginates the whole document.
11
+ *
12
+ * @param run - `cicero` is the resolved Cicero flags, handed to `run()`; `measures` asks for Cicero's own timings, read after the cut.
13
+ */
14
+ export async function cutInPage(run) {
11
15
  let at = 'prepare';
12
16
  try {
13
17
  // @ts-expect-error a URL the page resolves, not a module the checker can find. A literal on purpose: a computed `import()` is wrapped by `rewriteRelativeImportExtensions` in a helper the page does not have.
@@ -17,27 +21,51 @@ export async function cutInPage() {
17
21
  const cicero = window.__beltypeCicero;
18
22
  if (!cicero?.run)
19
23
  return { refused: 'the document loaded no Cicero client, so there is nothing to paginate with', at };
20
- const answer = await cicero.run();
24
+ const answer = await cicero.run(run.cicero);
21
25
  await document.fonts.ready;
22
- return { pages: answer.pages };
26
+ const measures = run.measures ? performance.getEntriesByType('measure').filter((m) => m.name.startsWith('cicero:')).map((m) => ({ name: m.name, ms: Math.round(m.duration * 100) / 100, detail: m.detail ?? null })) : [];
27
+ return { pages: answer.pages, measures };
23
28
  }
24
29
  catch (error) {
25
30
  return { refused: String(error?.message ?? error), at };
26
31
  }
27
32
  }
28
- /** Caster prints the presented pages; the bytes cross back as base64, the one form `evaluate` carries intact. */
29
- export async function printInPage() {
33
+ /**
34
+ * Caster prints the presented pages; the bytes cross back as base64, the one form `evaluate` carries intact.
35
+ *
36
+ * @param options - the resolved Caster flags. `printMs` in the answer is Caster's own time in the page, before the bytes are encoded.
37
+ */
38
+ export async function printInPage(options) {
30
39
  try {
40
+ const t0 = performance.now();
31
41
  // @ts-expect-error a URL the page resolves, not a module the checker can find. A literal on purpose: a computed `import()` is wrapped by `rewriteRelativeImportExtensions` in a helper the page does not have.
32
42
  const caster = await import('/_beltype/caster/src/index.js');
33
- const printed = await caster.print({ document });
43
+ const printed = await caster.print({ document }, options);
44
+ const printMs = performance.now() - t0;
34
45
  const bytes = printed.bytes;
35
46
  let binary = '';
36
47
  for (let i = 0; i < bytes.length; i += 0x8000)
37
48
  binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
38
- return { base64: btoa(binary), pages: printed.report.timing?.pages ?? 0, words: printed.report.timing?.words ?? 0, warnings: printed.report.warnings ?? [] };
49
+ return { base64: btoa(binary), pages: printed.report.timing?.pages ?? 0, words: printed.report.timing?.words ?? 0, warnings: printed.report.warnings ?? [], printMs, caster: { ms: printed.report.ms ?? {}, timing: printed.report.timing ?? null, fonts: (printed.report.emission?.faces ?? []).map((f) => ({ id: f.id, ms: f.ms ?? null })) } };
39
50
  }
40
51
  catch (error) {
41
52
  return { refused: String(error?.message ?? error), at: 'print' };
42
53
  }
43
54
  }
55
+ /** The size of each page Cicero presented, in CSS px. Cicero's pages live in the root's shadow tree. */
56
+ export function pageSizesInPage() {
57
+ const root = document.querySelector('beltype-root');
58
+ return [...(root?.shadowRoot?.querySelectorAll('beltype-page') ?? [])].map((p) => {
59
+ const r = p.getBoundingClientRect();
60
+ return { w: r.width, h: r.height };
61
+ });
62
+ }
63
+ /** Chromium's print sheet: `@page` is document-level, the page-break rule belongs in the shadow tree the pages live in. */
64
+ export function chromiumSheetInPage(sheet) {
65
+ const doc = document.createElement('style');
66
+ doc.textContent = sheet.page;
67
+ document.head.appendChild(doc);
68
+ const shadow = document.createElement('style');
69
+ shadow.textContent = sheet.shadow;
70
+ document.querySelector('beltype-root')?.shadowRoot?.appendChild(shadow);
71
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The `beltype` command.
3
+ *
4
+ * @remarks
5
+ * One verb so far: `beltype print <file.html> [-o <file.pdf>]`. The output defaults to the input's name with `.pdf`, beside it. A flag the parser does not know is refused, never ignored.
6
+ *
7
+ * Flags have one shape across BelType: `--flag <owner>.<name>=<value>`, repeatable, names a row of a package's table (`beltype print --help` lists them all). `--printer` and `--check` are the command line's own rows spelled plainly; `--trace <file>` takes a free string, so it is a plain option beside the table.
8
+ *
9
+ * @packageDocumentation
10
+ */
11
+ import { parseArgs } from 'node:util';
12
+ import { BelTypeError } from '@beltype/common/errors';
13
+ import { checkLines } from './check.js';
14
+ import { cliFlags, flagsHelp, parseFlagArgs } from './flags.js';
15
+ import { print } from './print.js';
16
+ const USAGE = 'usage: beltype print <file.html> [-o <file.pdf>] [--printer caster|chromium] [--check] [--trace <file.json>] [--flag <owner>.<name>=<value>]...';
17
+ async function main(argv) {
18
+ const { values, positionals } = parseArgs({ args: argv, allowPositionals: true, options: {
19
+ output: { type: 'string', short: 'o' },
20
+ help: { type: 'boolean', short: 'h' },
21
+ flag: { type: 'string', multiple: true },
22
+ printer: { type: 'string' },
23
+ check: { type: 'boolean' },
24
+ trace: { type: 'string' }
25
+ } });
26
+ const [verb, input, ...rest] = positionals;
27
+ if (values.help || !verb) {
28
+ console.log(`${USAGE}\n\nflags:\n${await flagsHelp()}`);
29
+ return values.help ? 0 : 1;
30
+ }
31
+ if (verb !== 'print' || !input || rest.length) {
32
+ console.error(verb !== 'print' ? `beltype: no verb '${verb}'\n${USAGE}` : USAGE);
33
+ return 1;
34
+ }
35
+ const given = await parseFlagArgs(values.flag ?? []);
36
+ if (values.printer !== undefined)
37
+ given.beltype['printer'] = cliFlags.parse('printer', values.printer);
38
+ if (values.check)
39
+ given.beltype['check'] = true;
40
+ const output = values.output ?? input.replace(/\.html?$/i, '') + '.pdf';
41
+ const result = await print(input, output, { given, ...(values.trace !== undefined ? { trace: values.trace } : {}) });
42
+ console.log(`${result.output}: ${result.pages} pages, ${result.words} words, ${(result.ms / 1000).toFixed(1)} s`);
43
+ if (result.check)
44
+ for (const line of checkLines(result.check))
45
+ console.log(line);
46
+ return 0;
47
+ }
48
+ main(process.argv.slice(2)).then((code) => process.exit(code), (error) => {
49
+ console.error(error instanceof BelTypeError ? error.message : error);
50
+ process.exit(1);
51
+ });
package/dist/src/print.js CHANGED
@@ -2,7 +2,7 @@
2
2
  * `beltype print`: a finished HTML document to a PDF, through Cicero and Caster, in Chromium's headless shell.
3
3
  *
4
4
  * @remarks
5
- * **One road.** Cicero paginates and Caster prints the pages Cicero presented. Chromium's own print (`page.pdf()`) is never called, which is why the bare headless shell is enough: where Cicero cuts is layout, the same Blink in the shell as in the full binary.
5
+ * **Two printers.** Cicero paginates, then Caster prints the pages Cicero presented (the default), or `--printer chromium` has Chromium's `page.pdf()` print them. Under Caster the bare headless shell is enough: where Cicero cuts is layout, the same Blink in the shell as in the full binary.
6
6
  *
7
7
  * **Every request is answered from disk, on one made-up origin.** The browser context's `route` answers the document at its own name, the document's folder at the root, and `/_beltype/` from the companions' built files. A request for anything else is refused and named, and a run that refused anything writes no file, because the print would not be the document.
8
8
  *
@@ -12,62 +12,171 @@ import fs from 'node:fs';
12
12
  import path from 'node:path';
13
13
  import { fileURLToPath } from 'node:url';
14
14
  import { chromium } from 'playwright-core';
15
- import { ciceroEngine } from '@beltype/cicero';
15
+ import { ciceroEngine } from '@beltype/cicero/engine';
16
+ import { ciceroFlags } from '@beltype/cicero/flags';
16
17
  import { BuildError } from '@beltype/common/errors';
17
18
  import { contentType } from '@beltype/common/mime';
18
- import { cutInPage, printInPage } from './in-page.js';
19
+ import { cutInPage, printInPage, pageSizesInPage, chromiumSheetInPage } from './in-page.js';
20
+ import { cliFlags, tableOf } from './flags.js';
21
+ import { chromiumSheet } from './chromium.js';
22
+ import { screenshotPages, checkPages } from './check.js';
19
23
  /** `.invalid` is reserved (RFC 2606), so nothing on a network can ever answer it instead. */
20
24
  const ORIGIN = 'https://print.invalid';
21
25
  const VIEWPORT = { width: 1200, height: 1600 };
22
26
  /** Deterministic text and timers that run while nobody watches the page. */
23
27
  const ARGS = ['--font-render-hinting=none', '--force-device-scale-factor=1', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--disable-backgrounding-occluded-windows'];
28
+ const round = (ms) => Math.round(ms * 10) / 10;
24
29
  /**
25
30
  * Print `input`, an HTML file, to `output`.
26
31
  *
27
32
  * @throws {BuildError} naming what the document asked for and could not be given, or the step that refused.
28
33
  */
29
- export async function print(input, output) {
34
+ export async function print(input, output, options = {}) {
30
35
  const started = performance.now();
31
- const html = fs.readFileSync(input, 'utf8');
32
- const root = path.dirname(path.resolve(input));
33
- const docPath = `/${encodeURIComponent(path.basename(input))}`;
34
- // Each companion's built folder, found from a module it exports: common exports no `package.json`.
35
- const distOf = (specifier, up) => path.resolve(path.dirname(fileURLToPath(import.meta.resolve(specifier))), ...Array(up).fill('..'));
36
- const cicero = ciceroEngine.vendorAssets();
37
- const assets = {
38
- files: cicero.files,
39
- dirs: { ...cicero.dirs, caster: distOf('@beltype/caster', 1), common: distOf('@beltype/common/errors', 0) },
36
+ // Caster's own defaults are resolved in the page by `caster.print`, so the given values are all the page is handed; its table is loaded for the trace alone, at the end.
37
+ const flags = {
38
+ caster: options.given?.caster ?? {},
39
+ cicero: ciceroFlags.resolve(options.given?.cicero),
40
+ beltype: cliFlags.resolve(options.given?.beltype)
40
41
  };
41
- // Caster's modules import common by its package names, which a page resolves only through an import map.
42
- const imports = Object.fromEntries(['errors', 'data-beltype', 'mime'].map((m) => [`@beltype/common/${m}`, `/_beltype/common/${m}.js`]));
43
- const served = spliceHead(html, `<script type="importmap">${JSON.stringify({ imports })}</script>\n ${ciceroEngine.headHtml('/')}`);
44
- const missing = new Set();
45
- const browser = await chromium.launch({ headless: true, args: ARGS });
42
+ const trace = { process: {}, cicero: [], caster: null, versions: {}, flags: {} };
43
+ let mark = started;
44
+ const lap = (key) => {
45
+ const now = performance.now();
46
+ trace.process[key] = round(now - mark);
47
+ mark = now;
48
+ };
49
+ let step = 'launch';
50
+ let chromiumVersion = 'unknown';
46
51
  try {
47
- const context = await browser.newContext({ viewport: VIEWPORT, serviceWorkers: 'block' });
48
- await answerFromDisk(context, missing, { root, docPath, served, assets });
49
- const page = await context.newPage();
50
- page.on('pageerror', (error) => console.warn(`page error: ${error.message}`));
51
- await page.goto(ORIGIN + docPath, { waitUntil: 'load' });
52
- const cut = await page.evaluate(cutInPage);
53
- if ('refused' in cut || missing.size)
54
- throw refusal(missing, 'refused' in cut ? cut : null);
55
- const printed = await page.evaluate(printInPage);
56
- if ('refused' in printed)
57
- throw refusal(missing, printed);
58
- await browser.close();
59
- if (missing.size)
60
- throw refusal(missing, null);
61
- for (const warning of printed.warnings)
62
- console.warn(typeof warning === 'string' ? warning : JSON.stringify(warning));
63
- fs.mkdirSync(path.dirname(path.resolve(output)), { recursive: true });
64
- fs.writeFileSync(output, Buffer.from(printed.base64, 'base64'));
65
- return { output, pages: printed.pages, words: printed.words, ms: performance.now() - started };
52
+ const html = fs.readFileSync(input, 'utf8');
53
+ const root = path.dirname(path.resolve(input));
54
+ const docPath = `/${encodeURIComponent(path.basename(input))}`;
55
+ // Each companion's built folder, found from a module it exports: common exports no `package.json`.
56
+ const distOf = (specifier, up) => path.resolve(path.dirname(fileURLToPath(import.meta.resolve(specifier))), ...Array(up).fill('..'));
57
+ const cicero = ciceroEngine.vendorAssets();
58
+ const assets = {
59
+ files: cicero.files,
60
+ dirs: { ...cicero.dirs, caster: distOf('@beltype/caster', 1), common: distOf('@beltype/common/errors', 0) },
61
+ };
62
+ // Caster's modules import common by its package names, which a page resolves only through an import map.
63
+ const imports = Object.fromEntries(['errors', 'data-beltype', 'flags', 'mime'].map((m) => [`@beltype/common/${m}`, `/_beltype/common/${m}.js`]));
64
+ const served = spliceHead(html, `<script type="importmap">${JSON.stringify({ imports })}</script>\n ${ciceroEngine.headHtml('/')}`);
65
+ const missing = new Set();
66
+ const browser = await chromium.launch({ headless: true, args: ARGS });
67
+ chromiumVersion = browser.version();
68
+ lap('launch');
69
+ try {
70
+ step = 'context';
71
+ const context = await browser.newContext({ viewport: VIEWPORT, serviceWorkers: 'block' });
72
+ await answerFromDisk(context, missing, { root, docPath, served, assets });
73
+ const page = await context.newPage();
74
+ page.on('pageerror', (error) => console.warn(`page error: ${error.message}`));
75
+ lap('context');
76
+ step = 'load';
77
+ await page.goto(ORIGIN + docPath, { waitUntil: 'load' });
78
+ lap('load');
79
+ step = 'cut';
80
+ const cut = await page.evaluate(cutInPage, { cicero: flags.cicero, measures: options.trace !== undefined });
81
+ lap('cut');
82
+ if (!('refused' in cut))
83
+ trace.cicero = cut.measures;
84
+ if ('refused' in cut || missing.size)
85
+ throw refusal(missing, 'refused' in cut ? cut : null);
86
+ let screens = [];
87
+ if (flags.beltype.check) {
88
+ step = 'check';
89
+ screens = await screenshotPages(page);
90
+ lap('check');
91
+ }
92
+ step = 'print';
93
+ let bytes;
94
+ let pages;
95
+ let words;
96
+ if (flags.beltype.printer === 'chromium') {
97
+ const sheet = chromiumSheet(await page.evaluate(pageSizesInPage));
98
+ await page.evaluate(chromiumSheetInPage, sheet);
99
+ bytes = await page.pdf({ preferCSSPageSize: true, printBackground: true });
100
+ pages = await page.locator('beltype-page').count();
101
+ words = 0;
102
+ lap('print');
103
+ }
104
+ else {
105
+ const printed = await page.evaluate(printInPage, flags.caster);
106
+ if ('refused' in printed)
107
+ throw refusal(missing, printed);
108
+ trace.process['print'] = round(printed.printMs);
109
+ trace.process['transfer'] = round(performance.now() - mark - printed.printMs);
110
+ mark = performance.now();
111
+ trace.caster = printed.caster;
112
+ for (const warning of printed.warnings)
113
+ console.warn(typeof warning === 'string' ? warning : JSON.stringify(warning));
114
+ bytes = Buffer.from(printed.base64, 'base64');
115
+ pages = printed.pages;
116
+ words = printed.words;
117
+ }
118
+ step = 'close';
119
+ await browser.close();
120
+ lap('close');
121
+ if (missing.size)
122
+ throw refusal(missing, null);
123
+ step = 'write';
124
+ fs.mkdirSync(path.dirname(path.resolve(output)), { recursive: true });
125
+ fs.writeFileSync(output, bytes);
126
+ lap('write');
127
+ let check;
128
+ if (flags.beltype.check) {
129
+ step = 'check';
130
+ check = await checkPages(screens, bytes, path.resolve(output).replace(/\.pdf$/i, '') + '.check', flags.beltype.printer);
131
+ lap('check');
132
+ }
133
+ return { output, pages, words, ms: performance.now() - started, ...(check ? { check } : {}) };
134
+ }
135
+ finally {
136
+ await browser.close();
137
+ }
138
+ }
139
+ catch (error) {
140
+ trace.refused = { step, message: error instanceof Error ? error.message : String(error) };
141
+ throw error;
66
142
  }
67
143
  finally {
68
- await browser.close();
144
+ if (options.trace !== undefined) {
145
+ trace.process['total'] = round(performance.now() - started);
146
+ trace.versions = versions(chromiumVersion);
147
+ // After the total: importing Caster's table here costs about 40 ms in Node, which is not the run's.
148
+ trace.flags = { ...flags, caster: (await tableOf('caster')).resolve(flags.caster) };
149
+ fs.mkdirSync(path.dirname(path.resolve(options.trace)), { recursive: true });
150
+ fs.writeFileSync(options.trace, JSON.stringify(trace, null, 2));
151
+ }
69
152
  }
70
153
  }
154
+ /** The version of each package the run used, read from the `package.json` above the module that `specifier` resolves to. */
155
+ function versions(chromiumVersion) {
156
+ const of = (url, name) => {
157
+ let dir = path.dirname(fileURLToPath(url));
158
+ for (;;) {
159
+ const file = path.join(dir, 'package.json');
160
+ if (fs.existsSync(file)) {
161
+ const pkg = JSON.parse(fs.readFileSync(file, 'utf8'));
162
+ if (pkg.name === name)
163
+ return pkg.version ?? 'unknown';
164
+ }
165
+ const up = path.dirname(dir);
166
+ if (up === dir)
167
+ return 'unknown';
168
+ dir = up;
169
+ }
170
+ };
171
+ return {
172
+ '@beltype/cli': of(import.meta.url, '@beltype/cli'),
173
+ '@beltype/cicero': of(import.meta.resolve('@beltype/cicero'), '@beltype/cicero'),
174
+ '@beltype/caster': of(import.meta.resolve('@beltype/caster'), '@beltype/caster'),
175
+ '@beltype/common': of(import.meta.resolve('@beltype/common/errors'), '@beltype/common'),
176
+ 'playwright-core': of(import.meta.resolve('playwright-core'), 'playwright-core'),
177
+ chromium: chromiumVersion
178
+ };
179
+ }
71
180
  /** The run's refusal: what it could not answer comes first, because a missing file's name says more than the step it broke. */
72
181
  function refusal(missing, answer) {
73
182
  if (missing.size)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@beltype/cli",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "beltype, the command line: a finished HTML document printed to PDF by Cicero and Caster in Chromium's headless shell.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -32,15 +32,15 @@
32
32
  ]
33
33
  },
34
34
  "dependencies": {
35
- "@beltype/caster": "0.1.3",
36
- "@beltype/cicero": "0.1.4",
37
- "@beltype/common": "0.1.1",
35
+ "@beltype/caster": "0.1.5",
36
+ "@beltype/cicero": "0.1.5",
37
+ "@beltype/common": "0.1.2",
38
+ "mupdf": "1.28.1",
38
39
  "playwright-core": "1.63.0"
39
40
  },
40
41
  "devDependencies": {
41
42
  "@beltype/specimens": "0.1.0",
42
43
  "@types/node": "^22.20.5",
43
- "mupdf": "1.28.1",
44
44
  "typescript": "7.0.2"
45
45
  },
46
46
  "bugs": {