Files
MorphDoc/packages/renderer/src/render-markdown.ts
T
SkyJourney 83e6559c2c 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。
2026-07-29 16:58:17 +08:00

505 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { anchor } from "@mdit/plugin-anchor";
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 {
markdownItECharts,
type MarkdownItEChartsErrorContext
} from "@md-to-pdf/markdown-echarts";
import matter from "gray-matter";
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 Token from "markdown-it/lib/token.mjs";
import sanitizeHtml from "sanitize-html";
import { renderDocumentStructure } from "./render-document-structure.js";
export const RENDERER_VERSION = 1;
export class MarkdownDocumentParseError extends Error {
readonly code = "INVALID_FRONT_MATTER";
constructor(message: string, options?: ErrorOptions) {
super(message, options);
this.name = "MarkdownDocumentParseError";
}
}
export type { MarkdownDocumentMetadata } from "@md-to-pdf/core";
export interface RenderMarkdownOptions {
language?: string;
imageSourceMap?: ReadonlyMap<string, string>;
warnings?: readonly string[];
}
export interface RenderedMarkdown
extends Omit<RenderedMarkdownDocument, "rendererVersion"> {
rendererVersion: typeof RENDERER_VERSION;
}
const markdownOptions: MarkdownItOptions = {
html: false,
breaks: false,
linkify: true,
typographer: false,
highlight(code, language) {
const normalizedLanguage = language.trim().toLowerCase();
if (normalizedLanguage && hljs.getLanguage(normalizedLanguage)) {
return hljs.highlight(code, {
language: normalizedLanguage,
ignoreIllegals: true
}).value;
}
return MarkdownIt().utils.escapeHtml(code);
}
};
const markdown = new MarkdownIt(markdownOptions)
.use(anchor, {
level: 1,
uniqueSlugStartIndex: 2,
tabIndex: false
})
.use(footnote)
.use(tasklist, {
disabled: true,
label: true,
containerClass: "contains-task-list",
itemClass: "md-task-list-item",
checkboxClass: "task-list-item-checkbox",
labelClass: "task-list-item-label"
})
.use(katex, {
delimiters: "all",
output: "html",
throwOnError: false,
strict: "warn",
trust: false
});
const defaultValidateLink = markdown.validateLink.bind(markdown);
markdown.validateLink = (href) =>
defaultValidateLink(href) ||
classifyDocumentLink(href).scheme === "file";
const defaultFenceRenderer = markdown.renderer.rules.fence;
const defaultImageRenderer = markdown.renderer.rules.image;
const defaultParagraphOpenRenderer =
markdown.renderer.rules.paragraph_open;
const defaultParagraphCloseRenderer =
markdown.renderer.rules.paragraph_close;
const lengthStylePattern = /^-?\d+(?:\.\d+)?(?:em|ex|px|%)$/u;
const tableTextAlignStylePattern = /^(?:left|center|right)$/u;
interface MarkdownRenderEnvironment {
warnings?: string[];
imageSourceMap?: ReadonlyMap<string, string>;
standaloneImage?: boolean;
}
function getStandaloneImage(
token: Token | undefined
): Token | undefined {
if (token?.type !== "inline") {
return undefined;
}
const meaningful = (token.children ?? []).filter(
(child) =>
child.type !== "text" || child.content.trim().length > 0
);
return meaningful.length === 1 && meaningful[0]?.type === "image"
? meaningful[0]
: undefined;
}
markdown.renderer.rules.paragraph_open = (
tokens,
index,
options,
environment,
renderer
) => {
const standaloneImage = getStandaloneImage(tokens[index + 1]);
(environment as MarkdownRenderEnvironment).standaloneImage =
Boolean(standaloneImage);
if (standaloneImage) {
return '<figure class="md-document-image-block">\n';
}
return defaultParagraphOpenRenderer
? defaultParagraphOpenRenderer(
tokens,
index,
options,
environment,
renderer
)
: renderer.renderToken(tokens, index, options);
};
markdown.renderer.rules.paragraph_close = (
tokens,
index,
options,
environment,
renderer
) => {
const renderEnvironment =
environment as MarkdownRenderEnvironment;
if (renderEnvironment.standaloneImage) {
renderEnvironment.standaloneImage = false;
return "</figure>\n";
}
return defaultParagraphCloseRenderer
? defaultParagraphCloseRenderer(
tokens,
index,
options,
environment,
renderer
)
: renderer.renderToken(tokens, index, options);
};
markdown.renderer.rules.fence = (
tokens,
index,
options,
environment,
renderer
) => {
const token = tokens[index];
const language = token?.info.trim().split(/\s+/u)[0]?.toLowerCase();
if (token && language === "mermaid") {
return `<div class="mermaid" data-mermaid-pending="true">${markdown.utils.escapeHtml(token.content)}</div>\n`;
}
if (defaultFenceRenderer) {
const renderedFence = defaultFenceRenderer(
tokens,
index,
options,
environment,
renderer
);
const languageAttribute = language
? ` lang="${escapeAttribute(language)}"`
: "";
return renderedFence.replace(
/^<pre>/u,
`<pre class="md-fences"${languageAttribute}>`
);
}
return renderFallbackFence(tokens, index, renderer);
};
markdown.renderer.rules.image = (
tokens,
index,
options,
environment,
renderer
) => {
const token = tokens[index];
const renderEnvironment =
environment as MarkdownRenderEnvironment;
const imageSourceMap = renderEnvironment.imageSourceMap;
const source = token?.attrGet("src");
const resolvedSource = source
? imageSourceMap?.get(source)
: undefined;
if (token) {
if (resolvedSource) {
token.attrSet("src", resolvedSource);
}
token.attrJoin("class", "md-document-image");
}
const imageHtml = defaultImageRenderer
? defaultImageRenderer(
tokens,
index,
options,
environment,
renderer
)
: renderer.renderToken(tokens, index, options);
if (renderEnvironment.standaloneImage && token) {
return (
imageHtml +
`<figcaption class="md-document-image-caption">${markdown.utils.escapeHtml(token.content)}</figcaption>`
);
}
return imageHtml;
};
markdown.use(markdownItECharts, {
onError(error, context: MarkdownItEChartsErrorContext) {
const environment = context.environment as
| { warnings?: string[] }
| undefined;
environment?.warnings?.push(
`ECharts 图表配置无效:${error.message}`
);
}
});
const safeHtmlOptions: sanitizeHtml.IOptions = {
allowedTags: [
...sanitizeHtml.defaults.allowedTags,
"article",
"div",
"figure",
"figcaption",
"img",
"input",
"mark",
"section",
"span",
"sub",
"summary",
"sup"
],
allowedAttributes: {
"*": ["class", "id", "title", "aria-*", "role"],
a: ["href", "name", "target", "rel"],
div: ["class", "data-mermaid-pending"],
figure: [
"class",
"data-echarts-pending",
"data-echarts-protocol"
],
img: ["src", "alt", "title", "width", "height"],
input: ["type", "checked", "disabled", "class"],
li: ["class", "value"],
ol: ["class", "start"],
pre: ["class", "hidden", "lang"],
span: ["class", "style"],
td: ["colspan", "rowspan", "style"],
th: ["colspan", "rowspan", "scope", "style"]
},
allowedSchemes: ["http", "https", "mailto"],
allowedSchemesByTag: {
img: ["http", "https", "data"]
},
allowedSchemesAppliedToAttributes: ["src", "cite"],
allowedStyles: {
span: {
height: [lengthStylePattern],
top: [lengthStylePattern],
width: [lengthStylePattern],
"min-width": [lengthStylePattern],
"margin-left": [lengthStylePattern],
"margin-right": [lengthStylePattern],
"padding-left": [lengthStylePattern],
"vertical-align": [lengthStylePattern],
"border-bottom-width": [lengthStylePattern]
},
td: {
"text-align": [tableTextAlignStylePattern]
},
th: {
"text-align": [tableTextAlignStylePattern]
}
},
transformTags: {
a(tagName, attributes) {
const href = attributes.href;
if (href && !isSafeDocumentLink(href)) {
delete attributes.href;
}
return {
tagName,
attribs: {
...attributes,
rel: "noopener noreferrer"
}
};
}
}
};
export function renderMarkdown(
source: string,
options: RenderMarkdownOptions = {}
): RenderedMarkdown {
const warnings = [...(options.warnings ?? [])];
const parsed = parseFrontMatter(source);
const environment = {
warnings,
imageSourceMap: options.imageSourceMap
};
const rendered = markdown.render(parsed.content, environment);
const bodyHtml = sanitizeHtml(rendered, safeHtmlOptions);
const metadata = normalizeMetadata(
parsed.data,
options.language ?? "zh-CN",
extractFirstHeading(parsed.content)
);
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,
articleHtml,
bodyHtml,
metadata,
features: detectFeatures(bodyHtml),
warnings
};
}
function collectImageSources(tokens: readonly Token[], sources: Set<string>) {
for (const token of tokens) {
if (token.type === "image") {
const source = token.attrGet("src");
if (source) {
sources.add(source);
}
}
if (token.children) {
collectImageSources(token.children, sources);
}
}
}
export function extractMarkdownImageSources(source: string) {
const parsed = parseFrontMatter(source);
const sources = new Set<string>();
collectImageSources(markdown.parse(parsed.content, {}), sources);
return [...sources];
}
function parseFrontMatter(source: string) {
try {
return matter(source);
} catch (error) {
const message = error instanceof Error ? error.message : "未知错误";
throw new MarkdownDocumentParseError(
`无法解析 Markdown Front Matter${message}`,
{ cause: error }
);
}
}
function normalizeMetadata(
data: Record<string, unknown>,
fallbackLanguage: string,
fallbackTitle: string
): MarkdownDocumentMetadata {
return {
title: readString(data.title) || fallbackTitle,
author: readString(data.author),
subject: readString(data.subject) || readString(data.description),
keywords: readStringList(data.keywords ?? data.tags),
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();
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
return "";
}
function readStringList(value: unknown): string[] {
if (Array.isArray(value)) {
return value.map(readString).filter(Boolean).slice(0, 30);
}
if (typeof value === "string") {
return value
.split(",")
.map((item) => item.trim())
.filter(Boolean)
.slice(0, 30);
}
return [];
}
function extractFirstHeading(source: string): string {
const heading = source.match(/^#\s+(.+?)\s*#*\s*$/mu);
return heading?.[1]?.trim() ?? "";
}
function detectFeatures(html: string): ThemeFeature[] {
const featureChecks: Array<[ThemeFeature, RegExp]> = [
["code", /<pre\b/u],
["table", /<table\b/u],
["task-list", /\bmd-task-list-item\b/u],
["footnote", /\bfootnote/u],
["katex", /\bkatex\b/u],
["mermaid", /\bmermaid\b/u],
["echarts", /\bmd-echarts\b/u]
];
return featureChecks
.filter(([, pattern]) => pattern.test(html))
.map(([feature]) => feature);
}
function renderFallbackFence(
tokens: Token[],
index: number,
renderer: Renderer
): string {
const token = tokens[index];
if (!token) {
return "";
}
return `<pre${renderer.renderAttrs(token)}><code>${markdown.utils.escapeHtml(token.content)}</code></pre>\n`;
}
function escapeAttribute(value: string): string {
return markdown.utils.escapeHtml(value);
}