release: 发布 v0.5.1

新增能力:内置 4 套红头、3 套正式文档和 3 套标书主题,支持主题推荐页边距、页面装饰、页眉页脚和页码;增加结构化公文、项目报告及标书 Front Matter,内置 Fandol 中文字体、两份教程和 14 份主题示例。

桌面工作流:重组更多菜单,增加新建、保存、另存为快捷键和未保存确认;另存为后跟随新路径,主题示例自动切换主题,Desktop 发行包完整携带教程、示例与字体。

问题修复:修正红头标题居中与正式文档字体;围栏代码按正文宽度自动换行,长内容安全断行,至少两行即可在当前页分页,并统一保留 8px 左侧内容留白;Docker Web 镜像正确打包 samples。

兼容与部署:未声明主题推荐设置时继续使用 16mm 默认页边距;Web 隐藏不适用的另存为。正式镜像 yixiong/md-to-pdf:v0.5.1 内容 ID 为 sha256:72f519a69bfd6cb2f6df30c2be938e58ceb6f20e4748a32551874de3d436b276,Compose 健康运行。

验证结果:最终修复前 65 个测试文件、286 项测试通过,最终代码块与主题定向测试 27 项通过;全项目类型检查、生产构建和 git diff --check 通过。容器检出 14 套主题、17 份 Markdown,代码块回归 PDF 为 2 页且无越界。NSIS SHA-256 为 676CCE73D782B0B15AC6BC68F253CFBC4740383583E4DAA98F746EDF9999C5CC,ZIP SHA-256 为 82BF6ADF1200604F145CC86FA3ED193955CF6741EBEE3DF8953483515CFF621F。
This commit is contained in:
SkyJourney
2026-07-29 16:58:17 +08:00
parent 58087d0c7e
commit 83e6559c2c
111 changed files with 8274 additions and 173 deletions
@@ -0,0 +1,290 @@
import type {
DocumentProfile,
MarkdownDocumentMetadata
} from "@md-to-pdf/core";
export interface RenderedDocumentStructure {
profile: DocumentProfile["profile"];
prefixHtml: string;
suffixHtml: string;
}
export function renderDocumentStructure(
metadata: MarkdownDocumentMetadata
): RenderedDocumentStructure | undefined {
const document = metadata.document;
if (!document) {
return undefined;
}
switch (document.profile) {
case "official":
return renderOfficialStructure(metadata.title, document);
case "briefing":
return renderBriefingStructure(metadata.title, document);
case "project-report":
return renderProjectReportStructure(metadata.title, document);
case "tender":
return renderTenderStructure(metadata.title, document);
}
}
function renderOfficialStructure(
title: string,
document: Extract<DocumentProfile, { profile: "official" }>
): RenderedDocumentStructure {
const classification = joinParts(
[
renderText("span", "doc-secrecy", document.secrecy),
renderText("span", "doc-urgency", document.urgency)
],
"doc-classification"
);
const issueRow = joinParts(
[
renderText("span", "doc-number", document.number),
renderLabeledText(
"span",
"doc-signatory",
"签发人:",
document.signatory
)
],
"doc-issue-row"
);
const masthead = joinParts(
[
classification,
renderText("p", "doc-issuer", document.issuer),
issueRow
],
"doc-masthead doc-masthead-official",
"header"
);
const signature = joinParts(
[
renderText("p", "doc-signature-issuer", document.issuer),
renderTime("doc-signature-date", document.date)
],
"doc-signature",
"section"
);
const edition = joinParts(
[
document.copyTo?.length
? `<p class="doc-copy-to"><span>抄送:</span>${escapeHtml(document.copyTo.join("、"))}</p>`
: "",
joinParts(
[
renderText(
"span",
"doc-printing-office",
document.printingOffice
),
document.date
? `<time class="doc-printing-date">${escapeHtml(document.date)}印发</time>`
: ""
],
"doc-printing-row"
)
],
"doc-edition",
"footer"
);
return {
profile: document.profile,
prefixHtml:
masthead + renderText("h1", "doc-title", title),
suffixHtml: signature + edition
};
}
function renderBriefingStructure(
title: string,
document: Extract<DocumentProfile, { profile: "briefing" }>
): RenderedDocumentStructure {
const details = joinParts(
[
renderText("span", "doc-briefing-issue", document.issue),
renderText(
"span",
"doc-briefing-publisher",
document.publisher
),
renderLabeledText(
"span",
"doc-briefing-signatory",
"签发:",
document.signatory
),
renderTime("doc-briefing-date", document.date)
],
"doc-briefing-meta"
);
const masthead = joinParts(
[
renderText(
"p",
"doc-briefing-masthead",
document.masthead
),
details
],
"doc-masthead doc-masthead-briefing",
"header"
);
const contact = renderText(
"footer",
"doc-briefing-contact",
document.contact
);
return {
profile: document.profile,
prefixHtml:
masthead + renderText("h1", "doc-title", title),
suffixHtml: contact
};
}
function renderProjectReportStructure(
title: string,
document: Extract<
DocumentProfile,
{ profile: "project-report" }
>
): RenderedDocumentStructure {
const cover = joinParts(
[
renderText(
"p",
"doc-cover-project-name",
document.projectName
),
renderText(
"h1",
"doc-cover-title",
document.documentType || title
),
renderText("p", "doc-cover-version", document.version),
renderLabeledText(
"p",
"doc-cover-owner",
"建设单位:",
document.owner
),
renderLabeledText(
"p",
"doc-cover-prepared-by",
"编制单位:",
document.preparedBy
),
renderTime("doc-cover-date", document.date)
],
"doc-cover doc-cover-project-report",
"header"
);
return {
profile: document.profile,
prefixHtml: cover,
suffixHtml: ""
};
}
function renderTenderStructure(
title: string,
document: Extract<DocumentProfile, { profile: "tender" }>
): RenderedDocumentStructure {
const cover = joinParts(
[
renderText("p", "doc-cover-copy-mark", document.copyMark),
renderText(
"p",
"doc-cover-project-name",
document.projectName
),
renderText(
"p",
"doc-cover-project-number",
document.projectNumber
),
renderText("h1", "doc-cover-title", title),
renderText("p", "doc-cover-volume", document.volume),
renderLabeledText(
"p",
"doc-cover-bidder",
"投标人:",
document.bidder
),
renderLabeledText(
"p",
"doc-cover-representative",
"法定代表人或授权代表:",
document.representative
),
renderTime("doc-cover-date", document.date)
],
"doc-cover doc-cover-tender",
"header"
);
return {
profile: document.profile,
prefixHtml: cover,
suffixHtml: ""
};
}
function renderText(
tagName: string,
className: string,
value: string | undefined
) {
return value
? `<${tagName} class="${className}">${escapeHtml(value)}</${tagName}>`
: "";
}
function renderLabeledText(
tagName: string,
className: string,
label: string,
value: string | undefined
) {
return value
? `<${tagName} class="${className}"><span>${label}</span>${escapeHtml(value)}</${tagName}>`
: "";
}
function renderTime(className: string, value: string | undefined) {
return value
? `<time class="${className}">${escapeHtml(value)}</time>`
: "";
}
function joinParts(
parts: readonly string[],
className: string,
tagName = "div"
) {
const content = parts.filter(Boolean).join("");
return content
? `<${tagName} class="${className}">${content}</${tagName}>`
: "";
}
function escapeHtml(value: string) {
return value.replace(
/[&<>"']/gu,
(character) =>
({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;"
})[character] ?? character
);
}
+29 -2
View File
@@ -3,12 +3,14 @@ import { footnote } from "@mdit/plugin-footnote";
import { katex } from "@mdit/plugin-katex";
import { tasklist } from "@mdit/plugin-tasklist";
import type {
DocumentProfile,
MarkdownDocumentMetadata,
RenderedMarkdownDocument,
ThemeFeature
} from "@md-to-pdf/core";
import {
classifyDocumentLink,
documentProfileSchema,
isSafeDocumentLink
} from "@md-to-pdf/core";
import {
@@ -22,6 +24,7 @@ import type { Options as MarkdownItOptions } from "markdown-it";
import type Renderer from "markdown-it/lib/renderer.mjs";
import type Token from "markdown-it/lib/token.mjs";
import sanitizeHtml from "sanitize-html";
import { renderDocumentStructure } from "./render-document-structure.js";
export const RENDERER_VERSION = 1;
@@ -350,7 +353,11 @@ export function renderMarkdown(
options.language ?? "zh-CN",
extractFirstHeading(parsed.content)
);
const articleHtml = `<article id="write" class="markdown-body" lang="${escapeAttribute(metadata.language)}">${bodyHtml}</article>`;
const structure = renderDocumentStructure(metadata);
const profileAttribute = structure
? ` data-document-profile="${structure.profile}"`
: "";
const articleHtml = `<article id="write" class="markdown-body" lang="${escapeAttribute(metadata.language)}"${profileAttribute}>${structure?.prefixHtml ?? ""}${bodyHtml}${structure?.suffixHtml ?? ""}</article>`;
return {
rendererVersion: RENDERER_VERSION,
@@ -405,10 +412,30 @@ function normalizeMetadata(
author: readString(data.author),
subject: readString(data.subject) || readString(data.description),
keywords: readStringList(data.keywords ?? data.tags),
language: readString(data.language ?? data.lang) || fallbackLanguage
language: readString(data.language ?? data.lang) || fallbackLanguage,
...(data.document === undefined
? {}
: { document: normalizeDocumentProfile(data.document) })
};
}
function normalizeDocumentProfile(value: unknown): DocumentProfile {
const parsed = documentProfileSchema.safeParse(value);
if (parsed.success) {
return parsed.data;
}
const details = parsed.error.issues
.map((issue) => {
const path = ["document", ...issue.path].join(".");
return `${path}${issue.message}`;
})
.join("");
throw new MarkdownDocumentParseError(
`文档结构配置无效:${details}`
);
}
function readString(value: unknown): string {
if (typeof value === "string") {
return value.trim();