single-file-cli 2.6.2 → 2.6.4
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/build.sh +1 -1
- package/deno.json +1 -1
- package/lib/deno-polyfill.js +6 -4
- package/lib/single-file-archive.js +5 -5
- package/lib/single-file-bundle.js +1 -1
- package/lib/version.js +1 -1
- package/package.json +1 -1
- package/test/e2e/canvas-round-trip.test.js +93 -0
- package/test/e2e/fidelity.test.js +214 -0
- package/test/e2e/saved-page-opens.test.js +79 -0
- 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 +49 -4
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
|
|
4
|
+
<head>
|
|
5
|
+
<meta charset="utf-8">
|
|
6
|
+
<title>Duplicate stylesheet</title>
|
|
7
|
+
<!--
|
|
8
|
+
Two <style> elements with byte-identical content. The archive writer groups them, stores the
|
|
9
|
+
content once as an entry, and points a <link> at it. Both elements are rewritten: the second
|
|
10
|
+
because it is a duplicate, the first because it is the one the duplicates were folded into —
|
|
11
|
+
leaving that one inline stored the same stylesheet twice, once as the entry and once in the
|
|
12
|
+
page.
|
|
13
|
+
|
|
14
|
+
The attributes are here because the rewrite replaces the element rather than editing it. An
|
|
15
|
+
id a script looks up and a class a selector matches have to survive that, and they were
|
|
16
|
+
being dropped: the archive came back with a bare <link>.
|
|
17
|
+
-->
|
|
18
|
+
<style id="palette" class="theme" data-role="tokens">
|
|
19
|
+
:root {
|
|
20
|
+
--ink: #17202a;
|
|
21
|
+
--paper: #f4f4f2;
|
|
22
|
+
--accent: #1f5673;
|
|
23
|
+
--edge: #c9c9c4;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
body {
|
|
27
|
+
background: var(--paper);
|
|
28
|
+
color: var(--ink);
|
|
29
|
+
font: 16px/1.6 Georgia, "Times New Roman", serif;
|
|
30
|
+
margin: 0;
|
|
31
|
+
padding: 40px;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
h1 {
|
|
35
|
+
font-size: 34px;
|
|
36
|
+
margin: 0 0 24px;
|
|
37
|
+
border-bottom: 3px solid var(--accent);
|
|
38
|
+
padding-bottom: 12px;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.grid {
|
|
42
|
+
display: grid;
|
|
43
|
+
grid-template-columns: repeat(3, 1fr);
|
|
44
|
+
gap: 16px;
|
|
45
|
+
margin: 24px 0;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.cell {
|
|
49
|
+
background: #fff;
|
|
50
|
+
border: 1px solid var(--edge);
|
|
51
|
+
padding: 20px;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.cell b {
|
|
55
|
+
color: var(--accent);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.rule {
|
|
59
|
+
height: 8px;
|
|
60
|
+
background: var(--accent);
|
|
61
|
+
margin: 32px 0;
|
|
62
|
+
}
|
|
63
|
+
</style>
|
|
64
|
+
<style>
|
|
65
|
+
:root {
|
|
66
|
+
--ink: #17202a;
|
|
67
|
+
--paper: #f4f4f2;
|
|
68
|
+
--accent: #1f5673;
|
|
69
|
+
--edge: #c9c9c4;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
body {
|
|
73
|
+
background: var(--paper);
|
|
74
|
+
color: var(--ink);
|
|
75
|
+
font: 16px/1.6 Georgia, "Times New Roman", serif;
|
|
76
|
+
margin: 0;
|
|
77
|
+
padding: 40px;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
h1 {
|
|
81
|
+
font-size: 34px;
|
|
82
|
+
margin: 0 0 24px;
|
|
83
|
+
border-bottom: 3px solid var(--accent);
|
|
84
|
+
padding-bottom: 12px;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.grid {
|
|
88
|
+
display: grid;
|
|
89
|
+
grid-template-columns: repeat(3, 1fr);
|
|
90
|
+
gap: 16px;
|
|
91
|
+
margin: 24px 0;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.cell {
|
|
95
|
+
background: #fff;
|
|
96
|
+
border: 1px solid var(--edge);
|
|
97
|
+
padding: 20px;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
.cell b {
|
|
101
|
+
color: var(--accent);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.rule {
|
|
105
|
+
height: 8px;
|
|
106
|
+
background: var(--accent);
|
|
107
|
+
margin: 32px 0;
|
|
108
|
+
}
|
|
109
|
+
</style>
|
|
110
|
+
</head>
|
|
111
|
+
|
|
112
|
+
<body>
|
|
113
|
+
<h1>Duplicate stylesheet</h1>
|
|
114
|
+
<p>The two style elements above hold the same declarations. Everything on this page is drawn by
|
|
115
|
+
them, so a stylesheet that goes missing in the save takes the whole layout with it.</p>
|
|
116
|
+
<div class="grid">
|
|
117
|
+
<div class="cell"><b>One</b><br>Bordered cell drawn by the shared stylesheet.</div>
|
|
118
|
+
<div class="cell"><b>Two</b><br>Bordered cell drawn by the shared stylesheet.</div>
|
|
119
|
+
<div class="cell"><b>Three</b><br>Bordered cell drawn by the shared stylesheet.</div>
|
|
120
|
+
</div>
|
|
121
|
+
<div class="rule"></div>
|
|
122
|
+
<p>The grid, the rule above and the serif family all come from the custom properties declared in
|
|
123
|
+
the same block, so the comparison fails loudly rather than subtly.</p>
|
|
124
|
+
</body>
|
|
125
|
+
|
|
126
|
+
</html>
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
|
|
4
|
+
<head>
|
|
5
|
+
<meta charset="utf-8">
|
|
6
|
+
<title>Frame fonts</title>
|
|
7
|
+
<!--
|
|
8
|
+
The frame is sandboxed, which gives it an opaque origin and puts its contentDocument out of
|
|
9
|
+
reach. SingleFile then re-parses it from its srcdoc with DOMParser, and that document is
|
|
10
|
+
never rendered: it reports no font as used at all.
|
|
11
|
+
|
|
12
|
+
An empty list of used fonts is not a short one. Every rendered element has a computed
|
|
13
|
+
font-family, so nothing rendered can report none — an empty list means the styles could not
|
|
14
|
+
be read. Read as an answer instead of as an absence, it said "this frame uses no font" and
|
|
15
|
+
every face the frame declared was pruned. Measured on derstandard.at, where a newsletter box
|
|
16
|
+
inside such a frame fell back to a system font, and on MDN, where the text in the CSS demo
|
|
17
|
+
reflowed.
|
|
18
|
+
|
|
19
|
+
The frame declares its own face and uses it, and the face is not declared anywhere in the
|
|
20
|
+
parent: nothing outside the frame can keep it alive.
|
|
21
|
+
-->
|
|
22
|
+
<style>
|
|
23
|
+
body {
|
|
24
|
+
background: #fff;
|
|
25
|
+
margin: 0;
|
|
26
|
+
padding: 40px;
|
|
27
|
+
font: 20px/1.5 monospace;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
iframe {
|
|
31
|
+
width: 700px;
|
|
32
|
+
height: 220px;
|
|
33
|
+
border: 2px solid #333;
|
|
34
|
+
}
|
|
35
|
+
</style>
|
|
36
|
+
</head>
|
|
37
|
+
|
|
38
|
+
<body>
|
|
39
|
+
<p>The frame below declares and uses a face that the page around it never names.</p>
|
|
40
|
+
<iframe sandbox title="framed" srcdoc="
|
|
41
|
+
<!DOCTYPE html>
|
|
42
|
+
<meta charset="utf-8">
|
|
43
|
+
<style>
|
|
44
|
+
@font-face { font-family: "Fidelity Frame"; src: url(../fonts/bar.ttf) format("truetype") }
|
|
45
|
+
body { margin: 0; padding: 20px; background: #fff }
|
|
46
|
+
p { font: 48px "Fidelity Frame", serif; margin: 0 0 16px }
|
|
47
|
+
</style>
|
|
48
|
+
<p>abcdefghij</p>
|
|
49
|
+
<p>klmnopqrst</p>
|
|
50
|
+
"></iframe>
|
|
51
|
+
</body>
|
|
52
|
+
|
|
53
|
+
</html>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
|
|
4
|
+
<head>
|
|
5
|
+
<meta charset="utf-8">
|
|
6
|
+
<title>Linked stylesheet</title>
|
|
7
|
+
<!--
|
|
8
|
+
An external stylesheet, linked with the attributes a page uses to find it again: an id a
|
|
9
|
+
theme switcher looks up, a class a selector matches, a data attribute a script reads.
|
|
10
|
+
|
|
11
|
+
A plain save has nowhere to put an external stylesheet, so the link becomes a style element
|
|
12
|
+
holding its content — a brand-new element, built with the media and the text and nothing
|
|
13
|
+
else. Everything the page used to identify that stylesheet was dropped, on every plain save
|
|
14
|
+
of every page with an external stylesheet. The archive path had the same defect in the other
|
|
15
|
+
direction and was fixed first; this is its mirror.
|
|
16
|
+
-->
|
|
17
|
+
<link rel="stylesheet" type="text/css" href="theme.css" id="theme" class="site-theme" data-role="tokens" title="Site theme">
|
|
18
|
+
</head>
|
|
19
|
+
|
|
20
|
+
<body>
|
|
21
|
+
<h1>Linked stylesheet</h1>
|
|
22
|
+
<p>Everything below is drawn by the linked stylesheet, so losing it in the save is not subtle.</p>
|
|
23
|
+
<div class="panel"><b>Panel</b> — bordered by the linked stylesheet.</div>
|
|
24
|
+
<div class="panel"><b>Panel</b> — bordered by the linked stylesheet.</div>
|
|
25
|
+
<div class="stripe"></div>
|
|
26
|
+
<p>The element carrying that stylesheet is looked up by id, by class and by data attribute, none
|
|
27
|
+
of which survives a rewrite that keeps only the text.</p>
|
|
28
|
+
</body>
|
|
29
|
+
|
|
30
|
+
</html>
|