新增共享 Preview Engine,统一 Web 连续预览、快速分页、Playwright PDF 与 Electron PDF;实现稳定前缀复用和修改位置后的增量分页,保留媒体块按文档顺序串行回填与单次重排。 完善跨端链接与桌面文档工作流:Web 受控处理锚点和 HTTP/HTTPS 外链;Desktop 支持本地路径、file URI、系统协议、多窗口、同文件单例、Markdown 当前或新窗口打开,以及聚焦时外部文件变化提示。 统一四套内置主题名称并默认使用 Typora Github;修复连续预览双滚动条、ECharts 尺寸、PDF 本地链接、围栏代码块 Typora DOM 与重复行内样式;桌面发行链强制完整重建内嵌 Web,避免安装包携带陈旧资源。 发布 Web/Compose 与 Windows NSIS/ZIP:镜像 yixiong/md-to-pdf:v0.5.0 已健康部署;NSIS SHA-256 为 60992D1FDCA513F46346C78478537EB4159D8C0E76B41ECF3CDC25BE77707D92,ZIP SHA-256 为 D74F82293FB67126E583546CBA894569EFC9A0B1B6343FAC648CCC95F1D188D8,本机安装版已升级至 v0.5.0。 验证:全项目 238 项测试通过,类型检查、生产构建和 git diff --check 通过;Web 快速/连续/精确预览、Compose、Desktop 多窗口、窗口状态、文件关联、链接与代码块均完成真实环境验收。
478 lines
12 KiB
TypeScript
478 lines
12 KiB
TypeScript
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 {
|
||
MarkdownDocumentMetadata,
|
||
RenderedMarkdownDocument,
|
||
ThemeFeature
|
||
} from "@md-to-pdf/core";
|
||
import {
|
||
classifyDocumentLink,
|
||
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";
|
||
|
||
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 articleHtml = `<article id="write" class="markdown-body" lang="${escapeAttribute(metadata.language)}">${bodyHtml}</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
|
||
};
|
||
}
|
||
|
||
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);
|
||
}
|