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",
"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",

View File

@ -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

View File

@ -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<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(() => {
@ -188,8 +286,8 @@ export function ArtifactFileDetail({
className,
)}
>
<ArtifactHeader className="">
<div className="flex items-center justify-start gap-2">
<ArtifactHeader className="grid grid-cols-12 gap-3">
<div className="col-span-3 flex min-w-0 items-center justify-start gap-2 overflow-hidden">
{previewable && (
<ToggleGroup
type="single"
@ -248,10 +346,12 @@ export function ArtifactFileDetail({
</ToggleGroupItem>
</ToggleGroup>
)}
{/* 放大缩小选择器 */}
<ArtifactZoomSelector value={zoom} onChange={setZoom} />
{/* 仅在代码视图显示缩放控制 */}
{isCodeFile && viewMode === "code" && (
<ArtifactZoomSelector value={zoom} onChange={setZoom} />
)}
</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>
{isWriteFile ? (
<div className="w-full overflow-hidden px-2 text-center text-ellipsis whitespace-nowrap">
@ -266,7 +366,7 @@ export function ArtifactFileDetail({
)}
</ArtifactTitle>
</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>
{isCodeFile && (
<ArtifactAction
@ -340,20 +440,35 @@ export function ArtifactFileDetail({
</ArtifactAction>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[160px]">
<DropdownMenuItem asChild>
<a
href={urlOfArtifact({
filepath,
threadId: threadId ?? "",
download: true,
})}
target="_blank"
className="w-full cursor-pointer"
{language === "markdown" ? (
<DropdownMenuItem
onClick={handleDownloadMarkdownBundle}
disabled={isPackagingMarkdownBundle}
className="cursor-pointer"
>
<DownloadIcon className="size-4" />
{isPackagingMarkdownBundle ? (
<LoaderIcon className="size-4 animate-spin" />
) : (
<DownloadIcon className="size-4" />
)}
{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 文件显示。 */}
{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<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({
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(
/(<img\b[^>]*\bsrc\s*=\s*)(["'])(\/?mnt\/user-data\/outputs\/[^"']+)\2/gi,
/(<img\b[^>]*\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}`;
},
);
}

View File

@ -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<string | null>;
}
// ============================================================================
@ -80,10 +86,18 @@ export async function downloadMarkdownAsDocx(
filename: string,
options: DocxOptions = {},
): Promise<void> {
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<string | null>;
} = {},
): Promise<void> {
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<DocxOptions>,
): Paragraph[] {
options: Required<Pick<DocxOptions, "codeFont" | "codeFontSize">> &
Pick<DocxOptions, "resolveAssetUrl">,
): Promise<Paragraph[]> {
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<DocxOptions>,
): TextRun[] {
const runs: TextRun[] = [];
options: Required<Pick<DocxOptions, "codeFont" | "codeFontSize">> &
Pick<DocxOptions, "resolveAssetUrl">,
): Promise<ParagraphChild[]> {
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<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 { 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<void>;
downloadAsDocx: (
markdown: string,
filename: string,
options?: DocxOptions,
) => Promise<void>;
/**
* 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(
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<string | null>;
},
) => {
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)),