feat(frontend): 支持 DOCX/PDF 下载时包含图片资源

This commit is contained in:
肖应宇 2026-04-11 11:35:10 +08:00
parent e5c0e9d584
commit 2deeb9f967
5 changed files with 548 additions and 53 deletions

View File

@ -69,6 +69,7 @@
"gsap": "^3.13.0", "gsap": "^3.13.0",
"hast": "^1.0.0", "hast": "^1.0.0",
"html2pdf.js": "^0.14.0", "html2pdf.js": "^0.14.0",
"jszip": "^3.10.1",
"katex": "^0.16.28", "katex": "^0.16.28",
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"marked": "^17.0.5", "marked": "^17.0.5",

View File

@ -155,6 +155,9 @@ importers:
html2pdf.js: html2pdf.js:
specifier: ^0.14.0 specifier: ^0.14.0
version: 0.14.0 version: 0.14.0
jszip:
specifier: ^3.10.1
version: 3.10.1
katex: katex:
specifier: ^0.16.28 specifier: ^0.16.28
version: 0.16.28 version: 0.16.28

View File

@ -1,3 +1,4 @@
import JSZip from "jszip";
import { import {
DownloadIcon, DownloadIcon,
FileTextIcon, FileTextIcon,
@ -126,6 +127,8 @@ export function ArtifactFileDetail({
}); });
const displayContent = content ?? ""; const displayContent = content ?? "";
const [isPackagingMarkdownBundle, setIsPackagingMarkdownBundle] =
useState(false);
const artifactOptions = useMemo(() => { const artifactOptions = useMemo(() => {
return (artifacts ?? []).map((artifactPath) => ({ 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 // 下载为 DOCX
const handleDownloadDocx = useCallback(() => { const handleDownloadDocx = useCallback(() => {
if (content) { if (content) {
void downloadAsDocx(content, fileName); void downloadAsDocx(content, fileName, {
resolveAssetUrl: resolveMarkdownAssetUrlForDownload,
});
} }
}, [content, fileName, downloadAsDocx]); }, [content, fileName, downloadAsDocx, resolveMarkdownAssetUrlForDownload]);
// 下载为 PDF // 下载为 PDF
const handleDownloadPdf = useCallback(() => { const handleDownloadPdf = useCallback(() => {
if (content) { 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<string, string>();
for (const ref of referencedTargets) {
const resolved = resolveReferencedVirtualPath(ref, filepath);
if (resolved) {
refToVirtualPath.set(ref, resolved);
}
}
const refToRelativeZipPath = new Map<string, string>();
const addedVirtualPaths = new Set<string>();
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(() => { const handleFullscreenToggle = useCallback(() => {
@ -188,8 +286,8 @@ export function ArtifactFileDetail({
className, className,
)} )}
> >
<ArtifactHeader className=""> <ArtifactHeader className="grid grid-cols-12 gap-3">
<div className="flex items-center justify-start gap-2"> <div className="col-span-3 flex min-w-0 items-center justify-start gap-2 overflow-hidden">
{previewable && ( {previewable && (
<ToggleGroup <ToggleGroup
type="single" type="single"
@ -248,10 +346,12 @@ export function ArtifactFileDetail({
</ToggleGroupItem> </ToggleGroupItem>
</ToggleGroup> </ToggleGroup>
)} )}
{/* 放大缩小选择器 */} {/* 仅在代码视图显示缩放控制 */}
<ArtifactZoomSelector value={zoom} onChange={setZoom} /> {isCodeFile && viewMode === "code" && (
<ArtifactZoomSelector value={zoom} onChange={setZoom} />
)}
</div> </div>
<div className="flex min-w-0 grow items-center justify-center"> <div className="col-span-6 flex min-w-0 items-center justify-center px-1">
<ArtifactTitle> <ArtifactTitle>
{isWriteFile ? ( {isWriteFile ? (
<div className="w-full overflow-hidden px-2 text-center text-ellipsis whitespace-nowrap"> <div className="w-full overflow-hidden px-2 text-center text-ellipsis whitespace-nowrap">
@ -266,7 +366,7 @@ export function ArtifactFileDetail({
)} )}
</ArtifactTitle> </ArtifactTitle>
</div> </div>
<div className="flex items-center justify-end overflow-hidden"> <div className="col-span-3 flex min-w-0 items-center justify-end overflow-hidden">
<ArtifactActions> <ArtifactActions>
{isCodeFile && ( {isCodeFile && (
<ArtifactAction <ArtifactAction
@ -340,20 +440,35 @@ export function ArtifactFileDetail({
</ArtifactAction> </ArtifactAction>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[160px]"> <DropdownMenuContent align="end" className="min-w-[160px]">
<DropdownMenuItem asChild> {language === "markdown" ? (
<a <DropdownMenuItem
href={urlOfArtifact({ onClick={handleDownloadMarkdownBundle}
filepath, disabled={isPackagingMarkdownBundle}
threadId: threadId ?? "", className="cursor-pointer"
download: true,
})}
target="_blank"
className="w-full cursor-pointer"
> >
<DownloadIcon className="size-4" /> {isPackagingMarkdownBundle ? (
<LoaderIcon className="size-4 animate-spin" />
) : (
<DownloadIcon className="size-4" />
)}
{t.common.downloadOriginal} {t.common.downloadOriginal}
</a> </DropdownMenuItem>
</DropdownMenuItem> ) : (
<DropdownMenuItem asChild>
<a
href={urlOfArtifact({
filepath,
threadId: threadId ?? "",
download: true,
})}
target="_blank"
className="w-full cursor-pointer"
>
<DownloadIcon className="size-4" />
{t.common.downloadOriginal}
</a>
</DropdownMenuItem>
)}
{/* DOCX 和 PDF 导出选项仅对 Markdown 文件显示。 */} {/* DOCX 和 PDF 导出选项仅对 Markdown 文件显示。 */}
{canConvertToDocxPdf && ( {canConvertToDocxPdf && (
<> <>
@ -478,6 +593,7 @@ export function ArtifactFileDetail({
language={language ?? "text"} language={language ?? "text"}
zoom={zoom} zoom={zoom}
threadId={threadId} 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<string> {
const targets = new Set<string>();
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, string>,
): 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({ export function ArtifactFilePreview({
content, content,
language, language,
zoom = 100, zoom = 100,
threadId, threadId,
filepath,
}: { }: {
content: string; content: string;
language: string; language: string;
zoom?: number; zoom?: number;
threadId: string; threadId: string;
filepath?: string;
}) { }) {
const zoomScale = zoom / 100; const zoomScale = zoom / 100;
const normalizedContent = useMemo(() => { const normalizedContent = useMemo(() => {
return rewriteArtifactImagePaths(content ?? "", threadId); return rewriteArtifactImagePaths(content ?? "", threadId, filepath);
}, [content, threadId]); }, [content, threadId, filepath]);
if (language === "markdown") { if (language === "markdown") {
return ( return (
@ -864,8 +1105,12 @@ function ArtifactPreviewFallback({
); );
} }
function rewriteArtifactImagePaths(content: string, threadId?: string) { function rewriteArtifactImagePaths(
if (!threadId || !/\/?mnt\/user-data\//.test(content)) { content: string,
threadId?: string,
baseFilepath?: string,
) {
if (!threadId) {
return content; return content;
} }
@ -873,6 +1118,20 @@ function rewriteArtifactImagePaths(content: string, threadId?: string) {
const normalizedPath = rawPath.startsWith("/") ? rawPath : `/${rawPath}`; const normalizedPath = rawPath.startsWith("/") ? rawPath : `/${rawPath}`;
return resolveArtifactURL(normalizedPath, threadId); 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( const markdownRewritten = content.replace(
/!\[([^\]]*)\]\(\s*(\/?mnt\/user-data\/outputs\/[^)\s]+)\s*\)/g, /!\[([^\]]*)\]\(\s*(\/?mnt\/user-data\/outputs\/[^)\s]+)\s*\)/g,
@ -880,8 +1139,18 @@ function rewriteArtifactImagePaths(content: string, threadId?: string) {
return `![${alt}](${toArtifactUrl(rawPath)})`; 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, /!(?!\[)([^\n()]+?)\s*[(]\s*(\/?mnt\/user-data\/outputs\/[^)\s]+)\s*[)]/g,
(_full, alt, rawPath) => { (_full, alt, rawPath) => {
return `![${String(alt).trim()}](${toArtifactUrl(rawPath)})`; return `![${String(alt).trim()}](${toArtifactUrl(rawPath)})`;
@ -889,9 +1158,16 @@ function rewriteArtifactImagePaths(content: string, threadId?: string) {
); );
return shorthandMarkdownRewritten.replace( return shorthandMarkdownRewritten.replace(
/(<img\b[^>]*\bsrc\s*=\s*)(["'])(\/?mnt\/user-data\/outputs\/[^"']+)\2/gi, /(<img\b[^>]*\bsrc\s*=\s*)(["'])([^"']+)\2/gi,
(_full, prefix, quote, rawPath) => { (_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}`;
}, },
); );
} }

View File

@ -4,6 +4,8 @@ import {
Paragraph, Paragraph,
TextRun, TextRun,
HeadingLevel, HeadingLevel,
ImageRun,
type ParagraphChild,
} from "docx"; } from "docx";
import { marked } from "marked"; import { marked } from "marked";
@ -57,6 +59,10 @@ export interface DocxOptions {
* @default 22 (11pt) * @default 22 (11pt)
*/ */
codeFontSize?: number; codeFontSize?: number;
/**
* Markdown
*/
resolveAssetUrl?: (rawPath: string) => string | null | Promise<string | null>;
} }
// ============================================================================ // ============================================================================
@ -80,10 +86,18 @@ export async function downloadMarkdownAsDocx(
filename: string, filename: string,
options: DocxOptions = {}, options: DocxOptions = {},
): Promise<void> { ): Promise<void> {
const { codeFont = "Courier New", codeFontSize = 22 } = options; const {
codeFont = "Courier New",
codeFontSize = 22,
resolveAssetUrl,
} = options;
const tokens = marked.lexer(markdown); const tokens = marked.lexer(markdown);
const children = parseTokensToDocx(tokens, { codeFont, codeFontSize }); const children = await parseTokensToDocx(tokens, {
codeFont,
codeFontSize,
resolveAssetUrl,
});
const doc = new DocxDocument({ const doc = new DocxDocument({
sections: [{ children }], sections: [{ children }],
@ -112,7 +126,9 @@ export async function downloadMarkdownAsDocx(
export async function downloadMarkdownAsPdf( export async function downloadMarkdownAsPdf(
markdown: string, markdown: string,
filename: string, filename: string,
options: PdfOptions = {}, options: PdfOptions & {
resolveAssetUrl?: (rawPath: string) => string | null | Promise<string | null>;
} = {},
): Promise<void> { ): Promise<void> {
const html2pdf = await loadHtml2Pdf(); const html2pdf = await loadHtml2Pdf();
@ -121,10 +137,16 @@ export async function downloadMarkdownAsPdf(
format = "a4", format = "a4",
orientation = "portrait", orientation = "portrait",
scale = 2, scale = 2,
resolveAssetUrl,
} = options; } = options;
const normalizedMarkdown = await rewriteMarkdownImageSources(
markdown,
resolveAssetUrl,
);
// 解析 Markdown 为 HTML // 解析 Markdown 为 HTML
const htmlContent = await marked.parse(markdown); const htmlContent = await marked.parse(normalizedMarkdown);
// 创建容器并应用样式 // 创建容器并应用样式
const container = createStyledContainer(htmlContent); const container = createStyledContainer(htmlContent);
@ -309,16 +331,17 @@ function fixColorsForHtml2Canvas(clonedDoc: Document): void {
/** /**
* Markdown Token DOCX Paragraph * Markdown Token DOCX Paragraph
*/ */
function parseTokensToDocx( async function parseTokensToDocx(
tokens: MarkdownToken[], tokens: MarkdownToken[],
options: Required<DocxOptions>, options: Required<Pick<DocxOptions, "codeFont" | "codeFontSize">> &
): Paragraph[] { Pick<DocxOptions, "resolveAssetUrl">,
): Promise<Paragraph[]> {
const paragraphs: Paragraph[] = []; const paragraphs: Paragraph[] = [];
for (const token of tokens) { for (const token of tokens) {
switch (token.type) { switch (token.type) {
case "heading": { case "heading": {
const runs = parseInlineTokens(token.tokens ?? [], options); const runs = await parseInlineTokens(token.tokens ?? [], options);
paragraphs.push( paragraphs.push(
new Paragraph({ new Paragraph({
children: runs, children: runs,
@ -330,7 +353,7 @@ function parseTokensToDocx(
} }
case "paragraph": { case "paragraph": {
const runs = parseInlineTokens(token.tokens ?? [], options); const runs = await parseInlineTokens(token.tokens ?? [], options);
paragraphs.push( paragraphs.push(
new Paragraph({ new Paragraph({
children: runs.length > 0 ? runs : [new TextRun("")], children: runs.length > 0 ? runs : [new TextRun("")],
@ -361,8 +384,8 @@ function parseTokensToDocx(
} }
case "list": { case "list": {
token.items?.forEach((item: MarkdownToken) => { for (const item of token.items ?? []) {
const runs = parseInlineTokens( const runs = await parseInlineTokens(
item.tokens?.[0]?.tokens ?? [], item.tokens?.[0]?.tokens ?? [],
options, options,
); );
@ -373,12 +396,12 @@ function parseTokensToDocx(
spacing: { after: 80 }, spacing: { after: 80 },
}), }),
); );
}); }
break; break;
} }
case "blockquote": { case "blockquote": {
const runs = parseInlineTokens( const runs = await parseInlineTokens(
token.tokens?.[0]?.tokens ?? [], token.tokens?.[0]?.tokens ?? [],
options, options,
); );
@ -407,6 +430,19 @@ function parseTokensToDocx(
paragraphs.push(new Paragraph({ children: [] })); paragraphs.push(new Paragraph({ children: [] }));
break; 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 * Token TextRun
*/ */
function parseInlineTokens( async function parseInlineTokens(
tokens: MarkdownToken[], tokens: MarkdownToken[],
options: Required<DocxOptions>, options: Required<Pick<DocxOptions, "codeFont" | "codeFontSize">> &
): TextRun[] { Pick<DocxOptions, "resolveAssetUrl">,
const runs: TextRun[] = []; ): Promise<ParagraphChild[]> {
const runs: ParagraphChild[] = [];
for (const token of tokens) { for (const token of tokens) {
switch (token.type) { switch (token.type) {
@ -460,6 +497,14 @@ function parseInlineTokens(
runs.push(new TextRun({ text: "", break: 1 })); runs.push(new TextRun({ text: "", break: 1 }));
break; break;
case "image": {
const imageRun = await createImageRunFromToken(token, options);
if (imageRun) {
runs.push(imageRun);
}
break;
}
default: default:
runs.push(new TextRun(token.raw ?? "")); runs.push(new TextRun(token.raw ?? ""));
} }
@ -468,6 +513,155 @@ function parseInlineTokens(
return runs; return runs;
} }
async function createImageRunFromToken(
token: MarkdownToken,
options: Pick<DocxOptions, "resolveAssetUrl">,
): Promise<ImageRun | null> {
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<string | null>,
): Promise<string> {
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(/(<img\b[^>]*\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<string | null>,
): Promise<string | null> {
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";
}
/** /**
* *
*/ */

View File

@ -1,6 +1,11 @@
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { downloadMarkdownAsDocx, downloadMarkdownAsPdf } from "./converter"; import {
downloadMarkdownAsDocx,
downloadMarkdownAsPdf,
type DocxOptions,
type PdfOptions,
} from "./converter";
/** /**
* Markdown Hook * Markdown Hook
@ -31,11 +36,21 @@ export interface UseMarkdownDownloadReturn {
/** /**
* DOCX * DOCX
*/ */
downloadAsDocx: (markdown: string, filename: string) => Promise<void>; downloadAsDocx: (
markdown: string,
filename: string,
options?: DocxOptions,
) => Promise<void>;
/** /**
* PDF * PDF
*/ */
downloadAsPdf: (markdown: string, filename: string) => Promise<void>; downloadAsPdf: (
markdown: string,
filename: string,
options?: PdfOptions & {
resolveAssetUrl?: (rawPath: string) => string | null | Promise<string | null>;
},
) => Promise<void>;
/** /**
* *
*/ */
@ -82,14 +97,14 @@ export function useMarkdownDownload(
); );
const downloadAsDocx = useCallback( const downloadAsDocx = useCallback(
async (markdown: string, filename: string) => { async (markdown: string, filename: string, options?: DocxOptions) => {
if (isDownloading) return; if (isDownloading) return;
setIsDownloading("docx"); setIsDownloading("docx");
onDownloadStart?.("docx"); onDownloadStart?.("docx");
try { try {
await downloadMarkdownAsDocx(markdown, filename); await downloadMarkdownAsDocx(markdown, filename, options);
} catch (error) { } catch (error) {
onError?.( onError?.(
error instanceof Error ? error : new Error(String(error)), error instanceof Error ? error : new Error(String(error)),
@ -104,14 +119,20 @@ export function useMarkdownDownload(
); );
const downloadAsPdf = useCallback( const downloadAsPdf = useCallback(
async (markdown: string, filename: string) => { async (
markdown: string,
filename: string,
options?: PdfOptions & {
resolveAssetUrl?: (rawPath: string) => string | null | Promise<string | null>;
},
) => {
if (isDownloading) return; if (isDownloading) return;
setIsDownloading("pdf"); setIsDownloading("pdf");
onDownloadStart?.("pdf"); onDownloadStart?.("pdf");
try { try {
await downloadMarkdownAsPdf(markdown, filename); await downloadMarkdownAsPdf(markdown, filename, options);
} catch (error) { } catch (error) {
onError?.( onError?.(
error instanceof Error ? error : new Error(String(error)), error instanceof Error ? error : new Error(String(error)),