feat(frontend): 支持 DOCX/PDF 下载时包含图片资源
This commit is contained in:
parent
e5c0e9d584
commit
2deeb9f967
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 `})`;
|
return `})`;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
const markdownRelativeRewritten = markdownRewritten.replace(
|
||||||
|
/!\[([^\]]*)\]\(\s*([^) \t]+)\s*\)/g,
|
||||||
|
(_full, alt, rawPath) => {
|
||||||
|
const absoluteUrl = toArtifactUrlFromRelative(rawPath);
|
||||||
|
if (!absoluteUrl) {
|
||||||
|
return ``;
|
||||||
|
}
|
||||||
|
return ``;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
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 `})`;
|
return `})`;
|
||||||
|
|
@ -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}`;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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],
|
||||||
|
``,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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";
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取标题级别
|
* 获取标题级别
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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)),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue