/** * 造好的资料文件**读回来给前端渲染** —— 进字节,出 `FileDoc`。 * * 为什么绕这一圈,而不是直接把 Markdown 原文丢给前端按格式摆: * **那样界面渲的就不是那份文件了**。人下载到的是 .docx,屏上看到的是另一条路 * 拼出来的样子,两边哪天不一致谁也发现不了。这里读的就是 `composeFile` 出的那一份 * 字节:docx 解出段落、xlsx 解出行列 —— 渲得出来,才证明那份文件是真的。 * * 纯计算:进字节、出结构,不碰库不碰网。 */ import type { FileDoc } from '@contracts'; import { markdownHtmlDoc, type FileFormat } from './file-compose.ts'; import { readZip, sharedStrings, sheetToRows, unescapeXml } from './upload-extract.ts'; /** docx 的一段:`` 里所有 `` 连起来就是这一段的文字 */ function docxParagraphs(xml: string): Array<{ style: string; text: string }> { const out: Array<{ style: string; text: string }> = []; for (const p of xml.matchAll(/]*>([\s\S]*?)<\/w:p>/g)) { const inner = p[1] ?? ''; const style = /]*>([\s\S]*?)<\/w:t>/g)].map((t) => unescapeXml(t[1] ?? '')).join(''); out.push({ style, text }); } return out; } /** 查看面认得的块 —— 与 `FileDoc` 的 word 分支同形 */ export type WordBlock = { t: 'h1' | 'h2' | 'p'; text: string } | { t: 'ul'; items: string[] }; /** * docx → 查看面的块。表格在 `` 里,而查看面(`FileViewer` 的 word 分支) * 只认 h1/h2/p/ul —— 表格按行摊成 `p`(用「 | 」连列),**宁可朴素也不丢内容**。 */ export function docxBlocks(xml: string): WordBlock[] { const blocks: WordBlock[] = []; let bullets: string[] = []; const flush = (): void => { if (bullets.length === 0) return; blocks.push({ t: 'ul', items: bullets }); bullets = []; }; // 表格与段落按它们在文档里的先后走:一次扫过,谁先出现算谁 for (const node of xml.matchAll(/|]*>[\s\S]*?<\/w:p>/g)) { const chunk = node[0]; if (chunk.startsWith(']*>([\s\S]*?)<\/w:tr>/g)) { const cells = [...(tr[1] ?? '').matchAll(/]*>([\s\S]*?)<\/w:tc>/g)].map((tc) => [...(tc[1] ?? '').matchAll(/]*>([\s\S]*?)<\/w:t>/g)].map((t) => unescapeXml(t[1] ?? '')).join(''), ); if (cells.some((c) => c !== '')) blocks.push({ t: 'p', text: cells.join(' | ') }); } continue; } const [para] = docxParagraphs(chunk); if (para === undefined) continue; const { style, text } = para; if (text === '') continue; if (style === 'ListParagraph') { bullets.push(text.replace(/^[•\d]+[.、]?\s*/, '')); continue; } flush(); if (style === 'Title' || style === 'Heading1') blocks.push({ t: 'h1', text }); else if (style === 'Heading2') blocks.push({ t: 'h2', text }); else blocks.push({ t: 'p', text }); } flush(); return blocks; } /** xlsx → 行列(第一张表)。读的是**真包**:解不开就如实说,不编一张表出来 */ export function xlsxRows(bytes: Uint8Array): { rows: string[][]; sheet: string } | undefined { const zip = readZip(bytes); if (zip.size === 0) return undefined; const dec = new TextDecoder('utf-8'); const sharedRaw = zip.get('xl/sharedStrings.xml'); const shared = sharedRaw === undefined ? [] : sharedStrings(dec.decode(sharedRaw)); const first = [...zip.keys()].filter((k) => /^xl\/worksheets\/sheet\d+\.xml$/.test(k)).sort()[0]; if (first === undefined) return undefined; const raw = zip.get(first); if (raw === undefined) return undefined; const wbRaw = zip.get('xl/workbook.xml'); const sheet = wbRaw === undefined ? 'Sheet1' : unescapeXml(/