Ethscriptions's picture
上传 BeadQR 微信小程序项目源码
9de7b70 verified
Raw History Blame Contribute Delete
15.5 kB
// 轻量 PDF 生成器(无依赖)
// - 图纸页为纯矢量:珠子、色号、网格都是矢量指令,任意放大都清晰,文件小、生成快
// - 中文(页眉、信息页)以 JPEG 图片嵌入
// - 支持 1-bit 蒙版图片(用于矢量页上的半透明水印)
const A4 = { w: 595.28, h: 841.89 }
// Helvetica / Helvetica-Bold 字宽(AFM,1/1000 em),色号只含 A-Z 0-9
const W_BOLD = { ' ': 278, '-': 333, '/': 278 }
const W_REG = { ' ': 278, '-': 333, '/': 278 }
'0123456789'.split('').forEach((c) => { W_BOLD[c] = 556; W_REG[c] = 556 })
const UPPER_BOLD = [722, 722, 722, 722, 667, 611, 778, 722, 278, 556, 722, 611, 833, 722, 778, 667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611]
const UPPER_REG = [667, 667, 722, 722, 667, 611, 778, 722, 278, 500, 667, 556, 833, 722, 778, 667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611]
for (let i = 0; i < 26; i += 1) {
W_BOLD[String.fromCharCode(65 + i)] = UPPER_BOLD[i]
W_REG[String.fromCharCode(65 + i)] = UPPER_REG[i]
}
function textWidth(text, size, bold = true) {
const table = bold ? W_BOLD : W_REG
let w = 0
for (let i = 0; i < text.length; i += 1) w += table[text[i]] || 556
return (w / 1000) * size
}
const n2 = (v) => (Math.round(v * 100) / 100).toString()
const esc = (s) => String(s).replace(/[\\()]/g, (m) => `\\${m}`).replace(/[^\x20-\x7e]/g, '?')
function ascii(text) {
const out = new Uint8Array(text.length)
for (let i = 0; i < text.length; i += 1) out[i] = text.charCodeAt(i) & 0xff
return out
}
class PdfDoc {
constructor() {
this.objects = [null, null, null] // 1: Catalog, 2: Pages(0 号占位)
this.pageIds = []
this.fontRegular = this.add(['<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>'])
this.fontBold = this.add(['<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>'])
this.forms = {}
this.gstates = {}
}
add(parts) {
this.objects.push(parts)
return this.objects.length - 1
}
stream(dict, data) {
const bytes = typeof data === 'string' ? ascii(data) : new Uint8Array(data)
return this.add([`<< ${dict} /Length ${bytes.length} >>\nstream\n`, bytes, '\nendstream'])
}
addJpeg(buffer, width, height) {
return this.stream(`/Type /XObject /Subtype /Image /Width ${width} /Height ${height} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode`, buffer)
}
// bits:每行按字节对齐,0 = 绘制
addImageMask(bits, width, height) {
return this.stream(`/Type /XObject /Subtype /Image /Width ${width} /Height ${height} /ImageMask true /BitsPerComponent 1`, bits)
}
// 单位圆(直径 1)表单:不设颜色,继承外部填充色
form(kind) {
if (this.forms[kind]) return this.forms[kind]
const k = 0.5523
const circle = (r) => {
const c = r * k
return `${n2(0.5 + r)} 0.5 m ${n2(0.5 + r)} ${n2(0.5 + c)} ${n2(0.5 + c)} ${n2(0.5 + r)} 0.5 ${n2(0.5 + r)} c `
+ `${n2(0.5 - c)} ${n2(0.5 + r)} ${n2(0.5 - r)} ${n2(0.5 + c)} ${n2(0.5 - r)} 0.5 c `
+ `${n2(0.5 - r)} ${n2(0.5 - c)} ${n2(0.5 - c)} ${n2(0.5 - r)} 0.5 ${n2(0.5 - r)} c `
+ `${n2(0.5 + c)} ${n2(0.5 - r)} ${n2(0.5 + r)} ${n2(0.5 - c)} ${n2(0.5 + r)} 0.5 c h `
}
let body
if (kind === 'round') body = `${circle(0.46)}f`
else if (kind === 'hollow') body = `${circle(0.46)}${circle(0.2)}f*`
else body = `${circle(0.14)}f` // peg 空钉位
const id = this.stream('/Type /XObject /Subtype /Form /BBox [0 0 1 1]', body)
this.forms[kind] = id
return id
}
gstate(alpha) {
const key = String(alpha)
if (!this.gstates[key]) this.gstates[key] = this.add([`<< /Type /ExtGState /ca ${alpha} /CA ${alpha} >>`])
return this.gstates[key]
}
addPage(content, { images = {} } = {}) {
const contentId = this.stream('', content)
const xobjects = Object.assign({}, images)
Object.keys(this.forms).forEach((k) => { xobjects[`Fm_${k}`] = this.forms[k] })
const xo = Object.keys(xobjects).map((k) => `/${k} ${xobjects[k]} 0 R`).join(' ')
const gs = Object.keys(this.gstates).map((k) => `/GS${k.replace('.', '_')} ${this.gstates[k]} 0 R`).join(' ')
const id = this.add([`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${A4.w} ${A4.h}] /Resources << /ProcSet [/PDF /Text /ImageB /ImageC] /Font << /F1 ${this.fontRegular} 0 R /F2 ${this.fontBold} 0 R >> /XObject << ${xo} >> /ExtGState << ${gs} >> >> /Contents ${contentId} 0 R >>`])
this.pageIds.push(id)
return id
}
build() {
this.objects[1] = ['<< /Type /Catalog /Pages 2 0 R >>']
this.objects[2] = [`<< /Type /Pages /Kids [${this.pageIds.map((id) => `${id} 0 R`).join(' ')}] /Count ${this.pageIds.length} >>`]
const chunks = []
let pos = 0
const push = (part) => {
const bytes = typeof part === 'string' ? ascii(part) : part
chunks.push(bytes)
pos += bytes.length
}
push('%PDF-1.4\n%\xe2\xe3\xcf\xd3\n')
const offsets = []
for (let id = 1; id < this.objects.length; id += 1) {
offsets[id] = pos
push(`${id} 0 obj\n`)
this.objects[id].forEach(push)
push('\nendobj\n')
}
const xref = pos
let table = `xref\n0 ${this.objects.length}\n0000000000 65535 f \n`
for (let id = 1; id < this.objects.length; id += 1) table += `${String(offsets[id]).padStart(10, '0')} 00000 n \n`
push(table)
push(`trailer\n<< /Size ${this.objects.length} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`)
const out = new Uint8Array(pos)
let cursor = 0
chunks.forEach((c) => { out.set(c, cursor); cursor += c.length })
return out.buffer
}
}
// —— 矢量图纸 ——
// 使用“左上角原点、y 向下”的坐标系(页面开头统一翻转),文字与图片局部再翻回来
function hexToPdf(hex) {
const v = parseInt(hex.slice(1), 16)
return `${n2(((v >> 16) & 255) / 255)} ${n2(((v >> 8) & 255) / 255)} ${n2((v & 255) / 255)}`
}
function lumaOf(hex) {
const v = parseInt(hex.slice(1), 16)
return (((v >> 16) & 255) * 0.299 + ((v >> 8) & 255) * 0.587 + (v & 255) * 0.114) / 255
}
function vectorSheet(doc, artwork, { x0, y0, cell, region, style, codes, grid }) {
const { size, cells, pal } = artwork
const out = []
out.push(`${hexToPdf('#f1eee7')} rg ${n2(x0)} ${n2(y0)} ${n2(region.cols * cell)} ${n2(region.rows * cell)} re f`)
const groups = new Map()
const empty = []
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]
const px = x0 + (x - region.col) * cell
const py = y0 + (y - region.row) * cell
if (c < 0) { empty.push([px, py]); continue }
if (!groups.has(c)) groups.set(c, [])
groups.get(c).push([px, py])
}
}
if (empty.length) {
doc.form('peg')
out.push(`${hexToPdf('#d9d3c7')} rg`)
empty.forEach(([x, y]) => out.push(`q ${n2(cell)} 0 0 ${n2(cell)} ${n2(x)} ${n2(y)} cm /Fm_peg Do Q`))
}
if (style !== 'square') doc.form(style)
groups.forEach((list, c) => {
out.push(`${hexToPdf(pal.hexes[c])} rg`)
if (style === 'square') {
// 略微外扩 0.05pt 消除阅读器抗锯齿造成的白缝
list.forEach(([x, y]) => out.push(`${n2(x - 0.05)} ${n2(y - 0.05)} ${n2(cell + 0.1)} ${n2(cell + 0.1)} re`))
out.push('f')
} else {
list.forEach(([x, y]) => out.push(`q ${n2(cell)} 0 0 ${n2(cell)} ${n2(x)} ${n2(y)} cm /Fm_${style} Do Q`))
}
})
if (codes) {
const fs = cell * (style === 'hollow' ? 0.27 : 0.34)
const writeGroup = (ink) => {
const lines = []
groups.forEach((list, c) => {
const useInk = style === 'hollow' || lumaOf(pal.hexes[c]) > 0.56
if (useInk !== ink) return
const id = pal.ids[c]
const tw = textWidth(id, fs)
list.forEach(([x, y]) => lines.push(`1 0 0 -1 ${n2(x + (cell - tw) / 2)} ${n2(y + cell / 2 + fs * 0.35)} Tm (${id}) Tj`))
})
if (!lines.length) return
out.push(`${ink ? hexToPdf('#1d2b2a') : '1 1 1'} rg BT /F2 ${n2(fs)} Tf`)
out.push(...lines)
out.push('ET')
}
writeGroup(true)
writeGroup(false)
}
if (grid && grid.enabled) {
const w = region.cols * cell
const h = region.rows * cell
const color = hexToPdf(grid.color)
const alpha = grid.opacity / 100
const thin = []
for (let i = 0; i <= region.cols; i += 1) thin.push(`${n2(x0 + i * cell)} ${n2(y0)} m ${n2(x0 + i * cell)} ${n2(y0 + h)} l`)
for (let i = 0; i <= region.rows; i += 1) thin.push(`${n2(x0)} ${n2(y0 + i * cell)} m ${n2(x0 + w)} ${n2(y0 + i * cell)} l`)
doc.gstate(Math.round(alpha * 30) / 100)
doc.gstate(alpha)
out.push(`q /GS${String(Math.round(alpha * 30) / 100).replace('.', '_')} gs ${color} RG ${n2(Math.max(0.2, cell * 0.02))} w`, ...thin, 'S Q')
const thick = []
for (let i = 0; i <= region.cols; i += 1) {
const g = region.col + i
if (g % grid.interval && i && i !== region.cols) continue
thick.push(`${n2(x0 + i * cell)} ${n2(y0)} m ${n2(x0 + i * cell)} ${n2(y0 + h)} l`)
}
for (let i = 0; i <= region.rows; i += 1) {
const g = region.row + i
if (g % grid.interval && i && i !== region.rows) continue
thick.push(`${n2(x0)} ${n2(y0 + i * cell)} m ${n2(x0 + w)} ${n2(y0 + i * cell)} l`)
}
out.push(`q /GS${String(alpha).replace('.', '_')} gs ${color} RG ${n2(Math.max(0.6, cell * 0.06))} w`, ...thick, 'S Q')
// 坐标数字
const fs = Math.min(7, Math.max(4, cell * 0.42))
const step = cell < 8 ? grid.interval : (cell < 13 ? 5 : 1)
const labels = []
for (let i = 0; i < region.cols; i += 1) {
const g = region.col + i + 1
if (g % step && g !== 1 && i !== 0) continue
const t = String(g)
labels.push(`1 0 0 -1 ${n2(x0 + (i + 0.5) * cell - textWidth(t, fs, false) / 2)} ${n2(y0 - fs * 0.5)} Tm (${t}) Tj`)
}
for (let i = 0; i < region.rows; i += 1) {
const g = region.row + i + 1
if (g % step && g !== 1 && i !== 0) continue
const t = String(g)
labels.push(`1 0 0 -1 ${n2(x0 - textWidth(t, fs, false) - fs * 0.4)} ${n2(y0 + (i + 0.5) * cell + fs * 0.35)} Tm (${t}) Tj`)
}
out.push(`${color} rg BT /F1 ${n2(fs)} Tf`, ...labels, 'ET')
}
return out.join('\n')
}
// 页眉图片 + 水印蒙版的放置
function imageCmd(name, x, y, w, h) {
return `q ${n2(w)} 0 0 ${n2(-h)} ${n2(x)} ${n2(y + h)} cm /${name} Do Q`
}
function watermarkCmd(mask) {
if (!mask) return ''
const h = 16
const w = h * mask.width / mask.height
const out = [`q /GS0_08 gs ${hexToPdf('#1d2b2a')} rg`]
const stepX = w + 90
const stepY = 120
for (let row = 0, y = -200; y < A4.h + 200; y += stepY, row += 1) {
for (let x = -200; x < A4.w + 200; x += stepX) {
// 旋转 -20°:cos=0.94 sin=0.342;y 轴已翻转,所以蒙版局部再翻回
out.push(`q 0.94 -0.342 0.342 0.94 ${n2(x + (row % 2) * stepX / 2)} ${n2(y)} cm ${n2(w)} 0 0 ${n2(-h)} 0 ${n2(h)} cm /WM Do Q`)
}
}
out.push('Q')
return out.join('\n')
}
const MARGIN = 28.35 // 10mm
const HEADER_H = 34
const AXIS = 14
const TARGET_CELL = 18 // 约 6.4mm/格,打印后色号清晰可读
const AREA_W = A4.w - MARGIN * 2
const AREA_H = A4.h - MARGIN * 2 - HEADER_H
/**
* 规划图纸页:小图一页搞定;大图 = 总览页 + 若干分块页
* 返回 { tiled, fitCell, tilesX, tilesY, tileCols, tileRows, pages: [{ kind, tile, region }] }
*/
function planPatternPages(size) {
const fitCell = Math.min((AREA_W - AXIS) / size, (AREA_H - AXIS) / size)
const tiled = fitCell < 11
const full = { col: 0, row: 0, cols: size, rows: size }
const pages = [{ kind: 'overview', tile: 0, region: full }]
let tilesX = 1
let tilesY = 1
let tileCols = size
let tileRows = size
if (tiled) {
tilesX = Math.ceil(size / Math.floor((AREA_W - AXIS) / TARGET_CELL))
tilesY = Math.ceil(size / Math.floor((AREA_H - AXIS) / TARGET_CELL))
tileCols = Math.ceil(size / tilesX)
tileRows = Math.ceil(size / tilesY)
let n = 1
for (let ty = 0; ty < tilesY; ty += 1) {
for (let tx = 0; tx < tilesX; tx += 1) {
const col = tx * tileCols
const row = ty * tileRows
if (col >= size || row >= size) continue
pages.push({ kind: 'tile', tile: n, region: { col, row, cols: Math.min(tileCols, size - col), rows: Math.min(tileRows, size - row) } })
n += 1
}
}
}
return { tiled, fitCell, tilesX, tilesY, tileCols, tileRows, pages }
}
/**
* 生成图纸页(矢量)。
* opts: { plan, headers: [{ id, width, height }](按页序), style, grid, watermark }
*/
function addPatternPages(doc, artwork, opts) {
const size = artwork.size
const plan = opts.plan || planPatternPages(size)
const headers = opts.headers || []
const flip = `1 0 0 -1 0 ${A4.h} cm`
const wm = opts.watermark ? watermarkCmd(opts.watermark) : ''
if (opts.watermark) doc.gstate(0.08)
if (plan.tiled) doc.gstate(0.85)
const images = (header) => {
const map = {}
if (header) map.HD = header.id
if (opts.watermark) map.WM = opts.watermark.id
return map
}
const headerCmd = (header) => (header ? imageCmd('HD', MARGIN, MARGIN, AREA_W, AREA_W * header.height / header.width) : '')
plan.pages.forEach((page, i) => {
const header = headers[i] || null
const content = [flip, headerCmd(header)]
if (page.kind === 'overview') {
const fitCell = plan.fitCell
const gridW = fitCell * size
const x0 = MARGIN + AXIS + (AREA_W - AXIS - gridW) / 2
const y0 = MARGIN + HEADER_H + AXIS
content.push(vectorSheet(doc, artwork, {
x0, y0, cell: fitCell, region: page.region, style: opts.style, codes: !plan.tiled, grid: opts.grid,
}))
if (plan.tiled) {
const lines = [`q ${hexToPdf('#e11d48')} RG 1.2 w`]
const labels = []
plan.pages.forEach((tp) => {
if (tp.kind !== 'tile') return
const r = tp.region
const cx = x0 + r.col * fitCell
const cy = y0 + r.row * fitCell
const cw = r.cols * fitCell
const ch = r.rows * fitCell
lines.push(`${n2(cx)} ${n2(cy)} ${n2(cw)} ${n2(ch)} re S`)
const t = String(tp.tile)
labels.push(`1 0 0 -1 ${n2(cx + cw / 2 - textWidth(t, 22) / 2)} ${n2(cy + ch / 2 + 8)} Tm (${t}) Tj`)
})
lines.push('Q')
content.push(lines.join('\n'))
content.push(`q /GS0_85 gs ${hexToPdf('#e11d48')} rg BT /F2 22 Tf\n${labels.join('\n')}\nET Q`)
}
} else {
const cell = Math.min((AREA_W - AXIS) / plan.tileCols, (AREA_H - AXIS) / plan.tileRows)
content.push(vectorSheet(doc, artwork, {
x0: MARGIN + AXIS, y0: MARGIN + HEADER_H + AXIS, cell, region: page.region, style: opts.style, codes: true, grid: opts.grid,
}))
}
content.push(wm)
doc.addPage(content.join('\n'), { images: images(header) })
})
return plan.pages.length
}
// 整页图片(信息页)
function addImagePage(doc, jpegId, pixelW, pixelH) {
const areaW = A4.w - MARGIN * 2
const areaH = A4.h - MARGIN * 2
const scale = Math.min(areaW / pixelW, areaH / pixelH)
const w = pixelW * scale
const h = pixelH * scale
const x = (A4.w - w) / 2
const y = (A4.h - h) / 2
doc.addPage(`1 0 0 -1 0 ${A4.h} cm\n${imageCmd('IM', x, y, w, h)}`, { images: { IM: jpegId } })
}
module.exports = { A4, PdfDoc, planPatternPages, addPatternPages, addImagePage, vectorSheet, textWidth, HEADER_H, MARGIN }