From 630be22707783892b02a7f3805d415b3897dd302 Mon Sep 17 00:00:00 2001 From: xigua Date: Fri, 2 Jan 2026 19:30:23 +0800 Subject: [PATCH] feat: add support for exporting current page as PNG --- .../Header/components/Toolbar/menu/Menu.tsx | 7 +++ apps/suika/src/locale/en.json | 1 + apps/suika/src/locale/zh.json | 1 + packages/core/src/clipboard.ts | 2 +- packages/core/src/graphics/frame/frame.ts | 4 ++ .../core/src/graphics/graphics/graphics.ts | 3 + packages/core/src/service/export_service.ts | 33 ++++++++-- packages/core/src/to_svg.ts | 61 ++++++++++++++++++- 8 files changed, 106 insertions(+), 6 deletions(-) diff --git a/apps/suika/src/components/Header/components/Toolbar/menu/Menu.tsx b/apps/suika/src/components/Header/components/Toolbar/menu/Menu.tsx index d13f0063e..4d9f71e3f 100644 --- a/apps/suika/src/components/Header/components/Toolbar/menu/Menu.tsx +++ b/apps/suika/src/components/Header/components/Toolbar/menu/Menu.tsx @@ -48,6 +48,10 @@ export const Menu: FC = () => { key: 'exportCurrentPageAsSVG', label: t({ id: 'export.currentPageAsSVG' }), }, + { + key: 'exportCurrentPageAsPNG', + label: t({ id: 'export.currentPageAsPNG' }), + }, ], }, { @@ -101,6 +105,9 @@ export const Menu: FC = () => { case 'exportCurrentPageAsSVG': exportService.exportCurrentPageSVG(editor); break; + case 'exportCurrentPageAsPNG': + exportService.exportCurrentPagePNG(editor); + break; case 'keepToolSelectedAfterUse': case 'invertZoomDirection': case 'highlightLayersOnHover': diff --git a/apps/suika/src/locale/en.json b/apps/suika/src/locale/en.json index b514eb55e..8e20db8c1 100644 --- a/apps/suika/src/locale/en.json +++ b/apps/suika/src/locale/en.json @@ -60,6 +60,7 @@ "import.originFile": "Import local file", "export.originFile": "Save local copy", "export.currentPageAsSVG": "Export current page as SVG", + "export.currentPageAsPNG": "Export current page as PNG", "preference": "Preference", "keepToolSelectedAfterUse": "Keep tool selected after use", diff --git a/apps/suika/src/locale/zh.json b/apps/suika/src/locale/zh.json index c88cd4ff5..b95cf356c 100644 --- a/apps/suika/src/locale/zh.json +++ b/apps/suika/src/locale/zh.json @@ -59,6 +59,7 @@ "import.originFile": "从本地导入", "export.originFile": "导出到本地", "export.currentPageAsSVG": "导出当前页面为 SVG", + "export.currentPageAsPNG": "导出当前页面为 PNG", "preference": "偏好设置", "keepToolSelectedAfterUse": "使用工具后保持选中状态", diff --git a/packages/core/src/clipboard.ts b/packages/core/src/clipboard.ts index a6fe12a22..3fafb0ee8 100644 --- a/packages/core/src/clipboard.ts +++ b/packages/core/src/clipboard.ts @@ -132,7 +132,7 @@ export class ClipboardManager { if (graphs.length === 0) { return; } - const svgStr = toSVG(graphs); + const svgStr = toSVG(graphs).svg; navigator.clipboard.writeText(svgStr).then(() => { console.log('SVG copied'); }); diff --git a/packages/core/src/graphics/frame/frame.ts b/packages/core/src/graphics/frame/frame.ts index 6dc730bc1..cd35400ad 100644 --- a/packages/core/src/graphics/frame/frame.ts +++ b/packages/core/src/graphics/frame/frame.ts @@ -315,6 +315,10 @@ export class SuikaFrame extends SuikaGraphics { } override toSVGSegment(offset: IPoint) { + if (!this.isVisible()) { + return ''; + } + let content = ''; if (!this.isGroup()) { content += super.toSVGSegment(offset); diff --git a/packages/core/src/graphics/graphics/graphics.ts b/packages/core/src/graphics/graphics/graphics.ts index 4ad5ba751..eec8b9bc6 100644 --- a/packages/core/src/graphics/graphics/graphics.ts +++ b/packages/core/src/graphics/graphics/graphics.ts @@ -728,6 +728,9 @@ export class SuikaGraphics { } toSVGSegment(offset: IPoint) { + if (!this.isVisible()) { + return ''; + } const tagHead = this.getSVGTagHead(offset); if (!tagHead) { console.warn( diff --git a/packages/core/src/service/export_service.ts b/packages/core/src/service/export_service.ts index 4251653ba..e4e5e08c9 100644 --- a/packages/core/src/service/export_service.ts +++ b/packages/core/src/service/export_service.ts @@ -1,5 +1,5 @@ import { type SuikaEditor } from '../editor'; -import { toSVG } from '../to_svg'; +import { toPNGBlob, toSVG } from '../to_svg'; export const exportService = { exportOriginFile: (editor: SuikaEditor, filename = 'design') => { @@ -12,7 +12,9 @@ export const exportService = { exportCurrentPageSVG: (editor: SuikaEditor) => { const currentPage = editor.doc.getCurrentCanvas(); - const graphicsItems = currentPage.getChildren(); + const graphicsItems = currentPage + .getChildren() + .filter((item) => item.isVisible()); if (graphicsItems.length === 0) { // TODO: if no graphics items, show error message @@ -20,11 +22,34 @@ export const exportService = { return; } - const svg = toSVG(graphicsItems); + const svg = toSVG(graphicsItems).svg; const blob = new Blob([svg], { type: 'image/svg+xml', }); - download(blob, 'design.svg'); + + const suffix = currentPage.attrs.objectName; + download(blob, `${suffix}.svg`); + }, + + exportCurrentPagePNG: async (editor: SuikaEditor) => { + const currentPage = editor.doc.getCurrentCanvas(); + const graphicsItems = currentPage + .getChildren() + .filter((item) => item.isVisible()); + + if (graphicsItems.length === 0) { + // TODO: if no graphics items, show error message + console.error('No graphics items to export'); + return; + } + + try { + const blob = await toPNGBlob(graphicsItems); + const suffix = currentPage.attrs.objectName; + download(blob, `${suffix}.png`); + } catch (error) { + console.error('Failed to export PNG:', error); + } }, }; diff --git a/packages/core/src/to_svg.ts b/packages/core/src/to_svg.ts index 32de361ff..0dcb9d41d 100644 --- a/packages/core/src/to_svg.ts +++ b/packages/core/src/to_svg.ts @@ -3,6 +3,8 @@ import { boxToRect, mergeBoxes } from '@suika/geo'; import { type SuikaGraphics } from './graphics'; export const toSVG = (graphicsArr: SuikaGraphics[]) => { + graphicsArr = graphicsArr.filter((item) => item.isVisible()); + // FIXME: to sort const mergedBbox = mergeBoxes( graphicsArr.map((el) => el.getBboxWithStroke()), @@ -21,5 +23,62 @@ export const toSVG = (graphicsArr: SuikaGraphics[]) => { content += graphics.toSVGSegment(offset); } - return svgHead + content + svgTail; + return { + width: mergedRect.width, + height: mergedRect.height, + svg: svgHead + content + svgTail, + }; +}; + +export const toPNGBlob = async ( + graphicsArr: SuikaGraphics[], +): Promise => { + const svgData = toSVG(graphicsArr); + const { svg, width, height } = svgData; + + const svgBlob = new Blob([svg], { type: 'image/svg+xml;charset=utf-8' }); + const svgDataUrl = URL.createObjectURL(svgBlob); + + // create image object and load SVG + const img = new Image(); + img.crossOrigin = 'anonymous'; + + return new Promise((resolve, reject) => { + img.onload = () => { + try { + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext('2d'); + + if (!ctx) { + URL.revokeObjectURL(svgDataUrl); + reject(new Error('Failed to get canvas context')); + return; + } + + ctx.drawImage(img, 0, 0); + + // convert to blob + canvas.toBlob((blob) => { + URL.revokeObjectURL(svgDataUrl); + if (blob) { + resolve(blob); + } else { + reject(new Error('Failed to convert canvas to blob')); + } + }, 'image/png'); + } catch (error) { + URL.revokeObjectURL(svgDataUrl); + reject(error); + } + }; + + img.onerror = () => { + URL.revokeObjectURL(svgDataUrl); + reject(new Error('Failed to load SVG')); + }; + + img.src = svgDataUrl; + }); };