@beltype/cli 0.1.7 → 0.1.8
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 +11 -6
- package/assets/merge/NOTICE +7 -0
- package/assets/merge/merge.js +106 -0
- package/dist/src/check.js +127 -13
- package/dist/src/compare.js +47 -8
- package/dist/src/flags.js +2 -2
- package/dist/src/in-page.js +33 -6
- package/dist/src/main.js +23 -12
- package/dist/src/merge-tab.js +139 -0
- package/dist/src/print.js +10 -6
- package/dist/src/run.js +17 -4
- package/dist/src/serve.js +249 -57
- package/package.json +19 -7
package/dist/src/check.js
CHANGED
|
@@ -1,19 +1,72 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `--check`: the
|
|
2
|
+
* `--check`: the PDF compared with the pages Cicero presented in the same run.
|
|
3
3
|
*
|
|
4
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
|
|
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 is the proof that the printer drew what the engine cut, not a proof against an outside oracle: the acceptance suite of the cast road is that. Under `--check` a page whose share crosses {@link MAX_SHARE}, a page whose worst tile crosses its kind's floor in {@link TILE_FLOORS}, or a page count that differs between screen and print, fails the run ({@link gate}); `--check=report` writes the same report and never fails. `mupdf` is imported here, dynamically, so a plain print never loads it, and it is an optional peer, so a plain install never has it: it is AGPL-3.0, and this package is MIT.
|
|
6
6
|
*
|
|
7
7
|
* @packageDocumentation
|
|
8
8
|
*/
|
|
9
9
|
import fs from 'node:fs';
|
|
10
10
|
import path from 'node:path';
|
|
11
|
-
|
|
11
|
+
import { BuildError } from '@beltype/common/errors';
|
|
12
|
+
/**
|
|
13
|
+
* The share of a page's pixels that may differ before the page fails the check: 3.5 %.
|
|
14
|
+
*
|
|
15
|
+
* @remarks
|
|
16
|
+
* Measured 2026-11-08 on Caster's corpus (`fixtures/lists/`, sixteen documents and 37 pages; `convert-images` and `convert-text` refuse at this CLI's Caster pin), with a bled page aligned on its trim: median 0.73 %, 95th percentile 2.26 %. Text pages sit at 0.3 to 1.8 %, the highest cast-divided-row's page 4 at 1.76 %: Skia and mupdf shade glyph edges past the one pixel of slip `compare.ts` allows. The highest page short of a real disagreement is vector-roads' page 2 at 2.88 %, its box opacity and blend over a backdrop drawn in other colours. The threshold is about twice the highest text page. What it catches: the paper's page 2 drawn blank reads 4.98 %, so a lost page or a page whose content moved crosses it; roads' page 2 reads 13.01 %, its blurred shadow drawn as a raster and its text under `blackText: 100k`. What it does not catch: the 120 by 30 CSS px red block in a table that Caster's acceptance suite fails (*on the cast road the oracle is the page Cicero presented*, Caster's DESIGN.md S91) reads 0.40 % of an A4 page, under the noise of a text page; the tile floors ({@link TILE_FLOORS}) hold it.
|
|
17
|
+
*/
|
|
18
|
+
export const MAX_SHARE = 0.035;
|
|
19
|
+
/** The side of a tile in pixels: the 24 px square Caster's acceptance suite reads (*on the cast road the oracle is the page Cicero presented*, Caster's DESIGN.md S91). */
|
|
20
|
+
export const TILE = 24;
|
|
21
|
+
/**
|
|
22
|
+
* The share of one tile's pixels that may differ before its page fails, by what the page holds: 40 % for each kind.
|
|
23
|
+
*
|
|
24
|
+
* @remarks
|
|
25
|
+
* Measured 2026-11-08 on the same 37 pages as {@link MAX_SHARE}, the worst tile of each. Text pages (18): median 7.1 %, highest 19.62 % (cast-divided-row's page 4, the glyph edges of a table's cells). Picture pages (19): median 12.5 %, highest clean 17.71 % (facsimile-pages' page 2, glyph edges again). Five picture pages read higher, and each is a real disagreement the gate is for, so none sets a floor: roads' page 1 at 43.6 % (its text under `blackText: 100k`, drawn in another black), images' page 1 at 58.3 % (a band of the CMYK JPEG), arithmetic's page 1 at 63.9 % (a JPEG the browser cannot decode and the file carries), roads' page 2 and vector-roads' page 2 at 100 % (a blurred shadow drawn as a raster; box opacity and a blend over a backdrop). The floor is about twice the clean maximum of either kind. S91's 120 by 30 px block fills a tile it lands squarely on, and wherever it falls its 30 rows cover at least 15 of some tile's 24, 62.5 %, half again the floor. The two kinds measured alike once the disagreements were set aside, so the floors are equal; they stay two so that a kind that measures noisier can move alone.
|
|
26
|
+
*/
|
|
27
|
+
export const TILE_FLOORS = { text: 0.4, picture: 0.4 };
|
|
28
|
+
/**
|
|
29
|
+
* Refuse `--check` before the run spends anything, when mupdf is not installed.
|
|
30
|
+
*
|
|
31
|
+
* @throws {BuildError} naming the package, its licence and the command.
|
|
32
|
+
*/
|
|
33
|
+
export async function requireMupdf(load = () => import('mupdf')) {
|
|
34
|
+
try {
|
|
35
|
+
await load();
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
if (error.code !== 'ERR_MODULE_NOT_FOUND')
|
|
39
|
+
throw error;
|
|
40
|
+
throw new BuildError('beltype print: --check rasterises with mupdf, which is not installed. It is under the AGPL-3.0, so this package does not bring it: `npm install mupdf@1.28.1` where you run beltype, or print without --check.');
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** Whether a presented page holds anything drawn rather than set; runs in the page, so it closes over nothing. Cicero assigns the document's own elements to the page's slots by hand, so a slot is followed to what it holds, and a shadow root into its tree. */
|
|
44
|
+
export function kindInPage(page) {
|
|
45
|
+
const drawn = new Set(['IMG', 'CANVAS', 'VIDEO', 'SVG', 'IFRAME', 'OBJECT', 'EMBED', 'PICTURE']);
|
|
46
|
+
const stack = [page];
|
|
47
|
+
while (stack.length) {
|
|
48
|
+
const el = stack.pop();
|
|
49
|
+
if (el !== page) {
|
|
50
|
+
if (drawn.has(el.tagName.toUpperCase()))
|
|
51
|
+
return 'picture';
|
|
52
|
+
const s = getComputedStyle(el);
|
|
53
|
+
if (s.backgroundImage !== 'none' || s.mixBlendMode !== 'normal' || s.filter !== 'none' || s.boxShadow !== 'none' || Number(s.opacity) < 1)
|
|
54
|
+
return 'picture';
|
|
55
|
+
}
|
|
56
|
+
stack.push(...el.children);
|
|
57
|
+
if (el.shadowRoot)
|
|
58
|
+
stack.push(...el.shadowRoot.children);
|
|
59
|
+
if (el instanceof HTMLSlotElement)
|
|
60
|
+
stack.push(...el.assignedElements());
|
|
61
|
+
}
|
|
62
|
+
return 'text';
|
|
63
|
+
}
|
|
64
|
+
/** Each `beltype-page` clipped to its page box, and what it holds. Playwright's locators pierce the root's open shadow tree. */
|
|
12
65
|
export async function screenshotPages(page) {
|
|
13
66
|
const pages = await page.locator('beltype-page').all();
|
|
14
67
|
const shots = [];
|
|
15
68
|
for (const p of pages)
|
|
16
|
-
shots.push(await p.screenshot({ type: 'png' }));
|
|
69
|
+
shots.push({ png: await p.screenshot({ type: 'png' }), kind: await p.evaluate(kindInPage) });
|
|
17
70
|
return shots;
|
|
18
71
|
}
|
|
19
72
|
/** The PNG's pixels as RGB, three bytes a pixel. */
|
|
@@ -32,29 +85,90 @@ async function rasterOfPng(png) {
|
|
|
32
85
|
rgb[(y * width + x) * 3 + c] = src[y * stride + x * n + c];
|
|
33
86
|
return { width, height, rgb };
|
|
34
87
|
}
|
|
35
|
-
/**
|
|
36
|
-
|
|
37
|
-
|
|
88
|
+
/**
|
|
89
|
+
* Rasterise `pdf`, diff it page by page against `shots`, and write the evidence into `dir`.
|
|
90
|
+
*
|
|
91
|
+
* @remarks
|
|
92
|
+
* A printed page larger than its screenshot (a bleed and print marks grow Caster's media round the page) is cropped to the presented page before the diff: its bottom-left corner is the page's user-space origin (`rasterize`), so the window is the screenshot's size with its bottom edge there. `print-N.png` stays the whole printed page.
|
|
93
|
+
*
|
|
94
|
+
* A tile over its floor is excused where the printer confessed a region over it: each confessed `rect`, grown by the one pixel of slip the diff allows, covers pixels of the screenshot, and a tile is excused when the share of its pixels outside every cover that still differ, counted against what lies outside (at least a quarter of a tile, as at an edge), is at or under the floor. A tile wholly inside a cover is excused; one straddling a cover's edge is excused only when its outside part, on its own, would pass. The tile names the confession that covers most of it. The share is never excused: a confessed region cannot stand for a lost page.
|
|
95
|
+
*/
|
|
96
|
+
export async function checkPages(shots, pdf, dir, printer, confessions = []) {
|
|
97
|
+
const { rasterize, diffPages, crop, THRESHOLD } = await import('./compare.js');
|
|
38
98
|
const printed = rasterize(pdf);
|
|
39
|
-
const screen = await Promise.all(
|
|
40
|
-
const
|
|
99
|
+
const screen = await Promise.all(shots.map((s) => rasterOfPng(s.png)));
|
|
100
|
+
const trims = printed.pages.map((p, i) => {
|
|
101
|
+
const s = screen[i];
|
|
102
|
+
// a page at a fractional height prints one pixel taller than its screenshot, which the slip covers; only a page grown past that is cropped
|
|
103
|
+
if (!s || (p.width <= s.width + 1 && p.height <= s.height + 1))
|
|
104
|
+
return undefined;
|
|
105
|
+
const o = printed.origins[i];
|
|
106
|
+
return { x: Math.round(o.x), y: Math.round(o.y) - s.height };
|
|
107
|
+
});
|
|
108
|
+
const aligned = printed.pages.map((p, i) => (trims[i] ? crop(p, trims[i].x, trims[i].y, screen[i].width, screen[i].height) : p));
|
|
109
|
+
const grown = (c, r) => ({ c, x0: Math.max(0, Math.floor(c.rect.x) - 1), y0: Math.max(0, Math.floor(c.rect.y) - 1), x1: Math.min(r.width, Math.ceil(c.rect.x + c.rect.width) + 1), y1: Math.min(r.height, Math.ceil(c.rect.y + c.rect.height) + 1) });
|
|
110
|
+
const regions = screen.map((r, i) => confessions.filter((c) => c.page === i + 1).map((c) => grown(c, r)));
|
|
111
|
+
const covers = screen.map((r, i) => {
|
|
112
|
+
if (!regions[i].length)
|
|
113
|
+
return undefined;
|
|
114
|
+
const cover = new Uint8Array(r.width * r.height);
|
|
115
|
+
for (const g of regions[i])
|
|
116
|
+
for (let y = g.y0; y < g.y1; y++)
|
|
117
|
+
cover.fill(1, y * r.width + g.x0, y * r.width + Math.max(g.x0, g.x1));
|
|
118
|
+
return cover;
|
|
119
|
+
});
|
|
120
|
+
const diff = diffPages(screen, aligned, TILE, covers);
|
|
41
121
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
42
122
|
fs.mkdirSync(dir, { recursive: true });
|
|
43
123
|
const pages = diff.pages.map((d, i) => {
|
|
44
124
|
const n = i + 1;
|
|
45
|
-
fs.writeFileSync(path.join(dir, `screen-${n}.png`),
|
|
125
|
+
fs.writeFileSync(path.join(dir, `screen-${n}.png`), shots[i].png);
|
|
46
126
|
fs.writeFileSync(path.join(dir, `print-${n}.png`), printed.pngs[i]);
|
|
47
127
|
fs.writeFileSync(path.join(dir, `diff-${n}.png`), d.image);
|
|
48
|
-
|
|
128
|
+
const kind = shots[i].kind;
|
|
129
|
+
const floor = TILE_FLOORS[kind];
|
|
130
|
+
const trimAt = trims[i];
|
|
131
|
+
const excused = [];
|
|
132
|
+
let tile = { share: 0, x: 0, y: 0 };
|
|
133
|
+
for (const t of d.tiles) {
|
|
134
|
+
const overlap = (g) => Math.max(0, Math.min(g.x1, t.x + TILE) - Math.max(g.x0, t.x)) * Math.max(0, Math.min(g.y1, t.y + TILE) - Math.max(g.y0, t.y));
|
|
135
|
+
const by = regions[i].reduce((best, g) => { const area = overlap(g); return area > (best?.area ?? 0) ? { c: g.c, area } : best; }, null);
|
|
136
|
+
if (t.share > floor && by && t.outside <= floor)
|
|
137
|
+
excused.push({ x: t.x, y: t.y, share: t.share, kind: by.c.kind, element: by.c.element });
|
|
138
|
+
else if (t.share > tile.share)
|
|
139
|
+
tile = { share: t.share, x: t.x, y: t.y };
|
|
140
|
+
}
|
|
141
|
+
return { page: n, share: d.share, tile, excused, kind, pass: d.share <= MAX_SHARE && tile.share <= floor, sameSize: d.sameSize, screen: { width: screen[i].width, height: screen[i].height }, print: { width: printed.pages[i].width, height: printed.pages[i].height }, ...(trimAt ? { trimAt } : {}) };
|
|
49
142
|
});
|
|
50
|
-
const result = { dir, printer, threshold: THRESHOLD, screenPages:
|
|
143
|
+
const result = { dir, printer, threshold: THRESHOLD, maxShare: MAX_SHARE, confessions: [...confessions], screenPages: shots.length, printPages: printed.pages.length, unmatched: diff.unmatched, mean: diff.mean, pages };
|
|
51
144
|
fs.writeFileSync(path.join(dir, 'check.json'), JSON.stringify(result, null, 2));
|
|
52
145
|
return result;
|
|
53
146
|
}
|
|
54
147
|
/** One line a page, and the mean. */
|
|
55
148
|
export function checkLines(r) {
|
|
56
149
|
return [
|
|
57
|
-
...r.pages.map((p) =>
|
|
150
|
+
...r.pages.map((p) => {
|
|
151
|
+
const worst = p.excused.reduce((w, t) => (t.share > (w?.share ?? 0) ? t : w), null);
|
|
152
|
+
const tile = worst && worst.share > p.tile.share ? `worst tile ${(worst.share * 100).toFixed(0)}% at (${worst.x}, ${worst.y}) excused by ${worst.kind} on ${worst.element}` : `worst tile ${(p.tile.share * 100).toFixed(1)}% at (${p.tile.x}, ${p.tile.y})`;
|
|
153
|
+
return `check page ${p.page}: ${(p.share * 100).toFixed(2)}% differ, ${tile}; a ${p.kind} page${p.sameSize ? '' : ` (sizes differ: screen ${p.screen.width}x${p.screen.height}, print ${p.print.width}x${p.print.height})`}`;
|
|
154
|
+
}),
|
|
58
155
|
`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
156
|
];
|
|
60
157
|
}
|
|
158
|
+
/**
|
|
159
|
+
* Fail the run on what the report found, under `--check`; under `--check=report`, nothing.
|
|
160
|
+
*
|
|
161
|
+
* @throws {BuildError} one line a failing page, naming its share where that is over {@link MAX_SHARE}, else its worst tile and the floor of its kind, and its diff picture; and one where the page counts differ.
|
|
162
|
+
*/
|
|
163
|
+
export function gate(r, mode) {
|
|
164
|
+
if (mode === 'report')
|
|
165
|
+
return;
|
|
166
|
+
const lines = r.pages.filter((p) => !p.pass).map((p) => {
|
|
167
|
+
const why = p.share > r.maxShare ? `${(p.share * 100).toFixed(2)}% differ, over ${(r.maxShare * 100).toFixed(1)}%${p.sameSize ? '' : ' (sizes differ)'}` : `tile at (${p.tile.x}, ${p.tile.y}) differs ${(p.tile.share * 100).toFixed(0)}%, over the ${(TILE_FLOORS[p.kind] * 100).toFixed(0)}% floor for a ${p.kind} page`;
|
|
168
|
+
return `check page ${p.page} fails: ${why}; ${path.join(r.dir, `diff-${p.page}.png`)}`;
|
|
169
|
+
});
|
|
170
|
+
if (r.screenPages !== r.printPages)
|
|
171
|
+
lines.push(`check fails: the screen has ${r.screenPages} pages and the print ${r.printPages}; ${r.dir}`);
|
|
172
|
+
if (lines.length)
|
|
173
|
+
throw new BuildError(lines.join('\n'));
|
|
174
|
+
}
|
package/dist/src/compare.js
CHANGED
|
@@ -17,13 +17,22 @@ export const THRESHOLD = 32;
|
|
|
17
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
18
|
*/
|
|
19
19
|
export const NEIGHBOURHOOD = 1;
|
|
20
|
-
/**
|
|
20
|
+
/**
|
|
21
|
+
* Draw every page of a PDF; `origins` is where each page's user-space origin falls in its picture, in pixels.
|
|
22
|
+
*
|
|
23
|
+
* @remarks
|
|
24
|
+
* The presented page sits on the origin, its bottom-left corner there: Chromium's media is `[0 0 w h]`, and Caster grows its media round the page for a bleed and print marks (`[-31.181 -31.181 371.338 371.338]` on Caster's `roads`) and writes no `/TrimBox`, so the origin is where the trim is found.
|
|
25
|
+
*/
|
|
21
26
|
export function rasterize(pdf, dpi = 96) {
|
|
22
27
|
const doc = mupdf.Document.openDocument(pdf, 'application/pdf');
|
|
23
28
|
const pages = [];
|
|
24
29
|
const pngs = [];
|
|
30
|
+
const origins = [];
|
|
25
31
|
for (let i = 0; i < doc.countPages(); i++) {
|
|
26
|
-
const
|
|
32
|
+
const page = doc.loadPage(i);
|
|
33
|
+
const t = page.getTransform();
|
|
34
|
+
origins.push({ x: t[4] * dpi / 72, y: t[5] * dpi / 72 });
|
|
35
|
+
const pix = page.toPixmap(mupdf.Matrix.scale(dpi / 72, dpi / 72), mupdf.ColorSpace.DeviceRGB, false);
|
|
27
36
|
const width = pix.getWidth();
|
|
28
37
|
const height = pix.getHeight();
|
|
29
38
|
const n = pix.getNumberOfComponents();
|
|
@@ -37,7 +46,16 @@ export function rasterize(pdf, dpi = 96) {
|
|
|
37
46
|
pages.push({ width, height, rgb });
|
|
38
47
|
pngs.push(pix.asPNG());
|
|
39
48
|
}
|
|
40
|
-
return { pages, pngs };
|
|
49
|
+
return { pages, pngs, origins };
|
|
50
|
+
}
|
|
51
|
+
/** The `width` by `height` window of `r` whose top-left corner is (x, y), clipped to `r`. */
|
|
52
|
+
export function crop(r, x, y, width, height) {
|
|
53
|
+
const x0 = Math.max(0, Math.min(x, r.width)), y0 = Math.max(0, Math.min(y, r.height));
|
|
54
|
+
const w = Math.min(width, r.width - x0), h = Math.min(height, r.height - y0);
|
|
55
|
+
const rgb = new Uint8Array(w * h * 3);
|
|
56
|
+
for (let row = 0; row < h; row++)
|
|
57
|
+
rgb.set(r.rgb.subarray(((y0 + row) * r.width + x0) * 3, ((y0 + row) * r.width + x0 + w) * 3), row * w * 3);
|
|
58
|
+
return { width: w, height: h, rgb };
|
|
41
59
|
}
|
|
42
60
|
/** 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
61
|
function matched(from, to, x, y, width, height) {
|
|
@@ -58,18 +76,30 @@ function matched(from, to, x, y, width, height) {
|
|
|
58
76
|
}
|
|
59
77
|
return false;
|
|
60
78
|
}
|
|
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) {
|
|
79
|
+
/** Compare two pages over their common area, read in `tile`-pixel squares; the picture is that area, the reference faded and the pixels that still differ after the slip red. `cover`, one byte a pixel of `ref`, marks the pixels a tile's `outside` leaves out. */
|
|
80
|
+
export function diffPage(ref, other, tile = 24, cover) {
|
|
63
81
|
const width = Math.min(ref.width, other.width);
|
|
64
82
|
const height = Math.min(ref.height, other.height);
|
|
65
83
|
const out = new Uint8Array(width * height * 3);
|
|
84
|
+
const across = Math.ceil(width / tile);
|
|
85
|
+
const count = across * Math.ceil(height / tile);
|
|
86
|
+
const tiles = new Uint32Array(count);
|
|
87
|
+
const outside = new Uint32Array(count);
|
|
88
|
+
const covered = new Uint32Array(count);
|
|
66
89
|
let differing = 0;
|
|
67
90
|
for (let y = 0; y < height; y++) {
|
|
68
91
|
for (let x = 0; x < width; x++) {
|
|
69
92
|
const a = (y * ref.width + x) * 3;
|
|
70
93
|
const o = (y * width + x) * 3;
|
|
94
|
+
const t = Math.floor(y / tile) * across + Math.floor(x / tile);
|
|
95
|
+
const inCover = cover ? cover[y * ref.width + x] === 1 : false;
|
|
96
|
+
if (inCover)
|
|
97
|
+
covered[t]++;
|
|
71
98
|
if (!matched(other, ref, x, y, width, height) || !matched(ref, other, x, y, width, height)) {
|
|
72
99
|
differing++;
|
|
100
|
+
tiles[t]++;
|
|
101
|
+
if (!inCover)
|
|
102
|
+
outside[t]++;
|
|
73
103
|
out[o] = 255;
|
|
74
104
|
out[o + 1] = 0;
|
|
75
105
|
out[o + 2] = 0;
|
|
@@ -81,11 +111,20 @@ export function diffPage(ref, other) {
|
|
|
81
111
|
}
|
|
82
112
|
const pix = new mupdf.Pixmap(mupdf.ColorSpace.DeviceRGB, [0, 0, width, height], false);
|
|
83
113
|
pix.getPixels().set(out);
|
|
84
|
-
|
|
114
|
+
const least = tile * tile / 4;
|
|
115
|
+
const diffs = [];
|
|
116
|
+
tiles.forEach((n, i) => {
|
|
117
|
+
if (!n)
|
|
118
|
+
return;
|
|
119
|
+
const x = (i % across) * tile, y = Math.floor(i / across) * tile;
|
|
120
|
+
const area = (Math.min(x + tile, width) - x) * (Math.min(y + tile, height) - y);
|
|
121
|
+
diffs.push({ x, y, share: n / Math.max(area, least), outside: outside[i] / Math.max(area - covered[i], least) });
|
|
122
|
+
});
|
|
123
|
+
return { share: width * height ? differing / (width * height) : 0, sameSize: ref.width === other.width && ref.height === other.height, width, height, tiles: diffs, image: pix.asPNG() };
|
|
85
124
|
}
|
|
86
125
|
/** Compare two page lists page by page. */
|
|
87
|
-
export function diffPages(ref, other) {
|
|
126
|
+
export function diffPages(ref, other, tile = 24, covers = []) {
|
|
88
127
|
const n = Math.min(ref.length, other.length);
|
|
89
|
-
const pages = Array.from({ length: n }, (_, i) => diffPage(ref[i], other[i]));
|
|
128
|
+
const pages = Array.from({ length: n }, (_, i) => diffPage(ref[i], other[i], tile, covers[i]));
|
|
90
129
|
return { pages, unmatched: Math.abs(ref.length - other.length), mean: n ? pages.reduce((s, p) => s + p.share, 0) / n : 0 };
|
|
91
130
|
}
|
package/dist/src/flags.js
CHANGED
|
@@ -11,8 +11,8 @@ import { ValidationError } from '@beltype/common/errors';
|
|
|
11
11
|
export const cliFlags = defineFlags('beltype', {
|
|
12
12
|
/** Default `'caster'`: Cicero cuts and Caster prints. `'chromium'` has Chromium print the pages Cicero presented, which is what `check` compares Caster against. */
|
|
13
13
|
printer: { values: ['caster', 'chromium'], default: 'caster' },
|
|
14
|
-
/** Default `false`: no
|
|
15
|
-
check: { values: [false, true], default: false }
|
|
14
|
+
/** Default `false`: no check. `true` screenshots each presented page, rasterises the PDF, diffs the pairs into `<name>.check/` and fails the run on a page over the threshold or a page count that differs; `'report'` writes the same report and never fails. */
|
|
15
|
+
check: { values: [false, true, 'report'], default: false }
|
|
16
16
|
});
|
|
17
17
|
/**
|
|
18
18
|
* The tables of the packages, each loaded the first time a run needs it.
|
package/dist/src/in-page.js
CHANGED
|
@@ -7,21 +7,21 @@
|
|
|
7
7
|
* @packageDocumentation
|
|
8
8
|
*/
|
|
9
9
|
/**
|
|
10
|
-
* Caster's `prepare()` makes markers real elements before the cut, then Cicero's client paginates the whole document.
|
|
10
|
+
* Caster's `prepare()` makes markers real elements before the cut, and its `ready` loads the faces the document's SVG images set text in, which the document itself never asks for (codeberg.org/BelType/caster issue 11); then Cicero's client paginates the whole document. A face that arrived after the cut would make Cicero cut again under the printer.
|
|
11
11
|
*
|
|
12
|
-
* @param run - `cicero` is the resolved Cicero flags, handed to `run()`; `measures` asks for Cicero's own timings, read after the cut.
|
|
12
|
+
* @param run - `cicero` is the resolved Cicero flags, handed to `run()`; `measures` asks for Cicero's own timings, read after the cut; `resumeAt` is the address of the first box a mail merge replaced, which makes the cut a resumed run.
|
|
13
13
|
*/
|
|
14
14
|
export async function cutInPage(run) {
|
|
15
15
|
let at = 'prepare';
|
|
16
16
|
try {
|
|
17
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.
|
|
18
18
|
const caster = await import('/_beltype/caster/src/index.js');
|
|
19
|
-
caster.prepare(document);
|
|
19
|
+
await caster.prepare(document).ready;
|
|
20
20
|
at = 'paginate';
|
|
21
21
|
const cicero = window.__beltypeCicero;
|
|
22
22
|
if (!cicero?.run)
|
|
23
23
|
return { refused: 'the document loaded no Cicero client, so there is nothing to paginate with', at };
|
|
24
|
-
const answer = await cicero.run(run.cicero);
|
|
24
|
+
const answer = await cicero.run(run.resumeAt ? { ...run.cicero, resumeAt: run.resumeAt } : run.cicero);
|
|
25
25
|
await document.fonts.ready;
|
|
26
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
27
|
return { pages: answer.pages, measures };
|
|
@@ -30,10 +30,37 @@ export async function cutInPage(run) {
|
|
|
30
30
|
return { refused: String(error?.message ?? error), at };
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* A mail merge's first customer, on the template just seated (`src/merge-tab.ts`, bundled and imported by URL).
|
|
35
|
+
*
|
|
36
|
+
* @param run - `template` is the template as it was uploaded; `data` the customer.
|
|
37
|
+
*/
|
|
38
|
+
export async function seatMergeInPage(run) {
|
|
39
|
+
try {
|
|
40
|
+
// @ts-expect-error a URL the page resolves, not a module the checker can find.
|
|
41
|
+
const merge = await import('/_beltype/merge/merge.js');
|
|
42
|
+
merge.seat(run.template, run.data);
|
|
43
|
+
return { done: true };
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
return { refused: String(error?.message ?? error), at: 'merge' };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/** A later customer of a mail merge, on the document already presented: the changed top-level boxes replaced, or why the template must be seated again. */
|
|
50
|
+
export async function keepMergeInPage(data) {
|
|
51
|
+
try {
|
|
52
|
+
// @ts-expect-error a URL the page resolves, not a module the checker can find.
|
|
53
|
+
const merge = await import('/_beltype/merge/merge.js');
|
|
54
|
+
return merge.keep(data);
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
return { refused: String(error?.message ?? error), at: 'merge' };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
33
60
|
/**
|
|
34
61
|
* Caster prints the presented pages; the bytes cross back as base64, the one form `evaluate` carries intact.
|
|
35
62
|
*
|
|
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.
|
|
63
|
+
* @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; `confessions` is the report's, empty from a Caster that keeps none.
|
|
37
64
|
*/
|
|
38
65
|
export async function printInPage(run) {
|
|
39
66
|
try {
|
|
@@ -47,7 +74,7 @@ export async function printInPage(run) {
|
|
|
47
74
|
let binary = '';
|
|
48
75
|
for (let i = 0; i < bytes.length; i += 0x8000)
|
|
49
76
|
binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
|
|
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 })) } };
|
|
77
|
+
return { base64: btoa(binary), pages: printed.report.timing?.pages ?? 0, words: printed.report.timing?.words ?? 0, warnings: printed.report.warnings ?? [], deferred: printed.report.deferred ?? [], conformance: printed.report.conformance, printMs, confessions: printed.report.confessions ?? [], caster: { ms: printed.report.ms ?? {}, timing: printed.report.timing ?? null, fonts: (printed.report.emission?.faces ?? []).map((f) => ({ id: f.id, ms: f.ms ?? null })) } };
|
|
51
78
|
}
|
|
52
79
|
catch (error) {
|
|
53
80
|
return { refused: String(error?.message ?? error), at: 'print' };
|
package/dist/src/main.js
CHANGED
|
@@ -4,25 +4,31 @@
|
|
|
4
4
|
* @remarks
|
|
5
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
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
|
|
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, and a bare `--check` is `--check=true`; `--trace <file>` takes a free string, so it is a plain option beside the table, and so is `--no-sandbox`, which belongs to the launch and to no package's table.
|
|
8
8
|
*
|
|
9
9
|
* @packageDocumentation
|
|
10
10
|
*/
|
|
11
11
|
import { parseArgs } from 'node:util';
|
|
12
12
|
import { BelTypeError, ValidationError } from '@beltype/common/errors';
|
|
13
|
-
import { checkLines } from './check.js';
|
|
13
|
+
import { checkLines, gate } from './check.js';
|
|
14
14
|
import { cliFlags, createdFromEnv, flagsHelp, parseFlagArgs } from './flags.js';
|
|
15
15
|
import { print } from './print.js';
|
|
16
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>]...';
|
|
17
|
+
const USAGE = 'usage: beltype serve [--host 127.0.0.1] [--port 3000] [--max-upload 50] [--timeout 60] [--restart-after 100] [--allowed-hosts <name[:port],…>] [--no-sandbox]\n beltype print <file.html> [-o <file.pdf>] [--printer caster|chromium] [--check[=report]] [--trace <file.json>] [--no-sandbox] [--flag <owner>.<name>=<value>]...';
|
|
18
18
|
async function serveVerb(argv) {
|
|
19
19
|
const { values } = parseArgs({ args: argv, options: {
|
|
20
20
|
host: { type: 'string', default: '127.0.0.1' },
|
|
21
21
|
port: { type: 'string', default: '3000' },
|
|
22
22
|
'max-upload': { type: 'string', default: '50' },
|
|
23
23
|
timeout: { type: 'string', default: '60' },
|
|
24
|
-
'restart-after': { type: 'string', default: '100' }
|
|
24
|
+
'restart-after': { type: 'string', default: '100' },
|
|
25
|
+
'allowed-hosts': { type: 'string' },
|
|
26
|
+
'no-sandbox': { type: 'boolean' }
|
|
25
27
|
} });
|
|
28
|
+
const allowedHosts = (values['allowed-hosts'] ?? '').split(',').map((h) => h.trim()).filter(Boolean);
|
|
29
|
+
for (const h of allowedHosts)
|
|
30
|
+
if (!/^([a-z0-9.-]+|\[[0-9a-f:.]+\])(:\d+)?$/i.test(h))
|
|
31
|
+
throw new ValidationError(`beltype serve: --allowed-hosts takes names as a request's Host header spells them, name or name:port, comma-separated; got "${h}".`);
|
|
26
32
|
const number = (name, raw, min) => {
|
|
27
33
|
if (!/^\d+$/.test(raw) || Number(raw) < min)
|
|
28
34
|
throw new ValidationError(`beltype serve: --${name} is a whole number of at least ${min}; got "${raw}".`);
|
|
@@ -33,7 +39,9 @@ async function serveVerb(argv) {
|
|
|
33
39
|
port: number('port', values.port, 0),
|
|
34
40
|
maxUpload: number('max-upload', values['max-upload'], 1) * 1024 * 1024,
|
|
35
41
|
timeout: number('timeout', values.timeout, 1) * 1000,
|
|
36
|
-
restartAfter: number('restart-after', values['restart-after'], 1)
|
|
42
|
+
restartAfter: number('restart-after', values['restart-after'], 1),
|
|
43
|
+
sandbox: !values['no-sandbox'],
|
|
44
|
+
allowedHosts
|
|
37
45
|
});
|
|
38
46
|
const stop = () => { void server.close().then(() => process.exit(0)); };
|
|
39
47
|
process.on('SIGINT', stop);
|
|
@@ -44,13 +52,14 @@ async function main(argv) {
|
|
|
44
52
|
await serveVerb(argv.slice(1));
|
|
45
53
|
return -1;
|
|
46
54
|
}
|
|
47
|
-
const { values, positionals } = parseArgs({ args: argv, allowPositionals: true, options: {
|
|
55
|
+
const { values, positionals } = parseArgs({ args: argv.map((a) => (a === '--check' ? '--check=true' : a)), allowPositionals: true, options: {
|
|
48
56
|
output: { type: 'string', short: 'o' },
|
|
49
57
|
help: { type: 'boolean', short: 'h' },
|
|
50
58
|
flag: { type: 'string', multiple: true },
|
|
51
59
|
printer: { type: 'string' },
|
|
52
|
-
check: { type: '
|
|
53
|
-
trace: { type: 'string' }
|
|
60
|
+
check: { type: 'string' },
|
|
61
|
+
trace: { type: 'string' },
|
|
62
|
+
'no-sandbox': { type: 'boolean' }
|
|
54
63
|
} });
|
|
55
64
|
const [verb, input, ...rest] = positionals;
|
|
56
65
|
if (values.help || !verb) {
|
|
@@ -64,15 +73,17 @@ async function main(argv) {
|
|
|
64
73
|
const given = await parseFlagArgs(values.flag ?? []);
|
|
65
74
|
if (values.printer !== undefined)
|
|
66
75
|
given.beltype['printer'] = cliFlags.parse('printer', values.printer);
|
|
67
|
-
if (values.check)
|
|
68
|
-
given.beltype['check'] =
|
|
76
|
+
if (values.check !== undefined)
|
|
77
|
+
given.beltype['check'] = cliFlags.parse('check', values.check);
|
|
69
78
|
const output = values.output ?? input.replace(/\.html?$/i, '') + '.pdf';
|
|
70
79
|
const created = createdFromEnv();
|
|
71
|
-
const result = await print(input, output, { given, ...(values.trace !== undefined ? { trace: values.trace } : {}), ...(created ? { created } : {}) });
|
|
80
|
+
const result = await print(input, output, { given, sandbox: !values['no-sandbox'], ...(values.trace !== undefined ? { trace: values.trace } : {}), ...(created ? { created } : {}) });
|
|
72
81
|
console.log(`${result.output}: ${result.pages} pages, ${result.words} words, ${(result.ms / 1000).toFixed(1)} s`);
|
|
73
|
-
if (result.check)
|
|
82
|
+
if (result.check) {
|
|
74
83
|
for (const line of checkLines(result.check))
|
|
75
84
|
console.log(line);
|
|
85
|
+
gate(result.check, given.beltype['check']);
|
|
86
|
+
}
|
|
76
87
|
return 0;
|
|
77
88
|
}
|
|
78
89
|
main(process.argv.slice(2)).then((code) => { if (code >= 0)
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The mail merge inside the tab: a template's data block opened with one customer's values and rows, its fields written, each `<template data-repeat>` filled.
|
|
3
|
+
*
|
|
4
|
+
* @remarks
|
|
5
|
+
* This file is bundled with `prosemirror-stores` into `assets/merge/merge.js` by `scripts/bundle-merge.ts`, and the page imports that bundle by URL; a change here means running the script. `test/merge-bundle.test.ts` holds the two together.
|
|
6
|
+
*
|
|
7
|
+
* **Two roads.** {@link seat} merges into the document just written into the page. {@link keep} merges into a detached copy of the template and replaces only the top-level boxes that differ from the last customer's, through Cicero's patch door, so the sheets, the faces and the unchanged boxes stay. Its answer `{ again }` asks the host to seat the template afresh, and names why: a merge that changes the shape of the body, or that Cicero will not promise, is never forced.
|
|
8
|
+
*
|
|
9
|
+
* What it does not do: it reads no CSVW. A table's cells arrive typed as `openBlock` takes them, a string or `{ value, text }`.
|
|
10
|
+
*
|
|
11
|
+
* @see codeberg.org/BelType/cli issue 2
|
|
12
|
+
* @packageDocumentation
|
|
13
|
+
*/
|
|
14
|
+
import { openBlock } from 'prosemirror-stores/graph';
|
|
15
|
+
import { writeMicrodata } from 'prosemirror-stores/microdata';
|
|
16
|
+
const BLOCK = 'script[type="application/ld+json"]';
|
|
17
|
+
/** What Caster's preparation stamps on an element, and Cicero's patch door compares on a top-level box. */
|
|
18
|
+
const SOURCE = 'data-pdf-source';
|
|
19
|
+
let template = '';
|
|
20
|
+
let last = { boxes: [], text: '' };
|
|
21
|
+
let unkept = null;
|
|
22
|
+
/** The table names of a block, each with the `csvw:url` its rows are handed to `openBlock` under. */
|
|
23
|
+
function tablesOf(text) {
|
|
24
|
+
const tables = new Map();
|
|
25
|
+
const graph = JSON.parse(text)['@graph'] ?? [];
|
|
26
|
+
for (const node of graph) {
|
|
27
|
+
for (const [key, value] of Object.entries(node)) {
|
|
28
|
+
const table = value;
|
|
29
|
+
if (table?.['@type'] === 'csvw:Table' && typeof table['csvw:url'] === 'string')
|
|
30
|
+
tables.set(key, table['csvw:url']);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return tables;
|
|
34
|
+
}
|
|
35
|
+
/** The fields of one repeated row. */
|
|
36
|
+
function writeRow(row, cells) {
|
|
37
|
+
for (const field of row.querySelectorAll('[itemprop]')) {
|
|
38
|
+
const cell = cells[field.getAttribute('itemprop')];
|
|
39
|
+
if (cell === undefined)
|
|
40
|
+
continue;
|
|
41
|
+
const shown = typeof cell === 'string' ? cell : cell.text;
|
|
42
|
+
if (field.localName === 'data')
|
|
43
|
+
field.setAttribute('value', typeof cell === 'string' ? cell : String(cell.value));
|
|
44
|
+
if (field.localName !== 'data' || typeof cell !== 'string')
|
|
45
|
+
field.textContent = shown;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Merge `data` into `doc`: the graph opened on the block, the values set on the document's item, the fields written, the templates replaced by their rows, the block gone.
|
|
50
|
+
*
|
|
51
|
+
* @throws {Error} Where the document has no block or no item, or `data` names a table the block does not.
|
|
52
|
+
*/
|
|
53
|
+
function merge(doc, data) {
|
|
54
|
+
const block = doc.querySelector(BLOCK);
|
|
55
|
+
if (!block)
|
|
56
|
+
throw new Error('the template has no data block (<script type="application/ld+json">)');
|
|
57
|
+
const item = doc.querySelector('[itemscope][itemid]:not([itemprop])')?.getAttribute('itemid');
|
|
58
|
+
if (!item)
|
|
59
|
+
throw new Error('the template has no top-level item (an element with itemscope and itemid, and no itemprop)');
|
|
60
|
+
const text = block.textContent;
|
|
61
|
+
const tables = tablesOf(text);
|
|
62
|
+
for (const name of Object.keys(data.rows))
|
|
63
|
+
if (!tables.has(name))
|
|
64
|
+
throw new Error(`the data names a table "${name}", and the block has ${[...tables.keys()].join(', ') || 'none'}`);
|
|
65
|
+
const graph = openBlock(text, { rows: new Map([...tables].map(([name, url]) => [url, data.rows[name] ?? []])) });
|
|
66
|
+
for (const [key, value] of Object.entries(data.values))
|
|
67
|
+
graph.set(item, key, value);
|
|
68
|
+
writeMicrodata(doc.documentElement, graph);
|
|
69
|
+
for (const repeat of [...doc.querySelectorAll('template[data-repeat]')]) {
|
|
70
|
+
const name = repeat.getAttribute('data-repeat');
|
|
71
|
+
const rowElement = repeat.content.firstElementChild;
|
|
72
|
+
if (rowElement) {
|
|
73
|
+
for (const cells of data.rows[name] ?? []) {
|
|
74
|
+
const row = rowElement.cloneNode(true);
|
|
75
|
+
row.setAttribute('itemscope', '');
|
|
76
|
+
row.setAttribute('itemprop', name);
|
|
77
|
+
writeRow(row, cells);
|
|
78
|
+
repeat.before(row);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
repeat.remove();
|
|
82
|
+
}
|
|
83
|
+
block.remove();
|
|
84
|
+
}
|
|
85
|
+
const snapshot = (doc) => ({
|
|
86
|
+
boxes: [...doc.body.children].map((box) => box.outerHTML),
|
|
87
|
+
text: [...doc.body.childNodes].filter((node) => node.nodeType === 3).map((node) => node.data.trim()).join(''),
|
|
88
|
+
});
|
|
89
|
+
/**
|
|
90
|
+
* The first customer on a seated document: `html` is the template as it was uploaded, kept for the customers after.
|
|
91
|
+
*
|
|
92
|
+
* @throws {Error} From {@link merge}.
|
|
93
|
+
*/
|
|
94
|
+
export function seat(html, data) {
|
|
95
|
+
template = html;
|
|
96
|
+
merge(document, data);
|
|
97
|
+
last = snapshot(document);
|
|
98
|
+
// The patch door numbers the boxes the engine took, and nothing keeps a field in the head up to date: a document like this is seated for every customer.
|
|
99
|
+
unkept = document.head.querySelector('[itemprop]') ? 'a field stands in the head' : document.body.querySelector(':scope > :is(script, style, template, link, meta)') ? 'the body holds a child that is no box' : null;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* A later customer on the document already presented.
|
|
103
|
+
*
|
|
104
|
+
* @throws {Error} From {@link merge}.
|
|
105
|
+
*/
|
|
106
|
+
export function keep(data) {
|
|
107
|
+
if (unkept)
|
|
108
|
+
return { again: unkept };
|
|
109
|
+
const handle = window.__beltypeCicero;
|
|
110
|
+
if (!handle?.box || !handle.patch)
|
|
111
|
+
return { again: 'the page shows no presented run' };
|
|
112
|
+
const copy = new DOMParser().parseFromString(template, 'text/html');
|
|
113
|
+
merge(copy, data);
|
|
114
|
+
const next = snapshot(copy);
|
|
115
|
+
if (next.boxes.length !== last.boxes.length)
|
|
116
|
+
return { again: `the top-level boxes went from ${last.boxes.length} to ${next.boxes.length}` };
|
|
117
|
+
if (next.text !== last.text)
|
|
118
|
+
return { again: 'text directly in the body changed' };
|
|
119
|
+
const fresh = [...copy.body.children];
|
|
120
|
+
let resumeAt = null;
|
|
121
|
+
let changed = 0;
|
|
122
|
+
for (const [i, box] of fresh.entries()) {
|
|
123
|
+
if (next.boxes[i] === last.boxes[i])
|
|
124
|
+
continue;
|
|
125
|
+
const old = handle.box(i);
|
|
126
|
+
if (!old)
|
|
127
|
+
return { again: `box ${i} is no flow block` };
|
|
128
|
+
const stamp = old.getAttribute(SOURCE);
|
|
129
|
+
if (stamp !== null)
|
|
130
|
+
box.setAttribute(SOURCE, stamp);
|
|
131
|
+
const address = handle.patch(i, [], document.adoptNode(box));
|
|
132
|
+
if (address === null)
|
|
133
|
+
return { again: `Cicero's patch door refused box ${i}` };
|
|
134
|
+
resumeAt ??= address;
|
|
135
|
+
changed++;
|
|
136
|
+
}
|
|
137
|
+
last = next;
|
|
138
|
+
return { resumeAt, changed };
|
|
139
|
+
}
|