Skip to content
300 linesCodeBlameRaw
1/**
2 * Word documents and spreadsheets for `make_file` (files.ts), as Office
3 * Open XML: a .docx from Markdown and an .xlsx from rows, each a zip of a
4 * few XML parts. Pure and small: the zip stores its parts uncompressed,
5 * which every reader takes.
6 */
7import { type Block, type Span, parseBlocks } from "./file-markdown.ts";
8
9// ── Zip ───────────────────────────────────────────────────────────────────
10
11const CRC_TABLE = (() => {
12 const table = new Uint32Array(256);
13 for (let i = 0; i < 256; i++) {
14 let c = i;
15 for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
16 table[i] = c >>> 0;
17 }
18 return table;
19})();
20
21export function crc32(bytes: Uint8Array): number {
22 let crc = 0xffffffff;
23 for (const byte of bytes) crc = CRC_TABLE[(crc ^ byte) & 0xff]! ^ (crc >>> 8);
24 return (crc ^ 0xffffffff) >>> 0;
25}
26
27/** A zip of `files` (path to bytes or text), stored without compression. */
28export function zip(files: [string, Uint8Array | string][], at = new Date()): Uint8Array {
29 const encoder = new TextEncoder();
30 const time = (at.getUTCHours() << 11) | (at.getUTCMinutes() << 5) | Math.floor(at.getUTCSeconds() / 2);
31 const date = ((Math.max(1980, at.getUTCFullYear()) - 1980) << 9) | ((at.getUTCMonth() + 1) << 5) | at.getUTCDate();
32 const locals: Uint8Array[] = [];
33 const centrals: Uint8Array[] = [];
34 let offset = 0;
35 for (const [path, content] of files) {
36 const name = encoder.encode(path);
37 const data = typeof content === "string" ? encoder.encode(content) : content;
38 const crc = crc32(data);
39 const local = new Uint8Array(30 + name.length + data.length);
40 const lv = new DataView(local.buffer);
41 lv.setUint32(0, 0x04034b50, true);
42 lv.setUint16(4, 20, true);
43 lv.setUint16(6, 0x0800, true);
44 lv.setUint16(8, 0, true);
45 lv.setUint16(10, time, true);
46 lv.setUint16(12, date, true);
47 lv.setUint32(14, crc, true);
48 lv.setUint32(18, data.length, true);
49 lv.setUint32(22, data.length, true);
50 lv.setUint16(26, name.length, true);
51 lv.setUint16(28, 0, true);
52 local.set(name, 30);
53 local.set(data, 30 + name.length);
54 const central = new Uint8Array(46 + name.length);
55 const cv = new DataView(central.buffer);
56 cv.setUint32(0, 0x02014b50, true);
57 cv.setUint16(4, 20, true);
58 cv.setUint16(6, 20, true);
59 cv.setUint16(8, 0x0800, true);
60 cv.setUint16(10, 0, true);
61 cv.setUint16(12, time, true);
62 cv.setUint16(14, date, true);
63 cv.setUint32(16, crc, true);
64 cv.setUint32(20, data.length, true);
65 cv.setUint32(24, data.length, true);
66 cv.setUint16(28, name.length, true);
67 cv.setUint32(42, offset, true);
68 central.set(name, 46);
69 locals.push(local);
70 centrals.push(central);
71 offset += local.length;
72 }
73 const centralSize = centrals.reduce((sum, c) => sum + c.length, 0);
74 const end = new Uint8Array(22);
75 const ev = new DataView(end.buffer);
76 ev.setUint32(0, 0x06054b50, true);
77 ev.setUint16(8, files.length, true);
78 ev.setUint16(10, files.length, true);
79 ev.setUint32(12, centralSize, true);
80 ev.setUint32(16, offset, true);
81 const out = new Uint8Array(offset + centralSize + end.length);
82 let at2 = 0;
83 for (const part of [...locals, ...centrals, end]) {
84 out.set(part, at2);
85 at2 += part.length;
86 }
87 return out;
88}
89
90/** Text for XML: escaped, without the control characters XML forbids. */
91export function xml(text: string): string {
92 return String(text)
93 // eslint-disable-next-line no-control-regex
94 .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f￾￿]/g, "")
95 .replace(/&/g, "&amp;")
96 .replace(/</g, "&lt;")
97 .replace(/>/g, "&gt;")
98 .replace(/"/g, "&quot;");
99}
100
101const XML_HEAD = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n';
102
103function coreProps(title: string, at: Date): string {
104 const when = at.toISOString().replace(/\.\d{3}Z$/, "Z");
105 return `${XML_HEAD}<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"><dc:title>${xml(title)}</dc:title><dc:creator>g1t</dc:creator><dcterms:created xsi:type="dcterms:W3CDTF">${when}</dcterms:created><dcterms:modified xsi:type="dcterms:W3CDTF">${when}</dcterms:modified></cp:coreProperties>`;
106}
107
108// ── Word ──────────────────────────────────────────────────────────────────
109
110const W = 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';
111
112const DOCX_STYLES = `${XML_HEAD}<w:styles ${W}>
113<w:docDefaults><w:rPrDefault><w:rPr><w:rFonts w:ascii="Calibri" w:hAnsi="Calibri" w:eastAsia="Calibri" w:cs="Calibri"/><w:sz w:val="22"/><w:szCs w:val="22"/><w:lang w:val="en-US"/></w:rPr></w:rPrDefault><w:pPrDefault><w:pPr><w:spacing w:after="140" w:line="276" w:lineRule="auto"/></w:pPr></w:pPrDefault></w:docDefaults>
114<w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/><w:qFormat/></w:style>
115<w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="heading 1"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:qFormat/><w:pPr><w:keepNext/><w:spacing w:before="360" w:after="160"/><w:outlineLvl w:val="0"/></w:pPr><w:rPr><w:b/><w:sz w:val="36"/><w:szCs w:val="36"/></w:rPr></w:style>
116<w:style w:type="paragraph" w:styleId="Heading2"><w:name w:val="heading 2"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:qFormat/><w:pPr><w:keepNext/><w:spacing w:before="280" w:after="120"/><w:outlineLvl w:val="1"/></w:pPr><w:rPr><w:b/><w:sz w:val="28"/><w:szCs w:val="28"/></w:rPr></w:style>
117<w:style w:type="paragraph" w:styleId="Heading3"><w:name w:val="heading 3"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:qFormat/><w:pPr><w:keepNext/><w:spacing w:before="200" w:after="80"/><w:outlineLvl w:val="2"/></w:pPr><w:rPr><w:b/><w:sz w:val="24"/><w:szCs w:val="24"/></w:rPr></w:style>
118<w:style w:type="paragraph" w:styleId="Quote"><w:name w:val="Quote"/><w:basedOn w:val="Normal"/><w:qFormat/><w:pPr><w:pBdr><w:left w:val="single" w:sz="18" w:space="8" w:color="BFBFBF"/></w:pBdr><w:ind w:left="240"/></w:pPr><w:rPr><w:color w:val="595959"/></w:rPr></w:style>
119<w:style w:type="paragraph" w:styleId="Code"><w:name w:val="Code"/><w:basedOn w:val="Normal"/><w:pPr><w:shd w:val="clear" w:color="auto" w:fill="F2F2F2"/><w:spacing w:after="140" w:line="240" w:lineRule="auto"/></w:pPr><w:rPr><w:rFonts w:ascii="Consolas" w:hAnsi="Consolas" w:cs="Consolas"/><w:sz w:val="19"/><w:szCs w:val="19"/></w:rPr></w:style>
120<w:style w:type="paragraph" w:styleId="ListParagraph"><w:name w:val="List Paragraph"/><w:basedOn w:val="Normal"/><w:qFormat/><w:pPr><w:spacing w:after="60"/></w:pPr></w:style>
121<w:style w:type="character" w:styleId="Hyperlink"><w:name w:val="Hyperlink"/><w:rPr><w:color w:val="2F5FD0"/><w:u w:val="single"/></w:rPr></w:style>
122<w:style w:type="table" w:styleId="Table"><w:name w:val="Table"/><w:tblPr><w:tblBorders><w:top w:val="single" w:sz="4" w:color="BFBFBF"/><w:left w:val="single" w:sz="4" w:color="BFBFBF"/><w:bottom w:val="single" w:sz="4" w:color="BFBFBF"/><w:right w:val="single" w:sz="4" w:color="BFBFBF"/><w:insideH w:val="single" w:sz="4" w:color="BFBFBF"/><w:insideV w:val="single" w:sz="4" w:color="BFBFBF"/></w:tblBorders><w:tblCellMar><w:left w:w="100" w:type="dxa"/><w:right w:w="100" w:type="dxa"/></w:tblCellMar></w:tblPr></w:style>
123</w:styles>`;
124
125function runs(spans: Span[], links: string[], base: { bold?: boolean } = {}): string {
126 return spans
127 .map((span) => {
128 const props = [
129 span.href ? '<w:rStyle w:val="Hyperlink"/>' : "",
130 span.code ? '<w:rFonts w:ascii="Consolas" w:hAnsi="Consolas" w:cs="Consolas"/>' : "",
131 span.bold || base.bold ? "<w:b/>" : "",
132 span.italic ? "<w:i/>" : "",
133 span.code ? '<w:shd w:val="clear" w:color="auto" w:fill="F2F2F2"/>' : "",
134 ].join("");
135 const run = `<w:r>${props ? `<w:rPr>${props}</w:rPr>` : ""}<w:t xml:space="preserve">${xml(span.text)}</w:t></w:r>`;
136 if (!span.href || !/^(https?:|mailto:)/i.test(span.href)) return run;
137 links.push(span.href);
138 return `<w:hyperlink r:id="rIdLink${links.length}">${run}</w:hyperlink>`;
139 })
140 .join("");
141}
142
143function paragraph(style: string | null, body: string, extra = ""): string {
144 const props = `${style ? `<w:pStyle w:val="${style}"/>` : ""}${extra}`;
145 return `<w:p>${props ? `<w:pPr>${props}</w:pPr>` : ""}${body}</w:p>`;
146}
147
148function wordBlock(block: Block, links: string[]): string {
149 switch (block.kind) {
150 case "heading":
151 return paragraph(`Heading${block.level}`, runs(block.spans, links));
152 case "paragraph":
153 return paragraph(null, runs(block.spans, links));
154 case "item": {
155 const left = 360 + block.depth * 360;
156 const marker = block.ordered ? `${block.number}.` : block.depth % 2 ? "–" : "•";
157 return paragraph("ListParagraph", `<w:r><w:t xml:space="preserve">${xml(marker)}</w:t></w:r><w:r><w:tab/></w:r>${runs(block.spans, links)}`, `<w:tabs><w:tab w:val="left" w:pos="${left}"/></w:tabs><w:ind w:left="${left}" w:hanging="300"/>`);
158 }
159 case "quote":
160 return paragraph("Quote", runs(block.spans, links));
161 case "code":
162 return paragraph(
163 "Code",
164 block.text
165 .split("\n")
166 .map((line, i) => `${i ? "<w:r><w:br/></w:r>" : ""}<w:r><w:t xml:space="preserve">${xml(line)}</w:t></w:r>`)
167 .join(""),
168 );
169 case "rule":
170 return paragraph(null, "", '<w:pBdr><w:bottom w:val="single" w:sz="6" w:space="1" w:color="BFBFBF"/></w:pBdr>');
171 case "table": {
172 const columns = Math.max(block.header.length, ...block.rows.map((row) => row.length), 1);
173 const row = (cells: Span[][], header: boolean) =>
174 `<w:tr>${header ? "<w:trPr><w:tblHeader/></w:trPr>" : ""}${Array.from({ length: columns }, (_, c) => `<w:tc><w:tcPr>${header ? '<w:shd w:val="clear" w:color="auto" w:fill="EFEFEF"/>' : ""}</w:tcPr>${paragraph(null, runs(cells[c] ?? [], links, { bold: header }), '<w:spacing w:after="0"/>')}</w:tc>`).join("")}</w:tr>`;
175 return `<w:tbl><w:tblPr><w:tblStyle w:val="Table"/><w:tblW w:w="5000" w:type="pct"/></w:tblPr><w:tblGrid>${Array.from({ length: columns }, () => `<w:gridCol w:w="${Math.floor(9720 / columns)}"/>`).join("")}</w:tblGrid>${block.header.length ? row(block.header, true) : ""}${block.rows.map((r) => row(r, false)).join("")}</w:tbl>${paragraph(null, "")}`;
176 }
177 }
178}
179
180/** A Word document of `markdown`, titled `title` (as its first heading when the Markdown starts without one). */
181export function markdownDocx(title: string, markdown: string, at = new Date()): Uint8Array {
182 let blocks = parseBlocks(markdown);
183 if (title.trim() && !(blocks[0]?.kind === "heading" && blocks[0].level === 1)) blocks = [{ kind: "heading", level: 1, spans: [{ text: title.trim() }] }, ...blocks];
184 const links: string[] = [];
185 const body = blocks.map((block) => wordBlock(block, links)).join("\n");
186 const document = `${XML_HEAD}<w:document ${W}><w:body>${body}<w:sectPr><w:pgSz w:w="12240" w:h="15840"/><w:pgMar w:top="1260" w:right="1260" w:bottom="1260" w:left="1260" w:header="708" w:footer="708" w:gutter="0"/></w:sectPr></w:body></w:document>`;
187 const rels = `${XML_HEAD}<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdStyles" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>${links
188 .map((href, i) => `<Relationship Id="rIdLink${i + 1}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" Target="${xml(href)}" TargetMode="External"/>`)
189 .join("")}</Relationships>`;
190 return zip(
191 [
192 [
193 "[Content_Types].xml",
194 `${XML_HEAD}<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/><Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/></Types>`,
195 ],
196 [
197 "_rels/.rels",
198 `${XML_HEAD}<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/></Relationships>`,
199 ],
200 ["docProps/core.xml", coreProps(title, at)],
201 ["word/document.xml", document],
202 ["word/_rels/document.xml.rels", rels],
203 ["word/styles.xml", DOCX_STYLES],
204 ],
205 at,
206 );
207}
208
209// ── Excel ─────────────────────────────────────────────────────────────────
210
211export type Cell = string | number | boolean | null;
212export type Sheet = { name: string; rows: Cell[][] };
213
214/** A column's letters: 0 is A, 26 is AA. */
215export function columnName(index: number): string {
216 let name = "";
217 for (let i = index + 1; i > 0; i = Math.floor((i - 1) / 26)) name = String.fromCharCode(65 + ((i - 1) % 26)) + name;
218 return name;
219}
220
221/** A cell's value as Excel keeps it: numbers (and numeric text) as numbers, the rest as text. */
222export function cellValue(value: Cell): { kind: "number"; value: number } | { kind: "text"; value: string } | null {
223 if (value === null || value === undefined || value === "") return null;
224 if (typeof value === "number") return Number.isFinite(value) ? { kind: "number", value } : { kind: "text", value: String(value) };
225 if (typeof value === "boolean") return { kind: "text", value: value ? "TRUE" : "FALSE" };
226 const text = String(value);
227 // Numeric text is a number, unless a leading zero says it's a code (a ZIP, an id).
228 if (/^-?(0|[1-9]\d{0,14})(\.\d+)?$/.test(text.trim())) return { kind: "number", value: Number(text.trim()) };
229 return { kind: "text", value: text };
230}
231
232/** A sheet name Excel takes: at most 31 characters, none of []:*?/\, unique in the workbook. */
233export function sheetNames(names: string[]): string[] {
234 const used = new Set<string>();
235 return names.map((raw, i) => {
236 const base = (String(raw ?? "").replace(/[[\]:*?/\\]/g, " ").replace(/\s+/g, " ").trim() || `Sheet${i + 1}`).replace(/^'|'$/g, "").slice(0, 31) || `Sheet${i + 1}`;
237 let name = base;
238 for (let k = 2; used.has(name.toLowerCase()); k++) name = `${base.slice(0, 31 - String(k).length - 1)} ${k}`;
239 used.add(name.toLowerCase());
240 return name;
241 });
242}
243
244function worksheet(sheet: Sheet): string {
245 const columns = Math.max(1, ...sheet.rows.map((row) => row.length));
246 const widths = Array.from({ length: columns }, (_, c) => Math.min(60, Math.max(8, ...sheet.rows.slice(0, 200).map((row) => String(row[c] ?? "").length + 2))));
247 const rows = sheet.rows
248 .map((row, r) => {
249 const cells = row
250 .map((raw, c) => {
251 const value = cellValue(raw);
252 if (!value) return "";
253 const ref = `${columnName(c)}${r + 1}`;
254 const style = r === 0 ? ' s="1"' : "";
255 return value.kind === "number" ? `<c r="${ref}"${style}><v>${value.value}</v></c>` : `<c r="${ref}"${style} t="inlineStr"><is><t xml:space="preserve">${xml(value.value)}</t></is></c>`;
256 })
257 .join("");
258 return `<row r="${r + 1}">${cells}</row>`;
259 })
260 .join("");
261 const frozen = sheet.rows.length > 1 ? '<pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/>' : "";
262 return `${XML_HEAD}<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetViews><sheetView workbookViewId="0">${frozen}</sheetView></sheetViews><cols>${widths.map((w, c) => `<col min="${c + 1}" max="${c + 1}" width="${w}" customWidth="1"/>`).join("")}</cols><sheetData>${rows}</sheetData></worksheet>`;
263}
264
265const XLSX_STYLES = `${XML_HEAD}<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><fonts count="2"><font><sz val="11"/><name val="Calibri"/></font><font><b/><sz val="11"/><name val="Calibri"/></font></fonts><fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills><borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders><cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs><cellXfs count="2"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/><xf numFmtId="0" fontId="1" fillId="0" borderId="0" xfId="0" applyFont="1"/></cellXfs><cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles></styleSheet>`;
266
267/** An Excel workbook of `sheets`, each one's first row its header (in bold, frozen). */
268export function workbook(title: string, sheets: Sheet[], at = new Date()): Uint8Array {
269 const names = sheetNames(sheets.map((sheet) => sheet.name));
270 return zip(
271 [
272 [
273 "[Content_Types].xml",
274 `${XML_HEAD}<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>${sheets
275 .map((_, i) => `<Override PartName="/xl/worksheets/sheet${i + 1}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>`)
276 .join("")}<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/></Types>`,
277 ],
278 [
279 "_rels/.rels",
280 `${XML_HEAD}<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/></Relationships>`,
281 ],
282 ["docProps/core.xml", coreProps(title, at)],
283 [
284 "xl/workbook.xml",
285 `${XML_HEAD}<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets>${names
286 .map((name, i) => `<sheet name="${xml(name)}" sheetId="${i + 1}" r:id="rId${i + 1}"/>`)
287 .join("")}</sheets></workbook>`,
288 ],
289 [
290 "xl/_rels/workbook.xml.rels",
291 `${XML_HEAD}<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">${sheets
292 .map((_, i) => `<Relationship Id="rId${i + 1}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet${i + 1}.xml"/>`)
293 .join("")}<Relationship Id="rIdStyles" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>`,
294 ],
295 ["xl/styles.xml", XLSX_STYLES],
296 ...sheets.map((sheet, i): [string, string] => [`xl/worksheets/sheet${i + 1}.xml`, worksheet(sheet)]),
297 ],
298 at,
299 );
300}