526 lines
24 KiB
JavaScript
526 lines
24 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 ? '电子发票(增值税专用发票)' : '电子发票(普通发票)'
|
|
|
|
const products = Array.isArray(invoice.orderProducts) && invoice.orderProducts.length
|
|
? invoice.orderProducts
|
|
: [{ name: invoice.invoiceContent, number: invoice.orderNum, price: invoice.total / invoice.orderNum, unit: '项' }]
|
|
const weights = products.map(product => Math.max(Number(product.price || 0) * Number(product.number || 1), 0))
|
|
const totalWeight = weights.reduce((sum, value) => sum + value, 0)
|
|
const nakedCents = Math.round(invoice.naked * 100)
|
|
const taxCents = Math.round(invoice.tax * 100)
|
|
let accumulatedWeight = 0
|
|
let allocatedNaked = 0
|
|
let allocatedTax = 0
|
|
// 按商品金额分摊票面金额,累计取整以消除逐行计算产生的分位尾差。
|
|
const invoiceItems = products.map((product, index) => {
|
|
accumulatedWeight += weights[index]
|
|
const ratio = totalWeight > 0 ? accumulatedWeight / totalWeight : (index + 1) / products.length
|
|
const nextNaked = index === products.length - 1 ? nakedCents : Math.round(nakedCents * ratio)
|
|
const nextTax = index === products.length - 1 ? taxCents : Math.round(taxCents * ratio)
|
|
const item = { ...product, number: Number(product.number || 1), naked: (nextNaked - allocatedNaked) / 100, tax: (nextTax - allocatedTax) / 100 }
|
|
allocatedNaked = nextNaked
|
|
allocatedTax = nextTax
|
|
return item
|
|
})
|
|
const pages = []
|
|
const itemsPerPage = 5
|
|
for (let itemOffset = 0; itemOffset < invoiceItems.length; itemOffset += itemsPerPage) {
|
|
commands = []
|
|
const lastItemPage = itemOffset + itemsPerPage >= invoiceItems.length
|
|
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)
|
|
})
|
|
invoiceItems.slice(itemOffset, itemOffset + itemsPerPage).forEach((item, index) => {
|
|
const y = 360 - index * 23
|
|
fitText(`*商品*${item.name || item.sku || '商品'}`, 50, y, 184, 9)
|
|
fitText(item.spec || item.skuText || '', 247, y, 69, 8)
|
|
fitText(item.unit || '件', 337, y, 40, 9)
|
|
fitText(item.number, 391, y, 40, 9)
|
|
fitText(Number((item.naked / item.number).toFixed(6)).toString(), 445, y, 60, 9)
|
|
fitText(money(item.naked), 526, y, 73, 9)
|
|
fitText(`${Number((invoice.taxRate * 100).toFixed(4))}%`, 627, y, 60, 9)
|
|
fitText(money(item.tax), 722, y, 70, 9)
|
|
})
|
|
if (lastItemPage) {
|
|
text('合 计', 92, 230, 10, labelColor)
|
|
fitText(`¥${money(invoice.naked)}`, 526, 230, 73, 10)
|
|
fitText(`¥${money(invoice.tax)}`, 722, 230, 70, 10)
|
|
} else {
|
|
text('项目明细续下页,合计见末页', 92, 230, 10, labelColor)
|
|
}
|
|
|
|
line(235, 183, 235, 217, 0.7, labelColor)
|
|
text('价税合计(大写)', 88, 196, 10, labelColor)
|
|
fitText(lastItemPage ? chineseAmount(invoice.total) : '详见项目明细末页', 245, 196, 340, 11)
|
|
text('(小写)', 603, 196, 10, labelColor)
|
|
if (lastItemPage) 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)
|
|
|
|
text(`项目明细第 ${pages.length + 1} / ${Math.ceil(invoiceItems.length / itemsPerPage)} 页`, 620, 73, 9, labelColor)
|
|
pages.push(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
|
|
}
|
|
}
|