@intentic/fileq 1.325.0 → 1.327.0

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/src/testing.ts DELETED
@@ -1,620 +0,0 @@
1
- import { gzipSync, strToU8, zipSync } from "fflate";
2
-
3
- // Fixture builders, one per binary format the suites derive, shared so deriver tests and CLI tests can't disagree on
4
- // what "a docx" means.
5
- // Each builds the smallest file its real-world parser accepts, in code rather than committed binaries, so the fixture's
6
- // content is reviewable in a diff.
7
-
8
- // Fixture text sits inside XML text nodes; a hostile fixture's payload must be text, not broken XML.
9
- const xmlEscape = (text: string): string => text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
10
-
11
- const CONTENT_TYPES = (overrides: string): string =>
12
- `<?xml version="1.0" encoding="UTF-8"?>
13
- <Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
14
- <Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
15
- <Default Extension="xml" ContentType="application/xml"/>
16
- ${overrides}
17
- </Types>`;
18
-
19
- const RELS = (target: string): string =>
20
- `<?xml version="1.0" encoding="UTF-8"?>
21
- <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
22
- <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="${target}"/>
23
- </Relationships>`;
24
-
25
- /** A one-heading, N-paragraph Word document mammoth accepts. */
26
- export const docxBytes = (heading: string, paragraphs: readonly string[]): Uint8Array => {
27
- const body = [
28
- `<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:t>${xmlEscape(heading)}</w:t></w:r></w:p>`,
29
- ...paragraphs.map((text) => `<w:p><w:r><w:t xml:space="preserve">${xmlEscape(text)}</w:t></w:r></w:p>`),
30
- ].join("");
31
- return zipSync({
32
- "[Content_Types].xml": strToU8(
33
- CONTENT_TYPES(
34
- '<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>',
35
- ),
36
- ),
37
- "_rels/.rels": strToU8(RELS("word/document.xml")),
38
- "word/document.xml": strToU8(
39
- `<?xml version="1.0" encoding="UTF-8"?>
40
- <w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>${body}</w:body></w:document>`,
41
- ),
42
- });
43
- };
44
-
45
- const slideXml = (lines: readonly string[]): string =>
46
- `<?xml version="1.0" encoding="UTF-8"?>
47
- <p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
48
- <p:cSld><p:spTree>${lines.map((line) => `<p:sp><p:txBody><a:p><a:r><a:t>${xmlEscape(line)}</a:t></a:r></a:p></p:txBody></p:sp>`).join("")}</p:spTree></p:cSld>
49
- </p:sld>`;
50
-
51
- /** A presentation with one slide per entry of `slides`, each slide one text line per entry. */
52
- export const pptxBytes = (slides: readonly (readonly string[])[]): Uint8Array => {
53
- const files: Record<string, Uint8Array> = {
54
- "[Content_Types].xml": strToU8(
55
- CONTENT_TYPES(
56
- '<Override PartName="/ppt/presentation.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"/>',
57
- ),
58
- ),
59
- "_rels/.rels": strToU8(RELS("ppt/presentation.xml")),
60
- "ppt/presentation.xml": strToU8(
61
- '<?xml version="1.0"?><p:presentation xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"/>',
62
- ),
63
- };
64
- slides.forEach((lines, index) => {
65
- files[`ppt/slides/slide${index + 1}.xml`] = strToU8(slideXml(lines));
66
- });
67
- return zipSync(files);
68
- };
69
-
70
- /**
71
- * Smallest well-formed one-page PDF with a text layer saying `text`; honest xref offsets keep it out of pdf.js's
72
- * damaged-file recovery path.
73
- */
74
- export const pdfBytes = (text: string): Uint8Array => {
75
- const objects = [
76
- "1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n",
77
- "2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n",
78
- "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>\nendobj\n",
79
- `4 0 obj\n<< /Length ${text.length + 31} >>\nstream\nBT /F1 12 Tf 72 720 Td (${text}) Tj ET\nendstream\nendobj\n`,
80
- "5 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n",
81
- ];
82
- const header = "%PDF-1.4\n";
83
- const offsets: number[] = [];
84
- let position = header.length;
85
- for (const object of objects) {
86
- offsets.push(position);
87
- position += object.length;
88
- }
89
- const xref = [
90
- "xref",
91
- `0 ${objects.length + 1}`,
92
- "0000000000 65535 f ",
93
- ...offsets.map((offset) => `${String(offset).padStart(10, "0")} 00000 n `),
94
- "",
95
- ].join("\n");
96
- const trailer = `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${position}\n%%EOF\n`;
97
- return strToU8(`${header}${objects.join("")}${xref}${trailer}`);
98
- };
99
-
100
- /** One second of 8kHz mono 8-bit silence in a well-formed RIFF/WAVE container. */
101
- export const wavBytes = (): Uint8Array => {
102
- const sampleRate = 8000;
103
- const data = new Uint8Array(sampleRate).fill(128);
104
- const buffer = new ArrayBuffer(44 + data.length);
105
- const view = new DataView(buffer);
106
- const ascii = (offset: number, value: string): void => {
107
- for (let i = 0; i < value.length; i++) {
108
- view.setUint8(offset + i, value.charCodeAt(i));
109
- }
110
- };
111
- ascii(0, "RIFF");
112
- view.setUint32(4, 36 + data.length, true);
113
- ascii(8, "WAVE");
114
- ascii(12, "fmt ");
115
- view.setUint32(16, 16, true); // fmt chunk size
116
- view.setUint16(20, 1, true); // PCM
117
- view.setUint16(22, 1, true); // mono
118
- view.setUint32(24, sampleRate, true);
119
- view.setUint32(28, sampleRate, true); // byte rate (8-bit mono)
120
- view.setUint16(32, 1, true); // block align
121
- view.setUint16(34, 8, true); // bits per sample
122
- ascii(36, "data");
123
- view.setUint32(40, data.length, true);
124
- const bytes = new Uint8Array(buffer);
125
- bytes.set(data, 44);
126
- return bytes;
127
- };
128
-
129
- // OpenDocument/EPUB need `mimetype` first in the archive and stored (level 0), per spec.
130
- const storedMimetype = (mimetype: string): [Uint8Array, { level: 0 }] => [strToU8(mimetype), { level: 0 }];
131
-
132
- /** A one-heading, N-paragraph OpenDocument text with a two-item list and a 1×2 table, in ODF's own vocabulary. */
133
- export const odtBytes = (heading: string, paragraphs: readonly string[]): Uint8Array =>
134
- zipSync({
135
- mimetype: storedMimetype("application/vnd.oasis.opendocument.text"),
136
- "META-INF/manifest.xml": strToU8(
137
- `<?xml version="1.0" encoding="UTF-8"?>
138
- <manifest:manifest xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0" manifest:version="1.2">
139
- <manifest:file-entry manifest:full-path="/" manifest:media-type="application/vnd.oasis.opendocument.text"/>
140
- <manifest:file-entry manifest:full-path="content.xml" manifest:media-type="text/xml"/>
141
- </manifest:manifest>`,
142
- ),
143
- "meta.xml": strToU8(
144
- `<?xml version="1.0" encoding="UTF-8"?>
145
- <office:document-meta xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:dc="http://purl.org/dc/elements/1.1/"><office:meta><dc:title>${xmlEscape(heading)}</dc:title></office:meta></office:document-meta>`,
146
- ),
147
- "content.xml": strToU8(
148
- `<?xml version="1.0" encoding="UTF-8"?>
149
- <office:document-content xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0">
150
- <office:automatic-styles><style:style style:name="P1" style:family="paragraph"/></office:automatic-styles>
151
- <office:body><office:text>
152
- <text:h text:style-name="Heading_20_1" text:outline-level="1">${xmlEscape(heading)}</text:h>
153
- ${paragraphs.map((text) => `<text:p text:style-name="P1">${xmlEscape(text)}</text:p>`).join("\n")}
154
- <office:annotation><text:p>a reviewer's comment, not the document</text:p></office:annotation>
155
- <text:list text:style-name="L1"><text:list-item><text:p>first item</text:p></text:list-item><text:list-item><text:p>second<text:s text:c="2"/>item</text:p></text:list-item></text:list>
156
- <table:table table:name="T1"><table:table-column table:number-columns-repeated="2"/><table:table-row><table:table-cell office:value-type="string"><text:p>cell a</text:p></table:table-cell><table:table-cell office:value-type="string"><text:p>cell b</text:p></table:table-cell></table:table-row></table:table>
157
- </office:text></office:body></office:document-content>`,
158
- ),
159
- });
160
-
161
- const odfPackage = (mimetype: string, content: string): Uint8Array =>
162
- zipSync({
163
- mimetype: storedMimetype(mimetype),
164
- "META-INF/manifest.xml": strToU8(
165
- `<?xml version="1.0" encoding="UTF-8"?>
166
- <manifest:manifest xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0" manifest:version="1.2">
167
- <manifest:file-entry manifest:full-path="/" manifest:media-type="${mimetype}"/>
168
- <manifest:file-entry manifest:full-path="content.xml" manifest:media-type="text/xml"/>
169
- </manifest:manifest>`,
170
- ),
171
- "content.xml": strToU8(
172
- `<?xml version="1.0" encoding="UTF-8"?>
173
- <office:document-content xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" xmlns:presentation="urn:oasis:names:tc:opendocument:xmlns:presentation:1.0">
174
- <office:body>${content}</office:body></office:document-content>`,
175
- ),
176
- });
177
-
178
- /** An OpenDocument spreadsheet: one sheet per entry, each row padded out the way a real .ods pads its own. */
179
- export const odsBytes = (sheets: readonly { readonly name: string; readonly rows: readonly (readonly string[])[] }[]): Uint8Array =>
180
- odfPackage(
181
- "application/vnd.oasis.opendocument.spreadsheet",
182
- `<office:spreadsheet>${sheets
183
- .map(
184
- (sheet) =>
185
- `<table:table table:name="${xmlEscape(sheet.name)}">${sheet.rows
186
- .map(
187
- (row) =>
188
- `<table:table-row>${row
189
- .map((cell) => `<table:table-cell office:value-type="string"><text:p>${xmlEscape(cell)}</text:p></table:table-cell>`)
190
- .join("")}<table:table-cell table:number-columns-repeated="1013"/></table:table-row>`,
191
- )
192
- .join("")}<table:table-row table:number-rows-repeated="1048576"><table:table-cell table:number-columns-repeated="1024"/></table:table-row></table:table>`,
193
- )
194
- .join("")}</office:spreadsheet>`,
195
- );
196
-
197
- /** An OpenDocument presentation: one page per entry, each with its lines and a speaker's note. */
198
- export const odpBytes = (slides: readonly { readonly name: string; readonly lines: readonly string[]; readonly note?: string }[]): Uint8Array =>
199
- odfPackage(
200
- "application/vnd.oasis.opendocument.presentation",
201
- `<office:presentation>${slides
202
- .map(
203
- (slide) =>
204
- `<draw:page draw:name="${xmlEscape(slide.name)}">` +
205
- `<draw:frame><draw:text-box>${slide.lines.map((line) => `<text:p>${xmlEscape(line)}</text:p>`).join("")}</draw:text-box></draw:frame>${
206
- slide.note === undefined
207
- ? ""
208
- : `<presentation:notes><draw:frame><draw:text-box><text:p>${xmlEscape(slide.note)}</text:p></draw:text-box></draw:frame></presentation:notes>`
209
- }</draw:page>`,
210
- )
211
- .join("")}</office:presentation>`,
212
- );
213
-
214
- /** A Word-shaped RTF: a font table and an ignorable group to skip, a \\'hh escape, and one table row. */
215
- export const rtfBytes = (paragraphs: readonly string[]): Uint8Array =>
216
- strToU8(
217
- `{\\rtf1\\ansi\\ansicpg1252\\deff0{\\fonttbl{\\f0\\froman Times New Roman;}}{\\*\\generator Not text;}${
218
- paragraphs.map((text) => `\\pard ${text.replaceAll("\\", "\\\\").replaceAll("{", "\\{").replaceAll("}", "\\}")}\\par`).join("")
219
- }\\pard\\trowd\\cellx1440\\cellx2880 left\\cell right\\cell\\row}`,
220
- );
221
-
222
- /** An EPUB 3 with one XHTML chapter per entry, spine-ordered, plus a nav document that must not read as a chapter. */
223
- export const epubBytes = (title: string, chapters: readonly { readonly title: string; readonly body: string }[]): Uint8Array => {
224
- const xhtml = (heading: string, body: string): string =>
225
- `<?xml version="1.0" encoding="UTF-8"?>
226
- <html xmlns="http://www.w3.org/1999/xhtml"><head><title>${xmlEscape(heading)}</title></head><body><h1>${xmlEscape(heading)}</h1><p>${xmlEscape(body)}</p></body></html>`;
227
- const files: Record<string, Uint8Array | [Uint8Array, { level: 0 }]> = {
228
- mimetype: storedMimetype("application/epub+zip"),
229
- "META-INF/container.xml": strToU8(
230
- `<?xml version="1.0" encoding="UTF-8"?>
231
- <container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container"><rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles></container>`,
232
- ),
233
- "OEBPS/content.opf": strToU8(
234
- `<?xml version="1.0" encoding="UTF-8"?>
235
- <package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="id"><metadata xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title>${xmlEscape(title)}</dc:title><dc:identifier id="id">urn:uuid:fixture</dc:identifier></metadata>
236
- <manifest><item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>${chapters.map((_, index) => `<item id="c${index + 1}" href="text/chapter${index + 1}.xhtml" media-type="application/xhtml+xml"/>`).join("")}</manifest>
237
- <spine><itemref idref="nav"/>${chapters.map((_, index) => `<itemref idref="c${index + 1}"/>`).join("")}</spine></package>`,
238
- ),
239
- "OEBPS/nav.xhtml": strToU8(
240
- `<?xml version="1.0" encoding="UTF-8"?>
241
- <html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops"><head><title>Contents</title></head><body><nav epub:type="toc"><ol>${chapters.map((chapter, index) => `<li><a href="text/chapter${index + 1}.xhtml">${xmlEscape(chapter.title)}</a></li>`).join("")}</ol></nav></body></html>`,
242
- ),
243
- };
244
- chapters.forEach((chapter, index) => {
245
- files[`OEBPS/text/chapter${index + 1}.xhtml`] = strToU8(xhtml(chapter.title, chapter.body));
246
- });
247
- return zipSync(files);
248
- };
249
-
250
- /** A Jupyter notebook (nbformat 4) with a markdown title cell, a code cell with stream output, and one rich output. */
251
- export const ipynbText = (title: string, code: string, outputLines: readonly string[]): string =>
252
- JSON.stringify({
253
- cells: [
254
- { cell_type: "markdown", metadata: {}, source: [`# ${title}\n`, "\n", "Some prose about the analysis.\n"] },
255
- {
256
- cell_type: "code",
257
- execution_count: 1,
258
- metadata: {},
259
- source: [code],
260
- outputs: [
261
- { output_type: "stream", name: "stdout", text: outputLines.map((line) => `${line}\n`) },
262
- { output_type: "display_data", metadata: {}, data: { "image/png": "iVBORw0KGgo=" } },
263
- ],
264
- },
265
- ],
266
- metadata: { kernelspec: { language: "python", name: "python3" }, language_info: { name: "python" } },
267
- nbformat: 4,
268
- nbformat_minor: 5,
269
- });
270
-
271
- /** A zip holding one member per key, deflated the way any packing tool writes it. */
272
- export const zipBytes = (files: Readonly<Record<string, string>>): Uint8Array =>
273
- zipSync(Object.fromEntries(Object.entries(files).map(([path, text]) => [path, strToU8(text)])));
274
-
275
- const TAR_BLOCK = 512;
276
-
277
- // One ustar header block. The checksum is summed with its own field read as spaces, which is the rule that makes a
278
- // header verifiable at all.
279
- const tarHeader = (path: string, size: number, type: string): Uint8Array => {
280
- const block = new Uint8Array(TAR_BLOCK);
281
- const put = (offset: number, value: string): void => block.set(strToU8(value), offset);
282
- put(0, path.slice(0, 100));
283
- put(100, "0000644\0");
284
- put(108, "0000000\0");
285
- put(116, "0000000\0");
286
- put(124, `${size.toString(8).padStart(11, "0")}\0`);
287
- put(136, "00000000000\0");
288
- block.fill(0x20, 148, 156);
289
- put(156, type);
290
- put(257, "ustar\0");
291
- put(263, "00");
292
- const sum = block.reduce((total, byte) => total + byte, 0);
293
- put(148, `${sum.toString(8).padStart(6, "0")}\0 `);
294
- return block;
295
- };
296
-
297
- const padded = (bytes: Uint8Array): Uint8Array => {
298
- const block = new Uint8Array(Math.ceil(bytes.length / TAR_BLOCK) * TAR_BLOCK);
299
- block.set(bytes);
300
- return block;
301
- };
302
-
303
- /**
304
- * A tar holding one member per key, in the layout GNU tar writes: a header per member, data padded to the block,
305
- * two zero blocks at the end. A path past the 100-byte name field gets an 'L' block in front of it, as GNU tar does.
306
- */
307
- export const tarBytes = (files: Readonly<Record<string, string>>): Uint8Array => {
308
- const parts: Uint8Array[] = [];
309
- for (const [path, text] of Object.entries(files)) {
310
- const data = strToU8(text);
311
- if (path.length > 100) {
312
- parts.push(tarHeader("././@LongLink", path.length + 1, "L"), padded(strToU8(`${path}\0`)));
313
- }
314
- parts.push(tarHeader(path, data.length, path.endsWith("/") ? "5" : "0"), padded(data));
315
- }
316
- parts.push(new Uint8Array(TAR_BLOCK * 2));
317
- const total = parts.reduce((sum, part) => sum + part.length, 0);
318
- const tar = new Uint8Array(total);
319
- let offset = 0;
320
- for (const part of parts) {
321
- tar.set(part, offset);
322
- offset += part.length;
323
- }
324
- return tar;
325
- };
326
-
327
- /** A gzip member carrying the original name in its header, the way `gzip <file>` writes one. */
328
- export const gzipBytes = (name: string, content: Uint8Array | string): Uint8Array =>
329
- gzipSync(typeof content === "string" ? strToU8(content) : content, { filename: name });
330
-
331
- /** A 1×1 PNG (no EXIF — dimensions are what image derivation reads off it). */
332
- export const pngBytes = (): Uint8Array =>
333
- Uint8Array.from(Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", "base64"));
334
-
335
- /* ---- documents to check: small OOXML and PDF files with one known defect each, built from their parts ---------- */
336
-
337
- const EMU = 914_400;
338
- const NS_P = 'xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';
339
- const REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
340
-
341
- export interface FixtureRel {
342
- readonly type: string;
343
- readonly target: string;
344
- readonly external?: boolean;
345
- }
346
-
347
- const relsXml = (rels: Readonly<Record<string, FixtureRel>>): string =>
348
- `<?xml version="1.0" encoding="UTF-8"?>
349
- <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">${Object.entries(rels)
350
- .map(([id, rel]) => `<Relationship Id="${id}" Type="${REL_TYPE}/${rel.type}" Target="${xmlEscape(rel.target)}"${rel.external === true ? ' TargetMode="External"' : ""}/>`)
351
- .join("")}</Relationships>`;
352
-
353
- /** Where a shape sits, in inches from the slide's top left. */
354
- export interface Frame {
355
- readonly x: number;
356
- readonly y: number;
357
- readonly w: number;
358
- readonly h: number;
359
- }
360
-
361
- const xfrm = (frame: Frame, tag = "a:xfrm"): string =>
362
- `<${tag}><a:off x="${Math.round(frame.x * EMU)}" y="${Math.round(frame.y * EMU)}"/><a:ext cx="${Math.round(frame.w * EMU)}" cy="${Math.round(frame.h * EMU)}"/></${tag}>`;
363
-
364
- export interface TextBoxOptions {
365
- /** Point size of every run; unset inherits (18 pt for a plain text box). */
366
- readonly size?: number;
367
- /** A placeholder of this type (and idx); a placeholder with no frame inherits its layout's. */
368
- readonly ph?: { readonly type?: string; readonly idx?: number };
369
- readonly autofit?: "shrink" | "grow";
370
- }
371
-
372
- let drawingId = 10;
373
-
374
- /** A text shape: one paragraph per line; `frame` undefined leaves the geometry to the layout (placeholders only). */
375
- export const textBox = (name: string, frame: Frame | undefined, lines: readonly string[], options: TextBoxOptions = {}): string => {
376
- drawingId += 1;
377
- const ph = options.ph === undefined ? "" : `<p:ph${options.ph.type === undefined ? "" : ` type="${options.ph.type}"`}${options.ph.idx === undefined ? "" : ` idx="${options.ph.idx}"`}/>`;
378
- const fit = options.autofit === "shrink" ? "<a:normAutofit/>" : options.autofit === "grow" ? "<a:spAutoFit/>" : "";
379
- const size = options.size === undefined ? "" : ` sz="${options.size * 100}"`;
380
- const paragraphs = lines.length === 0 ? "<a:p/>" : lines.map((line) => `<a:p><a:r><a:rPr lang="en-US"${size}/><a:t>${xmlEscape(line)}</a:t></a:r></a:p>`).join("");
381
- return `<p:sp><p:nvSpPr><p:cNvPr id="${drawingId}" name="${xmlEscape(name)}"/><p:cNvSpPr/><p:nvPr>${ph}</p:nvPr></p:nvSpPr><p:spPr>${frame === undefined ? "" : xfrm(frame)}</p:spPr><p:txBody><a:bodyPr wrap="square">${fit}</a:bodyPr><a:lstStyle/>${paragraphs}</p:txBody></p:sp>`;
382
- };
383
-
384
- /** A picture drawing its image from relationship `rId`, optionally cropped (fractions of 100000 per side). */
385
- export const pictureFrame = (name: string, frame: Frame, rId: string, crop?: { readonly l?: number; readonly r?: number; readonly t?: number; readonly b?: number }): string => {
386
- drawingId += 1;
387
- const src = crop === undefined ? "" : `<a:srcRect l="${crop.l ?? 0}" r="${crop.r ?? 0}" t="${crop.t ?? 0}" b="${crop.b ?? 0}"/>`;
388
- return `<p:pic><p:nvPicPr><p:cNvPr id="${drawingId}" name="${xmlEscape(name)}"/><p:cNvPicPr/><p:nvPr/></p:nvPicPr><p:blipFill><a:blip r:embed="${rId}"/>${src}<a:stretch><a:fillRect/></a:stretch></p:blipFill><p:spPr>${xfrm(frame)}<a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr></p:pic>`;
389
- };
390
-
391
- /** A table frame with one row per entry, each row `height` inches tall and its cells in `size`-point text. */
392
- export const tableFrame = (name: string, frame: Frame, rows: readonly (readonly string[])[], height: number, size = 18): string => {
393
- drawingId += 1;
394
- const columns = Math.max(1, ...rows.map((row) => row.length));
395
- const columnWidth = Math.round((frame.w * EMU) / columns);
396
- const cells = (row: readonly string[]): string =>
397
- row.map((cell) => `<a:tc><a:txBody><a:bodyPr/><a:lstStyle/><a:p><a:r><a:rPr lang="en-US" sz="${size * 100}"/><a:t>${xmlEscape(cell)}</a:t></a:r></a:p></a:txBody><a:tcPr/></a:tc>`).join("");
398
- return `<p:graphicFrame><p:nvGraphicFramePr><p:cNvPr id="${drawingId}" name="${xmlEscape(name)}"/><p:cNvGraphicFramePr/><p:nvPr/></p:nvGraphicFramePr>${xfrm(frame, "p:xfrm")}<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/table"><a:tbl><a:tblGrid>${Array.from({ length: columns }, () => `<a:gridCol w="${columnWidth}"/>`).join("")}</a:tblGrid>${rows.map((row) => `<a:tr h="${Math.round(height * EMU)}">${cells(row)}</a:tr>`).join("")}</a:tbl></a:graphicData></a:graphic></p:graphicFrame>`;
399
- };
400
-
401
- export interface DeckSlide {
402
- /** The spTree's drawings, as XML (textBox, pictureFrame, tableFrame, or by hand). */
403
- readonly drawings: readonly string[];
404
- /** Relationships beyond the layout, which every slide gets as rId1. */
405
- readonly rels?: Readonly<Record<string, FixtureRel>>;
406
- readonly hidden?: boolean;
407
- /** The part's file number, when a test needs parts numbered out of deck order. */
408
- readonly partNumber?: number;
409
- }
410
-
411
- export interface DeckOptions {
412
- readonly slides: readonly DeckSlide[];
413
- /** Media parts by name under ppt/media/. */
414
- readonly media?: Readonly<Record<string, Uint8Array>>;
415
- /** Slide size in inches; 13.333 × 7.5 (16:9) by default. */
416
- readonly width?: number;
417
- readonly height?: number;
418
- /** Leave png out of [Content_Types].xml, the damage a hand-zipped deck carries. */
419
- readonly untypedMedia?: boolean;
420
- }
421
-
422
- // A master with a title and a body placeholder and the text styles they inherit (44 pt titles, 28 pt body), and one
423
- // "Title and Content" layout whose placeholders carry no frame of their own, so a slide's geometry comes from the master.
424
- const MASTER = `<?xml version="1.0" encoding="UTF-8"?>
425
- <p:sldMaster ${NS_P}><p:cSld><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr/>
426
- <p:sp><p:nvSpPr><p:cNvPr id="2" name="Title Placeholder 1"/><p:cNvSpPr/><p:nvPr><p:ph type="title"/></p:nvPr></p:nvSpPr><p:spPr>${xfrm({ x: 0.92, y: 0.4, w: 11.5, h: 1.45 })}</p:spPr><p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:r><a:t>Click to edit Master title style</a:t></a:r></a:p></p:txBody></p:sp>
427
- <p:sp><p:nvSpPr><p:cNvPr id="3" name="Text Placeholder 2"/><p:cNvSpPr/><p:nvPr><p:ph type="body" idx="1"/></p:nvPr></p:nvSpPr><p:spPr>${xfrm({ x: 0.92, y: 2, w: 11.5, h: 4.75 })}</p:spPr><p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:r><a:t>Click to edit Master text styles</a:t></a:r></a:p></p:txBody></p:sp>
428
- </p:spTree></p:cSld><p:clrMap bg1="lt1" tx1="dk1" bg2="lt2" tx2="dk2" accent1="accent1" accent2="accent2" accent3="accent3" accent4="accent4" accent5="accent5" accent6="accent6" hlink="hlink" folHlink="folHlink"/><p:sldLayoutIdLst><p:sldLayoutId id="2147483649" r:id="rId1"/></p:sldLayoutIdLst>
429
- <p:txStyles><p:titleStyle><a:lvl1pPr><a:defRPr sz="4400"/></a:lvl1pPr></p:titleStyle><p:bodyStyle><a:lvl1pPr><a:defRPr sz="2800"/></a:lvl1pPr><a:lvl2pPr><a:defRPr sz="2400"/></a:lvl2pPr></p:bodyStyle><p:otherStyle/></p:txStyles></p:sldMaster>`;
430
-
431
- const LAYOUT = `<?xml version="1.0" encoding="UTF-8"?>
432
- <p:sldLayout ${NS_P} type="obj"><p:cSld name="Title and Content"><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr/>
433
- <p:sp><p:nvSpPr><p:cNvPr id="2" name="Title 1"/><p:cNvSpPr/><p:nvPr><p:ph type="title"/></p:nvPr></p:nvSpPr><p:spPr/><p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:r><a:t>Click to edit Master title style</a:t></a:r></a:p></p:txBody></p:sp>
434
- <p:sp><p:nvSpPr><p:cNvPr id="3" name="Content Placeholder 2"/><p:cNvSpPr/><p:nvPr><p:ph idx="1"/></p:nvPr></p:nvSpPr><p:spPr/><p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:r><a:t>Click to edit Master text styles</a:t></a:r></a:p></p:txBody></p:sp>
435
- </p:spTree></p:cSld></p:sldLayout>`;
436
-
437
- /** A PowerPoint deck with a real slide list, master and layout, one slide part per entry of `slides`. */
438
- export const deckBytes = (options: DeckOptions): Uint8Array => {
439
- const width = Math.round((options.width ?? 13.333) * EMU);
440
- const height = Math.round((options.height ?? 7.5) * EMU);
441
- const numbers = options.slides.map((slide, index) => slide.partNumber ?? index + 1);
442
- const types = [
443
- '<Override PartName="/ppt/presentation.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"/>',
444
- '<Override PartName="/ppt/slideMasters/slideMaster1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideMaster+xml"/>',
445
- '<Override PartName="/ppt/slideLayouts/slideLayout1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml"/>',
446
- ...numbers.map((number) => `<Override PartName="/ppt/slides/slide${number}.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/>`),
447
- options.untypedMedia === true ? "" : '<Default Extension="png" ContentType="image/png"/>',
448
- ].join("");
449
- const files = new Map<string, Uint8Array>([
450
- ["[Content_Types].xml", strToU8(CONTENT_TYPES(types))],
451
- ["_rels/.rels", strToU8(RELS("ppt/presentation.xml"))],
452
- ["ppt/presentation.xml", strToU8(
453
- `<?xml version="1.0" encoding="UTF-8"?>
454
- <p:presentation ${NS_P}><p:sldMasterIdLst><p:sldMasterId id="2147483648" r:id="rId1"/></p:sldMasterIdLst><p:sldIdLst>${numbers.map((number, index) => `<p:sldId id="${256 + index}" r:id="rId${number + 1}"/>`).join("")}</p:sldIdLst><p:sldSz cx="${width}" cy="${height}"/><p:notesSz cx="6858000" cy="9144000"/></p:presentation>`,
455
- )],
456
- ["ppt/_rels/presentation.xml.rels", strToU8(
457
- relsXml({
458
- rId1: { type: "slideMaster", target: "slideMasters/slideMaster1.xml" },
459
- ...Object.fromEntries(numbers.map((number) => [`rId${number + 1}`, { type: "slide", target: `slides/slide${number}.xml` }])),
460
- }),
461
- )],
462
- ["ppt/slideMasters/slideMaster1.xml", strToU8(MASTER)],
463
- ["ppt/slideMasters/_rels/slideMaster1.xml.rels", strToU8(relsXml({ rId1: { type: "slideLayout", target: "../slideLayouts/slideLayout1.xml" } }))],
464
- ["ppt/slideLayouts/slideLayout1.xml", strToU8(LAYOUT)],
465
- ["ppt/slideLayouts/_rels/slideLayout1.xml.rels", strToU8(relsXml({ rId1: { type: "slideMaster", target: "../slideMasters/slideMaster1.xml" } }))],
466
- ]);
467
- options.slides.forEach((slide, index) => {
468
- const number = numbers[index] ?? index + 1;
469
- files.set(`ppt/slides/slide${number}.xml`, strToU8(
470
- `<?xml version="1.0" encoding="UTF-8"?>
471
- <p:sld ${NS_P}${slide.hidden === true ? ' show="0"' : ""}><p:cSld><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr/>${slide.drawings.join("")}</p:spTree></p:cSld></p:sld>`,
472
- ),
473
- );
474
- files.set(`ppt/slides/_rels/slide${number}.xml.rels`, strToU8(relsXml({ rId1: { type: "slideLayout", target: "../slideLayouts/slideLayout1.xml" }, ...slide.rels })));
475
- });
476
- for (const [name, bytes] of Object.entries(options.media ?? {})) {
477
- files.set(`ppt/media/${name}`, bytes);
478
- }
479
- return zipSync(Object.fromEntries(files));
480
- };
481
-
482
- /** A PNG header claiming `width` × `height` pixels: all an aspect-ratio check reads of it. */
483
- export const pngOfSize = (width: number, height: number): Uint8Array => {
484
- const png = pngBytes().slice();
485
- const view = new DataView(png.buffer, png.byteOffset, png.byteLength);
486
- view.setUint32(16, width);
487
- view.setUint32(20, height);
488
- return png;
489
- };
490
-
491
- const NS_W = 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture"';
492
-
493
- /** A paragraph: plain, or in a style by id. */
494
- export const para = (text: string, style?: string): string =>
495
- `<w:p>${style === undefined ? "" : `<w:pPr><w:pStyle w:val="${style}"/></w:pPr>`}${text === "" ? "" : `<w:r><w:t xml:space="preserve">${xmlEscape(text)}</w:t></w:r>`}</w:p>`;
496
-
497
- /** An inline picture `width` inches wide drawing relationship `rId`, in a paragraph of its own. */
498
- export const inlinePicture = (rId: string, width: number, height: number): string =>
499
- `<w:p><w:r><w:drawing><wp:inline><wp:extent cx="${Math.round(width * EMU)}" cy="${Math.round(height * EMU)}"/><wp:docPr id="1" name="Picture 1"/><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:blipFill><a:blip r:embed="${rId}"/></pic:blipFill></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r></w:p>`;
500
-
501
- /** A complex field: begin, instruction, separate, the saved result (none when undefined), end. */
502
- export const field = (instruction: string, result: string | undefined): string =>
503
- `<w:p><w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> ${xmlEscape(instruction)} </w:instrText></w:r>${result === undefined ? "" : `<w:r><w:fldChar w:fldCharType="separate"/></w:r><w:r><w:t>${xmlEscape(result)}</w:t></w:r>`}<w:r><w:fldChar w:fldCharType="end"/></w:r></w:p>`;
504
-
505
- export interface WordOptions {
506
- /** The body's paragraphs and tables, as XML (para, field, inlinePicture, or by hand). */
507
- readonly body: readonly string[];
508
- readonly rels?: Readonly<Record<string, FixtureRel>>;
509
- readonly media?: Readonly<Record<string, Uint8Array>>;
510
- /** Comment texts, written as word/comments.xml. */
511
- readonly comments?: readonly string[];
512
- }
513
-
514
- // Heading styles under ids that are not their names, the way a localised Word template writes them: the check must
515
- // read the style's name, not guess from its id.
516
- const STYLES = `<?xml version="1.0" encoding="UTF-8"?>
517
- <w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/></w:style><w:style w:type="paragraph" w:styleId="berschrift1"><w:name w:val="heading 1"/><w:basedOn w:val="Normal"/></w:style><w:style w:type="paragraph" w:styleId="berschrift2"><w:name w:val="heading 2"/><w:basedOn w:val="Normal"/></w:style><w:style w:type="paragraph" w:styleId="Callout"><w:name w:val="Callout"/><w:basedOn w:val="berschrift2"/></w:style></w:styles>`;
518
-
519
- /** A Word document with styles (headings under localised ids), a letter-size section, and optional comments and media. */
520
- export const wordBytes = (options: WordOptions): Uint8Array => {
521
- const commentRels = options.comments === undefined ? {} : { rIdComments: { type: "comments", target: "comments.xml" } };
522
- const files = new Map<string, Uint8Array>([
523
- ["[Content_Types].xml", strToU8(
524
- CONTENT_TYPES(
525
- [
526
- '<Default Extension="png" ContentType="image/png"/>',
527
- '<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>',
528
- '<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>',
529
- options.comments === undefined ? "" : '<Override PartName="/word/comments.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml"/>',
530
- ].join(""),
531
- ),
532
- )],
533
- ["_rels/.rels", strToU8(RELS("word/document.xml"))],
534
- ["word/styles.xml", strToU8(STYLES)],
535
- ["word/document.xml", strToU8(
536
- `<?xml version="1.0" encoding="UTF-8"?>
537
- <w:document ${NS_W}><w:body>${options.body.join("")}<w:sectPr><w:pgSz w:w="12240" w:h="15840"/><w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440" w:gutter="0"/></w:sectPr></w:body></w:document>`,
538
- )],
539
- ]);
540
- if (options.comments !== undefined) {
541
- files.set("word/comments.xml", strToU8(
542
- `<?xml version="1.0" encoding="UTF-8"?>
543
- <w:comments xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">${options.comments.map((text, index) => `<w:comment w:id="${index}" w:author="Reviewer"><w:p><w:r><w:t>${xmlEscape(text)}</w:t></w:r></w:p></w:comment>`).join("")}</w:comments>`,
544
- ),
545
- );
546
- }
547
- files.set("word/_rels/document.xml.rels", strToU8(relsXml({ rIdStyles: { type: "styles", target: "styles.xml" }, ...options.rels, ...commentRels })));
548
- for (const [name, bytes] of Object.entries(options.media ?? {})) {
549
- files.set(`word/media/${name}`, bytes);
550
- }
551
- return zipSync(Object.fromEntries(files));
552
- };
553
-
554
- export interface SheetFixture {
555
- readonly name: string;
556
- /** The sheetData's rows, as XML: `<row r="1"><c r="A1"><v>1</v></c></row>`. */
557
- readonly rows: string;
558
- }
559
-
560
- /** An Excel workbook, one worksheet per entry, with optional defined names and calculation settings. */
561
- export const workbookBytes = (sheets: readonly SheetFixture[], options: { readonly definedNames?: string; readonly calcPr?: string } = {}): Uint8Array => {
562
- const files = new Map<string, Uint8Array>([
563
- ["[Content_Types].xml", strToU8(
564
- CONTENT_TYPES(
565
- [
566
- '<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>',
567
- ...sheets.map((_, index) => `<Override PartName="/xl/worksheets/sheet${index + 1}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>`),
568
- ].join(""),
569
- ),
570
- )],
571
- ["_rels/.rels", strToU8(RELS("xl/workbook.xml"))],
572
- ["xl/workbook.xml", strToU8(
573
- `<?xml version="1.0" encoding="UTF-8"?>
574
- <workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets>${sheets.map((sheet, index) => `<sheet name="${xmlEscape(sheet.name)}" sheetId="${index + 1}" r:id="rId${index + 1}"/>`).join("")}</sheets>${options.definedNames === undefined ? "" : `<definedNames>${options.definedNames}</definedNames>`}${options.calcPr ?? ""}</workbook>`,
575
- )],
576
- ["xl/_rels/workbook.xml.rels", strToU8(relsXml(Object.fromEntries(sheets.map((_, index) => [`rId${index + 1}`, { type: "worksheet", target: `worksheets/sheet${index + 1}.xml` }]))))],
577
- ]);
578
- sheets.forEach((sheet, index) => {
579
- files.set(`xl/worksheets/sheet${index + 1}.xml`, strToU8(
580
- `<?xml version="1.0" encoding="UTF-8"?>
581
- <worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData>${sheet.rows}</sheetData></worksheet>`,
582
- ),
583
- );
584
- });
585
- return zipSync(Object.fromEntries(files));
586
- };
587
-
588
- export interface PdfPageFixture {
589
- /** The page's content stream, e.g. `BT /F1 12 Tf 72 720 Td (Hello) Tj ET`; empty draws nothing. */
590
- readonly content: string;
591
- /** The BaseFont /F1 names; Helvetica (one of the fourteen every reader has) by default. */
592
- readonly font?: string;
593
- /** Extra page dictionary entries, e.g. `/Annots [...]`. */
594
- readonly extra?: string;
595
- /** Width and height in points; US Letter by default. */
596
- readonly size?: readonly [number, number];
597
- }
598
-
599
- /** A PDF with one page per entry, honest xref offsets, and no font embedded (a font the reader lacks is substituted). */
600
- export const pdfPagesBytes = (pages: readonly PdfPageFixture[]): Uint8Array => {
601
- const objects: string[] = ["", ""];
602
- const add = (body: string): number => objects.push(body);
603
- const kids = pages.map((page) => {
604
- const font = add(`<< /Type /Font /Subtype /${page.font === undefined ? "Type1" : "TrueType"} /BaseFont /${page.font ?? "Helvetica"} >>`);
605
- const content = add(`<< /Length ${page.content.length} >>\nstream\n${page.content}\nendstream`);
606
- const [width, height] = page.size ?? [612, 792];
607
- return add(`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${width} ${height}] /Contents ${content} 0 R /Resources << /Font << /F1 ${font} 0 R >> >> ${page.extra ?? ""} >>`);
608
- });
609
- objects[0] = "<< /Type /Catalog /Pages 2 0 R >>";
610
- objects[1] = `<< /Type /Pages /Kids [${kids.map((kid) => `${kid} 0 R`).join(" ")}] /Count ${kids.length} >>`;
611
- let out = "%PDF-1.4\n";
612
- const offsets = objects.map((body, index) => {
613
- const offset = out.length;
614
- out += `${index + 1} 0 obj\n${body}\nendobj\n`;
615
- return offset;
616
- });
617
- const xref = out.length;
618
- out += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n${offsets.map((offset) => `${String(offset).padStart(10, "0")} 00000 n `).join("\n")}\ntrailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`;
619
- return strToU8(out);
620
- };