// Canvas 绘制:预览、PNG 图纸页、作品信息页、水印 // 性能要点:按颜色批量合并路径,每种颜色只 fill 一次;字体只设置一次;网格线合并成单条路径 const { lumaHex } = require('./color') const BOARD = '#f1eee7' // 钉板底色 const PEG = '#d9d3c7' // 空钉位 const INK = '#1d2b2a' const MUTED = '#6b7671' const BRAND = '#0f766e' function roundRectPath(ctx, x, y, w, h, r) { const rr = Math.min(r, w / 2, h / 2) ctx.moveTo(x + rr, y) ctx.arcTo(x + w, y, x + w, y + h, rr) ctx.arcTo(x + w, y + h, x, y + h, rr) ctx.arcTo(x, y + h, x, y, rr) ctx.arcTo(x, y, x + w, y, rr) ctx.closePath() } function fitText(ctx, value, maxWidth) { const text = String(value || '') if (ctx.measureText(text).width <= maxWidth) return text let end = text.length while (end > 0 && ctx.measureText(`${text.slice(0, end)}…`).width > maxWidth) end -= 1 return `${text.slice(0, end)}…` } // 把同色格子分组:返回 Map function groupCells(artwork, region) { const { size, cells } = artwork const r = region || { col: 0, row: 0, cols: size, rows: size } const groups = new Map() const empty = [] for (let y = r.row; y < r.row + r.rows; y += 1) { for (let x = r.col; x < r.col + r.cols; x += 1) { const c = cells[y * size + x] if (c < 0) { empty.push(y * size + x); continue } let list = groups.get(c) if (!list) { list = []; groups.set(c, list) } list.push(y * size + x) } } return { groups, empty, region: r } } /** * 绘制珠子 * opts: { x0, y0, cell, style: square|round|hollow, region } */ function drawBeads(ctx, artwork, opts) { const { x0, y0, cell, style = 'square' } = opts const { size, pal } = artwork const { groups, empty, region } = groupCells(artwork, opts.region) const ARC_CHUNK = 12 const pos = (i) => { const x = i % size const y = (i - x) / size return [x0 + (x - region.col) * cell, y0 + (y - region.row) * cell] } ctx.fillStyle = BOARD ctx.fillRect(x0, y0, region.cols * cell, region.rows * cell) // 空钉位:小圆点,提示“这里不放豆” if (empty.length) { ctx.fillStyle = PEG ctx.beginPath() const r = cell * 0.14 empty.forEach((i, n) => { const [x, y] = pos(i) ctx.moveTo(x + cell / 2 + r, y + cell / 2) ctx.arc(x + cell / 2, y + cell / 2, r, 0, Math.PI * 2) if (n % ARC_CHUNK === ARC_CHUNK - 1) { ctx.fill(); ctx.beginPath() } }) ctx.fill() } const radius = cell * 0.46 const hole = cell * 0.2 groups.forEach((list, c) => { ctx.fillStyle = pal.hexes[c] ctx.beginPath() if (style === 'square') { list.forEach((i) => { const [x, y] = pos(i) // 取整边界避免相邻方块之间出现发丝缝 const lx = Math.round(x); const ty = Math.round(y) ctx.rect(lx, ty, Math.round(x + cell) - lx, Math.round(y + cell) - ty) }) } else { // 圆形子路径过多时渲染引擎会明显变慢,按小批提交(大图从数秒降到几十毫秒) list.forEach((i, n) => { const [x, y] = pos(i) const cx = x + cell / 2; const cy = y + cell / 2 ctx.moveTo(cx + radius, cy) ctx.arc(cx, cy, radius, 0, Math.PI * 2) if (style === 'hollow') { ctx.moveTo(cx + hole, cy) ctx.arc(cx, cy, hole, 0, Math.PI * 2, true) } if (n % ARC_CHUNK === ARC_CHUNK - 1) { ctx.fill(); ctx.beginPath() } }) } ctx.fill() }) } // 色号文字:先按深/浅文字颜色分两批 function drawCodes(ctx, artwork, opts) { const { x0, y0, cell, style = 'square' } = opts const { size, cells, pal } = artwork const region = opts.region || { col: 0, row: 0, cols: size, rows: size } const light = pal.hexes.map((hex) => lumaHex(hex) > 0.56) const fontSize = Math.max(8, Math.round(cell * (style === 'hollow' ? 0.26 : 0.32))) ctx.font = `bold ${fontSize}px sans-serif` ctx.textAlign = 'center' ctx.textBaseline = 'middle' const pass = (wantLight) => { ctx.fillStyle = wantLight ? INK : '#ffffff' for (let y = region.row; y < region.row + region.rows; y += 1) { for (let x = region.col; x < region.col + region.cols; x += 1) { const c = cells[y * size + x] if (c < 0) continue const useInk = style === 'hollow' ? true : light[c] if (useInk !== wantLight) continue ctx.fillText(pal.ids[c], x0 + (x - region.col + 0.5) * cell, y0 + (y - region.row + 0.5) * cell + fontSize * 0.04) } } } pass(true) pass(false) ctx.textAlign = 'start' ctx.textBaseline = 'alphabetic' } function rgba(hex, alpha) { const v = parseInt(hex.slice(1), 16) return `rgba(${(v >> 16) & 255},${(v >> 8) & 255},${v & 255},${alpha})` } // 网格 + 坐标:细线每格一条,粗线每 interval 格一条(按全局坐标对齐) function drawGrid(ctx, opts) { const { x0, y0, cell, region, interval = 10, color = BRAND, opacity = 60, labels = true } = opts const w = region.cols * cell const h = region.rows * cell const alpha = opacity / 100 ctx.lineWidth = Math.max(1, cell * 0.02) ctx.strokeStyle = rgba(color, alpha * 0.28) ctx.beginPath() for (let i = 0; i <= region.cols; i += 1) { const x = Math.round(x0 + i * cell) + 0.5; ctx.moveTo(x, y0); ctx.lineTo(x, y0 + h) } for (let i = 0; i <= region.rows; i += 1) { const y = Math.round(y0 + i * cell) + 0.5; ctx.moveTo(x0, y); ctx.lineTo(x0 + w, y) } ctx.stroke() ctx.lineWidth = Math.max(2, cell * 0.06) ctx.strokeStyle = rgba(color, alpha) ctx.beginPath() for (let i = 0; i <= region.cols; i += 1) { const g = region.col + i if (g % interval !== 0 && i !== 0 && i !== region.cols) continue const x = x0 + i * cell; ctx.moveTo(x, y0); ctx.lineTo(x, y0 + h) } for (let i = 0; i <= region.rows; i += 1) { const g = region.row + i if (g % interval !== 0 && i !== 0 && i !== region.rows) continue const y = y0 + i * cell; ctx.moveTo(x0, y); ctx.lineTo(x0 + w, y) } ctx.stroke() if (!labels) return const fontSize = Math.max(10, Math.round(Math.min(cell * 0.42, 34))) ctx.font = `${fontSize}px sans-serif` ctx.fillStyle = rgba(color, Math.max(0.7, alpha)) ctx.textAlign = 'center' ctx.textBaseline = 'middle' const step = cell < 22 ? interval : (cell < 36 ? 5 : 1) for (let i = 0; i < region.cols; i += 1) { const g = region.col + i + 1 if (g % step !== 0 && g !== 1) continue ctx.fillText(String(g), x0 + (i + 0.5) * cell, y0 - fontSize * 0.9) } for (let i = 0; i < region.rows; i += 1) { const g = region.row + i + 1 if (g % step !== 0 && g !== 1) continue ctx.fillText(String(g), x0 - fontSize * 1.1, y0 + (i + 0.5) * cell) } ctx.textAlign = 'start' ctx.textBaseline = 'alphabetic' } // 屏幕预览:整张作品,不带坐标(清爽) function renderPreview(canvas, artwork, pixelWidth, style) { const size = artwork.size const pad = Math.round(pixelWidth * 0.03) const cell = (pixelWidth - pad * 2) / size canvas.width = pixelWidth canvas.height = pixelWidth const ctx = canvas.getContext('2d') ctx.fillStyle = BOARD ctx.fillRect(0, 0, pixelWidth, pixelWidth) drawBeads(ctx, artwork, { x0: pad, y0: pad, cell, style }) } // —— PNG 图纸页 —— function patternPageLayout(size, cell) { const axis = Math.max(48, Math.round(cell * 1.1)) const pad = Math.max(48, Math.round(cell * 0.9)) const header = Math.max(120, Math.round(cell * 2.4)) const grid = size * cell return { axis, pad, header, width: grid + axis + pad * 2, height: header + axis + grid + pad * 2 } } function drawPatternPage(ctx, artwork, opts) { const { cell, style, grid, meta } = opts const L = patternPageLayout(artwork.size, cell) ctx.fillStyle = '#fbfaf7' ctx.fillRect(0, 0, L.width, L.height) // 头部 const unit = L.header / 120 ctx.fillStyle = INK ctx.font = `bold ${Math.round(40 * unit)}px sans-serif` ctx.textBaseline = 'middle' ctx.fillText('豆维 · 拼豆图纸', L.pad, L.pad * 0.6 + L.header * 0.36) ctx.font = `${Math.round(22 * unit)}px sans-serif` ctx.fillStyle = MUTED ctx.fillText(fitText(ctx, meta, L.width - L.pad * 2), L.pad, L.pad * 0.6 + L.header * 0.74) ctx.textBaseline = 'alphabetic' const x0 = L.pad + L.axis const y0 = L.header + L.pad * 0.6 + L.axis const region = { col: 0, row: 0, cols: artwork.size, rows: artwork.size } drawBeads(ctx, artwork, { x0, y0, cell, style, region }) if (cell >= 16) drawCodes(ctx, artwork, { x0, y0, cell, style, region }) if (grid.enabled) drawGrid(ctx, { x0, y0, cell, region, interval: grid.interval, color: grid.color, opacity: grid.opacity }) return L } // —— 作品信息页(A4 比例,可分页)—— function buildInfoPages({ artwork, width, text, paletteLabel, mpCode, thumbnail }) { const height = Math.round(width * 297 / 210) const u = width / 1600 const pad = 96 * u const colors = artwork.usage.slice().sort((a, b) => a.id.localeCompare(b.id, undefined, { numeric: true })) const columns = 4 const gap = 24 * u const cardW = (width - pad * 2 - gap * (columns - 1)) / columns const cardH = 124 * u const headerH = 300 * u // 缩略图区:headerH+64 起,高 400;清单标题与说明各占一行,避免与上方重叠 const listTitleY = headerH + 64 * u + 400 * u + 96 * u const firstTop = listTitleY + 88 * u const nextTop = 200 * u const bottom = height - 110 * u const rowsFirst = Math.max(1, Math.floor((bottom - firstTop + gap) / (cardH + gap))) const rowsNext = Math.max(1, Math.floor((bottom - nextTop + gap) / (cardH + gap))) const pages = [] let rest = colors.length let first = true do { const take = Math.min(rest, (first ? rowsFirst : rowsNext) * columns) pages.push(take) rest -= take first = false } while (rest > 0) const drawCard = (ctx, color, x, y) => { ctx.fillStyle = '#ffffff' ctx.beginPath(); roundRectPath(ctx, x, y, cardW, cardH, 18 * u); ctx.fill() ctx.strokeStyle = '#e7e2d8'; ctx.lineWidth = 2 * u; ctx.stroke() const sw = 76 * u ctx.fillStyle = color.hex ctx.beginPath(); ctx.arc(x + 24 * u + sw / 2, y + cardH / 2, sw / 2, 0, Math.PI * 2); ctx.fill() ctx.strokeStyle = 'rgba(0,0,0,0.12)'; ctx.stroke() ctx.fillStyle = INK ctx.font = `bold ${Math.round(34 * u)}px sans-serif` ctx.fillText(color.id, x + 24 * u + sw + 20 * u, y + 56 * u) ctx.fillStyle = BRAND ctx.font = `${Math.round(26 * u)}px sans-serif` ctx.fillText(`${color.count} 颗`, x + 24 * u + sw + 20 * u, y + 96 * u) } const draw = (ctx, page) => { ctx.textAlign = 'start' ctx.textBaseline = 'alphabetic' ctx.fillStyle = '#fbfaf7' ctx.fillRect(0, 0, width, height) let top = nextTop if (page === 0) { ctx.fillStyle = INK ctx.fillRect(0, 0, width, headerH) ctx.fillStyle = '#ffffff' ctx.font = `bold ${Math.round(62 * u)}px sans-serif` ctx.fillText('豆维 · 作品信息与用豆清单', pad, 132 * u) ctx.fillStyle = '#9fd3c7' ctx.font = `${Math.round(28 * u)}px sans-serif` ctx.fillText('照着清单备豆,照着图纸拼,拼完用微信扫一扫', pad, 200 * u) if (mpCode) { const s = 200 * u const x = width - pad - s const y = (headerH - s) / 2 ctx.fillStyle = '#ffffff' ctx.beginPath(); roundRectPath(ctx, x - 10 * u, y - 10 * u, s + 20 * u, s + 20 * u, 20 * u); ctx.fill() ctx.drawImage(mpCode, x, y, s, s) } // 缩略图 + 参数 const thumbS = 400 * u const ty = headerH + 64 * u if (thumbnail) { ctx.fillStyle = '#ffffff' ctx.beginPath(); roundRectPath(ctx, pad, ty, thumbS, thumbS, 24 * u); ctx.fill() const inner = thumbS - 32 * u const cell = inner / artwork.size drawBeads(ctx, artwork, { x0: pad + 16 * u, y0: ty + 16 * u, cell, style: 'square' }) } const mx = thumbnail ? pad + thumbS + 56 * u : pad const rows = [ ['扫码内容', text], ['作品尺寸', `${artwork.size} × ${artwork.size} 格`], ['用豆总数', `${artwork.beads.toLocaleString()} 颗 · ${artwork.usage.length} 个色号`], ['二维码', `${artwork.qr.size} × ${artwork.qr.size} 格 · 纠错 ${artwork.qr.level}`], ['色库', paletteLabel], ] rows.forEach(([k, v], idx) => { const y = ty + 52 * u + idx * 74 * u ctx.fillStyle = MUTED ctx.font = `${Math.round(26 * u)}px sans-serif` ctx.fillText(k, mx, y) ctx.fillStyle = INK ctx.font = `bold ${Math.round(28 * u)}px sans-serif` ctx.fillText(fitText(ctx, v, width - pad - mx - 150 * u), mx + 150 * u, y) }) ctx.fillStyle = INK ctx.font = `bold ${Math.round(42 * u)}px sans-serif` ctx.fillText('用豆清单', pad, listTitleY) ctx.fillStyle = MUTED ctx.font = `${Math.round(24 * u)}px sans-serif` ctx.fillText('按色号排序 · 建议每色多备 5% 以防掉落', pad, listTitleY + 44 * u) top = firstTop } else { ctx.fillStyle = INK ctx.font = `bold ${Math.round(36 * u)}px sans-serif` ctx.fillText(`用豆清单(续 ${page + 1}/${pages.length})`, pad, 120 * u) } const offset = pages.slice(0, page).reduce((s, v) => s + v, 0) for (let i = 0; i < pages[page]; i += 1) { const col = i % columns const row = Math.floor(i / columns) drawCard(ctx, colors[offset + i], pad + col * (cardW + gap), top + row * (cardH + gap)) } ctx.fillStyle = '#a3aaa5' ctx.font = `${Math.round(22 * u)}px sans-serif` ctx.fillText(`豆维 · 可扫码的拼豆 第 ${page + 1} / ${pages.length} 页`, pad, height - 56 * u) } return { width, height, pageCount: pages.length, draw } } // 轻度斜向平铺水印 function drawWatermark(ctx, w, h, text) { const value = String(text || '').trim() if (!value) return const fontSize = Math.max(18, Math.round(w * 0.018)) const stepX = Math.max(fontSize * value.length * 1.6, w / 4) const stepY = Math.max(fontSize * 6, w / 6) ctx.save() ctx.globalAlpha = 0.08 ctx.fillStyle = INK ctx.font = `${fontSize}px sans-serif` ctx.textAlign = 'center' ctx.textBaseline = 'middle' ctx.translate(w / 2, h / 2) ctx.rotate(-Math.PI / 9) const r = Math.hypot(w, h) / 2 for (let y = -r; y <= r; y += stepY) for (let x = -r; x <= r; x += stepX) ctx.fillText(value, x, y) ctx.restore() } module.exports = { BOARD, drawBeads, drawCodes, drawGrid, renderPreview, patternPageLayout, drawPatternPage, buildInfoPages, drawWatermark, }