diff --git a/frontend/package.json b/frontend/package.json index 5b6a144e..112a8761 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -69,6 +69,7 @@ "gsap": "^3.13.0", "hast": "^1.0.0", "html2pdf.js": "^0.14.0", + "jszip": "^3.10.1", "katex": "^0.16.28", "lucide-react": "^0.562.0", "marked": "^17.0.5", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 63ab0766..a2baa23d 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -155,6 +155,9 @@ importers: html2pdf.js: specifier: ^0.14.0 version: 0.14.0 + jszip: + specifier: ^3.10.1 + version: 3.10.1 katex: specifier: ^0.16.28 version: 0.16.28 diff --git a/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx b/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx index 47577084..8554fb6f 100644 --- a/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx +++ b/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx @@ -1,3 +1,4 @@ +import JSZip from "jszip"; import { DownloadIcon, FileTextIcon, @@ -126,6 +127,8 @@ export function ArtifactFileDetail({ }); const displayContent = content ?? ""; + const [isPackagingMarkdownBundle, setIsPackagingMarkdownBundle] = + useState(false); const artifactOptions = useMemo(() => { return (artifacts ?? []).map((artifactPath) => ({ @@ -148,19 +151,114 @@ export function ArtifactFileDetail({ }, }); + const resolveMarkdownAssetUrlForDownload = useCallback( + (rawPath: string): string | null => { + const normalizedRef = normalizeReference(rawPath); + if (!normalizedRef) return null; + if (isExternalReference(normalizedRef)) return normalizedRef; + + if (normalizedRef.startsWith("/mnt/user-data/")) { + return urlOfArtifact({ filepath: normalizedRef, threadId }); + } + if (normalizedRef.startsWith("mnt/user-data/")) { + return urlOfArtifact({ filepath: `/${normalizedRef}`, threadId }); + } + + const resolvedVirtualPath = resolveReferencedVirtualPath( + normalizedRef, + filepath, + ); + if (!resolvedVirtualPath) return null; + return urlOfArtifact({ filepath: resolvedVirtualPath, threadId }); + }, + [filepath, threadId], + ); + // 下载为 DOCX const handleDownloadDocx = useCallback(() => { if (content) { - void downloadAsDocx(content, fileName); + void downloadAsDocx(content, fileName, { + resolveAssetUrl: resolveMarkdownAssetUrlForDownload, + }); } - }, [content, fileName, downloadAsDocx]); + }, [content, fileName, downloadAsDocx, resolveMarkdownAssetUrlForDownload]); // 下载为 PDF const handleDownloadPdf = useCallback(() => { if (content) { - void downloadAsPdf(content, fileName); + void downloadAsPdf(content, fileName, { + resolveAssetUrl: resolveMarkdownAssetUrlForDownload, + }); } - }, [content, fileName, downloadAsPdf]); + }, [content, fileName, downloadAsPdf, resolveMarkdownAssetUrlForDownload]); + + const handleDownloadMarkdownBundle = useCallback(async () => { + if (!threadId || !content) return; + setIsPackagingMarkdownBundle(true); + try { + const zip = new JSZip(); + const markdownEntryPath = toWorkspaceRelativePath(filepath) ?? fileName; + const referencedTargets = collectMarkdownAssetTargets(content); + const refToVirtualPath = new Map(); + + for (const ref of referencedTargets) { + const resolved = resolveReferencedVirtualPath(ref, filepath); + if (resolved) { + refToVirtualPath.set(ref, resolved); + } + } + + const refToRelativeZipPath = new Map(); + const addedVirtualPaths = new Set(); + + for (const [ref, virtualPath] of refToVirtualPath) { + const artifactEntryPath = toWorkspaceRelativePath(virtualPath); + if (!artifactEntryPath) continue; + + const relativeFromMarkdown = toRelativePath( + dirnamePosix(markdownEntryPath), + artifactEntryPath, + ); + refToRelativeZipPath.set(ref, relativeFromMarkdown || getFileName(artifactEntryPath)); + + if (addedVirtualPaths.has(virtualPath)) continue; + addedVirtualPaths.add(virtualPath); + + const response = await fetch( + urlOfArtifact({ + filepath: virtualPath, + threadId, + }), + ); + if (!response.ok) { + continue; + } + const data = await response.arrayBuffer(); + zip.file(artifactEntryPath, data); + } + + const rewrittenMarkdown = rewriteMarkdownLinksForBundle( + content, + refToRelativeZipPath, + ); + zip.file(markdownEntryPath, rewrittenMarkdown); + + const zipBlob = await zip.generateAsync({ type: "blob" }); + const zipName = `${fileName.replace(/\.md$/i, "") || "document"}.zip`; + const url = URL.createObjectURL(zipBlob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = zipName; + anchor.click(); + URL.revokeObjectURL(url); + toast.success(t.common.exportSuccess); + } catch (error) { + console.error("Failed to package markdown bundle:", error); + toast.error("Failed to package markdown with referenced files."); + } finally { + setIsPackagingMarkdownBundle(false); + } + }, [threadId, content, filepath, fileName, t.common.exportSuccess]); // 全屏切换处理 const handleFullscreenToggle = useCallback(() => { @@ -188,8 +286,8 @@ export function ArtifactFileDetail({ className, )} > - -
+ +
{previewable && ( )} - {/* 放大缩小选择器 */} - + {/* 仅在代码视图显示缩放控制 */} + {isCodeFile && viewMode === "code" && ( + + )}
-
+
{isWriteFile ? (
@@ -266,7 +366,7 @@ export function ArtifactFileDetail({ )}
-
+
{isCodeFile && ( - - - + {isPackagingMarkdownBundle ? ( + + ) : ( + + )} {t.common.downloadOriginal} - - + + ) : ( + + + + {t.common.downloadOriginal} + + + )} {/* DOCX 和 PDF 导出选项仅对 Markdown 文件显示。 */} {canConvertToDocxPdf && ( <> @@ -478,6 +593,7 @@ export function ArtifactFileDetail({ language={language ?? "text"} zoom={zoom} threadId={threadId} + filepath={filepath} /> )} @@ -514,21 +630,146 @@ export function ArtifactFileDetail({ ); } +const USER_DATA_PREFIX = "/mnt/user-data/"; + +function normalizePosixPath(path: string): string { + const isAbs = path.startsWith("/"); + const parts = path.split("/").filter((part) => part.length > 0); + const stack: string[] = []; + for (const part of parts) { + if (part === ".") continue; + if (part === "..") { + if (stack.length > 0) stack.pop(); + continue; + } + stack.push(part); + } + return `${isAbs ? "/" : ""}${stack.join("/")}`; +} + +function dirnamePosix(path: string): string { + const normalized = normalizePosixPath(path); + const index = normalized.lastIndexOf("/"); + if (index <= 0) return ""; + return normalized.slice(0, index); +} + +function toWorkspaceRelativePath(virtualPath: string): string | null { + const normalized = normalizePosixPath(virtualPath); + if (!normalized.startsWith(USER_DATA_PREFIX)) return null; + return normalized.slice(USER_DATA_PREFIX.length) || "artifact"; +} + +function toRelativePath(fromDir: string, targetPath: string): string { + const from = normalizePosixPath(fromDir).split("/").filter(Boolean); + const to = normalizePosixPath(targetPath).split("/").filter(Boolean); + let i = 0; + while (i < from.length && i < to.length && from[i] === to[i]) { + i += 1; + } + const up = new Array(from.length - i).fill(".."); + const down = to.slice(i); + return [...up, ...down].join("/") || "."; +} + +function normalizeReference(ref: string): string { + const trimmed = ref.trim().replace(/^<|>$/g, ""); + return trimmed.split(/[ \t]/)[0] ?? ""; +} + +function isExternalReference(ref: string): boolean { + return ( + !ref || + ref.startsWith("#") || + ref.startsWith("//") || + /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(ref) + ); +} + +function resolveReferencedVirtualPath( + ref: string, + baseFilepath: string, +): string | null { + const normalizedRef = normalizeReference(ref); + if (isExternalReference(normalizedRef)) return null; + + let withoutHash = normalizedRef.split("#")[0] ?? normalizedRef; + withoutHash = withoutHash.split("?")[0] ?? withoutHash; + if (!withoutHash) return null; + + if (withoutHash.startsWith("/mnt/user-data/")) { + return normalizePosixPath(withoutHash); + } + if (withoutHash.startsWith("mnt/user-data/")) { + return normalizePosixPath(`/${withoutHash}`); + } + if (withoutHash.startsWith("/")) { + return null; + } + + const baseDir = dirnamePosix(baseFilepath); + const combined = normalizePosixPath(`${baseDir}/${withoutHash}`); + if (!combined.startsWith(USER_DATA_PREFIX)) return null; + return combined; +} + +function collectMarkdownAssetTargets(markdown: string): Set { + const targets = new Set(); + const markdownRefRegex = /!?\[[^\]]*\]\(([^)]+)\)/g; + const htmlAttrRegex = /<(?:img|a)\b[^>]*\b(?:src|href)\s*=\s*(["'])([^"']+)\1/gi; + + for (const match of markdown.matchAll(markdownRefRegex)) { + const raw = match[1]?.trim(); + if (raw) targets.add(raw); + } + for (const match of markdown.matchAll(htmlAttrRegex)) { + const raw = match[2]?.trim(); + if (raw) targets.add(raw); + } + return targets; +} + +function rewriteMarkdownLinksForBundle( + markdown: string, + refToRelativeZipPath: Map, +): string { + const rewriteTarget = (rawTarget: string): string => { + const normalized = normalizeReference(rawTarget); + return refToRelativeZipPath.get(normalized) ?? rawTarget; + }; + + const markdownRewritten = markdown.replace( + /(!?\[[^\]]*\]\()([^)]+)(\))/g, + (_full, prefix, target, suffix) => { + return `${prefix}${rewriteTarget(String(target))}${suffix}`; + }, + ); + + return markdownRewritten.replace( + /(<(?:img|a)\b[^>]*\b(?:src|href)\s*=\s*["'])([^"']+)(["'])/gi, + (_full, prefix, target, suffix) => { + return `${prefix}${rewriteTarget(String(target))}${suffix}`; + }, + ); +} + export function ArtifactFilePreview({ content, language, zoom = 100, threadId, + filepath, }: { content: string; language: string; zoom?: number; threadId: string; + filepath?: string; }) { const zoomScale = zoom / 100; const normalizedContent = useMemo(() => { - return rewriteArtifactImagePaths(content ?? "", threadId); - }, [content, threadId]); + return rewriteArtifactImagePaths(content ?? "", threadId, filepath); + }, [content, threadId, filepath]); if (language === "markdown") { return ( @@ -864,8 +1105,12 @@ function ArtifactPreviewFallback({ ); } -function rewriteArtifactImagePaths(content: string, threadId?: string) { - if (!threadId || !/\/?mnt\/user-data\//.test(content)) { +function rewriteArtifactImagePaths( + content: string, + threadId?: string, + baseFilepath?: string, +) { + if (!threadId) { return content; } @@ -873,6 +1118,20 @@ function rewriteArtifactImagePaths(content: string, threadId?: string) { const normalizedPath = rawPath.startsWith("/") ? rawPath : `/${rawPath}`; return resolveArtifactURL(normalizedPath, threadId); }; + const toArtifactUrlFromRelative = (rawPath: string) => { + const trimmed = rawPath.trim(); + if (!baseFilepath || !trimmed) return null; + if (trimmed.startsWith("/") || trimmed.startsWith("//")) return null; + if (trimmed.startsWith("#")) return null; + if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(trimmed)) return null; + + const baseDir = baseFilepath.replace(/[^/]*$/, ""); + if (!baseDir.startsWith("/")) return null; + + const absolutePath = new URL(trimmed, `file://${baseDir}`).pathname; + if (!absolutePath.startsWith("/mnt/user-data/")) return null; + return resolveArtifactURL(absolutePath, threadId); + }; const markdownRewritten = content.replace( /!\[([^\]]*)\]\(\s*(\/?mnt\/user-data\/outputs\/[^)\s]+)\s*\)/g, @@ -880,8 +1139,18 @@ function rewriteArtifactImagePaths(content: string, threadId?: string) { return `![${alt}](${toArtifactUrl(rawPath)})`; }, ); + const markdownRelativeRewritten = markdownRewritten.replace( + /!\[([^\]]*)\]\(\s*([^) \t]+)\s*\)/g, + (_full, alt, rawPath) => { + const absoluteUrl = toArtifactUrlFromRelative(rawPath); + if (!absoluteUrl) { + return `![${alt}](${rawPath})`; + } + return `![${alt}](${absoluteUrl})`; + }, + ); - const shorthandMarkdownRewritten = markdownRewritten.replace( + const shorthandMarkdownRewritten = markdownRelativeRewritten.replace( /!(?!\[)([^\n()()]+?)\s*[((]\s*(\/?mnt\/user-data\/outputs\/[^)\s)]+)\s*[))]/g, (_full, alt, rawPath) => { return `![${String(alt).trim()}](${toArtifactUrl(rawPath)})`; @@ -889,9 +1158,16 @@ function rewriteArtifactImagePaths(content: string, threadId?: string) { ); return shorthandMarkdownRewritten.replace( - /(]*\bsrc\s*=\s*)(["'])(\/?mnt\/user-data\/outputs\/[^"']+)\2/gi, + /(]*\bsrc\s*=\s*)(["'])([^"']+)\2/gi, (_full, prefix, quote, rawPath) => { - return `${prefix}${quote}${toArtifactUrl(rawPath)}${quote}`; + if (/^\/?mnt\/user-data\/outputs\//.test(rawPath)) { + return `${prefix}${quote}${toArtifactUrl(rawPath)}${quote}`; + } + const absoluteUrl = toArtifactUrlFromRelative(rawPath); + if (absoluteUrl) { + return `${prefix}${quote}${absoluteUrl}${quote}`; + } + return `${prefix}${quote}${rawPath}${quote}`; }, ); } diff --git a/frontend/src/core/utils/markdown-download/converter.ts b/frontend/src/core/utils/markdown-download/converter.ts index df81bbb2..2c99438d 100644 --- a/frontend/src/core/utils/markdown-download/converter.ts +++ b/frontend/src/core/utils/markdown-download/converter.ts @@ -4,6 +4,8 @@ import { Paragraph, TextRun, HeadingLevel, + ImageRun, + type ParagraphChild, } from "docx"; import { marked } from "marked"; @@ -57,6 +59,10 @@ export interface DocxOptions { * @default 22 (11pt) */ codeFontSize?: number; + /** + * 解析 Markdown 里的资源路径(如图片相对路径) + */ + resolveAssetUrl?: (rawPath: string) => string | null | Promise; } // ============================================================================ @@ -80,10 +86,18 @@ export async function downloadMarkdownAsDocx( filename: string, options: DocxOptions = {}, ): Promise { - const { codeFont = "Courier New", codeFontSize = 22 } = options; + const { + codeFont = "Courier New", + codeFontSize = 22, + resolveAssetUrl, + } = options; const tokens = marked.lexer(markdown); - const children = parseTokensToDocx(tokens, { codeFont, codeFontSize }); + const children = await parseTokensToDocx(tokens, { + codeFont, + codeFontSize, + resolveAssetUrl, + }); const doc = new DocxDocument({ sections: [{ children }], @@ -112,7 +126,9 @@ export async function downloadMarkdownAsDocx( export async function downloadMarkdownAsPdf( markdown: string, filename: string, - options: PdfOptions = {}, + options: PdfOptions & { + resolveAssetUrl?: (rawPath: string) => string | null | Promise; + } = {}, ): Promise { const html2pdf = await loadHtml2Pdf(); @@ -121,10 +137,16 @@ export async function downloadMarkdownAsPdf( format = "a4", orientation = "portrait", scale = 2, + resolveAssetUrl, } = options; + const normalizedMarkdown = await rewriteMarkdownImageSources( + markdown, + resolveAssetUrl, + ); + // 解析 Markdown 为 HTML - const htmlContent = await marked.parse(markdown); + const htmlContent = await marked.parse(normalizedMarkdown); // 创建容器并应用样式 const container = createStyledContainer(htmlContent); @@ -309,16 +331,17 @@ function fixColorsForHtml2Canvas(clonedDoc: Document): void { /** * 解析 Markdown Token 为 DOCX Paragraph */ -function parseTokensToDocx( +async function parseTokensToDocx( tokens: MarkdownToken[], - options: Required, -): Paragraph[] { + options: Required> & + Pick, +): Promise { const paragraphs: Paragraph[] = []; for (const token of tokens) { switch (token.type) { case "heading": { - const runs = parseInlineTokens(token.tokens ?? [], options); + const runs = await parseInlineTokens(token.tokens ?? [], options); paragraphs.push( new Paragraph({ children: runs, @@ -330,7 +353,7 @@ function parseTokensToDocx( } case "paragraph": { - const runs = parseInlineTokens(token.tokens ?? [], options); + const runs = await parseInlineTokens(token.tokens ?? [], options); paragraphs.push( new Paragraph({ children: runs.length > 0 ? runs : [new TextRun("")], @@ -361,8 +384,8 @@ function parseTokensToDocx( } case "list": { - token.items?.forEach((item: MarkdownToken) => { - const runs = parseInlineTokens( + for (const item of token.items ?? []) { + const runs = await parseInlineTokens( item.tokens?.[0]?.tokens ?? [], options, ); @@ -373,12 +396,12 @@ function parseTokensToDocx( spacing: { after: 80 }, }), ); - }); + } break; } case "blockquote": { - const runs = parseInlineTokens( + const runs = await parseInlineTokens( token.tokens?.[0]?.tokens ?? [], options, ); @@ -407,6 +430,19 @@ function parseTokensToDocx( paragraphs.push(new Paragraph({ children: [] })); break; } + + case "image": { + const imageRun = await createImageRunFromToken(token, options); + if (imageRun) { + paragraphs.push( + new Paragraph({ + children: [imageRun], + spacing: { after: 200 }, + }), + ); + } + break; + } } } @@ -416,11 +452,12 @@ function parseTokensToDocx( /** * 解析行内 Token 为 TextRun */ -function parseInlineTokens( +async function parseInlineTokens( tokens: MarkdownToken[], - options: Required, -): TextRun[] { - const runs: TextRun[] = []; + options: Required> & + Pick, +): Promise { + const runs: ParagraphChild[] = []; for (const token of tokens) { switch (token.type) { @@ -460,6 +497,14 @@ function parseInlineTokens( runs.push(new TextRun({ text: "", break: 1 })); break; + case "image": { + const imageRun = await createImageRunFromToken(token, options); + if (imageRun) { + runs.push(imageRun); + } + break; + } + default: runs.push(new TextRun(token.raw ?? "")); } @@ -468,6 +513,155 @@ function parseInlineTokens( return runs; } +async function createImageRunFromToken( + token: MarkdownToken, + options: Pick, +): Promise { + const rawHref = String(token?.href ?? token?.text ?? "").trim(); + if (!rawHref) return null; + + const resolvedUrl = await resolveAssetReference(rawHref, options.resolveAssetUrl); + if (!resolvedUrl || !isRenderableImageUrl(resolvedUrl)) { + return null; + } + + try { + const response = await fetch(resolvedUrl); + if (!response.ok) { + return null; + } + const blob = await response.blob(); + const imageType = getDocxImageType(blob.type, resolvedUrl); + if (!imageType) { + return null; + } + const bytes = new Uint8Array(await blob.arrayBuffer()); + const { width, height } = await getImageDimensions(blob); + const maxWidth = 560; + const scale = width > maxWidth ? maxWidth / width : 1; + return new ImageRun({ + data: bytes, + type: imageType, + transformation: { + width: Math.max(1, Math.round(width * scale)), + height: Math.max(1, Math.round(height * scale)), + }, + }); + } catch { + return null; + } +} + +async function getImageDimensions( + blob: Blob, +): Promise<{ width: number; height: number }> { + return await new Promise((resolve) => { + const url = URL.createObjectURL(blob); + const img = new Image(); + img.onload = () => { + const width = img.naturalWidth || 1; + const height = img.naturalHeight || 1; + URL.revokeObjectURL(url); + resolve({ width, height }); + }; + img.onerror = () => { + URL.revokeObjectURL(url); + resolve({ width: 600, height: 400 }); + }; + img.src = url; + }); +} + +async function rewriteMarkdownImageSources( + markdown: string, + resolveAssetUrl?: (rawPath: string) => string | null | Promise, +): Promise { + if (!resolveAssetUrl) { + return markdown; + } + + let rewritten = markdown; + const markdownMatches = [...rewritten.matchAll(/!\[([^\]]*)\]\(([^)]+)\)/g)]; + for (const match of markdownMatches) { + const alt = match[1] ?? ""; + const rawTarget = match[2]?.trim() ?? ""; + const resolved = await resolveAssetReference(rawTarget, resolveAssetUrl); + if (!resolved || resolved === rawTarget) continue; + rewritten = rewritten.replace( + match[0], + `![${alt}](${resolved})`, + ); + } + + const htmlMatches = [...rewritten.matchAll(/(]*\bsrc\s*=\s*)(["'])([^"']+)\2/gi)]; + for (const match of htmlMatches) { + const rawTarget = match[3]?.trim() ?? ""; + const resolved = await resolveAssetReference(rawTarget, resolveAssetUrl); + if (!resolved || resolved === rawTarget) continue; + rewritten = rewritten.replace( + match[0], + `${match[1]}${match[2]}${resolved}${match[2]}`, + ); + } + + return rewritten; +} + +async function resolveAssetReference( + rawPath: string, + resolveAssetUrl?: (rawPath: string) => string | null | Promise, +): Promise { + const normalized = normalizeReference(rawPath); + if (!normalized) return null; + if (isExternalReference(normalized)) return normalized; + if (!resolveAssetUrl) return normalized; + return (await resolveAssetUrl(normalized)) ?? normalized; +} + +function normalizeReference(ref: string): string { + const trimmed = ref.trim().replace(/^<|>$/g, ""); + return trimmed.split(/[ \t]/)[0] ?? ""; +} + +function isExternalReference(ref: string): boolean { + return ( + !ref || + ref.startsWith("#") || + ref.startsWith("//") || + ref.startsWith("data:") || + ref.startsWith("blob:") || + /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(ref) + ); +} + +function isRenderableImageUrl(url: string): boolean { + if (url.startsWith("data:image/")) return true; + if (/\.(png|jpe?g|gif|webp|bmp|ico|avif|tiff?)([?#].*)?$/i.test(url)) + return true; + if (/^https?:\/\//i.test(url)) return true; + if (url.startsWith("/")) return true; + return false; +} + +function getDocxImageType( + mimeType: string, + src: string, +): "png" | "jpg" | "gif" | "bmp" { + const mime = mimeType.toLowerCase(); + if (mime.includes("png")) return "png"; + if (mime.includes("jpeg") || mime.includes("jpg")) return "jpg"; + if (mime.includes("gif")) return "gif"; + if (mime.includes("bmp")) return "bmp"; + + const lower = src.toLowerCase(); + if (lower.includes(".png")) return "png"; + if (lower.includes(".jpg") || lower.includes(".jpeg")) return "jpg"; + if (lower.includes(".gif")) return "gif"; + if (lower.includes(".bmp")) return "bmp"; + + return "png"; +} + /** * 获取标题级别 */ diff --git a/frontend/src/core/utils/markdown-download/use-markdown-download.ts b/frontend/src/core/utils/markdown-download/use-markdown-download.ts index a4f2fb28..423cadf2 100644 --- a/frontend/src/core/utils/markdown-download/use-markdown-download.ts +++ b/frontend/src/core/utils/markdown-download/use-markdown-download.ts @@ -1,6 +1,11 @@ import { useCallback, useState } from "react"; -import { downloadMarkdownAsDocx, downloadMarkdownAsPdf } from "./converter"; +import { + downloadMarkdownAsDocx, + downloadMarkdownAsPdf, + type DocxOptions, + type PdfOptions, +} from "./converter"; /** * Markdown 下载 Hook 配置选项 @@ -31,11 +36,21 @@ export interface UseMarkdownDownloadReturn { /** * 下载为 DOCX */ - downloadAsDocx: (markdown: string, filename: string) => Promise; + downloadAsDocx: ( + markdown: string, + filename: string, + options?: DocxOptions, + ) => Promise; /** * 下载为 PDF */ - downloadAsPdf: (markdown: string, filename: string) => Promise; + downloadAsPdf: ( + markdown: string, + filename: string, + options?: PdfOptions & { + resolveAssetUrl?: (rawPath: string) => string | null | Promise; + }, + ) => Promise; /** * 是否可以下载(没有正在进行的下载) */ @@ -82,14 +97,14 @@ export function useMarkdownDownload( ); const downloadAsDocx = useCallback( - async (markdown: string, filename: string) => { + async (markdown: string, filename: string, options?: DocxOptions) => { if (isDownloading) return; setIsDownloading("docx"); onDownloadStart?.("docx"); try { - await downloadMarkdownAsDocx(markdown, filename); + await downloadMarkdownAsDocx(markdown, filename, options); } catch (error) { onError?.( error instanceof Error ? error : new Error(String(error)), @@ -104,14 +119,20 @@ export function useMarkdownDownload( ); const downloadAsPdf = useCallback( - async (markdown: string, filename: string) => { + async ( + markdown: string, + filename: string, + options?: PdfOptions & { + resolveAssetUrl?: (rawPath: string) => string | null | Promise; + }, + ) => { if (isDownloading) return; setIsDownloading("pdf"); onDownloadStart?.("pdf"); try { - await downloadMarkdownAsPdf(markdown, filename); + await downloadMarkdownAsPdf(markdown, filename, options); } catch (error) { onError?.( error instanceof Error ? error : new Error(String(error)),