release: 发布 v0.6.2 DOCX 真实文档修复

新增能力:将 DOCX 发布验收拆分为四套独立 140,支持真实语料冻结、指纹复用、失败与基础设施错误独立统计,并为表格换行、全 JSON 围栏、代码连续性和长文档分页建立通用门禁。

问题修复:冻结 Paged.js 分片前的逻辑表格列轨并传递打印几何,统一 Markdown 表格换行、代码、段落与 OOXML 翻译;改进 PDF 文本流排序、语义块映射、颜色与栅格比较,消除窄字符重叠和跨行范围符号误报。

兼容与部署:版本统一为 0.6.2;正式 Docker 镜像内置固定 Chromium、Pandoc 3.9.0.2 和 Serif/Sans/Mono 字体;Desktop NSIS 与 ZIP 继续直接内置字体,无需系统字体安装。

验证结果:合成基线与长庆严格 280/280,M4N 140/140;健康数据残余误报 6/115(5.22%),均核查为重复表头自动对齐/取样误报且基础设施错误为 0。全项目测试、类型检查、生产构建和 git diff --check 通过;正式 Docker、NSIS、ZIP、离线镜像、部署包、清单及 SHA-256 均已生成并校验。
This commit is contained in:
SkyJourney
2026-08-26 10:50:20 +08:00
parent 01b06abc2c
commit 64445322eb
75 changed files with 9255 additions and 298 deletions
+108
View File
@@ -23,6 +23,7 @@ import hljs from "highlight.js";
import MarkdownIt from "markdown-it";
import type { Options as MarkdownItOptions } from "markdown-it";
import type Renderer from "markdown-it/lib/renderer.mjs";
import type StateInline from "markdown-it/lib/rules_inline/state_inline.mjs";
import type Token from "markdown-it/lib/token.mjs";
import sanitizeHtml from "sanitize-html";
import { renderDocumentStructure } from "./render-document-structure.js";
@@ -49,6 +50,7 @@ export interface RenderMarkdownOptions {
export interface RenderedMarkdown
extends Omit<RenderedMarkdownDocument, "rendererVersion"> {
rendererVersion: typeof RENDERER_VERSION;
markdownBody: string;
}
const markdownOptions: MarkdownItOptions = {
@@ -93,6 +95,104 @@ const markdown = new MarkdownIt(markdownOptions)
trust: false
});
const tableBreakMarkupPattern = /^(?:<br>|<br\/>|<br \/>)/iu;
function tableBreakCandidateRule(
state: StateInline,
silent: boolean
): boolean {
const match = state.src.slice(state.pos).match(tableBreakMarkupPattern);
if (!match) {
return false;
}
if (!silent) {
const token = state.push("table_break_candidate", "", 0);
token.content = match[0];
}
state.pos += match[0].length;
return true;
}
markdown.inline.ruler.before(
"html_inline",
"table_break_candidate",
tableBreakCandidateRule
);
markdown.core.ruler.after("inline", "scope_table_breaks", (state) => {
let tableCellDepth = 0;
for (const token of state.tokens) {
if (token.type === "td_open" || token.type === "th_open") {
tableCellDepth += 1;
continue;
}
if (token.type === "td_close" || token.type === "th_close") {
tableCellDepth = Math.max(0, tableCellDepth - 1);
continue;
}
if (token.type !== "inline") {
continue;
}
for (const child of token.children ?? []) {
if (child.type === "table_break_candidate" && tableCellDepth > 0) {
child.type = "table_break";
}
}
}
});
markdown.renderer.rules.table_break_candidate = (tokens, index) =>
markdown.utils.escapeHtml(tokens[index]?.content ?? "");
markdown.renderer.rules.table_break = () => "<br>";
const markdownAlertTitles = new Map([
["note", "Note"],
["tip", "Tip"],
["important", "Important"],
["warning", "Warning"],
["caution", "Caution"]
]);
markdown.core.ruler.after("scope_table_breaks", "github_alerts", (state) => {
for (let index = 0; index < state.tokens.length - 2; index += 1) {
const blockquote = state.tokens[index];
const paragraph = state.tokens[index + 1];
const inline = state.tokens[index + 2];
if (
blockquote?.type !== "blockquote_open" ||
paragraph?.type !== "paragraph_open" ||
inline?.type !== "inline"
) {
continue;
}
const [marker, bodyBreak] = inline.children ?? [];
const match = marker?.type === "text"
? marker.content.match(/^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]$/iu)
: undefined;
if (!marker || !match) {
continue;
}
const alertType = match[1]?.toLowerCase();
const title = alertType ? markdownAlertTitles.get(alertType) : undefined;
if (!alertType || !title) {
continue;
}
blockquote.attrJoin("class", `md-alert md-alert-${alertType}`);
marker.type = "markdown_alert_title";
marker.content = title;
marker.meta = { alertType };
if (bodyBreak?.type === "softbreak") {
bodyBreak.type = "markdown_alert_body_break";
}
}
});
markdown.renderer.rules.markdown_alert_title = (tokens, index) => {
const token = tokens[index];
const alertType = typeof token?.meta?.alertType === "string"
? token.meta.alertType
: "note";
return `<span class="md-alert-text md-alert-text-${escapeAttribute(alertType)}">${markdown.utils.escapeHtml(token?.content ?? "")}</span>`;
};
markdown.renderer.rules.markdown_alert_body_break = () => "</p>\n<p>";
const defaultValidateLink = markdown.validateLink.bind(markdown);
markdown.validateLink = (href) =>
defaultValidateLink(href) ||
@@ -141,6 +241,13 @@ markdown.renderer.rules.paragraph_open = (
if (standaloneImage) {
return '<figure class="md-document-image-block">\n';
}
const inlineToken = tokens[index + 1];
if (
inlineToken?.type === "inline" &&
(inlineToken.children ?? []).some((child) => child.type === "code_inline")
) {
tokens[index]?.attrJoin("class", "md-inline-code-paragraph");
}
return defaultParagraphOpenRenderer
? defaultParagraphOpenRenderer(
tokens,
@@ -367,6 +474,7 @@ export function renderMarkdown(
return {
rendererVersion: RENDERER_VERSION,
markdownBody: parsed.content,
articleHtml,
bodyHtml,
metadata,
@@ -6,6 +6,38 @@ import {
} from "../src/render-markdown.js";
describe("renderMarkdown", () => {
it("将 GFM Alert 渲染为与 Pandoc 一致的独立标题引用块", () => {
const result = renderMarkdown(
"> [!CAUTION]\n> **关键约束**:必须执行。",
);
expect(result.bodyHtml).toContain(
'<blockquote class="md-alert md-alert-caution">',
);
expect(result.bodyHtml).toContain(
'<p><span class="md-alert-text md-alert-text-caution">Caution</span></p>\n<p><strong>关键约束</strong>:必须执行。</p>',
);
expect(result.bodyHtml).not.toContain("[!CAUTION]");
});
it("为含行内代码的普通段落标记分页保护类", () => {
const result = renderMarkdown("段落前 `inline_code` 段落后");
expect(result.articleHtml).toContain(
'<p class="md-inline-code-paragraph">段落前 <code>inline_code</code> 段落后</p>',
);
});
it("向下游暴露已剥离文首 Front Matter 的正文 Markdown", () => {
const rendered = renderMarkdown(
"---\ntitle: 测试标题\n---\n# 正文\n\n---\n\n后续内容"
);
expect(rendered.markdownBody).toBe(
"# 正文\n\n---\n\n后续内容"
);
expect(rendered.metadata.title).toBe("测试标题");
});
it("渲染常用 Markdown 扩展并识别能力", () => {
const result = renderMarkdown(`
# 示例文档
@@ -80,6 +112,39 @@ const answer = 42;
expect(result.bodyHtml).toContain('<td style="text-align:right">C</td>');
});
it("只在 GFM 表格单元格内解释无属性 br 换行", () => {
const result = renderMarkdown(`
| 场景 | 内容 |
| --- | --- |
| 标准 | 第一行<br>第二行<BR/>第三行<br />第四行 |
正文中的 <br>、<br/> 和 <br /> 保持文本。
| 转义与代码 | 内容 |
| --- | --- |
| 转义 | \\<br> 与 &lt;br&gt; |
| 代码 | \`<br>\` |
| 属性 | <br class="unsafe"> |
\`\`\`html
<br>
\`\`\`
`);
expect(result.bodyHtml).toContain(
"第一行<br />第二行<br />第三行<br />第四行"
);
expect(result.bodyHtml).toContain(
"正文中的 &lt;br&gt;、&lt;br/&gt; 和 &lt;br /&gt; 保持文本"
);
expect(result.bodyHtml).toContain("&lt;br&gt; 与 &lt;br&gt;");
expect(result.bodyHtml).toContain("<code>&lt;br&gt;</code>");
expect(result.bodyHtml).toContain("&lt;br class=\"unsafe\"&gt;");
expect(result.bodyHtml).toContain(
'<pre class="md-fences" lang="html"><code class="language-html">'
);
});
it("读取并规范化 Front Matter 元数据", () => {
const result = renderMarkdown(`---
title: 项目报告