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