Spaces:
Running
Running
Download BeadQR/miniprogram/utils/pattern.js from Ethscriptions/cut: direct link, hf CLI and curl.
- Browser
- Download file 14.7 kB
-
https://huggingface.co/spaces/Ethscriptions/cut/resolve/main/BeadQR/miniprogram/utils/pattern.js
- Command line
-
hf download hf://spaces/Ethscriptions/cut/BeadQR/miniprogram/utils/pattern.js
-
curl -L -o pattern.js https://huggingface.co/spaces/Ethscriptions/cut/resolve/main/BeadQR/miniprogram/utils/pattern.js
14.7 kB
| // 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<colorIndex, number[] cellIndex> | |
| 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, | |
| } | |