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 +12 -7
- package/core/index.js +19 -5
- package/core/lib/processor-helper-common.js +42 -18
- package/core/lib/processor-helper-inline.js +2 -10
- package/core/lib/processor-helper.js +25 -18
- package/doc/singlefile-archive.md +123 -26
- package/modules/css-fonts-minifier.js +114 -24
- package/modules/css-rules-minifier.js +9 -2
- package/package.json +2 -2
- package/processors/compression/compression-display.js +23 -2
- package/processors/compression/compression.js +12 -1
- package/processors/hooks/content/content-hooks-frames-web.js +27 -4
- package/test/sfz-harness/README.md +5 -2
- package/test/sfz-harness/adopted-stylesheets-hook.js +240 -0
- package/test/sfz-harness/css-fonts-minifier.js +184 -0
- package/test/sfz-harness/css-property-filter.js +85 -0
- package/test/sfz-harness/format-rules.js +28 -0
- package/test/sfz-harness/inlined-functions.js +82 -0
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
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
|
-
|
|
106
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
202
|
-
single-byte charset
|
|
203
|
-
declaration MUST appear within the first 1024 bytes of the file
|
|
204
|
-
encoding prescan finds it.
|
|
205
|
-
|
|
206
|
-
|
|
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
|
|
498
|
-
|
|
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.**
|
|
986
|
-
the format and so are withheld when a password is set
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
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>`
|
|
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
|
|
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
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
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
|
|
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).
|