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:
@@ -98,6 +98,64 @@ local function replace_code_block(block)
|
||||
return pandoc.Para { image }
|
||||
end
|
||||
|
||||
local function preserve_raw_html_as_text(inline)
|
||||
if inline.format == "html" then
|
||||
return pandoc.Str(inline.text)
|
||||
end
|
||||
return inline
|
||||
end
|
||||
|
||||
local function preserve_raw_html_block_as_text(block)
|
||||
if block.format == "html" then
|
||||
return pandoc.Para { pandoc.Str(block.text) }
|
||||
end
|
||||
return block
|
||||
end
|
||||
|
||||
local function replace_table_breaks(table_block)
|
||||
return table_block:walk {
|
||||
RawInline = function(inline)
|
||||
if inline.format ~= "html" then
|
||||
return inline
|
||||
end
|
||||
local normalized = string.lower(inline.text)
|
||||
if normalized == "<br>"
|
||||
or normalized == "<br/>"
|
||||
or normalized == "<br />" then
|
||||
return pandoc.LineBreak()
|
||||
end
|
||||
return inline
|
||||
end
|
||||
}
|
||||
end
|
||||
|
||||
local function normalize_task_list(list)
|
||||
for _, item in ipairs(list.content) do
|
||||
local block = item[1]
|
||||
if block ~= nil and (block.t == "Plain" or block.t == "Para") then
|
||||
local inlines = block.content
|
||||
local first = inlines[1]
|
||||
if first ~= nil and first.t == "Str" then
|
||||
local marker = string.lower(first.text)
|
||||
if marker == "[x]" then
|
||||
first.text = "☒"
|
||||
elseif marker == "[" then
|
||||
local second = inlines[2]
|
||||
local third = inlines[3]
|
||||
if second ~= nil and second.t == "Space"
|
||||
and third ~= nil and third.t == "Str"
|
||||
and third.text == "]" then
|
||||
first.text = "☐"
|
||||
inlines:remove(3)
|
||||
inlines:remove(2)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return list
|
||||
end
|
||||
|
||||
local function append_inlines(target, value)
|
||||
target:extend(pandoc.Inlines(value))
|
||||
end
|
||||
@@ -167,7 +225,10 @@ local function validate_counts()
|
||||
error(
|
||||
"DOCX media map contains unused "
|
||||
.. kind
|
||||
.. " resources"
|
||||
.. " resources: consumed "
|
||||
.. tostring(counters[kind])
|
||||
.. " of "
|
||||
.. tostring(#media_map[kind])
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -176,8 +237,14 @@ end
|
||||
return {
|
||||
Pandoc = function(document)
|
||||
local transformed = document:walk {
|
||||
Table = replace_table_breaks
|
||||
}
|
||||
transformed = transformed:walk {
|
||||
Image = replace_image,
|
||||
CodeBlock = replace_code_block
|
||||
CodeBlock = replace_code_block,
|
||||
RawInline = preserve_raw_html_as_text,
|
||||
RawBlock = preserve_raw_html_block_as_text,
|
||||
BulletList = normalize_task_list
|
||||
}
|
||||
if structure_plan.titlePolicy.metadataTitle == "suppress" then
|
||||
transformed.meta.title = nil
|
||||
|
||||
@@ -424,7 +424,7 @@ for (const theme of themes) {
|
||||
let conversion;
|
||||
try {
|
||||
conversion = await converter.convert({
|
||||
markdown,
|
||||
markdown: rendered.markdownBody,
|
||||
fileName: `${theme.id}.md`,
|
||||
language: rendered.metadata.language,
|
||||
exportConfig,
|
||||
@@ -433,7 +433,7 @@ for (const theme of themes) {
|
||||
metadata: rendered.metadata,
|
||||
semanticDocument: rendered.semanticDocument,
|
||||
fonts: readThemeFonts(theme),
|
||||
media: createMedia(markdown)
|
||||
media: createMedia(rendered.markdownBody)
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(`主题 ${theme.id} 的 DOCX 转换失败`, {
|
||||
|
||||
@@ -44,6 +44,8 @@ type DocxBorderToken = NonNullable<
|
||||
|
||||
const WORDPROCESSING_DRAWING_NAMESPACE =
|
||||
"http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing";
|
||||
const MATH_NAMESPACE =
|
||||
"http://schemas.openxmlformats.org/officeDocument/2006/math";
|
||||
const XML_NAMESPACE = "http://www.w3.org/XML/1998/namespace";
|
||||
// Chromium 预留 6pt;Word/WPS 的分节承载段落还需要额外分页保留量。
|
||||
const COVER_PAGE_BREAK_SAFETY_PT = 6;
|
||||
@@ -2064,9 +2066,10 @@ function applyDirectCharacterStyleTokens(
|
||||
const inlineCodeLayouts = documentLayout.inlineCodes ?? [];
|
||||
let inlineCodeCursor = 0;
|
||||
let count = 0;
|
||||
for (const run of Array.from(
|
||||
const runs = Array.from(
|
||||
document.getElementsByTagNameNS(WORD_NAMESPACE, "r")
|
||||
)) {
|
||||
);
|
||||
for (const [runIndex, run] of runs.entries()) {
|
||||
const properties = firstDirectChild(
|
||||
run,
|
||||
WORD_NAMESPACE,
|
||||
@@ -2099,8 +2102,29 @@ function applyDirectCharacterStyleTokens(
|
||||
const isSourceCode = paragraphStyle
|
||||
? wordAttribute(paragraphStyle, "val") === "SourceCode"
|
||||
: false;
|
||||
const previousRun = runs[runIndex - 1];
|
||||
const nextRun = runs[runIndex + 1];
|
||||
const isHtmlTagName =
|
||||
isSourceCode &&
|
||||
styleId === "KeywordTok" &&
|
||||
previousRun?.parentNode === run.parentNode &&
|
||||
nextRun?.parentNode === run.parentNode &&
|
||||
/^<\/?$/u.test(previousRun.textContent ?? "") &&
|
||||
/^\/?>$/u.test(nextRun.textContent ?? "");
|
||||
const isObjectPropertyKey =
|
||||
isSourceCode &&
|
||||
styleId === "NormalTok" &&
|
||||
nextRun?.parentNode === run.parentNode &&
|
||||
/^\s*:\s*$/u.test(nextRun.textContent ?? "");
|
||||
const syntaxSlot = isSourceCode && styleId
|
||||
? resolvePandocSyntaxStyleSlot(styleId, run.textContent ?? "")
|
||||
? resolvePandocSyntaxStyleSlot(
|
||||
styleId,
|
||||
run.textContent ?? "",
|
||||
{
|
||||
htmlTagName: isHtmlTagName,
|
||||
objectPropertyKey: isObjectPropertyKey
|
||||
}
|
||||
)
|
||||
: undefined;
|
||||
const token = isSourceCode
|
||||
? syntaxSlot
|
||||
@@ -2157,6 +2181,37 @@ function applyDirectCharacterStyleTokens(
|
||||
return count;
|
||||
}
|
||||
|
||||
function applyMeasuredEmojiRunColors(
|
||||
document: XmlDocument,
|
||||
documentLayout: DocxDocumentLayoutPlan
|
||||
) {
|
||||
const measuredRuns = documentLayout.emojiRuns ?? [];
|
||||
let cursor = 0;
|
||||
let count = 0;
|
||||
for (const run of Array.from(
|
||||
document.getElementsByTagNameNS(WORD_NAMESPACE, "r")
|
||||
)) {
|
||||
const text = (run.textContent ?? "").normalize("NFC").trim();
|
||||
if (!text) {
|
||||
continue;
|
||||
}
|
||||
const measuredIndex = measuredRuns.findIndex(
|
||||
(layout, index) => index >= cursor && layout.text === text
|
||||
);
|
||||
if (measuredIndex < 0) {
|
||||
continue;
|
||||
}
|
||||
cursor = measuredIndex + 1;
|
||||
const properties = ensureFirstElement(run, "rPr", "w:rPr");
|
||||
removeDirectChildren(properties, WORD_NAMESPACE, "color");
|
||||
appendElement(properties, WORD_NAMESPACE, "w:color", {
|
||||
"w:val": colorValue(measuredRuns[measuredIndex]!.color)
|
||||
});
|
||||
count += 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function applyTableCellToken(
|
||||
cell: XmlElement,
|
||||
token: DocxSlotStyleToken
|
||||
@@ -2221,15 +2276,24 @@ function applyMeasuredTableGeometry(
|
||||
table: XmlElement,
|
||||
properties: XmlElement,
|
||||
layout: DocxDocumentLayoutPlan["tables"][number],
|
||||
contentWidthTwips: number
|
||||
contentWidthTwips: number,
|
||||
leadingCellInsetTwips: number
|
||||
) {
|
||||
const tableWidthTwips = Math.max(
|
||||
const indentTwips = Math.max(
|
||||
0,
|
||||
Math.round(contentWidthTwips * layout.leftOffsetPercent / 100) +
|
||||
leadingCellInsetTwips
|
||||
);
|
||||
const requestedTableWidthTwips = Math.max(
|
||||
1,
|
||||
Math.round(contentWidthTwips * layout.widthPercent / 100)
|
||||
);
|
||||
const indentTwips = Math.max(
|
||||
0,
|
||||
Math.round(contentWidthTwips * layout.leftOffsetPercent / 100)
|
||||
const tableWidthTwips = Math.max(
|
||||
1,
|
||||
Math.min(
|
||||
requestedTableWidthTwips,
|
||||
Math.max(1, contentWidthTwips - indentTwips)
|
||||
)
|
||||
);
|
||||
const columnWidths = measuredColumnWidths(
|
||||
tableWidthTwips,
|
||||
@@ -2315,7 +2379,11 @@ function applyTables(
|
||||
table,
|
||||
properties,
|
||||
measuredLayout,
|
||||
contentWidthTwips
|
||||
contentWidthTwips,
|
||||
pointsToTwips(Math.max(
|
||||
tableCellToken?.paddingPt?.left ?? 0,
|
||||
tableHeaderToken?.paddingPt?.left ?? 0
|
||||
))
|
||||
);
|
||||
} else {
|
||||
removeDirectChildren(
|
||||
@@ -2427,8 +2495,15 @@ function applyTables(
|
||||
WORD_NAMESPACE,
|
||||
"p"
|
||||
)) {
|
||||
const properties = paragraphProperties(paragraph);
|
||||
const wordWrap = ensureFirstElement(
|
||||
properties,
|
||||
"wordWrap",
|
||||
"w:wordWrap"
|
||||
);
|
||||
setWordAttribute(wordWrap, "val", "1");
|
||||
const spacing = ensureFirstElement(
|
||||
paragraphProperties(paragraph),
|
||||
properties,
|
||||
"spacing",
|
||||
"w:spacing"
|
||||
);
|
||||
@@ -2436,7 +2511,7 @@ function applyTables(
|
||||
setWordAttribute(spacing, "after", "0");
|
||||
for (const property of ["autoSpaceDE", "autoSpaceDN"] as const) {
|
||||
const automaticSpacing = ensureFirstElement(
|
||||
paragraphProperties(paragraph),
|
||||
properties,
|
||||
property,
|
||||
`w:${property}`
|
||||
);
|
||||
@@ -2609,6 +2684,18 @@ function applyMeasuredListItemIndents(
|
||||
String(Math.min(targetLeft, hanging))
|
||||
);
|
||||
}
|
||||
if (measured.alignment) {
|
||||
const alignment = ensureDirectElement(
|
||||
properties,
|
||||
WORD_NAMESPACE,
|
||||
"w:jc"
|
||||
);
|
||||
setWordAttribute(
|
||||
alignment,
|
||||
"val",
|
||||
measured.alignment === "justify" ? "both" : measured.alignment
|
||||
);
|
||||
}
|
||||
appliedCount += 1;
|
||||
}
|
||||
return appliedCount;
|
||||
@@ -2727,6 +2814,33 @@ function measuredLineBreakOffsetsForParagraph(
|
||||
});
|
||||
}
|
||||
|
||||
function paragraphHasInlineCode(paragraph: XmlElement) {
|
||||
return Array.from(
|
||||
paragraph.getElementsByTagNameNS(WORD_NAMESPACE, "rStyle")
|
||||
).some((style) => wordAttribute(style, "val") === "VerbatimChar");
|
||||
}
|
||||
|
||||
function stabilizeInlineCodeParagraphLineRules(document: XmlDocument) {
|
||||
let count = 0;
|
||||
for (const paragraph of Array.from(
|
||||
document.getElementsByTagNameNS(WORD_NAMESPACE, "p")
|
||||
)) {
|
||||
if (!paragraphHasInlineCode(paragraph)) {
|
||||
continue;
|
||||
}
|
||||
const spacing = directChildren(
|
||||
paragraphProperties(paragraph),
|
||||
WORD_NAMESPACE,
|
||||
"spacing"
|
||||
)[0];
|
||||
if (spacing && wordAttribute(spacing, "lineRule") === "exact") {
|
||||
setWordAttribute(spacing, "lineRule", "atLeast");
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function applyMeasuredTextBlockLineBreaks(
|
||||
document: XmlDocument,
|
||||
documentLayout: DocxDocumentLayoutPlan
|
||||
@@ -2765,7 +2879,11 @@ function applyMeasuredTextBlockLineBreaks(
|
||||
"line",
|
||||
String(pointsToTwips(measured.linePitchPt))
|
||||
);
|
||||
setWordAttribute(spacing, "lineRule", "exact");
|
||||
setWordAttribute(
|
||||
spacing,
|
||||
"lineRule",
|
||||
paragraphHasInlineCode(paragraph) ? "atLeast" : "exact"
|
||||
);
|
||||
}
|
||||
for (const offset of [...measuredLineBreakOffsetsForParagraph(
|
||||
paragraph,
|
||||
@@ -2781,6 +2899,47 @@ function applyMeasuredTextBlockLineBreaks(
|
||||
return appliedCount;
|
||||
}
|
||||
|
||||
function stabilizeTableMathParagraphAlignment(document: XmlDocument) {
|
||||
let count = 0;
|
||||
for (const paragraph of Array.from(
|
||||
document.getElementsByTagNameNS(WORD_NAMESPACE, "p")
|
||||
)) {
|
||||
const parent = paragraph.parentNode;
|
||||
if (
|
||||
!parent ||
|
||||
parent.nodeType !== 1 ||
|
||||
(parent as XmlElement).namespaceURI !== WORD_NAMESPACE ||
|
||||
(parent as XmlElement).localName !== "tc"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const content = Array.from(paragraph.childNodes).filter(
|
||||
(node): node is XmlElement => node.nodeType === 1
|
||||
).filter(
|
||||
(child) =>
|
||||
!(child.namespaceURI === WORD_NAMESPACE && child.localName === "pPr")
|
||||
);
|
||||
if (
|
||||
content.length === 0 ||
|
||||
!content.every(
|
||||
(child) =>
|
||||
child.namespaceURI === MATH_NAMESPACE &&
|
||||
child.localName === "oMath"
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const run = appendElement(paragraph, WORD_NAMESPACE, "w:r");
|
||||
const properties = appendElement(run, WORD_NAMESPACE, "w:rPr");
|
||||
appendElement(properties, WORD_NAMESPACE, "w:noProof");
|
||||
const text = appendElement(run, WORD_NAMESPACE, "w:t");
|
||||
text.setAttributeNS(XML_NAMESPACE, "xml:space", "preserve");
|
||||
text.textContent = "\u200B";
|
||||
count += 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function pageBreakAfterStyleIds(tokens: DocxThemeTokenSet) {
|
||||
const ids = new Set<string>();
|
||||
for (const entry of tokens.slots) {
|
||||
@@ -3098,6 +3257,99 @@ function disableAutomaticCharacterSpacing(document: XmlDocument) {
|
||||
}
|
||||
}
|
||||
|
||||
const PANDOC_ALERT_LABELS = new Set([
|
||||
"note",
|
||||
"tip",
|
||||
"important",
|
||||
"warning",
|
||||
"caution"
|
||||
]);
|
||||
|
||||
function normalizePandocAlertParagraphStyles(
|
||||
body: XmlElement,
|
||||
documentLayout: DocxDocumentLayoutPlan
|
||||
) {
|
||||
const children = directChildren(body, WORD_NAMESPACE, "p");
|
||||
const measuredAlerts = (documentLayout.textBlocks ?? []).filter(
|
||||
(block) => block.alertRole !== undefined
|
||||
);
|
||||
let measuredCursor = 0;
|
||||
let count = 0;
|
||||
for (const [index, paragraph] of children.entries()) {
|
||||
if (
|
||||
paragraphStyleId(paragraph) !== "FirstParagraph" ||
|
||||
!PANDOC_ALERT_LABELS.has(
|
||||
normalizedLayoutText(paragraphText(paragraph)).toLocaleLowerCase(
|
||||
"en-US"
|
||||
)
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const content = children[index + 1];
|
||||
if (!content || paragraphStyleId(content) !== "BodyText") {
|
||||
continue;
|
||||
}
|
||||
const style = ensureDirectElement(
|
||||
paragraphProperties(content),
|
||||
WORD_NAMESPACE,
|
||||
"w:pStyle"
|
||||
);
|
||||
setWordAttribute(style, "val", "BlockText");
|
||||
for (const [target, role] of [
|
||||
[paragraph, "title"],
|
||||
[content, "body"]
|
||||
] as const) {
|
||||
const text = normalizedLayoutText(paragraphText(target));
|
||||
const relativeMatchIndex = measuredAlerts
|
||||
.slice(measuredCursor)
|
||||
.findIndex(
|
||||
(block) =>
|
||||
block.alertRole === role &&
|
||||
normalizedLayoutText(block.text) === text
|
||||
);
|
||||
if (relativeMatchIndex < 0) {
|
||||
continue;
|
||||
}
|
||||
const measuredIndex = measuredCursor + relativeMatchIndex;
|
||||
const measured = measuredAlerts[measuredIndex]!;
|
||||
measuredCursor = measuredIndex + 1;
|
||||
const properties = paragraphProperties(target);
|
||||
const indentation = ensureDirectElement(
|
||||
properties,
|
||||
WORD_NAMESPACE,
|
||||
"w:ind"
|
||||
);
|
||||
if (measured.leftIndentPt !== undefined) {
|
||||
setWordAttribute(
|
||||
indentation,
|
||||
"left",
|
||||
String(pointsToTwips(measured.leftIndentPt))
|
||||
);
|
||||
}
|
||||
if (measured.rightIndentPt !== undefined) {
|
||||
setWordAttribute(
|
||||
indentation,
|
||||
"right",
|
||||
String(pointsToTwips(measured.rightIndentPt))
|
||||
);
|
||||
}
|
||||
applyDirectRunToken(target, {
|
||||
fontCandidates: [],
|
||||
...(measured.fontSizePt !== undefined
|
||||
? { fontSizePt: measured.fontSizePt }
|
||||
: {}),
|
||||
bold: measured.bold,
|
||||
italic: measured.italic,
|
||||
color: measured.color,
|
||||
letterSpacingPt: measured.letterSpacingPt
|
||||
});
|
||||
}
|
||||
count += 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
export function finalizeGeneratedDocxStructure(
|
||||
content: Uint8Array,
|
||||
plan: PandocStructurePlan,
|
||||
@@ -3141,6 +3393,7 @@ export function finalizeGeneratedDocxStructure(
|
||||
containers.editableContainerTables.size > 0,
|
||||
usesEvenAndOddPages
|
||||
);
|
||||
normalizePandocAlertParagraphStyles(body, documentLayout);
|
||||
disableAutomaticCharacterSpacing(document);
|
||||
const final = finalSection(body);
|
||||
applyMeasuredTextBlockLineBreaks(document, documentLayout);
|
||||
@@ -3160,6 +3413,9 @@ export function finalizeGeneratedDocxStructure(
|
||||
);
|
||||
applyMeasuredTextBlockAlignments(document, documentLayout);
|
||||
applyDirectCharacterStyleTokens(document, tokens, documentLayout);
|
||||
applyMeasuredEmojiRunColors(document, documentLayout);
|
||||
stabilizeInlineCodeParagraphLineRules(document);
|
||||
stabilizeTableMathParagraphAlignment(document);
|
||||
const mediaDrawingCount = normalizeMediaParagraphs(
|
||||
document,
|
||||
tokens,
|
||||
|
||||
@@ -103,6 +103,12 @@ export interface PandocDocxConverterOptions {
|
||||
referenceCacheSize?: number;
|
||||
}
|
||||
|
||||
export interface PandocDocxConversionDiagnostics {
|
||||
outcome: string;
|
||||
exitCode: number | null;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
export class PandocDocxConversionError extends Error {
|
||||
constructor(
|
||||
readonly code: Extract<
|
||||
@@ -113,11 +119,26 @@ export class PandocDocxConversionError extends Error {
|
||||
| "DOCX_OUTPUT_INVALID"
|
||||
>,
|
||||
message: string,
|
||||
options?: ErrorOptions
|
||||
options?: ErrorOptions & {
|
||||
diagnostics?: PandocDocxConversionDiagnostics;
|
||||
}
|
||||
) {
|
||||
super(message, options);
|
||||
this.name = "PandocDocxConversionError";
|
||||
this.diagnostics = options?.diagnostics;
|
||||
}
|
||||
|
||||
readonly diagnostics: PandocDocxConversionDiagnostics | undefined;
|
||||
}
|
||||
|
||||
function processDiagnostics(
|
||||
result: Awaited<ReturnType<PandocProcessRunner>>
|
||||
): PandocDocxConversionDiagnostics {
|
||||
return {
|
||||
outcome: result.outcome,
|
||||
exitCode: result.exitCode,
|
||||
stderr: result.stderr.trim().slice(-16 * 1024)
|
||||
};
|
||||
}
|
||||
|
||||
function ensureOutputLimit(value: number | undefined) {
|
||||
@@ -172,7 +193,7 @@ function pandocArguments(paths: {
|
||||
return [
|
||||
paths.markdown,
|
||||
"--from",
|
||||
"markdown+yaml_metadata_block+pipe_tables+footnotes+task_lists+tex_math_dollars-raw_html",
|
||||
"commonmark_x-yaml_metadata_block+pipe_tables+footnotes+task_lists+tex_math_dollars+raw_html",
|
||||
"--to",
|
||||
"docx",
|
||||
"--standalone",
|
||||
@@ -351,7 +372,8 @@ export class PandocDocxConverter {
|
||||
processResult.outcome === "not-found"
|
||||
? "DOCX_RUNTIME_NOT_FOUND"
|
||||
: "DOCX_GENERATION_FAILED",
|
||||
"Pandoc 未能生成 DOCX"
|
||||
"Pandoc 未能生成 DOCX",
|
||||
{ diagnostics: processDiagnostics(processResult) }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ import {
|
||||
DOCX_MEDIA_CSS_DPI,
|
||||
MAXIMUM_DOCX_MEDIA_BYTES,
|
||||
MAXIMUM_DOCX_MEDIA_EDGE_PIXELS,
|
||||
MAXIMUM_DOCX_MEDIA_COUNT,
|
||||
MAXIMUM_DOCX_MEDIA_PIXELS,
|
||||
MAXIMUM_DOCX_RESOURCE_COUNT,
|
||||
MAXIMUM_DOCX_TOTAL_MEDIA_BYTES,
|
||||
type DocxMediaKind,
|
||||
type DocxDocumentLayoutPlan,
|
||||
@@ -101,7 +101,7 @@ export function preparePandocMedia(
|
||||
if (media.echartsErrors.length || media.mermaidErrors.length) {
|
||||
throw new Error("DOCX 图表渲染存在错误,已中止转换");
|
||||
}
|
||||
if (media.resources.length > MAXIMUM_DOCX_RESOURCE_COUNT) {
|
||||
if (media.resources.length > MAXIMUM_DOCX_MEDIA_COUNT) {
|
||||
throw new Error("DOCX 媒体数量超过限制");
|
||||
}
|
||||
|
||||
|
||||
@@ -139,6 +139,19 @@ export function transformSettingsXml(
|
||||
) {
|
||||
const document = parseXmlPart(content, "word/settings.xml");
|
||||
const settings = document.documentElement!;
|
||||
const compatibility =
|
||||
firstDirectChild(settings, WORD_NAMESPACE, "compat") ??
|
||||
appendElement(settings, WORD_NAMESPACE, "w:compat");
|
||||
removeDirectChildren(
|
||||
compatibility,
|
||||
WORD_NAMESPACE,
|
||||
"doNotExpandShiftReturn"
|
||||
);
|
||||
appendElement(
|
||||
compatibility,
|
||||
WORD_NAMESPACE,
|
||||
"w:doNotExpandShiftReturn"
|
||||
);
|
||||
removeDirectChildren(
|
||||
settings,
|
||||
WORD_NAMESPACE,
|
||||
|
||||
@@ -467,8 +467,13 @@ function applyTokenParagraphStyle(
|
||||
(token.paddingPt?.right ?? 0) > 0 ||
|
||||
(token.borders?.right?.widthPt ?? 0) > 0
|
||||
? (token.rightIndentPt ?? 0) +
|
||||
(token.paddingPt?.right ?? 0) +
|
||||
(token.borders?.right?.widthPt ?? 0)
|
||||
// 带边框代码块通过 w:pBdr/w:space 表达 CSS 右内边距。
|
||||
// 若再把 padding/border 写进 w:ind,Word/WPS 会重复扣减
|
||||
// 内容宽度,使本可容纳的等宽代码行在末尾额外回流。
|
||||
(horizontalPaddingThroughBorderSpace && token.borders?.right
|
||||
? 0
|
||||
: (token.paddingPt?.right ?? 0) +
|
||||
(token.borders?.right?.widthPt ?? 0))
|
||||
: undefined;
|
||||
if (
|
||||
token.firstLineIndentPt !== undefined ||
|
||||
@@ -1191,12 +1196,23 @@ function applyTokenStyles(
|
||||
binding.styleId === "ImageCaption"
|
||||
? { ...entry.style, alignment: "center" as const }
|
||||
: entry.style;
|
||||
applyTokenParagraphStyle(
|
||||
ensureDirectElement(
|
||||
target,
|
||||
const paragraphProperties = ensureDirectElement(
|
||||
target,
|
||||
WORD_NAMESPACE,
|
||||
"w:pPr"
|
||||
);
|
||||
// SourceCode 的主题令牌来自浏览器完整计算样式。背景透明时,
|
||||
// normalizer 会省略 backgroundColor;这里必须先清除基础模板的
|
||||
// 兜底底纹,否则模板灰底会泄漏成主题代码块的整段背景。
|
||||
if (binding.styleId === "SourceCode") {
|
||||
removeDirectChildren(
|
||||
paragraphProperties,
|
||||
WORD_NAMESPACE,
|
||||
"w:pPr"
|
||||
),
|
||||
"shd"
|
||||
);
|
||||
}
|
||||
applyTokenParagraphStyle(
|
||||
paragraphProperties,
|
||||
paragraphToken,
|
||||
fallbackFontSizeForSlot(slot, fallback),
|
||||
binding.styleId === "SourceCode"
|
||||
@@ -1205,12 +1221,20 @@ function applyTokenStyles(
|
||||
const runToken = binding.styleId === "SourceCode"
|
||||
? slots.get("code-block-text")?.style ?? entry.style
|
||||
: entry.style;
|
||||
applyTokenRunStyle(
|
||||
ensureDirectElement(
|
||||
target,
|
||||
const runProperties = ensureDirectElement(
|
||||
target,
|
||||
WORD_NAMESPACE,
|
||||
"w:rPr"
|
||||
);
|
||||
if (binding.styleId === "SourceCode") {
|
||||
removeDirectChildren(
|
||||
runProperties,
|
||||
WORD_NAMESPACE,
|
||||
"w:rPr"
|
||||
),
|
||||
"shd"
|
||||
);
|
||||
}
|
||||
applyTokenRunStyle(
|
||||
runProperties,
|
||||
runToken,
|
||||
fallbackFontsForSlot(slot, fallback),
|
||||
binding.styleId === "SourceCode" ||
|
||||
|
||||
@@ -210,17 +210,25 @@ const pandocSyntaxSlots: Readonly<
|
||||
|
||||
export function resolvePandocSyntaxStyleSlot(
|
||||
styleId: string,
|
||||
text = ""
|
||||
text = "",
|
||||
context: { htmlTagName?: boolean; objectPropertyKey?: boolean } = {}
|
||||
): DocxStyleSlotName | "code-block-text" | undefined {
|
||||
if (!(PANDOC_SYNTAX_STYLE_IDS as readonly string[]).includes(styleId)) {
|
||||
return undefined;
|
||||
}
|
||||
if (context.htmlTagName) {
|
||||
return "code-token-name";
|
||||
}
|
||||
if (context.objectPropertyKey) {
|
||||
return "code-token-attribute";
|
||||
}
|
||||
if (
|
||||
styleId === "NormalTok" ||
|
||||
styleId === "OtherTok" ||
|
||||
(styleId === "FunctionTok" && /^[\p{P}\p{S}\s]+$/u.test(text))
|
||||
((styleId === "FunctionTok" || styleId === "DataTypeTok") &&
|
||||
/^[\p{P}\p{S}\s]+$/u.test(text))
|
||||
) {
|
||||
return styleId === "FunctionTok"
|
||||
return styleId === "FunctionTok" || styleId === "DataTypeTok"
|
||||
? "code-token-punctuation"
|
||||
: "code-block-text";
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ const drawing =
|
||||
"http://schemas.openxmlformats.org/drawingml/2006/main";
|
||||
const picture =
|
||||
"http://schemas.openxmlformats.org/drawingml/2006/picture";
|
||||
const math =
|
||||
"http://schemas.openxmlformats.org/officeDocument/2006/math";
|
||||
|
||||
function generatedFixture() {
|
||||
const baseline = readReferenceDocxPackage(
|
||||
@@ -38,6 +40,14 @@ function generatedFixture() {
|
||||
`<w:p><w:r><w:t>CONTAINER_END</w:t></w:r></w:p>` +
|
||||
`<w:p><w:r><w:t>SECTION_BREAK</w:t></w:r></w:p>` +
|
||||
`<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:t>正文标题</w:t></w:r></w:p>` +
|
||||
`<w:p><w:pPr><w:pStyle w:val="SourceCode"/></w:pPr>` +
|
||||
`<w:r><w:rPr><w:rStyle w:val="DataTypeTok"/></w:rPr><w:t><</w:t></w:r>` +
|
||||
`<w:r><w:rPr><w:rStyle w:val="KeywordTok"/></w:rPr><w:t>br</w:t></w:r>` +
|
||||
`<w:r><w:rPr><w:rStyle w:val="DataTypeTok"/></w:rPr><w:t>></w:t></w:r></w:p>` +
|
||||
`<w:p><w:pPr><w:pStyle w:val="SourceCode"/></w:pPr>` +
|
||||
`<w:r><w:rPr><w:rStyle w:val="NormalTok"/></w:rPr><w:t xml:space="preserve"> method</w:t></w:r>` +
|
||||
`<w:r><w:rPr><w:rStyle w:val="OperatorTok"/></w:rPr><w:t>:</w:t></w:r>` +
|
||||
`<w:r><w:rPr><w:rStyle w:val="StringTok"/></w:rPr><w:t xml:space="preserve"> "POST"</w:t></w:r></w:p>` +
|
||||
`<w:p><w:pPr><w:pStyle w:val="FirstParagraph"/></w:pPr><w:r><w:drawing>` +
|
||||
`<wp:inline xmlns:wp="${wordprocessingDrawing}"><wp:extent cx="100" cy="200"/><wp:docPr id="1" name="Picture" descr="Mermaid 图表 1" title="mdtp-media:docx-media-1"/>` +
|
||||
`<a:graphic xmlns:a="${drawing}"><a:graphicData uri="${picture}"><pic:pic xmlns:pic="${picture}"><pic:nvPicPr><pic:cNvPr id="0" name="image.png"/></pic:nvPicPr><pic:blipFill><a:blip r:embed="rIdImage"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill><pic:spPr><a:xfrm rot="60000" flipH="1"><a:off x="0" y="0"/><a:ext cx="100" cy="200"/></a:xfrm></pic:spPr></pic:pic></a:graphicData></a:graphic>` +
|
||||
@@ -183,6 +193,46 @@ const tokens: DocxThemeTokenSet = {
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
slot: "code-block-text",
|
||||
source: "computed-css",
|
||||
confidence: "exact",
|
||||
style: {
|
||||
fontCandidates: ["Code Face"],
|
||||
fontSizePt: 9,
|
||||
color: "#24292e"
|
||||
}
|
||||
},
|
||||
{
|
||||
slot: "code-token-punctuation",
|
||||
source: "computed-css",
|
||||
confidence: "exact",
|
||||
style: {
|
||||
fontCandidates: ["Code Face"],
|
||||
fontSizePt: 9,
|
||||
color: "#7a7a7a"
|
||||
}
|
||||
},
|
||||
{
|
||||
slot: "code-token-name",
|
||||
source: "computed-css",
|
||||
confidence: "exact",
|
||||
style: {
|
||||
fontCandidates: ["Code Face"],
|
||||
fontSizePt: 9,
|
||||
color: "#22863a"
|
||||
}
|
||||
},
|
||||
{
|
||||
slot: "code-token-attribute",
|
||||
source: "computed-css",
|
||||
confidence: "exact",
|
||||
style: {
|
||||
fontCandidates: ["Code Face"],
|
||||
fontSizePt: 9,
|
||||
color: "#005cc5"
|
||||
}
|
||||
},
|
||||
{
|
||||
slot: "table-header",
|
||||
source: "computed-css",
|
||||
@@ -292,7 +342,12 @@ describe("生成 DOCX 结构收口", () => {
|
||||
documentXml.match(
|
||||
/<w:spacing[^>]*w:before="0"[^>]*w:after="0"[^>]*w:line="360"[^>]*w:lineRule="exact"/gu
|
||||
)
|
||||
).toHaveLength(2);
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
documentXml.match(
|
||||
/<w:spacing[^>]*w:before="0"[^>]*w:after="0"[^>]*w:line="360"[^>]*w:lineRule="atLeast"/gu
|
||||
)
|
||||
).toHaveLength(1);
|
||||
expect(documentXml.match(/w:fill="17324D"/gu)).toHaveLength(2);
|
||||
expect(documentXml.match(/w:val="FFFFFF"/gu)).toHaveLength(2);
|
||||
expect(
|
||||
@@ -304,6 +359,12 @@ describe("生成 DOCX 结构收口", () => {
|
||||
expect(tableParagraph).toContain('<w:autoSpaceDE w:val="0"/>');
|
||||
expect(tableParagraph).toContain('<w:autoSpaceDN w:val="0"/>');
|
||||
expect(documentXml).toContain("<w:cantSplit/>");
|
||||
expect(documentXml).toMatch(
|
||||
/<w:p><w:pPr><w:pStyle w:val="SourceCode"\/>[\s\S]*?<w:color w:val="7A7A7A"\/>[\s\S]*?<w:t><<\/w:t>[\s\S]*?<w:color w:val="22863A"\/>[\s\S]*?<w:t>br<\/w:t>[\s\S]*?<w:color w:val="7A7A7A"\/>[\s\S]*?<w:t>><\/w:t>[\s\S]*?<\/w:p>/u
|
||||
);
|
||||
expect(documentXml).toMatch(
|
||||
/<w:p><w:pPr><w:pStyle w:val="SourceCode"\/>[\s\S]*?<w:rStyle w:val="NormalTok"\/>[\s\S]*?<w:color w:val="005CC5"\/>[\s\S]*?<w:t xml:space="preserve"> method<\/w:t>[\s\S]*?<\/w:p>/u
|
||||
);
|
||||
expect(documentXml).toContain(
|
||||
'<w:tab w:val="right" w:pos="9806"/>'
|
||||
);
|
||||
@@ -389,6 +450,98 @@ describe("生成 DOCX 结构收口", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("含行内代码的实测行距允许 Word 扩展行盒", () => {
|
||||
const fixture = readGeneratedDocxPackage(generatedFixture());
|
||||
const documentXml = decoder.decode(
|
||||
fixture.entries.get("word/document.xml")!
|
||||
).replace(
|
||||
"<w:sectPr>",
|
||||
'<w:p><w:r><w:t>前缀</w:t></w:r><w:r><w:rPr><w:rStyle w:val="VerbatimChar"/></w:rPr><w:t>inline</w:t></w:r><w:r><w:t>后缀</w:t></w:r></w:p><w:sectPr>'
|
||||
);
|
||||
fixture.entries.set("word/document.xml", encoder.encode(documentXml));
|
||||
|
||||
const result = finalizeGeneratedDocxStructure(
|
||||
writeGeneratedDocxPackage(fixture.entries),
|
||||
plan,
|
||||
tokens,
|
||||
[],
|
||||
{
|
||||
tables: [],
|
||||
textBlocks: [{
|
||||
ordinal: 1,
|
||||
text: "前缀inline后缀",
|
||||
letterSpacingPt: 0,
|
||||
linePitchPt: 11.15,
|
||||
lineBreakOffsets: []
|
||||
}]
|
||||
}
|
||||
);
|
||||
const outputXml = decoder.decode(
|
||||
readGeneratedDocxPackage(result.content).entries.get(
|
||||
"word/document.xml"
|
||||
)!
|
||||
);
|
||||
|
||||
expect(outputXml).toMatch(
|
||||
/w:line="223" w:lineRule="atLeast"[\s\S]*?<w:t>前缀<\/w:t>[\s\S]*?<w:rStyle w:val="VerbatimChar"\/>/u
|
||||
);
|
||||
});
|
||||
|
||||
it("未匹配实测布局的行内代码段落也不保留 exact 行盒", () => {
|
||||
const fixture = readGeneratedDocxPackage(generatedFixture());
|
||||
const documentXml = decoder.decode(
|
||||
fixture.entries.get("word/document.xml")!
|
||||
).replace(
|
||||
"<w:sectPr>",
|
||||
'<w:p><w:pPr><w:spacing w:line="312" w:lineRule="exact"/></w:pPr><w:r><w:t>前缀</w:t></w:r><w:r><w:rPr><w:rStyle w:val="VerbatimChar"/></w:rPr><w:t>inline</w:t></w:r></w:p><w:sectPr>'
|
||||
);
|
||||
fixture.entries.set("word/document.xml", encoder.encode(documentXml));
|
||||
|
||||
const result = finalizeGeneratedDocxStructure(
|
||||
writeGeneratedDocxPackage(fixture.entries),
|
||||
plan,
|
||||
tokens
|
||||
);
|
||||
const outputXml = decoder.decode(
|
||||
readGeneratedDocxPackage(result.content).entries.get(
|
||||
"word/document.xml"
|
||||
)!
|
||||
);
|
||||
|
||||
expect(outputXml).toMatch(
|
||||
/w:line="312" w:lineRule="atLeast"[\s\S]*?<w:rStyle w:val="VerbatimChar"\/>/u
|
||||
);
|
||||
});
|
||||
|
||||
it("表格纯公式段落加入零宽普通 Run 以服从段落对齐", () => {
|
||||
const fixture = readGeneratedDocxPackage(generatedFixture());
|
||||
const documentXml = decoder.decode(
|
||||
fixture.entries.get("word/document.xml")!
|
||||
).replace(
|
||||
`xmlns:r="${relationships}"`,
|
||||
`xmlns:r="${relationships}" xmlns:m="${math}"`
|
||||
).replace(
|
||||
"<w:p><w:r><w:t>B</w:t></w:r></w:p>",
|
||||
'<w:p><w:pPr><w:jc w:val="left"/></w:pPr><m:oMath><m:r><m:t>≥0.80</m:t></m:r></m:oMath></w:p>'
|
||||
);
|
||||
fixture.entries.set("word/document.xml", encoder.encode(documentXml));
|
||||
|
||||
const result = finalizeGeneratedDocxStructure(
|
||||
writeGeneratedDocxPackage(fixture.entries),
|
||||
plan,
|
||||
tokens
|
||||
);
|
||||
const outputXml = decoder.decode(
|
||||
readGeneratedDocxPackage(result.content).entries.get(
|
||||
"word/document.xml"
|
||||
)!
|
||||
);
|
||||
|
||||
expect(outputXml).toMatch(
|
||||
/<w:jc w:val="left"\/>[\s\S]*?<m:oMath>[\s\S]*?<m:t>≥0\.80<\/m:t>[\s\S]*?<\/m:oMath><w:r><w:rPr><w:noProof\/><\/w:rPr><w:t xml:space="preserve">\u200B<\/w:t><\/w:r>/u
|
||||
);
|
||||
});
|
||||
|
||||
it("为右对齐盒模型保留 CSS 右侧内容内缩", () => {
|
||||
const fixture = readGeneratedDocxPackage(generatedFixture());
|
||||
const documentXml = decoder.decode(
|
||||
@@ -508,6 +661,7 @@ describe("生成 DOCX 结构收口", () => {
|
||||
'<w:tblInd w:w="495" w:type="dxa"/>'
|
||||
);
|
||||
expect(documentXml).toContain('<w:tblLayout w:type="fixed"/>');
|
||||
expect(documentXml).toContain('<w:wordWrap w:val="1"/>');
|
||||
expect(documentXml).toContain('<w:gridCol w:w="2675"/>');
|
||||
expect(documentXml).toContain('<w:gridCol w:w="6240"/>');
|
||||
expect(
|
||||
@@ -525,6 +679,112 @@ describe("生成 DOCX 结构收口", () => {
|
||||
expect(documentXml).toContain('<w:jc w:val="distribute"/>');
|
||||
});
|
||||
|
||||
it("将超出内容盒的实测表格等比收缩到 Word 可用宽度", () => {
|
||||
const result = finalizeGeneratedDocxStructure(
|
||||
generatedFixture(),
|
||||
plan,
|
||||
tokens,
|
||||
[],
|
||||
{
|
||||
tables: [
|
||||
{
|
||||
ordinal: 1,
|
||||
widthPercent: 300,
|
||||
leftOffsetPercent: 0,
|
||||
columnWidthPercents: [40, 10, 10, 10, 10, 20],
|
||||
rows: []
|
||||
}
|
||||
]
|
||||
}
|
||||
);
|
||||
const documentXml = decoder.decode(
|
||||
readGeneratedDocxPackage(result.content).entries.get(
|
||||
"word/document.xml"
|
||||
)!
|
||||
);
|
||||
|
||||
expect(documentXml).toContain(
|
||||
'<w:tblW w:w="9906" w:type="dxa"/>'
|
||||
);
|
||||
expect(documentXml).toContain('<w:gridCol w:w="3962"/>');
|
||||
expect(documentXml).toContain('<w:gridCol w:w="1980"/>');
|
||||
});
|
||||
|
||||
it("按 Chromium 实测 emoji 调色保留可编辑 Unicode 文本", () => {
|
||||
const fixture = readGeneratedDocxPackage(generatedFixture());
|
||||
const documentXml = decoder.decode(
|
||||
fixture.entries.get("word/document.xml")!
|
||||
).replace(
|
||||
'<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:t>正文标题</w:t></w:r></w:p>',
|
||||
'<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:t>正文标题</w:t></w:r></w:p>' +
|
||||
'<w:p><w:r><w:t>⭐⭐⭐⭐</w:t></w:r></w:p>'
|
||||
);
|
||||
fixture.entries.set("word/document.xml", encoder.encode(documentXml));
|
||||
|
||||
const result = finalizeGeneratedDocxStructure(
|
||||
writeGeneratedDocxPackage(fixture.entries),
|
||||
plan,
|
||||
tokens,
|
||||
[],
|
||||
{
|
||||
tables: [],
|
||||
emojiRuns: [
|
||||
{ ordinal: 1, text: "⭐⭐⭐⭐", color: "#e7bf36" }
|
||||
]
|
||||
}
|
||||
);
|
||||
const finalizedXml = decoder.decode(
|
||||
readGeneratedDocxPackage(result.content).entries.get(
|
||||
"word/document.xml"
|
||||
)!
|
||||
);
|
||||
|
||||
expect(finalizedXml).toMatch(
|
||||
/<w:r><w:rPr><w:color w:val="E7BF36"\/><\/w:rPr><w:t>⭐⭐⭐⭐<\/w:t><\/w:r>/u
|
||||
);
|
||||
});
|
||||
|
||||
it("实测表格缩进补偿 Word 以单元格内容而非外边框对齐的行为", () => {
|
||||
const paddedTokens = {
|
||||
...tokens,
|
||||
slots: tokens.slots.map((slot) =>
|
||||
slot.slot === "table-cell"
|
||||
? {
|
||||
...slot,
|
||||
style: {
|
||||
...slot.style,
|
||||
paddingPt: { top: 3, right: 6, bottom: 3, left: 6 }
|
||||
}
|
||||
}
|
||||
: slot
|
||||
)
|
||||
} satisfies DocxThemeTokenSet;
|
||||
const result = finalizeGeneratedDocxStructure(
|
||||
generatedFixture(),
|
||||
plan,
|
||||
paddedTokens,
|
||||
[],
|
||||
{
|
||||
tables: [{
|
||||
ordinal: 1,
|
||||
widthPercent: 90,
|
||||
leftOffsetPercent: 5,
|
||||
columnWidthPercents: [30, 70],
|
||||
rows: []
|
||||
}]
|
||||
}
|
||||
);
|
||||
const documentXml = decoder.decode(
|
||||
readGeneratedDocxPackage(result.content).entries.get(
|
||||
"word/document.xml"
|
||||
)!
|
||||
);
|
||||
|
||||
expect(documentXml).toContain(
|
||||
'<w:tblInd w:w="615" w:type="dxa"/>'
|
||||
);
|
||||
});
|
||||
|
||||
it("按稳定媒体计划规范化内联尺寸、比例锁和对齐", () => {
|
||||
const result = finalizeGeneratedDocxStructure(
|
||||
generatedFixture(),
|
||||
@@ -882,6 +1142,36 @@ describe("生成 DOCX 结构收口", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("将 Pandoc 拆分的标准 Alert 正文恢复为引用块样式", () => {
|
||||
const source = readGeneratedDocxPackage(generatedFixture());
|
||||
const entries = new Map(source.entries);
|
||||
const documentXml = decoder.decode(entries.get("word/document.xml")!);
|
||||
entries.set(
|
||||
"word/document.xml",
|
||||
encoder.encode(documentXml.replace(
|
||||
"<w:sectPr>",
|
||||
`<w:p><w:pPr><w:pStyle w:val="FirstParagraph"/></w:pPr><w:r><w:t>Caution</w:t></w:r></w:p>` +
|
||||
`<w:p><w:pPr><w:pStyle w:val="BodyText"/></w:pPr><w:r><w:t>警告正文</w:t></w:r></w:p>` +
|
||||
`<w:sectPr>`
|
||||
))
|
||||
);
|
||||
|
||||
const result = finalizeGeneratedDocxStructure(
|
||||
writeGeneratedDocxPackage(entries),
|
||||
plan,
|
||||
tokens
|
||||
);
|
||||
const finalizedXml = decoder.decode(
|
||||
readGeneratedDocxPackage(result.content).entries.get(
|
||||
"word/document.xml"
|
||||
)!
|
||||
);
|
||||
|
||||
expect(finalizedXml).toMatch(
|
||||
/<w:pPr><w:pStyle w:val="BlockText"\/>[\s\S]*?<w:t>警告正文<\/w:t>/u
|
||||
);
|
||||
});
|
||||
|
||||
it("按最终页面内容区重算右置百分比容器缩进", () => {
|
||||
const relativeTokens: DocxThemeTokenSet = {
|
||||
...tokens,
|
||||
|
||||
@@ -127,7 +127,7 @@ describe("Pandoc DOCX 转换器", () => {
|
||||
expect(arguments_).toContain("--data-dir");
|
||||
expect(arguments_).toContain("--resource-path");
|
||||
expect(argumentAfter(arguments_, "--from")).toBe(
|
||||
"markdown+yaml_metadata_block+pipe_tables+footnotes+task_lists+tex_math_dollars-raw_html"
|
||||
"commonmark_x-yaml_metadata_block+pipe_tables+footnotes+task_lists+tex_math_dollars+raw_html"
|
||||
);
|
||||
expect(
|
||||
options.env?.MD_TO_PDF_DOCX_MEDIA_MAP
|
||||
@@ -219,4 +219,29 @@ describe("Pandoc DOCX 转换器", () => {
|
||||
code: "DOCX_OUTPUT_INVALID"
|
||||
} satisfies Partial<PandocDocxConversionError>);
|
||||
});
|
||||
|
||||
it("进程失败时保留内部诊断但保持稳定的对外错误", async () => {
|
||||
const failedRunner = vi.fn<PandocProcessRunner>(async () => ({
|
||||
outcome: "completed",
|
||||
exitCode: 64,
|
||||
stdout: new Uint8Array(),
|
||||
stderr: "pandoc: 媒体映射失败"
|
||||
}));
|
||||
|
||||
await expect(
|
||||
new PandocDocxConverter({
|
||||
runtime,
|
||||
runner: failedRunner,
|
||||
temporaryRoot
|
||||
}).convert(input())
|
||||
).rejects.toMatchObject({
|
||||
code: "DOCX_GENERATION_FAILED",
|
||||
message: "Pandoc 未能生成 DOCX",
|
||||
diagnostics: {
|
||||
outcome: "completed",
|
||||
exitCode: 64,
|
||||
stderr: "pandoc: 媒体映射失败"
|
||||
}
|
||||
} satisfies Partial<PandocDocxConversionError>);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -404,11 +404,12 @@ describe("动态 reference.docx", () => {
|
||||
const sourceCodeStyle = stylesXml.match(
|
||||
/<w:style\b[^>]*w:styleId="SourceCode"[\s\S]*?<\/w:style>/u
|
||||
)?.[0];
|
||||
expect(sourceCodeStyle).not.toContain("<w:shd");
|
||||
expect(sourceCodeStyle).toContain(
|
||||
'<w:left w:val="single" w:sz="6" w:space="7" w:color="E7EAED"/>'
|
||||
);
|
||||
expect(sourceCodeStyle).toMatch(
|
||||
/<w:ind\b[^>]*w:left="195"[^>]*w:right="75"[^>]*\/>/u
|
||||
/<w:ind\b[^>]*w:left="195"[^>]*w:right="0"[^>]*\/>/u
|
||||
);
|
||||
expect(sourceCodeStyle).toContain('w:ascii="Code Text Face"');
|
||||
expect(stylesXml).toMatch(
|
||||
@@ -451,6 +452,49 @@ describe("动态 reference.docx", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("按代码容器令牌清除或写入 SourceCode 段落底纹", () => {
|
||||
const createWithBackground = (backgroundColor?: string) => {
|
||||
const result = createDynamicReferenceDocx(
|
||||
createBaselineReference(),
|
||||
{
|
||||
...createOptions(defaultExportConfig),
|
||||
themeTokens: themeTokens("f".repeat(64), [
|
||||
{
|
||||
slot: "code-block",
|
||||
source: "computed-css",
|
||||
confidence: "exact",
|
||||
style: {
|
||||
fontCandidates: ["Consolas"],
|
||||
fontSizePt: 10,
|
||||
...(backgroundColor ? { backgroundColor } : {})
|
||||
}
|
||||
},
|
||||
{
|
||||
slot: "code-block-text",
|
||||
source: "computed-css",
|
||||
confidence: "exact",
|
||||
style: {
|
||||
fontCandidates: ["Consolas"],
|
||||
fontSizePt: 10
|
||||
}
|
||||
}
|
||||
])
|
||||
}
|
||||
);
|
||||
const stylesXml = decoder.decode(
|
||||
unzipSync(result.content)["word/styles.xml"]
|
||||
);
|
||||
return stylesXml.match(
|
||||
/<w:style\b[^>]*w:styleId="SourceCode"[\s\S]*?<\/w:style>/u
|
||||
)?.[0];
|
||||
};
|
||||
|
||||
expect(createWithBackground()).not.toContain("<w:shd");
|
||||
expect(createWithBackground("#eef2f6")).toContain(
|
||||
'<w:shd w:val="clear" w:color="auto" w:fill="EEF2F6"/>'
|
||||
);
|
||||
});
|
||||
|
||||
it("生成横向、自定义页边距和奇偶首页页眉页脚", () => {
|
||||
const exportConfig: ExportConfig = {
|
||||
...defaultExportConfig,
|
||||
@@ -506,6 +550,7 @@ describe("动态 reference.docx", () => {
|
||||
expect(documentXml.match(/w:headerReference/gu)).toHaveLength(3);
|
||||
expect(documentXml.match(/w:footerReference/gu)).toHaveLength(3);
|
||||
expect(settingsXml).toContain("<w:evenAndOddHeaders");
|
||||
expect(settingsXml).toContain("<w:doNotExpandShiftReturn");
|
||||
expect(stylesXml).toContain('<w:kern w:val="2"');
|
||||
expect(headerXml).toContain("年度 <报告>");
|
||||
expect(headerXml).toContain("报告 & 计划.md");
|
||||
|
||||
@@ -89,6 +89,15 @@ describe("DOCX 令牌样式映射", () => {
|
||||
it("将 Pandoc 语法样式映射到引擎级代码语义槽位", () => {
|
||||
expect(resolvePandocSyntaxStyleSlot("DataTypeTok", '"key"'))
|
||||
.toBe("code-token-attribute");
|
||||
expect(resolvePandocSyntaxStyleSlot("DataTypeTok", "<"))
|
||||
.toBe("code-token-punctuation");
|
||||
expect(
|
||||
resolvePandocSyntaxStyleSlot(
|
||||
"KeywordTok",
|
||||
"section",
|
||||
{ htmlTagName: true }
|
||||
)
|
||||
).toBe("code-token-name");
|
||||
expect(resolvePandocSyntaxStyleSlot("StringTok", '"value"'))
|
||||
.toBe("code-token-string");
|
||||
expect(resolvePandocSyntaxStyleSlot("FunctionTok", ":"))
|
||||
@@ -97,6 +106,13 @@ describe("DOCX 令牌样式映射", () => {
|
||||
.toBe("code-token-title");
|
||||
expect(resolvePandocSyntaxStyleSlot("NormalTok", " "))
|
||||
.toBe("code-block-text");
|
||||
expect(
|
||||
resolvePandocSyntaxStyleSlot(
|
||||
"NormalTok",
|
||||
" method",
|
||||
{ objectPropertyKey: true }
|
||||
)
|
||||
).toBe("code-token-attribute");
|
||||
expect(resolvePandocSyntaxStyleSlot("UnknownTok", "x"))
|
||||
.toBeUndefined();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user