Spaces:
Running
Running
File size: 14,683 Bytes
9de7b70 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 | // 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,
}
|