fix: 完善本地与网络图片渲染
This commit is contained in:
@@ -34,6 +34,8 @@ export type { MarkdownDocumentMetadata } from "@md-to-pdf/core";
|
||||
|
||||
export interface RenderMarkdownOptions {
|
||||
language?: string;
|
||||
imageSourceMap?: ReadonlyMap<string, string>;
|
||||
warnings?: readonly string[];
|
||||
}
|
||||
|
||||
export interface RenderedMarkdown
|
||||
@@ -84,9 +86,83 @@ const markdown = new MarkdownIt(markdownOptions)
|
||||
});
|
||||
|
||||
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,
|
||||
@@ -114,6 +190,46 @@ markdown.renderer.rules.fence = (
|
||||
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
|
||||
@@ -193,10 +309,11 @@ export function renderMarkdown(
|
||||
source: string,
|
||||
options: RenderMarkdownOptions = {}
|
||||
): RenderedMarkdown {
|
||||
const warnings: string[] = [];
|
||||
const warnings = [...(options.warnings ?? [])];
|
||||
const parsed = parseFrontMatter(source);
|
||||
const environment = {
|
||||
warnings
|
||||
warnings,
|
||||
imageSourceMap: options.imageSourceMap
|
||||
};
|
||||
const rendered = markdown.render(parsed.content, environment);
|
||||
const bodyHtml = sanitizeHtml(rendered, safeHtmlOptions);
|
||||
@@ -217,6 +334,27 @@ export function renderMarkdown(
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
extractMarkdownImageSources,
|
||||
MarkdownDocumentParseError,
|
||||
renderMarkdown
|
||||
} from "../src/render-markdown.js";
|
||||
@@ -210,6 +211,55 @@ option:
|
||||
expect(result.bodyHtml).toContain("<script>");
|
||||
});
|
||||
|
||||
it("提取图片引用并使用受控资源地址替换", () => {
|
||||
const source =
|
||||
"\n\n";
|
||||
expect(extractMarkdownImageSources(source)).toEqual([
|
||||
"./%E6%96%87%E6%A1%A3.assets/a%20b.png",
|
||||
"https://example.com/a.png"
|
||||
]);
|
||||
|
||||
const result = renderMarkdown(source, {
|
||||
imageSourceMap: new Map([
|
||||
[
|
||||
"./%E6%96%87%E6%A1%A3.assets/a%20b.png",
|
||||
"data:image/png;base64,iVBORw0KGgo="
|
||||
]
|
||||
]),
|
||||
warnings: ["远程图片下载失败"]
|
||||
});
|
||||
|
||||
expect(result.bodyHtml).toContain(
|
||||
'class="md-document-image"'
|
||||
);
|
||||
expect(result.bodyHtml).toContain(
|
||||
'<figure class="md-document-image-block">'
|
||||
);
|
||||
expect(result.bodyHtml).toContain(
|
||||
'<figcaption class="md-document-image-caption">本地</figcaption>'
|
||||
);
|
||||
expect(result.bodyHtml).toContain(
|
||||
'src="data:image/png;base64,iVBORw0KGgo="'
|
||||
);
|
||||
expect(result.warnings).toContain("远程图片下载失败");
|
||||
});
|
||||
|
||||
it("只为独立图片块生成可见标题,行内图片保持段落语义", () => {
|
||||
const result = renderMarkdown(
|
||||
"文字  继续\n\n"
|
||||
);
|
||||
|
||||
expect(result.bodyHtml).toContain(
|
||||
'<p>文字 <img src="inline.png" alt="行内图" class="md-document-image" /> 继续</p>'
|
||||
);
|
||||
expect(result.bodyHtml).not.toContain(
|
||||
"md-document-image-caption\">行内图"
|
||||
);
|
||||
expect(result.bodyHtml).toContain(
|
||||
'<figcaption class="md-document-image-caption">独立标题</figcaption>'
|
||||
);
|
||||
});
|
||||
|
||||
it("为重复中文标题生成唯一锚点", () => {
|
||||
const result = renderMarkdown(`
|
||||
# 章节
|
||||
|
||||
Reference in New Issue
Block a user