Canvas Studio now uses dockable floating panels, app settings/help navigation, and improved SVG export; the backend adds an SVG line-spacing analysis API with SciPy acceleration and new design templates.
296 lines
11 KiB
TypeScript
296 lines
11 KiB
TypeScript
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<string> {
|
|
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<string> {
|
|
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<string | null> {
|
|
if (asset.type !== 'svg') return null;
|
|
const source = asset.source.trim();
|
|
if (!source) return null;
|
|
if (source.startsWith('<') || source.startsWith('<?xml')) {
|
|
return source;
|
|
}
|
|
if (source.startsWith('data:')) {
|
|
return decodeDataUrl(source);
|
|
}
|
|
return fetchText(source);
|
|
}
|
|
|
|
async function resolveStickerImageHref(asset: StickerAsset): Promise<string | null> {
|
|
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<string, StickerAsset>,
|
|
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 = [
|
|
`<svg xmlns="http://www.w3.org/2000/svg" width="${widthMm}mm" height="${heightMm}mm" viewBox="0 0 ${doc.width} ${doc.height}">`,
|
|
];
|
|
|
|
if (options.includeBackground !== false && hasCanvasBackground(doc.background)) {
|
|
parts.push(`<rect width="100%" height="100%" fill="${escapeXml(doc.background)}"/>`);
|
|
}
|
|
|
|
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(`<image href="${escapeXml(href)}" x="0" y="0" width="${element.width}" height="${element.height}" preserveAspectRatio="xMidYMid meet" opacity="${opacity}" transform="${transform}"${filter}/>`);
|
|
continue;
|
|
}
|
|
|
|
if (element.type === 'text') {
|
|
parts.push(
|
|
`<text x="0" y="${element.fontSize}" fill="${escapeXml(element.fill)}" font-size="${element.fontSize}" font-family="${escapeXml(element.fontFamily)}" font-weight="${escapeXml(element.fontWeight)}" opacity="${opacity}" transform="${transform}">${escapeXml(element.text)}</text>`,
|
|
);
|
|
continue;
|
|
}
|
|
|
|
if (element.type === 'rect') {
|
|
parts.push(`<rect x="0" y="0" width="${element.width}" height="${element.height}" fill="${escapeXml(element.fill)}" stroke="${escapeXml(element.stroke)}" stroke-width="${element.strokeWidth}" opacity="${opacity}" transform="${transform}"/>`);
|
|
continue;
|
|
}
|
|
|
|
if (element.type === 'ellipse') {
|
|
parts.push(`<ellipse cx="${element.width / 2}" cy="${element.height / 2}" rx="${element.width / 2}" ry="${element.height / 2}" fill="${escapeXml(element.fill)}" stroke="${escapeXml(element.stroke)}" stroke-width="${element.strokeWidth}" opacity="${opacity}" transform="${transform}"/>`);
|
|
continue;
|
|
}
|
|
|
|
parts.push(`<line x1="0" y1="${element.height / 2}" x2="${element.width}" y2="${element.height / 2}" stroke="${escapeXml(element.stroke)}" stroke-width="${element.strokeWidth}" stroke-linecap="round" opacity="${opacity}" transform="${transform}"/>`);
|
|
}
|
|
|
|
parts.push('</svg>');
|
|
return parts.join('\n');
|
|
}
|
|
|
|
export async function createLayerExportZip(
|
|
documentModel: CanvasDocument,
|
|
stickerById: Map<string, StickerAsset>,
|
|
selectedLayerIds: string[],
|
|
selectedFolderIds: string[],
|
|
) {
|
|
const doc = normalizeDocument(documentModel);
|
|
const files: { name: string; content: string }[] = [];
|
|
const used = new Map<string, number>();
|
|
|
|
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 [
|
|
`<svg xmlns="http://www.w3.org/2000/svg" width="${widthMm}mm" height="${heightMm}mm" viewBox="0 0 ${doc.width} ${doc.height}">`,
|
|
`<rect width="100%" height="100%" fill="${escapeXml(doc.background)}"/>`,
|
|
'</svg>',
|
|
].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 `<g opacity="${formatSvgNumber(opacity)}" transform="${contentTransform}"${filter}>\n${parsed.content}\n</g>`;
|
|
}
|
|
|
|
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 ? `<g ${rootAttributes}>\n${body}\n</g>` : 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<string, number>) {
|
|
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, '"')
|
|
.replace(/'/g, ''');
|
|
}
|