Merge branch 'ji' into master

This commit is contained in:
吉鑫升 committed 2026-08-26 16:07:37 +08:00
commit 676b00afb6
16 files changed
+1356 -609

No files matched your search

+2
View File
@@ -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
@@ -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<typeof Button> & {
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 ? (
<Loader2 size={12} className="animate-spin" />
) : (
<FileText size={12} />
);
if (questionMarkdown?.trim()) {
return (
<DropdownMenu>
<Tooltip content="下载为 Word">
<DropdownMenuTrigger asChild>
<Button
size="icon-sm"
type="button"
variant="ghost"
disabled={busy}
{...props}
>
{icon}
</Button>
</DropdownMenuTrigger>
</Tooltip>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => void handleDownload("answer")}>
<FileText className="text-muted-foreground" />
仅下载当前回答
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => void handleDownload("qa")}>
<MessagesSquare className="text-muted-foreground" />
下载当前问题和回答
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
return (
<Tooltip content="下载为 Word">
<Button
@@ -41,14 +102,10 @@ export function DownloadWordButton({
type="button"
variant="ghost"
disabled={busy}
onClick={handleDownload}
onClick={() => void handleDownload("answer")}
{...props}
>
{busy ? (
<Loader2 size={12} className="animate-spin" />
) : (
<FileText size={12} />
)}
{icon}
</Button>
</Tooltip>
);
@@ -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({
<div className="flex gap-1">
<CopyButton clipboardData={messageMarkdown} />
{messageMarkdown && (
<DownloadWordButton
markdown={messageMarkdown}
fileName="message"
/>
<DownloadWordButton
markdown={messageMarkdown}
fileName="message"
title="问答回答"
questionMarkdown={questionMarkdown}
/>
)}
{canSaveAssistantEdit && (
<Tooltip content="编辑消息">
@@ -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<string, string>();
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<string, string>();
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}
+10 -102
View File
@@ -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<void> {
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,
);
}
@@ -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<number, number> = {
// 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<Error> {
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<IRunOptions>,
): 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<Paragraph | Table> {
const lines = markdown.replace(/\r\n/g, "\n").split("\n");
const blocks: Array<Paragraph | Table> = [];
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<Blob> {
const children: Array<Paragraph | Table> = [];
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();
+1 -1
View File
@@ -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,
+6 -4
View File
@@ -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/<name>` 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,
+5 -3
View File
@@ -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),
@@ -0,0 +1,11 @@
# Word 导出字体资源
`方正小标宋简体.ttf` 是 Word 正式格式导出的标题字体,由部署方提供并授权本系统用于文档嵌入。
- 字体家族:方正小标宋简体
- SHA-256:`C52D577DD3AA719EF5FB9FF2043B7F7CA36FAE468EF34A47628A3A555D006A49`
- OpenType `OS/2.fsType`:`0x0008`(允许可编辑嵌入)
后端启动后不会把字体安装到操作系统;导出时会将字体以 OOXML 规定的混淆字体部件嵌入 `.docx`。因此下载者电脑无需预装此字体,也不会触发客户端字体回退。
如部署时需要从外部密钥卷提供字体,可设置 `DEERFLOW_WORD_TITLE_FONT_PATH` 指向同一字体文件。后端仍会校验字体内部家族名和嵌入许可。
@@ -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",
}
@@ -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
@@ -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(
@@ -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 = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
_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"</?(?:details|summary)\b[^>]*>", "", value, flags=re.I)
return value
def _xml_attr(value: str) -> str:
return escape(_safe_xml_text(value), {'"': "&quot;", "'": "&apos;"})
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"(?<!\\)\|", value)]
def _is_table_separator(line: str) -> 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'<Relationship Id="{relation_id}" Type="{rel_type}" Target="{target}"/>'
for relation_id, rel_type, target in fixed
)
relationships += "".join(
f'<Relationship Id="{relation_id}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" Target="{_xml_attr(target)}" TargetMode="External"/>'
for relation_id, target in self._hyperlinks
)
return f'{_XML_DECL}<Relationships xmlns="{_PACKAGE_REL_NS}">{relationships}</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'<w:rFonts w:ascii="{_xml_attr(font)}" w:hAnsi="{_xml_attr(font)}" '
f'w:eastAsia="{_xml_attr(font)}" w:cs="{_xml_attr(font)}"/>'
'<w:color w:val="000000"/>'
f'<w:sz w:val="{size}"/><w:szCs w:val="{size}"/>'
)
if bold:
properties += "<w:b/><w:bCs/>"
if italic:
properties += "<w:i/><w:iCs/>"
if underline:
properties += '<w:u w:val="single"/>'
space = ' xml:space="preserve"' if preserve else ""
return f"<w:r><w:rPr>{properties}</w:rPr><w:t{space}>{escape(text)}</w:t></w:r>"
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'<w:hyperlink r:id="{relation_id}" w:history="1">'
f'{_run_xml(match.group(1), font=font, size=size, underline=True)}</w:hyperlink>'
)
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'<w:pStyle w:val="{style}"/>'
if numbering_level is not None:
properties += (
f'<w:numPr><w:ilvl w:val="{numbering_level}"/><w:numId w:val="{numbering_id}"/></w:numPr>'
)
if keep_next:
properties += "<w:keepNext/>"
if alignment:
properties += f'<w:jc w:val="{alignment}"/>'
return f"<w:p><w:pPr>{properties}</w:pPr>{children}</w:p>"
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'<w:hyperlink r:id="{relation_id}" w:history="1">'
f'{_run_xml(f"({target})", underline=True)}</w:hyperlink>'
)
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 (
"<w:tc>"
f'<w:tcPr><w:tcW w:w="{width}" w:type="dxa"/><w:vAlign w:val="center"/>'
'<w:tcMar><w:top w:w="80" w:type="dxa"/><w:left w:w="100" w:type="dxa"/>'
'<w:bottom w:w="80" w:type="dxa"/><w:right w:w="100" w:type="dxa"/></w:tcMar></w:tcPr>'
f"{paragraph}</w:tc>"
)
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'<w:gridCol w:w="{width}"/>' for width in widths)
border = (
'<w:top w:val="single" w:sz="4" w:color="000000"/>'
'<w:left w:val="single" w:sz="4" w:color="000000"/>'
'<w:bottom w:val="single" w:sz="4" w:color="000000"/>'
'<w:right w:val="single" w:sz="4" w:color="000000"/>'
'<w:insideH w:val="single" w:sz="4" w:color="000000"/>'
'<w:insideV w:val="single" w:sz="4" w:color="000000"/>'
)
properties = (
f'<w:tblPr><w:tblW w:w="{CONTENT_WIDTH_TWIPS}" w:type="dxa"/>'
'<w:jc w:val="center"/><w:tblLayout w:type="fixed"/>'
f"<w:tblBorders>{border}</w:tblBorders></w:tblPr>"
)
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"<w:tr><w:trPr><w:tblHeader/><w:cantSplit/></w:trPr>{header_cells}</w:tr>"]
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"<w:tr><w:trPr><w:cantSplit/></w:trPr>{cells}</w:tr>")
return f"<w:tbl>{properties}<w:tblGrid>{grid}</w:tblGrid>{''.join(rows)}</w:tbl>"
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'<w:rFonts w:ascii="{_xml_attr(font)}" w:hAnsi="{_xml_attr(font)}" '
f'w:eastAsia="{_xml_attr(font)}" w:cs="{_xml_attr(font)}"/>'
'<w:color w:val="000000"/>'
f'<w:sz w:val="{size}"/><w:szCs w:val="{size}"/>'
)
return value + ("<w:b/><w:bCs/>" if bold else '<w:b w:val="0"/><w:bCs w:val="0"/>')
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'<w:jc w:val="{alignment}"/><w:spacing w:before="0" w:after="0" '
f'w:line="{EXACT_LINE_TWIPS}" w:lineRule="exact"/>'
f'<w:ind w:left="{left}" w:hanging="{hanging}" w:firstLineChars="{first_line_chars}"/>'
)
if keep_next:
paragraph += "<w:keepNext/>"
if keep_lines:
paragraph += "<w:keepLines/>"
if outline_level is not None:
paragraph += f'<w:outlineLvl w:val="{outline_level}"/>'
based_on_xml = f'<w:basedOn w:val="{based_on}"/>' if based_on else ""
return (
f'<w:style w:type="paragraph" w:styleId="{style_id}"><w:name w:val="{_xml_attr(name)}"/>'
f'{based_on_xml}<w:next w:val="{next_style}"/><w:qFormat/>'
f"<w:pPr>{paragraph}</w:pPr><w:rPr>{_font_run_properties(font, size, bold=bold)}</w:rPr></w:style>"
)
def _styles_xml() -> str:
defaults = (
"<w:docDefaults><w:rPrDefault><w:rPr>"
f"{_font_run_properties(BODY_FONT_FAMILY, BODY_SIZE_HALF_POINTS)}"
'<w:lang w:val="zh-CN" w:eastAsia="zh-CN"/></w:rPr></w:rPrDefault>'
'<w:pPrDefault><w:pPr><w:jc w:val="both"/>'
f'<w:spacing w:before="0" w:after="0" w:line="{EXACT_LINE_TWIPS}" w:lineRule="exact"/>'
'<w:ind w:firstLineChars="200"/></w:pPr></w:pPrDefault></w:docDefaults>'
)
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}<w:styles xmlns:w="{_W_NS}">{defaults}{"".join(styles)}</w:styles>'
def _heading_level_xml(level: int, format_value: str, text: str, font: str) -> str:
restart = f'<w:lvlRestart w:val="{level}"/>' if level > 0 else ""
return (
f'<w:lvl w:ilvl="{level}"><w:start w:val="1"/>{restart}<w:numFmt w:val="{format_value}"/>'
f'<w:lvlText w:val="{_xml_attr(text)}"/><w:lvlJc w:val="left"/>'
'<w:pPr><w:ind w:left="0" w:hanging="0"/></w:pPr>'
f'<w:rPr>{_font_run_properties(font, BODY_SIZE_HALF_POINTS)}</w:rPr></w:lvl>'
)
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'<w:lvl w:ilvl="{level}"><w:start w:val="1"/><w:numFmt w:val="{format_value}"/>'
f'<w:lvlText w:val="{_xml_attr(text)}"/><w:lvlJc w:val="left"/>'
f'<w:pPr><w:tabs><w:tab w:val="num" w:pos="{left}"/></w:tabs>'
f'<w:ind w:left="{left}" w:hanging="360"/></w:pPr>'
f'<w:rPr>{_font_run_properties(BODY_FONT_FAMILY, BODY_SIZE_HALF_POINTS)}</w:rPr></w:lvl>'
)
return f'<w:abstractNum w:abstractNumId="{abstract_id}"><w:multiLevelType w:val="multilevel"/>{"".join(levels)}</w:abstractNum>'
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}<w:numbering xmlns:w="{_W_NS}">'
f'<w:abstractNum w:abstractNumId="0"><w:multiLevelType w:val="multilevel">'
f"</w:multiLevelType>{heading_levels}</w:abstractNum>"
f'{_list_abstract_xml(1, ordered=True)}{_list_abstract_xml(2, ordered=False)}'
'<w:num w:numId="1"><w:abstractNumId w:val="0"/></w:num>'
'<w:num w:numId="2"><w:abstractNumId w:val="1"/></w:num>'
'<w:num w:numId="3"><w:abstractNumId w:val="2"/></w:num>'
"</w:numbering>"
)
def _footer_xml(alignment: str) -> str:
run_properties = _font_run_properties(TABLE_FONT_FAMILY, TABLE_SIZE_HALF_POINTS)
return (
f'{_XML_DECL}<w:ftr xmlns:w="{_W_NS}" xmlns:r="{_R_NS}">'
f'<w:p><w:pPr><w:jc w:val="{alignment}"/><w:spacing w:before="0" w:after="0"/></w:pPr>'
f'<w:r><w:rPr>{run_properties}</w:rPr><w:fldChar w:fldCharType="begin"/></w:r>'
f'<w:r><w:rPr>{run_properties}</w:rPr><w:instrText xml:space="preserve"> PAGE </w:instrText></w:r>'
f'<w:r><w:rPr>{run_properties}</w:rPr><w:fldChar w:fldCharType="separate"/></w:r>'
f'<w:r><w:rPr>{run_properties}</w:rPr><w:t>1</w:t></w:r>'
f'<w:r><w:rPr>{run_properties}</w:rPr><w:fldChar w:fldCharType="end"/></w:r>'
"</w:p></w:ftr>"
)
def _font_table_xml(font_key: str) -> str:
def font(name: str, *, embedded: bool = False) -> str:
embed = f'<w:embedRegular r:id="rId1" w:fontKey="{{{font_key}}}"/>' if embedded else ""
return (
f'<w:font w:name="{_xml_attr(name)}"><w:family w:val="roman"/>'
f'<w:charset w:val="86"/>{embed}</w:font>'
)
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}<w:fonts xmlns:w="{_W_NS}" xmlns:r="{_R_NS}">{fonts}</w:fonts>'
def _font_table_relationships_xml() -> str:
return (
f'{_XML_DECL}<Relationships xmlns="{_PACKAGE_REL_NS}">'
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/font" Target="fonts/font1.odttf"/>'
"</Relationships>"
)
def _settings_xml() -> str:
return (
f'{_XML_DECL}<w:settings xmlns:w="{_W_NS}">'
'<w:zoom w:percent="100"/><w:evenAndOddHeaders/><w:updateFields w:val="true"/>'
'<w:compat><w:compatSetting w:name="compatibilityMode" '
'w:uri="http://schemas.microsoft.com/office/word" w:val="15"/></w:compat>'
"</w:settings>"
)
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 = (
'<w:sectPr><w:footerReference w:type="default" r:id="rId5"/>'
'<w:footerReference w:type="even" r:id="rId6"/>'
f'<w:pgSz w:w="{A4_WIDTH_TWIPS}" w:h="{A4_HEIGHT_TWIPS}" w:orient="portrait"/>'
f'<w:pgMar w:top="{MARGIN_TOP_TWIPS}" w:right="{MARGIN_RIGHT_TWIPS}" '
f'w:bottom="{MARGIN_BOTTOM_TWIPS}" w:left="{MARGIN_LEFT_TWIPS}" '
'w:header="720" w:footer="992" w:gutter="0"/>'
'<w:pgNumType w:start="1" w:fmt="decimal"/><w:cols w:space="720"/></w:sectPr>'
)
return (
f'{_XML_DECL}<w:document xmlns:w="{_W_NS}" xmlns:r="{_R_NS}">'
f"<w:body>{''.join(body)}{section}</w:body></w:document>"
)
def _content_types_xml() -> str:
return (
f'{_XML_DECL}<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"/>'
'<Default Extension="odttf" ContentType="application/vnd.openxmlformats-officedocument.obfuscatedFont"/>'
'<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>'
'<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>'
'<Override PartName="/word/numbering.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml"/>'
'<Override PartName="/word/settings.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml"/>'
'<Override PartName="/word/fontTable.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.fontTable+xml"/>'
'<Override PartName="/word/footer1.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml"/>'
'<Override PartName="/word/footer2.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml"/>'
'<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>'
'<Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/>'
"</Types>"
)
def _package_relationships_xml() -> str:
return (
f'{_XML_DECL}<Relationships xmlns="{_PACKAGE_REL_NS}">'
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>'
'<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>'
'<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/>'
"</Relationships>"
)
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}<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" '
'xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" '
'xmlns:dcmitype="http://purl.org/dc/dcmitype/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">'
f"<dc:title>{safe_title}</dc:title><dc:creator>DeerFlow</dc:creator>"
f'<dcterms:created xsi:type="dcterms:W3CDTF">{now}</dcterms:created>'
f'<dcterms:modified xsi:type="dcterms:W3CDTF">{now}</dcterms:modified></cp:coreProperties>'
)
def _app_properties_xml() -> str:
return (
f'{_XML_DECL}<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties" '
'xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes">'
"<Application>DeerFlow</Application><AppVersion>1.0</AppVersion></Properties>"
)
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",
]
@@ -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 '<w:pgSz w:w="11906" w:h="16838" w:orient="portrait"/>' 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 '<w:pgNumType w:start="1" w:fmt="decimal"/>' in document
assert '<w:pStyle w:val="DocumentTitle"/>' 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 '<w:ind w:left="0" w:hanging="0" w:firstLineChars="200"/>' 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 '<w:numFmt w:val="chineseCounting"/>' in numbering
assert '<w:lvlText w:val="%1、"/>' in numbering
assert '<w:lvlText w:val="(%2)"/>' in numbering
assert '<w:lvlText w:val="%3."/>' in numbering
assert '<w:evenAndOddHeaders/>' in settings
assert '<w:updateFields w:val="true"/>' 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 '<w:jc w:val="left"/>' in default_footer
assert '<w:jc w:val="right"/>' 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)