single-file-core 1.5.109 → 1.5.111

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.
@@ -314,9 +314,9 @@ default wrapper is an HTML comment, and the HTML standard defines exactly which
314
314
  character sequences terminate one (`-->`, and the recovery form `--!>`); the writer
315
315
  MUST select a wrapper only after checking the bytes it must hide against that
316
316
  wrapper's patterns (the exact rules differ between the ZIP region and the PDF and
317
- PNG payloads, §5.1), so hiding relies on normative parsing behavior. One
318
- case weakens the wrapper itself: when no wrapper fits a PDF or PNG payload the
319
- payload is emitted bare (§5.1). Some binary content always sits *outside* a
317
+ PNG payloads, §5.1), so hiding relies on normative parsing behavior. When no
318
+ wrapper fits a PDF or PNG payload, the face is dropped rather than emitted bare
319
+ (§5.1). Some binary content always sits *outside* a
320
320
  wrapper: in the PNG variants, the signature, IHDR and chunk framing bytes that
321
321
  precede the root element start tag decode to a short run of text that HTML error recovery
322
322
  places in the (hidden) body. The backstop for all these cases is the prologue: it
@@ -678,7 +678,8 @@ this ladder, in order:
678
678
  | 5 | `<noembed>` | raw text | `</noembed` + delimiter |
679
679
  | 6 | `<iframe>` | raw text | `</iframe` + delimiter |
680
680
  | 7 | `<xmp>` | raw text | `</xmp` + delimiter |
681
- | 8 | `<plaintext>` | everything to end of file | nothing — the element cannot be closed |
681
+ | 8 | `<svg><![CDATA[` … `]]></svg>` | CDATA section | `]]>` |
682
+ | 9 | `<plaintext>` | everything to end of file | nothing — the element cannot be closed |
682
683
 
683
684
  The terminators are written lower case above, but HTML matches end tag names ASCII
684
685
  case-insensitively: `</XMP>` and `</Script ` close their elements just as `</xmp>` and
@@ -711,6 +712,31 @@ or stops reading `<xmp>`, changes only which archives end up in a local search i
711
712
  every rung still hides its content from the HTML parser, and a writer whose ladder is
712
713
  ordered differently produces files that are just as correct.
713
714
 
715
+ The CDATA rung sits where it does for the same reason, and it is the only rung whose
716
+ placement understates it. A CDATA section is a CDATA section only in foreign content,
717
+ which is what the `<svg>` element is there for — in HTML content `<![CDATA[` is a
718
+ bogus comment, and the payload would be markup. Given the `<svg>`, the construct is the
719
+ strongest on the ladder: `]]>` is the whole of its terminator, and it is a sequence real
720
+ payloads carry far less often than `-->` or `</script>`. That matters most for the one
721
+ thing the ladder cannot otherwise avoid — an archive nested inside another as a face
722
+ carries the terminator of every rung it climbed, so each rung is spent once and only
723
+ once (§5.1, ladder depth). A writer MUST place the identifier on the `<svg>` element and
724
+ not on the markup declaration, which takes no attributes: `<svg id=sfz-data><![CDATA[`.
725
+
726
+ Two properties of the CDATA section state are worth stating because a writer is tempted
727
+ to guard against both and needs neither. Sections do not nest, so a `<![CDATA[` inside
728
+ the payload is text like any other — the start-pattern test on this rung is the same
729
+ conservatism the raw-text rungs get, not a necessity. And trailing brackets are safe: a
730
+ payload ending `]]` against the writer's `]]>` produces `]]]]>`, and the tokenizer's
731
+ CDATA section end state emits the payload's own two brackets before closing, so the
732
+ recovered bytes are exact.
733
+
734
+ Verified in Blink, Gecko and WebKit, and against html5lib: a universal-mode archive on
735
+ this rung recovers from the parsed document byte for byte, with the checksum of §4.5
736
+ matching, indistinguishably from the same archive on the comment rung. The same probe
737
+ covered a payload holding every byte value, every rung's patterns, and the near-misses
738
+ `]]x>`, `] ]>`, `]>` and `]]`, in both the prologue position and mid-document.
739
+
714
740
  Two things about the ladder *are* required. Whatever order a writer gives the seven
715
741
  closable rungs, it MUST apply the selection test below to every rung it considers, and
716
742
  MUST keep `<plaintext>` available as the rung of last resort: §6.2's termination
@@ -730,14 +756,15 @@ what differs is how far the ladder goes:
730
756
  - **The ZIP region** rejects a rung whose *end* pattern the payload contains, and also
731
757
  a rung whose *start* pattern it contains. A rung's start pattern is the tag's opening
732
758
  delimiter and name, without attributes: `<!--` for the comment, then `<script`,
733
- `<style`, `<noframes`, `<noembed`, `<iframe`, `<xmp` for the elements.
759
+ `<style`, `<noframes`, `<noembed`, `<iframe`, `<xmp` for the elements, and
760
+ `<![CDATA[` for the CDATA rung.
734
761
 
735
762
  `<plaintext>` is exempt from **both** tests. It has no terminator to occur and no
736
763
  tokenizer states to escape into — a `<plaintext` inside a `<plaintext>` is inert
737
764
  text like everything else — so no payload can defeat it. §6.2's termination argument
738
765
  rests on that exemption.
739
766
 
740
- The other seven are all tested, and a writer MUST test all seven rather than the one
767
+ The other eight are all tested, and a writer MUST test all eight rather than the one
741
768
  that needs it. The `<script>` rung needs it
742
769
  to be correct at all, because script data has escape states no other rung has: `<!--`
743
770
  in script data enters *script data escaped*, and a `<script` after that enters *script
@@ -746,7 +773,8 @@ what differs is how far the ladder goes:
746
773
  and the wrapper then swallows its own end tag, the extra-data element and the rest of
747
774
  the document. On the other six the start test is genuine conservatism: a nested `<!--`
748
775
  is a parse error inside a comment but does not close it, and the raw-text rungs hold a
749
- flat run of characters with no states at all. The rule is uniform deliberately: the
776
+ flat run of characters with no states at all, while CDATA sections do not nest. The
777
+ rule is uniform deliberately: the
750
778
  exemption would save one pattern match per rung on bytes already in memory, at the
751
779
  cost of a special case an implementer has to remember correctly about the single rung
752
780
  where forgetting it destroys the document. An earlier draft offered exactly that
@@ -758,8 +786,12 @@ what differs is how far the ladder goes:
758
786
  the rest of the document, title, bootstrap and extra-data element included — but the
759
787
  `<plaintext>` rung is excluded from their ladder: those payloads sit in the middle
760
788
  of the file, so a wrapper that can never close is not an option. When no rung fits,
761
- the payload is written bare, with no wrapper at all; it then reaches the parser as
762
- text, and the blank-page backstop of §4.1 keeps it invisible.
789
+ the writer MUST omit the face and emit the archive without it. It MUST NOT write the
790
+ payload bare. Bare renders acceptably — the blank-page backstop of §4.1 keeps it
791
+ invisible — but the payload's markup joins the document, and a payload that is itself
792
+ a SingleFile archive then contributes an `sfz-data` node ahead of the file's own. A
793
+ reader looking for one node finds two, takes the first, and returns an archive that
794
+ passes every check it has (§7.4). A face is a convenience; the archive is not.
763
795
 
764
796
  The comment rung has one restriction more than a terminator. HTML forbids comment text
765
797
  that *starts* with `>` or `->`, and the tokenizer enforces it: it closes the comment
@@ -767,9 +799,14 @@ right there, spilling the payload into the parser. What starts the comment diffe
767
799
  payload — the ZIP region begins with the identifier, the PDF face with a local file
768
800
  header or `%PDF-` — but the PNG face begins with the CRC of the chunk carrying the
769
801
  start tag, four bytes that are only settled once the tag is chosen, and one in 256 of
770
- them is `>`. A writer using a comment there MUST compute that checksum and take the
771
- next rung when it opens with `>` or `->`; the next rung is an element, which has no
772
- such restriction, so the test cannot cascade. HTML also forbids comment text ending
802
+ them is `>`. A writer using a comment there MUST compute that checksum and leave the
803
+ comment rung when it opens with `>` or `->`. It MUST leave it by *resuming the rung
804
+ search* below it, not by taking the rung that follows: the checksum says only that the
805
+ comment is unusable, and which rung is usable remains the payload's to say. The test
806
+ itself cannot cascade — only the comment rung carries the restriction, and every rung
807
+ below it is an element — but the payload's terminators still apply, and a payload
808
+ holding `</script>` sends a writer that stepped rather than searched onto the one rung
809
+ it is guaranteed to close. HTML also forbids comment text ending
773
810
  with `<!-`, which the terminator check covers by testing that pattern anchored at the
774
811
  payload's end.
775
812
 
@@ -785,6 +822,18 @@ Verified on a build forced onto this rung with a screenshot embedded: the file e
785
822
  `49 45 4e 44 ae 42 60 82`, decodes as a PNG, and its archive extracts from the parsed
786
823
  page. The termination argument of §6.2 therefore holds for the faced variants too.
787
824
 
825
+ The ladder has a depth, and nesting reaches it. An archive used as the PDF or PNG
826
+ payload of another one carries the terminator of every rung its own faces climbed
827
+ through, and `-->`, `</script>` and `</style>` besides, which every prologue emits.
828
+ Each level of nesting therefore burns exactly one rung, and the eight a face may use
829
+ run out at the sixth: the reference writer selects `<!--`, then `<noframes>`,
830
+ `<noembed>`, `<iframe>`, `<xmp>`, the CDATA rung, and then has nothing left. No ladder
831
+ of fixed length avoids this; adding a rung moves the limit by one level, which is the
832
+ limit of what the CDATA rung buys here — its value is that real payloads rarely hold
833
+ `]]>`, not that it makes nesting unbounded. That is why the paragraph
834
+ above states a MUST rather than a quality-of-implementation preference — exhaustion is
835
+ reachable by construction, not only by a payload built to provoke it.
836
+
788
837
  The check MUST be made against the payload's final bytes, for every payload the
789
838
  writer hides and in every variant that hides one. Every rejection restarts the build
790
839
  (§6): the wrapper choice changes the bytes preceding the archive, so the archive must
@@ -909,8 +958,8 @@ inverse mapping the extractor applies is, for the declared charset:
909
958
  3. **U+FFFD → 0x00.** No byte decodes to U+FFFD under a qualifying encoding (§2.1), so
910
959
  the replacement character can only have come from a NUL byte. This holds because
911
960
  the payload is inside a wrapper: in every tokenizer state the ladder of §5.1
912
- produces — comment, raw text, script data, plaintext — the parser replaces NUL with
913
- U+FFFD.
961
+ produces — comment, raw text, script data, CDATA section, plaintext — the parser
962
+ replaces NUL with U+FFFD.
914
963
  4. **Newlines from the payload.** The parser normalizes CR and CR LF to LF, so the
915
964
  original byte sequence is unrecoverable from the text alone; each newline consumes
916
965
  the next 2-bit code (0 = LF, 1 = CR, 2 = CR LF).
@@ -1236,6 +1285,7 @@ only if it affects the bytes the page is built from:
1236
1285
  | 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 |
1237
1286
  | `page.pdf` is present but its data does not begin with `%PDF-` | Not an error. The entry is data like any other |
1238
1287
  | `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 |
1239
1289
  | 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 |
1240
1290
  | 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 |
1241
1291
 
@@ -1432,6 +1482,9 @@ predicts.
1432
1482
  | August 2026 | Core 1.5.108: the recovery payload stops two bytes short of the End Of Central Directory record, excluding its comment-length field (§1.3), which lets universal-mode archives declare their appended data as the archive comment — a writer option, for `java.util.zip` and the readers that reject undeclared trailing bytes (§4.2) |
1433
1483
  | August 2026 | Core 1.5.108: the PDF and PNG faces test a wrapper rung's start pattern as well as its end pattern, closing the same script-data escape hole the ZIP region was already guarded against — a face payload holding `<!--` and then `<script` took the `<script type=sfz-data>` rung and swallowed the rest of the document (§5.1) |
1434
1484
  | August 2026 | Core 1.5.108: the retry loop discards a relocation reservation at most once per build, so a payload sitting on the appended-data boundary cannot oscillate between the two placements forever (§6.2) |
1485
+ | 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
+ | 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
+ | 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 |
1435
1488
 
1436
1489
  This document was itself revised in August 2026, against core 1.5.108, after several
1437
1490
  independent reviews. One of them was a reader built from this specification alone, with
@@ -41,7 +41,11 @@ const REGEXP_COMMA = /\s*,\s*/;
41
41
  const REGEXP_DASH = /-/;
42
42
  const REGEXP_QUESTION_MARK = /\?/g;
43
43
  const REGEXP_STARTS_U_PLUS = /^U\+/i;
44
+ const REGEXP_CUSTOM_PROPERTY = /var\((--[^),]*)\)/g;
44
45
  const VALID_FONT_STYLES = [/^normal$/, /^italic$/, /^oblique$/, /^oblique\s+/];
46
+ // a family name kept when the "font" shorthand cannot be read: it resolves to nothing, so it
47
+ // survives the substitution below and marks the fonts as undetermined instead of unused
48
+ const UNRESOLVED_CUSTOM_PROPERTY_FAMILY = "var(--)";
45
49
 
46
50
  export {
47
51
  process
@@ -268,23 +272,36 @@ function getFontFamilyNames(declarations, options) {
268
272
  }
269
273
  const font = declarations.children.filter(node => node.property == "font").tail;
270
274
  if (font && font.data && font.data.value) {
275
+ const fontValue = cssTree.generate(font.data.value);
271
276
  try {
272
277
  let value = font.data.value;
273
- let fontFamilyName = cssTree.generate(value);
274
- const matchedVar = fontFamilyName.match(/^var\((--.*)\)$/);
275
- if (matchedVar && matchedVar[1]) {
276
- value = cssTree.parse(globalThis.getComputedStyle(options.doc.body).getPropertyValue(matchedVar[1]), { context: "value" });
278
+ const resolvedFontValue = resolveCustomProperties(fontValue, options);
279
+ if (resolvedFontValue != fontValue) {
280
+ value = cssTree.parse(resolvedFontValue, { context: "value" });
277
281
  }
278
282
  const parsedFont = fontPropertyParser.parse(value);
279
283
  parsedFont.family.forEach(familyName => fontFamilyNames.push(helper.normalizeFontFamily(familyName)));
280
284
  // eslint-disable-next-line no-unused-vars
281
285
  } catch (error) {
282
- // ignored
286
+ // the shorthand is unreadable, and dropping it here would count the fonts it names as
287
+ // unused: a custom property left in it means the families cannot be determined at all
288
+ if (fontValue.includes("var(")) {
289
+ fontFamilyNames.push(UNRESOLVED_CUSTOM_PROPERTY_FAMILY);
290
+ }
283
291
  }
284
292
  }
285
293
  return fontFamilyNames;
286
294
  }
287
295
 
296
+ function resolveCustomProperties(value, options) {
297
+ if (globalThis.getComputedStyle && options.doc) {
298
+ const computedStyle = globalThis.getComputedStyle(options.doc.body);
299
+ return value.replace(REGEXP_CUSTOM_PROPERTY, (property, name) => computedStyle.getPropertyValue(name) || property);
300
+ } else {
301
+ return value;
302
+ }
303
+ }
304
+
288
305
  function parseFamilyNames(fontFamilyNameTokenData, fontFamilyNames) {
289
306
  let nextToken = fontFamilyNameTokenData.children.head;
290
307
  while (nextToken) {
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "single-file-core",
3
- "version": "1.5.109",
3
+ "version": "1.5.111",
4
4
  "description": "SingleFile Core",
5
5
  "author": "Gildas Lormeau",
6
6
  "license": "AGPL-3.0-or-later",
7
7
  "scripts": {
8
- "test": "deno run --allow-read test/sfz-harness/format-rules.js && deno run --allow-read test/sfz-harness/stored-trigger.js && deno run --allow-read test/sfz-harness/check-determinism.js",
8
+ "test": "deno run --allow-read test/sfz-harness/format-rules.js && deno run --allow-read test/sfz-harness/stored-trigger.js && deno run --allow-read test/sfz-harness/check-determinism.js && deno run --allow-read test/sfz-harness/option-wiring.js",
9
9
  "bump-patch": "npm version patch --no-git-tag-version && npm run bump-commit",
10
10
  "bump-minor": "npm version minor --no-git-tag-version && npm run bump-commit",
11
11
  "bump-major": "npm version major --no-git-tag-version && npm run bump-commit",
@@ -83,6 +83,11 @@ async function createPagesArchive(pages, options) {
83
83
  selfExtractingArchive: options.selfExtractingArchive,
84
84
  extractDataFromPage: options.extractDataFromPage,
85
85
  preventAppendedData: options.preventAppendedData,
86
+ declareAppendedData: options.declareAppendedData,
87
+ // the faces belong to the archive and are supplied by the caller: one rendered from a
88
+ // single page would misrepresent every other page, so nothing is generated here
89
+ embeddedPdf: options.embeddedPdf,
90
+ embeddedImage: options.embeddedImage,
86
91
  includeBOM: options.includeBOM,
87
92
  insertMetaCSP: options.insertMetaCSP,
88
93
  insertCanonicalLink: options.insertCanonicalLink,
@@ -53,6 +53,11 @@ const SCRIPT_PATH = "/lib/single-file-zip.min.js";
53
53
  // they drop a comment: macOS Spotlight indexes the content of every other rung, and textutil
54
54
  // also reads the last two. Neither is applied nor executed, the type is neither CSS nor
55
55
  // JavaScript. <plaintext> stays last, it is the only rung that cannot be closed
56
+ // the CDATA section sits second to last, and it is the one rung a payload is unlikely to hold
57
+ // the terminator of: every other rung ends on a sequence that real documents carry, which is
58
+ // also why each level of self-nesting burns one. It is low in the ladder only because text
59
+ // extractors read its content; nothing about the parse is weaker. A CDATA section is only a
60
+ // CDATA section in foreign content, hence the <svg> element around it
56
61
  const EXTRA_DATA_TAGS = [
57
62
  ["<script type=sfz-data>", "</script>"],
58
63
  ["<style type=sfz-data>", "</style>"],
@@ -60,6 +65,7 @@ const EXTRA_DATA_TAGS = [
60
65
  ["<noembed>", "</noembed>"],
61
66
  ["<iframe>", "</iframe>"],
62
67
  ["<xmp>", "</xmp>"],
68
+ ["<svg><![CDATA[", "]]></svg>"],
63
69
  ["<plaintext>", "</plaintext>"]
64
70
  ];
65
71
  const EMBEDDED_DATA_TAGS = [
@@ -76,6 +82,11 @@ const EXTRA_DATA_REGEXPS = [
76
82
  [/<noembed/i, /<\/noembed[\t\n\f\r />]/i],
77
83
  [/<iframe/i, /<\/iframe[\t\n\f\r />]/i],
78
84
  [/<xmp/i, /<\/xmp[\t\n\f\r />]/i],
85
+ // a CDATA section ends on "]]>" and nothing else, so the terminator is the whole test; the
86
+ // start pattern is the same conservatism the raw text rungs get, since a nested "<![CDATA["
87
+ // is text like any other. Trailing brackets are safe: a payload ending "]]" against the
88
+ // writer's "]]>" gives "]]]]>", and the tokenizer emits the payload's own two before closing
89
+ [/<!\[CDATA\[/i, /\]\]>/],
79
90
  [/<plaintext/i, /<\/plaintext[\t\n\f\r />]/i]
80
91
  ];
81
92
  // a comment must also not end with "<!-", the last of the restrictions HTML puts on comment
@@ -102,6 +113,7 @@ const MAX_APPENDED_DATA_LENGTH = 65535;
102
113
  const PDF_ENTRY_FILENAME = "page.pdf";
103
114
  const PDF_HEADER_MAX_OFFSET = 1024;
104
115
  const MINIMAL_DOCTYPE = "<!DOCTYPE html>";
116
+ const UNHIDDEN_FACE_WARNING_MESSAGE = "SingleFile: the archive was written without a face, its data names every wrapper tag:";
105
117
  const LOCAL_FILE_HEADER_SIGNATURE = 0x04034b50;
106
118
  const CENTRAL_FILE_HEADER_SIGNATURE = 0x02014b50;
107
119
  const END_OF_CENTRAL_DIR_SIGNATURE = 0x06054b50;
@@ -110,9 +122,34 @@ const ZIP64_END_OF_CENTRAL_DIR_LOCATOR_SIGNATURE = 0x07064b50;
110
122
 
111
123
  const browser = globalThis.browser;
112
124
 
125
+ // the options process() accepts from its caller. single-file.js builds its argument from this
126
+ // list instead of a literal, because an option added here and forgotten there is undefined at
127
+ // every call site and the feature silently does nothing: that is how declareAppendedData and
128
+ // includeBOM both shipped inert. Options the module sets on itself between passes, and options
129
+ // the packager supplies, are deliberately absent
130
+ const PROCESS_OPTION_NAMES = [
131
+ "createRootDirectory",
132
+ "declareAppendedData",
133
+ "disableCompression",
134
+ "embeddedImage",
135
+ "embeddedPdf",
136
+ "extractDataFromPage",
137
+ "includeBOM",
138
+ "insertCanonicalLink",
139
+ "insertMetaCSP",
140
+ "insertMetaNoIndex",
141
+ "insertTextBody",
142
+ "password",
143
+ "preventAppendedData",
144
+ "selfExtractingArchive",
145
+ "url",
146
+ "zipScript"
147
+ ];
148
+
113
149
  export {
114
150
  process,
115
- createArchive
151
+ createArchive,
152
+ PROCESS_OPTION_NAMES
116
153
  };
117
154
 
118
155
  async function process(pageData, options, lastModDate = new Date()) {
@@ -136,32 +173,29 @@ async function createArchive(pageData, options, script, writeEntries, lastModDat
136
173
  let extraDataOffset, extraData, embeddedImageDataOffset, endTag, pdfEntry;
137
174
  if (options.embeddedImage) {
138
175
  options.embeddedImage = new Uint8Array(options.embeddedImage);
139
- const embeddedImageData = options.embeddedImage.slice(PNG_SIGNATURE_LENGTH + PNG_IHDR_LENGTH, options.embeddedImage.length - PNG_IEND_LENGTH);
176
+ }
177
+ // the whole chunk is built before the first byte of the image is written, because building it
178
+ // is what settles the rung, and the search can end with no rung at all: the image is then left
179
+ // out altogether rather than written unwrapped
180
+ let imageChunk;
181
+ if (options.embeddedImage && options.selfExtractingArchive) {
182
+ imageChunk = getImageHTMLChunk(pageData, options, lastModDate);
183
+ if (!imageChunk) {
184
+ dropUnhiddenFace(options, "embeddedImage");
185
+ }
186
+ }
187
+ if (options.embeddedImage) {
188
+ const embeddedImageData = getEmbeddedImageData(options.embeddedImage);
140
189
  await writeData(zipDataWriter.writable, options.embeddedImage.slice(0, PNG_SIGNATURE_LENGTH + PNG_IHDR_LENGTH));
141
190
  if (options.selfExtractingArchive) {
142
- const embeddedImageText = TEXT_DECODER.decode(embeddedImageData);
143
- let tagIndex = findEmbeddedDataTagIndex(embeddedImageText);
144
- let startTag, startHTMLData, htmlData, htmlDataCRC, abruptComment;
145
- do {
146
- [startTag, endTag] = tagIndex == -1 ? ["", ""] : EMBEDDED_DATA_TAGS[tagIndex];
147
- startHTMLData = getStartHTMLArray(pageData, options, lastModDate, startTag);
148
- htmlData = new Uint8Array([...getLength(startHTMLData.htmlArray.length + 4), ...[0x74, 0x45, 0x58, 0x74, 0x50, 0x4e, 0x47, 0], ...startHTMLData.htmlArray]);
149
- htmlDataCRC = getCRC32(htmlData, 4);
150
- // what follows the start tag is this chunk's CRC, four bytes only known once the
151
- // tag is chosen: a comment they open with ">" or "->" is closed by the parser
152
- // there and then, leaving the image data to be read as markup
153
- abruptComment = tagIndex == 0 && (htmlDataCRC[0] == 0x3e || (htmlDataCRC[0] == 0x2d && htmlDataCRC[1] == 0x3e));
154
- if (abruptComment) {
155
- tagIndex++;
156
- }
157
- } while (abruptComment);
158
- if (startHTMLData.pdfEntry) {
159
- pdfEntry = startHTMLData.pdfEntry;
191
+ endTag = imageChunk.endTag;
192
+ if (imageChunk.startHTMLData.pdfEntry) {
193
+ pdfEntry = imageChunk.startHTMLData.pdfEntry;
160
194
  // the htmlArray starts after the 4-byte length and the 8-byte type of the tEXt chunk
161
195
  pdfEntry.offset += zipDataWriter.offset + 12;
162
196
  }
163
- await writeData(zipDataWriter.writable, htmlData);
164
- await writeData(zipDataWriter.writable, htmlDataCRC);
197
+ await writeData(zipDataWriter.writable, imageChunk.htmlData);
198
+ await writeData(zipDataWriter.writable, imageChunk.htmlDataCRC);
165
199
  } else if (options.embeddedPdf) {
166
200
  const data = new Uint8Array([...getLength(options.embeddedPdf.length + 4), ...[0x74, 0x45, 0x58, 0x74, 0x50, 0x44, 0x46, 0], ...new Uint8Array(options.embeddedPdf)]);
167
201
  await writeData(zipDataWriter.writable, data);
@@ -517,9 +551,13 @@ async function prependHTMLData(pageData, zipDataWriter, script, options, lastMod
517
551
  // the extractor finds the zip data by identifier instead of by its position in the tree: an
518
552
  // element carries it as an attribute, a comment as the first characters of its data
519
553
  function getDataStartTag([startTag]) {
520
- return startTag == "<!--" ?
521
- startTag + DATA_IDENTIFIER :
522
- startTag.slice(0, -1) + " id=" + DATA_IDENTIFIER + ">";
554
+ if (startTag == "<!--") {
555
+ return startTag + DATA_IDENTIFIER;
556
+ }
557
+ // the attribute belongs to the element that opens the wrapper, which is not always the whole
558
+ // start tag: the CDATA rung opens with an <svg> and then a markup declaration that takes none
559
+ const tagEnd = startTag.indexOf(">");
560
+ return startTag.slice(0, tagEnd) + " id=" + DATA_IDENTIFIER + startTag.slice(tagEnd);
523
561
  }
524
562
 
525
563
  function getStartHTMLArray(pageData, options, lastModDate, startTag = "") {
@@ -543,24 +581,30 @@ function getStartHTMLArray(pageData, options, lastModDate, startTag = "") {
543
581
  const localHeader = pdfEntry ? pdfEntry.localHeader : new Uint8Array(0);
544
582
  const embeddedPdfText = TEXT_DECODER.decode(localHeader) + TEXT_DECODER.decode(embeddedPdf);
545
583
  const pdfTagIndex = findEmbeddedDataTagIndex(embeddedPdfText);
546
- const [pdfStartTag, pdfEndTag] = pdfTagIndex == -1 ? ["", ""] : EMBEDDED_DATA_TAGS[pdfTagIndex];
547
- let htmlArray1 = new TextEncoder().encode(html + pdfStartTag);
548
- if (htmlArray1.length + localHeader.length > PDF_HEADER_MAX_OFFSET) {
549
- // PDF readers only scan the start of the file for the %PDF- header, and the page
550
- // doctype is copied verbatim: it is the one part of the prefix with no bound, so a
551
- // long one is replaced rather than pushing the header out of the scan window
552
- htmlArray1 = new TextEncoder().encode(bom + MINIMAL_DOCTYPE + documentStart + pdfStartTag);
553
- }
554
- const htmlArray2 = new TextEncoder().encode(pdfEndTag + comment + htmlHeadData + startTag);
555
- htmlArray = new Uint8Array(htmlArray1.length + localHeader.length + embeddedPdf.length + htmlArray2.length);
556
- htmlArray.set(htmlArray1);
557
- htmlArray.set(localHeader, htmlArray1.length);
558
- htmlArray.set(embeddedPdf, htmlArray1.length + localHeader.length);
559
- htmlArray.set(htmlArray2, htmlArray1.length + localHeader.length + embeddedPdf.length);
560
- if (pdfEntry) {
561
- pdfEntry.offset = htmlArray1.length;
584
+ if (pdfTagIndex == -1) {
585
+ dropUnhiddenFace(options, "embeddedPdf");
586
+ pdfEntry = undefined;
587
+ } else {
588
+ const [pdfStartTag, pdfEndTag] = EMBEDDED_DATA_TAGS[pdfTagIndex];
589
+ let htmlArray1 = new TextEncoder().encode(html + pdfStartTag);
590
+ if (htmlArray1.length + localHeader.length > PDF_HEADER_MAX_OFFSET) {
591
+ // PDF readers only scan the start of the file for the %PDF- header, and the page
592
+ // doctype is copied verbatim: it is the one part of the prefix with no bound, so a
593
+ // long one is replaced rather than pushing the header out of the scan window
594
+ htmlArray1 = new TextEncoder().encode(bom + MINIMAL_DOCTYPE + documentStart + pdfStartTag);
595
+ }
596
+ const htmlArray2 = new TextEncoder().encode(pdfEndTag + comment + htmlHeadData + startTag);
597
+ htmlArray = new Uint8Array(htmlArray1.length + localHeader.length + embeddedPdf.length + htmlArray2.length);
598
+ htmlArray.set(htmlArray1);
599
+ htmlArray.set(localHeader, htmlArray1.length);
600
+ htmlArray.set(embeddedPdf, htmlArray1.length + localHeader.length);
601
+ htmlArray.set(htmlArray2, htmlArray1.length + localHeader.length + embeddedPdf.length);
602
+ if (pdfEntry) {
603
+ pdfEntry.offset = htmlArray1.length;
604
+ }
562
605
  }
563
- } else {
606
+ }
607
+ if (!options.embeddedPdf) {
564
608
  htmlArray = new TextEncoder().encode(html + comment + htmlHeadData + startTag);
565
609
  }
566
610
  return { htmlArray, pdfEntry };
@@ -624,8 +668,45 @@ function findExtraDataTags(textContent, pageData, options, script, writeEntries,
624
668
  // a rung is rejected on its end pattern, which terminates the wrapper, and on its start
625
669
  // pattern: script data has escape states the raw text rungs do not have, where "<!--"
626
670
  // followed by "<script" leaves "</script>" unable to close the element at all
627
- function findEmbeddedDataTagIndex(text) {
628
- return EMBEDDED_DATA_REGEXPS.slice(0, -1).findIndex(([startRegExp, endRegExp]) => !text.match(startRegExp) && !text.match(endRegExp));
671
+ function findEmbeddedDataTagIndex(text, fromIndex = 0) {
672
+ const tagIndex = EMBEDDED_DATA_REGEXPS.slice(fromIndex, -1).findIndex(([startRegExp, endRegExp]) => !text.match(startRegExp) && !text.match(endRegExp));
673
+ return tagIndex == -1 ? -1 : tagIndex + fromIndex;
674
+ }
675
+
676
+ // a face exists only while a rung can hide it. When the payload names every rung, the older
677
+ // fallback emitted it unwrapped, on the grounds that the page still rendered: but the payload's
678
+ // markup then joins the document, and a payload that is itself an archive contributes an
679
+ // sfz-data node ahead of this file's own. A reader takes that one and extracts it, checksum and
680
+ // all, with nothing to say the archive it returned is not the archive the file was built around.
681
+ // Dropping the face costs a picture; keeping it costs the archive
682
+ // what follows the start tag is the checksum of the chunk carrying it, four bytes only known once
683
+ // the tag is chosen: a comment they open with ">" or "->" is closed by the parser there and then,
684
+ // leaving the image data to be read as markup. Stepping past the comment rung means searching from
685
+ // the next one, not taking it — a rung qualifies on the payload, and the payload had no say in
686
+ // which rung the checksum sent the writer to
687
+ function getImageHTMLChunk(pageData, options, lastModDate) {
688
+ const embeddedImageText = TEXT_DECODER.decode(getEmbeddedImageData(options.embeddedImage));
689
+ let tagIndex = findEmbeddedDataTagIndex(embeddedImageText);
690
+ while (tagIndex != -1) {
691
+ const [startTag, endTag] = EMBEDDED_DATA_TAGS[tagIndex];
692
+ const startHTMLData = getStartHTMLArray(pageData, options, lastModDate, startTag);
693
+ const htmlData = new Uint8Array([...getLength(startHTMLData.htmlArray.length + 4), ...[0x74, 0x45, 0x58, 0x74, 0x50, 0x4e, 0x47, 0], ...startHTMLData.htmlArray]);
694
+ const htmlDataCRC = getCRC32(htmlData, 4);
695
+ if (tagIndex == 0 && (htmlDataCRC[0] == 0x3e || (htmlDataCRC[0] == 0x2d && htmlDataCRC[1] == 0x3e))) {
696
+ tagIndex = findEmbeddedDataTagIndex(embeddedImageText, tagIndex + 1);
697
+ } else {
698
+ return { endTag, startHTMLData, htmlData, htmlDataCRC };
699
+ }
700
+ }
701
+ }
702
+
703
+ function getEmbeddedImageData(embeddedImage) {
704
+ return embeddedImage.slice(PNG_SIGNATURE_LENGTH + PNG_IHDR_LENGTH, embeddedImage.length - PNG_IEND_LENGTH);
705
+ }
706
+
707
+ function dropUnhiddenFace(options, name) {
708
+ delete options[name];
709
+ console.warn(UNHIDDEN_FACE_WARNING_MESSAGE, name); // eslint-disable-line no-console
629
710
  }
630
711
 
631
712
  async function writeData(writable, array) {
@@ -669,7 +750,7 @@ async function addPageResources(zipWriter, pageData, options, prefixName, url) {
669
750
  ]);
670
751
  }
671
752
 
672
- async function addFile(zipWriter, prefixName, data, disableCompresson) {
753
+ async function addFile(zipWriter, prefixName, data, disableCompression) {
673
754
  const dataReader = typeof data.content == "string" ? new TextReader(data.content) : new BlobReader(new Blob([new Uint8Array(data.content)]));
674
755
  const options = { password: data.password, bufferedWrite: true };
675
756
  if (!data.password) {
@@ -677,7 +758,7 @@ async function addFile(zipWriter, prefixName, data, disableCompresson) {
677
758
  // password the resource URLs would be readable while the same map in manifest.json is not
678
759
  options.comment = data.url && data.url.startsWith("data:") ? "data:" : data.url;
679
760
  }
680
- if (NO_COMPRESSION_EXTENSIONS.includes(data.extension) || disableCompresson) {
761
+ if (NO_COMPRESSION_EXTENSIONS.includes(data.extension) || disableCompression) {
681
762
  options.level = 0;
682
763
  }
683
764
  await zipWriter.add(prefixName + data.name, dataReader, options);
package/single-file.js CHANGED
@@ -111,23 +111,11 @@ async function getPageData(options = {}, initOptions, doc, win) {
111
111
  }
112
112
  const pageData = await processor.getPageData();
113
113
  if (options.compressContent) {
114
- const blob = await processors.compression.process(pageData, {
115
- insertTextBody: options.insertTextBody,
116
- url: options.url,
117
- createRootDirectory: options.createRootDirectory,
118
- selfExtractingArchive: options.selfExtractingArchive,
119
- disableCompression: options.disableCompression,
120
- extractDataFromPage: options.extractDataFromPage,
121
- preventAppendedData: options.preventAppendedData,
122
- declareAppendedData: options.declareAppendedData,
123
- insertCanonicalLink: options.insertCanonicalLink,
124
- insertMetaNoIndex: options.insertMetaNoIndex,
125
- insertMetaCSP: options.insertMetaCSP,
126
- password: options.password,
127
- zipScript: options.zipScript,
128
- embeddedImage: options.embeddedImage,
129
- embeddedPdf: options.embeddedPdf
130
- });
114
+ const compressionOptions = {};
115
+ for (const optionName of processors.compression.PROCESS_OPTION_NAMES) {
116
+ compressionOptions[optionName] = options[optionName];
117
+ }
118
+ const blob = await processors.compression.process(pageData, compressionOptions);
131
119
  delete pageData.resources;
132
120
  let arrayBuffer;
133
121
  if (globalThis.FileReader) {
@@ -26,6 +26,7 @@ any check failed.
26
26
  | `format-rules.js` | The rules of the format: charset round trip, the wrapper-tag ladder and its selection tests, the identifier, appended-data placement and declaration, password scope, the PDF and PNG faces. |
27
27
  | `stored-trigger.js` | That a stored (uncompressed) entry whose bytes contain a rung's pattern moves the writer to the right rung. |
28
28
  | `check-determinism.js` | That the same inputs produce the same bytes, and that the levers which should change the output do. |
29
+ | `option-wiring.js` | That every option `compression.js` reads is either declared as a caller option or classified as internal, and that `single-file.js` still builds its argument from that declaration. Guards the layer the other three suites sit below. |
29
30
 
30
31
  ## The tools
31
32
 
@@ -166,7 +166,7 @@ function countIdentifiers(text) {
166
166
  // the last rung is exempt from both tests: it has no terminator and no tokenizer
167
167
  // states, so a payload naming every rung still fits inside it
168
168
  const content = new Uint8Array(4096).fill(0x21);
169
- content.set(new TextEncoder().encode("<!--<script<style<noframes<noembed<iframe<xmp<plaintext"), 128);
169
+ content.set(new TextEncoder().encode("<!--<script<style<noframes<noembed<iframe<xmp<![CDATA[<plaintext"), 128);
170
170
  const options = makeOptions();
171
171
  const pageData = makePageData(19, 4 * 1024);
172
172
  pageData.resources.images.push({ name: "images/all-starts.png", extension: ".png", content });
@@ -176,6 +176,33 @@ function countIdentifiers(text) {
176
176
  check("the last rung still carries the identifier", decodeText(bytes).includes("<plaintext id=sfz-data>PK"), true);
177
177
  }
178
178
 
179
+ // the CDATA rung is the last one that can be closed, so it is what stands between a payload
180
+ // naming every element rung and <plaintext>, whose selection costs the appended-data placement.
181
+ // Its identifier goes on the svg, not on the markup declaration, which takes no attributes
182
+ {
183
+ const content = new Uint8Array(4096).fill(0x21);
184
+ content.set(new TextEncoder().encode("<!--<script<style<noframes<noembed<iframe<xmp"), 128);
185
+ const options = makeOptions();
186
+ const pageData = makePageData(24, 4 * 1024);
187
+ pageData.resources.images.push({ name: "images/no-cdata.png", extension: ".png", content });
188
+ const { bytes } = await runProcess(pageData, options);
189
+ check("a payload naming every element rung stops at the cdata rung", options.extractDataFromPageTags[0], "<svg><![CDATA[");
190
+ check("the cdata rung identifies the svg, not the declaration", decodeText(bytes).includes("<svg id=sfz-data><![CDATA[PK"), true);
191
+ check("the cdata rung leaves the appended data placement alone", options.preventAppendedData !== true, true);
192
+ }
193
+
194
+ // the terminator is the whole test for this rung, so a payload holding it must step past
195
+ {
196
+ const content = new Uint8Array(4096).fill(0x21);
197
+ content.set(new TextEncoder().encode("<!--<script<style<noframes<noembed<iframe<xmp]]>"), 128);
198
+ const options = makeOptions();
199
+ const pageData = makePageData(25, 4 * 1024);
200
+ pageData.resources.images.push({ name: "images/cdata-end.png", extension: ".png", content });
201
+ const { bytes } = await runProcess(pageData, options);
202
+ check("a payload holding \"]]>\" defeats the cdata rung", options.extractDataFromPageTags[0], "<plaintext>");
203
+ check("the rung below takes it", decodeText(bytes).includes("<plaintext id=sfz-data>PK"), true);
204
+ }
205
+
179
206
  // script data has escape states the raw text rungs do not: "<!--" then "<script" in a
180
207
  // payload leaves "</script>" unable to close the element, so a face carrying both must
181
208
  // step past the script rung even though it holds no closer at all
@@ -202,6 +229,42 @@ const DOUBLE_ESCAPE = "--> <!-- <script ";
202
229
  check("the png face takes no script rung", decodeText(bytes).includes("<script type=sfz-data>"), false);
203
230
  }
204
231
 
232
+ // a face is hidden by the same ladder as the zip data, minus the rung that cannot be closed:
233
+ // a payload naming all seven of the rest leaves nowhere to put it. Writing it unwrapped was the
234
+ // older behaviour, and it is the dangerous one, because the payload's own markup then joins the
235
+ // document. These payloads carry the identifier the way a nested archive does: a reader looking
236
+ // for one node finds two, takes the first, and extracts an archive that checksums
237
+ const ALL_FACE_RUNGS = "<!--sfz-data<script<style<noframes<noembed<iframe<xmp<![CDATA[";
238
+
239
+ {
240
+ const embeddedPdf = new TextEncoder().encode("%PDF-1.4\n1 0 obj\n<< /X (" + ALL_FACE_RUNGS + ") >>\nendobj\ntrailer\n<<>>\n%%EOF\n");
241
+ const options = makeOptions({ embeddedPdf });
242
+ const pageData = makePageData(22, 4 * 1024);
243
+ const { bytes } = await runProcess(pageData, options);
244
+ const text = decodeText(bytes);
245
+ check("an unhidable pdf face is dropped", text.includes("%PDF-"), false);
246
+ check("the dropped pdf face leaves one identifier", countIdentifiers(text), 1);
247
+ const zipReader = new ZipReader(new BlobReader(new Blob([bytes])));
248
+ const entries = await zipReader.getEntries();
249
+ await zipReader.close();
250
+ check("the archive survives the dropped pdf face", entries.length > 0, true);
251
+ check("no page.pdf entry is left behind", entries.some(entry => entry.filename.endsWith("page.pdf")), false);
252
+ }
253
+
254
+ {
255
+ const embeddedImage = new Uint8Array(8 + 25 + 512 + 12);
256
+ embeddedImage.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
257
+ embeddedImage.fill(0x41, 33, 33 + 512);
258
+ embeddedImage.set(new TextEncoder().encode(ALL_FACE_RUNGS), 100);
259
+ const options = makeOptions({ embeddedImage });
260
+ const pageData = makePageData(23, 4 * 1024);
261
+ const { bytes } = await runProcess(pageData, options);
262
+ const text = decodeText(bytes);
263
+ check("an unhidable png face is dropped", bytes[0], 0x3c);
264
+ check("the dropped png face leaves one identifier", countIdentifiers(text), 1);
265
+ check("the page keeps its doctype without the png face", text.startsWith("<!DOCTYPE html>"), true);
266
+ }
267
+
205
268
  // every rung pattern is matched case-insensitively, because the HTML tokenizer closes an
206
269
  // element on any case of its end tag. Dropping that would pick a rung the payload itself
207
270
  // terminates, and no other test in this file would notice
@@ -245,6 +308,29 @@ for (const seed of [147, 148, 616]) {
245
308
  check("no wrapper comment is closed abruptly, seed " + seed, /<!--(>|->)/.test(decodeText(bytes)), false);
246
309
  }
247
310
 
311
+ // leaving the comment rung is a search, not an increment. The checksum decides only that the
312
+ // comment is unusable; which rung is usable is still the payload's to say, and these payloads
313
+ // terminate the rung immediately below. Taking it on trust put the image data, the chunk framing
314
+ // and the whole ZIP region outside the wrapper, 19KB of it, read by the parser as markup
315
+ for (const seed of [148, 616]) {
316
+ const embeddedImage = randomPng(seed);
317
+ embeddedImage.set(new TextEncoder().encode("</script>"), 100);
318
+ const options = makeOptions({ embeddedImage });
319
+ const pageData = makePageData(seed, 2 * 1024);
320
+ const { bytes } = await runProcess(pageData, options);
321
+ const text = decodeText(bytes);
322
+ check("the step off the comment rung skips what the payload terminates, seed " + seed, text.includes("<script type=sfz-data>"), false);
323
+ check("the step off the comment rung lands on a rung that qualifies, seed " + seed, text.includes("<style type=sfz-data>"), true);
324
+ }
325
+
326
+ // the control for the pair above: the same seeds, the same step, a payload that leaves the rung
327
+ // below usable — the writer must still take it rather than skip past it
328
+ {
329
+ const options = makeOptions({ embeddedImage: randomPng(148) });
330
+ const { bytes } = await runProcess(makePageData(148, 2 * 1024), options);
331
+ check("the step off the comment rung takes a usable rung", decodeText(bytes).includes("<script type=sfz-data>"), true);
332
+ }
333
+
248
334
  function readAppendedData(bytes) {
249
335
  const view = new DataView(bytes.buffer, bytes.byteOffset);
250
336
  for (let index = bytes.length - 22; index >= 0; index--) {
@@ -0,0 +1,63 @@
1
+ // The compression processor is reached through a whitelist in single-file.js. An option this
2
+ // module reads but the whitelist omits is undefined at every call site, so the feature works on
3
+ // the multi-page path (which calls createArchive directly) and silently does nothing on every
4
+ // single-page capture. That is how --declare-appended-data and --include-BOM both shipped inert,
5
+ // and no test caught either: the other suites call compression.process() directly, one layer
6
+ // below the wiring they would have to see.
7
+ import { PROCESS_OPTION_NAMES } from "../../processors/compression/compression.js";
8
+
9
+ // names matched by the source scan that are NOT caller options. Keeping them listed here rather
10
+ // than filtering them out silently is the point of this test: a new name must be classified as
11
+ // one thing or the other before the suite goes green again
12
+ const INTERNAL_OPTION_NAMES = [
13
+ // state the module sets on itself between build passes
14
+ "extraData",
15
+ "extraDataSize",
16
+ "extraDataSizeDropped",
17
+ "extractDataFromPageTags",
18
+ "preventEmbeddedPdfEntry",
19
+ // supplied by compression-packager.js, which calls createArchive directly
20
+ "embeddedScreenshotImage",
21
+ "multiPageArchive",
22
+ // read for the root directory name, but no caller has ever passed it
23
+ "tabId",
24
+ // a different `options` object: the per-entry zip options built inside addFile()
25
+ "comment",
26
+ "level"
27
+ ];
28
+
29
+ let failed = false;
30
+
31
+ function check(label, actual, expected) {
32
+ const ok = actual === expected;
33
+ console.log(`${ok ? "PASS" : "FAIL"} ${label}: ${actual}${ok ? "" : " (expected " + expected + ")"}`);
34
+ failed ||= !ok;
35
+ }
36
+
37
+ const compressionSource = await Deno.readTextFile(new URL("../../processors/compression/compression.js", import.meta.url));
38
+ const singleFileSource = await Deno.readTextFile(new URL("../../single-file.js", import.meta.url));
39
+ const readNames = [...new Set([...compressionSource.matchAll(/options\.([A-Za-z0-9_]+)/g)].map(match => match[1]))].sort();
40
+
41
+ const unclassified = readNames.filter(name => !PROCESS_OPTION_NAMES.includes(name) && !INTERNAL_OPTION_NAMES.includes(name));
42
+ check("every option compression.js reads is classified", unclassified.join(", "), "");
43
+
44
+ const unread = PROCESS_OPTION_NAMES.filter(name => !readNames.includes(name));
45
+ check("no declared caller option is dead", unread.join(", "), "");
46
+
47
+ const misclassified = INTERNAL_OPTION_NAMES.filter(name => PROCESS_OPTION_NAMES.includes(name));
48
+ check("no option is both a caller option and internal", misclassified.join(", "), "");
49
+
50
+ check("the declared list is sorted", PROCESS_OPTION_NAMES.join(), [...PROCESS_OPTION_NAMES].sort().join());
51
+
52
+ // the guard is only worth anything while single-file.js builds its argument from the list; a
53
+ // literal object there would drift again, which is exactly the bug this file exists to prevent
54
+ check("single-file.js builds its argument from the list", singleFileSource.includes("PROCESS_OPTION_NAMES"), true);
55
+ check("single-file.js keeps no literal option whitelist", /insertTextBody:\s*options\.insertTextBody/.test(singleFileSource), false);
56
+
57
+ check("includeBOM reaches the compressed path", PROCESS_OPTION_NAMES.includes("includeBOM"), true);
58
+
59
+ if (failed) {
60
+ console.log("FAILED");
61
+ Deno.exit(1);
62
+ }
63
+ console.log("OK");
@@ -2,7 +2,7 @@ import { makePageData, makeOptions, runProcess, mulberry32 } from "./common.js";
2
2
 
3
3
  function storedResource(name, literals) {
4
4
  const rand = mulberry32(0xbeef);
5
- const bytes = new Uint8Array(4096).map(() => (rand() * 256) | 0);
5
+ const bytes = new Uint8Array(Math.max(4096, 256 + literals.length * 512)).map(() => (rand() * 256) | 0);
6
6
  const encoder = new TextEncoder();
7
7
  let offset = 256;
8
8
  for (const literal of literals) {
@@ -33,7 +33,7 @@ function check(label, actual, expected) {
33
33
  const options = makeOptions();
34
34
  const pageData = makePageData(2, 64 * 1024);
35
35
  pageData.resources.images.push(storedResource("photo.jpg",
36
- ["-->", "</noscript>", "</noframes>", "</noembed>", "</script>", "</style>", "</iframe>", "</xmp>"]));
36
+ ["-->", "</noscript>", "</noframes>", "</noembed>", "</script>", "</style>", "</iframe>", "</xmp>", "]]>"]));
37
37
  const result = await runProcess(pageData, options);
38
38
  check("all closers exhaust to", result.fallbackTag, "<plaintext>");
39
39
  check("all closers keep extraction", result.extractionDisabled, false);