Add floating canvas panels, theme settings, and SVG line-spacing analysis.
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.
This commit is contained in:
+165
-17
@@ -1,5 +1,6 @@
|
||||
import { CanvasDocument, StickerAsset } from '../types';
|
||||
import { normalizeDocument, pxToMm } from './canvasDocument';
|
||||
import { apiUrl } from './api';
|
||||
import { hasCanvasBackground, normalizeDocument, pxToMm } from './canvasDocument';
|
||||
import { createZip } from './zip';
|
||||
|
||||
export interface SerializeOptions {
|
||||
@@ -8,7 +9,7 @@ export interface SerializeOptions {
|
||||
}
|
||||
|
||||
async function fetchBlobAsDataUrl(url: string): Promise<string> {
|
||||
const res = await fetch(url);
|
||||
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) => {
|
||||
@@ -19,16 +20,30 @@ async function fetchBlobAsDataUrl(url: string): Promise<string> {
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveStickerHref(asset: StickerAsset): Promise<string> {
|
||||
// Legacy inline content (still supported for imported files / tests)
|
||||
if (asset.type === 'svg' && asset.source.trim().startsWith('<svg')) {
|
||||
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(asset.source)}`;
|
||||
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 (asset.source.startsWith('data:')) {
|
||||
return asset.source;
|
||||
if (source.startsWith('data:')) {
|
||||
return decodeDataUrl(source);
|
||||
}
|
||||
// Backend URL: fetch and inline so the exported SVG is self-contained
|
||||
return fetchBlobAsDataUrl(asset.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(
|
||||
@@ -45,7 +60,7 @@ export async function serializeDocument(
|
||||
`<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) {
|
||||
if (options.includeBackground !== false && hasCanvasBackground(doc.background)) {
|
||||
parts.push(`<rect width="100%" height="100%" fill="${escapeXml(doc.background)}"/>`);
|
||||
}
|
||||
|
||||
@@ -60,7 +75,14 @@ export async function serializeDocument(
|
||||
if (element.type === 'sticker') {
|
||||
const asset = stickerById.get(element.assetId);
|
||||
if (!asset) continue;
|
||||
const href = await resolveStickerHref(asset);
|
||||
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;
|
||||
@@ -100,6 +122,13 @@ export async function createLayerExportZip(
|
||||
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;
|
||||
@@ -118,14 +147,133 @@ export async function createLayerExportZip(
|
||||
});
|
||||
}
|
||||
|
||||
files.push({
|
||||
name: uniqueSvgName('总效果', used),
|
||||
content: await serializeDocument(doc, stickerById, { includeBackground: true }),
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user