/** * 画个标 · 核心:导出功能 * 支持: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 声明,需注入 才能用 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 = ''; for (const id of Object.keys(grads)) { const g = grads[id]; if (g.type === 'radial') { d += `` + `` + ''; } else { d += `` + `` + ''; } } for (const el of arcs) { d += ``; } defs = d + ''; } let inner = ''; inner += ``; if (defs) inner += defs; for (const el of elements) { if (!el.visible) continue; inner += buildElementSvg(el); } return '\n' + `` + inner + ''; } /** 生成当前设计的导出 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 `${applyIconStyle(el)}`; } 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 `${esc(el.text || '')}`; } return `${esc(el.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, '"'); } /* ============ 位图渲染(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} sizes 尺寸列表(px) * @param {object} [opts] { bg: 背景色(null=透明,仅 SVG 内 rect 提供背景), * round: 是否圆形裁切, base: 文件名前缀 } * @returns {Promise>} */ 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 }; })();