* 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>
653 lines
28 KiB
TypeScript
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')
|
|
})
|
|
})
|