资料文件真产出来了;E04/I01 补到验收线,顺手填了几个「墙拦住却不给路」的坑

**资料文件全链**(用户:审核资料这一步要真产出 word/excel/md/html,前端能渲、点资料名能开)
接手时这一步一个文件都产不出来 —— S3 剧本白纸黑字写着「file_ref 本 MVP 阶段可留空」。
文件是台账那一行的投影,不另存一份:
· 声明 `file:{titleColumn,bodyColumn,formatColumn}`,它同时是授权面(没声明就点不开)
· 现造真字节 —— docx/xlsx 是自己打的 OOXML 包(zip-write:store+CRC32,零依赖)
· 读回来渲 —— 解的就是造出来那份字节,渲得出来才证明文件是真的
· 件上 `data-file-of` → 工作区二级界面(Word 纸面/Excel 网格/渲好的 md/沙箱 html)+ 下载原件
放行落地前把每一份冻进 `sys.task_asset_file`(迁移 0029):归档件点开的必须是签字那一刻
那一份,而资料落在主数据上、试跑单不落正式表 —— 不存这一份,收工后一个都点不开。
工作成果里「产出的资料」自成一摞。Markdown 富文本渲染顺带销账(仍零依赖)。

**Harness**
· 画面按「这一拍办完之后」在办的那一步挑 —— 从前拿拍首的局面决定拍尾的事:
  收掉 S4、发出 S5 的卡,而屏上还摆着 S4 的只读件,人无处可填(墙要配路第四次现形)
· 验收「重做」真能退回重做,且打回后台账没动就不许再提交(软硬两段,不锁死单子)
· 立监听的授权面是清单声明;发卡的授权面是调人预算,不是计划项
· 读工具同参重调有兜底;空转写身后还有行动就不废串;finish 的 doneWhen 按写完后的草稿算

**界面**
· 翻件与翻页各归各位(从前二选一,多卡多页时页翻不动);没得翻写「—」不写 0/0
· 工作成果详情能翻份;表宽超出能横滑(E04/I01 抄版式时把 overflow-x 抄丢了)
· 查看面灰底滚过一屏变白 —— 两条独立的路都治了(沙箱内不同色 / 弹性子项不长个)
· 测试台「取不到」不再当成「还活着」

**活动**
E04 关系覆盖、I01 品牌内容各自补到验收线:判据全读基座数、判据指向的东西摆上卡面、
样例满载且名字 ≤14 字;提交前要裁的判据搬进说明书(模型会压拍,压过之后只有说明书还在)。

验收:e2e i01 4/4、e04 3/3 全部 released,资料在办中渲得出、放行后存了档点得开;
门禁 typecheck/lint/depcruise/1628 测全绿。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EEJX8Z7CHejvwmDQyPQuBX
This commit is contained in:
xcs
2026-08-24 10:26:45 +08:00
co-authored by Claude Opus 5
parent 70e83d0b09
commit 0aa2fc11b0
92 changed files with 13984 additions and 370 deletions
+425
View File
@@ -0,0 +1,425 @@
/**
* 资料文件**现造** —— 进「标题 + Markdown 正文 + 要哪种格式」,出真字节。
*
* 为什么是现造而不是另存一份文件:资料的正身是**内容**,而内容本来就落在台账里。
* 再存一份字节,同一份事实两处各存一份 —— 改了台账那份文件就成了旧的,而没人看得出
* 以哪份为准。现造的这一份永远与台账一致,下载拿到的又是真 .docx / .xlsx
* (Word / Excel 打得开),两头都不将就。
*
* 不引第三方库 —— 与 `upload-extract.ts` 同一条路:docx / xlsx 本质是 ZIP 包着几份 XML,
* 写一个只做 store(不压缩)的 ZIP 打包器就够了,为一件事拉进一整个文档库不值当。
*
* 这一份是**纯计算**:进字符串、出字节,不碰库不碰网,所以能逐条测。
*/
import { crc32Of, zipStore } from './zip-write.ts';
export const FILE_FORMATS = ['md', 'html', 'docx', 'xlsx'] as const;
export type FileFormat = (typeof FILE_FORMATS)[number];
export const isFileFormat = (v: unknown): v is FileFormat =>
typeof v === 'string' && (FILE_FORMATS as readonly string[]).includes(v);
/** 一份造好的文件 —— 字节 + 它该叫什么 + 该以什么 MIME 交出去 */
export type ComposedFile = {
readonly bytes: Uint8Array;
readonly fileName: string;
readonly mime: string;
readonly format: FileFormat;
};
/** 各格式该以什么 MIME 交出去 —— 存档那一路也认它(`desk.ts`) */
export const MIME_OF: Record<FileFormat, string> = {
md: 'text/markdown; charset=utf-8',
html: 'text/html; charset=utf-8',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
};
/* ==================== Markdown → 结构块 ==================== */
export type MdBlock =
| { readonly t: 'h1' | 'h2' | 'h3' | 'p'; readonly text: string }
/* ul / ol 各成一员 —— 合成 `'ul' | 'ol'` 一个成员时,判过这两个分支
TS 也不会把它从联合里剔掉,后面取 `text` 就通不过 */
| { readonly t: 'ul'; readonly items: readonly string[] }
| { readonly t: 'ol'; readonly items: readonly string[] }
| { readonly t: 'table'; readonly rows: ReadonlyArray<readonly string[]> }
| { readonly t: 'hr' };
const TABLE_SPLIT = /\s*\|\s*/;
/** 一行表格 `| a | b |` → ['a','b'](首尾的竖线不算列) */
function tableCells(line: string): string[] {
const inner = line.trim().replace(/^\|/, '').replace(/\|$/, '');
return inner.split(TABLE_SPLIT).map((c) => c.trim());
}
const isTableLine = (line: string): boolean => line.trim().startsWith('|') && line.trim().endsWith('|');
/** 分隔行 `|---|:--:|` —— 它只说对齐,不是数据 */
const isTableRule = (line: string): boolean => isTableLine(line) && /^[\s|:-]+$/.test(line);
/**
* Markdown 子集 → 块。**认的就是这几样**:标题(#/##/###)、无序列表(-/*)、
* 有序列表(1.)、表格(GFM)、分隔线(---)、段落。
*
* 为什么只认一个子集:这些文件是给人看的资料(白皮书 / 规格书 / 对照表),
* 上面这几样够写;认得越多、docx/xlsx 那一侧要对应的东西越多,而每多一样
* 都是一处可能与前端渲染不一致的地方。**认不出的一律当段落原样保留**,不吞字。
*/
export function parseMarkdown(md: string): MdBlock[] {
const lines = md.replace(/\r\n?/g, '\n').split('\n');
const blocks: MdBlock[] = [];
let para: string[] = [];
const flushPara = (): void => {
if (para.length === 0) return;
blocks.push({ t: 'p', text: para.join(' ').trim() });
para = [];
};
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i] ?? '';
const trimmed = line.trim();
if (trimmed === '') {
flushPara();
continue;
}
if (/^(-{3,}|\*{3,}|_{3,})$/.test(trimmed)) {
flushPara();
blocks.push({ t: 'hr' });
continue;
}
const heading = /^(#{1,3})\s+(.*)$/.exec(trimmed);
if (heading !== null) {
flushPara();
const depth = (heading[1] ?? '#').length;
blocks.push({ t: depth === 1 ? 'h1' : depth === 2 ? 'h2' : 'h3', text: (heading[2] ?? '').trim() });
continue;
}
if (isTableLine(trimmed)) {
flushPara();
const rows: string[][] = [];
while (i < lines.length && isTableLine(lines[i] ?? '')) {
const one = lines[i] ?? '';
if (!isTableRule(one)) rows.push(tableCells(one));
i += 1;
}
i -= 1;
if (rows.length > 0) blocks.push({ t: 'table', rows });
continue;
}
if (/^[-*+]\s+/.test(trimmed)) {
flushPara();
const items: string[] = [];
while (i < lines.length && /^\s*[-*+]\s+/.test(lines[i] ?? '')) {
items.push((lines[i] ?? '').trim().replace(/^[-*+]\s+/, ''));
i += 1;
}
i -= 1;
blocks.push({ t: 'ul', items });
continue;
}
if (/^\d+[.)]\s+/.test(trimmed)) {
flushPara();
const items: string[] = [];
while (i < lines.length && /^\s*\d+[.)]\s+/.test(lines[i] ?? '')) {
items.push((lines[i] ?? '').trim().replace(/^\d+[.)]\s+/, ''));
i += 1;
}
i -= 1;
blocks.push({ t: 'ol', items });
continue;
}
para.push(trimmed);
}
flushPara();
return blocks;
}
/* ==================== 行内:粗体 / 斜体 / 代码 ==================== */
export function escapeXml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
/** 行内标记 → HTML。**先转义再上标记** —— 顺序反了就是把模型写的字当标签执行 */
export function inlineHtml(text: string): string {
return escapeXml(text)
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
.replace(/(^|[^*])\*([^*]+)\*/g, '$1<em>$2</em>');
}
/** 行内标记**去掉**(docx / xlsx 的文本格里不做富文本,留记号反而像乱码) */
export function inlinePlain(text: string): string {
return text
.replace(/`([^`]+)`/g, '$1')
.replace(/\*\*([^*]+)\*\*/g, '$1')
.replace(/(^|[^*])\*([^*]+)\*/g, '$1$2');
}
/* ==================== md → HTML(前端渲染也用这一份) ==================== */
/**
* 正文骨架 —— 只吐这几种标签,内容全程转义:摆进沙箱是安全的,
* 而且前端 Markdown 查看面与下载到的 .html 长得一模一样(同一段函数出的)。
*/
export function markdownBody(md: string): string {
return parseMarkdown(md)
.map((b) => {
if (b.t === 'hr') return '<hr />';
if (b.t === 'ul') return `<ul>${b.items.map((i) => `<li>${inlineHtml(i)}</li>`).join('')}</ul>`;
if (b.t === 'ol') return `<ol>${b.items.map((i) => `<li>${inlineHtml(i)}</li>`).join('')}</ol>`;
if (b.t === 'table') {
const [head, ...rest] = b.rows;
const thead =
head === undefined ? '' : `<thead><tr>${head.map((c) => `<th>${inlineHtml(c)}</th>`).join('')}</tr></thead>`;
const tbody = rest.map((r) => `<tr>${r.map((c) => `<td>${inlineHtml(c)}</td>`).join('')}</tr>`).join('');
return `<div class="doc-tbl"><table>${thead}<tbody>${tbody}</tbody></table></div>`;
}
return `<${b.t}>${inlineHtml(b.text)}</${b.t}>`;
})
.join('\n');
}
/** 纸面样式 —— 下载的 .html 与前端查看面共用,两处看着是同一份文件 */
export const DOC_CSS = `
:root { color-scheme: light; }
body { margin: 0; background: #f4f5f7; }
.doc-paper { max-width: 820px; margin: 0 auto; padding: 46px 56px 60px; background: #fff; color: #1f2329;
font: 14px/1.85 "PingFang SC", "Microsoft YaHei", -apple-system, "Segoe UI", sans-serif; }
.doc-paper h1 { font-size: 25px; line-height: 1.35; margin: 0 0 18px; padding-bottom: 12px; border-bottom: 2px solid #1f2329; }
.doc-paper h2 { font-size: 18px; margin: 30px 0 10px; padding-left: 10px; border-left: 3px solid #3370ff; }
.doc-paper h3 { font-size: 15px; margin: 22px 0 8px; color: #3d4046; }
.doc-paper p { margin: 0 0 11px; }
.doc-paper ul, .doc-paper ol { margin: 0 0 12px; padding-left: 22px; }
.doc-paper li { margin: 3px 0; }
.doc-paper hr { border: 0; border-top: 1px solid #dfe1e5; margin: 22px 0; }
.doc-paper code { font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 12.5px;
background: #f2f3f5; padding: 1px 5px; border-radius: 4px; }
.doc-paper strong { font-weight: 700; }
.doc-tbl { overflow-x: auto; margin: 0 0 14px; }
.doc-paper table { border-collapse: collapse; width: 100%; font-size: 13px; }
.doc-paper th, .doc-paper td { border: 1px solid #dfe1e5; padding: 7px 10px; text-align: left; vertical-align: top; }
.doc-paper th { background: #f7f8fa; font-weight: 700; }
@media (max-width: 720px) { .doc-paper { padding: 26px 20px 34px; } }
`.trim();
/** 整份 HTML 文件(下载的那一份 —— 自带样式,离线打开也是这个样子) */
export function markdownHtmlDoc(title: string, md: string): string {
return `<!doctype html>
<html lang="zh-CN"><head><meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${escapeXml(title)}</title>
<style>${DOC_CSS}</style></head>
<body><article class="doc-paper">${markdownBody(md)}</article></body></html>`;
}
/* ==================== md → docx(真 OOXML) ==================== */
const DOCX_CONTENT_TYPES = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<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"/>
</Types>`;
const DOCX_RELS = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<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"/>
</Relationships>`;
const DOCX_DOC_RELS = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
</Relationships>`;
/** 只定义用到的那几个段落样式 —— Word 靠 styleId 认标题层级(导航窗格据此出目录) */
const DOCX_STYLES = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:style w:type="paragraph" w:styleId="Title"><w:name w:val="Title"/><w:pPr><w:spacing w:after="240"/></w:pPr><w:rPr><w:b/><w:sz w:val="44"/></w:rPr></w:style>
<w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="heading 1"/><w:pPr><w:outlineLvl w:val="0"/><w:spacing w:before="280" w:after="120"/></w:pPr><w:rPr><w:b/><w:sz w:val="32"/></w:rPr></w:style>
<w:style w:type="paragraph" w:styleId="Heading2"><w:name w:val="heading 2"/><w:pPr><w:outlineLvl w:val="1"/><w:spacing w:before="240" w:after="100"/></w:pPr><w:rPr><w:b/><w:sz w:val="26"/></w:rPr></w:style>
<w:style w:type="paragraph" w:styleId="ListParagraph"><w:name w:val="List Paragraph"/><w:pPr><w:ind w:left="420"/></w:pPr></w:style>
</w:styles>`;
const wRun = (text: string): string =>
`<w:r><w:t xml:space="preserve">${escapeXml(inlinePlain(text))}</w:t></w:r>`;
const wPara = (text: string, style?: string): string =>
`<w:p>${style === undefined ? '' : `<w:pPr><w:pStyle w:val="${style}"/></w:pPr>`}${wRun(text)}</w:p>`;
/** 表格:一行一 `<w:tr>`,首行加底纹当表头 —— Word 里是真表格,能选能改 */
function wTable(rows: ReadonlyArray<readonly string[]>): string {
const cols = Math.max(...rows.map((r) => r.length), 1);
const width = Math.floor(9000 / cols);
const cell = (text: string, head: boolean): string =>
`<w:tc><w:tcPr><w:tcW w:w="${width}" w:type="dxa"/>${head ? '<w:shd w:val="clear" w:fill="F2F3F5"/>' : ''}</w:tcPr>` +
`<w:p>${head ? '<w:pPr><w:rPr><w:b/></w:rPr></w:pPr>' : ''}${wRun(text)}</w:p></w:tc>`;
const body = rows
.map(
(r, ri) =>
`<w:tr>${Array.from({ length: cols }, (_, ci) => cell(r[ci] ?? '', ri === 0)).join('')}</w:tr>`,
)
.join('');
return (
`<w:tbl><w:tblPr><w:tblW w:w="0" w:type="auto"/>` +
`<w:tblBorders>${['top', 'left', 'bottom', 'right', 'insideH', 'insideV']
.map((s) => `<w:${s} w:val="single" w:sz="4" w:color="DFE1E5"/>`)
.join('')}</w:tblBorders></w:tblPr>${body}</w:tbl>`
);
}
export function markdownDocxXml(title: string, md: string): string {
const body = parseMarkdown(md)
.map((b) => {
if (b.t === 'hr') return '<w:p><w:pPr><w:pBdr><w:bottom w:val="single" w:sz="6" w:color="DFE1E5"/></w:pBdr></w:pPr></w:p>';
if (b.t === 'ul') return b.items.map((i) => wPara(`• ${i}`, 'ListParagraph')).join('');
if (b.t === 'ol') return b.items.map((i, n) => wPara(`${n + 1}. ${i}`, 'ListParagraph')).join('');
if (b.t === 'table') return wTable(b.rows);
if (b.t === 'h1') return wPara(b.text, 'Heading1');
if (b.t === 'h2') return wPara(b.text, 'Heading2');
if (b.t === 'h3') return wPara(b.text, 'Heading2');
return wPara(b.text);
})
.join('');
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>${wPara(title, 'Title')}${body}<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1134" w:right="1134" w:bottom="1134" w:left="1134"/></w:sectPr></w:body></w:document>`;
}
/* ==================== md → xlsx(真 OOXML) ==================== */
const XLSX_CONTENT_TYPES = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<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/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>
</Types>`;
const XLSX_RELS = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<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"/>
</Relationships>`;
const XLSX_WB_RELS = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>
</Relationships>`;
const xlsxWorkbook = (sheetName: string): string => `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
<sheets><sheet name="${escapeXml(sheetName).slice(0, 31)}" sheetId="1" r:id="rId1"/></sheets></workbook>`;
/** 列号 0 → A,26 → AA */
export function columnName(index: number): string {
let n = index + 1;
let out = '';
while (n > 0) {
const rem = (n - 1) % 26;
out = String.fromCharCode(65 + rem) + out;
n = Math.floor((n - 1) / 26);
}
return out;
}
/**
* 表格化:**表格块原样成行**,其余块各占一行(标题前空一行)——
* 一份规格书导成 Excel,人要的是那几张对照表,而不是把正文丢掉。
*/
export function markdownSheetRows(title: string, md: string): string[][] {
const rows: string[][] = [[title]];
for (const b of parseMarkdown(md)) {
if (b.t === 'hr') continue;
if (b.t === 'table') {
rows.push([]);
for (const r of b.rows) rows.push(r.map((c) => inlinePlain(c)));
continue;
}
if (b.t === 'ul') {
for (const i of b.items) rows.push([`• ${inlinePlain(i)}`]);
continue;
}
if (b.t === 'ol') {
b.items.forEach((i, n) => rows.push([`${n + 1}. ${inlinePlain(i)}`]));
continue;
}
if (b.t === 'h1' || b.t === 'h2' || b.t === 'h3') rows.push([]);
rows.push([inlinePlain(b.text)]);
}
return rows;
}
/** 值全部按 inlineStr 写 —— 不建共享串表:少一份要对齐的索引,少一处能错的地方 */
function xlsxSheet(rows: ReadonlyArray<readonly string[]>): string {
const body = rows
.map((cells, ri) => {
const tds = cells
.map((v, ci) =>
v === ''
? ''
: `<c r="${columnName(ci)}${ri + 1}" t="inlineStr"><is><t xml:space="preserve">${escapeXml(v)}</t></is></c>`,
)
.join('');
return `<row r="${ri + 1}">${tds}</row>`;
})
.join('');
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData>${body}</sheetData></worksheet>`;
}
/* ==================== 出口 ==================== */
/** 文件名里不许出现的字符(跨平台取交集),一律换成下划线 */
export function safeFileName(title: string, format: FileFormat): string {
const base = title.replace(/[\\/:*?"<>|\n\r\t]/g, '_').trim().slice(0, 80) || '资料';
return `${base}.${format}`;
}
export function composeFile(input: {
readonly title: string;
readonly markdown: string;
readonly format: FileFormat;
}): ComposedFile {
const { title, markdown, format } = input;
const enc = new TextEncoder();
const common = { fileName: safeFileName(title, format), mime: MIME_OF[format], format };
if (format === 'md') {
// 原文里没有标题行就补一条 —— 文件得自报家门(台账那一列是标题,正文常常直接从正文起)
const head = /^\s*#\s+/.test(markdown) ? '' : `# ${title}\n\n`;
return { ...common, bytes: enc.encode(head + markdown) };
}
if (format === 'html') return { ...common, bytes: enc.encode(markdownHtmlDoc(title, markdown)) };
if (format === 'docx') {
return {
...common,
bytes: zipStore([
['[Content_Types].xml', enc.encode(DOCX_CONTENT_TYPES)],
['_rels/.rels', enc.encode(DOCX_RELS)],
['word/_rels/document.xml.rels', enc.encode(DOCX_DOC_RELS)],
['word/styles.xml', enc.encode(DOCX_STYLES)],
['word/document.xml', enc.encode(markdownDocxXml(title, markdown))],
]),
};
}
return {
...common,
bytes: zipStore([
['[Content_Types].xml', enc.encode(XLSX_CONTENT_TYPES)],
['_rels/.rels', enc.encode(XLSX_RELS)],
['xl/_rels/workbook.xml.rels', enc.encode(XLSX_WB_RELS)],
['xl/workbook.xml', enc.encode(xlsxWorkbook(title))],
['xl/worksheets/sheet1.xml', enc.encode(xlsxSheet(markdownSheetRows(title, markdown)))],
]),
};
}
export { crc32Of };