feat(artifacts): 新增 Markdown 导出 DOCX/PDF 功能
- 新增 document-converter.ts 工具模块,支持 Markdown 转换为 DOCX(docx 库)和 PDF(html2pdf.js) - 在 artifact-file-detail 添加下载菜单选项(downloadAsDocx、downloadAsPdf) - .gitignore 添加 docs 目录忽略
This commit is contained in:
parent
04df7e25b1
commit
da2023b42b
|
|
@ -21,6 +21,8 @@ next-env.d.ts
|
||||||
# production
|
# production
|
||||||
/build
|
/build
|
||||||
|
|
||||||
|
docs
|
||||||
|
|
||||||
# misc
|
# misc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.pem
|
*.pem
|
||||||
|
|
|
||||||
|
|
@ -59,12 +59,15 @@
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"codemirror": "^6.0.2",
|
"codemirror": "^6.0.2",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"docx": "^9.6.1",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"gsap": "^3.13.0",
|
"gsap": "^3.13.0",
|
||||||
"hast": "^1.0.0",
|
"hast": "^1.0.0",
|
||||||
|
"html2pdf.js": "^0.14.0",
|
||||||
"katex": "^0.16.28",
|
"katex": "^0.16.28",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
|
"marked": "^17.0.5",
|
||||||
"motion": "^12.26.2",
|
"motion": "^12.26.2",
|
||||||
"nanoid": "^5.1.6",
|
"nanoid": "^5.1.6",
|
||||||
"next": "^16.1.4",
|
"next": "^16.1.4",
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -90,6 +90,7 @@ export type ArtifactActionProps = ComponentProps<typeof Button> & {
|
||||||
tooltip?: string;
|
tooltip?: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
icon?: LucideIcon;
|
icon?: LucideIcon;
|
||||||
|
asChild?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ArtifactAction = ({
|
export const ArtifactAction = ({
|
||||||
|
|
@ -100,6 +101,7 @@ export const ArtifactAction = ({
|
||||||
className,
|
className,
|
||||||
size = "sm",
|
size = "sm",
|
||||||
variant = "ghost",
|
variant = "ghost",
|
||||||
|
asChild = false,
|
||||||
...props
|
...props
|
||||||
}: ArtifactActionProps) => {
|
}: ArtifactActionProps) => {
|
||||||
const button = (
|
const button = (
|
||||||
|
|
@ -111,6 +113,7 @@ export const ArtifactAction = ({
|
||||||
size={size}
|
size={size}
|
||||||
type="button"
|
type="button"
|
||||||
variant={variant}
|
variant={variant}
|
||||||
|
asChild={asChild}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{Icon ? <Icon className="size-4" /> : children}
|
{Icon ? <Icon className="size-4" /> : children}
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,8 @@
|
||||||
import {
|
import { DownloadIcon, FileTextIcon, LoaderIcon, FileTypeIcon } from "lucide-react";
|
||||||
Code2Icon,
|
|
||||||
CopyIcon,
|
|
||||||
DownloadIcon,
|
|
||||||
EyeIcon,
|
|
||||||
LoaderIcon,
|
|
||||||
PackageIcon,
|
|
||||||
SquareArrowOutUpRightIcon,
|
|
||||||
XIcon,
|
|
||||||
ZoomIn,
|
|
||||||
ZoomOut,
|
|
||||||
type LucideIcon,
|
|
||||||
} from "lucide-react";
|
|
||||||
import {
|
import {
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
|
||||||
useState,
|
useState,
|
||||||
type HTMLAttributes,
|
type HTMLAttributes,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
|
@ -30,14 +17,13 @@ import {
|
||||||
ArtifactHeader,
|
ArtifactHeader,
|
||||||
ArtifactTitle,
|
ArtifactTitle,
|
||||||
} from "@/components/ai-elements/artifact";
|
} from "@/components/ai-elements/artifact";
|
||||||
import { DropdownSelector } from "@/components/ui/dropdown-selector";
|
|
||||||
import { Select, SelectItem } from "@/components/ui/select";
|
|
||||||
import {
|
import {
|
||||||
SelectContent,
|
DropdownMenu,
|
||||||
SelectGroup,
|
DropdownMenuContent,
|
||||||
SelectTrigger,
|
DropdownMenuItem,
|
||||||
SelectValue,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { DropdownSelector } from "@/components/ui/dropdown-selector";
|
||||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||||
import { CodeEditor } from "@/components/workspace/code-editor";
|
import { CodeEditor } from "@/components/workspace/code-editor";
|
||||||
import { useArtifactContent } from "@/core/artifacts/hooks";
|
import { useArtifactContent } from "@/core/artifacts/hooks";
|
||||||
|
|
@ -47,11 +33,10 @@ import { POST_MESSAGE_TYPES, sendToParent } from "@/core/iframe-messages";
|
||||||
import { installSkill } from "@/core/skills/api";
|
import { installSkill } from "@/core/skills/api";
|
||||||
import { streamdownPlugins } from "@/core/streamdown";
|
import { streamdownPlugins } from "@/core/streamdown";
|
||||||
import { checkCodeFile, getFileName } from "@/core/utils/files";
|
import { checkCodeFile, getFileName } from "@/core/utils/files";
|
||||||
import { env } from "@/env";
|
import { useMarkdownDownload } from "@/core/utils/markdown-download";
|
||||||
import { cn, copyToClipboard } from "@/lib/utils";
|
import { cn, copyToClipboard } from "@/lib/utils";
|
||||||
|
|
||||||
import { CitationLink } from "../citations/citation-link";
|
import { CitationLink } from "../citations/citation-link";
|
||||||
import { Tooltip } from "../tooltip";
|
|
||||||
|
|
||||||
import { useArtifacts } from "./context";
|
import { useArtifacts } from "./context";
|
||||||
|
|
||||||
|
|
@ -114,6 +99,34 @@ export function ArtifactFileDetail({
|
||||||
const [isInstalling, setIsInstalling] = useState(false);
|
const [isInstalling, setIsInstalling] = useState(false);
|
||||||
const [zoom, setZoom] = useState(80);
|
const [zoom, setZoom] = useState(80);
|
||||||
|
|
||||||
|
// 获取文件名(不含路径)
|
||||||
|
const fileName = useMemo(() => getFileName(filepath), [filepath]);
|
||||||
|
|
||||||
|
// 是否可以转换为docx/pdf(仅markdown文件支持)
|
||||||
|
const canConvertToDocxPdf = language === "markdown";
|
||||||
|
|
||||||
|
// 使用 Markdown 下载 hook
|
||||||
|
const { isDownloading, downloadAsDocx, downloadAsPdf } = useMarkdownDownload({
|
||||||
|
onError: (error, format) => {
|
||||||
|
console.error(`Failed to download as ${format}:`, error);
|
||||||
|
toast.error(`Failed to download as ${format.toUpperCase()}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 下载为 DOCX
|
||||||
|
const handleDownloadDocx = useCallback(() => {
|
||||||
|
if (content) {
|
||||||
|
void downloadAsDocx(content, fileName);
|
||||||
|
}
|
||||||
|
}, [content, fileName, downloadAsDocx]);
|
||||||
|
|
||||||
|
// 下载为 PDF
|
||||||
|
const handleDownloadPdf = useCallback(() => {
|
||||||
|
if (content) {
|
||||||
|
void downloadAsPdf(content, fileName);
|
||||||
|
}
|
||||||
|
}, [content, fileName, downloadAsPdf]);
|
||||||
|
|
||||||
// 全屏切换处理
|
// 全屏切换处理
|
||||||
const handleFullscreenToggle = useCallback(() => {
|
const handleFullscreenToggle = useCallback(() => {
|
||||||
const newFullscreen = !fullscreen;
|
const newFullscreen = !fullscreen;
|
||||||
|
|
@ -153,6 +166,7 @@ export function ArtifactFileDetail({
|
||||||
setIsInstalling(false);
|
setIsInstalling(false);
|
||||||
}
|
}
|
||||||
}, [threadId, filepath, isInstalling]);
|
}, [threadId, filepath, isInstalling]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// 给滚动遮挡头部定位relative
|
// 给滚动遮挡头部定位relative
|
||||||
<Artifact className={cn("relative",className)}>
|
<Artifact className={cn("relative",className)}>
|
||||||
|
|
@ -274,14 +288,15 @@ export function ArtifactFileDetail({
|
||||||
</ArtifactAction>
|
</ArtifactAction>
|
||||||
)}
|
)}
|
||||||
{!isWriteFile && (
|
{!isWriteFile && (
|
||||||
<a
|
<DropdownMenu>
|
||||||
href={urlOfArtifact({ filepath, threadId, download: true })}
|
<DropdownMenuTrigger asChild>
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
<ArtifactAction
|
<ArtifactAction
|
||||||
label={t.common.download}
|
label={t.common.download}
|
||||||
tooltip={t.common.download}
|
tooltip={t.common.download}
|
||||||
>
|
>
|
||||||
|
{isDownloading ? (
|
||||||
|
<LoaderIcon className="size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
<svg
|
<svg
|
||||||
width="18"
|
width="18"
|
||||||
height="18"
|
height="18"
|
||||||
|
|
@ -301,8 +316,47 @@ export function ArtifactFileDetail({
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
|
)}
|
||||||
</ArtifactAction>
|
</ArtifactAction>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="min-w-[160px]">
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<a
|
||||||
|
href={urlOfArtifact({
|
||||||
|
filepath,
|
||||||
|
threadId,
|
||||||
|
download: true,
|
||||||
|
})}
|
||||||
|
target="_blank"
|
||||||
|
className="w-full cursor-pointer"
|
||||||
|
>
|
||||||
|
<DownloadIcon className="size-4" />
|
||||||
|
{t.common.downloadOriginal}
|
||||||
</a>
|
</a>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{/* DOCX 和 PDF 导出选项仅对 Markdown 文件显示。 */}
|
||||||
|
{canConvertToDocxPdf && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={handleDownloadDocx}
|
||||||
|
disabled={isDownloading !== null || !content}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
<FileTextIcon className="size-4" />
|
||||||
|
{isDownloading === "docx" ? t.common.loading : t.common.downloadAsDocx}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={handleDownloadPdf}
|
||||||
|
disabled={isDownloading !== null || !content}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
<FileTypeIcon className="size-4" />
|
||||||
|
{isDownloading === "pdf" ? t.common.loading : t.common.downloadAsPdf}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
)}
|
)}
|
||||||
{/* 全屏按钮 */}
|
{/* 全屏按钮 */}
|
||||||
<ArtifactAction
|
<ArtifactAction
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,9 @@ export const enUS: Translations = {
|
||||||
more: "More",
|
more: "More",
|
||||||
search: "Search",
|
search: "Search",
|
||||||
download: "Download",
|
download: "Download",
|
||||||
|
downloadOriginal: "Original File",
|
||||||
|
downloadAsDocx: "Download as DOCX",
|
||||||
|
downloadAsPdf: "Download as PDF",
|
||||||
thinking: "Thinking",
|
thinking: "Thinking",
|
||||||
artifacts: "Artifacts",
|
artifacts: "Artifacts",
|
||||||
public: "Public",
|
public: "Public",
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,9 @@ export interface Translations {
|
||||||
more: string;
|
more: string;
|
||||||
search: string;
|
search: string;
|
||||||
download: string;
|
download: string;
|
||||||
|
downloadOriginal: string;
|
||||||
|
downloadAsDocx: string;
|
||||||
|
downloadAsPdf: string;
|
||||||
thinking: string;
|
thinking: string;
|
||||||
artifacts: string;
|
artifacts: string;
|
||||||
public: string;
|
public: string;
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,9 @@ export const zhCN: Translations = {
|
||||||
more: "更多",
|
more: "更多",
|
||||||
search: "搜索",
|
search: "搜索",
|
||||||
download: "下载",
|
download: "下载",
|
||||||
|
downloadOriginal: "原文件",
|
||||||
|
downloadAsDocx: "下载为 DOCX",
|
||||||
|
downloadAsPdf: "下载为 PDF",
|
||||||
thinking: "思考",
|
thinking: "思考",
|
||||||
artifacts: "查看结果",
|
artifacts: "查看结果",
|
||||||
public: "公共",
|
public: "公共",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,464 @@
|
||||||
|
import { Document as DocxDocument, Packer, Paragraph, TextRun, HeadingLevel } from "docx";
|
||||||
|
import { marked } from "marked";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Markdown Token 类型(简化版)
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
type MarkdownToken = any;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PDF 转换选项
|
||||||
|
*/
|
||||||
|
export interface PdfOptions {
|
||||||
|
/**
|
||||||
|
* 页边距 [上, 右, 下, 左],单位 mm
|
||||||
|
* @default [15, 15, 15, 15]
|
||||||
|
*/
|
||||||
|
margin?: [number, number, number, number];
|
||||||
|
/**
|
||||||
|
* 页面格式
|
||||||
|
* @default "a4"
|
||||||
|
*/
|
||||||
|
format?: "a3" | "a4" | "a5" | "letter" | "legal";
|
||||||
|
/**
|
||||||
|
* 页面方向
|
||||||
|
* @default "portrait"
|
||||||
|
*/
|
||||||
|
orientation?: "portrait" | "landscape";
|
||||||
|
/**
|
||||||
|
* 缩放比例
|
||||||
|
* @default 2
|
||||||
|
*/
|
||||||
|
scale?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DOCX 转换选项
|
||||||
|
*/
|
||||||
|
export interface DocxOptions {
|
||||||
|
/**
|
||||||
|
* 代码块字体
|
||||||
|
* @default "Courier New"
|
||||||
|
*/
|
||||||
|
codeFont?: string;
|
||||||
|
/**
|
||||||
|
* 代码块字号(半磅)
|
||||||
|
* @default 22 (11pt)
|
||||||
|
*/
|
||||||
|
codeFontSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// DOCX Converter
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 Markdown 内容转换为 DOCX 文件并下载
|
||||||
|
*
|
||||||
|
* @param markdown - Markdown 文本内容
|
||||||
|
* @param filename - 文件名(不含扩展名,或包含 .md 扩展名)
|
||||||
|
* @param options - 转换选项
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* await downloadMarkdownAsDocx("# Hello World", "document");
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export async function downloadMarkdownAsDocx(markdown: string, filename: string, options: DocxOptions = {}): Promise<void> {
|
||||||
|
const { codeFont = "Courier New", codeFontSize = 22 } = options;
|
||||||
|
|
||||||
|
const tokens = marked.lexer(markdown);
|
||||||
|
const children = parseTokensToDocx(tokens, { codeFont, codeFontSize });
|
||||||
|
|
||||||
|
const doc = new DocxDocument({
|
||||||
|
sections: [{ children }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const blob = await Packer.toBlob(doc);
|
||||||
|
downloadBlob(blob, normalizeFilename(filename, ".docx"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// PDF Converter
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 Markdown 内容转换为 PDF 文件并下载
|
||||||
|
*
|
||||||
|
* @param markdown - Markdown 文本内容
|
||||||
|
* @param filename - 文件名(不含扩展名,或包含 .md 扩展名)
|
||||||
|
* @param options - 转换选项
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* await downloadMarkdownAsPdf("# Hello World", "document");
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export async function downloadMarkdownAsPdf(markdown: string, filename: string, options: PdfOptions = {}): Promise<void> {
|
||||||
|
const html2pdf = await loadHtml2Pdf();
|
||||||
|
|
||||||
|
const { margin = [15, 15, 15, 15], format = "a4", orientation = "portrait", scale = 2 } = options;
|
||||||
|
|
||||||
|
// 解析 Markdown 为 HTML
|
||||||
|
const htmlContent = await marked.parse(markdown);
|
||||||
|
|
||||||
|
// 创建容器并应用样式
|
||||||
|
const container = createStyledContainer(htmlContent);
|
||||||
|
|
||||||
|
// 配置 html2pdf
|
||||||
|
const opt = {
|
||||||
|
margin,
|
||||||
|
filename: normalizeFilename(filename, ".pdf"),
|
||||||
|
image: { type: "jpeg" as const, quality: 0.98 },
|
||||||
|
html2canvas: {
|
||||||
|
scale,
|
||||||
|
useCORS: true,
|
||||||
|
logging: false,
|
||||||
|
onclone: fixColorsForHtml2Canvas,
|
||||||
|
},
|
||||||
|
jsPDF: { unit: "mm" as const, format, orientation },
|
||||||
|
};
|
||||||
|
|
||||||
|
await html2pdf().set(opt).from(container).save();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Internal Utilities
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 动态加载 html2pdf.js
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
||||||
|
async function loadHtml2Pdf(): Promise<Function> {
|
||||||
|
const html2pdf = await import("html2pdf.js");
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||||
|
return html2pdf.default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建带样式的 HTML 容器
|
||||||
|
*/
|
||||||
|
function createStyledContainer(htmlContent: string): HTMLDivElement {
|
||||||
|
const container = document.createElement("div");
|
||||||
|
container.innerHTML = htmlContent;
|
||||||
|
|
||||||
|
// 容器基础样式
|
||||||
|
container.style.cssText = `
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
padding: 20px;
|
||||||
|
max-width: 800px;
|
||||||
|
color: #333333;
|
||||||
|
background-color: #ffffff;
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 应用元素样式
|
||||||
|
applyElementStyles(container);
|
||||||
|
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用元素样式
|
||||||
|
*/
|
||||||
|
function applyElementStyles(container: HTMLElement): void {
|
||||||
|
// 标题
|
||||||
|
container.querySelectorAll("h1, h2, h3, h4, h5, h6").forEach((h) => {
|
||||||
|
const el = h as HTMLElement;
|
||||||
|
el.style.marginTop = "1.5em";
|
||||||
|
el.style.marginBottom = "0.5em";
|
||||||
|
el.style.fontWeight = "600";
|
||||||
|
el.style.color = "#1a1a1a";
|
||||||
|
});
|
||||||
|
|
||||||
|
// 段落
|
||||||
|
container.querySelectorAll("p").forEach((p) => {
|
||||||
|
(p as HTMLElement).style.marginBottom = "1em";
|
||||||
|
});
|
||||||
|
|
||||||
|
// 代码块
|
||||||
|
container.querySelectorAll("pre, code").forEach((code) => {
|
||||||
|
const el = code as HTMLElement;
|
||||||
|
el.style.fontFamily = "'SF Mono', 'Fira Code', Consolas, monospace";
|
||||||
|
el.style.backgroundColor = "#f5f5f5";
|
||||||
|
el.style.color = "#333333";
|
||||||
|
el.style.fontSize = "13px";
|
||||||
|
if (code.tagName === "PRE") {
|
||||||
|
el.style.padding = "12px";
|
||||||
|
el.style.borderRadius = "6px";
|
||||||
|
el.style.overflow = "auto";
|
||||||
|
} else {
|
||||||
|
el.style.padding = "2px 4px";
|
||||||
|
el.style.borderRadius = "3px";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 列表
|
||||||
|
container.querySelectorAll("ul, ol").forEach((list) => {
|
||||||
|
const el = list as HTMLElement;
|
||||||
|
el.style.marginBottom = "1em";
|
||||||
|
el.style.paddingLeft = "2em";
|
||||||
|
});
|
||||||
|
|
||||||
|
// 引用块
|
||||||
|
container.querySelectorAll("blockquote").forEach((bq) => {
|
||||||
|
const el = bq as HTMLElement;
|
||||||
|
el.style.borderLeft = "4px solid #dddddd";
|
||||||
|
el.style.marginLeft = "0";
|
||||||
|
el.style.paddingLeft = "16px";
|
||||||
|
el.style.color = "#666666";
|
||||||
|
});
|
||||||
|
|
||||||
|
// 表格
|
||||||
|
container.querySelectorAll("table").forEach((table) => {
|
||||||
|
const el = table as HTMLElement;
|
||||||
|
el.style.borderCollapse = "collapse";
|
||||||
|
el.style.width = "100%";
|
||||||
|
el.style.marginBottom = "1em";
|
||||||
|
});
|
||||||
|
|
||||||
|
container.querySelectorAll("th, td").forEach((cell) => {
|
||||||
|
const el = cell as HTMLElement;
|
||||||
|
el.style.border = "1px solid #dddddd";
|
||||||
|
el.style.padding = "8px";
|
||||||
|
});
|
||||||
|
|
||||||
|
// 链接
|
||||||
|
container.querySelectorAll("a").forEach((link) => {
|
||||||
|
const el = link as HTMLElement;
|
||||||
|
el.style.color = "#0066cc";
|
||||||
|
el.style.textDecoration = "underline";
|
||||||
|
});
|
||||||
|
|
||||||
|
// 分割线
|
||||||
|
container.querySelectorAll("hr").forEach((hr) => {
|
||||||
|
const el = hr as HTMLElement;
|
||||||
|
el.style.border = "none";
|
||||||
|
el.style.borderTop = "1px solid #dddddd";
|
||||||
|
el.style.margin = "2em 0";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 修复 html2canvas 不支持的颜色函数
|
||||||
|
*/
|
||||||
|
function fixColorsForHtml2Canvas(clonedDoc: Document): void {
|
||||||
|
// 移除外部样式表(可能包含 lab、oklab 等不支持的颜色)
|
||||||
|
clonedDoc.querySelectorAll<HTMLStyleElement | HTMLLinkElement>('link[rel="stylesheet"], style').forEach((sheet) => sheet.remove());
|
||||||
|
|
||||||
|
// 重置所有元素的颜色属性为安全值
|
||||||
|
clonedDoc.querySelectorAll<HTMLElement>("*").forEach((el) => {
|
||||||
|
const props = [
|
||||||
|
"color",
|
||||||
|
"background-color",
|
||||||
|
"border-color",
|
||||||
|
"border-top-color",
|
||||||
|
"border-bottom-color",
|
||||||
|
"border-left-color",
|
||||||
|
"border-right-color",
|
||||||
|
"outline-color",
|
||||||
|
"text-decoration-color",
|
||||||
|
"caret-color",
|
||||||
|
"column-rule-color",
|
||||||
|
"accent-color",
|
||||||
|
"fill",
|
||||||
|
"stroke",
|
||||||
|
];
|
||||||
|
|
||||||
|
props.forEach((prop) => el.style.removeProperty(prop));
|
||||||
|
|
||||||
|
el.style.color = "#333333";
|
||||||
|
el.style.backgroundColor = "transparent";
|
||||||
|
});
|
||||||
|
|
||||||
|
// 设置 body 背景
|
||||||
|
const body = clonedDoc.body;
|
||||||
|
body.style.color = "#333333";
|
||||||
|
body.style.backgroundColor = "#ffffff";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 Markdown Token 为 DOCX Paragraph
|
||||||
|
*/
|
||||||
|
function parseTokensToDocx(tokens: MarkdownToken[], options: Required<DocxOptions>): Paragraph[] {
|
||||||
|
const paragraphs: Paragraph[] = [];
|
||||||
|
|
||||||
|
for (const token of tokens) {
|
||||||
|
switch (token.type) {
|
||||||
|
case "heading": {
|
||||||
|
const runs = parseInlineTokens(token.tokens ?? [], options);
|
||||||
|
paragraphs.push(
|
||||||
|
new Paragraph({
|
||||||
|
children: runs,
|
||||||
|
heading: getHeadingLevel(token.depth),
|
||||||
|
spacing: { before: 240, after: 120 },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "paragraph": {
|
||||||
|
const runs = parseInlineTokens(token.tokens ?? [], options);
|
||||||
|
paragraphs.push(
|
||||||
|
new Paragraph({
|
||||||
|
children: runs.length > 0 ? runs : [new TextRun("")],
|
||||||
|
spacing: { after: 200 },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "code": {
|
||||||
|
const lines = token.text.split("\n");
|
||||||
|
lines.forEach((line: string) => {
|
||||||
|
paragraphs.push(
|
||||||
|
new Paragraph({
|
||||||
|
children: [
|
||||||
|
new TextRun({
|
||||||
|
text: line.length > 0 ? line : " ",
|
||||||
|
font: options.codeFont,
|
||||||
|
size: options.codeFontSize,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
shading: { fill: "F5F5F5" },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
paragraphs.push(new Paragraph({ children: [] }));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "list": {
|
||||||
|
token.items?.forEach((item: MarkdownToken) => {
|
||||||
|
const runs = parseInlineTokens(item.tokens?.[0]?.tokens ?? [], options);
|
||||||
|
paragraphs.push(
|
||||||
|
new Paragraph({
|
||||||
|
children: runs.length > 0 ? runs : [new TextRun("")],
|
||||||
|
bullet: { level: 0 },
|
||||||
|
spacing: { after: 80 },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "blockquote": {
|
||||||
|
const runs = parseInlineTokens(token.tokens?.[0]?.tokens ?? [], options);
|
||||||
|
paragraphs.push(
|
||||||
|
new Paragraph({
|
||||||
|
children: runs.length > 0 ? runs : [new TextRun("")],
|
||||||
|
indent: { left: 720 },
|
||||||
|
border: { left: { style: "single", size: 12, color: "CCCCCC" } },
|
||||||
|
spacing: { after: 200 },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "hr": {
|
||||||
|
paragraphs.push(
|
||||||
|
new Paragraph({
|
||||||
|
children: [new TextRun({ text: "─".repeat(50), color: "CCCCCC" })],
|
||||||
|
spacing: { before: 200, after: 200 },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "space": {
|
||||||
|
paragraphs.push(new Paragraph({ children: [] }));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return paragraphs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析行内 Token 为 TextRun
|
||||||
|
*/
|
||||||
|
function parseInlineTokens(tokens: MarkdownToken[], options: Required<DocxOptions>): TextRun[] {
|
||||||
|
const runs: TextRun[] = [];
|
||||||
|
|
||||||
|
for (const token of tokens) {
|
||||||
|
switch (token.type) {
|
||||||
|
case "text":
|
||||||
|
runs.push(new TextRun(token.raw ?? token.text ?? ""));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "strong":
|
||||||
|
runs.push(new TextRun({ text: token.text, bold: true }));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "em":
|
||||||
|
runs.push(new TextRun({ text: token.text, italics: true }));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "codespan":
|
||||||
|
runs.push(
|
||||||
|
new TextRun({
|
||||||
|
text: token.text,
|
||||||
|
font: options.codeFont,
|
||||||
|
shading: { fill: "F0F0F0" },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "link":
|
||||||
|
runs.push(
|
||||||
|
new TextRun({
|
||||||
|
text: token.text,
|
||||||
|
color: "0066CC",
|
||||||
|
underline: {},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "br":
|
||||||
|
runs.push(new TextRun({ text: "", break: 1 }));
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
runs.push(new TextRun(token.raw ?? ""));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return runs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取标题级别
|
||||||
|
*/
|
||||||
|
function getHeadingLevel(depth: number): typeof HeadingLevel[keyof typeof HeadingLevel] | undefined {
|
||||||
|
const levels = [HeadingLevel.HEADING_1, HeadingLevel.HEADING_2, HeadingLevel.HEADING_3, HeadingLevel.HEADING_4, HeadingLevel.HEADING_5, HeadingLevel.HEADING_6];
|
||||||
|
return levels[depth - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 规范化文件名
|
||||||
|
*/
|
||||||
|
function normalizeFilename(filename: string, extension: string): string {
|
||||||
|
return filename.replace(/\.md$/i, "") + extension;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 触发 Blob 下载
|
||||||
|
*/
|
||||||
|
function downloadBlob(blob: Blob, filename: string): void {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
/**
|
||||||
|
* Markdown 文档下载工具
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* 将 Markdown 内容转换为 DOCX 或 PDF 格式并下载。
|
||||||
|
* 可在任何 React + TypeScript 项目中使用。
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* // React Hook 使用方式
|
||||||
|
* import { useMarkdownDownload } from "./markdown-download";
|
||||||
|
*
|
||||||
|
* function MyComponent() {
|
||||||
|
* const { downloadAsDocx, downloadAsPdf, isDownloading } = useMarkdownDownload();
|
||||||
|
*
|
||||||
|
* return (
|
||||||
|
* <div>
|
||||||
|
* <button onClick={() => downloadAsDocx("# Hello", "doc")}>
|
||||||
|
* Download DOCX
|
||||||
|
* </button>
|
||||||
|
* <button onClick={() => downloadAsPdf("# Hello", "doc")}>
|
||||||
|
* Download PDF
|
||||||
|
* </button>
|
||||||
|
* </div>
|
||||||
|
* );
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* // 非 React 环境直接使用转换函数
|
||||||
|
* import { downloadMarkdownAsDocx, downloadMarkdownAsPdf } from "./markdown-download";
|
||||||
|
*
|
||||||
|
* await downloadMarkdownAsDocx("# Hello World", "document");
|
||||||
|
* await downloadMarkdownAsPdf("# Hello World", "document", { format: "a4" });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
// React Hook
|
||||||
|
export { useMarkdownDownload } from "./use-markdown-download";
|
||||||
|
|
||||||
|
// 类型
|
||||||
|
export type { UseMarkdownDownloadOptions, UseMarkdownDownloadReturn } from "./use-markdown-download";
|
||||||
|
export type { PdfOptions, DocxOptions } from "./converter";
|
||||||
|
|
||||||
|
// 转换函数(供非 React 环境使用)
|
||||||
|
export { downloadMarkdownAsDocx, downloadMarkdownAsPdf } from "./converter";
|
||||||
|
|
@ -0,0 +1,127 @@
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
|
||||||
|
import { downloadMarkdownAsDocx, downloadMarkdownAsPdf } from "./converter";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Markdown 下载 Hook 配置选项
|
||||||
|
*/
|
||||||
|
export interface UseMarkdownDownloadOptions {
|
||||||
|
/**
|
||||||
|
* 下载开始时的回调
|
||||||
|
*/
|
||||||
|
onDownloadStart?: (format: "docx" | "pdf") => void;
|
||||||
|
/**
|
||||||
|
* 下载完成时的回调
|
||||||
|
*/
|
||||||
|
onDownloadEnd?: (format: "docx" | "pdf") => void;
|
||||||
|
/**
|
||||||
|
* 下载失败时的回调
|
||||||
|
*/
|
||||||
|
onError?: (error: Error, format: "docx" | "pdf") => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Markdown 下载 Hook 返回值
|
||||||
|
*/
|
||||||
|
export interface UseMarkdownDownloadReturn {
|
||||||
|
/**
|
||||||
|
* 当前下载状态
|
||||||
|
*/
|
||||||
|
isDownloading: "docx" | "pdf" | null;
|
||||||
|
/**
|
||||||
|
* 下载为 DOCX
|
||||||
|
*/
|
||||||
|
downloadAsDocx: (markdown: string, filename: string) => Promise<void>;
|
||||||
|
/**
|
||||||
|
* 下载为 PDF
|
||||||
|
*/
|
||||||
|
downloadAsPdf: (markdown: string, filename: string) => Promise<void>;
|
||||||
|
/**
|
||||||
|
* 是否可以下载(没有正在进行的下载)
|
||||||
|
*/
|
||||||
|
canDownload: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Markdown 文档下载 Hook
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* 将 Markdown 内容转换为 DOCX 或 PDF 格式并下载。
|
||||||
|
* 可在任何 React + TypeScript 项目中使用。
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* import { useMarkdownDownload } from "./hooks/use-markdown-download";
|
||||||
|
*
|
||||||
|
* function MyComponent() {
|
||||||
|
* const { downloadAsDocx, downloadAsPdf, isDownloading, canDownload } = useMarkdownDownload({
|
||||||
|
* onError: (error, format) => {
|
||||||
|
* console.error(`Failed to download as ${format}:`, error);
|
||||||
|
* },
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* const handleDownload = () => {
|
||||||
|
* downloadAsDocx("# Hello World", "document");
|
||||||
|
* };
|
||||||
|
*
|
||||||
|
* return (
|
||||||
|
* <button onClick={handleDownload} disabled={!canDownload}>
|
||||||
|
* {isDownloading === "docx" ? "Converting..." : "Download DOCX"}
|
||||||
|
* </button>
|
||||||
|
* );
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useMarkdownDownload(options: UseMarkdownDownloadOptions = {}): UseMarkdownDownloadReturn {
|
||||||
|
const { onDownloadStart, onDownloadEnd, onError } = options;
|
||||||
|
|
||||||
|
const [isDownloading, setIsDownloading] = useState<"docx" | "pdf" | null>(null);
|
||||||
|
|
||||||
|
const downloadAsDocx = useCallback(
|
||||||
|
async (markdown: string, filename: string) => {
|
||||||
|
if (isDownloading) return;
|
||||||
|
|
||||||
|
setIsDownloading("docx");
|
||||||
|
onDownloadStart?.("docx");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await downloadMarkdownAsDocx(markdown, filename);
|
||||||
|
} catch (error) {
|
||||||
|
onError?.(error instanceof Error ? error : new Error(String(error)), "docx");
|
||||||
|
} finally {
|
||||||
|
setIsDownloading(null);
|
||||||
|
onDownloadEnd?.("docx");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isDownloading, onDownloadStart, onDownloadEnd, onError]
|
||||||
|
);
|
||||||
|
|
||||||
|
const downloadAsPdf = useCallback(
|
||||||
|
async (markdown: string, filename: string) => {
|
||||||
|
if (isDownloading) return;
|
||||||
|
|
||||||
|
setIsDownloading("pdf");
|
||||||
|
onDownloadStart?.("pdf");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await downloadMarkdownAsPdf(markdown, filename);
|
||||||
|
} catch (error) {
|
||||||
|
onError?.(error instanceof Error ? error : new Error(String(error)), "pdf");
|
||||||
|
} finally {
|
||||||
|
setIsDownloading(null);
|
||||||
|
onDownloadEnd?.("pdf");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isDownloading, onDownloadStart, onDownloadEnd, onError]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isDownloading,
|
||||||
|
downloadAsDocx,
|
||||||
|
downloadAsPdf,
|
||||||
|
canDownload: isDownloading === null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出转换函数,供非 React 环境使用
|
||||||
|
export { downloadMarkdownAsDocx, downloadMarkdownAsPdf } from "./converter";
|
||||||
Loading…
Reference in New Issue