// 轻量 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 }