@beltype/cli 0.1.3 → 0.1.5
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 +32 -2
- package/assets/warmup/OFL-SourceSans3.txt +93 -0
- package/assets/warmup/SourceSans3-Variable.woff2 +0 -0
- package/dist/src/check.js +60 -0
- package/dist/src/chromium.js +22 -0
- package/dist/src/cli.js +5 -26
- package/dist/src/compare.js +91 -0
- package/dist/src/flags.js +63 -0
- package/dist/src/in-page.js +37 -8
- package/dist/src/main.js +82 -0
- package/dist/src/print.js +111 -104
- package/dist/src/run.js +157 -0
- package/dist/src/serve.js +272 -0
- package/package.json +8 -7
package/README.md
CHANGED
|
@@ -1,14 +1,44 @@
|
|
|
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
|
|
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
|
|
9
|
+
beltype serve # the same, behind HTTP: see below
|
|
8
10
|
```
|
|
9
11
|
|
|
12
|
+
## Flags
|
|
13
|
+
|
|
14
|
+
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.
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
beltype print paper/index.html --flag caster.shaper=fontkit --flag cicero.composed=true
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
- `--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.
|
|
21
|
+
- `--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.
|
|
22
|
+
- `--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.
|
|
23
|
+
- `SOURCE_DATE_EPOCH=<seconds>` (an environment variable, not a flag): the moment the file says it was made, whole seconds since 1970 as the reproducible-builds convention has it. Set, the file's `/CreationDate` and `/ModDate` are that instant and the same document printed twice gives the same bytes; unset, they are the wall clock.
|
|
24
|
+
|
|
10
25
|
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
26
|
|
|
27
|
+
## beltype serve
|
|
28
|
+
|
|
29
|
+
`beltype serve` is `beltype print` behind an HTTP door, in Gotenberg's shape: one Chromium kept warm, and forme kept warm in a shared worker that every document's page hands its display list to, so a print skips the browser's launch and forme's cold first write. A print is still `beltype print`'s: Cicero cuts and Caster prints in the document's own page, answered from the files it was posted with.
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
beltype serve --port 3000
|
|
33
|
+
curl -F index.html=@index.html -F paper.css=@paper.css -F fonts/a.woff2=@fonts/a.woff2 -F caster.writer=forme http://127.0.0.1:3000/print -o out.pdf
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
- `POST /print`, `multipart/form-data`. `index.html` is required; every other file keeps its form field name as its path relative to the document (`fonts/a.woff2`), plain segments with `/` separators, never absolute and never `..`. A field that is not a file is a flag, `<owner>.<name>` = value (`caster.writer=forme`, `cicero.…`); `beltype.check` is refused. A field `created` (an ISO 8601 instant) fixes the file's dates, else `SOURCE_DATE_EPOCH`, else the clock. The answer is `200 application/pdf` with `X-Beltype-Pages`, `X-Beltype-Words` and `X-Beltype-Warnings` (the count); `422` with `beltype print`'s sentence where the document is refused (a file it asked for and was not given, among them); `400` for a bad request; `413` over the upload limit; `504` over the time limit; `500` otherwise, after which the browser is restarted.
|
|
37
|
+
- `GET /health`: `{ "status": "up", "chromium": …, "prints": …, "queued": … }`, or `503` with `"status": "restarting"` while the browser starts again.
|
|
38
|
+
- `--host 127.0.0.1`, `--port 3000`, `--max-upload 50` (MB), `--timeout 60` (seconds per print), `--restart-after 100` (prints before the browser is restarted).
|
|
39
|
+
- One print runs at a time; the others wait their turn. After every start the server prints a built-in document twice under forme (the log shows both writes), so the first request is warm.
|
|
40
|
+
- It binds `127.0.0.1` unless `--host` says otherwise, and fetches nothing from a network: a document that asks for anything it was not posted is refused by name, as in `beltype print`. The built-in warm-up document ships one font, Source Sans 3 (SIL OFL), in `assets/warmup/`.
|
|
41
|
+
|
|
12
42
|
## The sample
|
|
13
43
|
|
|
14
44
|
`paper`, from [`@beltype/specimens`](https://codeberg.org/BelType/specimens), is a short scientific article on A4 (its data are invented). It exercises what a paged document needs:
|
|
@@ -46,6 +76,6 @@ The companions come from the organization's repositories as git dependencies, ea
|
|
|
46
76
|
```bash
|
|
47
77
|
pnpm install
|
|
48
78
|
pnpm run check # tsc over src and test, strict
|
|
49
|
-
pnpm test # prints the specimen and reads the
|
|
79
|
+
pnpm test # prints the specimen under each printer, and reads the files back with mupdf
|
|
50
80
|
pnpm run build # dist/, which the beltype command runs
|
|
51
81
|
```
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
Copyright 2010-2020 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'. All Rights Reserved. Source is a trademark of Adobe in the United States and/or other countries.
|
|
2
|
+
|
|
3
|
+
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
|
4
|
+
|
|
5
|
+
This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
-----------------------------------------------------------
|
|
9
|
+
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
|
10
|
+
-----------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
PREAMBLE
|
|
13
|
+
The goals of the Open Font License (OFL) are to stimulate worldwide
|
|
14
|
+
development of collaborative font projects, to support the font creation
|
|
15
|
+
efforts of academic and linguistic communities, and to provide a free and
|
|
16
|
+
open framework in which fonts may be shared and improved in partnership
|
|
17
|
+
with others.
|
|
18
|
+
|
|
19
|
+
The OFL allows the licensed fonts to be used, studied, modified and
|
|
20
|
+
redistributed freely as long as they are not sold by themselves. The
|
|
21
|
+
fonts, including any derivative works, can be bundled, embedded,
|
|
22
|
+
redistributed and/or sold with any software provided that any reserved
|
|
23
|
+
names are not used by derivative works. The fonts and derivatives,
|
|
24
|
+
however, cannot be released under any other type of license. The
|
|
25
|
+
requirement for fonts to remain under this license does not apply
|
|
26
|
+
to any document created using the fonts or their derivatives.
|
|
27
|
+
|
|
28
|
+
DEFINITIONS
|
|
29
|
+
"Font Software" refers to the set of files released by the Copyright
|
|
30
|
+
Holder(s) under this license and clearly marked as such. This may
|
|
31
|
+
include source files, build scripts and documentation.
|
|
32
|
+
|
|
33
|
+
"Reserved Font Name" refers to any names specified as such after the
|
|
34
|
+
copyright statement(s).
|
|
35
|
+
|
|
36
|
+
"Original Version" refers to the collection of Font Software components as
|
|
37
|
+
distributed by the Copyright Holder(s).
|
|
38
|
+
|
|
39
|
+
"Modified Version" refers to any derivative made by adding to, deleting,
|
|
40
|
+
or substituting -- in part or in whole -- any of the components of the
|
|
41
|
+
Original Version, by changing formats or by porting the Font Software to a
|
|
42
|
+
new environment.
|
|
43
|
+
|
|
44
|
+
"Author" refers to any designer, engineer, programmer, technical
|
|
45
|
+
writer or other person who contributed to the Font Software.
|
|
46
|
+
|
|
47
|
+
PERMISSION & CONDITIONS
|
|
48
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
49
|
+
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
|
50
|
+
redistribute, and sell modified and unmodified copies of the Font
|
|
51
|
+
Software, subject to the following conditions:
|
|
52
|
+
|
|
53
|
+
1) Neither the Font Software nor any of its individual components,
|
|
54
|
+
in Original or Modified Versions, may be sold by itself.
|
|
55
|
+
|
|
56
|
+
2) Original or Modified Versions of the Font Software may be bundled,
|
|
57
|
+
redistributed and/or sold with any software, provided that each copy
|
|
58
|
+
contains the above copyright notice and this license. These can be
|
|
59
|
+
included either as stand-alone text files, human-readable headers or
|
|
60
|
+
in the appropriate machine-readable metadata fields within text or
|
|
61
|
+
binary files as long as those fields can be easily viewed by the user.
|
|
62
|
+
|
|
63
|
+
3) No Modified Version of the Font Software may use the Reserved Font
|
|
64
|
+
Name(s) unless explicit written permission is granted by the corresponding
|
|
65
|
+
Copyright Holder. This restriction only applies to the primary font name as
|
|
66
|
+
presented to the users.
|
|
67
|
+
|
|
68
|
+
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
|
69
|
+
Software shall not be used to promote, endorse or advertise any
|
|
70
|
+
Modified Version, except to acknowledge the contribution(s) of the
|
|
71
|
+
Copyright Holder(s) and the Author(s) or with their explicit written
|
|
72
|
+
permission.
|
|
73
|
+
|
|
74
|
+
5) The Font Software, modified or unmodified, in part or in whole,
|
|
75
|
+
must be distributed entirely under this license, and must not be
|
|
76
|
+
distributed under any other license. The requirement for fonts to
|
|
77
|
+
remain under this license does not apply to any document created
|
|
78
|
+
using the Font Software.
|
|
79
|
+
|
|
80
|
+
TERMINATION
|
|
81
|
+
This license becomes null and void if any of the above conditions are
|
|
82
|
+
not met.
|
|
83
|
+
|
|
84
|
+
DISCLAIMER
|
|
85
|
+
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
86
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
|
87
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
|
88
|
+
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
|
89
|
+
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|
90
|
+
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
|
91
|
+
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
92
|
+
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
|
93
|
+
OTHER DEALINGS IN THE FONT SOFTWARE.
|
|
Binary file
|
|
@@ -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`
|
|
3
|
+
* The `beltype` executable: turns on Node's compile cache, then runs `main.ts`.
|
|
4
4
|
*
|
|
5
5
|
* @remarks
|
|
6
|
-
*
|
|
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 {
|
|
11
|
-
|
|
12
|
-
import
|
|
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,63 @@
|
|
|
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
|
+
}
|
|
55
|
+
/** The instant `SOURCE_DATE_EPOCH` names (whole seconds since 1970, the reproducible-builds convention), as an ISO 8601 string; undefined where the variable is not set. @throws {ValidationError} where it is not whole seconds. */
|
|
56
|
+
export function createdFromEnv(env = process.env) {
|
|
57
|
+
const epoch = env['SOURCE_DATE_EPOCH'];
|
|
58
|
+
if (epoch === undefined || epoch === '')
|
|
59
|
+
return undefined;
|
|
60
|
+
if (!/^\d{1,15}$/.test(epoch))
|
|
61
|
+
throw new ValidationError(`beltype: SOURCE_DATE_EPOCH is whole seconds since 1970; got "${epoch}".`);
|
|
62
|
+
return new Date(Number(epoch) * 1000).toISOString();
|
|
63
|
+
}
|
package/dist/src/in-page.js
CHANGED
|
@@ -6,8 +6,12 @@
|
|
|
6
6
|
*
|
|
7
7
|
* @packageDocumentation
|
|
8
8
|
*/
|
|
9
|
-
/**
|
|
10
|
-
|
|
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,52 @@ 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
|
-
|
|
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
|
-
/**
|
|
29
|
-
|
|
33
|
+
/**
|
|
34
|
+
* Caster prints the presented pages; the bytes cross back as base64, the one form `evaluate` carries intact.
|
|
35
|
+
*
|
|
36
|
+
* @param run - `options` is the resolved Caster flags; `created` is the instant the file says it was made, absent for the wall clock; `sharedWriter` has forme write through a `SharedWorker` the page opens (Caster's `formeSharedWorkerUrl()`), so a host that keeps the page's browser alive keeps the writer warm. `printMs` in the answer is Caster's own time in the page, before the bytes are encoded.
|
|
37
|
+
*/
|
|
38
|
+
export async function printInPage(run) {
|
|
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
|
|
43
|
+
const formePort = run.sharedWriter ? new SharedWorker(caster.formeSharedWorkerUrl(), { type: 'module', name: 'forme' }).port : undefined;
|
|
44
|
+
const printed = await caster.print({ document, ...(run.created !== undefined ? { created: run.created } : {}), ...(formePort ? { formePort } : {}) }, run.options);
|
|
45
|
+
const printMs = performance.now() - t0;
|
|
34
46
|
const bytes = printed.bytes;
|
|
35
47
|
let binary = '';
|
|
36
48
|
for (let i = 0; i < bytes.length; i += 0x8000)
|
|
37
49
|
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 ?? [] };
|
|
50
|
+
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
51
|
}
|
|
40
52
|
catch (error) {
|
|
41
53
|
return { refused: String(error?.message ?? error), at: 'print' };
|
|
42
54
|
}
|
|
43
55
|
}
|
|
56
|
+
/** The size of each page Cicero presented, in CSS px. Cicero's pages live in the root's shadow tree. */
|
|
57
|
+
export function pageSizesInPage() {
|
|
58
|
+
const root = document.querySelector('beltype-root');
|
|
59
|
+
return [...(root?.shadowRoot?.querySelectorAll('beltype-page') ?? [])].map((p) => {
|
|
60
|
+
const r = p.getBoundingClientRect();
|
|
61
|
+
return { w: r.width, h: r.height };
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
/** Chromium's print sheet: `@page` is document-level, the page-break rule belongs in the shadow tree the pages live in. */
|
|
65
|
+
export function chromiumSheetInPage(sheet) {
|
|
66
|
+
const doc = document.createElement('style');
|
|
67
|
+
doc.textContent = sheet.page;
|
|
68
|
+
document.head.appendChild(doc);
|
|
69
|
+
const shadow = document.createElement('style');
|
|
70
|
+
shadow.textContent = sheet.shadow;
|
|
71
|
+
document.querySelector('beltype-root')?.shadowRoot?.appendChild(shadow);
|
|
72
|
+
}
|
package/dist/src/main.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `beltype` command.
|
|
3
|
+
*
|
|
4
|
+
* @remarks
|
|
5
|
+
* Two verbs: `beltype print <file.html> [-o <file.pdf>]` and `beltype serve` (see `serve.ts`). 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, ValidationError } from '@beltype/common/errors';
|
|
13
|
+
import { checkLines } from './check.js';
|
|
14
|
+
import { cliFlags, createdFromEnv, flagsHelp, parseFlagArgs } from './flags.js';
|
|
15
|
+
import { print } from './print.js';
|
|
16
|
+
import { serve } from './serve.js';
|
|
17
|
+
const USAGE = 'usage: beltype serve [--host 127.0.0.1] [--port 3000] [--max-upload 50] [--timeout 60] [--restart-after 100]\n beltype print <file.html> [-o <file.pdf>] [--printer caster|chromium] [--check] [--trace <file.json>] [--flag <owner>.<name>=<value>]...';
|
|
18
|
+
async function serveVerb(argv) {
|
|
19
|
+
const { values } = parseArgs({ args: argv, options: {
|
|
20
|
+
host: { type: 'string', default: '127.0.0.1' },
|
|
21
|
+
port: { type: 'string', default: '3000' },
|
|
22
|
+
'max-upload': { type: 'string', default: '50' },
|
|
23
|
+
timeout: { type: 'string', default: '60' },
|
|
24
|
+
'restart-after': { type: 'string', default: '100' }
|
|
25
|
+
} });
|
|
26
|
+
const number = (name, raw, min) => {
|
|
27
|
+
if (!/^\d+$/.test(raw) || Number(raw) < min)
|
|
28
|
+
throw new ValidationError(`beltype serve: --${name} is a whole number of at least ${min}; got "${raw}".`);
|
|
29
|
+
return Number(raw);
|
|
30
|
+
};
|
|
31
|
+
const server = await serve({
|
|
32
|
+
host: values.host,
|
|
33
|
+
port: number('port', values.port, 0),
|
|
34
|
+
maxUpload: number('max-upload', values['max-upload'], 1) * 1024 * 1024,
|
|
35
|
+
timeout: number('timeout', values.timeout, 1) * 1000,
|
|
36
|
+
restartAfter: number('restart-after', values['restart-after'], 1)
|
|
37
|
+
});
|
|
38
|
+
const stop = () => { void server.close().then(() => process.exit(0)); };
|
|
39
|
+
process.on('SIGINT', stop);
|
|
40
|
+
process.on('SIGTERM', stop);
|
|
41
|
+
}
|
|
42
|
+
async function main(argv) {
|
|
43
|
+
if (argv[0] === 'serve') {
|
|
44
|
+
await serveVerb(argv.slice(1));
|
|
45
|
+
return -1;
|
|
46
|
+
}
|
|
47
|
+
const { values, positionals } = parseArgs({ args: argv, allowPositionals: true, options: {
|
|
48
|
+
output: { type: 'string', short: 'o' },
|
|
49
|
+
help: { type: 'boolean', short: 'h' },
|
|
50
|
+
flag: { type: 'string', multiple: true },
|
|
51
|
+
printer: { type: 'string' },
|
|
52
|
+
check: { type: 'boolean' },
|
|
53
|
+
trace: { type: 'string' }
|
|
54
|
+
} });
|
|
55
|
+
const [verb, input, ...rest] = positionals;
|
|
56
|
+
if (values.help || !verb) {
|
|
57
|
+
console.log(`${USAGE}\n\nflags:\n${await flagsHelp()}`);
|
|
58
|
+
return values.help ? 0 : 1;
|
|
59
|
+
}
|
|
60
|
+
if (verb !== 'print' || !input || rest.length) {
|
|
61
|
+
console.error(verb !== 'print' ? `beltype: no verb '${verb}'\n${USAGE}` : USAGE);
|
|
62
|
+
return 1;
|
|
63
|
+
}
|
|
64
|
+
const given = await parseFlagArgs(values.flag ?? []);
|
|
65
|
+
if (values.printer !== undefined)
|
|
66
|
+
given.beltype['printer'] = cliFlags.parse('printer', values.printer);
|
|
67
|
+
if (values.check)
|
|
68
|
+
given.beltype['check'] = true;
|
|
69
|
+
const output = values.output ?? input.replace(/\.html?$/i, '') + '.pdf';
|
|
70
|
+
const created = createdFromEnv();
|
|
71
|
+
const result = await print(input, output, { given, ...(values.trace !== undefined ? { trace: values.trace } : {}), ...(created ? { created } : {}) });
|
|
72
|
+
console.log(`${result.output}: ${result.pages} pages, ${result.words} words, ${(result.ms / 1000).toFixed(1)} s`);
|
|
73
|
+
if (result.check)
|
|
74
|
+
for (const line of checkLines(result.check))
|
|
75
|
+
console.log(line);
|
|
76
|
+
return 0;
|
|
77
|
+
}
|
|
78
|
+
main(process.argv.slice(2)).then((code) => { if (code >= 0)
|
|
79
|
+
process.exit(code); }, (error) => {
|
|
80
|
+
console.error(error instanceof BelTypeError ? error.message : error);
|
|
81
|
+
process.exit(1);
|
|
82
|
+
});
|