Files
accounted/lib/invoices/__tests__/pdf-template-layout.test.ts
T
MattssonandClaude Fable 5.1 32721b9f61 feat(invoices): diagonal UTKAST/DRAFT watermark on draft PDFs instead of the top-margin banner (#2441)
* feat(invoices): mark draft PDFs with a diagonal UTKAST/DRAFT watermark instead of a banner

Why the problem occurred: the draft marking was a boxed yellow banner in
the page's top margin. It stayed out of the flow (#2369) but still read as
UI chrome pasted on a document, and carried a two-line legal sentence that
nobody reads on a preview.

What was removed: the banner block, its three styles and the four legal
sentences (sv+en). The draft state is now one word, bold, rotated -35deg at
14% opacity, centred on every page, the way a stamp marks paper. The
download dialog (#2399) already explains why a draft is not a valid
invoice before the file exists, so the PDF does not repeat it.

Why this shape: rotation and opacity sit on a padded wrapper View so the
word turns about its own centre and the Text keeps a plain type style.
The overlay is absolutely positioned over the page box and `fixed`, so the
document underneath previews pixel-identical to the final print; a test
asserts the first row sits at the same y as on a sent invoice. BETALD and
MAKULERAD banners are unchanged (separate concern).

Fixes #2437

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

* fix(invoices): darken the draft watermark so it survives a greyscale print

Skeptic refutation: #6b7280 at 0.14 composites to about 92% brightness on
white, which a monochrome print or greyscale scan drops, and a numbered
draft otherwise prints the FAKTURA title, its number and an OCR like an
issued invoice. Now #4b5563 at 0.3 (about 79% brightness), with a test
pinning the composited grey between 70% and 85% so neither extreme can
creep back in.

Refs #2437

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(invoices): paint the draft watermark last so opaque boxes cannot cover it

Skeptic refutation (correctness and regression, independently): the
overlay was the first child of the Page. react-pdf paints children in
document order and `fixed` does not hoist, so the customer box and the
full-width payment section (opaque #f5f5f5 / #f8f9fa) painted over the
word. On a two-page draft the last page, the one with totals, bankgiro
and OCR, lost the word entirely.

The overlay is now the last child of the Page, behind a single
isDraftMarked flag that also keeps the cancelled > draft > paid banner
precedence. A new test inflates the rendered PDF content streams and
asserts the UTKAST glyph run comes after the last rectangle fill on
every page, so the element tree alone can no longer pass while the
paint order is wrong.

Refs #2437

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs(invoices): note the English DRAFT label in the download-decision comment

CodeRabbit on #2441: the comment said every draft is stamped UTKAST; an
English document says DRAFT.

Refs #2437

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 18:11:43 +02:00

653 lines
28 KiB
TypeScript

/**
* Page layout of the invoice PDF: what a user reported after the English
* translation shipped.
*
* - The draft watermark must not move the document: a faint diagonal word
* over the page, 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 { inflateSync } from 'node:zlib'
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 {
DRAFT_WATERMARK_COLOR,
DRAFT_WATERMARK_FONT_SIZE_PT,
DRAFT_WATERMARK_OPACITY,
DRAFT_WATERMARK_ROTATION_DEG,
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
}
/** Every deflated content stream in a rendered PDF, as PDF operator text. */
function contentStreams(buffer: Buffer): string[] {
const s = buffer.toString('latin1')
const out: string[] = []
let idx = 0
for (;;) {
const start = s.indexOf('stream\n', idx)
if (start < 0) break
const dataStart = start + 'stream\n'.length
const end = s.indexOf('endstream', dataStart)
if (end < 0) break
try {
out.push(inflateSync(buffer.subarray(dataStart, end)).toString('latin1'))
} catch {
// Not a deflated stream (font program, image): skip.
}
idx = end + 'endstream'.length
}
return out
}
// 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 watermark', () => {
// #2437: one diagonal, faint word across the page instead of a banner in
// the top margin. Out of the flow, on every page, and nothing else: the
// legal sentence about löpnummer is gone on purpose.
it('is a fixed full-page overlay carrying only the word UTKAST', () => {
const tree = InvoicePDF({ invoice: draftInvoice(), customer, items: [makeItem()], company })
const overlay = elements(tree).find(
(el) => el.props.fixed === true && containsText(el, 'UTKAST'),
)
expect(overlay).toBeDefined()
const style = styleOf(overlay!)
expect(style.position).toBe('absolute')
expect([style.top, style.left, style.right, style.bottom]).toEqual([0, 0, 0, 0])
expect(textLeaves(overlay!.props.children)).toEqual(['UTKAST'])
// Rotation and opacity sit on a wrapper around the Text; the Text
// carries the type.
const wrapper = elements(overlay!).find((el) => containsText(el, 'UTKAST') && el !== overlay)
const wrapperStyle = styleOf(wrapper!)
expect(wrapperStyle.opacity).toBe(DRAFT_WATERMARK_OPACITY)
// Faint enough to stay background, dark enough to survive a greyscale
// print: the composited grey on white must land between 70% and 85%.
const [r, g, b] = [1, 3, 5].map((i) => parseInt(DRAFT_WATERMARK_COLOR.slice(i, i + 2), 16))
const composited = 255 - DRAFT_WATERMARK_OPACITY * (255 - (0.2126 * r + 0.7152 * g + 0.0722 * b))
expect(composited / 255).toBeGreaterThan(0.7)
expect(composited / 255).toBeLessThan(0.85)
expect(wrapperStyle.transform).toBe(`rotate(${DRAFT_WATERMARK_ROTATION_DEG}deg)`)
const word = elements(wrapper!).find((el) => el.props.children === 'UTKAST')
expect(styleOf(word!).fontSize).toBe(DRAFT_WATERMARK_FONT_SIZE_PT)
})
it('says DRAFT on an English document', () => {
const tree = InvoicePDF({ invoice: draftInvoice(), customer, items: [makeItem()], company, language: 'en' })
const overlay = elements(tree).find((el) => el.props.fixed === true && containsText(el, 'DRAFT'))
expect(overlay).toBeDefined()
expect(textLeaves(overlay!.props.children)).toEqual(['DRAFT'])
expect(elements(tree).some((el) => containsText(el, 'not a valid invoice'))).toBe(false)
})
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 watermark 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('is painted last on every page, so no opaque box can cover the word', { timeout: 30_000 }, async () => {
// react-pdf paints children in document order and `fixed` does not hoist:
// an overlay emitted before the payment box (opaque #f8f9fa, full content
// width) is painted underneath it and the word disappears on exactly the
// page that carries totals, bankgiro and OCR. Skeptic finding on #2437.
const items = Array.from({ length: 22 }, (_, i) =>
makeItem({ sort_order: i, id: `item-${i}`, description: `Rad ${i + 1}` }),
)
const tree = InvoicePDF({ invoice: draftInvoice(), customer, items, company })
const page = elements(tree).find((el) => el.props.size === 'A4')
const pageChildren = (page!.props.children as ReactNode[]).flat().filter(
(c) => c !== null && c !== undefined && typeof c !== 'boolean',
)
const last = pageChildren[pageChildren.length - 1] as AnyElement
expect(last.props.fixed).toBe(true)
expect(containsText(last, 'UTKAST')).toBe(true)
// And in the actual PDF: within each page's content stream the watermark
// glyph run comes after the last rectangle fill.
const buffer = await renderToBuffer(tree)
expect(pageCount(buffer)).toBeGreaterThan(1)
const streams = contentStreams(buffer)
// U T K A S T as WinAnsi glyph codes, letter-spaced, in one TJ array.
const watermarkRun = /\[<55>[^\]<]*<54>[^\]<]*<4b>[^\]<]*<41>[^\]<]*<53>[^\]<]*<54>[^\]<]*\]\s*TJ/
const pagesWithWord = streams.filter((s) => watermarkRun.test(s))
expect(pagesWithWord.length).toBe(pageCount(buffer))
for (const s of pagesWithWord) {
const wordAt = s.search(watermarkRun)
const lastFill = Math.max(s.lastIndexOf(' re\nf'), s.lastIndexOf(' re\n'))
expect(lastFill).toBeGreaterThan(-1)
expect(wordAt).toBeGreaterThan(lastFill)
}
})
it('does not move the document: the first row sits where it sits on a sent invoice', async () => {
const items = [makeItem({ description: 'Rad 1' })]
const draftPages = await layOut(InvoicePDF({ invoice: draftInvoice(), customer, items, company }))
const sentPages = await layOut(InvoicePDF({ invoice: sentInvoice(), customer, items, company }))
const rowBottom = (ps: LaidOutNode[]) =>
absoluteTextBottoms(ps[0]).find((t) => t.text === 'Rad 1')?.bottom
expect(rowBottom(draftPages)).toBeDefined()
expect(rowBottom(draftPages)).toBe(rowBottom(sentPages))
})
it.each([
['sv', 'draft', 'invoice'],
['en', 'draft', 'invoice'],
['sv', 'sent', 'invoice'],
['en', 'sent', 'invoice'],
['sv', 'draft', 'quote'],
['en', 'draft', 'quote'],
] as const)('covers the page and keeps the word on one line inside it (%s, %s %s)', async (language, status, documentType) => {
// 'sent' without a number is the corrupt-state case; it is marked too.
const invoice = { ...draftInvoice(), status, document_type: documentType }
const pages = await layOut(InvoicePDF({ invoice, customer, items: [makeItem()], company, language }))
const word = textNodes(pages[0]).find((n) => /^(UTKAST|DRAFT)$/.test(textOf(n)))
expect(word).toBeDefined()
expect(word!.lines).toHaveLength(1)
// word -> rotated wrapper -> full-page overlay
let wrapper: LaidOutNode | undefined
let overlay: LaidOutNode | undefined
walk(pages[0], (n) => {
if (n.children?.includes(word!)) wrapper = n
})
walk(pages[0], (n) => {
if (wrapper && n.children?.includes(wrapper)) overlay = n
})
expect(overlay?.box).toBeDefined()
const page = pages[0].box!
expect(overlay!.box!.top).toBe(0)
expect(overlay!.box!.left).toBe(0)
expect(overlay!.box!.width).toBeCloseTo(page.width, 0)
expect(overlay!.box!.height).toBeCloseTo(page.height, 0)
// Rotation happens at paint time around the word's centre; the unrotated
// ink must fit the page width so no glyph is clipped once turned.
const ink = word!.lines![0].xAdvance ?? word!.lines![0].box.width
expect(ink).toBeLessThan(page.width)
// Centred on the page, well below the top margin (boxes are parent-relative;
// the overlay sits at the page origin, so the wrapper's top is absolute).
expect(wrapper!.box!.top).toBeGreaterThan(PAGE_TOP_PADDING)
expect(wrapper!.box!.top + wrapper!.box!.height / 2).toBeCloseTo(page.height / 2, 0)
})
})
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')
})
})