Spaces:
Running
Running
File size: 15,542 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 | // 轻量 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 }
|