diff --git a/.gitignore b/.gitignore index 70640f15..35e457d6 100644 --- a/.gitignore +++ b/.gitignore @@ -90,6 +90,8 @@ hermes-agent-main/ offline-backend-20260512/backend/tests/* !offline-backend-20260512/backend/tests/test_llmwiki_weknora.py !offline-backend-20260512/backend/tests/test_health_auth_bypass.py +# Keep formal Word OOXML and embedded-font regression coverage versioned. +!offline-backend-20260512/backend/tests/test_word_export.py # Keep the model configuration regression coverage versioned even though # this deployment repository otherwise ignores newly added backend tests. !offline-backend-20260512/backend/tests/test_model_management.py diff --git a/frontend-web/src/components/workspace/messages/download-word-button.tsx b/frontend-web/src/components/workspace/messages/download-word-button.tsx index c50c734f..40e7d79d 100644 --- a/frontend-web/src/components/workspace/messages/download-word-button.tsx +++ b/frontend-web/src/components/workspace/messages/download-word-button.tsx @@ -1,8 +1,14 @@ -import { FileText, Loader2 } from "lucide-react"; +import { FileText, Loader2, MessagesSquare } from "lucide-react"; import { useState, type ComponentProps } from "react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx"; import { Tooltip } from "../tooltip"; @@ -11,29 +17,84 @@ export function DownloadWordButton({ markdown, fileName, title, + questionMarkdown, ...props }: ComponentProps & { markdown: string; fileName?: string; title?: string; + /** When present, the button offers both answer-only and current Q&A export. */ + questionMarkdown?: string; }) { - const [busy, setBusy] = useState(false); - const handleDownload = async () => { + const [busyScope, setBusyScope] = useState<"answer" | "qa" | null>(null); + const handleDownload = async (scope: "answer" | "qa" = "answer") => { const source = markdown?.trim(); if (!source) { toast.error("无可下载的 Markdown 内容"); return; } - setBusy(true); + const question = questionMarkdown?.trim(); + setBusyScope(scope); try { - await downloadMarkdownAsDocx(source, fileName || "message", title); + if (scope === "qa" && question) { + const qaMarkdown = `## 问题\n\n${question}\n\n## 回答\n\n${source}`; + await downloadMarkdownAsDocx( + qaMarkdown, + `${fileName || "message"}-问答`, + "问答记录", + ); + } else { + await downloadMarkdownAsDocx( + source, + fileName || "message", + title || (question ? "问答回答" : undefined), + ); + } toast.success("Word 文件已生成"); } catch (error) { toast.error(error instanceof Error ? error.message : "生成 Word 失败"); } finally { - setBusy(false); + setBusyScope(null); } }; + + const busy = busyScope !== null; + const icon = busy ? ( + + ) : ( + + ); + + if (questionMarkdown?.trim()) { + return ( + + + + + + + + void handleDownload("answer")}> + + 仅下载当前回答 + + void handleDownload("qa")}> + + 下载当前问题和回答 + + + + ); + } + return ( ); diff --git a/frontend-web/src/components/workspace/messages/message-list-item.tsx b/frontend-web/src/components/workspace/messages/message-list-item.tsx index 3e138bbd..dc548ff1 100644 --- a/frontend-web/src/components/workspace/messages/message-list-item.tsx +++ b/frontend-web/src/components/workspace/messages/message-list-item.tsx @@ -159,8 +159,9 @@ export function MessageListItem({ className, fitRowToContent = false, anchorId, - message, - isLoading, + message, + questionMarkdown, + isLoading, threadId, tokenUsageEnabled = false, displaySources = [], @@ -177,7 +178,9 @@ export function MessageListItem({ * message. `scroll-mt` keeps the target clear of the overlay header. */ anchorId?: string; - message: Message; + message: Message; + /** Nearest preceding user question, used by the current-Q&A Word export. */ + questionMarkdown?: string; isLoading?: boolean; threadId: string; tokenUsageEnabled?: boolean; @@ -327,10 +330,12 @@ export function MessageListItem({
{messageMarkdown && ( - + )} {canSaveAssistantEdit && ( diff --git a/frontend-web/src/components/workspace/messages/message-list.tsx b/frontend-web/src/components/workspace/messages/message-list.tsx index 84fca01c..72044df3 100644 --- a/frontend-web/src/components/workspace/messages/message-list.tsx +++ b/frontend-web/src/components/workspace/messages/message-list.tsx @@ -262,7 +262,7 @@ export function MessageList({ // Durable ask time per message id, used to time-stamp the AI answer (which has // no timestamp of its own) with the time of the question that triggered it. - const turnTimes = useMemo(() => { + const turnTimes = useMemo(() => { const map = new Map(); let lastQuestionTime: string | undefined; for (const message of messages) { @@ -276,7 +276,22 @@ export function MessageList({ } } return map; - }, [messages, askTimes]); + }, [messages, askTimes]); + + // Associate each assistant answer with the nearest preceding user question. + // This powers the per-answer Word menu without changing message persistence. + const questionMarkdownByAssistantId = useMemo(() => { + const map = new Map(); + let currentQuestion = ""; + for (const message of messages) { + if (message.type === "human") { + currentQuestion = extractContentFromMessage(message)?.trim() ?? ""; + } else if (message.type === "ai" && message.id && currentQuestion) { + map.set(message.id, currentQuestion); + } + } + return map; + }, [messages]); const [settings] = useLocalSettings(); const ragModeEnabled = @@ -529,8 +544,11 @@ export function MessageList({ ? getQuestionAnchorId(msg, messages.indexOf(msg)) : undefined } - message={msg} - isLoading={messageIsLoading} + message={msg} + questionMarkdown={ + msg.id ? questionMarkdownByAssistantId.get(msg.id) : undefined + } + isLoading={messageIsLoading} threadId={threadId} tokenUsageEnabled={tokenUsageEnabled} displaySources={displaySources} diff --git a/frontend-web/src/core/api/markdown-docx-export.ts b/frontend-web/src/core/api/markdown-docx-export.ts index 5b7f10f7..88fa1a0c 100644 --- a/frontend-web/src/core/api/markdown-docx-export.ts +++ b/frontend-web/src/core/api/markdown-docx-export.ts @@ -1,114 +1,22 @@ -import { apiFetch } from "@/core/api/fetch-client"; -import { getBackendBaseURL } from "@/core/config"; -import { OptType, writeYLog } from "@/core/log/y-log"; import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx"; -function filenameFromContentDisposition(header: string | null): string | null { - if (!header) return null; - const star = /filename\*=(?:UTF-8'')?([^;]+)/i.exec(header); - if (star) { - const raw = star[1].replace(/["']/g, "").trim(); - try { - return decodeURIComponent(raw); - } catch { - return raw; - } - } - const plain = /filename="?([^";\n]+)"?/i.exec(header); - return plain ? plain[1].trim() : null; -} - -function sanitizeFallbackFilename(title: string | undefined): string { - const base = (title?.trim() || "document").replace(/[<>:"/\\|?*\x00-\x1f]+/g, "_"); - return base.toLowerCase().endsWith(".docx") ? base : `${base}.docx`; -} - -async function downloadBlobResponse(response: Response, fallbackFilename: string) { - if (!response.ok) { - const error = await response - .json() - .catch(() => ({ detail: "导出 Word 失败" })); - const detail = error.detail; - if (typeof detail === "string") throw new Error(detail); - throw new Error("导出 Word 失败"); - } - - const blob = await response.blob(); - const filename = - filenameFromContentDisposition(response.headers.get("Content-Disposition")) ?? - fallbackFilename; - const url = URL.createObjectURL(blob); - const anchor = document.createElement("a"); - anchor.href = url; - anchor.download = filename; - anchor.rel = "noopener"; - document.body.appendChild(anchor); - anchor.click(); - anchor.remove(); - window.setTimeout(() => URL.revokeObjectURL(url), 30_000); -} - /** - * 通用 Markdown → Word 导出(Gateway `writing` 路由,与 `/api/writing/rewrite` 同级)。 + * Unified Markdown → Word export. * - * POST /api/writing/export/docx - * Body: { title?: string, markdown: string } - * - * 返回 `application/vnd.openxmlformats-officedocument.wordprocessingml.document`, - * 文件名在 `Content-Disposition` 中。 + * All download surfaces use the same required Gateway endpoint. The Gateway + * applies the formal OOXML profile and embeds its bundled title font; there is + * deliberately no client-font fallback. */ export async function downloadMarkdownDocxFromBackend(options: { title?: string; markdown: string; }): Promise { const markdown = options.markdown.trim(); - if (!markdown) { - throw new Error("文档内容为空"); - } + if (!markdown) throw new Error("文档内容为空"); - const fallbackFilename = sanitizeFallbackFilename(options.title); - - try { - const response = await apiFetch(`${getBackendBaseURL()}/api/writing/export/docx`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - title: options.title?.trim() || undefined, - markdown, - }), - }); - - // Older/offline gateways mount /api/writing but do not provide export/docx; - // FastAPI then answers 404 or 405. The browser converter is feature-equivalent. - if (response.status === 404 || response.status === 405) { - await downloadMarkdownAsDocx(markdown, options.title?.trim() || "document", options.title); - writeYLog({ - optType: OptType.export, - optDetail: "CM助手-全量-导出-Word", - url: `${getBackendBaseURL()}/api/writing/export/docx`, - reqParam: { title: options.title?.trim() || undefined }, - }); - return; - } - - await downloadBlobResponse(response, fallbackFilename); - writeYLog({ - optType: OptType.export, - optDetail: "CM助手-全量-导出-Word", - url: `${getBackendBaseURL()}/api/writing/export/docx`, - reqParam: { title: options.title?.trim() || undefined }, - }); - } catch (err) { - if (err instanceof TypeError) { - await downloadMarkdownAsDocx(markdown, options.title?.trim() || "document", options.title); - writeYLog({ - optType: OptType.export, - optDetail: "CM助手-全量-导出-Word", - url: `${getBackendBaseURL()}/api/writing/export/docx`, - reqParam: { title: options.title?.trim() || undefined }, - }); - return; - } - throw err; - } + await downloadMarkdownAsDocx( + markdown, + options.title?.trim() || "document", + options.title, + ); } diff --git a/frontend-web/src/core/scheduled-tasks/markdown-docx.ts b/frontend-web/src/core/scheduled-tasks/markdown-docx.ts index 48c6676e..4116c66d 100644 --- a/frontend-web/src/core/scheduled-tasks/markdown-docx.ts +++ b/frontend-web/src/core/scheduled-tasks/markdown-docx.ts @@ -1,485 +1,63 @@ /** - * Convert markdown text to a Word (.docx) Blob in the browser. + * Unified server-side Markdown -> Word export. * - * Designed for scheduled-task reports that often contain Chinese text, - * markdown tables, and emojis. Compared to Word's stock heading styles we - * deliberately: - * - Force every run color to pure black (`#000000`) so headings, links, - * and inline code never render as Word's default blue / hyperlink theme. - * - Render markdown tables as real OOXML tables (full-width, auto-wrapping) - * instead of falling back to plain text rows that overflow the page. - * - Strip variation selectors, zero-width joiners, and other invisible - * characters that often break Word renderers when emojis leak into the - * report. - * - * Block grammar handled line-by-line: - * - ATX heading: `^(#{1,6})\s+(.*)$` - * - Fenced code: lines between ``` ``` are emitted verbatim in a mono font - * - Blockquote: `^>\s?(.*)$` - * - Ordered list: `^\s*(\d+)\.\s+(.*)$` - * - Unordered list: `^\s*[-*+]\s+(.*)$` - * - Horizontal rule: `^(---|\*\*\*|___)\s*$` - * - Pipe table: contiguous lines starting with `|` plus a separator row - * - Blank line: paragraph break - * - Anything else: paragraph (with inline formatting) - * - * Inline grammar handled: - * `code`, **bold**, __bold__, *italic*, _italic_, [text](url) + * Every Word download surface calls the Gateway so the document receives the + * same formal OOXML styles and the server-bundled 方正小标宋简体 title font. + * The user's browser and desktop Word installation are never used for font + * detection or document generation. */ -import { - AlignmentType, - BorderStyle, - Document, - HeadingLevel, - Packer, - Paragraph, - Table, - TableCell, - TableRow, - TextRun, - WidthType, - type IRunOptions, -} from "docx"; - +import { apiFetch } from "@/core/api/fetch-client"; +import { getBackendBaseURL } from "@/core/config"; import { OptType, writeYLog } from "@/core/log/y-log"; -const BLACK = "000000"; -const BODY_FONT = { ascii: "Calibri", eastAsia: "Microsoft YaHei" }; -const MONO_FONT = { ascii: "Consolas", eastAsia: "Microsoft YaHei" }; +const DOCX_MEDIA_TYPE = + "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; -const HEADING_SIZES_HALF_PT: Record = { - // Word "size" property is half-points. 32 = 16pt. - 1: 36, - 2: 32, - 3: 28, - 4: 26, - 5: 24, - 6: 22, -}; - -const BODY_SIZE_HALF_PT = 22; // 11pt - -type InlineSegment = { - text: string; - bold?: boolean; - italic?: boolean; - code?: boolean; -}; - -/** - * Drop characters that Word renders inconsistently or that frequently smuggle - * emoji glitches into corporate reports. - * - * Removes: - * - Variation selectors (FE00–FE0F, E0100–E01EF) - * - Zero-width joiner / non-joiner / space (200C, 200D, FEFF, 200B) - * - Soft hyphen (00AD) - * - Object replacement character (FFFC) - */ -function sanitizeText(text: string): string { - return text - .replace(/[︀-️]/g, "") - .replace(/[​-‍]/g, "") - .replace(/­/g, "") - .replace(//g, "") - .replace(/[\u{E0100}-\u{E01EF}]/gu, ""); +function sanitizeFilename(value: string): string { + const name = value + .replace(/\.(md|markdown|docx)$/i, "") + .replace(/[<>:"/\\|?*\x00-\x1f]+/g, "_") + .trim(); + return name || "document"; } -/** - * Tokenise a single line of markdown into formatted segments. - * - * Recognised inline syntax: - * `code`, **bold**, __bold__, *italic*, _italic_, [text](url) - * Markdown links are flattened to their text content — we deliberately do not - * emit hyperlink runs because they pick up Word's blue theme color. - */ -function parseInlineSegments(text: string): InlineSegment[] { - const cleaned = sanitizeText(text); - // Strip markdown link syntax first so the regex below doesn't trip on `[...]`. - const withoutLinks = cleaned.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1"); - - const tokens: InlineSegment[] = []; - const pattern = - /(`+)([^`]+)\1|\*\*([^*]+)\*\*|__([^_]+)__|\*([^*]+)\*|_([^_]+)_/g; - let cursor = 0; - let match: RegExpExecArray | null; - while ((match = pattern.exec(withoutLinks)) !== null) { - if (match.index > cursor) { - tokens.push({ text: withoutLinks.slice(cursor, match.index) }); - } - if (match[2] !== undefined) tokens.push({ text: match[2], code: true }); - else if (match[3] !== undefined) tokens.push({ text: match[3], bold: true }); - else if (match[4] !== undefined) tokens.push({ text: match[4], bold: true }); - else if (match[5] !== undefined) - tokens.push({ text: match[5], italic: true }); - else if (match[6] !== undefined) - tokens.push({ text: match[6], italic: true }); - cursor = pattern.lastIndex; - } - if (cursor < withoutLinks.length) { - tokens.push({ text: withoutLinks.slice(cursor) }); - } - return tokens.length > 0 ? tokens : [{ text: withoutLinks }]; +async function responseError(response: Response): Promise { + const payload = await response + .json() + .catch(() => ({ detail: `生成 Word 失败(HTTP ${response.status})` })); + const detail = payload?.detail; + return new Error(typeof detail === "string" ? detail : "生成 Word 失败"); } -function segmentsToRuns( - segments: InlineSegment[], - extra?: Partial, -): TextRun[] { - return segments.map((seg) => { - const opts: IRunOptions = { - text: seg.text, - bold: seg.bold, - italics: seg.italic, - color: BLACK, - font: seg.code ? MONO_FONT : BODY_FONT, - size: extra?.size ?? BODY_SIZE_HALF_PT, - ...extra, - }; - return new TextRun(opts); - }); -} - -function makeParagraphFromInline(text: string): Paragraph { - return new Paragraph({ - spacing: { after: 120 }, - children: segmentsToRuns(parseInlineSegments(text)), - }); -} - -function makeHeadingParagraph(level: number, text: string): Paragraph { - const clamped = Math.max(1, Math.min(level, 6)); - return new Paragraph({ - // We attach a heading level so the table-of-contents still works, but the - // run-level color / font / size below override Word's blue theme. - heading: - ({ - 1: HeadingLevel.HEADING_1, - 2: HeadingLevel.HEADING_2, - 3: HeadingLevel.HEADING_3, - 4: HeadingLevel.HEADING_4, - 5: HeadingLevel.HEADING_5, - 6: HeadingLevel.HEADING_6, - }[clamped] as (typeof HeadingLevel)[keyof typeof HeadingLevel]) ?? undefined, - spacing: { before: 200, after: 120 }, - children: segmentsToRuns(parseInlineSegments(text), { - bold: true, - color: BLACK, - size: HEADING_SIZES_HALF_PT[clamped] ?? BODY_SIZE_HALF_PT, - }), - }); -} - -function makeListItem( - text: string, - ordered: boolean, - level: number, -): Paragraph { - return new Paragraph({ - spacing: { after: 80 }, - children: segmentsToRuns(parseInlineSegments(text)), - numbering: ordered - ? { reference: "ordered-list", level: Math.min(level, 8) } - : undefined, - bullet: ordered ? undefined : { level: Math.min(level, 8) }, - }); -} - -function makeCodeParagraph(line: string): Paragraph { - return new Paragraph({ - spacing: { after: 0 }, - children: [ - new TextRun({ - text: sanitizeText(line) || " ", - font: MONO_FONT, - color: BLACK, - size: BODY_SIZE_HALF_PT, - }), - ], - }); -} - -function makeQuoteParagraph(text: string): Paragraph { - return new Paragraph({ - indent: { left: 720 }, - spacing: { after: 120 }, - children: segmentsToRuns(parseInlineSegments(text), { italics: true }), - }); -} - -function makeHorizontalRule(): Paragraph { - return new Paragraph({ - alignment: AlignmentType.CENTER, - spacing: { before: 120, after: 120 }, - children: [ - new TextRun({ - text: "────────", - color: BLACK, - font: BODY_FONT, - size: BODY_SIZE_HALF_PT, - }), - ], - }); -} - -const CELL_BORDER = { - style: BorderStyle.SINGLE, - size: 4, - color: "BFBFBF", -}; - -function makeTableCell( - text: string, - options: { header?: boolean } = {}, -): TableCell { - const segments = parseInlineSegments(text); - return new TableCell({ - margins: { top: 80, bottom: 80, left: 100, right: 100 }, - children: [ - new Paragraph({ - spacing: { after: 0 }, - children: segmentsToRuns(segments, { - bold: options.header || undefined, - size: BODY_SIZE_HALF_PT, - }), - }), - ], - }); -} - -function splitTableRow(line: string): string[] { - // Strip leading/trailing pipe and any escaped pipes (rare in agent output). - const trimmed = line.replace(/^\s*\|/, "").replace(/\|\s*$/, ""); - return trimmed.split("|").map((cell) => cell.trim()); -} - -function buildTable(headerLine: string, bodyLines: string[]): Table { - const headerCells = splitTableRow(headerLine); - const colCount = headerCells.length; - const rows: TableRow[] = []; - rows.push( - new TableRow({ - tableHeader: true, - children: headerCells.map((cell) => - makeTableCell(cell, { header: true }), - ), - }), - ); - for (const row of bodyLines) { - const cells = splitTableRow(row); - // Pad/truncate to match the header column count so Word renders evenly. - const normalized = Array.from( - { length: colCount }, - (_, i) => cells[i] ?? "", - ); - rows.push( - new TableRow({ - children: normalized.map((c) => makeTableCell(c)), - }), - ); - } - return new Table({ - width: { size: 100, type: WidthType.PERCENTAGE }, - rows, - borders: { - top: CELL_BORDER, - bottom: CELL_BORDER, - left: CELL_BORDER, - right: CELL_BORDER, - insideHorizontal: CELL_BORDER, - insideVertical: CELL_BORDER, - }, - }); -} - -function isTableSeparator(line: string): boolean { - // e.g. |---|:---:|---:| with optional spaces - return /^\s*\|?(\s*:?-{3,}:?\s*\|)+\s*:?-{3,}:?\s*\|?\s*$/.test(line); -} - -function looksLikeTableRow(line: string): boolean { - return /^\s*\|.*\|\s*$/.test(line) || /^\s*\|.+/.test(line); -} - -/** - * Convert markdown text into an array of docx block elements (Paragraph | Table). - */ -function markdownToBlocks(markdown: string): Array { - const lines = markdown.replace(/\r\n/g, "\n").split("\n"); - const blocks: Array = []; - - let inFence = false; - let paragraphBuffer: string[] = []; - - const flushParagraph = () => { - if (paragraphBuffer.length === 0) return; - blocks.push(makeParagraphFromInline(paragraphBuffer.join(" "))); - paragraphBuffer = []; - }; - - for (let i = 0; i < lines.length; i++) { - const rawLine = lines[i] ?? ""; - const line = rawLine.replace(/\t/g, " "); - - if (line.trim().startsWith("```")) { - flushParagraph(); - inFence = !inFence; - continue; - } - if (inFence) { - blocks.push(makeCodeParagraph(line)); - continue; - } - - if (!line.trim()) { - flushParagraph(); - continue; - } - - const heading = /^(#{1,6})\s+(.*)$/.exec(line); - if (heading) { - flushParagraph(); - blocks.push( - makeHeadingParagraph(heading[1]!.length, heading[2]!.trim()), - ); - continue; - } - - if (/^(?:---|\*\*\*|___)\s*$/.test(line)) { - flushParagraph(); - blocks.push(makeHorizontalRule()); - continue; - } - - const blockquote = /^>\s?(.*)$/.exec(line); - if (blockquote) { - flushParagraph(); - blocks.push(makeQuoteParagraph(blockquote[1] ?? "")); - continue; - } - - // Table detection — a header row followed by a separator row. - if ( - looksLikeTableRow(line) && - i + 1 < lines.length && - isTableSeparator(lines[i + 1] ?? "") - ) { - flushParagraph(); - const headerLine = line; - i += 2; // skip header + separator - const body: string[] = []; - while (i < lines.length && looksLikeTableRow(lines[i] ?? "")) { - body.push(lines[i] ?? ""); - i++; - } - // Re-step back one so the outer loop's i++ lands on the next unprocessed - // line. Without this, blank lines after the table get swallowed. - i--; - blocks.push(buildTable(headerLine, body)); - // Word's table rendering eats following spacing; inject a tiny gap. - blocks.push( - new Paragraph({ spacing: { after: 0 }, children: [new TextRun("")] }), - ); - continue; - } - - const orderedItem = /^(\s*)(\d+)\.\s+(.*)$/.exec(line); - if (orderedItem) { - flushParagraph(); - const indent = Math.floor(orderedItem[1]!.length / 2); - blocks.push(makeListItem(orderedItem[3]!, true, indent)); - continue; - } - - const unorderedItem = /^(\s*)[-*+]\s+(.*)$/.exec(line); - if (unorderedItem) { - flushParagraph(); - const indent = Math.floor(unorderedItem[1]!.length / 2); - blocks.push(makeListItem(unorderedItem[2]!, false, indent)); - continue; - } - - paragraphBuffer.push(line.trim()); - } - - flushParagraph(); - return blocks; -} - -/** - * Convert a markdown string into a .docx Blob suitable for download. - */ +/** Ask the Gateway to build a DOCX with its embedded licensed font resource. */ export async function markdownToDocxBlob( markdown: string, title?: string, ): Promise { - const children: Array = []; - if (title?.trim()) { - children.push( - new Paragraph({ - spacing: { before: 0, after: 240 }, - children: [ - new TextRun({ - text: sanitizeText(title.trim()), - bold: true, - color: BLACK, - font: BODY_FONT, - size: 40, // 20pt - }), - ], - }), - ); - } - children.push(...markdownToBlocks(markdown)); + const source = markdown?.trim(); + if (!source) throw new Error("文档内容为空"); - const doc = new Document({ - styles: { - default: { - document: { - run: { - color: BLACK, - font: BODY_FONT, - size: BODY_SIZE_HALF_PT, - }, - }, - }, - }, - numbering: { - config: [ - { - reference: "ordered-list", - levels: [ - { - level: 0, - format: "decimal", - text: "%1.", - alignment: AlignmentType.START, - }, - { - level: 1, - format: "decimal", - text: "%2.", - alignment: AlignmentType.START, - }, - { - level: 2, - format: "decimal", - text: "%3.", - alignment: AlignmentType.START, - }, - ], - }, - ], - }, - sections: [{ children }], + const endpoint = `${getBackendBaseURL()}/api/writing/export/docx`; + const response = await apiFetch(endpoint, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + title: title?.trim() || undefined, + markdown: source, + }), }); - return Packer.toBlob(doc); + if (!response.ok) throw await responseError(response); + + const blob = await response.blob(); + if (blob.size === 0) throw new Error("服务器返回了空的 Word 文件"); + if (blob.type && blob.type !== DOCX_MEDIA_TYPE) { + throw new Error("服务器返回的文件类型不是 Word 文档"); + } + return blob; } -/** - * Trigger a browser download of the markdown source converted to .docx. - */ +/** Generate and immediately download a formal DOCX. */ export async function downloadMarkdownAsDocx( markdown: string, fileName: string, @@ -488,15 +66,15 @@ export async function downloadMarkdownAsDocx( const blob = await markdownToDocxBlob(markdown, title); writeYLog({ optType: OptType.export, - optDetail: "CM助手-全量-定时任务-导出", - url: "/scheduled-tasks/export/docx", + optDetail: "CM助手-Word导出", + url: "/api/writing/export/docx", reqParam: { fileName, title }, }); - const safeName = fileName.replace(/\.(md|markdown)$/i, "") || "document"; + const url = URL.createObjectURL(blob); const anchor = document.createElement("a"); anchor.href = url; - anchor.download = `${safeName}.docx`; + anchor.download = `${sanitizeFilename(fileName)}.docx`; anchor.rel = "noopener"; document.body.appendChild(anchor); anchor.click(); diff --git a/frontend-web/src/core/threads/export.ts b/frontend-web/src/core/threads/export.ts index 40f38f7d..cc9b053e 100644 --- a/frontend-web/src/core/threads/export.ts +++ b/frontend-web/src/core/threads/export.ts @@ -144,7 +144,7 @@ export function exportThreadAsJSON(thread: AgentThread, messages: Message[]) { /** * Export the conversation as a Word document (.docx). The whole thread is * serialised to Markdown first (same formatter the `.md` export uses), and - * then converted to a docx Blob in the browser via `markdownToDocxBlob`. + * then sent to the Gateway's shared formal DOCX generator. */ export async function exportThreadAsWord( thread: AgentThread, diff --git a/offline-backend-20260512/backend/CLAUDE.md b/offline-backend-20260512/backend/CLAUDE.md index a9a201b5..1450366e 100644 --- a/offline-backend-20260512/backend/CLAUDE.md +++ b/offline-backend-20260512/backend/CLAUDE.md @@ -324,6 +324,7 @@ FastAPI application on port 8001 with health checks at `GET|HEAD /health`, `/api | **Threads** (`/api/threads/{id}`) | `DELETE /` - remove DeerFlow-managed local thread data after LangGraph thread deletion; unexpected failures are logged server-side and return a generic 500 detail | | **Threads search** (`POST /threads/search`, LangGraph-compat) | Conversation listing. **System threads are excluded by default**: rows whose `metadata.system` is truthy (scheduler runs, roundtable workers) never crowd the user's list; a caller whose `metadata` filter explicitly references `thread_type`/`system` (e.g. studio notebooks) opts out of the exclusion. New `query` field = case-insensitive title (`display_name`) substring search, used by the chats page's server-side pagination. `POST /threads/count` takes the same request body (limit/offset ignored) and returns `{total}` under identical filtering/exclusion semantics ? powers the chats page's ??/??? display without fetching rows (`ThreadMetaStore.count`). `ThreadMetaStore.search` gained `exclude_system`/`query` params; JSON-blob filters batch-scan until the requested `limit`/`offset` window is full (so pagination over a heavily-filtered set never under-fills), with a `thread_id` tie-breaker on the `updated_at DESC` ordering for stable OFFSET paging. Tests: `tests/test_thread_meta_search.py` | | **Artifacts** (`/api/threads/{id}/artifacts`) | `GET /{path}` - serve artifacts; active content types (`text/html`, `application/xhtml+xml`, `image/svg+xml`) are always forced as download attachments to reduce XSS risk; `?download=true` still forces download for other file types | +| **Word export** (`POST /api/writing/export/docx`) | Shared Markdown → DOCX generator used by conversation, artifact, scheduled-task, Deep Research, and AI-writing download surfaces. `app/gateway/word_export.py` builds OOXML with no Python conversion dependency: A4 portrait, 3.7/3.5/2.8/2.6 cm margins, real multilevel heading numbering, formal body/table styles, page numbers beginning at 1 with odd pages left-aligned and even pages right-aligned, and embedded `app/gateway/assets/fonts/方正小标宋简体.ttf`. The font loader validates its internal family, SHA-256, and OpenType embedding permission (`OS/2.fsType`) before export. Tests: `tests/test_word_export.py`. | | **Deep Research** (`/api/deep-research`) | Durable, user-owned research sessions/jobs with replayable SSE events and hidden-thread artifacts. `basic`/`quick` use the compact runner; `detailed` creates bounded subtopics, searches/writes sections concurrently, and assembles a report; `deep` recursively searches follow-up questions under a deterministic breadth/depth query budget. `multi_agent` is a LangGraph 1.x editor/researcher/writer/reviewer graph: its initial plan emits `awaiting_input`, the recorder stores the interrupt and releases the lease, and `POST /jobs/{id}/resume` rebuilds the graph from the persisted approved/revised plan. A bounded `custom_outline` is frozen into the session; headings/list entries deterministically supply the detailed and multi-agent plan, and the shared writer receives it only as a guarded content constraint. Completed reports support owner-scoped follow-up (`GET /sessions/{id}/messages`, `POST /sessions/{id}/chat`): context is only the current report, selected sources and recent messages; source-id citations are persisted, while `allow_new_research=true` is explicitly rejected. Owners may change that evidence set only after the job stops via `PATCH /sessions/{id}/sources/{source_id}`; this applies to future follow-ups rather than rewriting a completed report. Optional report illustrations are gated by `config.yaml -> deep_research.image_generation`: the Harness `OpenAICompatibleImageProvider` requires base64 output, the shared runner step writes only bounded `research-image-01..04.(png|jpg|webp)` artifacts and Markdown uses private `deep-research://` links. `GET /sessions/{id}/artifacts/{name}` checks session ownership before serving an image; an unavailable provider yields a warning but never fails the text report. Runners live in `packages/harness/deerflow/agents/deep_research/runners/`, may never import `app.*`, and use only the `AdapterBundle`. | | **Gateway auth** | Owner-scoped Deep Research routes await the platform's asynchronous `get_current_user` resolver before they read or write persistence, ensuring each session and job operation receives a concrete user ID. Deep Research artifacts are always resolved from a whitelisted filename via `/mnt/user-data/outputs/` before mapping to a host path, preserving the sandbox guard on native Windows and Linux hosts. | | **Suggestions** (`/api/threads/{id}/suggestions`) | `POST /` - generate follow-up questions; rich list/block model content is normalized before JSON parsing | @@ -1342,10 +1343,11 @@ the message-list thought trace, while the sandbox receives answer Markdown only. The post-report digest (`summarize_report`) must likewise forward provider reasoning through live-only `summary_thinking` frames so the chat timeline can render thought while waiting for the first `summary_delta`. -The Vite -page owns Word export through -`frontend-web/src/core/scheduled-tasks/markdown-docx.ts`; do not add Python -document-conversion dependencies merely to export text. +The Vite page starts Word downloads through +`frontend-web/src/core/scheduled-tasks/markdown-docx.ts`, but the Gateway owns +generation through `POST /api/writing/export/docx`. Keep the standard-library +OOXML builder and its embedded-font validation; do not add a Python document- +conversion dependency merely to export text. All non-streaming Deep Research control-plane calls (`complete()` for query / section / plan / review JSON) run through `asyncio.wait_for` using the frozen `DeepResearchConfig.control_plane_timeout_seconds` (45 seconds by default, diff --git a/offline-backend-20260512/backend/README.md b/offline-backend-20260512/backend/README.md index 3b91da27..bc7f0e6a 100644 --- a/offline-backend-20260512/backend/README.md +++ b/offline-backend-20260512/backend/README.md @@ -156,6 +156,7 @@ FastAPI application providing REST endpoints for frontend integration: | `GET/POST /api/deep-research/*` | Durable Deep Research sessions and jobs. `quick`/`basic` run the compact report flow; `detailed` plans subtopics and writes independent sections; `deep` recursively investigates bounded follow-up branches. All modes reuse DeerFlow's configured material provider, LLM factory, hidden-thread artifacts, and SSE event log. Deep Research session/job/event/source/message writes serialise their rows in the writer transaction, rather than doing a post-commit ORM refresh or readback; this prevents MySQL read/write-splitting replica lag from failing “start writing”, dispatcher claim, report persistence, or follow-up history. Whole-document artifact/report rewrites likewise create their reversible version snapshots from writer-local ORM data and never refresh after commit, so replica lag cannot restore an otherwise successful rewrite. A report is written to the session and confirmed before its Job becomes `completed`; an unconfirmed write is retried and never exposed as a false completion. Retrying an already-created queued job re-nudges the dispatcher, recovering an interrupted HTTP response without creating a second report. If progress-event persistence, durable replay, or live fan-out is briefly unavailable, the worker/stream logs the degradation and continues or reconnects rather than failing the whole job. Whole-report rewrite accepts an optional `reportOutline` Markdown template; the durable job persists it and applies it as the chapter structure constraint. Optional report illustrations use a separately configured OpenAI-compatible image endpoint and are served only through the owning session's authenticated artifact route. | | `/api/enterprise-research/*` | Temporarily disabled. Its router and persistence initialization are not registered, so these endpoints return 404 and no enterprise-research tables are created or migrated during startup. | | `POST /api/ai-writing/sample/extract` | Extract text from Word/PDF/Markdown/TXT samples for AI-writing imitation mode | +| `POST /api/writing/export/docx` | Generate the shared formal Word download from Markdown. Uses the standard-library OOXML builder in `app/gateway/word_export.py`, applies the prescribed A4/margin/heading/body/table/footer profile, and embeds the server-bundled `方正小标宋简体.ttf`; the client computer does not need that font installed. | | `POST /cop/saveSuperiorTask` | TaskCOP compatibility API (DeerFlow Bearer/session authentication required): create a situation-overview task with a sequential four-digit id (`0001`, `0002`, ...) and return the legacy `{state, msg, data}` envelope | | `POST /api/taskcop/import/tasks` | TaskCOP compatibility API (authenticated): import tasks from a JSON array or `tasks`/`records` envelope; every row's supplied `id`/`taskId` is authoritative and a duplicate id completely replaces the stored task | | `DELETE /cop/tasks/{task_id}` | TaskCOP compatibility API (authenticated): delete one task and its task-scoped situation-report document | @@ -287,9 +288,10 @@ The Deep Research page is a conversation workspace: the original research question, observable retrieval steps, completion summary, and report-grounded follow-ups appear in chronological order. The report itself is written in the adjacent sandbox as `/mnt/user-data/outputs/report.md`, with Word, Markdown, -and HTML export actions. Word export uses the frontend's existing `docx` -dependency and includes the collected reference list, without changing backend -Python dependencies. In chat-collection sessions the report configuration card +and HTML export actions. Word export calls the shared Gateway +`POST /api/writing/export/docx` generator, embeds the licensed title font, and +includes the collected reference list without adding a backend Python package. +In chat-collection sessions the report configuration card shows the unique material count inferred from the collector's visible tool results before writing begins. The write lifecycle itself is rendered as ordinary workspace message-list tool steps (not a second timeline component), diff --git a/offline-backend-20260512/backend/app/gateway/assets/fonts/README.md b/offline-backend-20260512/backend/app/gateway/assets/fonts/README.md new file mode 100644 index 00000000..27d9c64f --- /dev/null +++ b/offline-backend-20260512/backend/app/gateway/assets/fonts/README.md @@ -0,0 +1,11 @@ +# Word 导出字体资源 + +`方正小标宋简体.ttf` 是 Word 正式格式导出的标题字体,由部署方提供并授权本系统用于文档嵌入。 + +- 字体家族:方正小标宋简体 +- SHA-256:`C52D577DD3AA719EF5FB9FF2043B7F7CA36FAE468EF34A47628A3A555D006A49` +- OpenType `OS/2.fsType`:`0x0008`(允许可编辑嵌入) + +后端启动后不会把字体安装到操作系统;导出时会将字体以 OOXML 规定的混淆字体部件嵌入 `.docx`。因此下载者电脑无需预装此字体,也不会触发客户端字体回退。 + +如部署时需要从外部密钥卷提供字体,可设置 `DEERFLOW_WORD_TITLE_FONT_PATH` 指向同一字体文件。后端仍会校验字体内部家族名和嵌入许可。 diff --git a/offline-backend-20260512/backend/app/gateway/assets/fonts/方正小标宋简体.ttf b/offline-backend-20260512/backend/app/gateway/assets/fonts/方正小标宋简体.ttf new file mode 100644 index 00000000..2fe32152 Binary files /dev/null and b/offline-backend-20260512/backend/app/gateway/assets/fonts/方正小标宋简体.ttf differ diff --git a/offline-backend-20260512/backend/app/gateway/auth_middleware.py b/offline-backend-20260512/backend/app/gateway/auth_middleware.py index eabd9bf5..c438d4e5 100644 --- a/offline-backend-20260512/backend/app/gateway/auth_middleware.py +++ b/offline-backend-20260512/backend/app/gateway/auth_middleware.py @@ -72,6 +72,8 @@ _PUBLIC_EXACT_PATHS: frozenset[str] = frozenset( "/api/v1/auth/initialize", # Anonymous vector search over published local Wiki indexes. "/api/knowledge/vector-search", + # Stateless Markdown -> DOCX conversion; required by public report pages. + "/api/writing/export/docx", # 并行多智能体面板专用的「绕过常规登录」取 token 端点(内网部署限制了标准登录时用)。 "/api/parallel-agents/auth/token", } diff --git a/offline-backend-20260512/backend/app/gateway/csrf_middleware.py b/offline-backend-20260512/backend/app/gateway/csrf_middleware.py index 8aec22a3..b46a42cd 100644 --- a/offline-backend-20260512/backend/app/gateway/csrf_middleware.py +++ b/offline-backend-20260512/backend/app/gateway/csrf_middleware.py @@ -4,9 +4,14 @@ Per RFC-001: State-changing operations require CSRF protection. """ -import secrets -from collections.abc import Callable - +import secrets +from collections.abc import Callable + +from fastapi import Request, Response +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.responses import JSONResponse +from starlette.types import ASGIApp + from app.gateway.auth.disabled_mode import is_auth_disabled from app.gateway.proxy_path import app_relative_path from app.gateway.weknora_embed import ( @@ -14,11 +19,7 @@ from app.gateway.weknora_embed import ( has_valid_weknora_embed_cookie, is_weknora_embed_proxy_path, ) -from fastapi import Request, Response -from starlette.middleware.base import BaseHTTPMiddleware -from starlette.responses import JSONResponse -from starlette.types import ASGIApp - + CSRF_COOKIE_NAME = "csrf_token" CSRF_HEADER_NAME = "X-CSRF-Token" CSRF_TOKEN_LENGTH = 64 # bytes @@ -52,6 +53,8 @@ def should_check_csrf(request: Request) -> bool: return False if stripped == "/api/knowledge/vector-search": return False + if stripped == "/api/writing/export/docx": + return False # Anonymous public endpoints (mirror auth_middleware._PUBLIC_PATH_PREFIXES). # These intentionally have no cookie session, so the Double-Submit Cookie # pattern is both impossible (no token to compare) and unnecessary (no diff --git a/offline-backend-20260512/backend/app/gateway/routers/writing.py b/offline-backend-20260512/backend/app/gateway/routers/writing.py index aca0184a..02cc0247 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/writing.py +++ b/offline-backend-20260512/backend/app/gateway/routers/writing.py @@ -15,11 +15,13 @@ import hashlib import json import logging import os +import re import uuid from pathlib import Path +from urllib.parse import quote from fastapi import APIRouter, Depends, HTTPException, Query, Request -from fastapi.responses import StreamingResponse +from fastapi.responses import Response, StreamingResponse from langchain_core.messages import AIMessage, HumanMessage, SystemMessage from pydantic import BaseModel, Field @@ -57,6 +59,12 @@ from app.gateway.document_rewrite_pipeline import ( ) from app.gateway.document_rewrite_summary import build_document_rewrite_summary from app.gateway.path_utils import aresolve_thread_virtual_path +from app.gateway.word_export import ( + DOCX_MEDIA_TYPE, + WordExportError, + WordExportFontError, + build_markdown_docx, +) from deerflow.config.app_config import AppConfig from deerflow.models import create_chat_model from deerflow.utils.stream_text import InlineThinkTagFilter @@ -130,6 +138,13 @@ class RewriteRequest(BaseModel): model_config = {"populate_by_name": True} +class MarkdownDocxExportRequest(BaseModel): + """Markdown source for the server-rendered formal Word download.""" + + title: str | None = Field(default=None, max_length=300) + markdown: str = Field(..., min_length=1, max_length=2_000_000) + + class DocumentRewriteRequest(BaseModel): """Request for a complete Markdown artifact rewrite. @@ -363,6 +378,40 @@ def build_rewrite_messages(body: RewriteRequest) -> tuple[str, list]: # ── Endpoint ────────────────────────────────────────────────────────────────── +@router.post( + "/export/docx", + summary="Export Markdown as a formally formatted Word document", + response_class=Response, +) +async def export_markdown_docx(body: MarkdownDocxExportRequest) -> Response: + """Generate DOCX on the server and embed the required title font. + + This route is intentionally usable by both authenticated workspaces and + public scheduled-task pages. It performs no external fetches and does not + persist the supplied Markdown. + """ + try: + content = await asyncio.to_thread(build_markdown_docx, body.markdown, body.title) + except WordExportFontError as exc: + raise HTTPException(status_code=503, detail=str(exc)) from exc + except WordExportError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + fallback_name = (body.title or "document").strip() or "document" + fallback_name = re.sub(r'[<>:"/\\|?*\x00-\x1f]+', "_", fallback_name).strip(" .")[:120] or "document" + filename = f"{fallback_name}.docx" + return Response( + content=content, + media_type=DOCX_MEDIA_TYPE, + headers={ + "Content-Disposition": ( + f'attachment; filename="document.docx"; filename*=UTF-8\'\'{quote(filename, safe="")}' + ), + "Cache-Control": "no-store", + }, + ) + + @router.post("/document-rewrite/jobs") @require_permission("threads", "write", owner_check=True) async def start_durable_document_rewrite_job( diff --git a/offline-backend-20260512/backend/app/gateway/word_export.py b/offline-backend-20260512/backend/app/gateway/word_export.py new file mode 100644 index 00000000..eb164d57 --- /dev/null +++ b/offline-backend-20260512/backend/app/gateway/word_export.py @@ -0,0 +1,994 @@ +"""Deterministic Markdown -> DOCX export with an embedded title font. + +The implementation intentionally uses Python's standard library only. It +builds the small set of OOXML parts required by Word instead of depending on a +desktop Office installation or a document-conversion service. The bundled +方正小标宋简体 font is validated and embedded according to ECMA-376 so the +recipient's computer does not need the font installed. +""" + +from __future__ import annotations + +import hashlib +import io +import os +import re +import struct +import uuid +import zipfile +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path +from xml.sax.saxutils import escape + +DOCX_MEDIA_TYPE = "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + +TITLE_FONT_FAMILY = "方正小标宋简体" +BODY_FONT_FAMILY = "仿宋" +HEADING_1_FONT_FAMILY = "黑体" +HEADING_2_FONT_FAMILY = "楷体" +TABLE_FONT_FAMILY = "宋体" + +TITLE_SIZE_HALF_POINTS = 44 # 二号 / 22 pt +BODY_SIZE_HALF_POINTS = 32 # 三号 / 16 pt +TABLE_SIZE_HALF_POINTS = 28 # 四号 / 14 pt +EXACT_LINE_TWIPS = 579 # 28.95 pt * 20 + +A4_WIDTH_TWIPS = 11_906 +A4_HEIGHT_TWIPS = 16_838 +MARGIN_TOP_TWIPS = 2_098 # 3.7 cm +MARGIN_BOTTOM_TWIPS = 1_984 # 3.5 cm +MARGIN_LEFT_TWIPS = 1_587 # 2.8 cm +MARGIN_RIGHT_TWIPS = 1_474 # 2.6 cm +CONTENT_WIDTH_TWIPS = A4_WIDTH_TWIPS - MARGIN_LEFT_TWIPS - MARGIN_RIGHT_TWIPS + +_XML_DECL = '' +_W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main" +_R_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships" +_PACKAGE_REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships" + +_FONT_DIR = Path(__file__).resolve().parent / "assets" / "fonts" +_BUNDLED_FONT_PATH = _FONT_DIR / "方正小标宋简体.ttf" +_BUNDLED_FONT_SHA256 = "C52D577DD3AA719EF5FB9FF2043B7F7CA36FAE468EF34A47628A3A555D006A49" + + +class WordExportError(RuntimeError): + """Base error returned to the Word export API.""" + + +class WordExportFontError(WordExportError): + """The required server-side font is missing, invalid, or not embeddable.""" + + +@dataclass(slots=True) +class FontAsset: + data: bytes + path: Path + names: frozenset[str] + fs_type: int + sha256: str + + +@dataclass(slots=True) +class ParagraphBlock: + kind: str + text: str + level: int = 0 + + +@dataclass(slots=True) +class TableBlock: + header: list[str] + rows: list[list[str]] + + +Block = ParagraphBlock | TableBlock + + +def _safe_xml_text(value: str) -> str: + """Remove XML-illegal controls and invisible Word-breaking selectors.""" + value = re.sub(r"[\u0000-\u0008\u000b\u000c\u000e-\u001f]", "", value) + value = re.sub(r"[\u200b-\u200d\ufeff\ufe00-\ufe0f]", "", value) + value = value.replace("\u00ad", "").replace("\ufffc", "") + value = re.sub(r"]*>", "", value, flags=re.I) + return value + + +def _xml_attr(value: str) -> str: + return escape(_safe_xml_text(value), {'"': """, "'": "'"}) + + +def _decode_name_record(platform_id: int, raw: bytes) -> str | None: + try: + if platform_id in {0, 3}: + return raw.decode("utf-16-be").strip("\x00") + if platform_id == 1: + return raw.decode("mac_roman").strip("\x00") + except (UnicodeDecodeError, LookupError): + return None + return None + + +def _read_sfnt_metadata(data: bytes) -> tuple[frozenset[str], int]: + if len(data) < 12: + raise WordExportFontError("Word 标题字体文件不是有效的 OpenType/TrueType 字体。") + try: + table_count = struct.unpack_from(">H", data, 4)[0] + tables: dict[str, tuple[int, int]] = {} + for index in range(table_count): + position = 12 + index * 16 + tag, _checksum, offset, length = struct.unpack_from(">4sIII", data, position) + tag_text = tag.decode("latin-1") + if offset + length > len(data): + raise WordExportFontError("Word 标题字体的表目录已损坏。") + tables[tag_text] = (offset, length) + + if "OS/2" not in tables or "name" not in tables: + raise WordExportFontError("Word 标题字体缺少 OS/2 或 name 元数据表。") + + os2_offset, os2_length = tables["OS/2"] + if os2_length < 10: + raise WordExportFontError("Word 标题字体的 OS/2 元数据无效。") + fs_type = struct.unpack_from(">H", data, os2_offset + 8)[0] + + name_offset, name_length = tables["name"] + if name_length < 6: + raise WordExportFontError("Word 标题字体的 name 元数据无效。") + name_count, string_offset = struct.unpack_from(">HH", data, name_offset + 2) + names: set[str] = set() + for index in range(name_count): + record_offset = name_offset + 6 + index * 12 + if record_offset + 12 > name_offset + name_length: + break + platform_id, _encoding, _language, name_id, length, offset = struct.unpack_from( + ">HHHHHH", data, record_offset + ) + if name_id not in {1, 4, 6}: + continue + value_start = name_offset + string_offset + offset + value_end = value_start + length + if value_end > name_offset + name_length: + continue + decoded = _decode_name_record(platform_id, data[value_start:value_end]) + if decoded: + names.add(decoded) + return frozenset(names), fs_type + except struct.error as exc: + raise WordExportFontError("Word 标题字体文件结构不完整。") from exc + + +def resolve_title_font_path() -> Path: + configured = os.getenv("DEERFLOW_WORD_TITLE_FONT_PATH", "").strip() + return Path(configured).expanduser() if configured else _BUNDLED_FONT_PATH + + +def load_title_font(path: Path | None = None) -> FontAsset: + font_path = (path or resolve_title_font_path()).resolve() + if not font_path.is_file(): + raise WordExportFontError( + f"服务器缺少“{TITLE_FONT_FAMILY}”字体资源:{font_path}。" + "请将已授权字体放入后端字体目录后再导出。" + ) + data = font_path.read_bytes() + if not data or len(data) > 25 * 1024 * 1024: + raise WordExportFontError("Word 标题字体文件为空或超过 25 MB 限制。") + + names, fs_type = _read_sfnt_metadata(data) + accepted_name = TITLE_FONT_FAMILY in names or any("FZXiaoBiaoSong" in name for name in names) + if not accepted_name: + readable_names = "、".join(sorted(names)[:6]) or "无法读取" + raise WordExportFontError( + f"服务器字体不是“{TITLE_FONT_FAMILY}”(检测到:{readable_names}),已阻止导出。" + ) + if fs_type & 0x0002: + raise WordExportFontError(f"“{TITLE_FONT_FAMILY}”授权标志禁止文档嵌入,已阻止导出。") + if fs_type & 0x0200: + raise WordExportFontError(f"“{TITLE_FONT_FAMILY}”仅允许位图嵌入,不能用于本次 Word 导出。") + + digest = hashlib.sha256(data).hexdigest().upper() + if font_path == _BUNDLED_FONT_PATH.resolve() and digest != _BUNDLED_FONT_SHA256: + raise WordExportFontError("代码仓库中的 Word 标题字体校验和不匹配,已阻止导出。") + return FontAsset(data=data, path=font_path, names=names, fs_type=fs_type, sha256=digest) + + +def _obfuscate_font(data: bytes, font_key: str) -> bytes: + """Apply the ECMA-376 ODTTF XOR obfuscation to the first 32 bytes.""" + key_hex = font_key.replace("-", "") + if len(key_hex) != 32: + raise WordExportFontError("生成 Word 嵌入字体密钥失败。") + key = bytes.fromhex(key_hex)[::-1] + output = bytearray(data) + for index in range(min(32, len(output))): + output[index] ^= key[index % len(key)] + return bytes(output) + + +def _strip_inline_markdown(text: str) -> str: + text = _safe_xml_text(text) + text = re.sub(r"^\s*#+\s*", "", text) + text = re.sub(r"!\[([^\]]*)\]\([^)]*\)", r"\1", text) + text = re.sub(r"\[([^\]]+)\]\([^)]*\)", r"\1", text) + text = re.sub(r"(?:\*\*|__)(.*?)(?:\*\*|__)", r"\1", text) + return re.sub(r"[`*_~]", "", text).strip() + + +def _strip_manual_heading_number(text: str) -> str: + return re.sub( + r"^\s*(?:[一二三四五六七八九十百千]+、|[((][一二三四五六七八九十百千]+[))]|\d+[.、]|[((]\d+[))]|\d+[))])\s*", + "", + text, + ).strip() + + +def _split_table_row(line: str) -> list[str]: + value = re.sub(r"^\s*\|", "", line) + value = re.sub(r"\|\s*$", "", value) + return [part.replace(r"\|", "|").strip() for part in re.split(r"(? bool: + cells = _split_table_row(line) + return bool(cells) and all(re.fullmatch(r":?-{3,}:?", cell.replace(" ", "")) for cell in cells) + + +def _looks_like_table_row(line: str) -> bool: + return bool(re.match(r"^\s*\|.+", line)) + + +def _is_table_caption(text: str) -> bool: + return bool(re.match(r"^\s*表\s*(?:\d+|[一二三四五六七八九十百]+|[::])", _strip_inline_markdown(text))) + + +def _extract_title(markdown: str, explicit_title: str | None) -> tuple[str | None, list[str]]: + lines = markdown.replace("\r\n", "\n").replace("\r", "\n").split("\n") + in_fence = False + for index, line in enumerate(lines): + if line.strip().startswith("```"): + in_fence = not in_fence + continue + if not in_fence: + match = re.match(r"^#\s+(.+)$", line) + if match: + del lines[index] + return _strip_inline_markdown(match.group(1)) or (explicit_title or "").strip() or None, lines + return (explicit_title or "").strip() or None, lines + + +def _parse_markdown(markdown: str, explicit_title: str | None) -> tuple[str | None, list[Block]]: + title, lines = _extract_title(markdown, explicit_title) + blocks: list[Block] = [] + paragraph_buffer: list[str] = [] + in_fence = False + + def flush_paragraph() -> None: + if paragraph_buffer: + blocks.append(ParagraphBlock("body", " ".join(paragraph_buffer))) + paragraph_buffer.clear() + + index = 0 + while index < len(lines): + raw = lines[index].replace("\t", " ") + if raw.strip().startswith("```"): + flush_paragraph() + in_fence = not in_fence + index += 1 + continue + if in_fence: + blocks.append(ParagraphBlock("code", raw or " ")) + index += 1 + continue + if not raw.strip(): + flush_paragraph() + index += 1 + continue + + heading = re.match(r"^(#{1,6})\s+(.+)$", raw) + if heading: + flush_paragraph() + markdown_level = max(2, len(heading.group(1))) + section_level = max(0, min(markdown_level - 2, 4)) + blocks.append(ParagraphBlock("heading", _strip_manual_heading_number(heading.group(2)), section_level)) + index += 1 + continue + + if _looks_like_table_row(raw) and index + 1 < len(lines) and _is_table_separator(lines[index + 1]): + caption: str | None = None + if paragraph_buffer and _is_table_caption(paragraph_buffer[-1]): + caption = paragraph_buffer.pop() + flush_paragraph() + if caption: + blocks.append(ParagraphBlock("table_caption", caption)) + header = _split_table_row(raw) + index += 2 + rows: list[list[str]] = [] + while index < len(lines) and _looks_like_table_row(lines[index]): + rows.append(_split_table_row(lines[index])) + index += 1 + blocks.append(TableBlock(header, rows)) + continue + + image = re.match(r'^\s*!\[([^\]]*)\]\(([^)\s]+)(?:\s+["\'][^"\']*["\'])?\)\s*$', raw) + if image: + flush_paragraph() + alt = image.group(1).strip() or "未命名图片" + blocks.append(ParagraphBlock("image", f"图片:{alt}|{image.group(2)}")) + index += 1 + continue + + quote = re.match(r"^>\s?(.*)$", raw) + if quote: + flush_paragraph() + blocks.append(ParagraphBlock("quote", quote.group(1))) + index += 1 + continue + + ordered = re.match(r"^(\s*)(\d+)[.)、]\s+(.+)$", raw) + if ordered: + flush_paragraph() + blocks.append(ParagraphBlock("ordered_list", ordered.group(3), min(len(ordered.group(1)) // 2, 8))) + index += 1 + continue + + unordered = re.match(r"^(\s*)[-*+]\s+(.+)$", raw) + if unordered: + flush_paragraph() + blocks.append(ParagraphBlock("unordered_list", unordered.group(2), min(len(unordered.group(1)) // 2, 8))) + index += 1 + continue + + if re.fullmatch(r"\s*(?:---|\*\*\*|___)\s*", raw): + flush_paragraph() + blocks.append(ParagraphBlock("horizontal_rule", "────────")) + index += 1 + continue + + paragraph_buffer.append(raw.strip()) + index += 1 + + flush_paragraph() + return title, blocks + + +class _DocumentRelationships: + def __init__(self) -> None: + self._hyperlinks: list[tuple[str, str]] = [] + + def add_hyperlink(self, target: str) -> str: + relation_id = f"rId{100 + len(self._hyperlinks)}" + self._hyperlinks.append((relation_id, target)) + return relation_id + + def xml(self) -> str: + fixed = [ + ("rId1", "http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles", "styles.xml"), + ("rId2", "http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering", "numbering.xml"), + ("rId3", "http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings", "settings.xml"), + ("rId4", "http://schemas.openxmlformats.org/officeDocument/2006/relationships/fontTable", "fontTable.xml"), + ("rId5", "http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer", "footer1.xml"), + ("rId6", "http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer", "footer2.xml"), + ] + relationships = "".join( + f'' + for relation_id, rel_type, target in fixed + ) + relationships += "".join( + f'' + for relation_id, target in self._hyperlinks + ) + return f'{_XML_DECL}{relationships}' + + +_INLINE_PATTERN = re.compile( + r"\[([^\]]+)\]\(([^)\s]+)(?:\s+[\"\'][^\"\']*[\"\'])?\)|(`+)([^`]+)\3|\*\*([^*]+)\*\*|__([^_]+)__|\*([^*]+)\*|_([^_]+)_" +) + + +def _run_xml( + text: str, + *, + font: str = BODY_FONT_FAMILY, + size: int = BODY_SIZE_HALF_POINTS, + bold: bool = False, + italic: bool = False, + underline: bool = False, +) -> str: + text = _safe_xml_text(text) + preserve = text.startswith(" ") or text.endswith(" ") or " " in text + properties = ( + f'' + '' + f'' + ) + if bold: + properties += "" + if italic: + properties += "" + if underline: + properties += '' + space = ' xml:space="preserve"' if preserve else "" + return f"{properties}{escape(text)}" + + +def _inline_xml( + text: str, + relationships: _DocumentRelationships, + *, + font: str = BODY_FONT_FAMILY, + size: int = BODY_SIZE_HALF_POINTS, +) -> str: + cleaned = _safe_xml_text(text) + output: list[str] = [] + cursor = 0 + for match in _INLINE_PATTERN.finditer(cleaned): + if match.start() > cursor: + output.append(_run_xml(cleaned[cursor : match.start()], font=font, size=size)) + if match.group(1) is not None and match.group(2) is not None: + relation_id = relationships.add_hyperlink(match.group(2)) + output.append( + f'' + f'{_run_xml(match.group(1), font=font, size=size, underline=True)}' + ) + elif match.group(4) is not None: + output.append(_run_xml(match.group(4), font=font, size=size)) + elif match.group(5) is not None: + output.append(_run_xml(match.group(5), font=font, size=size, bold=True)) + elif match.group(6) is not None: + output.append(_run_xml(match.group(6), font=font, size=size, bold=True)) + elif match.group(7) is not None: + output.append(_run_xml(match.group(7), font=font, size=size, italic=True)) + elif match.group(8) is not None: + output.append(_run_xml(match.group(8), font=font, size=size, italic=True)) + cursor = match.end() + if cursor < len(cleaned): + output.append(_run_xml(cleaned[cursor:], font=font, size=size)) + return "".join(output) or _run_xml(" ", font=font, size=size) + + +def _paragraph_xml( + style: str, + children: str, + *, + numbering_level: int | None = None, + numbering_id: int = 1, + keep_next: bool = False, + alignment: str | None = None, +) -> str: + properties = f'' + if numbering_level is not None: + properties += ( + f'' + ) + if keep_next: + properties += "" + if alignment: + properties += f'' + return f"{properties}{children}" + + +def _image_paragraph_xml(text: str, relationships: _DocumentRelationships) -> str: + label, _, target = text.partition("|") + children = _run_xml(label) + if target: + relation_id = relationships.add_hyperlink(target) + children += ( + f'' + f'{_run_xml(f"({target})", underline=True)}' + ) + return _paragraph_xml("ImageCaption", children, alignment="center") + + +def _cell_xml(text: str, width: int, relationships: _DocumentRelationships, *, header: bool) -> str: + style = "TableHeader" if header else "TableBody" + paragraph = _paragraph_xml( + style, + _inline_xml(text, relationships, font=TABLE_FONT_FAMILY, size=TABLE_SIZE_HALF_POINTS), + ) + return ( + "" + f'' + '' + '' + f"{paragraph}" + ) + + +def _table_xml(block: TableBlock, relationships: _DocumentRelationships) -> str: + column_count = max(1, len(block.header)) + base_width = CONTENT_WIDTH_TWIPS // column_count + widths = [base_width] * column_count + widths[-1] = CONTENT_WIDTH_TWIPS - base_width * (column_count - 1) + grid = "".join(f'' for width in widths) + border = ( + '' + '' + '' + '' + '' + '' + ) + properties = ( + f'' + '' + f"{border}" + ) + + header = list(block.header[:column_count]) + [""] * max(0, column_count - len(block.header)) + header_cells = "".join( + _cell_xml(header[index], widths[index], relationships, header=True) for index in range(column_count) + ) + rows = [f"{header_cells}"] + for source_row in block.rows: + normalized = list(source_row[:column_count]) + [""] * max(0, column_count - len(source_row)) + cells = "".join( + _cell_xml(normalized[index], widths[index], relationships, header=False) + for index in range(column_count) + ) + rows.append(f"{cells}") + return f"{properties}{grid}{''.join(rows)}" + + +def _blocks_xml(blocks: list[Block], relationships: _DocumentRelationships) -> str: + output: list[str] = [] + for block in blocks: + if isinstance(block, TableBlock): + output.append(_table_xml(block, relationships)) + continue + if block.kind == "heading": + style = f"Heading{block.level + 1}" + font = ( + HEADING_1_FONT_FAMILY + if block.level == 0 + else HEADING_2_FONT_FAMILY + if block.level == 1 + else BODY_FONT_FAMILY + ) + output.append( + _paragraph_xml( + style, + _inline_xml(block.text, relationships, font=font), + numbering_level=block.level, + numbering_id=1, + keep_next=True, + ) + ) + elif block.kind == "table_caption": + output.append( + _paragraph_xml( + "TableCaption", + _inline_xml(block.text, relationships, font=HEADING_1_FONT_FAMILY, size=TABLE_SIZE_HALF_POINTS), + keep_next=True, + ) + ) + elif block.kind == "ordered_list": + output.append( + _paragraph_xml( + "ListText", + _inline_xml(block.text, relationships), + numbering_level=block.level, + numbering_id=2, + ) + ) + elif block.kind == "unordered_list": + output.append( + _paragraph_xml( + "ListText", + _inline_xml(block.text, relationships), + numbering_level=block.level, + numbering_id=3, + ) + ) + elif block.kind == "code": + output.append(_paragraph_xml("CodeText", _run_xml(block.text))) + elif block.kind == "quote": + output.append(_paragraph_xml("QuoteText", _inline_xml(block.text, relationships))) + elif block.kind == "image": + output.append(_image_paragraph_xml(block.text, relationships)) + elif block.kind == "horizontal_rule": + output.append(_paragraph_xml("BodyText", _run_xml(block.text), alignment="center")) + else: + output.append(_paragraph_xml("BodyText", _inline_xml(block.text, relationships))) + return "".join(output) + + +def _font_run_properties(font: str, size: int, *, bold: bool = False) -> str: + value = ( + f'' + '' + f'' + ) + return value + ("" if bold else '') + + +def _style_xml( + style_id: str, + name: str, + *, + font: str, + size: int, + based_on: str | None = "Normal", + next_style: str = "BodyText", + alignment: str = "both", + first_line_chars: int = 0, + left: int = 0, + hanging: int = 0, + keep_next: bool = False, + keep_lines: bool = False, + outline_level: int | None = None, + bold: bool = False, +) -> str: + paragraph = ( + f'' + f'' + ) + if keep_next: + paragraph += "" + if keep_lines: + paragraph += "" + if outline_level is not None: + paragraph += f'' + based_on_xml = f'' if based_on else "" + return ( + f'' + f'{based_on_xml}' + f"{paragraph}{_font_run_properties(font, size, bold=bold)}" + ) + + +def _styles_xml() -> str: + defaults = ( + "" + f"{_font_run_properties(BODY_FONT_FAMILY, BODY_SIZE_HALF_POINTS)}" + '' + '' + f'' + '' + ) + styles = [ + _style_xml( + "Normal", + "Normal", + font=BODY_FONT_FAMILY, + size=BODY_SIZE_HALF_POINTS, + based_on=None, + next_style="BodyText", + first_line_chars=200, + ), + _style_xml( + "DocumentTitle", + "Document Title", + font=TITLE_FONT_FAMILY, + size=TITLE_SIZE_HALF_POINTS, + alignment="center", + keep_next=True, + ), + _style_xml( + "BodyText", + "Body Text", + font=BODY_FONT_FAMILY, + size=BODY_SIZE_HALF_POINTS, + first_line_chars=200, + ), + _style_xml( + "Heading1", + "heading 1", + font=HEADING_1_FONT_FAMILY, + size=BODY_SIZE_HALF_POINTS, + alignment="left", + keep_next=True, + keep_lines=True, + outline_level=0, + ), + _style_xml( + "Heading2", + "heading 2", + font=HEADING_2_FONT_FAMILY, + size=BODY_SIZE_HALF_POINTS, + alignment="left", + keep_next=True, + keep_lines=True, + outline_level=1, + ), + ] + for level in range(3, 6): + styles.append( + _style_xml( + f"Heading{level}", + f"heading {level}", + font=BODY_FONT_FAMILY, + size=BODY_SIZE_HALF_POINTS, + alignment="left", + keep_next=True, + keep_lines=True, + outline_level=level - 1, + ) + ) + styles.extend( + [ + _style_xml( + "ListText", + "List Text", + font=BODY_FONT_FAMILY, + size=BODY_SIZE_HALF_POINTS, + alignment="both", + ), + _style_xml( + "QuoteText", + "Quote Text", + font=BODY_FONT_FAMILY, + size=BODY_SIZE_HALF_POINTS, + alignment="both", + first_line_chars=200, + left=640, + ), + _style_xml( + "CodeText", + "Code Text", + font=BODY_FONT_FAMILY, + size=BODY_SIZE_HALF_POINTS, + alignment="left", + ), + _style_xml( + "TableCaption", + "Table Caption", + font=HEADING_1_FONT_FAMILY, + size=TABLE_SIZE_HALF_POINTS, + alignment="center", + keep_next=True, + ), + _style_xml( + "TableHeader", + "Table Header", + font=TABLE_FONT_FAMILY, + size=TABLE_SIZE_HALF_POINTS, + alignment="center", + keep_lines=True, + ), + _style_xml( + "TableBody", + "Table Body", + font=TABLE_FONT_FAMILY, + size=TABLE_SIZE_HALF_POINTS, + alignment="both", + keep_lines=True, + ), + _style_xml( + "ImageCaption", + "Image Caption", + font=BODY_FONT_FAMILY, + size=BODY_SIZE_HALF_POINTS, + alignment="center", + keep_lines=True, + ), + ] + ) + return f'{_XML_DECL}{defaults}{"".join(styles)}' + + +def _heading_level_xml(level: int, format_value: str, text: str, font: str) -> str: + restart = f'' if level > 0 else "" + return ( + f'{restart}' + f'' + '' + f'{_font_run_properties(font, BODY_SIZE_HALF_POINTS)}' + ) + + +def _list_abstract_xml(abstract_id: int, *, ordered: bool) -> str: + levels: list[str] = [] + bullets = ["●", "○", "▪"] + for level in range(9): + format_value = "decimal" if ordered else "bullet" + text = f"%{level + 1}." if ordered else bullets[level % len(bullets)] + left = 720 * (level + 1) + levels.append( + f'' + f'' + f'' + f'' + f'{_font_run_properties(BODY_FONT_FAMILY, BODY_SIZE_HALF_POINTS)}' + ) + return f'{"".join(levels)}' + + +def _numbering_xml() -> str: + heading_levels = "".join( + [ + _heading_level_xml(0, "chineseCounting", "%1、", HEADING_1_FONT_FAMILY), + _heading_level_xml(1, "chineseCounting", "(%2)", HEADING_2_FONT_FAMILY), + _heading_level_xml(2, "decimal", "%3.", BODY_FONT_FAMILY), + _heading_level_xml(3, "decimal", "(%4)", BODY_FONT_FAMILY), + _heading_level_xml(4, "decimal", "%5)", BODY_FONT_FAMILY), + ] + ) + return ( + f'{_XML_DECL}' + f'' + f"{heading_levels}" + f'{_list_abstract_xml(1, ordered=True)}{_list_abstract_xml(2, ordered=False)}' + '' + '' + '' + "" + ) + + +def _footer_xml(alignment: str) -> str: + run_properties = _font_run_properties(TABLE_FONT_FAMILY, TABLE_SIZE_HALF_POINTS) + return ( + f'{_XML_DECL}' + f'' + f'{run_properties}' + f'{run_properties} PAGE ' + f'{run_properties}' + f'{run_properties}1' + f'{run_properties}' + "" + ) + + +def _font_table_xml(font_key: str) -> str: + def font(name: str, *, embedded: bool = False) -> str: + embed = f'' if embedded else "" + return ( + f'' + f'{embed}' + ) + + fonts = "".join( + [ + font(TITLE_FONT_FAMILY, embedded=True), + font(BODY_FONT_FAMILY), + font(HEADING_1_FONT_FAMILY), + font(HEADING_2_FONT_FAMILY), + font(TABLE_FONT_FAMILY), + ] + ) + return f'{_XML_DECL}{fonts}' + + +def _font_table_relationships_xml() -> str: + return ( + f'{_XML_DECL}' + '' + "" + ) + + +def _settings_xml() -> str: + return ( + f'{_XML_DECL}' + '' + '' + "" + ) + + +def _document_xml(title: str | None, blocks: list[Block], relationships: _DocumentRelationships) -> str: + body: list[str] = [] + if title: + body.append( + _paragraph_xml( + "DocumentTitle", + _run_xml(title, font=TITLE_FONT_FAMILY, size=TITLE_SIZE_HALF_POINTS), + keep_next=True, + alignment="center", + ) + ) + body.append(_blocks_xml(blocks, relationships)) + section = ( + '' + '' + f'' + f'' + '' + ) + return ( + f'{_XML_DECL}' + f"{''.join(body)}{section}" + ) + + +def _content_types_xml() -> str: + return ( + f'{_XML_DECL}' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + "" + ) + + +def _package_relationships_xml() -> str: + return ( + f'{_XML_DECL}' + '' + '' + '' + "" + ) + + +def _core_properties_xml(title: str | None) -> str: + now = datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z") + safe_title = escape(title or "Word 文档") + return ( + f'{_XML_DECL}' + f"{safe_title}DeerFlow" + f'{now}' + f'{now}' + ) + + +def _app_properties_xml() -> str: + return ( + f'{_XML_DECL}' + "DeerFlow1.0" + ) + + +def build_markdown_docx(markdown: str, title: str | None = None, *, font_path: Path | None = None) -> bytes: + """Build a formal DOCX and embed the validated server-side title font.""" + if not markdown or not markdown.strip(): + raise WordExportError("文档内容为空。") + if len(markdown) > 2_000_000: + raise WordExportError("文档内容超过 200 万字符,无法导出。") + + font = load_title_font(font_path) + resolved_title, blocks = _parse_markdown(markdown, title) + relationships = _DocumentRelationships() + document_xml = _document_xml(resolved_title, blocks, relationships) + font_key = str(uuid.uuid4()).upper() + + output = io.BytesIO() + with zipfile.ZipFile(output, mode="w", compression=zipfile.ZIP_DEFLATED, compresslevel=6) as archive: + parts: dict[str, str | bytes] = { + "[Content_Types].xml": _content_types_xml(), + "_rels/.rels": _package_relationships_xml(), + "docProps/core.xml": _core_properties_xml(resolved_title), + "docProps/app.xml": _app_properties_xml(), + "word/document.xml": document_xml, + "word/_rels/document.xml.rels": relationships.xml(), + "word/styles.xml": _styles_xml(), + "word/numbering.xml": _numbering_xml(), + "word/settings.xml": _settings_xml(), + "word/fontTable.xml": _font_table_xml(font_key), + "word/_rels/fontTable.xml.rels": _font_table_relationships_xml(), + # Word applies the default footer to odd pages and the even footer + # to even pages. The requested placement is odd-left/even-right. + "word/footer1.xml": _footer_xml("left"), + "word/footer2.xml": _footer_xml("right"), + "word/fonts/font1.odttf": _obfuscate_font(font.data, font_key), + } + for name, value in parts.items(): + archive.writestr(name, value.encode("utf-8") if isinstance(value, str) else value) + return output.getvalue() + + +__all__ = [ + "DOCX_MEDIA_TYPE", + "TITLE_FONT_FAMILY", + "WordExportError", + "WordExportFontError", + "build_markdown_docx", + "load_title_font", + "resolve_title_font_path", +] diff --git a/offline-backend-20260512/backend/tests/test_word_export.py b/offline-backend-20260512/backend/tests/test_word_export.py new file mode 100644 index 00000000..9121c10c --- /dev/null +++ b/offline-backend-20260512/backend/tests/test_word_export.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +import hashlib +import importlib.util +import re +import sys +import zipfile +from pathlib import Path +from xml.etree import ElementTree + +import pytest + +BACKEND_ROOT = Path(__file__).resolve().parents[1] +MODULE_PATH = BACKEND_ROOT / "app" / "gateway" / "word_export.py" + + +def _load_word_export_module(): + """Load the leaf module without executing app.gateway.__init__.""" + spec = importlib.util.spec_from_file_location("word_export_test_module", MODULE_PATH) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +word_export = _load_word_export_module() + + +def test_bundled_font_is_exact_and_allows_editable_embedding() -> None: + font = word_export.load_title_font() + + assert font.sha256 == "C52D577DD3AA719EF5FB9FF2043B7F7CA36FAE468EF34A47628A3A555D006A49" + assert font.fs_type == 0x0008 + assert any("FZXiaoBiaoSong" in name for name in font.names) + + +def test_formal_docx_contains_required_layout_numbering_and_embedded_font(tmp_path: Path) -> None: + markdown = """# 样例标题 + +## 一、一级标题 +这是正文,用于验证首行缩进、两端对齐和固定行距。 + +### (一)二级标题 +#### 1. 三级标题 + +表1 测试表格 +| 项目 | 数值 | +|---|---:| +| 页面 | A4 纵向 | +""" + docx_path = tmp_path / "sample.docx" + docx_path.write_bytes(word_export.build_markdown_docx(markdown)) + + with zipfile.ZipFile(docx_path) as archive: + names = set(archive.namelist()) + assert "word/fonts/font1.odttf" in names + assert "word/_rels/fontTable.xml.rels" in names + for name in names: + if name.endswith(".xml") or name.endswith(".rels"): + ElementTree.fromstring(archive.read(name)) + + document = archive.read("word/document.xml").decode("utf-8") + styles = archive.read("word/styles.xml").decode("utf-8") + numbering = archive.read("word/numbering.xml").decode("utf-8") + settings = archive.read("word/settings.xml").decode("utf-8") + font_table = archive.read("word/fontTable.xml").decode("utf-8") + default_footer = archive.read("word/footer1.xml").decode("utf-8") + even_footer = archive.read("word/footer2.xml").decode("utf-8") + + assert '' in document + assert 'w:top="2098"' in document + assert 'w:bottom="1984"' in document + assert 'w:left="1587"' in document + assert 'w:right="1474"' in document + assert '' in document + assert '' in document + assert "方正小标宋简体" in document + assert "一、一级标题" not in document + assert "(一)二级标题" not in document + assert "1. 三级标题" not in document + + assert 'w:line="579" w:lineRule="exact"' in styles + assert '' in styles + assert "方正小标宋简体" in styles + assert 'w:val="44"' in styles + assert "仿宋" in styles and 'w:val="32"' in styles + assert "宋体" in styles and 'w:val="28"' in styles + + assert '' in numbering + assert '' in numbering + assert '' in numbering + assert '' in numbering + assert '' in settings + assert '' in settings + + assert "方正小标宋简体" in font_table + key_match = re.search(r'w:fontKey="\{([0-9A-F-]{36})\}"', font_table) + assert key_match is not None + font_key = key_match.group(1) + restored = word_export._obfuscate_font(archive.read("word/fonts/font1.odttf"), font_key) + assert hashlib.sha256(restored).hexdigest().upper() == word_export._BUNDLED_FONT_SHA256 + + # Word's default footer applies to odd pages; the separate even footer + # applies to even pages. The requested alignment is odd-left/even-right. + assert '' in default_footer + assert '' in even_footer + assert " PAGE " in default_footer and " PAGE " in even_footer + + +def test_export_rejects_wrong_font(tmp_path: Path) -> None: + wrong_font = tmp_path / "wrong.ttf" + wrong_font.write_bytes(b"not-a-font") + + with pytest.raises(word_export.WordExportFontError): + word_export.build_markdown_docx("正文", font_path=wrong_font)