fix: 完善本地与网络图片渲染

This commit is contained in:
SkyJourney
2026-07-27 22:12:16 +08:00
parent 06b30d084e
commit 32a37293d0
31 changed files with 1204 additions and 71 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@md-to-pdf/application",
"version": "0.4.0",
"version": "0.4.1",
"private": true,
"type": "module",
"main": "./dist/index.js",
@@ -6,12 +6,18 @@ import {
createThemeRegistry,
type ThemeRegistryOptions
} from "./theme-registry.js";
import {
createImageResourceResolver,
type ImageResolutionContext,
type ImageResourceResolverOptions
} from "./image-resources.js";
export const MAXIMUM_MARKDOWN_LENGTH = 1_500_000;
export interface MarkdownRenderRequest {
markdown?: unknown;
language?: unknown;
resources?: unknown;
}
export class ApplicationRequestError extends Error {
@@ -25,15 +31,21 @@ export class ApplicationRequestError extends Error {
}
}
export interface ApplicationServiceOptions extends ThemeRegistryOptions {}
export interface ApplicationServiceOptions
extends ThemeRegistryOptions,
ImageResourceResolverOptions {}
export function createApplicationService(
options: ApplicationServiceOptions
) {
const themes = createThemeRegistry(options);
const resolveImages = createImageResourceResolver(options);
function render(request: MarkdownRenderRequest) {
const { markdown, language } = request;
async function render(
request: MarkdownRenderRequest,
context: ImageResolutionContext = {}
) {
const { markdown, language, resources } = request;
if (typeof markdown !== "string") {
throw new ApplicationRequestError(
400,
@@ -57,8 +69,11 @@ export function createApplicationService(
}
try {
const images = await resolveImages(markdown, resources, context);
return renderMarkdown(markdown, {
...(language ? { language } : {})
...(language ? { language } : {}),
imageSourceMap: images.sources,
warnings: images.warnings
});
} catch (error) {
if (error instanceof MarkdownDocumentParseError) {
@@ -68,6 +83,16 @@ export function createApplicationService(
error.message
);
}
if (error instanceof Error) {
const tooLarge = /|/u.test(error.message);
throw new ApplicationRequestError(
tooLarge ? 413 : 400,
tooLarge
? "IMAGE_RESOURCES_TOO_LARGE"
: "INVALID_IMAGE_RESOURCES",
error.message
);
}
throw error;
}
}
+440
View File
@@ -0,0 +1,440 @@
import { lookup } from "node:dns/promises";
import { readFile, realpath, stat } from "node:fs/promises";
import { isIP } from "node:net";
import path from "node:path";
import { extractMarkdownImageSources } from "@md-to-pdf/renderer";
export const MAXIMUM_IMAGE_COUNT = 50;
export const MAXIMUM_IMAGE_BYTES = 8 * 1024 * 1024;
export const MAXIMUM_TOTAL_IMAGE_BYTES = 15 * 1024 * 1024;
export const REMOTE_IMAGE_TIMEOUT_MS = 10_000;
export const MAXIMUM_REMOTE_REDIRECTS = 3;
const failedImageDataUrl = `data:image/svg+xml;base64,${Buffer.from(
`<svg xmlns="http://www.w3.org/2000/svg" width="640" height="120" viewBox="0 0 640 120"><rect width="640" height="120" fill="#f8fafc"/><rect x="1" y="1" width="638" height="118" fill="none" stroke="#cbd5e1"/><text x="320" y="67" text-anchor="middle" font-family="sans-serif" font-size="18" fill="#64748b">图片加载失败</text></svg>`
).toString("base64")}`;
export interface MarkdownImageResource {
path: string;
contentType?: string;
data: string;
}
export interface ImageResolutionContext {
localRoot?: string;
}
export interface ResolvedMarkdownImages {
sources: ReadonlyMap<string, string>;
warnings: string[];
}
export interface ImageResourceResolverOptions {
remoteLoader?: (url: string) => Promise<LoadedImage>;
}
export interface LoadedImage {
content: Buffer;
contentType: string;
}
function isPrivateIpv4(
address: string,
allowProxyBenchmarkRange = false
) {
const parts = address.split(".").map(Number);
const [first, second] = parts;
if (
parts.length !== 4 ||
parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255) ||
first === undefined ||
second === undefined
) {
return true;
}
return (
first === 0 ||
first === 10 ||
first === 127 ||
(first === 100 && second >= 64 && second <= 127) ||
(first === 169 && second === 254) ||
(first === 172 && second >= 16 && second <= 31) ||
(first === 192 && second === 168) ||
(!allowProxyBenchmarkRange &&
first === 198 &&
(second === 18 || second === 19)) ||
first >= 224
);
}
function isPrivateIpAddress(
address: string,
allowProxyBenchmarkRange = false
) {
const normalized = address.toLowerCase();
if (isIP(normalized) === 4) {
return isPrivateIpv4(normalized, allowProxyBenchmarkRange);
}
if (isIP(normalized) !== 6) {
return true;
}
if (
normalized === "::" ||
normalized === "::1" ||
normalized.startsWith("fc") ||
normalized.startsWith("fd") ||
/^fe[89ab]/u.test(normalized)
) {
return true;
}
const mapped = normalized.match(/::ffff:(\d+\.\d+\.\d+\.\d+)$/u)?.[1];
return mapped
? isPrivateIpv4(mapped, allowProxyBenchmarkRange)
: false;
}
async function assertPublicRemoteUrl(url: URL) {
if (!["http:", "https:"].includes(url.protocol)) {
throw new Error("仅支持 HTTP/HTTPS 图片");
}
if (
url.username ||
url.password ||
url.hostname.toLowerCase() === "localhost"
) {
throw new Error("图片地址包含不允许的认证或本地主机");
}
if (isIP(url.hostname)) {
if (isPrivateIpAddress(url.hostname)) {
throw new Error("图片地址指向非公网 IP");
}
return;
}
const addresses = await lookup(url.hostname, {
all: true,
verbatim: true
});
if (
addresses.length === 0 ||
addresses.some(({ address }) => isPrivateIpAddress(address, true))
) {
throw new Error("图片域名解析到非公网 IP");
}
}
function detectImageContentType(content: Buffer) {
if (
content.length >= 8 &&
content.subarray(0, 8).equals(
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
)
) {
return "image/png";
}
if (
content.length >= 3 &&
content[0] === 0xff &&
content[1] === 0xd8 &&
content[2] === 0xff
) {
return "image/jpeg";
}
const prefix = content.subarray(0, 12).toString("ascii");
if (prefix.startsWith("GIF87a") || prefix.startsWith("GIF89a")) {
return "image/gif";
}
if (prefix.startsWith("RIFF") && prefix.endsWith("WEBP")) {
return "image/webp";
}
if (
content.length >= 12 &&
content.subarray(4, 12).toString("ascii").startsWith("ftyp") &&
/(?:avif|avis)/u.test(content.subarray(8, 32).toString("ascii"))
) {
return "image/avif";
}
const textPrefix = content.subarray(0, Math.min(content.length, 4096))
.toString("utf8")
.replace(/^\uFEFF/u, "")
.trimStart();
if (
/^(?:<\?xml[\s\S]*?\?>\s*)?<svg[\s>]/iu.test(textPrefix)
) {
const svg = content.toString("utf8");
if (
/<script\b|<foreignObject\b|\son\w+\s*=|(?:href|src)\s*=\s*["'](?:https?:|\/\/)/iu.test(
svg
)
) {
throw new Error("SVG 包含不安全的脚本或外部资源");
}
return "image/svg+xml";
}
throw new Error("资源不是受支持的图片格式");
}
function imageToDataUrl(image: LoadedImage) {
return `data:${image.contentType};base64,${image.content.toString("base64")}`;
}
async function readResponseBody(response: Response) {
const declaredLength = Number(response.headers.get("content-length"));
if (
Number.isFinite(declaredLength) &&
declaredLength > MAXIMUM_IMAGE_BYTES
) {
throw new Error("远程图片超过单文件大小限制");
}
if (!response.body) {
throw new Error("远程图片响应为空");
}
const chunks: Buffer[] = [];
let total = 0;
const reader = response.body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
total += value.byteLength;
if (total > MAXIMUM_IMAGE_BYTES) {
await reader.cancel();
throw new Error("远程图片超过单文件大小限制");
}
chunks.push(Buffer.from(value));
}
return Buffer.concat(chunks, total);
}
export async function downloadRemoteImage(
source: string,
fetcher: typeof fetch = fetch
): Promise<LoadedImage> {
const controller = new AbortController();
const timeout = setTimeout(
() => controller.abort(),
REMOTE_IMAGE_TIMEOUT_MS
);
try {
let current = new URL(source);
for (
let redirectCount = 0;
redirectCount <= MAXIMUM_REMOTE_REDIRECTS;
redirectCount += 1
) {
await assertPublicRemoteUrl(current);
const response = await fetcher(current, {
redirect: "manual",
signal: controller.signal,
headers: {
accept: "image/avif,image/webp,image/png,image/jpeg,image/gif,image/svg+xml",
"user-agent": "md-to-pdf/0.4.1"
}
});
if (
response.status >= 300 &&
response.status < 400 &&
response.headers.has("location")
) {
if (redirectCount === MAXIMUM_REMOTE_REDIRECTS) {
throw new Error("远程图片重定向次数过多");
}
current = new URL(response.headers.get("location")!, current);
continue;
}
if (!response.ok) {
throw new Error(`远程图片返回 HTTP ${response.status}`);
}
const content = await readResponseBody(response);
return {
content,
contentType: detectImageContentType(content)
};
}
throw new Error("远程图片重定向次数过多");
} finally {
clearTimeout(timeout);
}
}
export function normalizeDocumentAssetPath(source: string) {
const pathOnly = source.split(/[?#]/u, 1)[0] ?? "";
let decoded: string;
try {
decoded = decodeURIComponent(pathOnly);
} catch {
throw new Error("图片路径 URL 编码无效");
}
const normalizedSeparators = decoded.replace(/\\/gu, "/");
if (
normalizedSeparators.startsWith("/") ||
/^[a-z]:\//iu.test(normalizedSeparators) ||
normalizedSeparators.includes("\0")
) {
throw new Error("不允许绝对图片路径");
}
const segments = normalizedSeparators
.split("/")
.filter((segment) => segment && segment !== ".");
if (segments.length === 0 || segments.includes("..")) {
throw new Error("图片路径为空或越过文档目录");
}
return segments.join("/");
}
function isContainedPath(root: string, target: string) {
const relative = path.relative(root, target);
return (
relative !== ".." &&
!relative.startsWith(`..${path.sep}`) &&
!path.isAbsolute(relative)
);
}
async function loadLocalImage(root: string, source: string) {
const normalized = normalizeDocumentAssetPath(source);
const realRoot = await realpath(root);
const target = path.resolve(realRoot, ...normalized.split("/"));
const realTarget = await realpath(target);
if (!isContainedPath(realRoot, realTarget)) {
throw new Error("图片路径越过文档目录");
}
const targetStat = await stat(realTarget);
if (!targetStat.isFile()) {
throw new Error("图片资源不是文件");
}
if (targetStat.size > MAXIMUM_IMAGE_BYTES) {
throw new Error("本地图片超过单文件大小限制");
}
const content = await readFile(realTarget);
return {
content,
contentType: detectImageContentType(content)
};
}
function parseUploadedResources(value: unknown) {
if (value === undefined) {
return new Map<string, LoadedImage>();
}
if (!Array.isArray(value)) {
throw new Error("图片素材必须是数组");
}
if (value.length > MAXIMUM_IMAGE_COUNT) {
throw new Error(`图片数量不能超过 ${MAXIMUM_IMAGE_COUNT}`);
}
const result = new Map<string, LoadedImage>();
let totalBytes = 0;
for (const item of value) {
if (
typeof item !== "object" ||
item === null ||
!("path" in item) ||
!("data" in item) ||
typeof item.path !== "string" ||
typeof item.data !== "string" ||
item.data.length > Math.ceil(MAXIMUM_IMAGE_BYTES * 4 / 3) + 16
) {
throw new Error("图片素材参数无效");
}
const normalized = normalizeDocumentAssetPath(item.path);
if (result.has(normalized)) {
throw new Error(`图片素材路径重复:${normalized}`);
}
const content = Buffer.from(item.data, "base64");
if (content.length === 0 || content.length > MAXIMUM_IMAGE_BYTES) {
throw new Error(`图片素材大小无效:${normalized}`);
}
totalBytes += content.length;
if (totalBytes > MAXIMUM_TOTAL_IMAGE_BYTES) {
throw new Error("图片素材总大小超过限制");
}
result.set(normalized, {
content,
contentType: detectImageContentType(content)
});
}
return result;
}
export function createImageResourceResolver(
options: ImageResourceResolverOptions = {}
) {
const remoteLoader = options.remoteLoader ?? downloadRemoteImage;
const remoteCache = new Map<
string,
{ expiresAt: number; promise: Promise<LoadedImage> }
>();
function loadRemote(source: string) {
const cached = remoteCache.get(source);
if (cached && cached.expiresAt > Date.now()) {
return cached.promise;
}
const promise = remoteLoader(source);
remoteCache.set(source, {
expiresAt: Date.now() + 5 * 60_000,
promise
});
if (remoteCache.size > 100) {
const oldest = remoteCache.keys().next().value as string | undefined;
if (oldest) {
remoteCache.delete(oldest);
}
}
void promise.catch(() => {
if (remoteCache.get(source)?.promise === promise) {
remoteCache.delete(source);
}
});
return promise;
}
return async function resolve(
markdown: string,
resources: unknown,
context: ImageResolutionContext = {}
): Promise<ResolvedMarkdownImages> {
const references = extractMarkdownImageSources(markdown);
if (references.length > MAXIMUM_IMAGE_COUNT) {
throw new Error(`图片引用数量不能超过 ${MAXIMUM_IMAGE_COUNT}`);
}
const uploaded = parseUploadedResources(resources);
const sources = new Map<string, string>();
const warnings: string[] = [];
let resolvedBytes = 0;
for (const source of references) {
if (source.startsWith("data:")) {
continue;
}
try {
let image: LoadedImage;
if (/^https?:\/\//iu.test(source)) {
image = await loadRemote(source);
} else {
const normalized = normalizeDocumentAssetPath(source);
const uploadedImage = uploaded.get(normalized);
if (uploadedImage) {
image = uploadedImage;
} else if (context.localRoot) {
image = await loadLocalImage(context.localRoot, source);
} else {
throw new Error("未提供对应素材目录");
}
}
resolvedBytes += image.content.length;
if (resolvedBytes > MAXIMUM_TOTAL_IMAGE_BYTES) {
throw new Error("文档图片总大小超过限制");
}
sources.set(source, imageToDataUrl(image));
} catch (error) {
const message =
error instanceof Error ? error.message : "未知错误";
warnings.push(`图片 ${source} 加载失败:${message}`);
sources.set(source, failedImageDataUrl);
}
}
return { sources, warnings };
};
}
+14
View File
@@ -6,6 +6,20 @@ export {
type ApplicationServiceOptions,
type MarkdownRenderRequest
} from "./application-service.js";
export {
MAXIMUM_IMAGE_BYTES,
MAXIMUM_IMAGE_COUNT,
MAXIMUM_TOTAL_IMAGE_BYTES,
REMOTE_IMAGE_TIMEOUT_MS,
createImageResourceResolver,
downloadRemoteImage,
normalizeDocumentAssetPath,
type ImageResolutionContext,
type ImageResourceResolverOptions,
type LoadedImage,
type MarkdownImageResource,
type ResolvedMarkdownImages
} from "./image-resources.js";
export {
createThemeRegistry,
type ThemeRecord,
@@ -80,7 +80,7 @@ describe("共享应用服务", () => {
const roots = await createThemeFixture();
const service = createApplicationService(roots);
const document = service.render({
const document = await service.render({
markdown: "# 文档\n\n<script>alert('xss')</script>",
language: "zh-CN"
});
@@ -94,7 +94,7 @@ describe("共享应用服务", () => {
const roots = await createThemeFixture();
const service = createApplicationService(roots);
expect(() => service.render({ markdown: 42 })).toThrow(
await expect(service.render({ markdown: 42 })).rejects.toEqual(
expect.objectContaining<ApplicationRequestError>({
statusCode: 400,
code: "INVALID_MARKDOWN"
@@ -102,6 +102,39 @@ describe("共享应用服务", () => {
);
});
it("解析上传素材与桌面文档目录中的相对图片", async () => {
const roots = await createThemeFixture();
const service = createApplicationService(roots);
const image = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a
]);
const assetRoot = join(temporaryDirectory!, "document");
await mkdir(join(assetRoot, "文档.assets"), { recursive: true });
await writeFile(join(assetRoot, "文档.assets", "本地.png"), image);
const local = await service.render(
{
markdown:
"![本地](./%E6%96%87%E6%A1%A3.assets/%E6%9C%AC%E5%9C%B0.png)"
},
{ localRoot: assetRoot }
);
const uploaded = await service.render({
markdown: "![网页](文档.assets/网页.png)",
resources: [
{
path: "文档.assets/网页.png",
data: image.toString("base64")
}
]
});
expect(local.articleHtml).toContain("data:image/png;base64,");
expect(uploaded.articleHtml).toContain("data:image/png;base64,");
expect(local.articleHtml).toContain("md-document-image");
expect(uploaded.warnings).toEqual([]);
});
it("列出主题并使用调用方提供的资源 URL", async () => {
const roots = await createThemeFixture();
const service = createApplicationService({
@@ -0,0 +1,62 @@
import { describe, expect, it, vi } from "vitest";
import {
createImageResourceResolver,
downloadRemoteImage,
normalizeDocumentAssetPath
} from "../src/index.js";
const png = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a
]);
describe("Markdown 图片资源安全", () => {
it("解码合法相对路径并拒绝越界与绝对路径", () => {
expect(
normalizeDocumentAssetPath(
"./%E6%96%87%E6%A1%A3.assets/a%20b.png?raw=1"
)
).toBe("文档.assets/a b.png");
expect(() => normalizeDocumentAssetPath("../secret.png")).toThrow(
"越过文档目录"
);
expect(() => normalizeDocumentAssetPath("C:/secret.png")).toThrow(
"绝对图片路径"
);
});
it("在发起请求前拒绝直接指向本机的远程地址", async () => {
const fetcher = vi.fn<typeof fetch>();
await expect(
downloadRemoteImage("http://127.0.0.1/private.png", fetcher)
).rejects.toThrow("非公网 IP");
expect(fetcher).not.toHaveBeenCalled();
});
it("缓存成功的远程图片并把失败转换为可诊断占位", async () => {
const remoteLoader = vi
.fn()
.mockResolvedValueOnce({
content: png,
contentType: "image/png"
})
.mockRejectedValueOnce(new Error("网络不可用"));
const resolve = createImageResourceResolver({ remoteLoader });
const first = await resolve(
"![a](https://example.com/a.png)\n![a2](https://example.com/a.png)",
undefined
);
const failed = await resolve(
"![b](https://example.com/b.png)",
undefined
);
expect(remoteLoader).toHaveBeenCalledTimes(2);
expect(first.sources.get("https://example.com/a.png")).toMatch(
/^data:image\/png;base64,/u
);
expect(failed.sources.get("https://example.com/b.png")).toMatch(
/^data:image\/svg\+xml;base64,/u
);
expect(failed.warnings[0]).toContain("网络不可用");
});
});
+140 -2
View File
@@ -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("&lt;script&gt;");
});
it("提取图片引用并使用受控资源地址替换", () => {
const source =
"![本地](./文档.assets/a%20b.png)\n\n![远程](https://example.com/a.png)";
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(
"文字 ![行内图](inline.png) 继续\n\n![独立标题](block.png)"
);
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(`
# 章节