Files

431 lines
16 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 画个标 · 核心:导出功能
* 支持: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, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
/* ============ 位图渲染(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
};
})();