single-file-core 1.5.115 → 1.5.116

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
@@ -496,17 +496,22 @@ function getStylesheetsContent(styleSheets, adoptedStyleSheetsCache = new Map())
496
496
  function getResourcesInfo(win, doc, element, options, data, elementHidden, computedStyle) {
497
497
  const tagName = element.tagName && element.tagName.toUpperCase();
498
498
  if (tagName == "CANVAS") {
499
+ const canvasComputedStyle = computedStyle || getComputedStyle(win, element);
500
+ const canvasData = {
501
+ backgroundColor: canvasComputedStyle && canvasComputedStyle.getPropertyValue("background-color")
502
+ };
499
503
  try {
500
- data.canvases.push({
501
- dataURI: element.toDataURL("image/png"),
502
- backgroundColor: computedStyle.getPropertyValue("background-color")
503
- });
504
- element.setAttribute(CANVAS_ATTRIBUTE_NAME, data.canvases.length - 1);
505
- data.markedElements.push(element);
504
+ canvasData.dataURI = element.toDataURL("image/png");
506
505
  // eslint-disable-next-line no-unused-vars
507
506
  } catch (error) {
508
- // ignored
507
+ // a canvas painted with a cross-origin resource is tainted and toDataURL throws for the
508
+ // whole element, its own drawing included. The entry is pushed and the element marked
509
+ // anyway: the loss is then counted and reported instead of being silent, and an
510
+ // out-of-page fallback re-rendering the element has something to find it by
509
511
  }
512
+ data.canvases.push(canvasData);
513
+ element.setAttribute(CANVAS_ATTRIBUTE_NAME, data.canvases.length - 1);
514
+ data.markedElements.push(element);
510
515
  }
511
516
  if (tagName == "IMG") {
512
517
  const imageData = {
package/core/index.js CHANGED
@@ -422,6 +422,7 @@ const SHADOWROOT_CLONABLE = "shadowrootclonable";
422
422
  const SHADOWROOT_SERIALIZABLE = "shadowrootserializable";
423
423
  const SCRIPT_OPTIONS = "data-single-file-options";
424
424
  const UTF8_CHARSET = "utf-8";
425
+ const TAINTED_CANVAS_WARNING_MESSAGE = "SingleFile: canvas elements tainted by a cross-origin resource, dropped from the page:";
425
426
 
426
427
  class Processor {
427
428
  constructor(options, processorHelper, batchRequest) {
@@ -1055,20 +1056,29 @@ class Processor {
1055
1056
 
1056
1057
  replaceCanvasElements() {
1057
1058
  if (this.options.canvases) {
1059
+ let discardedCount = 0;
1058
1060
  this.doc.querySelectorAll("canvas").forEach(canvasElement => {
1059
1061
  const attributeValue = canvasElement.getAttribute(util.CANVAS_ATTRIBUTE_NAME);
1060
1062
  if (attributeValue) {
1061
1063
  const canvasData = this.options.canvases[Number(attributeValue)];
1062
1064
  if (canvasData) {
1063
- const backgroundStyle = {};
1064
- if (canvasData.backgroundColor) {
1065
- backgroundStyle["background-color"] = canvasData.backgroundColor;
1065
+ if (canvasData.dataURI) {
1066
+ const backgroundStyle = {};
1067
+ if (canvasData.backgroundColor) {
1068
+ backgroundStyle["background-color"] = canvasData.backgroundColor;
1069
+ }
1070
+ this.processorHelper.setBackgroundImage(canvasElement, "url(" + canvasData.dataURI + ")", backgroundStyle);
1071
+ this.stats.add("processed", "canvas", 1);
1072
+ } else {
1073
+ discardedCount++;
1074
+ this.stats.add("discarded", "canvas", 1);
1066
1075
  }
1067
- this.processorHelper.setBackgroundImage(canvasElement, "url(" + canvasData.dataURI + ")", backgroundStyle);
1068
- this.stats.add("processed", "canvas", 1);
1069
1076
  }
1070
1077
  }
1071
1078
  });
1079
+ if (discardedCount) {
1080
+ warn(TAINTED_CANVAS_WARNING_MESSAGE, discardedCount);
1081
+ }
1072
1082
  }
1073
1083
  }
1074
1084
 
@@ -1726,6 +1736,10 @@ function log(...args) {
1726
1736
  console.log("S-File <core> ", ...args); // eslint-disable-line no-console
1727
1737
  }
1728
1738
 
1739
+ function warn(...args) {
1740
+ console.warn("S-File <core> ", ...args); // eslint-disable-line no-console
1741
+ }
1742
+
1729
1743
  // -----
1730
1744
  // Stats
1731
1745
  // -----
@@ -102,9 +102,10 @@ class ProcessorHelperCommon {
102
102
  ["image, feImage", "xlink:href"],
103
103
  ["image, feImage", "href"]
104
104
  ];
105
- if (options.blockImages) {
106
- doc.querySelectorAll("svg").forEach(element => element.remove());
107
- }
105
+ // an inline svg is document content, not a fetched resource: blocking images empties the
106
+ // references it makes outwards (image, feImage and use are all processed below) and leaves
107
+ // the markup itself alone. Removing it deleted JS-drawn charts, inline icons, and the
108
+ // <defs> holding gradients and filters that CSS applies to ordinary HTML elements
108
109
  let resourcePromises = processAttributeArgs.map(([selector, attributeName, removeElementIfMissing, processDuplicates]) =>
109
110
  this.processAttribute(doc, doc.querySelectorAll(selector), attributeName, baseURI, options, "image", resources, removeElementIfMissing, batchRequest, styles, processDuplicates)
110
111
  );
@@ -136,9 +137,12 @@ class ProcessorHelperCommon {
136
137
  resourceElement.setAttribute("data-sf-original-href", originalResourceURL);
137
138
  }
138
139
  let resourceURL = normalizeURL(originalResourceURL);
139
- if (!options.blockImages) {
140
- if (testValidPath(resourceURL) && !testIgnoredPath(resourceURL)) {
141
- resourceElement.setAttribute(attributeName, util.EMPTY_RESOURCE);
140
+ // a reference into the page itself costs no request, so blocking images must not empty
141
+ // it: normalizeURL drops the fragment, which leaves a bare "#symbol" as the empty path
142
+ // below and a same-document URL matching options.url
143
+ if (testValidPath(resourceURL) && !testIgnoredPath(resourceURL)) {
144
+ resourceElement.setAttribute(attributeName, util.EMPTY_RESOURCE);
145
+ if (!options.blockImages) {
142
146
  try {
143
147
  resourceURL = util.resolveURL(resourceURL, baseURI);
144
148
  // eslint-disable-next-line no-unused-vars
@@ -172,11 +176,9 @@ class ProcessorHelperCommon {
172
176
  }
173
177
  }
174
178
  }
175
- } else if (resourceURL == options.url) {
176
- resourceElement.setAttribute(attributeName, originalResourceURL.substring(resourceURL.length));
177
179
  }
178
- } else {
179
- resourceElement.setAttribute(attributeName, util.EMPTY_RESOURCE);
180
+ } else if (resourceURL == options.url) {
181
+ resourceElement.setAttribute(attributeName, originalResourceURL.substring(resourceURL.length));
180
182
  }
181
183
  }));
182
184
  }
@@ -255,6 +257,17 @@ class ProcessorHelperCommon {
255
257
  }));
256
258
  }
257
259
 
260
+ // a video or an audio element gets the attribute REMOVED rather than emptied: util.EMPTY_RESOURCE
261
+ // is inert on an image but not on a media element, which would attempt the load, fail, and sit
262
+ // in an error state where removing the attribute leaves the poster showing cleanly
263
+ setAttributeEmpty(resourceElement, attributeName, expectedType) {
264
+ if (expectedType == "video" || expectedType == "audio") {
265
+ resourceElement.removeAttribute(attributeName);
266
+ } else {
267
+ resourceElement.setAttribute(attributeName, util.EMPTY_RESOURCE);
268
+ }
269
+ }
270
+
258
271
  setBackgroundImage(element, url, style) {
259
272
  element.style.setProperty("background-blend-mode", "normal", "important");
260
273
  element.style.setProperty("background-clip", "content-box", "important");
@@ -382,12 +395,7 @@ class ProcessorHelperCommon {
382
395
  }
383
396
 
384
397
  async removeAlternativeFonts(doc, stylesheets, fonts, fontTests) {
385
- const fontsDetails = {
386
- fonts: new Map(),
387
- medias: new Map(),
388
- supports: new Map(),
389
- layers: new Map()
390
- };
398
+ const fontsDetails = this.createFontsDetailsInfo();
391
399
  const stats = { rules: { processed: 0, discarded: 0 }, fonts: { processed: 0, discarded: 0 } };
392
400
  let sheetIndex = 0;
393
401
  stylesheets.forEach(stylesheetInfo => {
@@ -446,7 +454,18 @@ class ProcessorHelperCommon {
446
454
  const key = this.getFontKey(ruleData);
447
455
  const fontInfo = fontsDetails.fonts.get(key);
448
456
  if (fontInfo) {
449
- await this.processFontFaceRule(ruleData, fontInfo, fonts, fontTests, stats);
457
+ // a face declaring the same descriptors and the same sources as one already kept
458
+ // cannot change what that one did, and its sources are embedded a second time. A
459
+ // stylesheet reached from two <link> elements, or imported by two sheets, repeats
460
+ // every face it carries. The test is made before the await: the sources are read
461
+ // as they stand for both rules, and processing them is what would interleave
462
+ const ruleKey = key + " " + this.getPropertyValue(ruleData, "src");
463
+ if (fontsDetails.emittedFonts.has(ruleKey)) {
464
+ removedRules.push(cssRule);
465
+ } else {
466
+ fontsDetails.emittedFonts.add(ruleKey);
467
+ await this.processFontFaceRule(ruleData, fontInfo, fonts, fontTests, stats);
468
+ }
450
469
  } else {
451
470
  removedRules.push(cssRule);
452
471
  }
@@ -504,7 +523,12 @@ class ProcessorHelperCommon {
504
523
  fonts: new Map(),
505
524
  medias: new Map(),
506
525
  supports: new Map(),
507
- layers: new Map()
526
+ layers: new Map(),
527
+ // the faces already emitted in this cascade context, so a second declaration of one of
528
+ // them can be dropped instead of embedding the same bytes again. Each media, supports
529
+ // and layer block gets its own info, so only rules that apply under the same conditions
530
+ // are ever compared
531
+ emittedFonts: new Set()
508
532
  };
509
533
  }
510
534
 
@@ -348,7 +348,7 @@ function getProcessorHelperClass(utilInstance) {
348
348
  delete resourceElement.dataset.singleFileOriginURL;
349
349
  if (!expectedType || !options["block" + expectedType.charAt(0).toUpperCase() + expectedType.substring(1) + "s"]) {
350
350
  if (!testIgnoredPath(resourceURL)) {
351
- setAttributeEmpty(resourceElement, attributeName, expectedType);
351
+ this.setAttributeEmpty(resourceElement, attributeName, expectedType);
352
352
  if (testValidPath(resourceURL)) {
353
353
  try {
354
354
  resourceURL = util.resolveURL(resourceURL, baseURI);
@@ -435,18 +435,10 @@ function getProcessorHelperClass(utilInstance) {
435
435
  }
436
436
  }
437
437
  } else {
438
- setAttributeEmpty(resourceElement, attributeName, expectedType);
438
+ this.setAttributeEmpty(resourceElement, attributeName, expectedType);
439
439
  }
440
440
  }
441
441
  }));
442
-
443
- function setAttributeEmpty(resourceElement, attributeName, expectedType) {
444
- if (expectedType == "video" || expectedType == "audio") {
445
- resourceElement.removeAttribute(attributeName);
446
- } else {
447
- resourceElement.setAttribute(attributeName, util.EMPTY_RESOURCE);
448
- }
449
- }
450
442
  }
451
443
 
452
444
  async processImageSrcset(resourceURL, srcsetValue, resources, batchRequest) {
@@ -106,11 +106,16 @@ function getProcessorHelperClass(utilInstance) {
106
106
  linkElement.setAttribute("type", "text/css");
107
107
  const name = "stylesheet_" + resources.stylesheets.size + ".css";
108
108
  linkElement.setAttribute("href", name);
109
- let { content } = options.inlineStylesheets.get(stylesheetRefIndex);
110
- const stylesheet = cssTree.parse(content, { context: "stylesheet", parseCustomProperty: true });
111
- this.replacePseudoClassDefined(stylesheet);
112
- content = this.generateStylesheetContent(stylesheet, options);
113
- resources.stylesheets.set(resources.stylesheets.size, { name, content });
109
+ // the shared copy is generated from the stylesheet of the element the duplicates were
110
+ // folded into, not from the text that was captured: that text still names the resources
111
+ // by their addresses in the original page, which resolve to nothing once the page is
112
+ // inside the archive
113
+ const { styleElement, content } = options.inlineStylesheets.get(stylesheetRefIndex);
114
+ const sharedEntry = entries.find(([key]) => key.element == styleElement);
115
+ const stylesheet = sharedEntry
116
+ ? sharedEntry[1].stylesheet
117
+ : cssTree.parse(content, { context: "stylesheet", parseCustomProperty: true });
118
+ resources.stylesheets.set(resources.stylesheets.size, { name, content: this.generateStylesheetContent(stylesheet, options) });
114
119
  linkElements.set(stylesheetRefIndex, linkElement);
115
120
  });
116
121
  for (const [key, stylesheetInfo] of entries) {
@@ -151,7 +156,7 @@ function getProcessorHelperClass(utilInstance) {
151
156
  }
152
157
  }
153
158
 
154
- async resolveImportURLs(stylesheetInfo, baseURI, options, workStylesheet, resources, stylesheets) {
159
+ async resolveImportURLs(stylesheetInfo, baseURI, options, workStylesheet, resources, stylesheets, importedStyleSheets = new Set()) {
155
160
  const stylesheet = stylesheetInfo.stylesheet;
156
161
  const scoped = stylesheetInfo.scoped;
157
162
  this.resolveStylesheetURLs(stylesheet, baseURI, workStylesheet);
@@ -168,7 +173,10 @@ function getProcessorHelperClass(utilInstance) {
168
173
  } catch (error) {
169
174
  // ignored
170
175
  }
171
- if (testValidURL(resourceURL)) {
176
+ // a sheet already open higher in this import chain must not be entered again: the
177
+ // ancestors are carried down the branch, not accumulated across the whole document,
178
+ // so two sibling imports of one sheet are still both resolved
179
+ if (testValidURL(resourceURL) && !importedStyleSheets.has(resourceURL)) {
172
180
  const mediaQueryListNode = cssTree.find(node, node => node.type == "MediaQueryList");
173
181
  let mediaText, layerName, supportsCondition;
174
182
  if (mediaQueryListNode) {
@@ -198,12 +206,19 @@ function getProcessorHelperClass(utilInstance) {
198
206
  layerName,
199
207
  supportsCondition
200
208
  };
209
+ const requestedURL = resourceURL;
201
210
  const content = await this.getStylesheetContent(resourceURL, options);
202
211
  stylesheetInfo.url = resourceURL = content.resourceURL;
203
212
  content.data = getUpdatedResourceContent(resourceURL, options) || content.data;
204
213
  stylesheetInfo.stylesheet = cssTree.parse(content.data, { context: "stylesheet", parseCustomProperty: true });
205
214
  stylesheet = stylesheetInfo.stylesheet;
206
- await this.resolveImportURLs(stylesheetInfo, resourceURL, options, workStylesheet, resources, stylesheets);
215
+ const ancestorStyleSheets = new Set(importedStyleSheets);
216
+ // both identities of the sheet are remembered: a redirect makes the URL that was
217
+ // requested and the URL that answered differ, and an import of either one is the
218
+ // same cycle
219
+ ancestorStyleSheets.add(requestedURL);
220
+ ancestorStyleSheets.add(resourceURL);
221
+ await this.resolveImportURLs(stylesheetInfo, resourceURL, options, workStylesheet, resources, stylesheets, ancestorStyleSheets);
207
222
  stylesheets.set({ urlNode }, stylesheetInfo);
208
223
  }
209
224
  urlNode.importedChildren = stylesheet.children;
@@ -319,7 +334,7 @@ function getProcessorHelperClass(utilInstance) {
319
334
  delete resourceElement.dataset.singleFileOriginURL;
320
335
  if (!expectedType || !options["block" + expectedType.charAt(0).toUpperCase() + expectedType.substring(1) + "s"]) {
321
336
  if (!testIgnoredPath(resourceURL)) {
322
- setAttributeEmpty(resourceElement, attributeName, expectedType);
337
+ this.setAttributeEmpty(resourceElement, attributeName, expectedType);
323
338
  if (testValidPath(resourceURL)) {
324
339
  try {
325
340
  resourceURL = util.resolveURL(resourceURL, baseURI);
@@ -373,18 +388,10 @@ function getProcessorHelperClass(utilInstance) {
373
388
  }
374
389
  }
375
390
  } else {
376
- setAttributeEmpty(resourceElement, attributeName, expectedType);
391
+ this.setAttributeEmpty(resourceElement, attributeName, expectedType);
377
392
  }
378
393
  }
379
394
  }));
380
-
381
- function setAttributeEmpty(resourceElement, attributeName, expectedType) {
382
- if (expectedType == "video" || expectedType == "audio") {
383
- resourceElement.removeAttribute(attributeName);
384
- } else {
385
- resourceElement.setAttribute(attributeName, util.EMPTY_RESOURCE);
386
- }
387
- }
388
395
  }
389
396
 
390
397
  async processImageSrcset(resourceURL, srcsetValue, resources, batchRequest) {
@@ -198,12 +198,54 @@ Notes on composition:
198
198
 
199
199
  ### 2.1 The charset rule
200
200
 
201
- The HTML face declares `<meta charset=utf-8>` when universal mode is off, and a
202
- single-byte charset when it is on — `windows-1252` in the reference writer. The
203
- declaration MUST appear within the first 1024 bytes of the file so the parser's
204
- encoding prescan finds it. The prescan reads exactly that many bytes and does not
205
- resume, so the whole `<meta>` tag has to fit: one that straddles the boundary is not
206
- seen, and the parser falls back to its default encoding.
201
+ The HTML face declares `<meta charset=utf-8>` when universal mode is off. When
202
+ universal mode is on it declares a single-byte charset instead — `windows-1252` in the
203
+ reference writer. The declaration MUST appear within the first 1024 bytes of the file
204
+ so the parser's encoding prescan finds it. That bound is the HTML standard's own
205
+ authoring rule. The prescan it serves is weaker than the rule suggests: the standard
206
+ makes it optional, and only *encourages* scanning the first 1024 bytes. Treat the
207
+ number as a ceiling to write under, never as a budget a parser promises to read. The
208
+ whole `<meta>` tag has to fit: one that straddles the boundary is not seen, and the
209
+ parser falls back to its default encoding. Meeting the declaration later, during
210
+ tokenization, does not rescue the file. The parser does not resume the prescan. It
211
+ re-navigates the document under the new encoding instead, and a writer must not rely
212
+ on that.
213
+
214
+ The declaration decides the decoding only when nothing outranks it. Three things do,
215
+ each returning an encoding with the standard's *certain* confidence, all of them ahead
216
+ of the prescan: a byte order mark, a user's explicit encoding override, and a charset
217
+ stated by the transport layer, which over HTTP means a `Content-Type` header carrying
218
+ its own `charset`. Any of the three replaces the declared charset, the parsed text is
219
+ then not what the writer encoded, and the region cannot be recovered from it. This is
220
+ the one precondition universal mode has that the file cannot satisfy from within
221
+ itself.
222
+
223
+ Where it bites is narrower than that makes it sound, because the parsed text is the
224
+ last rung, not the first. The bootstrap reads the file's raw bytes whenever it can
225
+ (§4.1), and raw bytes carry no encoding; universal extraction is the fallback for when
226
+ they are out of reach. Taking the three in turn:
227
+
228
+ - A **transport charset** exists only over HTTP, and over HTTP the raw read is what
229
+ runs — the bootstrap requests its own URL and takes the response as bytes, which no
230
+ `Content-Type` can reinterpret. It reaches universal extraction only in a double
231
+ failure: the response has to defeat the raw read, through a network or CORS failure
232
+ or a non-200 status, *and* state a charset of its own.
233
+ - A **BOM** is the writer's own doing. It is why universal and PNG variants never carry
234
+ one (§3.1): the reference writer emits a BOM for the plain variant only
235
+ (`includeBOM`), where nothing depends on the declared charset.
236
+ - A **user override** is the one no software can prevent, and the rarest.
237
+
238
+ On `file:` URLs the bootstrap goes straight to page-text extraction, since no raw read
239
+ is available there (§4.1) — but there is also no transport layer, so the first of the
240
+ three cannot arise on the very path that depends on the charset most.
241
+
242
+ The failure is safe rather than silent, which is why the precondition is worth stating
243
+ at all. Decoded under the wrong charset the reconstructed bytes are wrong, the payload
244
+ checksum does not match, and the extractor MUST fail to the error message (§4.5)
245
+ instead of displaying a corrupt page. A reader MAY tell the case apart from ordinary
246
+ corruption by comparing the encoding the document was actually decoded with —
247
+ `document.characterSet` in a browser — against the declared one, and say so in the
248
+ error message. Nothing requires it, and the MUST is unaffected either way.
207
249
 
208
250
  Universal mode works in two parts, and the charset carries the first. The archive
209
251
  bytes themselves are recovered *from the parsed page text*: the browser decoded
@@ -267,7 +309,7 @@ face adds, then the regions the PNG face adds.
267
309
 
268
310
  | Region | Producer | Present | Contents |
269
311
  |---|---|---|---|
270
- | `html-prologue` | HTML | HTML face | Doctype, the root element start tag, an optional implementation-defined comment, `<meta charset>`, title, optional head elements (canonical link, `robots` meta, viewport, Content-Security-Policy), minimal CSS, `<body hidden>`, wait/error messages, optional table of contents, optional text body (§4.6). In the plain variant an optional UTF-8 BOM MAY precede the doctype (`includeBOM`); universal and PNG variants never carry one. In the PNG variants the region is split: everything through `<body hidden>` is the data of the `tEXt "PNG"` chunk, while the messages, the optional table of contents and the optional text body follow the `tEXt "ZIP"` chunk header; the doctype and the leading comment are dropped. |
312
+ | `html-prologue` | HTML | HTML face | Doctype, the root element start tag, an optional implementation-defined comment, `<meta charset>`, title, optional head elements (canonical link, `robots` meta, viewport, Content-Security-Policy), minimal CSS, `<body hidden>`, wait/error messages, optional table of contents, optional text body (§4.6). The leading comment, the title, the canonical link and the text body are withheld when a password is set (§5.6). In the plain variant an optional UTF-8 BOM MAY precede the doctype (`includeBOM`); universal and PNG variants never carry one. In the PNG variants the region is split: everything through `<body hidden>` is the data of the `tEXt "PNG"` chunk, while the messages, the optional table of contents and the optional text body follow the `tEXt "ZIP"` chunk header; the doctype and the leading comment are dropped. |
271
313
  | `bootstrap` | HTML | HTML face | One inline `<script>`: the embedded ZIP reader, the extractor, the display routine, and the content-acquisition logic (§4.1). The wrapper start tag that opens the ZIP region follows it, directly or after a relocated `extra-data`. |
272
314
  | `<!--` / `-->` | HTML | HTML face | The wrapper tag pair hiding a binary region from the HTML parser — comment tags by default, another pair when the hidden bytes contain `-->` (§5.1). Drawn at each opening and closing position. The close tag is absent when appended data is prevented (`preventAppendedData`, or the `<plaintext>` wrapper which cannot close): no markup follows the archive and the wrapper runs to end-of-file. That does not mean the file ends at the EOCD — the PNG face's tail still follows, inside the wrapper, where it parses as text (§5.1). |
273
315
  | `zip-entries` | ZIP | always | The archive's local file headers and entry data, written by the ZIP writer. The central directory of an archive written by the reference writer lists `index.html` (the page) first, then `manifest.json` (a JSON description of the archive: original URL, title, save time, resource-to-URL map — informative; the page displays without it), then the resources; the *physical* order of the local headers inside the region is not guaranteed to match, and readers MUST NOT rely on either order — entries are addressed by name (§7.1). |
@@ -307,7 +349,12 @@ change.
307
349
  The HTML parser consumes the whole file as one document. Its encoding prescan finds
308
350
  the `<meta charset>` declaration within the first 1024 bytes (§2.1) and the file is
309
351
  decoded as a single text; every binary region therefore also exists as characters in
310
- the parsed document, which is what universal mode exploits (§4.5).
352
+ the parsed document, which is what universal mode exploits (§4.5). This holds only
353
+ while the declaration is what decides the decoding: a BOM, a user override or a
354
+ transport-layer charset outranks it, and universal extraction then fails its checksum
355
+ rather than recovering anything (§2.1). The acquisition order below keeps that off the
356
+ common path — the raw bytes are read in preference to the parsed text wherever they
357
+ can be, and no encoding applies to them.
311
358
 
312
359
  The binary regions are kept out of the rendered page by the wrapper tags. The
313
360
  default wrapper is an HTML comment, and the HTML standard defines exactly which
@@ -494,8 +541,10 @@ decoded image is exactly that image.
494
541
 
495
542
  The last reader is the format's own: the extraction path of universal mode, used
496
543
  when the raw bytes are unreachable (§4.1). Its input is not the file but the *parsed
497
- document* — the characters the HTML parser produced — and its output is the exact
498
- ZIP region, byte for byte.
544
+ document* — the characters the HTML parser produced — and its output is the ZIP
545
+ region reconstructed byte for byte, with one deliberate exception: the two bytes of
546
+ the EOCD comment-length field, which the payload does not describe and the extractor
547
+ always writes as zero (step 2 below, and the row in §7.4).
499
548
 
500
549
  It works in three steps:
501
550
 
@@ -968,6 +1017,24 @@ The payload itself is a sequence of little-endian 32-bit words — checksum, rec
968
1017
  range length, newline count, then the codes packed 16 per word, least-significant pair
969
1018
  first — raw-deflated and base64-encoded with the standard alphabet and padding.
970
1019
 
1020
+ Those word widths cap what the payload can describe. A writer MUST NOT use universal
1021
+ mode for a ZIP region of 2^32 bytes or more, since the length field cannot express it.
1022
+ The cap is not enforced by the wire format itself: a writer that ignores it stores the
1023
+ length modulo 2^32 and produces a file that looks well-formed, and the mismatch
1024
+ surfaces only when a reader verifies the field (§4.5). The reference writer is in that
1025
+ position — it assigns the length into a `Uint32Array`, where the truncation is silent
1026
+ — and reaches the cap in no saved page. This bound and zip64 (§5.7) are separate
1027
+ things: zip64 is reachable at any archive size through the 65535-entry trigger and
1028
+ stays compatible with universal mode, and it is only a region large enough to need
1029
+ zip64's 64-bit *offsets* that runs past what the payload can describe.
1030
+
1031
+ An engine limit binds long before the format's. The extractor holds the region as one
1032
+ JavaScript string, and the maximum string length is engine-specific: V8 caps it at
1033
+ 2^29 − 24 characters, 536870888, measured on V8 15.0.245. A universal-mode archive
1034
+ whose ZIP region approaches half a gigabyte is therefore already unreadable in Chrome,
1035
+ Edge and Node, whatever the payload declares. Other engines set the limit elsewhere.
1036
+ The practical ceiling on universal mode is this one, not the 4 GiB above.
1037
+
971
1038
  ### 5.6 Password scope
972
1039
 
973
1040
  A password encrypts the *contents* of ZIP entries with AES, and nothing else. A reader
@@ -982,15 +1049,18 @@ gets no protection beyond that. Four consequences follow:
982
1049
  - **Entry metadata is never encrypted.** Names, uncompressed sizes and dates remain
983
1050
  readable in the central directory, so the resource list of an encrypted archive is
984
1051
  public. This is standard ZIP behavior, not a property of this format; §7 restates it.
985
- - **What the writer withholds instead.** Three things are not forced into the clear by
986
- the format and so are withheld when a password is set: the entry comments, which
987
- would otherwise publish every resource's source URL (§4.2); the `<title>` element's
988
- text, whose value `manifest.json` carries as an encrypted entry, leaving an empty
989
- `<title></title>` in the prologue; and the optional
990
- text body, which repeats the whole page text outside the archive (§4.6). Unlike the
991
- PNG and PDF faces, none of the three is load-bearing for a reader, so a writer that
992
- emits them in a password-protected archive publishes what the password is meant to
993
- cover for no gain.
1052
+ - **What the writer withholds instead.** Five things are not forced into the clear by
1053
+ the format, and so are withheld when a password is set. Three of them state a URL:
1054
+ the entry comments, which publish every resource's source URL (§4.2), and two
1055
+ prologue fields carrying the address the page was saved from the provenance
1056
+ comment an implementation may write there, and the canonical `<link>` among the head
1057
+ elements (§3.1). The other two are the `<title>` element's text, leaving an empty
1058
+ `<title></title>` in the prologue, and the optional text body, which repeats the
1059
+ whole page text outside the archive (§4.6). Nothing is lost by leaving any of them
1060
+ out: `manifest.json` holds the page URL, the title and the resource-URL map, and it
1061
+ is an encrypted entry like the rest. Unlike the PNG and PDF faces, none of the five
1062
+ is load-bearing for a reader, so a writer that emits them in a password-protected
1063
+ archive publishes what the password is meant to cover for no gain.
994
1064
 
995
1065
  Encrypted entries are stamped AE-2, so their CRC-32 field is zero (§5.4). `page.pdf`
996
1066
  stays unencrypted, so in a password-protected archive its checksum is the only one a
@@ -1018,6 +1088,12 @@ first by both Info-ZIP and the reference reader, the central directory offset in
1018
1088
  zip64 record points at the injected record, and extraction produces the same page as
1019
1089
  the non-zip64 build.
1020
1090
 
1091
+ zip64 does not conflict with universal mode. Its commonest trigger, 65535 entries or
1092
+ more, is reached at any archive size, and §4.5 gives the offset arithmetic for a
1093
+ recovered region whose EOCD fields are sentinels. What universal mode cannot carry is
1094
+ a ZIP region of 2^32 bytes or more, which the recovery payload's 32-bit length field
1095
+ cannot express (§5.5) — a size bound, not a zip64 one.
1096
+
1021
1097
  ## 6. Writer algorithm
1022
1098
 
1023
1099
  This section specifies the reference writer's build order. It is normative in the
@@ -1049,9 +1125,11 @@ pages can stop at the first row; the files it produces are accepted by every rea
1049
1125
  2. **HTML prologue.** With the HTML face, emit the doctype (omitted under the PNG
1050
1126
  face, which owns the start of the file), the root element start tag, any comment the
1051
1127
  implementation adds, the `<meta charset>` required by §2.1, the head elements (the
1052
- `<title>` among them, unless a password is set (§5.6)), the CSS and `<body hidden>`,
1128
+ `<title>` and the canonical link among them), the CSS and `<body hidden>`,
1053
1129
  the wait and error messages, the optional table of contents and text body, and the
1054
- bootstrap script. With the PNG face the head of this region,
1130
+ bootstrap script. With a password, five of those are left out: the comment, the
1131
+ title, the canonical link, the text body and the entry comments of step 6 (§5.6).
1132
+ With the PNG face the head of this region,
1055
1133
  through `<body hidden>`, is the data of the `tEXt "PNG"` chunk and the remainder is
1056
1134
  emitted after the `tEXt "ZIP"` chunk header in step 12; with the PDF face the
1057
1135
  region is interrupted by step 3 as well.
@@ -1259,10 +1337,13 @@ alongside it.
1259
1337
  Software that displays either MUST do so in a sandboxed context, and MUST NOT run
1260
1338
  the bootstrap in a privileged one. The format's own display path replaces the
1261
1339
  document with the extracted page, which is not an isolation boundary by itself.
1262
- - **A password protects entry contents only** (§5.6). Entry names, sizes, dates and
1263
- source URLs stay readable, and the PNG, PDF and text-body faces render the page
1264
- regardless. Software MUST NOT present a password-protected archive as an encrypted
1265
- document.
1340
+ - **A password protects entry contents only** (§5.6). Entry names, sizes and dates
1341
+ stay readable in the central directory, and a name commonly states the resource's
1342
+ filename. The PNG and PDF faces render the page regardless. A conforming writer
1343
+ withholds the five fields of §5.6, the source URLs among them, but a reader MUST NOT
1344
+ read their absence as protection: nothing in the format stops a writer from emitting
1345
+ any of them, so an archive of unknown provenance may state every URL in the clear.
1346
+ Software MUST NOT present a password-protected archive as an encrypted document.
1266
1347
  - **Sniffing disagrees with itself on these files.** `file(1)` reports HTML, PNG, PDF
1267
1348
  or "data" depending on the variant (§8.1), so a server that guesses the media type
1268
1349
  from content may serve a saved page as an image. Software that serves SingleFile
@@ -1285,7 +1366,7 @@ only if it affects the bytes the page is built from:
1285
1366
  | A `tEXt` chunk CRC does not match, or a chunk holds bytes PNG does not permit (§4.4) | Irrelevant to extraction; a reader of the archive MAY ignore both |
1286
1367
  | `page.pdf` is present but its data does not begin with `%PDF-` | Not an error. The entry is data like any other |
1287
1368
  | `index.html` is present without `manifest.json` | **MUST** still extract (§7.1) |
1288
- | More than one node carries the `sfz-data` identifier | **MUST NOT** extract either silently. A conforming writer emits one (§5.1), so a second is a payload that escaped its wrapper most often a nested archive written by a writer that emitted a face bare. Both candidates extract cleanly and check out, and the checksums say nothing about which one the file was built around |
1369
+ | More than one candidate carries the `sfz-data` identifier once §4.5's tie-break has been applied | **MUST NOT** extract either silently. The tie-break comes first and settles the ordinary pairing: an id-bearing element that is one of §5.1's wrapper rungs wins over a comment, and one that is not a rung loses to it, since the `id` is then something else in the page. What this row forbids is what the tie-break does not reach — two elements, or two comments, or an element and a comment that both survive it. A conforming writer emits one candidate (§5.1), so a second is a payload that escaped its wrapper, most often a nested archive written by a writer that emitted a face bare. Both extract cleanly and check out, and the checksums say nothing about which one the file was built around |
1289
1370
  | The recovered region (universal mode) disagrees with the same bytes read directly, in the EOCD's two comment-length bytes only | Expected, not an error. A recovered region always declares a zero-length comment (§4.5), so it differs here from any archive written in the declared form (§4.2). Compare the two only up to those bytes |
1290
1371
  | The recovered region (universal mode) disagrees with the same bytes read directly, anywhere else | The file is not well-formed, whichever side is at fault, and a reader that has both MUST NOT silently merge them or pick per entry. Prefer the direct read — it is the writer's own output, where the recovered region is a reconstruction of it — and surface the disagreement rather than displaying either as intact |
1291
1372
 
@@ -1485,6 +1566,7 @@ predicts.
1485
1566
  | August 2026 | Core 1.5.110: a PDF or PNG face whose payload names every rung is dropped instead of written bare (§5.1). Found by nesting an archive inside itself as both faces: the fifth level exhausts the ladder, and readers then extracted the fourth level's archive — checksums intact, no way to tell (§7.4) |
1486
1567
  | August 2026 | Core 1.5.110: a PNG face leaving the comment rung on its checksum resumes the rung search instead of taking the next rung untested (§5.1). Taking it put a payload holding `</script>` on the script rung, where its own bytes closed the wrapper 93 bytes in and left the image data, the chunk framing and the whole ZIP region to the parser |
1487
1568
  | August 2026 | Core 1.5.110: `<svg><![CDATA[` joins the ladder above `<plaintext>` (§5.1) — the one rung whose terminator, `]]>`, real payloads rarely carry. It gives a payload naming every element rung somewhere to go that does not cost the appended-data placement, and moves the self-nesting limit from the fifth level to the sixth |
1569
+ | August 2026 | Core 1.5.115: password-protected archives withhold the provenance comment and the canonical link as well (§5.6). Both wrote the page's own URL into the prologue, beside the title that was already withheld, so the address the archive was saved from stayed in the clear |
1488
1570
 
1489
1571
  This document was itself revised in August 2026, against core 1.5.108, after several
1490
1572
  independent reviews. One of them was a reader built from this specification alone, with
@@ -1506,3 +1588,18 @@ document; the limits of the reconstructed-`page.pdf` CRC check (§4.5); the dura
1506
1588
  ranking of the faces (§1.1); what each face costs a writer (§6); and the silent loss of
1507
1589
  the other faces to a pipeline that repacks the file (§7.2). One review found a live
1508
1590
  defect rather than a documentation one, the non-monotone retry step recorded above.
1591
+
1592
+ A later pass found four places where the document contradicted itself or the standard
1593
+ it cites: §7.3 stated that source URLs stay readable under a password while §5.6 said
1594
+ the writer withholds them, §4.5 called the recovered region exact while excluding two
1595
+ bytes from it, §7.4 rejected a duplicate identifier that §4.5 resolves by tie-break,
1596
+ and §2.1 described the HTML encoding prescan as mandatory and 1024 bytes wide when the
1597
+ standard makes it optional and only encourages that bound. None of the four changes
1598
+ what a writer emits or a reader accepts.
1599
+
1600
+ The same pass added the two boundaries universal mode had left unstated: that it
1601
+ recovers the region only where the declared charset is what decided the decoding, a
1602
+ BOM, a user override and a transport-layer charset all outranking it — narrow in
1603
+ practice, since the raw read comes first and no encoding applies to it (§2.1) — and that
1604
+ the recovery payload's 32-bit length field caps the region below 2^32 bytes, with
1605
+ engine string limits binding well before that (§5.5).