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.
- package/.github/workflows/ci.yml +24 -0
- package/build-dev.sh +35 -14
- package/build.sh +1 -1
- package/deno.json +1 -1
- package/eslint.config.mjs +1 -1
- package/lib/deno-polyfill.js +6 -4
- package/lib/single-file-archive.js +4 -4
- package/lib/single-file-bundle.js +1 -1
- package/lib/version.js +1 -1
- package/package.json +2 -1
- package/test/e2e/automation-detection.test.js +2 -3
- package/test/e2e/backend-fetch.test.js +2 -3
- package/test/e2e/blocked-url-pattern.test.js +2 -3
- package/test/e2e/browser-profile.test.js +2 -3
- package/test/e2e/compress-content.test.js +3 -4
- package/test/e2e/crawl-save-archive.test.js +3 -4
- package/test/e2e/crawl.test.js +2 -3
- package/test/e2e/errors-file.test.js +2 -3
- package/test/e2e/fidelity.test.js +214 -0
- package/test/e2e/frame-gate.test.js +2 -3
- package/test/e2e/http-header.test.js +2 -3
- package/test/e2e/oopif.test.js +2 -3
- package/test/e2e/output-json.test.js +2 -3
- package/test/e2e/saved-page-opens.test.js +79 -0
- package/test/e2e/service-worker.test.js +2 -3
- package/test/e2e/timeouts.test.js +2 -3
- package/test/e2e/urls-file.test.js +2 -3
- package/test/fidelity/README.md +111 -0
- package/test/fidelity/browser.js +162 -0
- package/test/fidelity/make-font.js +294 -0
- package/test/fidelity/pages/duplicate-stylesheet/index.html +126 -0
- package/test/fidelity/pages/fonts/band.ttf +0 -0
- package/test/fidelity/pages/fonts/bar.ttf +0 -0
- package/test/fidelity/pages/fonts/block.ttf +0 -0
- package/test/fidelity/pages/frame-fonts/index.html +53 -0
- package/test/fidelity/pages/linked-stylesheet/index.html +30 -0
- package/test/fidelity/pages/linked-stylesheet/theme.css +38 -0
- package/test/fidelity/pages/synthetic-italic/index.html +59 -0
- package/test/fidelity/pages/unresolved-font-property/index.html +75 -0
- package/test/fidelity/pages/used-fonts/index.html +96 -0
- package/test/fidelity/server.js +70 -0
- package/test/target.js +77 -0
- 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
|
+
}
|