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 { 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>(); 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((resolve, reject) => canvas.toBlob( (value) => (value ? resolve(value) : reject(new Error("图片生成失败"))), "image/png" ) ); return { blob, width: pixelWidth, height: pixelHeight }; }