Files
points-mall/supplied-service/tools/mock-invoice-pdf.js
T

490 lines
22 KiB
JavaScript

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> <FFFF>', '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 <json文件> 或 --data <json字符串> 传入开票信息')
}
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
}
}