single-file-cli 2.6.1 → 2.6.3

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 (43) hide show
  1. package/.github/workflows/ci.yml +24 -0
  2. package/build-dev.sh +35 -14
  3. package/build.sh +1 -1
  4. package/deno.json +1 -1
  5. package/eslint.config.mjs +1 -1
  6. package/lib/deno-polyfill.js +6 -4
  7. package/lib/single-file-archive.js +4 -4
  8. package/lib/single-file-bundle.js +1 -1
  9. package/lib/version.js +1 -1
  10. package/package.json +2 -1
  11. package/test/e2e/automation-detection.test.js +2 -3
  12. package/test/e2e/backend-fetch.test.js +2 -3
  13. package/test/e2e/blocked-url-pattern.test.js +2 -3
  14. package/test/e2e/browser-profile.test.js +2 -3
  15. package/test/e2e/compress-content.test.js +3 -4
  16. package/test/e2e/crawl-save-archive.test.js +3 -4
  17. package/test/e2e/crawl.test.js +2 -3
  18. package/test/e2e/errors-file.test.js +2 -3
  19. package/test/e2e/fidelity.test.js +214 -0
  20. package/test/e2e/frame-gate.test.js +2 -3
  21. package/test/e2e/http-header.test.js +2 -3
  22. package/test/e2e/oopif.test.js +2 -3
  23. package/test/e2e/output-json.test.js +2 -3
  24. package/test/e2e/saved-page-opens.test.js +79 -0
  25. package/test/e2e/service-worker.test.js +2 -3
  26. package/test/e2e/timeouts.test.js +2 -3
  27. package/test/e2e/urls-file.test.js +2 -3
  28. package/test/fidelity/README.md +111 -0
  29. package/test/fidelity/browser.js +162 -0
  30. package/test/fidelity/make-font.js +294 -0
  31. package/test/fidelity/pages/duplicate-stylesheet/index.html +126 -0
  32. package/test/fidelity/pages/fonts/band.ttf +0 -0
  33. package/test/fidelity/pages/fonts/bar.ttf +0 -0
  34. package/test/fidelity/pages/fonts/block.ttf +0 -0
  35. package/test/fidelity/pages/frame-fonts/index.html +53 -0
  36. package/test/fidelity/pages/linked-stylesheet/index.html +30 -0
  37. package/test/fidelity/pages/linked-stylesheet/theme.css +38 -0
  38. package/test/fidelity/pages/synthetic-italic/index.html +59 -0
  39. package/test/fidelity/pages/unresolved-font-property/index.html +75 -0
  40. package/test/fidelity/pages/used-fonts/index.html +96 -0
  41. package/test/fidelity/server.js +70 -0
  42. package/test/target.js +77 -0
  43. package/test/unit/archive-packager.test.js +2 -1
@@ -0,0 +1,111 @@
1
+ # Fidelity harness
2
+
3
+ Renders a page, saves it, renders the save, and compares the pixels. The rest of the e2e suite
4
+ reads a capture as text or as bytes — a marker is present, an entry is named what it should be, the
5
+ console is clean. A save can pass all of that and still come back with its type in a fallback
6
+ family, its grid collapsed or a frame drawn blank.
7
+
8
+ The checks live in [`../e2e/fidelity.test.js`](../e2e/fidelity.test.js) and run with the rest of the
9
+ suite:
10
+
11
+ ```
12
+ ./build-dev.sh && npm run test:dev
13
+ ```
14
+
15
+ `test:dev` is the form that matters here, and it is also what switches these checks on. They are
16
+ skipped on the default target, which runs the committed `lib/` — a build of the *pinned npm release*
17
+ of single-file-core, so a green run there says nothing about a local fix, and a check written for one
18
+ stays red until it ships. To run them against that released build anyway:
19
+
20
+ ```
21
+ SINGLE_FILE_FIDELITY=1 node --test test/e2e/fidelity.test.js
22
+ ```
23
+
24
+ **They do not run in CI yet, and that is a hold rather than a decision.** The first CI run wedged on
25
+ a CDP command that never answered: simple-cdp leaves commands without a limit by default, so nothing
26
+ failed, the connection stayed stuck, every check after the first was never reached, and the job had
27
+ to be cancelled after ten minutes. The limit is set now (`commandMaxTime`), so a repeat would name
28
+ the method that did not answer instead of hanging — but that has not been seen on a runner yet.
29
+ Where they belong is a CI job that builds core from source and runs `test:dev`.
30
+
31
+ ## What it can assert, and why
32
+
33
+ A page that does not render identically to **itself** cannot be held to rendering identically to its
34
+ save. Every check therefore captures the source twice first and uses the difference as its floor.
35
+ On these fixtures the floor is zero, which is why they are hand-built and static rather than
36
+ mirrored from the web — real pages reflow between two shots of the same file, and a suite built on
37
+ them reports noise as regression.
38
+
39
+ The comparison is done in the browser, on the two PNGs: Node has no image decoder, and adding one as
40
+ a dependency to compare pictures taken by a browser that already decodes PNG natively would be
41
+ paying twice for the same capability. Images are compared in horizontal bands so that a failure says
42
+ *where* the two renderings parted company, and a save shorter than its source counts its missing rows
43
+ as differing rather than having them cropped away.
44
+
45
+ Two things pixels cannot see, which are asserted on the saved markup instead:
46
+
47
+ - an `id` a script looks up or a `class` a selector matches, on an element the save replaced;
48
+ - pruning that did not happen. A build that gives up and keeps every font renders exactly like a
49
+ correct one.
50
+
51
+ ## The fixtures
52
+
53
+ Each is a defect that shipped, kept in the shape that made it visible, with a comment in the page
54
+ saying which.
55
+
56
+ | Fixture | What it holds |
57
+ |---|---|
58
+ | `duplicate-stylesheet/` | Two `<style>` elements with identical content. The archive writer folds them into one entry and points links at it; the element they were folded into kept its content inline as well, and the replacement dropped the attributes that identified it. |
59
+ | `linked-stylesheet/` | An external stylesheet linked with an id, a class, a data attribute and a title. A plain save has nowhere to put it, so the link becomes a style element — a new one, built from the media and the text and nothing else. |
60
+ | `used-fonts/` | Five declared faces, three drawn — named as a plain family, through a custom property declared on a descendant, and through the `font` shorthand. The minifier has stopped resolving each of those at some point. |
61
+ | `frame-fonts/` | A sandboxed frame declaring and using a face the page around it never names. Its `contentDocument` is out of reach, so it is re-parsed from its srcdoc and reports nothing about what it draws with. |
62
+ | `synthetic-italic/` | A face drawn in an italic it declares no face for, so the browser slants the upright one. Matching a loaded face against the computed style found nothing and pruned the family off the page that draws it. |
63
+ | `unresolved-font-property/` | A family named inside a property holding a whole font shorthand, declared twice so there is no single value to substitute. The stylesheets cannot name it; the rendered list can. |
64
+
65
+ `pages/fonts/` holds generated fonts in which every printable ASCII character is the same filled
66
+ rectangle: text set in them is a solid bar, so a face that goes missing is not a subtle reflow. The
67
+ three shapes differ, so keeping the *wrong* face is as visible as keeping none.
68
+ [`make-font.js`](make-font.js) writes them; run it after changing it, and commit the result.
69
+
70
+ ## Rules the harness enforces, and what they cost to learn
71
+
72
+ Each of these is a way a run reported good news that was not true.
73
+
74
+ 1. **A run without a noise floor concludes nothing.** Measured every time, never assumed.
75
+ 2. **A whole-image verdict is not usable.** One line of reflow near the top moves every pixel below
76
+ it, and a single number cannot tell a small local difference from a large one.
77
+ 3. **A fixture that fails to load renders identically to itself.** The floor is zero, the save of
78
+ that same failure matches it, and the check passes while testing nothing — it happened here, with
79
+ a directory served as a file giving two beautifully identical 404 pages. The server records every
80
+ miss and the checks refuse to conclude when there is one. Only the favicon is excused: a request
81
+ to the captured site that the site cannot answer is a defect wherever it comes from, and this is
82
+ how the archive writer was caught asking that site for a zip worker three times per save.
83
+ 4. **The output file is removed before each save.** The default conflict action is to uniquify, so a
84
+ second save writes `saved (2).html` and leaves the stale file where the test is looking.
85
+ 5. **A stale dev build reads as "the change has no effect".** [`../target.js`](../target.js) refuses
86
+ to start when `.dev/` is older than the newest source file in single-file-core.
87
+ 6. **Assert on content, not on a number.** A five-megabyte cookie-consent wall looks exactly like a
88
+ successful save.
89
+
90
+ ## Adding a fixture
91
+
92
+ Build the page around the defect, in the shape that made it visible, and say in an HTML comment what
93
+ that was — the fixtures read as a record of what has gone wrong, which is most of their value once
94
+ the bug is a year old.
95
+
96
+ Then confirm the check can fail. Break the fix in single-file-core, rebuild, watch it go red, and
97
+ put it back. A check that passes against deliberately broken code is protecting nothing, and this is
98
+ not a formality here: `frame-fonts` found a defect on its first run that the commit it was written
99
+ to guard did not cover, `unresolved-font-property` had to be reshaped twice before it exercised the
100
+ code it names — a var() in family position resolves through the ordinary path, whatever it is nested
101
+ in — and `synthetic-italic` exists because a fixture that should have been unremarkable came back
102
+ with an empty list of used fonts.
103
+
104
+ Two traps worth knowing before writing one. A face used in a style the fonts do not declare is
105
+ drawn synthesized, which changes what the page reports about it; and a family name appears in the
106
+ declarations that *use* it as well as in the `@font-face` that declares it, so read the declared
107
+ faces out of the `@font-face` rules rather than searching the page for a name.
108
+
109
+ And commit the fix before mutating it. Restoring the tree between mutations is a `git checkout`,
110
+ which takes the uncommitted fix with it — the next mutation then fails to apply against code that
111
+ is already reverted, and reports a pass. It has happened twice here.
@@ -0,0 +1,162 @@
1
+ /* global setTimeout, clearTimeout, fetch, createImageBitmap, OffscreenCanvas */
2
+
3
+ // One browser, driven over CDP, doing two jobs: rendering a page to a PNG, and comparing two of
4
+ // those PNGs. The comparison runs *in* the browser on purpose — Node has no image decoder, and
5
+ // adding one as a dependency to compare pictures taken by a browser that already decodes PNG
6
+ // natively would be paying twice for the same capability.
7
+ //
8
+ // The rules encoded here were all learned by getting them wrong:
9
+ //
10
+ // - a comparison without a noise floor says nothing. Pages that reflow between two shots of the
11
+ // SAME file are common, so every comparison is made against a control taken from the same
12
+ // source, and the floor is a measured output of the run rather than an assumption.
13
+ // - a whole-image verdict is not usable. One line of reflow near the top shifts every pixel
14
+ // below it, so a single number cannot tell a small local difference from a large one. The
15
+ // images are compared in horizontal bands, and the bands are reported separately.
16
+ // - images of different heights are not an error to be normalised away. A saved page that is
17
+ // shorter than its source has lost something; the extra rows count as differing pixels.
18
+ import { options as cdpOptions, CDP } from "simple-cdp";
19
+ import { importLibModule } from "../target.js";
20
+
21
+ const LOCALHOST = "http://localhost:";
22
+ const EMPTY_PAGE_URL = "about:blank";
23
+ const BAND_HEIGHT = 1000;
24
+ const VIEWPORT_WIDTH = 1200;
25
+ const VIEWPORT_HEIGHT = 900;
26
+ const LOAD_TIMEOUT = 60000;
27
+ const COMMAND_TIMEOUT = 60000;
28
+ const SETTLE_TIMEOUT = 10000;
29
+ // Waiting for the fonts and for two frames is waiting on the page, and a page is allowed not to
30
+ // answer: a self-extracting archive replaces the document as it opens, and a font that never
31
+ // resolves leaves document.fonts.ready pending for good. The race means this expression always
32
+ // settles, so the only thing that can hang is the connection, which has its own limit below.
33
+ const SETTLE_EXPRESSION = `Promise.race([
34
+ (async () => {
35
+ if (document.fonts) {
36
+ await document.fonts.ready;
37
+ }
38
+ await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
39
+ })(),
40
+ new Promise(resolve => setTimeout(resolve, ${SETTLE_TIMEOUT}))
41
+ ])`;
42
+
43
+ export { openBrowser, BAND_HEIGHT };
44
+
45
+ async function openBrowser({ headless = true } = {}) {
46
+ const { launchBrowser, closeBrowser } = await importLibModule("browser.js");
47
+ cdpOptions.apiUrl = LOCALHOST + (await launchBrowser({ headless }));
48
+ // A command with no limit waits for its answer for ever, and that is the default. One that never
49
+ // came back took a whole CI run with it: the suite reported nothing but its own test timeout,
50
+ // the connection stayed wedged, and every check after it in the file was never reached. With a
51
+ // limit the failure names the method that did not answer, which is the difference between a
52
+ // diagnosis and a rerun.
53
+ cdpOptions.commandMaxTime = COMMAND_TIMEOUT;
54
+ const comparisonTarget = await createSession();
55
+ return { capture, compare, close };
56
+
57
+ async function capture(url, { width = VIEWPORT_WIDTH, height = VIEWPORT_HEIGHT } = {}) {
58
+ const { cdp, targetId } = await createSession();
59
+ const { Page, Emulation, Runtime } = cdp;
60
+ try {
61
+ await Page.enable();
62
+ await Emulation.setDeviceMetricsOverride({ width, height, deviceScaleFactor: 1, mobile: false });
63
+ await Promise.all([waitUntilLoaded(Page), Page.navigate({ url })]);
64
+ await Runtime.evaluate({ expression: SETTLE_EXPRESSION, awaitPromise: true });
65
+ const { data } = await Page.captureScreenshot({ format: "png", captureBeyondViewport: true });
66
+ return data;
67
+ } finally {
68
+ await CDP.closeTarget(targetId);
69
+ }
70
+ }
71
+
72
+ // the two screenshots and the band height go in, the count of pixels that differ comes out —
73
+ // per band, so that a caller can say where the pages parted company and not only that they did
74
+ async function compare(firstImage, secondImage, bandHeight = BAND_HEIGHT) {
75
+ const expression = "(" + compareInBrowser.toString() + ")(" +
76
+ JSON.stringify(firstImage) + "," + JSON.stringify(secondImage) + "," + bandHeight + ")";
77
+ const { result, exceptionDetails } = await comparisonTarget.cdp.Runtime.evaluate({
78
+ expression,
79
+ awaitPromise: true,
80
+ returnByValue: true
81
+ });
82
+ if (exceptionDetails) {
83
+ throw new Error("the comparison failed in the browser: " + exceptionDetails.text);
84
+ }
85
+ return result.value;
86
+ }
87
+
88
+ async function close() {
89
+ await CDP.closeTarget(comparisonTarget.targetId).catch(() => { });
90
+ await closeBrowser();
91
+ }
92
+ }
93
+
94
+ async function createSession(url = EMPTY_PAGE_URL) {
95
+ const targetInfo = await CDP.createTarget(url);
96
+ return { cdp: new CDP(targetInfo), targetId: targetInfo.id };
97
+ }
98
+
99
+ function waitUntilLoaded(Page) {
100
+ return new Promise((resolve, reject) => {
101
+ const timeout = setTimeout(() => {
102
+ Page.removeEventListener("loadEventFired", onLoad);
103
+ reject(new Error("the page did not finish loading"));
104
+ }, LOAD_TIMEOUT);
105
+ Page.addEventListener("loadEventFired", onLoad);
106
+
107
+ function onLoad() {
108
+ clearTimeout(timeout);
109
+ Page.removeEventListener("loadEventFired", onLoad);
110
+ resolve();
111
+ }
112
+ });
113
+ }
114
+
115
+ // serialised into the browser, so it stands alone: no imports, no closure over anything here
116
+ function compareInBrowser(firstImage, secondImage, bandHeight) {
117
+ return (async () => {
118
+ const [first, second] = await Promise.all([decode(firstImage), decode(secondImage)]);
119
+ const width = Math.max(first.width, second.width);
120
+ const height = Math.max(first.height, second.height);
121
+ const [firstPixels, secondPixels] = [draw(first), draw(second)];
122
+ const bands = [];
123
+ let differing = 0;
124
+ for (let top = 0; top < height; top += bandHeight) {
125
+ const bottom = Math.min(top + bandHeight, height);
126
+ let bandDiffering = 0;
127
+ for (let offset = top * width * 4; offset < bottom * width * 4; offset += 4) {
128
+ if (firstPixels[offset] != secondPixels[offset] ||
129
+ firstPixels[offset + 1] != secondPixels[offset + 1] ||
130
+ firstPixels[offset + 2] != secondPixels[offset + 2] ||
131
+ firstPixels[offset + 3] != secondPixels[offset + 3]) {
132
+ bandDiffering++;
133
+ }
134
+ }
135
+ differing += bandDiffering;
136
+ bands.push({ top, bottom, differing: bandDiffering, total: (bottom - top) * width });
137
+ }
138
+ return {
139
+ width,
140
+ height,
141
+ differing,
142
+ total: width * height,
143
+ sizeMatches: first.width == second.width && first.height == second.height,
144
+ bands
145
+ };
146
+
147
+ async function decode(data) {
148
+ const response = await fetch("data:image/png;base64," + data);
149
+ return createImageBitmap(await response.blob());
150
+ }
151
+
152
+ // both images are drawn on a canvas of the union size, so an image that is shorter than the
153
+ // other leaves transparent rows where the other has content, and those rows count as
154
+ // differing rather than being quietly cropped away
155
+ function draw(image) {
156
+ const canvas = new OffscreenCanvas(width, height);
157
+ const context = canvas.getContext("2d", { willReadFrequently: true });
158
+ context.drawImage(image, 0, 0);
159
+ return context.getImageData(0, 0, width, height).data;
160
+ }
161
+ })();
162
+ }
@@ -0,0 +1,294 @@
1
+ // Writes a TrueType font in which every printable ASCII character is the same filled rectangle.
2
+ // Text set in it is a solid bar, so a page that loses the face does not merely reflow slightly —
3
+ // it changes from a black block to readable words, which no pixel comparison can miss.
4
+ //
5
+ // It is generated rather than borrowed for two reasons. A real font carries a licence and a few
6
+ // hundred kilobytes into a fixture directory, and, more importantly, the fonts already lying around
7
+ // for probing turned out to map no letters at all: text set in them silently fell back to a system
8
+ // family, which made a working fix look broken and cost an afternoon. A font built here maps
9
+ // exactly what it claims to map, and the shapes differ per variant so that "the right family was
10
+ // kept" is a visible statement and not only "some family was kept".
11
+ //
12
+ // Not a test. Run it to regenerate the committed .ttf files:
13
+ //
14
+ // node test/fidelity/make-font.js
15
+ //
16
+ import { writeFile } from "node:fs/promises";
17
+ import { join, dirname } from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+ import process from "node:process";
20
+
21
+ const UNITS_PER_EM = 1000;
22
+ const FIRST_CHARACTER_CODE = 0x20;
23
+ const LAST_CHARACTER_CODE = 0x7e;
24
+ const ASCENT = 800;
25
+ const DESCENT = -200;
26
+ const ADVANCE_WIDTH = 600;
27
+ const MAGIC_NUMBER = 0x5f0f3cf5;
28
+ const CHECKSUM_MAGIC = 0xb1b0afba;
29
+ const TABLE_RECORD_SIZE = 16;
30
+ const HEAD_CHECKSUM_ADJUSTMENT_OFFSET = 8;
31
+
32
+ // each fixture family is a different shape, so a screenshot says which face was used and not just
33
+ // that one was: a full block, a band across the middle, a bar sitting on the baseline
34
+ const VARIANTS = {
35
+ "block": { bottom: 0, top: 760, left: 60, right: 540 },
36
+ "band": { bottom: 260, top: 500, left: 40, right: 560 },
37
+ "bar": { bottom: 0, top: 160, left: 40, right: 560 }
38
+ };
39
+
40
+ export { createFont, VARIANTS };
41
+
42
+ if (import.meta.url === ("file://" + process.argv[1]) || import.meta.filename === process.argv[1]) {
43
+ const directory = join(dirname(fileURLToPath(import.meta.url)), "pages", "fonts");
44
+ await Promise.all(Object.keys(VARIANTS).map(async name => {
45
+ const path = join(directory, name + ".ttf");
46
+ await writeFile(path, createFont(Object.assign({ familyName: "Fidelity " + name }, VARIANTS[name])));
47
+ console.log("wrote " + path); // eslint-disable-line no-console
48
+ }));
49
+ }
50
+
51
+ function createFont({ familyName, top, bottom, left, right }) {
52
+ const glyph = buildGlyph({ top, bottom, left, right });
53
+ const tables = [
54
+ ["OS/2", buildOS2({ top, bottom })],
55
+ ["cmap", buildCmap()],
56
+ ["glyf", glyph],
57
+ ["head", buildHead({ top, bottom, left, right })],
58
+ ["hhea", buildHhea()],
59
+ ["hmtx", buildHmtx()],
60
+ ["loca", buildLoca(glyph.length)],
61
+ ["maxp", buildMaxp()],
62
+ ["name", buildName(familyName)],
63
+ ["post", buildPost()]
64
+ ];
65
+ return assemble(tables);
66
+ }
67
+
68
+ // the offset table, the table records and the tables themselves, followed by the one value that can
69
+ // only be computed once the whole file exists: the checksum adjustment in head
70
+ function assemble(tables) {
71
+ const headerSize = 12 + tables.length * TABLE_RECORD_SIZE;
72
+ const size = tables.reduce((total, [, content]) => total + align(content.length), headerSize);
73
+ const font = new Uint8Array(size);
74
+ const view = new DataView(font.buffer);
75
+ const entrySelector = Math.floor(Math.log2(tables.length));
76
+ const searchRange = 16 * (2 ** entrySelector);
77
+ view.setUint32(0, 0x00010000);
78
+ view.setUint16(4, tables.length);
79
+ view.setUint16(6, searchRange);
80
+ view.setUint16(8, entrySelector);
81
+ view.setUint16(10, tables.length * 16 - searchRange);
82
+ let offset = headerSize;
83
+ let headOffset;
84
+ tables.forEach(([tag, content], index) => {
85
+ const record = 12 + index * TABLE_RECORD_SIZE;
86
+ Array.from(tag).forEach((character, position) => view.setUint8(record + position, character.charCodeAt(0)));
87
+ view.setUint32(record + 4, checksum(content));
88
+ view.setUint32(record + 8, offset);
89
+ view.setUint32(record + 12, content.length);
90
+ font.set(content, offset);
91
+ if (tag == "head") {
92
+ headOffset = offset;
93
+ }
94
+ offset += align(content.length);
95
+ });
96
+ view.setUint32(headOffset + HEAD_CHECKSUM_ADJUSTMENT_OFFSET, (CHECKSUM_MAGIC - checksum(font)) >>> 0);
97
+ return font;
98
+ }
99
+
100
+ function checksum(content) {
101
+ const view = new DataView(content.buffer, content.byteOffset, content.byteLength);
102
+ let sum = 0;
103
+ for (let offset = 0; offset + 4 <= content.length; offset += 4) {
104
+ sum = (sum + view.getUint32(offset)) >>> 0;
105
+ }
106
+ // a table is padded to a multiple of four with zeroes, and the checksum is taken over the
107
+ // padded form: the trailing bytes are read as if those zeroes were already there
108
+ if (content.length % 4) {
109
+ let tail = 0;
110
+ for (let offset = content.length - content.length % 4; offset < content.length; offset++) {
111
+ tail = (tail << 8) | content[offset];
112
+ }
113
+ sum = (sum + (tail << (8 * (4 - content.length % 4)))) >>> 0;
114
+ }
115
+ return sum;
116
+ }
117
+
118
+ function align(length) {
119
+ return length + (length % 4 ? 4 - length % 4 : 0);
120
+ }
121
+
122
+ function buildHead({ top, bottom, left, right }) {
123
+ const writer = createWriter(54);
124
+ writer.uint32(0x00010000);
125
+ writer.uint32(0x00010000);
126
+ writer.uint32(0);
127
+ writer.uint32(MAGIC_NUMBER);
128
+ writer.uint16(0x0003);
129
+ writer.uint16(UNITS_PER_EM);
130
+ writer.uint32(0); writer.uint32(0);
131
+ writer.uint32(0); writer.uint32(0);
132
+ writer.int16(left); writer.int16(bottom); writer.int16(right); writer.int16(top);
133
+ writer.uint16(0);
134
+ writer.uint16(8);
135
+ writer.int16(2);
136
+ // the long form of loca, so that the offsets are plain byte counts rather than halves
137
+ writer.int16(1);
138
+ writer.int16(0);
139
+ return writer.content;
140
+ }
141
+
142
+ function buildHhea() {
143
+ const writer = createWriter(36);
144
+ writer.uint32(0x00010000);
145
+ writer.int16(ASCENT); writer.int16(DESCENT); writer.int16(0);
146
+ writer.uint16(ADVANCE_WIDTH);
147
+ writer.int16(0); writer.int16(0); writer.int16(ADVANCE_WIDTH);
148
+ writer.int16(1); writer.int16(0); writer.int16(0);
149
+ writer.int16(0); writer.int16(0); writer.int16(0); writer.int16(0);
150
+ writer.int16(0);
151
+ writer.uint16(2);
152
+ return writer.content;
153
+ }
154
+
155
+ function buildMaxp() {
156
+ const writer = createWriter(32);
157
+ writer.uint32(0x00010000);
158
+ writer.uint16(2);
159
+ writer.uint16(4); writer.uint16(1);
160
+ writer.uint16(0); writer.uint16(0);
161
+ writer.uint16(1); writer.uint16(0);
162
+ writer.uint16(0); writer.uint16(0); writer.uint16(0); writer.uint16(0); writer.uint16(0);
163
+ writer.uint16(0); writer.uint16(0);
164
+ return writer.content;
165
+ }
166
+
167
+ function buildHmtx() {
168
+ const writer = createWriter(8);
169
+ writer.uint16(ADVANCE_WIDTH); writer.int16(0);
170
+ writer.uint16(ADVANCE_WIDTH); writer.int16(0);
171
+ return writer.content;
172
+ }
173
+
174
+ // format 4, one segment covering printable ASCII and the terminator the format requires. Every
175
+ // character in the segment maps to the single glyph, including the space: text set in this font is
176
+ // one unbroken bar, which is exactly the point.
177
+ //
178
+ // The segment therefore has to name its glyph per character, through idRangeOffset and an array of
179
+ // indices. The shorter-looking route, a single idDelta added to the character code, maps a range
180
+ // LINEARLY — a font written that way claims a different glyph for every character, and the browser
181
+ // rejects the whole table as soon as one of them is past the last glyph
182
+ function buildCmap() {
183
+ const characterCount = LAST_CHARACTER_CODE - FIRST_CHARACTER_CODE + 1;
184
+ const subtableLength = 32 + characterCount * 2;
185
+ const writer = createWriter(12 + subtableLength);
186
+ writer.uint16(0); writer.uint16(1);
187
+ writer.uint16(3); writer.uint16(1); writer.uint32(12);
188
+ writer.uint16(4); writer.uint16(subtableLength); writer.uint16(0);
189
+ writer.uint16(4); writer.uint16(4); writer.uint16(1); writer.uint16(0);
190
+ writer.uint16(LAST_CHARACTER_CODE); writer.uint16(0xffff);
191
+ writer.uint16(0);
192
+ writer.uint16(FIRST_CHARACTER_CODE); writer.uint16(0xffff);
193
+ writer.uint16(0); writer.uint16(1);
194
+ // counted from the position of this very field, which is why the first segment's offset is the
195
+ // four bytes that the second segment's offset occupies
196
+ writer.uint16(4); writer.uint16(0);
197
+ for (let index = 0; index < characterCount; index++) {
198
+ writer.uint16(1);
199
+ }
200
+ return writer.content;
201
+ }
202
+
203
+ // glyph 0 is the empty .notdef, so the whole table is glyph 1: one closed contour of four on-curve
204
+ // points, given as deltas from the previous point. It is padded here rather than at assembly time,
205
+ // so that the offset loca gives for the end of the glyph stays inside the length glyf declares
206
+ function buildGlyph({ top, bottom, left, right }) {
207
+ const writer = createWriter(36);
208
+ writer.int16(1);
209
+ writer.int16(left); writer.int16(bottom); writer.int16(right); writer.int16(top);
210
+ writer.uint16(3);
211
+ writer.uint16(0);
212
+ writer.uint8(1); writer.uint8(1); writer.uint8(1); writer.uint8(1);
213
+ writer.int16(left); writer.int16(right - left); writer.int16(0); writer.int16(left - right);
214
+ writer.int16(bottom); writer.int16(0); writer.int16(top - bottom); writer.int16(0);
215
+ return writer.content;
216
+ }
217
+
218
+ function buildLoca(glyphLength) {
219
+ const writer = createWriter(12);
220
+ writer.uint32(0); writer.uint32(0); writer.uint32(align(glyphLength));
221
+ return writer.content;
222
+ }
223
+
224
+ function buildOS2({ top, bottom }) {
225
+ const writer = createWriter(96);
226
+ writer.uint16(4);
227
+ writer.int16(ADVANCE_WIDTH);
228
+ writer.uint16(400); writer.uint16(5); writer.uint16(0);
229
+ writer.int16(650); writer.int16(600); writer.int16(0); writer.int16(75);
230
+ writer.int16(650); writer.int16(600); writer.int16(0); writer.int16(350);
231
+ writer.int16(50); writer.int16(300);
232
+ writer.int16(0);
233
+ for (let index = 0; index < 10; index++) {
234
+ writer.uint8(0);
235
+ }
236
+ writer.uint32(1); writer.uint32(0); writer.uint32(0); writer.uint32(0);
237
+ Array.from("SFTD").forEach(character => writer.uint8(character.charCodeAt(0)));
238
+ writer.uint16(0x0040);
239
+ writer.uint16(FIRST_CHARACTER_CODE); writer.uint16(LAST_CHARACTER_CODE);
240
+ writer.int16(ASCENT); writer.int16(DESCENT); writer.int16(0);
241
+ writer.uint16(ASCENT); writer.uint16(-DESCENT);
242
+ writer.uint32(1); writer.uint32(0);
243
+ writer.int16(Math.round((top - bottom) / 2)); writer.int16(top);
244
+ writer.uint16(0); writer.uint16(FIRST_CHARACTER_CODE); writer.uint16(1);
245
+ return writer.content;
246
+ }
247
+
248
+ function buildName(familyName) {
249
+ const names = [[1, familyName], [2, "Regular"], [3, familyName + " Regular"], [4, familyName], [5, "Version 1.0"], [6, familyName.replace(/ /g, "")]];
250
+ const strings = names.map(([, value]) => encodeUTF16(value));
251
+ const header = 6 + names.length * 12;
252
+ const writer = createWriter(header + strings.reduce((total, string) => total + string.length, 0));
253
+ writer.uint16(0); writer.uint16(names.length); writer.uint16(header);
254
+ let offset = 0;
255
+ names.forEach(([identifier], index) => {
256
+ writer.uint16(3); writer.uint16(1); writer.uint16(0x0409); writer.uint16(identifier);
257
+ writer.uint16(strings[index].length); writer.uint16(offset);
258
+ offset += strings[index].length;
259
+ });
260
+ strings.forEach(string => string.forEach(byte => writer.uint8(byte)));
261
+ return writer.content;
262
+ }
263
+
264
+ function encodeUTF16(value) {
265
+ const bytes = [];
266
+ Array.from(value).forEach(character => {
267
+ const code = character.charCodeAt(0);
268
+ bytes.push(code >> 8, code & 0xff);
269
+ });
270
+ return bytes;
271
+ }
272
+
273
+ function buildPost() {
274
+ const writer = createWriter(32);
275
+ writer.uint32(0x00030000);
276
+ writer.uint32(0);
277
+ writer.int16(-100); writer.int16(50);
278
+ writer.uint32(1);
279
+ writer.uint32(0); writer.uint32(0); writer.uint32(0); writer.uint32(0);
280
+ return writer.content;
281
+ }
282
+
283
+ function createWriter(size) {
284
+ const content = new Uint8Array(size);
285
+ const view = new DataView(content.buffer);
286
+ let offset = 0;
287
+ return {
288
+ content,
289
+ uint8: value => view.setUint8(offset++, value),
290
+ int16: value => (view.setInt16(offset, value), offset += 2),
291
+ uint16: value => (view.setUint16(offset, value & 0xffff), offset += 2),
292
+ uint32: value => (view.setUint32(offset, value >>> 0), offset += 4)
293
+ };
294
+ }