import { createHash } from 'node:crypto' import { deflateSync } from 'node:zlib' import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' const PAGE_WIDTH = 842 const PAGE_HEIGHT = 595 const findFont = requestedPath => { const candidates = [ requestedPath, process.env.INVOICE_PDF_FONT_PATH, 'C:/Windows/Fonts/simhei.ttf', 'C:/Windows/Fonts/msyh.ttf', '/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttf', '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf' ].filter(Boolean) const fontPath = candidates.find(candidate => existsSync(candidate)) if (!fontPath) { throw new Error('未找到可用字体,请通过 INVOICE_PDF_FONT_PATH 指定支持中文的 TTF 字体') } if (path.extname(fontPath).toLowerCase() !== '.ttf') { throw new Error('模拟发票工具仅支持 TTF 字体') } return fontPath } const readTableDirectory = font => { const tableCount = font.readUInt16BE(4) const tables = new Map() for (let index = 0; index < tableCount; index += 1) { const offset = 12 + index * 16 tables.set(font.toString('ascii', offset, offset + 4), { offset: font.readUInt32BE(offset + 8), length: font.readUInt32BE(offset + 12) }) } return tables } const createGlyphMapper = font => { const cmapTable = readTableDirectory(font).get('cmap') if (!cmapTable) throw new Error('字体缺少 cmap 字符映射表') const base = cmapTable.offset const count = font.readUInt16BE(base + 2) let format12Offset = null let format4Offset = null for (let index = 0; index < count; index += 1) { const record = base + 4 + index * 8 const platformId = font.readUInt16BE(record) const encodingId = font.readUInt16BE(record + 2) const subtableOffset = base + font.readUInt32BE(record + 4) const format = font.readUInt16BE(subtableOffset) if (format === 12 && (platformId === 0 || (platformId === 3 && encodingId === 10))) { format12Offset = subtableOffset } if (format === 4 && (platformId === 0 || platformId === 3)) { format4Offset = subtableOffset } } const fromFormat12 = codePoint => { if (format12Offset === null) return 0 const groupCount = font.readUInt32BE(format12Offset + 12) let low = 0 let high = groupCount - 1 while (low <= high) { const middle = (low + high) >> 1 const group = format12Offset + 16 + middle * 12 const start = font.readUInt32BE(group) const end = font.readUInt32BE(group + 4) if (codePoint < start) high = middle - 1 else if (codePoint > end) low = middle + 1 else return font.readUInt32BE(group + 8) + codePoint - start } return 0 } const fromFormat4 = codePoint => { if (format4Offset === null || codePoint > 0xffff) return 0 const segmentCount = font.readUInt16BE(format4Offset + 6) / 2 const endCodes = format4Offset + 14 const startCodes = endCodes + segmentCount * 2 + 2 const deltas = startCodes + segmentCount * 2 const rangeOffsets = deltas + segmentCount * 2 for (let index = 0; index < segmentCount; index += 1) { const end = font.readUInt16BE(endCodes + index * 2) if (codePoint > end) continue const start = font.readUInt16BE(startCodes + index * 2) if (codePoint < start) return 0 const delta = font.readInt16BE(deltas + index * 2) const rangeOffsetPosition = rangeOffsets + index * 2 const rangeOffset = font.readUInt16BE(rangeOffsetPosition) if (rangeOffset === 0) return (codePoint + delta) & 0xffff const glyphPosition = rangeOffsetPosition + rangeOffset + (codePoint - start) * 2 if (glyphPosition + 2 > font.length) return 0 const glyph = font.readUInt16BE(glyphPosition) return glyph === 0 ? 0 : (glyph + delta) & 0xffff } return 0 } return codePoint => fromFormat12(codePoint) || fromFormat4(codePoint) } // 保留使用的字形及复合字形依赖,避免嵌入整套中文字体超出文件接口大小限制。 const subsetFont = (font, usedGlyphs) => { const tables = readTableDirectory(font) for (const name of ['head', 'hhea', 'maxp', 'hmtx', 'loca', 'glyf']) { if (!tables.has(name)) throw new Error(`字体缺少 ${name} 表,请配置中文 TrueType 字体`) } const head = tables.get('head').offset const glyphCount = font.readUInt16BE(tables.get('maxp').offset + 4) const longLoca = font.readInt16BE(head + 50) === 1 const loca = tables.get('loca').offset const glyphOffset = id => longLoca ? font.readUInt32BE(loca + id * 4) : font.readUInt16BE(loca + id * 2) * 2 const selected = new Set([0, ...usedGlyphs]) for (const id of selected) { const start = tables.get('glyf').offset + glyphOffset(id) if (glyphOffset(id) === glyphOffset(id + 1) || font.readInt16BE(start) >= 0) continue let pos = start + 10 let flags do { flags = font.readUInt16BE(pos) selected.add(font.readUInt16BE(pos + 2)) pos += 4 + ((flags & 1) ? 4 : 2) if (flags & 8) pos += 2 else if (flags & 64) pos += 4 else if (flags & 128) pos += 8 } while (flags & 32) } const chunks = [] const newLoca = Buffer.alloc((glyphCount + 1) * 4) let length = 0 for (let id = 0; id < glyphCount; id += 1) { newLoca.writeUInt32BE(length, id * 4) if (!selected.has(id)) continue const data = font.subarray(tables.get('glyf').offset + glyphOffset(id), tables.get('glyf').offset + glyphOffset(id + 1)) const padded = Buffer.alloc((data.length + 3) & ~3) data.copy(padded) chunks.push(padded) length += padded.length } newLoca.writeUInt32BE(length, glyphCount * 4) const kept = ['OS/2', 'cmap', 'cvt ', 'fpgm', 'gasp', 'glyf', 'head', 'hhea', 'hmtx', 'loca', 'maxp', 'name', 'post', 'prep'].filter(name => tables.has(name)).sort() const checksum = bytes => { let sum = 0 for (let i = 0; i < bytes.length; i += 4) sum = (sum + bytes.readUInt32BE(i)) >>> 0 return sum } const directory = Buffer.alloc(12 + kept.length * 16) directory.writeUInt32BE(0x00010000, 0) directory.writeUInt16BE(kept.length, 4) const power = Math.floor(Math.log2(kept.length)) directory.writeUInt16BE(16 * 2 ** power, 6) directory.writeUInt16BE(power, 8) directory.writeUInt16BE(kept.length * 16 - 16 * 2 ** power, 10) const result = [directory] let offset = directory.length let headOffset = 0 kept.forEach((name, index) => { const table = tables.get(name) let data = name === 'glyf' ? Buffer.concat(chunks) : name === 'loca' ? newLoca : Buffer.from(font.subarray(table.offset, table.offset + table.length)) if (name === 'head') { data.writeUInt32BE(0, 8) data.writeInt16BE(1, 50) headOffset = offset } if (name === 'post') { data = Buffer.from(data.subarray(0, 32)) data.writeUInt32BE(0x00030000, 0) } const padded = Buffer.alloc((data.length + 3) & ~3) data.copy(padded) const record = 12 + index * 16 directory.write(name, record, 4, 'ascii') directory.writeUInt32BE(checksum(padded), record + 4) directory.writeUInt32BE(offset, record + 8) directory.writeUInt32BE(data.length, record + 12) result.push(padded) offset += padded.length }) const output = Buffer.concat(result) output.writeUInt32BE((0xb1b0afba - checksum(output)) >>> 0, headOffset + 8) return output } const utf16Hex = value => { const buffer = Buffer.from(value, 'utf16le') buffer.swap16() return buffer.toString('hex').toUpperCase() } const createPdfWriter = () => { const objects = [] return { add(body) { objects.push(Buffer.isBuffer(body) ? body : Buffer.from(body, 'binary')) return objects.length }, save(outputPath) { const header = Buffer.from('%PDF-1.7\n%\xFF\xFF\xFF\xFF\n', 'binary') const chunks = [header] const offsets = [0] let cursor = header.length objects.forEach((body, index) => { offsets.push(cursor) const prefix = Buffer.from(`${index + 1} 0 obj\n`, 'ascii') const suffix = Buffer.from('\nendobj\n', 'ascii') chunks.push(prefix, body, suffix) cursor += prefix.length + body.length + suffix.length }) const xrefOffset = cursor const xref = [`xref\n0 ${objects.length + 1}\n`, '0000000000 65535 f \n'] for (let index = 1; index <= objects.length; index += 1) { xref.push(`${String(offsets[index]).padStart(10, '0')} 00000 n \n`) } xref.push(`trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`) chunks.push(Buffer.from(xref.join(''), 'ascii')) mkdirSync(path.dirname(outputPath), { recursive: true }) writeFileSync(outputPath, Buffer.concat(chunks)) } } } const streamObject = (dictionary, data) => Buffer.concat([ Buffer.from(`<< ${dictionary} /Length ${data.length} >>\nstream\n`, 'ascii'), data, Buffer.from('\nendstream', 'ascii') ]) const money = value => Number(value || 0).toFixed(2) const chineseAmount = value => { const digits = '零壹贰叁肆伍陆柒捌玖' const units = ['', '拾', '佰', '仟', '万', '拾', '佰', '仟', '亿', '拾', '佰', '仟'] const amount = Math.max(Number(value || 0), 0) const integer = Math.floor(amount) const chars = String(integer).split('').reverse() let result = '' let pendingZero = false chars.forEach((char, index) => { const digit = Number(char) if (digit === 0) { pendingZero = result.length > 0 if (index === 4 || index === 8) result = units[index] + result } else { result = `${digits[digit]}${units[index]}${pendingZero ? '零' : ''}${result}` pendingZero = false } }) const cents = Math.round((amount - integer) * 100) const jiao = Math.floor(cents / 10) const fen = cents % 10 return `${result || '零'}元${jiao ? `${digits[jiao]}角` : ''}${fen ? `${digits[fen]}分` : '整'}` } const normalizeInvoice = input => { const digest = createHash('sha256').update(JSON.stringify(input)).digest('hex').toUpperCase() const total = Number(input.orderTotalPrice || 0) const taxRate = Number(input.taxRate ?? 0.13) const naked = Number(input.invoiceNakedPrice ?? total / (1 + taxRate)) const tax = Number(input.invoiceTaxPrice ?? total - naked) return { ...input, invoiceDate: input.invoiceDate || new Date().toISOString().slice(0, 10), invoiceNumber: input.invoiceNumber || digest.slice(0, 20), documentInvoiceCode: input.documentInvoiceCode || digest.slice(0, 12), taxIdNumber: input.taxIdNumber || input.buyerTaxId || input.invoiceCode || '', checkCode: input.checkCode || digest.slice(20, 40), sellerName: input.sellerName || '模拟积分商城供应商', sellerTaxId: input.sellerTaxId || input.supplierId || 'SIMULATED-SUPPLIER', sellerAddress: input.sellerAddress || '模拟供应商注册地址', sellerContact: input.sellerContact || '400-000-0000', invoiceContent: input.invoiceContent || '积分商城商品', orderNum: Number(input.orderNum || 1), total, naked, tax, taxRate } } export const generateMockInvoicePdf = (invoiceInput, outputPath, _options = {}) => { const invoice = normalizeInvoice(invoiceInput) let commands = [] const font = readFileSync(findFont(_options.fontPath)) const glyphFor = createGlyphMapper(font) const characters = new Map() const encode = value => Array.from(String(value ?? '')).map(character => { if (!characters.has(character)) { const glyph = glyphFor(character.codePointAt(0)) || (character === '¥' ? glyphFor('¥'.codePointAt(0)) : 0) if (!glyph) throw new Error(`字体不支持字符“${character}”,请配置支持中文的 TTF 字体`) characters.set(character, { cid: characters.size + 1, glyph }) } return characters.get(character).cid.toString(16).padStart(4, '0') }).join('') const text = (value, x, y, size = 10, color = '0 0 0') => { commands.push(`BT /F1 ${size} Tf ${color} rg 1 0 0 1 ${x} ${y} Tm <${encode(value)}> Tj ET`) } const line = (x1, y1, x2, y2, width = 0.7, color = '0.45 0.12 0.12') => { commands.push(`${color} RG ${width} w ${x1} ${y1} m ${x2} ${y2} l S`) } const rect = (x, y, width, height, color = '0.45 0.12 0.12') => { commands.push(`${color} RG 0.7 w ${x} ${y} ${width} ${height} re S`) } const wrappedText = (value, x, y, maxChars, size = 9, lineHeight = 13) => { const characters = Array.from(String(value || '')) for (let index = 0; index < characters.length; index += maxChars) { text(characters.slice(index, index + maxChars).join(''), x, y - index / maxChars * lineHeight, size) } } const labelColor = '0.65 0.05 0.05' const fitText = (value, x, y, width, size = 10, color = '0 0 0') => { const content = String(value ?? '') const length = Array.from(content).reduce((sum, character) => sum + (character.charCodeAt(0) < 128 ? 0.6 : 1), 0) text(content, x, y, Math.min(size, width / Math.max(length, 1)), color) } const dateValue = typeof invoice.invoiceDate === 'number' ? new Date(invoice.invoiceDate).toISOString().slice(0, 10) : String(invoice.invoiceDate) const invoiceDate = dateValue.replace(/^(\d{4})-(\d{2})-(\d{2}).*$/, '$1年$2月$3日') const title = Number(invoice.invoiceType) === 9 ? '电子发票(增值税专用发票)' : '电子发票(普通发票)' commands.push('1 1 1 rg 0 0 842 595 re f') text(title, (PAGE_WIDTH - title.length * 21) / 2, 546, 21, labelColor) line(245, 531, 590, 531, 0.8, labelColor) line(245, 527, 590, 527, 0.8, labelColor) text('模拟发票', 52, 554, 13, labelColor) text('仅供开发测试,禁止报销', 52, 534, 9, labelColor) text('发票号码:', 625, 556, 9, labelColor) fitText(invoice.invoiceNumber, 680, 556, 120, 9) text('开票日期:', 625, 535, 9, labelColor) fitText(invoiceDate, 680, 535, 120, 9) rect(42, 100, 758, 388, labelColor) line(42, 398, 800, 398, 0.7, labelColor) line(42, 217, 800, 217, 0.7, labelColor) line(42, 183, 800, 183, 0.7, labelColor) line(70, 398, 70, 488, 0.7, labelColor) line(421, 398, 421, 488, 0.7, labelColor) line(449, 398, 449, 488, 0.7, labelColor) ;['购', '买', '方', '信', '息'].forEach((value, index) => text(value, 51, 474 - index * 15, 10, labelColor)) ;['销', '售', '方', '信', '息'].forEach((value, index) => text(value, 430, 474 - index * 15, 10, labelColor)) text('名称:', 80, 464, 10, labelColor) fitText(invoice.invoiceTitle, 111, 464, 300, 10) text('统一社会信用代码/纳税人识别号:', 80, 422, 8, labelColor) fitText(invoice.taxIdNumber, 242, 422, 169, 10) text('名称:', 459, 464, 10, labelColor) fitText(invoice.sellerName, 490, 464, 300, 10) text('统一社会信用代码/纳税人识别号:', 459, 422, 8, labelColor) fitText(invoice.sellerTaxId, 621, 422, 169, 10) const columns = [42, 240, 322, 385, 438, 510, 605, 692, 800] ;['项目名称', '规格型号', '单位', '数量', '单价', '金额', '税率/征收率', '税额'].forEach((label, index) => { const x = (columns[index] + columns[index + 1] - label.length * 9) / 2 text(label, x, 382, 9, labelColor) }) wrappedText(`*商品*${String(invoice.invoiceContent).length > 50 ? '订单商品汇总(详见后附明细)' : invoice.invoiceContent}`, 50, 360, 20, 9, 14) text('项', 344, 360, 9) fitText(invoice.orderNum, 398, 360, 32, 9) fitText(money(invoice.naked / invoice.orderNum), 445, 360, 60, 9) fitText(money(invoice.naked), 526, 360, 73, 9) fitText(`${Number((invoice.taxRate * 100).toFixed(4))}%`, 627, 360, 60, 9) fitText(money(invoice.tax), 722, 360, 70, 9) text('合 计', 92, 230, 10, labelColor) fitText(`¥${money(invoice.naked)}`, 526, 230, 73, 10) fitText(`¥${money(invoice.tax)}`, 722, 230, 70, 10) line(235, 183, 235, 217, 0.7, labelColor) text('价税合计(大写)', 88, 196, 10, labelColor) fitText(chineseAmount(invoice.total), 245, 196, 340, 11) text('(小写)', 603, 196, 10, labelColor) fitText(`¥${money(invoice.total)}`, 657, 196, 133, 11) line(70, 100, 70, 183, 0.7, labelColor) text('备', 51, 153, 10, labelColor) text('注', 51, 130, 10, labelColor) fitText(`结算单号:${invoice.settlementId || ''}`, 80, 165, 710, 9) fitText(`订单号:${String(invoice.orderIds || '').length > 90 ? '详见后附订单商品明细' : invoice.orderIds || ''}`, 80, 147, 710, 9) wrappedText(String(invoice.remark || '模拟开票,仅用于开发与联调测试'), 80, 129, 78, 9, 12) text('开票人:', 90, 73, 10, labelColor) fitText(invoice.biller || '模拟开票员', 140, 73, 250, 10) const pages = [Buffer.from(commands.join('\n'), 'ascii')] const rows = (Array.isArray(invoice.orderProducts) ? invoice.orderProducts : []).flatMap(product => { const name = Array.from(String(product.name || product.sku || '商品')) const lines = [] for (let i = 0; i < name.length; i += 28) { lines.push({ ...product, name: name.slice(i, i + 28).join(''), continued: i > 0 }) } return lines }) for (let offset = 0; offset < rows.length; offset += 18) { commands = [] text('订单商品明细(模拟发票附件)', 270, 550, 18) text(`结算单号:${invoice.settlementId || ''}`, 42, 522, 10) text(`发票号码:${invoice.invoiceNumber}`, 440, 522, 10) text('订单号', 42, 485, 10) text('商品名称', 285, 485, 10) text('数量', 610, 485, 10) text('单价(元)', 665, 485, 10) line(42, 476, 800, 476) rows.slice(offset, offset + 18).forEach((product, index) => { const y = 453 - index * 21 text(product.orderId, 42, y, 8) text(product.name, 285, y, 9) if (!product.continued) { text(product.number, 610, y, 9) text(money(product.price), 665, y, 9) } }) text(`第 ${pages.length + 1} 页 · 仅供开发测试,禁止报销`, 290, 35, 9) pages.push(Buffer.from(commands.join('\n'), 'ascii')) } const tables = readTableDirectory(font) const head = tables.get('head').offset const hhea = tables.get('hhea').offset const units = font.readUInt16BE(head + 18) const scale = value => Math.round(value * 1000 / units) const metricsCount = font.readUInt16BE(hhea + 34) const cidMap = Buffer.alloc((characters.size + 1) * 2) const widths = [] const unicode = [] for (const [character, { cid, glyph }] of characters) { cidMap.writeUInt16BE(glyph, cid * 2) widths.push(scale(font.readUInt16BE(tables.get('hmtx').offset + Math.min(glyph, metricsCount - 1) * 4))) unicode.push(`<${cid.toString(16).padStart(4, '0')}> <${utf16Hex(character)}>`) } const cmap = ['/CIDInit /ProcSet findresource begin', '12 dict begin', 'begincmap', '/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def', '/CMapName /InvoiceUnicode def', '/CMapType 2 def', '1 begincodespacerange', '<0000> ', 'endcodespacerange'] for (let i = 0; i < unicode.length; i += 100) { const batch = unicode.slice(i, i + 100) cmap.push(`${batch.length} beginbfchar`, ...batch, 'endbfchar') } cmap.push('endcmap', 'CMapName currentdict /CMap defineresource pop', 'end', 'end') const embedded = subsetFont(font, Array.from(characters.values(), item => item.glyph)) const writer = createPdfWriter() writer.add('<< /Type /Catalog /Pages 2 0 R >>') const pageIds = [3, ...pages.slice(1).map((_, index) => 11 + index * 2)] writer.add(`<< /Type /Pages /Kids [${pageIds.map(id => `${id} 0 R`).join(' ')}] /Count ${pages.length} >>`) writer.add(`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${PAGE_WIDTH} ${PAGE_HEIGHT}] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>`) writer.add(streamObject('', pages[0])) writer.add('<< /Type /Font /Subtype /Type0 /BaseFont /InvoiceFont /Encoding /Identity-H /DescendantFonts [6 0 R] /ToUnicode 10 0 R >>') writer.add(`<< /Type /Font /Subtype /CIDFontType2 /BaseFont /InvoiceFont /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> /FontDescriptor 7 0 R /CIDToGIDMap 9 0 R /DW 1000 /W [1 [${widths.join(' ')}]] >>`) const bbox = [36, 38, 40, 42].map(offset => scale(font.readInt16BE(head + offset))).join(' ') writer.add(`<< /Type /FontDescriptor /FontName /InvoiceFont /Flags 4 /FontBBox [${bbox}] /ItalicAngle 0 /Ascent ${scale(font.readInt16BE(hhea + 4))} /Descent ${scale(font.readInt16BE(hhea + 6))} /CapHeight ${scale(font.readInt16BE(hhea + 4))} /StemV 80 /FontFile2 8 0 R >>`) writer.add(streamObject(`/Filter /FlateDecode /Length1 ${embedded.length}`, deflateSync(embedded))) writer.add(streamObject('', cidMap)) writer.add(streamObject('', Buffer.from(cmap.join('\n'), 'ascii'))) pages.slice(1).forEach((content, index) => { const pageId = 11 + index * 2 writer.add(`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${PAGE_WIDTH} ${PAGE_HEIGHT}] /Resources << /Font << /F1 5 0 R >> >> /Contents ${pageId + 1} 0 R >>`) writer.add(streamObject('', content)) }) writer.save(outputPath) return outputPath } const parseArguments = argv => { const options = {} for (let index = 0; index < argv.length; index += 1) { if (argv[index] === '--input') options.input = argv[++index] else if (argv[index] === '--data') options.data = argv[++index] else if (argv[index] === '--output') options.output = argv[++index] else if (argv[index] === '--font') options.font = argv[++index] } return options } if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { try { const options = parseArguments(process.argv.slice(2)) if (!options.input && !options.data) { throw new Error('请使用 --input 或 --data 传入开票信息') } const input = options.input ? JSON.parse(readFileSync(path.resolve(options.input), 'utf8')) : JSON.parse(options.data) const defaultName = `mock-invoice-${String(input.settlementId || Date.now()).replace(/[^a-zA-Z0-9_-]/g, '_')}.pdf` const outputPath = path.resolve(options.output || path.join('temp', 'pdf', defaultName)) generateMockInvoicePdf(input, outputPath, { fontPath: options.font }) console.log(outputPath) } catch (error) { console.error(error instanceof Error ? error.message : String(error)) process.exitCode = 1 } }