import { createHash } from 'node:crypto' 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 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), 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) const fontPath = findFont(options.fontPath) const font = readFileSync(fontPath) const glyphFor = createGlyphMapper(font) const glyphUnicode = new Map() const commands = [] const encode = value => Array.from(String(value ?? '')).map(character => { const glyph = glyphFor(character.codePointAt(0)) if (glyph && !glyphUnicode.has(glyph)) glyphUnicode.set(glyph, character) return glyph.toString(16).padStart(4, '0') }).join('').toUpperCase() 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) } } commands.push('1 1 1 rg 0 0 842 595 re f') text('增值税电子普通发票(模拟)', 280, 548, 22, '0.65 0.05 0.05') line(274, 540, 568, 540, 1.2, '0.65 0.05 0.05') text('仅供系统联调测试,禁止报销', 302, 522, 11, '0.75 0.1 0.1') text(`发票号码:${invoice.invoiceNumber}`, 610, 555, 9) text(`开票日期:${invoice.invoiceDate}`, 610, 538, 9) text(`校验码:${invoice.checkCode}`, 610, 521, 9) text(`结算单号:${invoice.settlementId || ''}`, 50, 521, 9) rect(42, 188, 758, 320) line(42, 414, 800, 414) line(42, 320, 800, 320) line(42, 280, 800, 280) line(42, 226, 800, 226) line(120, 414, 120, 508) line(120, 320, 120, 414) line(475, 414, 475, 508) line(475, 320, 475, 414) text('购买方', 68, 457, 12, '0.55 0.05 0.05') text('销售方', 68, 365, 12, '0.55 0.05 0.05') text('名称:', 132, 488, 9) wrappedText(invoice.invoiceTitle, 180, 488, 26, 9) text('纳税人识别号:', 132, 464, 9) text(invoice.invoiceCode, 235, 464, 9) text('地址、电话:', 487, 488, 9) wrappedText(`${invoice.invoiceAddress || ''} ${invoice.invoiceContact || ''}`, 574, 488, 20, 8) text('开户行及账号:', 487, 448, 9) wrappedText(`${invoice.bankName || ''} ${invoice.bankAccount || ''}`, 574, 448, 20, 8) text('名称:', 132, 395, 9) text(invoice.sellerName, 180, 395, 9) text('纳税人识别号:', 132, 371, 9) text(invoice.sellerTaxId, 235, 371, 9) text('地址、电话:', 487, 395, 9) wrappedText(`${invoice.sellerAddress} ${invoice.sellerContact}`, 574, 395, 20, 8) text('开户行及账号:', 487, 350, 9) wrappedText(`${invoice.sellerBankName || '模拟银行'} ${invoice.sellerBankAccount || '0000000000000000'}`, 574, 350, 20, 8) const columns = [42, 290, 382, 440, 495, 580, 655, 720, 800] columns.slice(1, -1).forEach(x => line(x, 226, x, 320)) ;['货物或应税劳务、服务名称', '规格型号', '单位', '数量', '单价', '金额', '税率', '税额'].forEach((label, index) => { text(label, columns[index] + 7, 300, index === 0 ? 9 : 8) }) wrappedText(`*商品*${invoice.invoiceContent}`, 50, 263, 28, 9) text(invoice.invoiceType || '电子发票', 300, 263, 8) text('项', 401, 263, 9) text(invoice.orderNum, 458, 263, 9) text(money(invoice.naked / invoice.orderNum), 503, 263, 8) text(money(invoice.naked), 590, 263, 8) text(`${(invoice.taxRate * 100).toFixed(0)}%`, 674, 263, 8) text(money(invoice.tax), 730, 263, 8) text('合计', 240, 203, 10) text(`¥${money(invoice.naked)}`, 590, 203, 10) text(`¥${money(invoice.tax)}`, 730, 203, 10) text(`价税合计(大写):${chineseAmount(invoice.total)}`, 52, 164, 11) text(`(小写):¥${money(invoice.total)}`, 600, 164, 11) text(`订单号:${invoice.orderIds || ''}`, 52, 132, 8) wrappedText(`备注:${invoice.remark || '模拟开票,仅用于开发与联调测试'}`, 52, 111, 70, 8) text('收款人:模拟收款员', 52, 65, 9) text('复核:模拟复核员', 285, 65, 9) text('开票人:模拟开票员', 510, 65, 9) text('销售方(章)', 682, 65, 9, '0.7 0.05 0.05') commands.push('q 0.92 0.92 0.92 rg BT /F1 34 Tf 0.866 0.5 -0.5 0.866 235 170 Tm') commands.push(`<${encode('模拟发票 禁止报销')}> Tj ET Q`) commands.push('0.75 0.08 0.08 RG 2 w 798 88 m 798 111.196 779.196 130 756 130 c 732.804 130 714 111.196 714 88 c 714 64.804 732.804 46 756 46 c 779.196 46 798 64.804 798 88 c S') const content = Buffer.from(commands.join('\n'), 'ascii') const toUnicodeEntries = [...glyphUnicode.entries()].map(([glyph, character]) => `<${glyph.toString(16).padStart(4, '0').toUpperCase()}> <${utf16Hex(character)}>`) const toUnicodeBlocks = [] for (let index = 0; index < toUnicodeEntries.length; index += 100) { const entries = toUnicodeEntries.slice(index, index + 100) toUnicodeBlocks.push(`${entries.length} beginbfchar`, ...entries, 'endbfchar') } const toUnicode = Buffer.from([ '/CIDInit /ProcSet findresource begin', '12 dict begin', 'begincmap', '/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def', '/CMapName /MockInvoiceUnicode def', '/CMapType 2 def', '1 begincodespacerange', '<0000> ', 'endcodespacerange', ...toUnicodeBlocks, 'endcmap', 'CMapName currentdict /CMap defineresource pop', 'end', 'end' ].join('\n'), 'ascii') const writer = createPdfWriter() writer.add('<< /Type /Catalog /Pages 2 0 R >>') writer.add('<< /Type /Pages /Kids [3 0 R] /Count 1 >>') 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('', content)) writer.add('<< /Type /Font /Subtype /Type0 /BaseFont /MockInvoiceFont /Encoding /Identity-H /DescendantFonts [6 0 R] /ToUnicode 9 0 R >>') writer.add('<< /Type /Font /Subtype /CIDFontType2 /BaseFont /MockInvoiceFont /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> /FontDescriptor 7 0 R /DW 1000 /CIDToGIDMap /Identity >>') writer.add('<< /Type /FontDescriptor /FontName /MockInvoiceFont /Flags 4 /FontBBox [-1000 -1000 3000 3000] /ItalicAngle 0 /Ascent 1000 /Descent -300 /CapHeight 800 /StemV 80 /FontFile2 8 0 R >>') writer.add(streamObject(`/Length1 ${font.length}`, font)) writer.add(streamObject('', toUnicode)) 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('output', '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 } }