feat: 初始化画个标仓库 - AI驱动设计系统(BYOK模式) - 224源/371504图标+25海报源+130技能
This commit is contained in:
@@ -0,0 +1,430 @@
|
||||
/**
|
||||
* 画个标 · 核心:导出功能
|
||||
* 支持:SVG 矢量导出、PNG / JPG 位图导出(多尺寸规格)、PDF 文档导出
|
||||
*/
|
||||
const HGB_Exporter = (function () {
|
||||
'use strict';
|
||||
|
||||
const SVG_NS = 'http://www.w3.org/2000/svg';
|
||||
|
||||
/** 尺寸规格预设(PNG/JPG 应用场景) */
|
||||
const SIZES = [
|
||||
{ name: '社交媒体头像', size: 256, note: '微信/微博头像' },
|
||||
{ name: '方形通用', size: 512, note: '网站 LOGO' },
|
||||
{ name: '高清印刷', size: 1024, note: '名片/宣传物料' },
|
||||
{ name: '超清大图', size: 2048, note: '海报/横幅' }
|
||||
];
|
||||
|
||||
/* ============ SVG 构建 ============ */
|
||||
|
||||
/** 估算文本宽度(与 generator 一致:中文全角、西文 0.55、计入字距) */
|
||||
function textWidthOf(text, fontSize, spacing) {
|
||||
const t = String(text || '');
|
||||
const cn = (t.match(/[\u4e00-\u9fa5]/g) || []).length;
|
||||
const other = t.length - cn;
|
||||
return cn * fontSize + other * fontSize * 0.55 + Math.max(0, t.length - 1) * (spacing || 0);
|
||||
}
|
||||
|
||||
/** 元素视觉包围盒(文字按估算宽度水平居中、按字号定高;环形文字用外接圆盒) */
|
||||
function elementBox(el) {
|
||||
if (!el || el.visible === false) return null;
|
||||
if (el.type === 'text' && !el.arc) {
|
||||
const fs = el.fontSize || 40;
|
||||
const w = Math.max(textWidthOf(el.text, fs, el.letterSpacing), 8);
|
||||
const bw = el.w || w, bh = el.h || fs;
|
||||
return { x: el.x + (bw - w) / 2, y: el.y + (bh - fs) / 2, w, h: fs };
|
||||
}
|
||||
return { x: el.x, y: el.y, w: el.w, h: el.h };
|
||||
}
|
||||
|
||||
/** 全部可见元素的内容包围盒(无内容返回 null) */
|
||||
function contentBox(elements) {
|
||||
let box = null;
|
||||
for (const el of elements || []) {
|
||||
const b = elementBox(el);
|
||||
if (!b) continue;
|
||||
if (!box) box = { x: b.x, y: b.y, w: b.w, h: b.h };
|
||||
else {
|
||||
const x2 = Math.max(box.x + box.w, b.x + b.w);
|
||||
const y2 = Math.max(box.y + box.h, b.y + b.h);
|
||||
box.x = Math.min(box.x, b.x);
|
||||
box.y = Math.min(box.y, b.y);
|
||||
box.w = x2 - box.x;
|
||||
box.h = y2 - box.y;
|
||||
}
|
||||
}
|
||||
return box;
|
||||
}
|
||||
|
||||
/**
|
||||
* 由任意元素数组构建 SVG(用于方案库预览/导出)
|
||||
* @param {Array} elements 元素数组
|
||||
* @param {string} bgColor 背景色
|
||||
* @param {number} [size] 输出边长(默认画布 600)
|
||||
* @param {object} [opts] { trim: 是否按内容包围盒裁剪 viewBox(内容顶满画布,适合头像/favicon),
|
||||
* pad: 内容四周留白比例(默认 0.05) }
|
||||
*/
|
||||
function buildSvgFrom(elements, bgColor, size, opts) {
|
||||
const W = size || HGB_Store.CANVAS_W, H = size || HGB_Store.CANVAS_H;
|
||||
const bg = bgColor || '#FFFFFF';
|
||||
|
||||
// 内容包围盒裁剪:viewBox 收敛到内容 + 少量留白,内容顶满画布
|
||||
let vx = 0, vy = 0, vw = W, vh = H;
|
||||
if (opts && opts.trim) {
|
||||
const box = contentBox(elements);
|
||||
if (box) {
|
||||
const pad = (opts.pad != null ? opts.pad : 0.05) * Math.max(box.w, box.h);
|
||||
vx = box.x - pad; vy = box.y - pad;
|
||||
vw = box.w + pad * 2; vh = box.h + pad * 2;
|
||||
if (vw < 4) { vx -= 2; vw = 4; }
|
||||
if (vh < 4) { vy -= 2; vh = 4; }
|
||||
}
|
||||
}
|
||||
|
||||
// 收集渐变 defs(元素以 el.grad 声明,需注入 <defs> 才能用 url(#id) 填充)
|
||||
// 收集环形文字路径(text 带 el.arc 时注入 path)
|
||||
let defs = '';
|
||||
const grads = {};
|
||||
const arcs = [];
|
||||
for (const el of elements) {
|
||||
if (el && el.grad && el.grad.id && !grads[el.grad.id]) grads[el.grad.id] = el.grad;
|
||||
if (el && el.arc) arcs.push(el);
|
||||
}
|
||||
if (Object.keys(grads).length || arcs.length) {
|
||||
let d = '<defs>';
|
||||
for (const id of Object.keys(grads)) {
|
||||
const g = grads[id];
|
||||
if (g.type === 'radial') {
|
||||
d += `<radialGradient id="${esc(id)}" cx="0.5" cy="0.36" r="0.78">` +
|
||||
`<stop offset="0%" stop-color="${esc(g.from)}"/><stop offset="100%" stop-color="${esc(g.to)}"/>` +
|
||||
'</radialGradient>';
|
||||
} else {
|
||||
d += `<linearGradient id="${esc(id)}" x1="0" y1="0" x2="1" y2="1">` +
|
||||
`<stop offset="0%" stop-color="${esc(g.from)}"/><stop offset="100%" stop-color="${esc(g.to)}"/>` +
|
||||
'</linearGradient>';
|
||||
}
|
||||
}
|
||||
for (const el of arcs) {
|
||||
d += `<path id="arc_${esc(el.id)}" d="${esc(arcPathD(el.arc))}" fill="none" stroke="none"/>`;
|
||||
}
|
||||
defs = d + '</defs>';
|
||||
}
|
||||
|
||||
let inner = '';
|
||||
inner += `<rect x="${vx}" y="${vy}" width="${vw}" height="${vh}" fill="${esc(bg)}"/>`;
|
||||
if (defs) inner += defs;
|
||||
|
||||
for (const el of elements) {
|
||||
if (!el.visible) continue;
|
||||
inner += buildElementSvg(el);
|
||||
}
|
||||
|
||||
return '<?xml version="1.0" encoding="UTF-8"?>\n' +
|
||||
`<svg xmlns="${SVG_NS}" xmlns:xlink="http://www.w3.org/1999/xlink" width="${W}" height="${H}" viewBox="${vx} ${vy} ${vw} ${vh}">` +
|
||||
inner + '</svg>';
|
||||
}
|
||||
|
||||
/** 生成当前设计的导出 SVG 字符串(不含选中框/手柄) */
|
||||
function buildExportSvg(bgColor) {
|
||||
const s = HGB_Store.get();
|
||||
return buildSvgFrom(s.elements, bgColor || s.bg || '#FFFFFF');
|
||||
}
|
||||
|
||||
function buildElementSvg(el) {
|
||||
const cx = el.x + el.w / 2;
|
||||
const cy = el.y + el.h / 2;
|
||||
const opacity = el.opacity != null ? ` opacity="${el.opacity}"` : '';
|
||||
|
||||
if (el.type === 'icon') {
|
||||
// 图标源为 24x24 viewBox:先缩放到元素尺寸,再平移到目标位置
|
||||
const sx = (el.w || 24) / 24, sy = (el.h || 24) / 24;
|
||||
let t = `translate(${el.x} ${el.y}) scale(${sx.toFixed(4)} ${sy.toFixed(4)})`;
|
||||
if (el.rotation) {
|
||||
// 绕元素中心旋转(在平移缩放后的坐标系中)
|
||||
t = `translate(${cx} ${cy}) rotate(${el.rotation}) translate(${-cx} ${-cy}) ${t}`;
|
||||
}
|
||||
return `<g transform="${t}"${opacity}>${applyIconStyle(el)}</g>`;
|
||||
}
|
||||
if (el.type === 'text') {
|
||||
let transform = '';
|
||||
if (el.rotation) {
|
||||
transform = ` transform="translate(${cx} ${cy}) rotate(${el.rotation}) translate(${-cx} ${-cy})"`;
|
||||
}
|
||||
const f = el.fontFamily || 'sans-serif';
|
||||
const attrs = [
|
||||
'text-anchor="middle"', 'dominant-baseline="central"',
|
||||
'font-family="' + esc(f) + '"',
|
||||
'font-size="' + (el.fontSize || 40) + '"',
|
||||
'font-weight="' + (el.fontWeight || 400) + '"',
|
||||
'fill="' + esc(el.fill || '#000') + '"',
|
||||
el.letterSpacing ? 'letter-spacing="' + el.letterSpacing + '"' : '',
|
||||
el.stroke && el.stroke !== 'none'
|
||||
? ` stroke="${esc(el.stroke)}" stroke-width="${el.strokeWidth}"` : '',
|
||||
'xml:space="preserve"'
|
||||
].filter(Boolean).join(' ');
|
||||
if (el.arc) {
|
||||
// 环形文字:沿注入的圆弧 path 排列
|
||||
return `<text${transform}${opacity} ${attrs}><textPath href="#arc_${esc(el.id)}" startOffset="50%">${esc(el.text || '')}</textPath></text>`;
|
||||
}
|
||||
return `<text${transform}${opacity} x="${cx}" y="${cy}" ${attrs}>${esc(el.text || '')}</text>`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/** 生成圆弧路径 d(环形文字用,与 renderer 保持一致) */
|
||||
function arcPathD(arc) {
|
||||
const a1 = arc.start * Math.PI / 180;
|
||||
const a2 = (arc.start + arc.sweep) * Math.PI / 180;
|
||||
const x1 = arc.cx + arc.r * Math.cos(a1);
|
||||
const y1 = arc.cy + arc.r * Math.sin(a1);
|
||||
const x2 = arc.cx + arc.r * Math.cos(a2);
|
||||
const y2 = arc.cy + arc.r * Math.sin(a2);
|
||||
const large = Math.abs(arc.sweep) > 180 ? 1 : 0;
|
||||
const sweepFlag = arc.sweep < 0 ? 0 : 1; // sweep-flag=0 逆时针(y向下坐标系中负角走下半弧)
|
||||
return 'M ' + x1.toFixed(2) + ' ' + y1.toFixed(2) +
|
||||
' A ' + arc.r + ' ' + arc.r + ' 0 ' + large + ' ' + sweepFlag + ' ' + x2.toFixed(2) + ' ' + y2.toFixed(2);
|
||||
}
|
||||
|
||||
function applyIconStyle(el) {
|
||||
// 多色素材(emoji/品牌)保留原色:直接内嵌原始 svg body,不覆盖 fill/stroke
|
||||
if (el.preserve) return el.svg;
|
||||
const tmp = document.createElementNS(SVG_NS, 'g');
|
||||
tmp.innerHTML = el.svg;
|
||||
tmp.querySelectorAll('path,circle,rect,polygon,polyline,line,ellipse').forEach(p => {
|
||||
if (el.fill && el.fill !== 'none') p.setAttribute('fill', el.fill);
|
||||
else p.setAttribute('fill', 'none');
|
||||
if (el.stroke && el.stroke !== 'none') {
|
||||
p.setAttribute('stroke', el.stroke);
|
||||
// stroke-width 为"视觉像素"宽度:non-scaling-stroke 保证不随整体 scale 放大变粗
|
||||
p.setAttribute('stroke-width', el.strokeWidth);
|
||||
p.setAttribute('vector-effect', 'non-scaling-stroke');
|
||||
p.setAttribute('stroke-linecap', 'round');
|
||||
p.setAttribute('stroke-linejoin', 'round');
|
||||
} else {
|
||||
p.setAttribute('stroke', 'none');
|
||||
}
|
||||
});
|
||||
return tmp.innerHTML;
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s).replace(/&/g, '&').replace(/</g, '<')
|
||||
.replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
/* ============ 位图渲染(SVG -> Canvas) ============ */
|
||||
|
||||
/**
|
||||
* 将当前设计渲染到指定尺寸的 canvas
|
||||
* @param {number} size 输出边长(px)
|
||||
* @param {string|null} bgColor 背景色;null 表示透明
|
||||
*/
|
||||
function renderCanvas(size, bgColor, svgOverride) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const svgStr = svgOverride ||
|
||||
buildExportSvg(bgColor === undefined ? null : bgColor);
|
||||
const blob = new Blob([svgStr], { type: 'image/svg+xml;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (bgColor !== null && bgColor !== undefined) {
|
||||
ctx.fillStyle = bgColor;
|
||||
ctx.fillRect(0, 0, size, size);
|
||||
}
|
||||
ctx.drawImage(img, 0, 0, size, size);
|
||||
URL.revokeObjectURL(url);
|
||||
resolve(canvas);
|
||||
};
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
reject(new Error('SVG 渲染失败,请检查元素内容'));
|
||||
};
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
/* ============ 导出:SVG ============ */
|
||||
function exportSvg(svgOverride, bgColor) {
|
||||
const svgStr = svgOverride || buildExportSvg(bgColor || HGB_Store.get().bg || '#FFFFFF');
|
||||
download(svgStr, 'image/svg+xml', safeName() + '.svg');
|
||||
return svgStr;
|
||||
}
|
||||
|
||||
/* ============ 导出:PNG ============ */
|
||||
async function exportPng(size = 1024, bgColor, svgOverride) {
|
||||
const canvas = await renderCanvas(size, bgColor, svgOverride);
|
||||
const blob = await canvasToBlob(canvas, 'image/png');
|
||||
download(blob, 'image/png', safeName() + '_' + size + 'px.png');
|
||||
return canvas;
|
||||
}
|
||||
|
||||
/* ============ 导出:JPG ============ */
|
||||
async function exportJpg(size = 1024, bgColor = '#FFFFFF', svgOverride) {
|
||||
const canvas = await renderCanvas(size, bgColor, svgOverride);
|
||||
const blob = await canvasToBlob(canvas, 'image/jpeg', 0.92);
|
||||
download(blob, 'image/jpeg', safeName() + '_' + size + 'px.jpg');
|
||||
return canvas;
|
||||
}
|
||||
|
||||
/* ============ 导出:PDF(手写最小 PDF,嵌入 JPEG) ============ */
|
||||
async function exportPdf(size = 1024, bgColor = '#FFFFFF', svgOverride) {
|
||||
// 1. 渲染 JPEG
|
||||
const canvas = await renderCanvas(size, bgColor, svgOverride);
|
||||
const jpegDataUrl = canvas.toDataURL('image/jpeg', 0.92);
|
||||
const jpegBase64 = jpegDataUrl.split(',')[1];
|
||||
|
||||
// 2. 构建 PDF(单页,图片全页)
|
||||
const pt = Math.round(size * 72 / 96); // 96dpi → pt(近似 A4 常用分辨率换算)
|
||||
const pdf = buildPdf(jpegBase64, pt, pt);
|
||||
|
||||
const blob = new Blob([pdf], { type: 'application/pdf' });
|
||||
download(blob, 'application/pdf', safeName() + '_' + size + 'px.pdf');
|
||||
return blob;
|
||||
}
|
||||
|
||||
/**
|
||||
* 手写最小 PDF 1.4 文件
|
||||
* 结构:Catalog → Pages → Page → XObject(Image) + Content stream
|
||||
*/
|
||||
function buildPdf(jpegBase64, pageW, pageH) {
|
||||
// 将 base64 解码为字节
|
||||
const bin = atob(jpegBase64);
|
||||
const imgBytes = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) imgBytes[i] = bin.charCodeAt(i);
|
||||
|
||||
const objects = [];
|
||||
|
||||
// Object 1: Catalog
|
||||
objects.push('<< /Type /Catalog /Pages 2 0 R >>');
|
||||
// Object 2: Pages
|
||||
objects.push(`<< /Type /Pages /Kids [3 0 R] /Count 1 >>`);
|
||||
// Object 3: Page
|
||||
objects.push(
|
||||
`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${pageW} ${pageH}] ` +
|
||||
`/Resources << /XObject << /Im0 4 0 R >> >> /Contents 5 0 R >>`
|
||||
);
|
||||
// Object 4: Image XObject(JPEG 流)
|
||||
objects.push(
|
||||
`<< /Type /XObject /Subtype /Image /Width ${pageW} /Height ${pageH} ` +
|
||||
`/ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length ${imgBytes.length} >>`
|
||||
);
|
||||
// Object 5: Content(绘制图片铺满页面)
|
||||
const content = `q\n${pageW} 0 0 ${pageH} 0 0 cm\n/Im0 Do\nQ`;
|
||||
objects.push(`<< /Length ${content.length} >>\nstream\n${content}\nendstream`);
|
||||
|
||||
// 组装 PDF 字节流
|
||||
const encoder = new TextEncoder();
|
||||
const chunks = [];
|
||||
chunks.push(encoder.encode('%PDF-1.4\n'));
|
||||
const offsets = [];
|
||||
|
||||
for (let i = 0; i < objects.length; i++) {
|
||||
offsets.push(chunks.reduce((a, c) => a + c.length, 0));
|
||||
const header = `${i + 1} 0 obj\n`;
|
||||
chunks.push(encoder.encode(header));
|
||||
if (i === 3) {
|
||||
// 图片对象:字典 + stream
|
||||
chunks.push(encoder.encode(objects[i] + '\nstream\n'));
|
||||
chunks.push(imgBytes);
|
||||
chunks.push(encoder.encode('\nendstream\n'));
|
||||
} else {
|
||||
chunks.push(encoder.encode(objects[i] + '\n'));
|
||||
}
|
||||
chunks.push(encoder.encode('endobj\n'));
|
||||
}
|
||||
|
||||
const xrefStart = chunks.reduce((a, c) => a + c.length, 0);
|
||||
const xref = `xref\n0 ${objects.length + 1}\n` +
|
||||
'0000000000 65535 f \n' +
|
||||
offsets.map(o => String(o).padStart(10, '0') + ' 00000 n \n').join('') +
|
||||
`trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\n` +
|
||||
'startxref\n' + xrefStart + '\n%%EOF';
|
||||
|
||||
chunks.push(encoder.encode(xref));
|
||||
return new Blob(chunks, { type: 'application/pdf' });
|
||||
}
|
||||
|
||||
/* ============ 一键导出全部尺寸 ============ */
|
||||
async function exportAllSizes(bgColor) {
|
||||
const results = [];
|
||||
for (const spec of SIZES) {
|
||||
const canvas = await exportPng(spec.size, bgColor);
|
||||
results.push({ spec, canvas });
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/* ============ 头像导出(圆形/方形 多规格 PNG) ============ */
|
||||
|
||||
/** 将 canvas 圆形裁切(以画布中心为圆心,四周透明) */
|
||||
function circleCropCanvas(canvas) {
|
||||
const s = canvas.width;
|
||||
const out = document.createElement('canvas');
|
||||
out.width = s;
|
||||
out.height = s;
|
||||
const ctx = out.getContext('2d');
|
||||
ctx.beginPath();
|
||||
ctx.arc(s / 2, s / 2, s / 2, 0, Math.PI * 2);
|
||||
ctx.clip();
|
||||
ctx.drawImage(canvas, 0, 0);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量导出头像 PNG(多平台规格)
|
||||
* @param {string} svgOverride 头像 SVG 字符串(应已含背景 rect)
|
||||
* @param {Array<number>} sizes 尺寸列表(px)
|
||||
* @param {object} [opts] { bg: 背景色(null=透明,仅 SVG 内 rect 提供背景),
|
||||
* round: 是否圆形裁切, base: 文件名前缀 }
|
||||
* @returns {Promise<Array<{size:number, canvas:HTMLCanvasElement}>>}
|
||||
*/
|
||||
async function exportAvatarPngs(svgOverride, sizes, opts) {
|
||||
const o = opts || {};
|
||||
const results = [];
|
||||
for (const size of sizes) {
|
||||
const canvas = await renderCanvas(size, o.bg, svgOverride);
|
||||
const out = o.round ? circleCropCanvas(canvas) : canvas;
|
||||
const blob = await canvasToBlob(out, 'image/png');
|
||||
const suffix = o.round ? '_圆形' : '';
|
||||
download(blob, 'image/png', (o.base || safeName()) + '_' + size + suffix + 'px.png');
|
||||
results.push({ size, canvas: out });
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/* ============ 工具 ============ */
|
||||
function canvasToBlob(canvas, type, quality) {
|
||||
return new Promise((resolve, reject) => {
|
||||
canvas.toBlob(b => b ? resolve(b) : reject(new Error(type + ' 编码失败')), type, quality);
|
||||
});
|
||||
}
|
||||
|
||||
function download(data, mime, filename) {
|
||||
const blob = data instanceof Blob ? data : new Blob([data], { type: mime });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
|
||||
function safeName() {
|
||||
const n = (HGB_Store.get().name || '画个标设计').replace(/[\\/:*?"<>|]/g, '_');
|
||||
return n || '画个标设计';
|
||||
}
|
||||
|
||||
return {
|
||||
SIZES, buildExportSvg, buildSvgFrom, renderCanvas,
|
||||
exportSvg, exportPng, exportJpg, exportPdf, exportAllSizes,
|
||||
exportAvatarPngs, circleCropCanvas,
|
||||
buildPdf, download, safeName
|
||||
};
|
||||
})();
|
||||
Reference in New Issue
Block a user