browser-broker 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/.env.example +173 -0
  2. package/LICENSE +21 -0
  3. package/README.md +374 -0
  4. package/RELEASES.md +97 -0
  5. package/dist/package.json +58 -0
  6. package/dist/src/adapter/conformance/case.js +1 -0
  7. package/dist/src/adapter/conformance/cases.js +429 -0
  8. package/dist/src/adapter/conformance/discovery.js +156 -0
  9. package/dist/src/adapter/conformance/driver.js +1 -0
  10. package/dist/src/adapter/conformance/drivers.js +36 -0
  11. package/dist/src/adapter/conformance/run.js +224 -0
  12. package/dist/src/adapter/conformance/service-subject.js +165 -0
  13. package/dist/src/adapter/contract.js +24 -0
  14. package/dist/src/adapter/operations.js +114 -0
  15. package/dist/src/adapter/service-seam.js +1 -0
  16. package/dist/src/artifacts/names.js +229 -0
  17. package/dist/src/artifacts/store.js +174 -0
  18. package/dist/src/bin/broker-tool.js +63 -0
  19. package/dist/src/bin/broker.js +111 -0
  20. package/dist/src/browser/adoption.js +143 -0
  21. package/dist/src/browser/automation-probe.js +113 -0
  22. package/dist/src/browser/conformance/case.js +1 -0
  23. package/dist/src/browser/conformance/cases.js +192 -0
  24. package/dist/src/browser/conformance/run.js +102 -0
  25. package/dist/src/browser/conformance/subjects.js +19 -0
  26. package/dist/src/browser/discovery.js +226 -0
  27. package/dist/src/browser/driver.js +195 -0
  28. package/dist/src/browser/fake.js +585 -0
  29. package/dist/src/browser/launch.js +504 -0
  30. package/dist/src/browser/real.js +1425 -0
  31. package/dist/src/browser/setup.js +161 -0
  32. package/dist/src/capture/accounting.js +59 -0
  33. package/dist/src/capture/image.js +112 -0
  34. package/dist/src/capture/ladder.js +72 -0
  35. package/dist/src/capture/legibility.js +195 -0
  36. package/dist/src/capture/pipeline.js +153 -0
  37. package/dist/src/capture/tiers.js +166 -0
  38. package/dist/src/cli/adapter.js +233 -0
  39. package/dist/src/cli/commands.js +270 -0
  40. package/dist/src/cli/conformance-driver.js +119 -0
  41. package/dist/src/cli/diffs.js +122 -0
  42. package/dist/src/cli/image.js +274 -0
  43. package/dist/src/cli/index.js +895 -0
  44. package/dist/src/cli/login-command.js +401 -0
  45. package/dist/src/cli/operations-commands.js +186 -0
  46. package/dist/src/cli/reconcile-command.js +137 -0
  47. package/dist/src/cli/sign-in.js +134 -0
  48. package/dist/src/cli/telemetry.js +222 -0
  49. package/dist/src/config/environment.js +446 -0
  50. package/dist/src/diff/artifact-path.js +77 -0
  51. package/dist/src/diff/crops.js +102 -0
  52. package/dist/src/diff/geometry.js +122 -0
  53. package/dist/src/diff/image.js +132 -0
  54. package/dist/src/diff/mask.js +46 -0
  55. package/dist/src/diff/regions.js +263 -0
  56. package/dist/src/diff/settings.js +135 -0
  57. package/dist/src/doctor/checks.js +588 -0
  58. package/dist/src/doctor/report.js +152 -0
  59. package/dist/src/doctor/session.js +161 -0
  60. package/dist/src/errors.js +36 -0
  61. package/dist/src/feedback/read.js +119 -0
  62. package/dist/src/feedback/record.js +199 -0
  63. package/dist/src/operations/addresses.js +175 -0
  64. package/dist/src/operations/derive.js +109 -0
  65. package/dist/src/operations/ledger.js +194 -0
  66. package/dist/src/operations/status.js +197 -0
  67. package/dist/src/operations/telemetry.js +280 -0
  68. package/dist/src/report/document.js +419 -0
  69. package/dist/src/report/escape.js +68 -0
  70. package/dist/src/report/snapshot.js +97 -0
  71. package/dist/src/service/arbitration.js +537 -0
  72. package/dist/src/service/artifacts.js +85 -0
  73. package/dist/src/service/bridge.js +577 -0
  74. package/dist/src/service/broker.js +120 -0
  75. package/dist/src/service/browser-session.js +269 -0
  76. package/dist/src/service/capacity.js +62 -0
  77. package/dist/src/service/capture-seam.js +83 -0
  78. package/dist/src/service/capture-store.js +91 -0
  79. package/dist/src/service/comparison-store.js +101 -0
  80. package/dist/src/service/comparison.js +173 -0
  81. package/dist/src/service/events.js +93 -0
  82. package/dist/src/service/keys.js +68 -0
  83. package/dist/src/service/leases.js +147 -0
  84. package/dist/src/service/nudge.js +66 -0
  85. package/dist/src/service/operations/claim.js +692 -0
  86. package/dist/src/service/operations/give-back.js +131 -0
  87. package/dist/src/service/operations/pages.js +771 -0
  88. package/dist/src/service/operations/sign-in.js +915 -0
  89. package/dist/src/service/operations/status.js +62 -0
  90. package/dist/src/service/ownership.js +93 -0
  91. package/dist/src/service/pages.js +616 -0
  92. package/dist/src/service/pending-seeds.js +20 -0
  93. package/dist/src/service/queue.js +233 -0
  94. package/dist/src/service/reconcile.js +220 -0
  95. package/dist/src/service/refusals.js +262 -0
  96. package/dist/src/service/runtime.js +131 -0
  97. package/dist/src/service/signin-recovery.js +148 -0
  98. package/dist/src/service/storage-seed.js +239 -0
  99. package/dist/src/service/tabs.js +123 -0
  100. package/dist/src/store/budget.js +99 -0
  101. package/dist/src/store/location.js +42 -0
  102. package/dist/src/store/network-path.js +182 -0
  103. package/dist/src/store/network-volume.js +92 -0
  104. package/dist/src/store/open.js +226 -0
  105. package/dist/src/store/schema/step-001-initial.js +523 -0
  106. package/dist/src/store/schema/step-002-tab-budget.js +53 -0
  107. package/dist/src/store/schema/step-003-queue-order.js +110 -0
  108. package/dist/src/store/schema/step-004-tab-never-opened.js +100 -0
  109. package/dist/src/store/schema/step-005-storage-seed-event.js +90 -0
  110. package/dist/src/store/schema/step-006-signin-events.js +104 -0
  111. package/dist/src/store/schema/step-007-signin-without-process.js +92 -0
  112. package/dist/src/store/schema/step-008-signin-owner.js +76 -0
  113. package/dist/src/store/schema/step-009-named-browsers.js +138 -0
  114. package/dist/src/store/schema/step-010-signin-request.js +135 -0
  115. package/dist/src/store/schema/step.js +172 -0
  116. package/dist/src/store/schema/steps.js +58 -0
  117. package/dist/src/store/transaction.js +37 -0
  118. package/dist/src/tool/adapter.js +90 -0
  119. package/dist/src/tool/conformance-driver.js +184 -0
  120. package/dist/src/tool/protocol.js +310 -0
  121. package/dist/src/tool/session.js +351 -0
  122. package/dist/src/tool/tools.js +310 -0
  123. package/package.json +58 -0
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Geometry reconciliation — what happens when the two pictures are not the
3
+ * same size (`SCHEMA.md` §1.9, §3.11, `MILESTONES.md` #40).
4
+ *
5
+ * ── The coupling this file exists to keep cut ───────────────────────────
6
+ *
7
+ * §3.11 records an arrangement that was deliberately deleted: a capture that
8
+ * consulted a canonical picture for the view it named and **took the picture
9
+ * at that picture's geometry**. That made the ordinary capture path depend on
10
+ * the comparison feature's data model, and gave every capture a reason to fail
11
+ * that had nothing to do with capturing.
12
+ *
13
+ * **So geometry is handled here, at diff time, where both images are already
14
+ * in hand.** Nothing constrains a capture, and a mismatch is reported against
15
+ * the specific pair that mismatched rather than pre-empted by a rule.
16
+ *
17
+ * ── The two mismatches are not the same fact, and that is the design ────
18
+ *
19
+ * - **A width mismatch is reported in the result.** Two pictures of the same
20
+ * page at different widths are pictures of different layouts, and a diff
21
+ * over them is close to meaningless — nearly every pixel moves. It is still
22
+ * not a refusal: the caller gets its picture and the fact, per §1.9's
23
+ * standing rule that a diff is an optional argument whose failure cannot
24
+ * withhold a screenshot that succeeded.
25
+ * - **A full page's height is allowed to differ**, because "two full-page
26
+ * pictures of one page legitimately differ in height when the content gets
27
+ * longer" (§3.11). The change in page length is reported **as its own fact
28
+ * rather than as a region** — a page that grew by two hundred pixels has not
29
+ * changed in two hundred places, and reporting the growth as a region would
30
+ * drown the actual change.
31
+ *
32
+ * **The comparison runs over the height they share** (§3.11), which is the
33
+ * only region where the question "did this pixel change" has an answer at all.
34
+ */
35
+ /**
36
+ * Reconcile the two geometries.
37
+ *
38
+ * Takes the kind of the **new** capture. The earlier capture's own kind is
39
+ * deliberately not consulted: what a caller can act on is what it just asked
40
+ * for, and a full-page picture compared against a viewport one is a caller
41
+ * mistake that the page-length fact describes perfectly well without a second
42
+ * enumeration of which-kind-against-which-kind.
43
+ */
44
+ export function reconcileGeometry(earlier, current, kind) {
45
+ const widthMismatch = earlier.width !== current.width;
46
+ // A width mismatch stops the comparison outright. There is no honest
47
+ // sub-rectangle to fall back to: content reflows across a width change, so
48
+ // the leftmost shared column of a 1024-wide page is not the same content as
49
+ // the leftmost shared column of a 1440-wide one. Comparing the overlap
50
+ // would produce a confident number about two different layouts.
51
+ if (widthMismatch) {
52
+ return {
53
+ width: null,
54
+ comparableHeight: null,
55
+ widthMismatch: true,
56
+ pageLengthChange: null,
57
+ comparable: false,
58
+ explanation: `The two captures are different widths — the earlier one is ${String(earlier.width)} pixels ` +
59
+ `wide and this one is ${String(current.width)}. No diff was produced: content reflows across a ` +
60
+ 'width change, so comparing them would report a difference at nearly every pixel. Capture both at ' +
61
+ 'the same viewport width to compare them.',
62
+ };
63
+ }
64
+ const heightMismatch = earlier.height !== current.height;
65
+ const fullPage = kind === 'full_page';
66
+ if (!heightMismatch) {
67
+ return {
68
+ width: current.width,
69
+ comparableHeight: current.height,
70
+ widthMismatch: false,
71
+ pageLengthChange: null,
72
+ comparable: true,
73
+ explanation: null,
74
+ };
75
+ }
76
+ // A height mismatch on anything but a full page is the same situation a
77
+ // width mismatch is: a viewport is a fixed rectangle, so two viewport
78
+ // pictures of different heights were taken at different viewport sizes, and
79
+ // an element that changed height changed shape rather than grew a page.
80
+ if (!fullPage) {
81
+ return {
82
+ width: null,
83
+ comparableHeight: null,
84
+ widthMismatch: false,
85
+ pageLengthChange: null,
86
+ comparable: false,
87
+ explanation: `The two captures are different heights — the earlier one is ${String(earlier.height)} pixels ` +
88
+ `tall and this one is ${String(current.height)} — and this is a ${kind} capture, whose height is ` +
89
+ 'fixed by what was captured rather than by how much content there was. No diff was produced. A ' +
90
+ 'full-page capture is the one whose height is allowed to differ.',
91
+ };
92
+ }
93
+ const shared = Math.min(earlier.height, current.height);
94
+ const change = current.height - earlier.height;
95
+ // Two full pages that share no rows at all. Vanishingly unlikely in
96
+ // practice and still a real shape — a page that failed to render is one
97
+ // pixel tall — and a comparison over zero rows would report zero changed
98
+ // pixels, which reads as "nothing changed".
99
+ if (shared <= 0) {
100
+ return {
101
+ width: null,
102
+ comparableHeight: null,
103
+ widthMismatch: false,
104
+ pageLengthChange: change,
105
+ comparable: false,
106
+ explanation: `The two full-page captures share no rows to compare — the earlier one is ` +
107
+ `${String(earlier.height)} pixels tall and this one is ${String(current.height)}. No diff was ` +
108
+ 'produced.',
109
+ };
110
+ }
111
+ return {
112
+ width: current.width,
113
+ comparableHeight: shared,
114
+ widthMismatch: false,
115
+ pageLengthChange: change,
116
+ comparable: true,
117
+ explanation: `The page got ${change > 0 ? 'longer' : 'shorter'} by ${String(Math.abs(change))} pixels — the ` +
118
+ `earlier capture is ${String(earlier.height)} tall and this one is ${String(current.height)}. That ` +
119
+ `is reported as a change in page length rather than as a changed region. The comparison ran over ` +
120
+ `the top ${String(shared)} rows they share.`,
121
+ };
122
+ }
@@ -0,0 +1,132 @@
1
+ import { PNG } from 'pngjs';
2
+ /**
3
+ * A decoded image, and the two operations the comparison feature performs on
4
+ * one: cutting a rectangle out of it, and drawing a rectangle onto it.
5
+ *
6
+ * **Why this file exists at all**, given that the comparison itself is a
7
+ * library call: the library takes raw pixel buffers and returns a count. It
8
+ * does not decode a file, it does not crop, and it does not draw. Those three
9
+ * are what turn "how many pixels differ" into the paths §1.9 promises, and
10
+ * they are small enough that a second dependency for them would be a larger
11
+ * commitment than the code it saved.
12
+ *
13
+ * **Four channels, eight bits each, row-major from the top left**, which is
14
+ * both what the decoder produces and the layout the comparison library
15
+ * requires. §1.9 measures every region "in the capture's own pixels, measured
16
+ * from the top left", and that is the same origin, so nothing here has to
17
+ * flip a coordinate.
18
+ */
19
+ /** How many bytes one pixel occupies. Red, green, blue, alpha. */
20
+ export const CHANNELS = 4;
21
+ /** Decode PNG bytes. */
22
+ export function decodePng(bytes) {
23
+ const png = PNG.sync.read(Buffer.from(bytes));
24
+ return { width: png.width, height: png.height, data: new Uint8Array(png.data) };
25
+ }
26
+ /** Encode an image as PNG bytes. */
27
+ export function encodePng(image) {
28
+ const png = new PNG({ width: image.width, height: image.height });
29
+ png.data = Buffer.from(image.data);
30
+ return new Uint8Array(PNG.sync.write(png));
31
+ }
32
+ /** A transparent image of the given size, which every drawing surface starts as. */
33
+ export function blankImage(width, height) {
34
+ return { width, height, data: new Uint8Array(width * height * CHANNELS) };
35
+ }
36
+ /**
37
+ * Clamp a rectangle to an image's bounds.
38
+ *
39
+ * **This is what makes padding safe**, and it is the reason padding is applied
40
+ * by widening a rectangle and then clamping rather than by checking first. A
41
+ * region touching the top edge of a page — a header, which is the single most
42
+ * likely thing to change — cannot be padded upwards, and §1.9 asks for "the
43
+ * crop from the earlier capture and the crop from the new one, cut from the
44
+ * same rectangle". Clamping keeps that sameness true: one rectangle is
45
+ * computed, clamped once against the geometry both crops are taken at, and
46
+ * used for both.
47
+ *
48
+ * Returns `null` when nothing survives, which happens only for a rectangle
49
+ * entirely outside the image.
50
+ */
51
+ export function clampToImage(rectangle, width, height) {
52
+ const left = Math.max(0, Math.min(rectangle.x, width));
53
+ const top = Math.max(0, Math.min(rectangle.y, height));
54
+ const right = Math.min(width, rectangle.x + rectangle.width);
55
+ const bottom = Math.min(height, rectangle.y + rectangle.height);
56
+ if (right <= left || bottom <= top) {
57
+ return null;
58
+ }
59
+ return { x: left, y: top, width: right - left, height: bottom - top };
60
+ }
61
+ /**
62
+ * Cut a rectangle out of an image.
63
+ *
64
+ * The rectangle must already be within the image; `clampToImage` is what puts
65
+ * it there. Reading outside would produce a crop padded with whatever the
66
+ * buffer happened to hold, which is a picture of nothing presented as a
67
+ * picture of something.
68
+ */
69
+ export function crop(image, rectangle) {
70
+ if (rectangle.x < 0 ||
71
+ rectangle.y < 0 ||
72
+ rectangle.x + rectangle.width > image.width ||
73
+ rectangle.y + rectangle.height > image.height ||
74
+ rectangle.width <= 0 ||
75
+ rectangle.height <= 0) {
76
+ throw new Error(`A crop of ${String(rectangle.width)}x${String(rectangle.height)} at ` +
77
+ `${String(rectangle.x)},${String(rectangle.y)} does not fit an image of ` +
78
+ `${String(image.width)}x${String(image.height)}. Clamp the rectangle first.`);
79
+ }
80
+ const out = blankImage(rectangle.width, rectangle.height);
81
+ for (let row = 0; row < rectangle.height; row += 1) {
82
+ const from = ((rectangle.y + row) * image.width + rectangle.x) * CHANNELS;
83
+ out.data.set(image.data.subarray(from, from + rectangle.width * CHANNELS), row * rectangle.width * CHANNELS);
84
+ }
85
+ return out;
86
+ }
87
+ /** Set one pixel, ignoring anything outside the image. */
88
+ function setPixel(image, x, y, colour) {
89
+ if (x < 0 || y < 0 || x >= image.width || y >= image.height) {
90
+ return;
91
+ }
92
+ const at = (y * image.width + x) * CHANNELS;
93
+ image.data[at] = colour.red;
94
+ image.data[at + 1] = colour.green;
95
+ image.data[at + 2] = colour.blue;
96
+ image.data[at + 3] = colour.alpha;
97
+ }
98
+ /**
99
+ * Draw a rectangle's outline, `thickness` pixels wide, growing inwards.
100
+ *
101
+ * **Inwards rather than centred on the edge**, so an outline around a region
102
+ * flush with the image border is still fully visible. An outline drawn
103
+ * outwards or centred loses half its width off the edge exactly where the
104
+ * region is hardest to locate — the very top of a page.
105
+ *
106
+ * Mutates the image it is given, because the overlay is one image with a dozen
107
+ * outlines on it and copying per outline would copy a full page a dozen times.
108
+ */
109
+ export function outlineRectangle(image, rectangle, colour, thickness) {
110
+ const depth = Math.max(1, Math.floor(thickness));
111
+ for (let ring = 0; ring < depth; ring += 1) {
112
+ const left = rectangle.x + ring;
113
+ const top = rectangle.y + ring;
114
+ const right = rectangle.x + rectangle.width - 1 - ring;
115
+ const bottom = rectangle.y + rectangle.height - 1 - ring;
116
+ if (right < left || bottom < top) {
117
+ break;
118
+ }
119
+ for (let x = left; x <= right; x += 1) {
120
+ setPixel(image, x, top, colour);
121
+ setPixel(image, x, bottom, colour);
122
+ }
123
+ for (let y = top; y <= bottom; y += 1) {
124
+ setPixel(image, left, y, colour);
125
+ setPixel(image, right, y, colour);
126
+ }
127
+ }
128
+ }
129
+ /** Copy an image, so a drawing operation does not alter what it drew onto. */
130
+ export function copyImage(image) {
131
+ return { width: image.width, height: image.height, data: new Uint8Array(image.data) };
132
+ }
@@ -0,0 +1,46 @@
1
+ import pixelmatch from 'pixelmatch';
2
+ import { CHANNELS } from "./image.js";
3
+ /**
4
+ * Compare two images and return the mask.
5
+ *
6
+ * Both images must be the same width, and the compared height must fit in
7
+ * both. `geometry.ts` is what establishes that; this throws rather than
8
+ * guessing, because a silent adjustment here would produce a confident mask of
9
+ * two misaligned pictures.
10
+ */
11
+ export function computeMask(earlier, current, options) {
12
+ if (earlier.width !== current.width) {
13
+ throw new Error(`A mask needs two images of one width; got ${String(earlier.width)} and ${String(current.width)}. ` +
14
+ 'Reconcile the geometry first.');
15
+ }
16
+ const width = current.width;
17
+ const height = options.height ?? Math.min(earlier.height, current.height);
18
+ if (height > earlier.height || height > current.height) {
19
+ throw new Error(`A mask over ${String(height)} rows does not fit images of ${String(earlier.height)} and ` +
20
+ `${String(current.height)} rows.`);
21
+ }
22
+ const pixels = width * height;
23
+ // The library writes its output as four channels, and `diffMask` makes the
24
+ // unchanged ones transparent — which is what turns "an image a person looks
25
+ // at" into "a bitmap something iterates".
26
+ const output = new Uint8Array(pixels * CHANNELS);
27
+ const changedPixels = pixelmatch(earlier.data.subarray(0, pixels * CHANNELS), current.data.subarray(0, pixels * CHANNELS), output, width, height, {
28
+ threshold: options.colourTolerance,
29
+ // Transparent background, opaque where something changed. Without this
30
+ // the output is the original image dimmed underneath the differences,
31
+ // and every pixel is non-zero.
32
+ diffMask: true,
33
+ // Anti-aliased pixels are excluded from the count rather than coloured
34
+ // in, which is the default and is the behaviour §3.11's settling
35
+ // requirement depends on.
36
+ includeAA: false,
37
+ });
38
+ const changed = new Uint8Array(pixels);
39
+ for (let index = 0; index < pixels; index += 1) {
40
+ // The alpha channel is the decision. With `diffMask` set, an unchanged
41
+ // pixel is fully transparent and a changed one is not — so any non-zero
42
+ // alpha is a change, whatever colour the library chose to mark it with.
43
+ changed[index] = output[index * CHANNELS + 3] === 0 ? 0 : 1;
44
+ }
45
+ return { width, height, changed, changedPixels };
46
+ }
@@ -0,0 +1,263 @@
1
+ /**
2
+ * Changed-region extraction (`MILESTONES.md` #41).
3
+ *
4
+ * Three steps, in order, and each one is a decision the milestone names:
5
+ *
6
+ * 1. **Connected components** over the mask, into bounding boxes.
7
+ * 2. **Merged at a configurable distance** (§6.2, default 8 pixels), because
8
+ * two words changing in one sentence are one change to a person.
9
+ * 3. **Filtered on area with a thin-line allowance** — and the negative half
10
+ * of that is the point of the row.
11
+ *
12
+ * ══════════════════════════════════════════════════════════════════════════
13
+ * THE FILTER, AND THE SHAPE IT MUST NOT DISCARD
14
+ * ══════════════════════════════════════════════════════════════════════════
15
+ *
16
+ * #41: filtered "**on area with a thin-line allowance** — not on the shorter
17
+ * side, which discards a one-pixel line across a wide page". §6.2 says the
18
+ * same from the other direction: "the size filter is on area with a thin-line
19
+ * allowance, so a one-pixel line across a page survives it".
20
+ *
21
+ * **The rejected rule and why it is so tempting.** Filtering on the shorter
22
+ * side — the smaller of width and height against a minimum — reads as
23
+ * obviously right: it drops specks, and a speck is small in both directions.
24
+ * It is wrong because the changes most worth catching in a visual review are
25
+ * thin by nature:
26
+ *
27
+ * | Change | Shape | Shorter side |
28
+ * |---|---|---|
29
+ * | A border width going from one pixel to two | a line the width of an element | **1** |
30
+ * | A focus ring appearing | a thin rectangle outline | **1–2** |
31
+ * | An underline added or removed | a line the width of a word | **1** |
32
+ * | A horizontal rule moving | a line the width of a page | **1** |
33
+ *
34
+ * Every one of those is invisible to a shorter-side filter and every one is a
35
+ * real regression somebody wants to see. A one-pixel line across a page a
36
+ * thousand pixels wide has an area of a thousand, which is many times the
37
+ * default minimum — so **area alone already keeps it**, and the shorter-side
38
+ * rule is strictly worse than the simpler thing.
39
+ *
40
+ * **So what is the allowance for, if area alone keeps the long line?** For the
41
+ * *short* thin line — a two-pixel-tall underline beneath a single short word,
42
+ * area perhaps forty against a minimum of sixty-four. Area alone eats that,
43
+ * and it is the same class of change as the long one. The allowance is a
44
+ * second way to survive: **a region that is thin and long relative to its
45
+ * thickness is kept whatever its area**, because being line-shaped is itself
46
+ * evidence that it is a deliberate piece of a layout rather than a rendering
47
+ * speck. A speck is small in both directions and roughly square; a line is
48
+ * not.
49
+ *
50
+ * The two tests that pin this are the ones to read before touching the
51
+ * numbers: a one-pixel line across a wide page survives, and a short thin
52
+ * underline survives, and both fail if the filter reverts to the shorter side.
53
+ */
54
+ /**
55
+ * How long a thin region must be, as a multiple of its thickness, to be kept
56
+ * regardless of area.
57
+ *
58
+ * **Six rather than a larger number**, because the shortest change worth
59
+ * keeping is an underline beneath one short word: two pixels tall and perhaps
60
+ * twenty long is a ratio of ten, and a two-by-three speck is a ratio of one
61
+ * and a half. Six sits between them with room on both sides, and the cost of
62
+ * it being slightly too low is an extra small region in a list that is ordered
63
+ * largest first and capped anyway.
64
+ */
65
+ export const THIN_LINE_ASPECT_RATIO = 6;
66
+ /**
67
+ * The thickest a region can be and still be judged a line, in pixels.
68
+ *
69
+ * Without this, a large block on a wide page whose sides happen to sit at a
70
+ * ratio of six would be kept "as a line" — which is harmless, since a region
71
+ * that size passes on area many times over, but it makes the allowance mean
72
+ * something it does not. Four pixels covers a border, a focus ring, an
73
+ * underline and a rule at the device pixel ratios a capture is taken at.
74
+ */
75
+ export const THIN_LINE_MAXIMUM_THICKNESS = 4;
76
+ /**
77
+ * Connected components over the mask, four-connected.
78
+ *
79
+ * **Four rather than eight**, and the difference matters here in one
80
+ * direction: eight-connectivity joins two areas touching only at a corner,
81
+ * which is how two separate one-pixel changes a diagonal apart become one box
82
+ * spanning both. Merging is a separate, configurable step immediately below,
83
+ * so the conservative connectivity loses nothing — anything that should have
84
+ * been joined is joined by distance, under a number somebody can see and
85
+ * change.
86
+ *
87
+ * Iterative rather than recursive: a full-page region can be a million pixels,
88
+ * and a recursive flood fill on one is a stack overflow rather than a slow
89
+ * answer.
90
+ */
91
+ function components(mask) {
92
+ const { width, height, changed } = mask;
93
+ const seen = new Uint8Array(width * height);
94
+ const found = [];
95
+ // Reused across components. A per-component array would allocate once per
96
+ // region, and a page-wide re-render produces a lot of them.
97
+ const stack = [];
98
+ for (let start = 0; start < changed.length; start += 1) {
99
+ if (changed[start] === 0 || seen[start] === 1) {
100
+ continue;
101
+ }
102
+ seen[start] = 1;
103
+ stack.length = 0;
104
+ stack.push(start);
105
+ let left = width;
106
+ let right = -1;
107
+ let top = height;
108
+ let bottom = -1;
109
+ let count = 0;
110
+ while (stack.length > 0) {
111
+ // `pop` on a non-empty array, which the loop condition guarantees.
112
+ const at = stack.pop();
113
+ const x = at % width;
114
+ const y = (at - x) / width;
115
+ count += 1;
116
+ if (x < left)
117
+ left = x;
118
+ if (x > right)
119
+ right = x;
120
+ if (y < top)
121
+ top = y;
122
+ if (y > bottom)
123
+ bottom = y;
124
+ if (x > 0 && changed[at - 1] === 1 && seen[at - 1] === 0) {
125
+ seen[at - 1] = 1;
126
+ stack.push(at - 1);
127
+ }
128
+ if (x + 1 < width && changed[at + 1] === 1 && seen[at + 1] === 0) {
129
+ seen[at + 1] = 1;
130
+ stack.push(at + 1);
131
+ }
132
+ if (y > 0 && changed[at - width] === 1 && seen[at - width] === 0) {
133
+ seen[at - width] = 1;
134
+ stack.push(at - width);
135
+ }
136
+ if (y + 1 < height && changed[at + width] === 1 && seen[at + width] === 0) {
137
+ seen[at + width] = 1;
138
+ stack.push(at + width);
139
+ }
140
+ }
141
+ found.push({
142
+ x: left,
143
+ y: top,
144
+ width: right - left + 1,
145
+ height: bottom - top + 1,
146
+ changedPixels: count,
147
+ });
148
+ }
149
+ return found;
150
+ }
151
+ /** Do two boxes come within `distance` of each other, in both axes? */
152
+ function within(a, b, distance) {
153
+ const horizontalGap = Math.max(0, Math.max(a.x - (b.x + b.width), b.x - (a.x + a.width)));
154
+ const verticalGap = Math.max(0, Math.max(a.y - (b.y + b.height), b.y - (a.y + a.height)));
155
+ return horizontalGap <= distance && verticalGap <= distance;
156
+ }
157
+ /** The smallest box containing both. */
158
+ function union(a, b) {
159
+ const x = Math.min(a.x, b.x);
160
+ const y = Math.min(a.y, b.y);
161
+ const right = Math.max(a.x + a.width, b.x + b.width);
162
+ const bottom = Math.max(a.y + a.height, b.y + b.height);
163
+ return {
164
+ x,
165
+ y,
166
+ width: right - x,
167
+ height: bottom - y,
168
+ // Summed rather than recounted from the mask. The two components are
169
+ // disjoint by construction, so the sum is exact, and recounting would mean
170
+ // a second pass over the union's area for every merge.
171
+ changedPixels: a.changedPixels + b.changedPixels,
172
+ };
173
+ }
174
+ /**
175
+ * Merge boxes that come within `distance` of one another, repeatedly.
176
+ *
177
+ * **Repeatedly, until nothing moves**, and that is not an optimisation detail:
178
+ * merging one box into another can bring the union within reach of a third
179
+ * that was too far from either alone. A single pass would report two regions
180
+ * where a person sees one, and which two would depend on the order the
181
+ * components happened to be found in.
182
+ */
183
+ function merge(regions, distance) {
184
+ let current = regions;
185
+ let moved = true;
186
+ while (moved) {
187
+ moved = false;
188
+ const next = [];
189
+ for (const region of current) {
190
+ let merged = region;
191
+ let index = 0;
192
+ while (index < next.length) {
193
+ // Indexed inside the bound the loop condition establishes; the local
194
+ // is what lets the compiler see that.
195
+ const candidate = next[index];
196
+ if (within(merged, candidate, distance)) {
197
+ merged = union(merged, candidate);
198
+ next.splice(index, 1);
199
+ moved = true;
200
+ // Not advancing: the widened box has to be retried against
201
+ // everything already passed, because it now reaches further.
202
+ index = 0;
203
+ continue;
204
+ }
205
+ index += 1;
206
+ }
207
+ next.push(merged);
208
+ }
209
+ current = next;
210
+ }
211
+ return current;
212
+ }
213
+ /**
214
+ * Is this region a thin line — kept whatever its area?
215
+ *
216
+ * Exported because the filter is the row's whole point, and a test that
217
+ * asserted it only through the end-to-end result would pass just as well with
218
+ * the allowance deleted, so long as the fixture happened to be large.
219
+ */
220
+ export function isThinLine(region) {
221
+ const thickness = Math.min(region.width, region.height);
222
+ const length = Math.max(region.width, region.height);
223
+ if (thickness > THIN_LINE_MAXIMUM_THICKNESS) {
224
+ return false;
225
+ }
226
+ return length >= thickness * THIN_LINE_ASPECT_RATIO;
227
+ }
228
+ /**
229
+ * Does this region survive the size filter?
230
+ *
231
+ * **Area, or the thin-line allowance. Never the shorter side.** The header of
232
+ * this file is the argument; this is the one line it is about.
233
+ */
234
+ export function survivesSizeFilter(region, minimumArea) {
235
+ return region.width * region.height >= minimumArea || isThinLine(region);
236
+ }
237
+ /**
238
+ * Extract the changed regions from a mask.
239
+ *
240
+ * Ordered **largest first by area**, which is the order §1.9 promises and the
241
+ * order the region cap depends on: a truncated result drops the smallest ones,
242
+ * since the list is ordered largest first.
243
+ *
244
+ * The cap itself is deliberately **not** applied here. Truncation is a fact
245
+ * about what the caller was given, and it belongs beside the crops that were
246
+ * actually written — otherwise a region could be dropped here and the count
247
+ * reported downstream would describe a list nobody has.
248
+ */
249
+ export function extractRegions(mask, options) {
250
+ const merged = merge(components(mask), options.mergeDistance);
251
+ const kept = merged.filter((region) => survivesSizeFilter(region, options.minimumArea));
252
+ return kept.sort((a, b) => {
253
+ const byArea = b.width * b.height - a.width * a.height;
254
+ // Ties broken by position, so the order is the same on two runs over the
255
+ // same mask. Without it the order is whatever the flood fill happened to
256
+ // produce, and a test asserting "the first region" would be flaky.
257
+ if (byArea !== 0)
258
+ return byArea;
259
+ if (a.y !== b.y)
260
+ return a.y - b.y;
261
+ return a.x - b.x;
262
+ });
263
+ }