Files
accounted/lib/invoices/pdf-fonts.ts
T
Mattsson e11f70b347 Bug/gh issues fiz (#1103)
* refactor: optimize page loading and data fetching

* fix: resolve recurring production runtime errors

* feat: add MCP company and customer updates

* fix: handle year-end tax adjustments

* feat: harden annual report compliance

* fix: expand invoice logo and font support

* fix: sanitize API route error responses

* fix: sanitize user-facing error messages

* feat: persist onboarding and tax assessment notices

* fix: reduce cloud backup audit churn

* feat: refine invoice editor layout

* fix: show saved tax adjustments in INK2

* fix: complete annual report API mappings

* docs: record operational safeguards and decisions

* fix: harden annual report review findings

* fix: adjust column span for description based on VAT registration

* New css class name
2026-07-21 23:00:15 +02:00

221 lines
7.0 KiB
TypeScript

import { createHash } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { Font } from '@react-pdf/renderer'
import type { CompanySettings, InvoiceFontFamily } from '@/types'
import type { InvoiceBranding } from '@/lib/invoices/pdf-template'
import {
BUNDLED_INVOICE_FONT_FAMILIES,
CUSTOM_INVOICE_FONT_FAMILY,
INVOICE_FONT_UPLOAD_MAX_BYTES,
STANDARD_PDF_FONT_FAMILIES,
} from '@/lib/invoices/branding-constants'
import {
detectInvoiceFontFileFormat,
toInvoiceFontDataUrl,
} from '@/lib/invoices/font-files'
import { createLogger } from '@/lib/logger'
import { createServiceClient } from '@/lib/supabase/server'
export const CUSTOM_INVOICE_FONT_RENDER_PREFIX = 'InvoiceCustom-'
type BundledInvoiceFontFamily = (typeof BUNDLED_INVOICE_FONT_FAMILIES)[number]
interface FontVariantFiles {
regular: string
bold: string
italic: string
boldItalic: string
}
const BUNDLED_FONT_FILES: Record<BundledInvoiceFontFamily, FontVariantFiles> = {
'Source Sans 3': {
regular: 'SourceSans3-Regular.ttf',
bold: 'SourceSans3-Bold.ttf',
italic: 'SourceSans3-Italic.ttf',
boldItalic: 'SourceSans3-BoldItalic.ttf',
},
'Source Serif 4': {
regular: 'SourceSerif4-Regular.ttf',
bold: 'SourceSerif4-Bold.ttf',
italic: 'SourceSerif4-Italic.ttf',
boldItalic: 'SourceSerif4-BoldItalic.ttf',
},
}
const log = createLogger('invoice.pdf-fonts')
const registeredFamilies = new Set<string>()
const bundledRegistration = new Map<string, Promise<void>>()
const customRegistration = new Map<string, Promise<string | null>>()
const invalidCustomFontPaths = new Set<string>()
function isStandardPdfFont(
family: InvoiceFontFamily,
): family is (typeof STANDARD_PDF_FONT_FAMILIES)[number] {
return STANDARD_PDF_FONT_FAMILIES.includes(
family as (typeof STANDARD_PDF_FONT_FAMILIES)[number],
)
}
function isBundledInvoiceFont(
family: InvoiceFontFamily,
): family is BundledInvoiceFontFamily {
return BUNDLED_INVOICE_FONT_FAMILIES.includes(
family as BundledInvoiceFontFamily,
)
}
async function readBundledFont(fileName: string): Promise<string> {
const bytes = await readFile(join(process.cwd(), 'public', 'fonts', 'invoice', fileName))
const format = detectInvoiceFontFileFormat(bytes)
if (!format) throw new Error(`Invalid bundled invoice font: ${fileName}`)
return toInvoiceFontDataUrl(bytes, format)
}
async function registerBundledFont(family: BundledInvoiceFontFamily): Promise<void> {
if (registeredFamilies.has(family)) return
let registration = bundledRegistration.get(family)
if (!registration) {
registration = (async () => {
const files = BUNDLED_FONT_FILES[family]
const [regular, bold, italic, boldItalic] = await Promise.all([
readBundledFont(files.regular),
readBundledFont(files.bold),
readBundledFont(files.italic),
readBundledFont(files.boldItalic),
])
Font.register({
family,
fonts: [
{ src: regular, fontWeight: 400, fontStyle: 'normal' },
{ src: bold, fontWeight: 700, fontStyle: 'normal' },
{ src: italic, fontWeight: 400, fontStyle: 'italic' },
{ src: boldItalic, fontWeight: 700, fontStyle: 'italic' },
],
})
await Promise.all([
Font.load({ fontFamily: family, fontWeight: 400, fontStyle: 'normal' }),
Font.load({ fontFamily: family, fontWeight: 700, fontStyle: 'normal' }),
Font.load({ fontFamily: family, fontWeight: 400, fontStyle: 'italic' }),
Font.load({ fontFamily: family, fontWeight: 700, fontStyle: 'italic' }),
])
registeredFamilies.add(family)
})()
bundledRegistration.set(family, registration)
}
try {
await registration
} finally {
bundledRegistration.delete(family)
}
}
async function downloadCustomFontDataUrl(
companyId: string,
storagePath: string,
): Promise<string | null> {
const pathPrefix = `${companyId}/`
const fileName = storagePath.slice(pathPrefix.length)
if (
!storagePath.startsWith(pathPrefix) ||
!/^invoice-font-\d+\.(ttf|woff)$/.test(fileName)
) {
return null
}
try {
const serviceClient = createServiceClient()
const { data, error } = await serviceClient.storage
.from('invoice-fonts')
.download(storagePath)
if (error || !data || data.size > INVOICE_FONT_UPLOAD_MAX_BYTES) return null
const bytes = new Uint8Array(await data.arrayBuffer())
if (bytes.byteLength > INVOICE_FONT_UPLOAD_MAX_BYTES) return null
const format = detectInvoiceFontFileFormat(bytes)
return format ? toInvoiceFontDataUrl(bytes, format) : null
} catch {
return null
}
}
async function registerCustomFont(company: CompanySettings): Promise<string | null> {
const storagePath = company.invoice_custom_font_path
if (!storagePath || invalidCustomFontPaths.has(storagePath)) return null
const digest = createHash('sha256').update(storagePath).digest('hex').slice(0, 12)
const family = `${CUSTOM_INVOICE_FONT_RENDER_PREFIX}${digest}`
if (registeredFamilies.has(family)) return family
let registration = customRegistration.get(storagePath)
if (!registration) {
registration = (async () => {
const src = await downloadCustomFontDataUrl(company.company_id, storagePath)
if (!src) return null
Font.register({
family,
fonts: [
{ src, fontWeight: 400, fontStyle: 'normal' },
{ src, fontWeight: 700, fontStyle: 'normal' },
{ src, fontWeight: 400, fontStyle: 'italic' },
{ src, fontWeight: 700, fontStyle: 'italic' },
],
})
try {
await Font.load({ fontFamily: family, fontWeight: 400, fontStyle: 'normal' })
} catch {
invalidCustomFontPaths.add(storagePath)
return null
}
registeredFamilies.add(family)
return family
})()
customRegistration.set(storagePath, registration)
}
try {
return await registration
} finally {
customRegistration.delete(storagePath)
}
}
function withHelveticaFallback(branding: InvoiceBranding): InvoiceBranding {
return { ...branding, fontFamily: 'Helvetica' }
}
export async function prepareInvoiceFont(
company: CompanySettings,
branding: InvoiceBranding,
): Promise<InvoiceBranding> {
const family = branding.fontFamily ?? 'Helvetica'
if (isStandardPdfFont(family as InvoiceFontFamily)) return branding
if (isBundledInvoiceFont(family as InvoiceFontFamily)) {
try {
await registerBundledFont(family as BundledInvoiceFontFamily)
return branding
} catch (error) {
log.warn('bundled invoice font registration failed', {
family,
error: error instanceof Error ? error.message : String(error),
})
return withHelveticaFallback(branding)
}
}
if (family === CUSTOM_INVOICE_FONT_FAMILY) {
const registeredFamily = await registerCustomFont(company)
if (registeredFamily) return { ...branding, fontFamily: registeredFamily }
log.warn('custom invoice font unavailable, using Helvetica', {
companyId: company.company_id,
})
}
return withHelveticaFallback(branding)
}