ai-chatbot/lib/flow-board/image-export.ts
134 c3f5903a86 feat(flow-board): add rich notes, Gantt tasks and PNG export
Add native task metadata, working-day calendar settings, dependency scheduling and rich note editing. Support complete-sheet PNG export with resolution and transparency controls, preserve XMind extensions and update browser regressions.
2026-10-06 18:06:29 +08:00

138 lines
4.8 KiB
TypeScript

import { PlaitBoard } from "@plait/core";
export type ImageExportOptions = {
scale: 1 | 2 | 3;
transparent: boolean;
backgroundColor: string;
};
const textStyles = [
"display",
"box-sizing",
"margin",
"padding",
"width",
"height",
"font-family",
"font-size",
"font-weight",
"font-style",
"line-height",
"color",
"background-color",
"text-align",
"text-decoration",
"white-space",
"word-break",
"overflow-wrap",
"border-radius",
"vertical-align",
];
async function embedResource(url: string): Promise<string> {
const response = await fetch(url, { signal: AbortSignal.timeout(15_000) });
if (!response.ok) throw new Error("图片资源加载失败,请稍后重试");
const blob = await response.blob();
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result));
reader.onerror = () => reject(new Error("图片资源读取失败"));
reader.readAsDataURL(blob);
});
}
/** Render the complete content layer in board coordinates, independent of pan/zoom. */
export async function exportBoardImage(
board: PlaitBoard,
options: ImageExportOptions
) {
if (!board.children.length) throw new Error("当前工作表没有可导出的内容");
await document.fonts.ready;
const host = PlaitBoard.getElementHost(board);
const bounds = host.getBBox();
const padding = 32;
const width = Math.ceil(bounds.width + padding * 2);
const height = Math.ceil(bounds.height + padding * 2);
const pixelWidth = width * options.scale;
const pixelHeight = height * options.scale;
if (!bounds.width || !bounds.height)
throw new Error("画布尚未准备好,请稍后重试");
if (
pixelWidth > 16_384 ||
pixelHeight > 16_384 ||
pixelWidth * pixelHeight > 64_000_000
) {
throw new Error("图片尺寸过大,请降低清晰度或拆分工作表后导出");
}
const clone = host.cloneNode(true) as SVGGElement;
clone.removeAttribute("transform");
const sources = host.querySelectorAll("foreignObject *");
const copies = clone.querySelectorAll("foreignObject *");
sources.forEach((source, index) => {
const copy = copies[index] as HTMLElement;
const computed = getComputedStyle(source);
for (const property of textStyles)
copy.style.setProperty(property, computed.getPropertyValue(property));
copy.removeAttribute("contenteditable");
});
const resources = new Map<string, Promise<string>>();
await Promise.all(
Array.from(clone.querySelectorAll("img, image")).map(async (node) => {
const attribute = node.tagName.toLowerCase() === "img" ? "src" : "href";
const url =
node.getAttribute(attribute) ||
node.getAttributeNS("http://www.w3.org/1999/xlink", "href");
if (!url || url.startsWith("data:")) return;
if (!resources.has(url)) resources.set(url, embedResource(url));
node.setAttribute(attribute, await resources.get(url)!);
node.removeAttributeNS("http://www.w3.org/1999/xlink", "href");
})
);
const namespace = "http://www.w3.org/2000/svg";
const svg = document.createElementNS(namespace, "svg");
svg.setAttribute("width", String(width));
svg.setAttribute("height", String(height));
svg.setAttribute(
"viewBox",
`${bounds.x - padding} ${bounds.y - padding} ${width} ${height}`
);
for (const defs of PlaitBoard.getHost(board).querySelectorAll(
":scope > defs"
))
svg.append(defs.cloneNode(true));
const style = document.createElementNS(namespace, "style");
const weights = [400, 500, 700, 800];
const names = ["Regular", "Medium", "Bold", "Extrabold"];
style.textContent = (
await Promise.all(
names.map(async (name, index) => {
const data = await embedResource(`/fonts/xmind/NeverMind-${name}.ttf`);
return `@font-face{font-family:NeverMind;src:url('${data}') format('truetype');font-weight:${weights[index]};}`;
})
)
).join("\n");
svg.append(style, clone);
const image = new Image();
const svgData = new XMLSerializer().serializeToString(svg);
// A self-contained data URI keeps foreignObject content safe to draw on canvas.
image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgData)}`;
await image.decode();
const canvas = document.createElement("canvas");
canvas.width = pixelWidth;
canvas.height = pixelHeight;
const context = canvas.getContext("2d");
if (!context) throw new Error("浏览器无法生成图片");
if (!options.transparent) {
context.fillStyle = options.backgroundColor;
context.fillRect(0, 0, pixelWidth, pixelHeight);
}
context.drawImage(image, 0, 0, pixelWidth, pixelHeight);
const blob = await new Promise<Blob>((resolve, reject) =>
canvas.toBlob(
(value) => (value ? resolve(value) : reject(new Error("图片生成失败"))),
"image/png"
)
);
return { blob, width: pixelWidth, height: pixelHeight };
}