Initial project baseline
This commit is contained in:
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
Reference in New Issue
Block a user