Initial project baseline

This commit is contained in:
2026-07-04 02:40:45 +08:00
commit d5d8caef2f
86 changed files with 15590 additions and 0 deletions
+147
View File
@@ -0,0 +1,147 @@
import { CanvasDocument, StickerAsset } from '../types';
import { 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(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 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)}`;
}
if (asset.source.startsWith('data:')) {
return asset.source;
}
// Backend URL: fetch and inline so the exported SVG is self-contained
return fetchBlobAsDataUrl(asset.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) {
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 href = await resolveStickerHref(asset);
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>();
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 }),
});
}
files.push({
name: uniqueSvgName('总效果', used),
content: await serializeDocument(doc, stickerById, { includeBackground: true }),
});
return createZip(files);
}
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}