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:
@@ -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) =>
|
||||
({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'"
|
||||
})[character] ?? character
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user