88 lines
3.0 KiB
TypeScript
88 lines
3.0 KiB
TypeScript
// Minimal single-page PDF writer — no dependency (jspdf/pdf-lib) needed.
|
|
// Builds a valid PDF byte-for-byte using the base14 Helvetica font, which every
|
|
// PDF reader supports without embedding. Good enough for text/table documents
|
|
// like a payslip breakdown; not a general-purpose PDF library.
|
|
|
|
export interface PdfLine {
|
|
text: string
|
|
x: number
|
|
y: number
|
|
size?: number
|
|
bold?: boolean
|
|
color?: [number, number, number] // 0-1 RGB
|
|
}
|
|
|
|
const PAGE_WIDTH = 612 // US Letter, points
|
|
const PAGE_HEIGHT = 792
|
|
|
|
function escapePdfText(s: string): string {
|
|
return s.replace(/\\/g, "\\\\").replace(/\(/g, "\\(").replace(/\)/g, "\\)")
|
|
}
|
|
|
|
/** Builds a one-page PDF from absolutely-positioned text lines (origin bottom-left, like PDF space). */
|
|
export function buildSimplePdf(lines: PdfLine[]): Blob {
|
|
const content: string[] = ["q"]
|
|
let currentFont: "F1" | "F2" | null = null
|
|
let currentSize: number | null = null
|
|
let currentColor: string | null = null
|
|
|
|
for (const line of lines) {
|
|
const font = line.bold ? "F2" : "F1"
|
|
const size = line.size ?? 11
|
|
const [r, g, b] = line.color ?? [0.07, 0.07, 0.07]
|
|
const colorKey = `${r},${g},${b}`
|
|
|
|
if (colorKey !== currentColor) {
|
|
content.push(`${r.toFixed(3)} ${g.toFixed(3)} ${b.toFixed(3)} rg`)
|
|
currentColor = colorKey
|
|
}
|
|
content.push("BT")
|
|
if (font !== currentFont || size !== currentSize) {
|
|
content.push(`/${font} ${size} Tf`)
|
|
currentFont = font
|
|
currentSize = size
|
|
}
|
|
content.push(`${line.x.toFixed(2)} ${line.y.toFixed(2)} Td`)
|
|
content.push(`(${escapePdfText(line.text)}) Tj`)
|
|
content.push("ET")
|
|
}
|
|
content.push("Q")
|
|
const contentStream = content.join("\n")
|
|
|
|
const objects: string[] = []
|
|
objects.push("<< /Type /Catalog /Pages 2 0 R >>")
|
|
objects.push("<< /Type /Pages /Kids [3 0 R] /Count 1 >>")
|
|
objects.push(
|
|
`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${PAGE_WIDTH} ${PAGE_HEIGHT}] /Resources << /Font << /F1 4 0 R /F2 5 0 R >> >> /Contents 6 0 R >>`
|
|
)
|
|
objects.push("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>")
|
|
objects.push("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >>")
|
|
objects.push(`<< /Length ${contentStream.length} >>\nstream\n${contentStream}\nendstream`)
|
|
|
|
let pdf = "%PDF-1.4\n"
|
|
const offsets: number[] = [0]
|
|
objects.forEach((obj, i) => {
|
|
offsets.push(pdf.length)
|
|
pdf += `${i + 1} 0 obj\n${obj}\nendobj\n`
|
|
})
|
|
|
|
const xrefStart = pdf.length
|
|
let xref = `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`
|
|
for (let i = 1; i <= objects.length; i++) {
|
|
xref += `${offsets[i].toString().padStart(10, "0")} 00000 n \n`
|
|
}
|
|
pdf += xref
|
|
pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefStart}\n%%EOF`
|
|
|
|
return new Blob([pdf], { type: "application/pdf" })
|
|
}
|
|
|
|
export function downloadPdf(blob: Blob, filename: string) {
|
|
const url = URL.createObjectURL(blob)
|
|
const a = document.createElement("a")
|
|
a.href = url
|
|
a.download = filename
|
|
a.click()
|
|
URL.revokeObjectURL(url)
|
|
}
|