336 lines
14 KiB
JavaScript
336 lines
14 KiB
JavaScript
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),
|
|
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)
|
|
const commands = []
|
|
|
|
const encode = value => utf16Hex(String(value ?? ''))
|
|
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, 572, 9)
|
|
text(`发票代码:${invoice.documentInvoiceCode}`, 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.taxIdNumber, 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 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 /STSong-Light /Encoding /UniGB-UCS2-H /DescendantFonts [6 0 R] >>')
|
|
writer.add('<< /Type /Font /Subtype /CIDFontType0 /BaseFont /STSong-Light /CIDSystemInfo << /Registry (Adobe) /Ordering (GB1) /Supplement 4 >> /DW 1000 >>')
|
|
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('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
|
|
}
|
|
}
|