import { CanvasDocument, StickerAsset } from '../types'; import { apiUrl } from './api'; import { hasCanvasBackground, normalizeDocument, pxToMm } from './canvasDocument'; import { createZip } from './zip'; export interface SerializeOptions { layerIds?: string[]; includeBackground?: boolean; } async function fetchBlobAsDataUrl(url: string): Promise { const res = await fetch(apiUrl(url)); if (!res.ok) throw new Error(`fetch failed: ${url} (${res.status})`); const blob = await res.blob(); return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onloadend = () => resolve(reader.result as string); reader.onerror = reject; reader.readAsDataURL(blob); }); } async function fetchText(url: string): Promise { const res = await fetch(apiUrl(url)); if (!res.ok) throw new Error(`fetch failed: ${url} (${res.status})`); return res.text(); } async function resolveStickerSvg(asset: StickerAsset): Promise { if (asset.type !== 'svg') return null; const source = asset.source.trim(); if (!source) return null; if (source.startsWith('<') || source.startsWith(' { const source = asset.source.trim(); if (!source) return null; if (source.startsWith('data:')) return source; return fetchBlobAsDataUrl(source); } export async function serializeDocument( documentModel: CanvasDocument, stickerById: Map, options: SerializeOptions = {}, ) { const doc = normalizeDocument(documentModel); const layerFilter = options.layerIds ? new Set(options.layerIds) : null; const visibleLayers = new Set((doc.layers || []).filter(layer => layer.visible !== false).map(layer => layer.id)); const widthMm = pxToMm(doc.width).toFixed(1); const heightMm = pxToMm(doc.height).toFixed(1); const parts = [ ``, ]; if (options.includeBackground !== false && hasCanvasBackground(doc.background)) { parts.push(``); } for (const element of doc.elements) { const layerId = element.layerId || doc.layers?.[0]?.id; if (layerFilter && (!layerId || !layerFilter.has(layerId))) continue; if (!layerFilter && layerId && !visibleLayers.has(layerId)) continue; const transform = `translate(${element.x} ${element.y}) rotate(${element.rotation} ${element.width / 2} ${element.height / 2})`; const opacity = Number.isFinite(element.opacity) ? element.opacity : 1; if (element.type === 'sticker') { const asset = stickerById.get(element.assetId); if (!asset) continue; const svg = await resolveStickerSvg(asset); if (svg) { const inline = serializeInlineSvgSticker(svg, element.width, element.height, transform, opacity, asset.tint === 'gray'); if (inline) parts.push(inline); continue; } const href = await resolveStickerImageHref(asset); if (!href) continue; const filter = asset.tint === 'gray' ? ' style="filter: grayscale(1)"' : ''; parts.push(``); continue; } if (element.type === 'text') { parts.push( `${escapeXml(element.text)}`, ); continue; } if (element.type === 'rect') { parts.push(``); continue; } if (element.type === 'ellipse') { parts.push(``); continue; } parts.push(``); } parts.push(''); return parts.join('\n'); } export async function createLayerExportZip( documentModel: CanvasDocument, stickerById: Map, selectedLayerIds: string[], selectedFolderIds: string[], ) { const doc = normalizeDocument(documentModel); const files: { name: string; content: string }[] = []; const used = new Map(); if (hasCanvasBackground(doc.background)) { files.push({ name: uniqueSvgName('背景', used), content: serializeBackgroundLayer(doc), }); } for (const layerId of selectedLayerIds) { const layer = doc.layers?.find(item => item.id === layerId); if (!layer) continue; files.push({ name: uniqueSvgName(layer.name, used), content: await serializeDocument(doc, stickerById, { layerIds: [layer.id], includeBackground: false }), }); } for (const folderId of selectedFolderIds) { const folder = doc.layerFolders?.find(item => item.id === folderId); if (!folder) continue; files.push({ name: uniqueSvgName(folder.name, used), content: await serializeDocument(doc, stickerById, { layerIds: folder.layerIds, includeBackground: false }), }); } return createZip(files); } function serializeBackgroundLayer(documentModel: CanvasDocument) { const doc = normalizeDocument(documentModel); const widthMm = pxToMm(doc.width).toFixed(1); const heightMm = pxToMm(doc.height).toFixed(1); return [ ``, ``, '', ].join('\n'); } function serializeInlineSvgSticker( svgText: string, targetWidth: number, targetHeight: number, transform: string, opacity: number, grayscale: boolean, ) { const parsed = parseInlineSvg(svgText); if (!parsed || !parsed.content.trim()) return null; const scale = Math.min(targetWidth / parsed.width, targetHeight / parsed.height); const safeScale = Number.isFinite(scale) && scale > 0 ? scale : 1; const offsetX = (targetWidth - parsed.width * safeScale) / 2; const offsetY = (targetHeight - parsed.height * safeScale) / 2; const contentTransform = [ transform, `translate(${formatSvgNumber(offsetX)} ${formatSvgNumber(offsetY)})`, `scale(${formatSvgNumber(safeScale)})`, `translate(${formatSvgNumber(-parsed.minX)} ${formatSvgNumber(-parsed.minY)})`, ].join(' '); const filter = grayscale ? ' style="filter: grayscale(1)"' : ''; return `\n${parsed.content}\n`; } function parseInlineSvg(svgText: string) { const parser = new DOMParser(); const xml = parser.parseFromString(svgText, 'image/svg+xml'); if (xml.querySelector('parsererror')) return null; const root = xml.documentElement; if (!root || root.localName.toLowerCase() !== 'svg') return null; removeEmptyReferences(root); const viewBox = parseViewBox(root.getAttribute('viewBox') || root.getAttribute('viewbox')); const sourceWidth = viewBox?.width || parseSvgLength(root.getAttribute('width')); const sourceHeight = viewBox?.height || parseSvgLength(root.getAttribute('height')); const width = sourceWidth && sourceWidth > 0 ? sourceWidth : viewBox?.width || 1; const height = sourceHeight && sourceHeight > 0 ? sourceHeight : viewBox?.height || 1; const minX = viewBox?.minX || 0; const minY = viewBox?.minY || 0; const serializer = new XMLSerializer(); const body = Array.from(root.childNodes) .map(node => serializer.serializeToString(node)) .join('\n'); const rootAttributes = serializeRootPresentationAttributes(root); const content = rootAttributes ? `\n${body}\n` : body; return { content, minX, minY, width, height }; } function removeEmptyReferences(root: Element) { Array.from(root.querySelectorAll('*')).forEach(element => { const hrefAttributes = Array.from(element.attributes).filter(attr => attr.localName === 'href' || attr.name === 'href' || attr.name.endsWith(':href')); hrefAttributes.forEach(attr => { if (!attr.value.trim()) element.removeAttribute(attr.name); }); if (element.localName.toLowerCase() === 'image') { const href = element.getAttribute('href') || element.getAttribute('xlink:href') || element.getAttributeNS('http://www.w3.org/1999/xlink', 'href'); if (!href || !href.trim()) element.remove(); } }); } function serializeRootPresentationAttributes(root: Element) { const excluded = new Set([ 'height', 'id', 'preserveAspectRatio', 'version', 'viewBox', 'viewbox', 'width', 'x', 'y', ]); return Array.from(root.attributes) .filter(attr => !excluded.has(attr.name) && !attr.name.startsWith('xmlns')) .map(attr => `${attr.name}="${escapeXml(attr.value)}"`) .join(' '); } function parseViewBox(value: string | null) { if (!value) return null; const parts = value.trim().split(/[\s,]+/).map(Number); if (parts.length !== 4 || parts.some(part => !Number.isFinite(part))) return null; const [minX, minY, width, height] = parts; if (width <= 0 || height <= 0) return null; return { minX, minY, width, height }; } function parseSvgLength(value: string | null) { if (!value) return null; const match = value.trim().match(/^(-?\d+(?:\.\d+)?)/); if (!match) return null; const parsed = Number.parseFloat(match[1]); return Number.isFinite(parsed) && parsed > 0 ? parsed : null; } function decodeDataUrl(dataUrl: string) { const commaIndex = dataUrl.indexOf(','); if (commaIndex < 0) return null; const header = dataUrl.slice(0, commaIndex); const payload = dataUrl.slice(commaIndex + 1); if (!/image\/svg\+xml/i.test(header)) return null; try { return /;base64/i.test(header) ? atob(payload) : decodeURIComponent(payload); } catch { return null; } } function formatSvgNumber(value: number) { return Number.isFinite(value) ? Number.parseFloat(value.toFixed(4)).toString() : '0'; } function uniqueSvgName(name: string, used: Map) { const base = sanitizeFileName(name || '未命名'); const count = used.get(base) || 0; used.set(base, count + 1); return `${base}${count > 0 ? `-${count + 1}` : ''}.svg`; } function sanitizeFileName(value: string) { return value.replace(/[\\/:*?"<>|]/g, '-').replace(/\s+/g, ' ').trim().slice(0, 80) || '未命名'; } export function escapeXml(value: string) { return value .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); }