/** * 资料文件**现造** —— 进「标题 + 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 = { 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 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, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } /** 行内标记 → HTML。**先转义再上标记** —— 顺序反了就是把模型写的字当标签执行 */ export function inlineHtml(text: string): string { return escapeXml(text) .replace(/`([^`]+)`/g, '$1') .replace(/\*\*([^*]+)\*\*/g, '$1') .replace(/(^|[^*])\*([^*]+)\*/g, '$1$2'); } /** 行内标记**去掉**(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 '
'; if (b.t === 'ul') return ``; if (b.t === 'ol') return `
    ${b.items.map((i) => `
  1. ${inlineHtml(i)}
  2. `).join('')}
`; if (b.t === 'table') { const [head, ...rest] = b.rows; const thead = head === undefined ? '' : `${head.map((c) => `${inlineHtml(c)}`).join('')}`; const tbody = rest.map((r) => `${r.map((c) => `${inlineHtml(c)}`).join('')}`).join(''); return `
${thead}${tbody}
`; } return `<${b.t}>${inlineHtml(b.text)}`; }) .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 ` ${escapeXml(title)}
${markdownBody(md)}
`; } /* ==================== md → docx(真 OOXML) ==================== */ const DOCX_CONTENT_TYPES = ` `; const DOCX_RELS = ` `; const DOCX_DOC_RELS = ` `; /** 只定义用到的那几个段落样式 —— Word 靠 styleId 认标题层级(导航窗格据此出目录) */ const DOCX_STYLES = ` `; const wRun = (text: string): string => `${escapeXml(inlinePlain(text))}`; const wPara = (text: string, style?: string): string => `${style === undefined ? '' : ``}${wRun(text)}`; /** 表格:一行一 ``,首行加底纹当表头 —— Word 里是真表格,能选能改 */ function wTable(rows: ReadonlyArray): string { const cols = Math.max(...rows.map((r) => r.length), 1); const width = Math.floor(9000 / cols); const cell = (text: string, head: boolean): string => `${head ? '' : ''}` + `${head ? '' : ''}${wRun(text)}`; const body = rows .map( (r, ri) => `${Array.from({ length: cols }, (_, ci) => cell(r[ci] ?? '', ri === 0)).join('')}`, ) .join(''); return ( `` + `${['top', 'left', 'bottom', 'right', 'insideH', 'insideV'] .map((s) => ``) .join('')}${body}` ); } export function markdownDocxXml(title: string, md: string): string { const body = parseMarkdown(md) .map((b) => { if (b.t === 'hr') return ''; 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 ` ${wPara(title, 'Title')}${body}`; } /* ==================== md → xlsx(真 OOXML) ==================== */ const XLSX_CONTENT_TYPES = ` `; const XLSX_RELS = ` `; const XLSX_WB_RELS = ` `; const xlsxWorkbook = (sheetName: string): string => ` `; /** 列号 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): string { const body = rows .map((cells, ri) => { const tds = cells .map((v, ci) => v === '' ? '' : `${escapeXml(v)}`, ) .join(''); return `${tds}`; }) .join(''); return ` ${body}`; } /* ==================== 出口 ==================== */ /** 文件名里不许出现的字符(跨平台取交集),一律换成下划线 */ 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 };