Files
accounted/lib/invoices/__tests__/pdf-template-layout.test.ts
T
MattssonandClaude Fable 5.1 7a30f623ba fix(invoices): invoice PDF draft stamp, page breaks, wrapping, units, multi-line descriptions (#2369)
* fix(invoices): draft stamp out of flow, page-break control, whole-word wrapping, English units, multi-line descriptions

A user reported five things about the invoice PDF after the English
translation shipped:

- The yellow draft banner pushed the whole document down, so a draft
  previewed differently from the final invoice. It is now a small stamp in
  the page's top margin (absolute, fixed), repeated on every page, out of
  the flow.
- Table rows, totals, the payment box and the notice boxes could split
  across a page break, and a section heading could be left alone at the
  bottom of a page. Those blocks now carry wrap={false}; headings and the
  table header carry minPresenceAhead.
- react-pdf hyphenated Swedish words with English patterns ("Septem-ber").
  Descriptions, notes, notices and the footer now wrap whole words.
- "st" printed verbatim on an English invoice. The editor's known units map
  to English labels at render time; user-typed units print as stored.
- Descriptions were single-line inputs, so a user could never choose where
  a line breaks. The editor field is now an auto-growing textarea; the PDF
  and the on-screen views keep the line breaks; the Peppol item name
  collapses them (single-line field).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BuYbae3WWwYzBucTUVayoW

* fix(invoices): keep long tokens printable and oversize text splittable in the invoice PDF

Skeptic findings on the first commit:

- A word wider than its column was dropped from the page or overprinted the
  quantity column, because the no-hyphenation callback gave react-pdf no
  break point inside it. Words up to 16 characters still wrap whole; longer
  tokens (URLs, e-mail addresses, references) break after separators and
  every 16 characters.
- wrap={false} on a text row or notes taller than a page clipped everything
  past the page edge. Line descriptions and notes are now kept together only
  while a line estimate says they fit; past that they split.
- A multi-line description reached the periodisering voucher text and broke
  the SIE export (one record per line). The accrual builder and the Peppol
  item name share toSingleLine(); the SIE writer collapses line breaks in
  quoted text as a format guard.
- The English no-number draft stamp ended 1.3pt below the top margin.

Tests lay the document out with @react-pdf/layout and assert that no text
node ends past the page edge and that every line's ink stays inside its
column, for 80-line descriptions and notes, a 3000-character description,
and four long tokens.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BuYbae3WWwYzBucTUVayoW

* fix(invoices): count rendered lines and cap the kept-together budget so no font can clip a row

Skeptic cycle 2: with the bundled Source Serif 4 font (13.7pt per line)
and a description of 20 source lines of wide glyphs, the estimate said
"fits" while the chunked token wrapping produced 58 rendered lines; the
non-splittable row then ran off the page. The estimate now counts the
chunks a long token is broken into, and the cap is 12 lines: at 20pt per
line that is under a third of the page for any font a company can pick.

The page-edge test now measures absolute positions (box.top is
parent-relative) and covers the bundled serif case through
prepareInvoiceFont().

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BuYbae3WWwYzBucTUVayoW

* fix(invoices): keep words whole by estimated ink width, not a character cap

Skeptic cycle 2 (regression): the flat 16-character cap gave react-pdf a
break point inside ordinary Swedish compounds (Fastighetsskötse-l,
Företagsförsäkri-ng), which the breaker used whenever it filled the line
better. Words are now kept whole whenever a rough per-glyph width estimate
says they fit the column; only a token wider than the column gets parts,
after separators and where the column is full. The page-fit estimate uses
the same widths.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BuYbae3WWwYzBucTUVayoW

* fix(invoices): estimate word width from real Helvetica metrics

Skeptic cycle 3: the hand-rolled per-glyph estimate over-counted ordinary
lowercase (6.5pt for a 5.56pt glyph), so 24 to 28 character compounds
(Momskompensationsansökan, Mervärdesskattedeklarationen) were still handed
a break point while they fit the column, and under-counted rare glyphs
(æ, œ, Cyrillic) so a token of those could overflow. The estimate now uses
Helvetica advances measured through react-pdf's own metrics
(lib/invoices/pdf-glyph-widths.ts) with a 10% margin for the bundled
fonts, and counts any glyph outside Helvetica at the widest Latin advance.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BuYbae3WWwYzBucTUVayoW

* docs(invoices): state the bundled-font width margin accurately

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BuYbae3WWwYzBucTUVayoW

* fix(invoices): review round: SIE backslash escaping, text-row budget, ROT/RUT box keep-together

- CodeQL: the SIE quoted-text escaper left backslashes alone while backslash
  is the escape character. It now writes a literal backslash as two, and the
  parser unescapes both that and the escaped quote, so text round-trips.
- CodeRabbit: a free-text row renders at full table width but was budgeted
  at the description column, so it could split unnecessarily. It uses the
  full-width budget now.
- CodeRabbit: the ROT/RUT box was unconditionally kept on one page although
  its per-line breakdown carries the (possibly multi-line) descriptions. It
  uses the same keep-together estimate as rows and notes, and its line texts
  wrap whole words.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BuYbae3WWwYzBucTUVayoW

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 14:45:38 +02:00

537 lines
22 KiB
TypeScript

/**
* Page layout of the invoice PDF: what a user reported after the English
* translation shipped.
*
* - The draft stamp must not move the document: it sits in the page margin,
* out of the flow, on every page. A draft is otherwise a preview that lies
* about where the final invoice will break.
* - Table rows, totals, the payment box and the notice boxes never split
* across a page; a section heading never ends up alone at a page bottom.
* - Words wrap whole: react-pdf's default English hyphenation split Swedish
* words ("Septem-ber").
* - Units follow the document language ("st" prints as "pcs" in English).
* - A description with line breaks keeps them.
*/
import { describe, expect, it } from 'vitest'
import type { ReactElement, ReactNode } from 'react'
import { renderToBuffer } from '@react-pdf/renderer'
import { Font, pdf } from '@react-pdf/renderer'
import layoutDocument from '@react-pdf/layout'
import {
HEADING_MIN_PRESENCE_AHEAD,
InvoicePDF,
DESCRIPTION_COLUMN_PT,
FULL_WIDTH_BOX_PT,
MAX_KEEP_TOGETHER_LINES,
approxWidthPt,
fitsOnOnePage,
wrapDescriptionWords,
wrapFullWidthWords,
} from '@/lib/invoices/pdf-template'
import { makeCompanySettings, makeCustomer, makeInvoice } from '@/tests/helpers'
import type { InvoiceItem } from '@/types'
type AnyElement = ReactElement<Record<string, unknown> & { children?: ReactNode }>
/** Every React element in the tree, in document order. */
function elements(node: ReactNode, out: AnyElement[] = []): AnyElement[] {
if (node === null || node === undefined || typeof node === 'boolean') return out
if (typeof node === 'string' || typeof node === 'number') return out
if (Array.isArray(node)) {
for (const child of node) elements(child, out)
return out
}
const element = node as AnyElement
out.push(element)
if (element.props) elements(element.props.children, out)
return out
}
/** Every string leaf in the element tree, in document order. */
function textLeaves(node: ReactNode, out: string[] = []): string[] {
if (node === null || node === undefined || typeof node === 'boolean') return out
if (typeof node === 'string' || typeof node === 'number') {
out.push(String(node))
return out
}
if (Array.isArray(node)) {
for (const child of node) textLeaves(child, out)
return out
}
const element = node as AnyElement
if (element.props) textLeaves(element.props.children, out)
return out
}
function styleOf(el: AnyElement): Record<string, unknown> {
const style = el.props.style
if (Array.isArray(style)) return Object.assign({}, ...style)
return (style ?? {}) as Record<string, unknown>
}
function containsText(el: AnyElement, needle: string): boolean {
return textLeaves(el.props.children).some((leaf) => leaf.includes(needle))
}
function makeItem(overrides: Partial<InvoiceItem> = {}): InvoiceItem {
return {
id: `item-${overrides.sort_order ?? 0}`,
invoice_id: 'invoice-1',
sort_order: 0,
line_type: 'product',
description: 'Konsulttimmar',
quantity: 10,
unit: 'tim',
unit_price: 1000,
line_total: 10000,
vat_rate: 25,
discount_percent: 0,
accrual_start_date: null,
accrual_end_date: null,
product_id: null,
created_at: '2026-09-01T00:00:00Z',
...overrides,
} as InvoiceItem
}
const company = makeCompanySettings({ company_name: 'Testbrand AB', invoice_show_logo: false })
const customer = makeCustomer({ name: 'Kund AB' })
function draftInvoice() {
return makeInvoice({
status: 'draft',
invoice_number: null,
invoice_date: '2026-09-07',
due_date: '2026-10-07',
subtotal: 10000,
vat_amount: 2500,
total: 12500,
})
}
function sentInvoice() {
return makeInvoice({
status: 'sent',
invoice_number: '1042',
invoice_date: '2026-09-07',
due_date: '2026-10-07',
subtotal: 10000,
vat_amount: 2500,
total: 12500,
})
}
/** Number of pages in a rendered PDF (pdfkit writes one /Type /Page per page). */
function pageCount(buffer: Buffer): number {
return (buffer.toString('latin1').match(/\/Type\s*\/Page\b/g) ?? []).length
}
// The laid-out node tree react-pdf hands to the painter: every node carries
// its resolved box (top/left/width/height, relative to the page) and TEXT
// nodes carry their broken lines. This is what the PDF will look like, so it
// is the place to check geometry rather than parsing content streams.
interface LaidOutNode {
type: string
value?: string
box?: { top: number; left: number; width: number; height: number }
// `box.width` is the line's allotment (the column); `xAdvance` is the ink.
lines?: Array<{ box: { width: number; height: number }; xAdvance?: number; string?: string }>
children?: LaidOutNode[]
}
async function layOut(element: ReactElement): Promise<LaidOutNode[]> {
const instance = pdf(element as Parameters<typeof pdf>[0]) as unknown as { container: { document: unknown } }
// The layout package types its default export as one argument; at runtime
// it takes the document and the font store (this is how the renderer calls
// it). `Font` is the renderer's own store, so fonts registered through
// prepareInvoiceFont() are visible here.
const layout = layoutDocument as unknown as (document: unknown, fontStore: unknown) => Promise<LaidOutNode>
const root = await layout(instance.container.document, Font)
return root.children ?? []
}
function walk(node: LaidOutNode, visit: (n: LaidOutNode) => void) {
visit(node)
for (const child of node.children ?? []) walk(child, visit)
}
function textOf(node: LaidOutNode): string {
let out = ''
walk(node, (n) => {
if (n.type === 'TEXT_INSTANCE') out += n.value ?? ''
})
return out
}
/** Every TEXT node on the page. */
function textNodes(page: LaidOutNode): LaidOutNode[] {
const out: LaidOutNode[] = []
walk(page, (n) => {
if (n.type === 'TEXT' && n.box) out.push(n)
})
return out
}
/**
* Bottom edge of every TEXT node in page coordinates. `box.top` is relative
* to the parent, so the ancestors' tops are summed on the way down.
*/
function absoluteTextBottoms(page: LaidOutNode): Array<{ text: string; bottom: number }> {
const out: Array<{ text: string; bottom: number }> = []
const visit = (node: LaidOutNode, offset: number) => {
const top = offset + (node.box?.top ?? 0)
if (node.type === 'TEXT' && node.box) out.push({ text: textOf(node), bottom: top + node.box.height })
for (const child of node.children ?? []) visit(child, top)
}
for (const child of page.children ?? []) visit(child, 0)
return out
}
function expectNothingPastThePageEdge(pages: LaidOutNode[]) {
for (const page of pages) {
const pageHeight = page.box!.height
for (const { text, bottom } of absoluteTextBottoms(page)) {
expect(bottom, `"${text.slice(0, 40)}" ends past the page edge`).toBeLessThanOrEqual(pageHeight + 0.5)
}
}
}
function expectEveryLineInsideItsBox(pages: LaidOutNode[], needle: string) {
let seen = 0
for (const page of pages) {
for (const node of textNodes(page)) {
if (!textOf(node).includes(needle)) continue
seen += 1
for (const line of node.lines ?? []) {
const ink = line.xAdvance ?? line.box.width
expect(ink, `line "${line.string}" overflows its column`).toBeLessThanOrEqual(node.box!.width + 0.5)
}
}
}
expect(seen).toBeGreaterThan(0)
}
const PAGE_TOP_PADDING = 40
describe('draft stamp', () => {
it('is out of the flow, in the top margin, and repeats on every page', () => {
const tree = InvoicePDF({ invoice: draftInvoice(), customer, items: [makeItem()], company })
const stamp = elements(tree).find(
(el) => el.props.fixed === true && containsText(el, 'UTKAST'),
)
expect(stamp).toBeDefined()
const style = styleOf(stamp!)
expect(style.position).toBe('absolute')
// The page has a 40pt top padding; the stamp must fit inside it.
expect(style.top).toBeLessThan(40)
})
it('is not rendered for a numbered, sent invoice', () => {
const tree = InvoicePDF({ invoice: sentInvoice(), customer, items: [makeItem()], company })
expect(elements(tree).some((el) => containsText(el, 'UTKAST'))).toBe(false)
})
it('renders a real PDF with the stamp on each page of a long draft', { timeout: 30_000 }, async () => {
const items = Array.from({ length: 60 }, (_, i) =>
makeItem({ sort_order: i, id: `item-${i}`, description: `Rad ${i + 1}` }),
)
const buffer = await renderToBuffer(
InvoicePDF({ invoice: draftInvoice(), customer, items, company }),
)
expect(pageCount(buffer)).toBeGreaterThan(1)
const pages = await layOut(InvoicePDF({ invoice: draftInvoice(), customer, items, company }))
expect(pages.length).toBeGreaterThan(1)
for (const page of pages) {
expect(textOf(page)).toContain('UTKAST')
}
})
it.each([
['sv', 'draft'],
['en', 'draft'],
['sv', 'sent'],
['en', 'sent'],
] as const)('stays inside the top margin (%s, %s without number)', async (language, status) => {
// 'sent' without a number is the corrupt-state case with the longest text.
const invoice = { ...draftInvoice(), status }
const pages = await layOut(InvoicePDF({ invoice, customer, items: [makeItem()], company, language }))
const stamp = textNodes(pages[0]).find((n) => /UTKAST|DRAFT/.test(textOf(n)))
expect(stamp).toBeDefined()
let box: LaidOutNode['box']
walk(pages[0], (n) => {
if (n.children?.includes(stamp!)) box = n.box
})
expect(box).toBeDefined()
expect(box!.top + box!.height).toBeLessThanOrEqual(PAGE_TOP_PADDING)
})
})
describe('oversize free text', () => {
it('estimates whether a block fits on one page', () => {
const column = DESCRIPTION_COLUMN_PT
expect(fitsOnOnePage('Konsultation', column)).toBe(true)
expect(fitsOnOnePage(null, column)).toBe(true)
expect(fitsOnOnePage(Array.from({ length: MAX_KEEP_TOGETHER_LINES }, () => 'Rad').join('\n'), column)).toBe(true)
expect(fitsOnOnePage(Array.from({ length: MAX_KEEP_TOGETHER_LINES + 1 }, () => 'Rad').join('\n'), column)).toBe(false)
// About 25 short words per column line; 13 lines' worth on one source line.
expect(fitsOnOnePage(Array.from({ length: 25 * (MAX_KEEP_TOGETHER_LINES + 1) }, () => 'ord').join(' '), column)).toBe(false)
})
it('counts the chunks a long token is broken into, not the source line', () => {
// 35 W per line is one source line but three rendered chunk lines.
const wide = Array.from({ length: 5 }, () => 'W'.repeat(35)).join('\n')
expect(fitsOnOnePage(wide, DESCRIPTION_COLUMN_PT)).toBe(false)
})
it('keeps the kept-together budget under a third of the page for any font', () => {
// Worst case: every line at 20pt (an uploaded font), plus row padding.
expect(MAX_KEEP_TOGETHER_LINES * 20 + 12).toBeLessThan(762 / 3)
})
it('a wide-glyph description in the bundled serif font is split instead of clipped', async () => {
const { prepareInvoiceFont } = await import('@/lib/invoices/pdf-fonts')
const branding = await prepareInvoiceFont(company, { fontFamily: 'Source Serif 4' } as never)
const description = 'Leverans\n' + Array.from({ length: 19 }, () => 'W'.repeat(35)).join('\n')
const items = [
makeItem({ description, discount_percent: 10 }),
makeItem({ sort_order: 1, id: 'item-1', description: 'Efterföljande rad', vat_rate: 12 }),
]
const pages = await layOut(InvoicePDF({ invoice: sentInvoice(), customer, items, company, branding }))
expectNothingPastThePageEdge(pages)
expect(pages.map(textOf).join('')).toContain('Efterföljande rad')
})
it('an 80-line description is split across pages instead of clipped', async () => {
const description = Array.from({ length: 80 }, (_, i) => `Specifikationsrad ${i + 1}`).join('\n')
const items = [makeItem({ description }), makeItem({ sort_order: 1, id: 'item-1', description: 'Efterföljande rad' })]
const pages = await layOut(InvoicePDF({ invoice: sentInvoice(), customer, items, company }))
expectNothingPastThePageEdge(pages)
const all = pages.map(textOf).join('')
expect(all).toContain('Specifikationsrad 80')
expect(all).toContain('Efterföljande rad')
})
it('80 lines of notes are split across pages instead of clipped', async () => {
const notes = Array.from({ length: 80 }, (_, i) => `Villkor ${i + 1}: leverans sker enligt avtal.`).join('\n')
const pages = await layOut(InvoicePDF({ invoice: { ...sentInvoice(), notes }, customer, items: [makeItem()], company }))
expectNothingPastThePageEdge(pages)
expect(pages.map(textOf).join('')).toContain('Villkor 80')
})
it('a free-text row is budgeted at its full width, not the description column', () => {
// 12 lines of 60 characters: fits the full width, not a 160pt column.
const text = Array.from({ length: MAX_KEEP_TOGETHER_LINES }, () => 'x'.repeat(60)).join('\n')
expect(fitsOnOnePage(text, FULL_WIDTH_BOX_PT)).toBe(true)
expect(fitsOnOnePage(text, DESCRIPTION_COLUMN_PT)).toBe(false)
const items = [makeItem({ description: text, line_type: 'text', quantity: 0, unit_price: 0 })]
const row = elements(InvoicePDF({ invoice: sentInvoice(), customer, items, company })).find(
(el) => el.props.wrap === false && containsText(el, 'x'.repeat(60)),
)
expect(row).toBeDefined()
})
it('the ROT/RUT box splits instead of clipping when its line breakdown is long', async () => {
const rutItem = (i: number, description: string) =>
makeItem({
sort_order: i,
id: `rut-${i}`,
description,
deduction_type: 'rut',
deduction_amount: 250,
work_type: 'STAD',
} as Partial<InvoiceItem>)
const invoice = { ...sentInvoice(), deduction_total: 250 }
const short = InvoicePDF({ invoice, customer, items: [rutItem(0, 'Städning')], company })
const shortBox = elements(short).find(
(el) => el.props.wrap !== undefined && containsText(el, 'Underlag för skattereduktion'),
)
expect(shortBox?.props.wrap).toBe(false)
const long = Array.from({ length: 40 }, (_, i) => rutItem(i, `Städning vecka ${i + 1}\nExtra fönsterputs`))
const pages = await layOut(InvoicePDF({ invoice, customer, items: long, company }))
expectNothingPastThePageEdge(pages)
expect(pages.map(textOf).join('')).toContain('Städning vecka 40')
})
it('a 3000-character description without line breaks is not clipped', async () => {
const description = Array.from({ length: 400 }, (_, i) => `ord${i + 1}`).join(' ')
const pages = await layOut(InvoicePDF({ invoice: sentInvoice(), customer, items: [makeItem({ description })], company }))
expectNothingPastThePageEdge(pages)
expect(pages.map(textOf).join('')).toContain('ord400')
})
})
describe('long tokens', () => {
it.each([
'https://app.testbrand.example/invoices/pay/7c1f0b7e-3d2a-4f1c-9a8e-2b6d5c4e3f21',
'AB-2026-09-KUND-1042-LEVERANS-SPECIFIKATION',
'fornamn.efternamn@ekonomi.exempelforetaget.se',
'Konsulttjänsteavtalsförlängningsdokumentationssammanställning',
])('stay inside the description column and on the page: %s', async (token) => {
const items = [makeItem({ description: `Leverans ${token}` })]
const pages = await layOut(InvoicePDF({ invoice: sentInvoice(), customer, items, company }))
expectEveryLineInsideItsBox(pages, 'Leverans')
// The whole token is printed, not dropped by the line breaker.
const printed = pages.map(textOf).join('')
expect(printed).toContain(token)
})
it('stay inside a text row and in the notes', async () => {
const url = 'https://www.skatteverket.se/foretag/moms/saljavarorochtjanster/omvandbetalningsskyldighet.4.html'
const items = [makeItem({ description: `Villkor: ${url}`, line_type: 'text', quantity: 0, unit_price: 0 })]
const invoice = { ...sentInvoice(), notes: `Läs mer: ${url}` }
const pages = await layOut(InvoicePDF({ invoice, customer, items, company }))
expectEveryLineInsideItsBox(pages, 'Villkor:')
expectEveryLineInsideItsBox(pages, 'Läs mer:')
const printed = pages.map(textOf).join('')
expect(printed.split(url).length - 1).toBe(2)
})
})
describe('page breaks', () => {
const tree = InvoicePDF({ invoice: sentInvoice(), customer, items: [makeItem()], company })
const all = elements(tree)
it('never splits a table row, the totals, the payment box or a notice box', () => {
const rowsWithText = all.filter((el) => containsText(el, 'Konsulttimmar') && el.props.wrap === false)
expect(rowsWithText.length).toBeGreaterThan(0)
expect(all.some((el) => el.props.wrap === false && containsText(el, 'Delsumma:'))).toBe(true)
expect(all.some((el) => el.props.wrap === false && containsText(el, 'Betalningsinformation'))).toBe(true)
})
it('keeps every section heading with the content below it', () => {
const headings = all.filter((el) => {
const style = styleOf(el)
return style.textTransform === 'uppercase' && style.letterSpacing === 0.5
})
expect(headings.length).toBeGreaterThanOrEqual(3)
for (const heading of headings) {
expect(heading.props.minPresenceAhead).toBe(HEADING_MIN_PRESENCE_AHEAD)
}
})
})
const ORDINARY_LONG_WORDS = [
'September',
'Konsulttimmar',
'Öresavrundning',
'Företagsförsäkring',
'Marknadsföringstjänster',
'Fastighetsskötsel',
'Löneadministration',
'Verksamhetsutveckling',
'Kvartalsrapportering',
'Redovisningskonsult',
'Momskompensationsansökan',
'Kommunikationsavdelningen',
'Sammanställningsdokumentet',
'Systemadministrationstjänst',
'Semesterlöneskuldsberäkning',
'Mervärdesskattedeklarationen',
]
describe('word wrapping', () => {
it('never hyphenates an ordinary word, however long', () => {
for (const word of ORDINARY_LONG_WORDS) {
expect(wrapDescriptionWords(word)).toEqual([word])
expect(wrapFullWidthWords(word)).toEqual([word])
}
})
it('gives a token wider than the column break opportunities after separators and where the column is full', () => {
const url = 'https://app.testbrand.example/invoices/pay/7c1f0b7e-3d2a-4f1c-9a8e-2b6d5c4e3f21'
const parts = wrapDescriptionWords(url)
expect(parts.join('')).toBe(url)
expect(parts.length).toBeGreaterThan(1)
for (const part of parts) expect(approxWidthPt(part)).toBeLessThanOrEqual(DESCRIPTION_COLUMN_PT)
expect(parts[0]).toBe('https:')
const compound = 'Konsulttjänsteavtalsförlängningsdokumentationssammanställning'
const chunks = wrapDescriptionWords(compound)
expect(chunks.join('')).toBe(compound)
expect(chunks.length).toBeGreaterThan(1)
for (const chunk of chunks) expect(approxWidthPt(chunk)).toBeLessThanOrEqual(DESCRIPTION_COLUMN_PT)
})
it('estimates from real Helvetica metrics and over-estimates unknown glyphs', () => {
// 'a' is 556 units: 5.56pt at 10pt, times the 10% margin.
expect(approxWidthPt('a')).toBeCloseTo(6.116, 3)
expect(approxWidthPt('æ')).toBeGreaterThan(approxWidthPt('a'))
// A glyph outside Helvetica counts wider than any Latin glyph.
expect(approxWidthPt('Ш')).toBeGreaterThan(approxWidthPt('W'))
})
it('breaks tokens of wide or unknown glyphs so they never overflow the column', async () => {
for (const token of ['æ'.repeat(24), 'Œ'.repeat(24), 'Ш'.repeat(24)]) {
expect(wrapDescriptionWords(token).length).toBeGreaterThan(1)
}
const items = [
makeItem({ description: `Ref ${'æ'.repeat(24)}`, discount_percent: 10 }),
makeItem({ sort_order: 1, id: 'item-1', description: 'Annan rad', vat_rate: 12 }),
]
const pages = await layOut(InvoicePDF({ invoice: sentInvoice(), customer, items, company }))
expectEveryLineInsideItsBox(pages, 'Ref ')
})
it('renders ordinary compounds whole in the narrowest description column', async () => {
const descriptions = [
'Timarvode augusti, uppdrag Fastighetsskötsel 2026',
'Utveckling av ny funktion samt Företagsförsäkring 2026',
'Konsultarvode enligt avtal för Marknadsföringstjänster',
'Timarvode augusti, uppdrag Verksamhetsutveckling',
'Möte om Momskompensationsansökan',
'Möte om Kommunikationsavdelningen',
'Möte om Sammanställningsdokumentet',
'Avser Systemadministrationstjänst 2026',
'Uppdrag: Mervärdesskattedeklarationen',
]
for (const description of descriptions) {
// Discount and a second VAT rate show every column, so the description
// column is at its narrowest.
const items = [
makeItem({ description, discount_percent: 10 }),
makeItem({ sort_order: 1, id: 'item-1', description: 'Annan rad', vat_rate: 12 }),
]
const pages = await layOut(InvoicePDF({ invoice: sentInvoice(), customer, items, company }))
const node = textNodes(pages[0]).find((n) => textOf(n) === description)
expect(node).toBeDefined()
for (const line of node!.lines ?? []) {
expect(line.string, description).not.toMatch(/-$/)
}
expectEveryLineInsideItsBox(pages, description.slice(0, 12))
}
})
it('applies to line descriptions, notes and the footer', () => {
const invoice = { ...sentInvoice(), notes: 'Tack för förtroendet' }
const tree = InvoicePDF({ invoice, customer, items: [makeItem()], company })
const withCallback = elements(tree).filter(
(el) => el.props.hyphenationCallback === wrapDescriptionWords || el.props.hyphenationCallback === wrapFullWidthWords,
)
expect(withCallback.some((el) => containsText(el, 'Konsulttimmar'))).toBe(true)
expect(withCallback.some((el) => containsText(el, 'Tack för förtroendet'))).toBe(true)
expect(withCallback.some((el) => containsText(el, 'Testbrand AB') || containsText(el, 'Org.nr'))).toBe(true)
})
})
describe('units', () => {
it('print in English on an English invoice and as stored on a Swedish one', () => {
const items = [makeItem({ unit: 'st' })]
const en = textLeaves(InvoicePDF({ invoice: sentInvoice(), customer, items, company, language: 'en' }))
const sv = textLeaves(InvoicePDF({ invoice: sentInvoice(), customer, items, company, language: 'sv' }))
expect(en).toContain('pcs')
expect(en).not.toContain('st')
expect(sv).toContain('st')
expect(sv).not.toContain('pcs')
})
})
describe('multi-line descriptions', () => {
it('keep their line breaks in the PDF text', () => {
const items = [makeItem({ description: 'Konsultation\nSeptember 2026' })]
const leaves = textLeaves(InvoicePDF({ invoice: sentInvoice(), customer, items, company }))
expect(leaves).toContain('Konsultation\nSeptember 2026')
})
})