fix: 完善本地与网络图片渲染
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
}
|
||||
@@ -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:
|
||||
""
|
||||
},
|
||||
{ localRoot: assetRoot }
|
||||
);
|
||||
const uploaded = await service.render({
|
||||
markdown: "",
|
||||
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(
|
||||
"\n",
|
||||
undefined
|
||||
);
|
||||
const failed = await resolve(
|
||||
"",
|
||||
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("网络不可用");
|
||||
});
|
||||
});
|
||||
@@ -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