single-file-core 1.5.116 → 1.5.117

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/core/helper.js CHANGED
@@ -408,10 +408,10 @@ function getElementsInfo(win, doc, element, options, data = { usedFonts: new Map
408
408
  }
409
409
  }
410
410
  if (options.removeUnusedFonts) {
411
- getUsedFont(computedStyle, options, data.usedFonts);
412
- getUsedFont(getComputedStyle(win, element, ":first-letter"), options, data.usedFonts);
413
- getUsedFont(getComputedStyle(win, element, ":before"), options, data.usedFonts);
414
- getUsedFont(getComputedStyle(win, element, ":after"), options, data.usedFonts);
411
+ getUsedFont(computedStyle, data.usedFonts);
412
+ getUsedFont(getComputedStyle(win, element, ":first-letter"), data.usedFonts);
413
+ getUsedFont(getComputedStyle(win, element, ":before"), data.usedFonts);
414
+ getUsedFont(getComputedStyle(win, element, ":after"), data.usedFonts);
415
415
  }
416
416
  }
417
417
  }
@@ -611,12 +611,28 @@ function getResourcesInfo(win, doc, element, options, data, elementHidden, compu
611
611
  }
612
612
  }
613
613
 
614
- function getUsedFont(computedStyle, options, usedFonts) {
614
+ // Every family in the computed font-family is recorded, and the set of faces the document actually
615
+ // resolved is deliberately NOT consulted. Narrowing the list to the loaded faces is the obvious
616
+ // idea — a fallback stack names every family the page might need and only some are used — and it
617
+ // was written in 2019, wired to an option nothing ever set, and dead until it was reconnected here.
618
+ // Reconnected, it saved nothing measurable on nine real pages and cost fidelity twice: a face
619
+ // matched on its declared style is not found when the browser SYNTHESIZED that style, so a family
620
+ // drawn in an italic it declares no face for was dropped from the page that draws it; and a webfont
621
+ // that merely failed to load on the capturing machine would be dropped from the archive for good.
622
+ // It belongs with the unicode-range work, which needs the same information and can weigh a measured
623
+ // benefit against those, not here.
624
+ function getUsedFont(computedStyle, usedFonts) {
615
625
  if (computedStyle) {
616
626
  const fontStyle = computedStyle.getPropertyValue("font-style") || "normal";
617
627
  computedStyle.getPropertyValue("font-family").split(",").forEach(fontFamilyName => {
618
628
  fontFamilyName = normalizeFontFamily(fontFamilyName);
619
- if (!options.loadedFonts || options.loadedFonts.find(font => normalizeFontFamily(font.family) == fontFamilyName && font.style == fontStyle)) {
629
+ // an element with no computed font-family at all is one whose styles could not be read,
630
+ // not one drawn in a family with no name. It happens to every element of a frame that
631
+ // was re-parsed from its srcdoc, because the computed style is asked of the parent
632
+ // window and the document it belongs to is not the one being rendered. Recorded, it
633
+ // turns "nothing is known here" into a list of length one, which reads downstream as a
634
+ // complete answer and prunes every face the frame declares
635
+ if (fontFamilyName) {
620
636
  const fontWeight = getFontWeight(computedStyle.getPropertyValue("font-weight"));
621
637
  const fontVariant = computedStyle.getPropertyValue("font-variant") || "normal";
622
638
  const value = [fontFamilyName, fontWeight, fontStyle, fontVariant];
@@ -44,6 +44,10 @@ const EMPTY_URL_SOURCE = /^url\(["']?data:[^,]*,?["']?\)/;
44
44
  const LOCAL_SOURCE = "local(";
45
45
  const FONT_MAX_LOAD_DELAY = 5000;
46
46
  const DUPLICATE_STYLESHEET_ATTRIBUTE_NAME = "data-sf-duplicate-stylesheet-ref";
47
+ // the attributes that say how the link fetched its stylesheet, which the style element now holding
48
+ // that stylesheet has no use for. Everything else identified the element in the page
49
+ const LINK_FETCH_ATTRIBUTE_NAMES = ["rel", "href", "type", "media", "as", "crossorigin", "integrity",
50
+ "referrerpolicy", "hreflang", "sizes", "imagesrcset", "imagesizes", "fetchpriority"];
47
51
 
48
52
  let util;
49
53
 
@@ -162,6 +166,15 @@ function getProcessorHelperClass(utilInstance) {
162
166
  if (stylesheetInfo) {
163
167
  stylesheets.delete(linkElement);
164
168
  const styleElement = doc.createElement("style");
169
+ // the element is replaced rather than rewritten, so whatever identified it in
170
+ // the page has to be carried over: an id a script still looks up, a class a
171
+ // selector still matches. The attributes left out are the ones that describe
172
+ // the fetch the style element no longer performs
173
+ Array.from(linkElement.attributes).forEach(({ name, value }) => {
174
+ if (!LINK_FETCH_ATTRIBUTE_NAMES.includes(name.toLowerCase())) {
175
+ styleElement.setAttribute(name, value);
176
+ }
177
+ });
165
178
  if (stylesheetInfo.mediaText) {
166
179
  styleElement.media = stylesheetInfo.mediaText;
167
180
  }
@@ -36,6 +36,7 @@ const CANVAS_TAG_FOUND = /<canvas/gi;
36
36
  const EMPTY_URL_SOURCE = /^url\(["']?data:[^,]*,?["']?\)/;
37
37
  const LOCAL_SOURCE = "local(";
38
38
  const FONT_MAX_LOAD_DELAY = 5000;
39
+ const LINK_OWN_ATTRIBUTE_NAMES = ["rel", "type", "href", "media"];
39
40
 
40
41
  let util;
41
42
 
@@ -100,6 +101,7 @@ function getProcessorHelperClass(utilInstance) {
100
101
  replaceStylesheets(doc, stylesheets, options, resources) {
101
102
  const entries = Array.from(stylesheets).reverse();
102
103
  const linkElements = new Map();
104
+ const sharedStyleElements = new Map();
103
105
  Array.from(new Set(options.inlineStylesheetsRefs.values())).forEach(stylesheetRefIndex => {
104
106
  const linkElement = doc.createElement("link");
105
107
  linkElement.setAttribute("rel", "stylesheet");
@@ -117,6 +119,11 @@ function getProcessorHelperClass(utilInstance) {
117
119
  : cssTree.parse(content, { context: "stylesheet", parseCustomProperty: true });
118
120
  resources.stylesheets.set(resources.stylesheets.size, { name, content: this.generateStylesheetContent(stylesheet, options) });
119
121
  linkElements.set(stylesheetRefIndex, linkElement);
122
+ // the element the duplicates were folded into is not itself a duplicate, so it is
123
+ // absent from inlineStylesheetsRefs and would keep its content inline: the archive
124
+ // would then hold the same stylesheet twice, once as the entry the links point at
125
+ // and once in the page
126
+ sharedStyleElements.set(styleElement, stylesheetRefIndex);
120
127
  });
121
128
  for (const [key, stylesheetInfo] of entries) {
122
129
  if (key.urlNode) {
@@ -135,11 +142,22 @@ function getProcessorHelperClass(utilInstance) {
135
142
  resources.stylesheets.set(resources.stylesheets.size, { name, stylesheet: stylesheetInfo.stylesheet, url: stylesheetInfo.url });
136
143
  } else {
137
144
  const styleElement = key.element;
138
- const stylesheetRefIndex = options.inlineStylesheetsRefs.get(styleElement);
145
+ const stylesheetRefIndex = options.inlineStylesheetsRefs.has(styleElement)
146
+ ? options.inlineStylesheetsRefs.get(styleElement)
147
+ : sharedStyleElements.get(styleElement);
139
148
  if (stylesheetRefIndex === undefined) {
140
149
  styleElement.textContent = this.generateStylesheetContent(stylesheetInfo.stylesheet, options);
141
150
  } else {
142
151
  const linkElement = linkElements.get(stylesheetRefIndex).cloneNode(true);
152
+ // the element is replaced rather than rewritten, so whatever identified it in
153
+ // the page has to be carried over: an id a script still looks up, a class a
154
+ // selector still matches. The attributes left out are the ones that describe
155
+ // the link itself, which the code around here sets
156
+ Array.from(styleElement.attributes).forEach(({ name, value }) => {
157
+ if (!LINK_OWN_ATTRIBUTE_NAMES.includes(name.toLowerCase())) {
158
+ linkElement.setAttribute(name, value);
159
+ }
160
+ });
143
161
  if (stylesheetInfo.mediaText) {
144
162
  linkElement.media = stylesheetInfo.mediaText;
145
163
  }
@@ -92,19 +92,33 @@ function process(doc, stylesheets, styles, options) {
92
92
  fontsInfo.used = fontsInfo.used.map(fontNames => fontNames.map(familyName => resolveFamilyName(familyName, options)));
93
93
  fontsInfo.used = fontsInfo.used.map(fontNames => helper.flatten(fontNames));
94
94
  const variableFound = fontsInfo.used.find(fontNames => fontNames.find(fontName => fontName.match(/^var\(--/)));
95
+ // an empty list of rendered fonts does not mean the document uses none: every rendered element
96
+ // has a computed font-family, so an empty list means the computed styles could not be read at
97
+ // all. A frame whose contentDocument is unreachable is re-parsed from its srcdoc with
98
+ // DOMParser, and that document is never rendered, so it reports nothing and every face it
99
+ // declares would be dropped
100
+ const usedFontsUnknown = !options.usedFonts || !options.usedFonts.length;
95
101
  let unusedFonts, filteredUsedFonts;
96
- if (variableFound) {
102
+ if (usedFontsUnknown) {
97
103
  unusedFonts = [];
98
104
  } else {
99
105
  filteredUsedFonts = new Map();
100
- fontsInfo.used.forEach(fontNames => fontNames.forEach(familyName => {
101
- if (fontsInfo.declared.find(fontInfo => fontInfo.fontFamily == familyName)) {
102
- const optionalData = options.usedFonts && options.usedFonts.filter(fontInfo => fontInfo[0] == familyName);
103
- if (optionalData && optionalData.length) {
104
- filteredUsedFonts.set(familyName, optionalData);
105
- }
106
- }
107
- }));
106
+ fontsInfo.used.forEach(fontNames => fontNames.forEach(familyName =>
107
+ keepDeclaredFontIfRendered(familyName, fontsInfo, filteredUsedFonts, options)));
108
+ // A family named through a value that could not be resolved cannot be looked up in the
109
+ // stylesheets but the browser resolved it when it drew the page, so whatever it named is
110
+ // in the list of fonts actually rendered, and that list answers for it.
111
+ //
112
+ // Giving up on the whole document instead, which is what an unresolved name used to do,
113
+ // kept every declared face on any page holding one unreadable value anywhere while every
114
+ // other page pruned as usual. That was not a policy about uncertainty: a page that names
115
+ // its families plainly has always dropped a face it had not drawn yet, and only a page
116
+ // whose value happened not to parse was spared. The difference came from a parse failure,
117
+ // so it is the rendered list that decides in both cases now.
118
+ if (variableFound) {
119
+ fontsInfo.declared.forEach(fontInfo =>
120
+ keepDeclaredFontIfRendered(fontInfo.fontFamily, fontsInfo, filteredUsedFonts, options));
121
+ }
108
122
  unusedFonts = fontsInfo.declared.filter(fontInfo => !filteredUsedFonts.has(fontInfo.fontFamily));
109
123
  }
110
124
  const docChars = Array.from(new Set(docContent)).map(char => char.charCodeAt(0)).sort((value1, value2) => value1 - value2);
@@ -120,6 +134,17 @@ function process(doc, stylesheets, styles, options) {
120
134
  return stats;
121
135
  }
122
136
 
137
+ // a face is kept when the page declares it and the browser reports having drawn with it: the
138
+ // stylesheets say what exists, the rendered list says what was needed
139
+ function keepDeclaredFontIfRendered(familyName, fontsInfo, filteredUsedFonts, options) {
140
+ if (fontsInfo.declared.find(fontInfo => fontInfo.fontFamily == familyName)) {
141
+ const optionalData = options.usedFonts.filter(fontInfo => fontInfo[0] == familyName);
142
+ if (optionalData.length) {
143
+ filteredUsedFonts.set(familyName, optionalData);
144
+ }
145
+ }
146
+ }
147
+
123
148
  function getFontsInfo(cssRules, fontsInfo, options) {
124
149
  cssRules.forEach(ruleData => {
125
150
  if (ruleData.type == "Atrule" && (ruleData.name == "media" || ruleData.name == "supports" || ruleData.name == "layer" || ruleData.name == "container") && ruleData.block && ruleData.block.children) {
@@ -200,25 +225,68 @@ function getCustomPropertyValues(name, options) {
200
225
  return values;
201
226
  }
202
227
 
203
- function resolveFamilyName(familyName, options) {
228
+ function resolveFamilyName(familyName, options, resolvedProperties = new Set()) {
204
229
  const matchedVar = familyName.match(REGEXP_CUSTOM_PROPERTY_FAMILY);
205
230
  if (matchedVar) {
231
+ const propertyName = matchedVar[1];
206
232
  const fallback = matchedVar[2];
207
- // a var() nested in the fallback cannot be split on its commas, so the family is left
208
- // undetermined rather than read as a list of broken names
209
- if (!fallback || !fallback.includes("var(")) {
210
- const values = getCustomPropertyValues(matchedVar[1], options);
233
+ // a property naming itself, directly or through another one, would resolve for ever: the
234
+ // chain already walked is carried down the branch so it stops instead
235
+ if (!resolvedProperties.has(propertyName)) {
236
+ const properties = new Set(resolvedProperties);
237
+ properties.add(propertyName);
238
+ const values = getCustomPropertyValues(propertyName, options);
211
239
  if (values) {
212
- const families = helper.flatten(values.map(value => splitFamilyNames(value)));
213
- return fallback ? families.concat(splitFamilyNames(fallback)) : families;
240
+ const families = helper.flatten(values.map(value => splitFamilyNames(value, options, properties)));
241
+ const fallbackFamilies = fallback ? splitFamilyNames(fallback, options, properties) : [];
242
+ // the browser takes the property or the fallback, so knowing one branch is not
243
+ // knowing the value: a var() left unresolved in either one keeps the family
244
+ // undetermined, exactly as it was before the nested one could be read at all
245
+ if (!families.concat(fallbackFamilies).some(testUnresolvedFamilyName)) {
246
+ return families.concat(fallbackFamilies);
247
+ }
214
248
  }
215
249
  }
216
250
  }
217
251
  return familyName;
218
252
  }
219
253
 
220
- function splitFamilyNames(value) {
221
- return value.split(",").map(familyName => normalizeFamilyName(familyName)).filter(familyName => familyName);
254
+ function testUnresolvedFamilyName(familyName) {
255
+ return typeof familyName == "string" && familyName.startsWith("var(");
256
+ }
257
+
258
+ function splitFamilyNames(value, options, resolvedProperties) {
259
+ const familyNames = splitValues(value).map(familyName => normalizeFamilyName(familyName)).filter(familyName => familyName);
260
+ return options
261
+ ? helper.flatten(familyNames.map(familyName => resolveFamilyName(familyName, options, resolvedProperties)))
262
+ : familyNames;
263
+ }
264
+
265
+ // a family list is split on its top-level commas only: the commas inside a var() belong to that
266
+ // var(), and the ones inside a quoted name belong to the name. Splitting on every comma is what
267
+ // made a var() nested in a fallback unreadable, and it also broke a family named "Foo, Bar"
268
+ function splitValues(value) {
269
+ const values = [];
270
+ let depth = 0, quote, start = 0;
271
+ for (let index = 0; index < value.length; index++) {
272
+ const character = value.charAt(index);
273
+ if (quote) {
274
+ if (character == quote && value.charAt(index - 1) != "\\") {
275
+ quote = null;
276
+ }
277
+ } else if (character == "\"" || character == "'") {
278
+ quote = character;
279
+ } else if (character == "(") {
280
+ depth++;
281
+ } else if (character == ")") {
282
+ depth--;
283
+ } else if (character == "," && !depth) {
284
+ values.push(value.substring(start, index));
285
+ start = index + 1;
286
+ }
287
+ }
288
+ values.push(value.substring(start));
289
+ return values;
222
290
  }
223
291
 
224
292
  function filterUnusedFonts(cssRules, declaredFonts, unusedFonts, filteredUsedFonts, docChars) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "single-file-core",
3
- "version": "1.5.116",
3
+ "version": "1.5.117",
4
4
  "description": "SingleFile Core",
5
5
  "author": "Gildas Lormeau",
6
6
  "license": "AGPL-3.0-or-later",
@@ -31,7 +31,8 @@ import {
31
31
  ZipReader
32
32
  } from "./../../vendor/zip/zip.js";
33
33
  import {
34
- createArchive
34
+ createArchive,
35
+ escapeHTML
35
36
  } from "./compression.js";
36
37
 
37
38
  const browser = globalThis.browser;
@@ -232,13 +233,3 @@ function getTOCContent(pages) {
232
233
  pages.map(page => "<li><a href=\"" + escapeHTML(page.url) + "\">" + escapeHTML(page.title || page.url) + "</a></li>").join("") +
233
234
  "</ul></nav>";
234
235
  }
235
-
236
- // the prelude declares the windows-1252 charset, non-ASCII characters must be
237
- // encoded as HTML entities to survive it
238
- function escapeHTML(value) {
239
- return Array.from(value).map(character => {
240
- const codePoint = character.codePointAt(0);
241
- return codePoint < 32 || codePoint > 126 || character == "&" || character == "<" || character == ">" || character == "\"" ?
242
- "&#" + codePoint + ";" : character;
243
- }).join("");
244
- }
@@ -151,15 +151,27 @@ const PROCESS_OPTION_NAMES = [
151
151
  export {
152
152
  process,
153
153
  createArchive,
154
+ escapeHTML,
154
155
  PROCESS_OPTION_NAMES
155
156
  };
156
157
 
157
158
  async function process(pageData, options, lastModDate = new Date()) {
158
159
  let script;
160
+ // The worker is configured before anything else, and outside the extension it is turned off
161
+ // rather than left alone. Given no address, zip.js resolves its default one against the page
162
+ // being saved, so the browser asks the CAPTURED SITE for a file that site has never heard of:
163
+ // three 404s in the user's own server logs for every archive, and then a fallback to the main
164
+ // thread anyway, which is where the work was always going to happen. Choosing the fallback
165
+ // costs nothing that was ever gained and asks the site for nothing.
166
+ const extensionContext = Boolean(browser && browser.runtime && browser.runtime.getURL);
167
+ if (extensionContext) {
168
+ configure({ workerURI: "/lib/single-file-z-worker.js" });
169
+ } else {
170
+ configure({ useWebWorkers: false });
171
+ }
159
172
  if (options.zipScript) {
160
173
  script = options.zipScript;
161
- } else if (browser && browser.runtime && browser.runtime.getURL) {
162
- configure({ workerURI: "/lib/single-file-z-worker.js" });
174
+ } else if (extensionContext) {
163
175
  script = await (await fetch(browser.runtime.getURL(SCRIPT_PATH))).text();
164
176
  }
165
177
  return createArchive(pageData, options, script, zipWriter => {
@@ -622,7 +634,7 @@ function getHTMLHeadData(pageData, options) {
622
634
  let pageContent = "";
623
635
  // the title is left out of a password-protected archive: manifest.json carries it too and
624
636
  // is encrypted, so emitting it here would publish what the password is meant to cover
625
- const title = options.password ? "" : getPageTitle(pageData);
637
+ const title = options.password ? "" : escapeHTML(pageData.title || "");
626
638
  pageContent += "<title>" + title + "</title>";
627
639
  // the canonical link publishes the URL the archive was saved from, for the same reason
628
640
  // the title above is left out of a password-protected archive
@@ -644,12 +656,13 @@ function getHTMLHeadData(pageData, options) {
644
656
  return pageContent;
645
657
  }
646
658
 
647
- function getPageTitle(pageData) {
648
- // numeric character references are ASCII bytes, so the title survives the single-byte
649
- // charset universal mode declares, and any parser decodes them back to the original text
650
- return Array.from(pageData.title || "").map(character => {
659
+ // every piece of text in the prelude goes through this, wherever it is assembled: the prelude
660
+ // declares a single-byte charset, and numeric character references are ASCII bytes, so they
661
+ // survive it and any parser decodes them back to the original text
662
+ function escapeHTML(value) {
663
+ return Array.from(value).map(character => {
651
664
  const codePoint = character.codePointAt(0);
652
- return codePoint < 32 || codePoint > 126 || character == "&" || character == "<" || character == ">" ?
665
+ return codePoint < 32 || codePoint > 126 || character == "&" || character == "<" || character == ">" || character == "\"" ?
653
666
  "&#" + codePoint + ";" : character;
654
667
  }).join("");
655
668
  }
@@ -11,6 +11,13 @@
11
11
  // so it can only keep too much, never too little — and options.usedFonts, which comes from the
12
12
  // rendered computed styles, gates the result anyway.
13
13
  //
14
+ // What happens when even the union cannot answer changed with it. Switching pruning off for the
15
+ // whole document was never a policy about uncertainty — a page naming its families plainly has
16
+ // always dropped a face it had not drawn yet, and only a page holding one unreadable value anywhere
17
+ // was spared, all of it. So the rendered list decides in that case too: a family the browser
18
+ // resolved when it drew the page is in that list whatever the stylesheets can be made to say. The
19
+ // one case that still keeps everything is the one where the rendering itself is missing.
20
+ //
14
21
  // The other var() defects this pins, all found alongside it:
15
22
  // - custom property names are case-sensitive; they were lowercased with the family names, so
16
23
  // var(--ProbeFont) never resolved, not even from :root
@@ -45,6 +52,11 @@ const USED_FONTS = [
45
52
 
46
53
  const ALL_FAMILIES = ["usedone", "usedtwo", "usedthree", "unused"];
47
54
 
55
+ // what survives when the stylesheets cannot say which family a value names: the faces the page
56
+ // declares AND the rendering reports having drawn with. It is USED_FONTS that decides there, so a
57
+ // face nothing drew is dropped even though nothing could be shown to name it either
58
+ const RENDERED_FAMILIES = ["usedone", "usedtwo", "usedthree"];
59
+
48
60
  let failures = 0;
49
61
 
50
62
  check("a plain family name prunes the rest",
@@ -83,12 +95,18 @@ check("the font shorthand resolves a property written with a fallback",
83
95
  run({ rules: ".card{--probe-font:\"UsedOne\"}.card p{font:italic 1em var(--probe-font,serif)}" }),
84
96
  ["usedone"]);
85
97
 
86
- // the conservative half of the contract: when the value genuinely cannot be determined, every
87
- // declared font stays, including the one no rule names. These are the cases the union does not
88
- // cover, and getting them wrong loses fonts from the saved page rather than merely wasting bytes
89
- check("a property declared nowhere keeps every font",
98
+ // The other half of the contract: what happens when the value genuinely cannot be determined. The
99
+ // stylesheets no longer decide there the rendering does. The browser resolved the value when it
100
+ // drew the page, so whatever the property named is in the list of fonts reported as used, and a
101
+ // face absent from that list was drawn by nothing.
102
+ //
103
+ // It used to keep EVERY declared face, and not as a decision about uncertainty: a page naming its
104
+ // families plainly has always dropped a face it had not drawn yet, and only a page holding one
105
+ // value that happened not to parse was spared — the whole document, over one unreadable name. The
106
+ // case these checks still guard is the one below them, where the rendering itself is unavailable.
107
+ check("a property declared nowhere falls back to the fonts that were drawn",
90
108
  run({ rules: ".card p{font-family:var(--set-by-script),serif}" }),
91
- ALL_FAMILIES);
109
+ RENDERED_FAMILIES);
92
110
 
93
111
  // the shorthand cannot be substituted with several candidate values, but its var() sits in family
94
112
  // position, so the parser hands it back as the family and the union answers it there instead
@@ -97,18 +115,51 @@ check("a font shorthand with several candidates resolves through the family",
97
115
  ["usedone", "usedtwo"]);
98
116
 
99
117
  // a property holding the whole shorthand is the case the union must NOT touch: its values are not
100
- // family lists, and reading them as such would drop every font in the document
101
- check("a property holding a whole shorthand keeps every font",
118
+ // family lists, and reading them as such would name families the document never had
119
+ check("a property holding a whole shorthand falls back to the fonts that were drawn",
102
120
  run({ rules: ".card{--font:italic 1em \"UsedOne\"}.note{--font:italic 1em \"UsedTwo\"}p{font:var(--font)}" }),
103
- ALL_FAMILIES);
121
+ RENDERED_FAMILIES);
104
122
 
105
- check("a var() nested in a fallback keeps every font",
123
+ check("a var() nested in a fallback falls back to the fonts that were drawn",
106
124
  run({ rules: ".card{--probe-font:\"UsedOne\"}.card p{font-family:var(--other,var(--probe-font),serif)}" }),
125
+ RENDERED_FAMILIES);
126
+
127
+ // the check above leaves the family undetermined because the OUTER property is declared nowhere.
128
+ // A var() written in the font-family itself is split by the AST walk, which hands each branch over
129
+ // separately, so nesting alone was never the problem there. The chain that did give up is a var()
130
+ // inside a property VALUE: it was read one level deep and whatever it named stayed unresolved,
131
+ // which switched pruning off for the whole document
132
+ check("a property whose value is another declared property resolves through it",
133
+ run({ rules: ".card{--first-font:var(--second-font)}.note{--second-font:\"UsedOne\"}.card p{font-family:var(--first-font),serif}" }),
134
+ ["usedone"]);
135
+
136
+ // splitting that value on every comma cut this one into "var(--second-font" and "\"UsedTwo\")",
137
+ // two names that resolve to nothing, and the document went undetermined over a value it holds in full
138
+ check("a value holding a var() with its own fallback is split on the top-level comma",
139
+ run({ rules: ".card{--first-font:var(--second-font,\"UsedTwo\"),serif}.note{--second-font:\"UsedOne\"}.card p{font-family:var(--first-font)}" }),
140
+ ["usedone", "usedtwo"]);
141
+
142
+ // two properties naming each other resolve for ever without the guard: this check hangs rather
143
+ // than fails when it regresses
144
+ check("a property naming itself through another one falls back to the fonts that were drawn",
145
+ run({ rules: ".card{--first-font:var(--second-font)}.note{--second-font:var(--first-font)}.card p{font-family:var(--first-font),serif}" }),
146
+ RENDERED_FAMILIES);
147
+
148
+ // The rendered-fonts list is what says a declared face is really drawn, and an EMPTY one is not
149
+ // the same answer as a short one: every rendered element has a computed font-family, so an empty
150
+ // list means the computed styles could not be read at all. It happens for real. A frame whose
151
+ // contentDocument is unreachable is re-parsed from its srcdoc with DOMParser
152
+ // (processors/frame-tree/content/content-frame-tree.js), and that document is never rendered, so
153
+ // it reports no font and the frame lost EVERY face it declared — measured on derstandard.at,
154
+ // where the newsletter box inside such a frame fell back to a system font, and on MDN, where the
155
+ // text in the CSS-demo frame reflowed. The families are named right there in the frame's own CSS.
156
+ check("a document that reports no rendered font keeps every font",
157
+ run({ rules: ".card p{font-family:\"UsedOne\",serif}", usedFonts: [] }),
107
158
  ALL_FAMILIES);
108
159
 
109
- check("a property whose value is another undetermined property keeps every font",
160
+ check("a property whose value is another undetermined property falls back to the fonts that were drawn",
110
161
  run({ rules: ".card{--probe-font:var(--set-by-script)}.card p{font-family:var(--probe-font),serif}" }),
111
- ALL_FAMILIES);
162
+ RENDERED_FAMILIES);
112
163
 
113
164
  // An unquoted family name is a sequence of identifier tokens, and the walk that joins them has to
114
165
  // resume after the LAST of them. Resuming after the first pushed every word but that one again as a
@@ -2,7 +2,9 @@ import "./dom-stub.js";
2
2
  import { makePageData, makeOptions, runProcess, mulberry32 } from "./common.js";
3
3
  import { ZipReader, BlobReader } from "../../vendor/zip/zip.js";
4
4
 
5
- const TITLE = "日本語 café & <b>";
5
+ // the quote is there because the escaper the title shares with the table of contents encodes
6
+ // it for an attribute value, where it matters, and a title has to round-trip through that too
7
+ const TITLE = "日本語 — café & <b> \"quoted\"";
6
8
  const PDF = new TextEncoder().encode("%PDF-1.4\n1 0 obj\n<<>>\nendobj\ntrailer\n<<>>\n%%EOF\n");
7
9
 
8
10
  let failed = false;