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>
This commit is contained in:
Mattsson
2026-09-07 14:45:38 +02:00
committed by GitHub
parent c634cf9ae0
commit 7a30f623ba
18 changed files with 1020 additions and 52 deletions
+4
View File
@@ -1636,5 +1636,9 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-09-06] Bundled SKV ROT/RUT payout books ONE voucher (one 1513 leg per begäran) and the set is suggested at read time with no hint column: one bank row = one verifikat (match-batch precedent) and a uuid[] hint would need six clear paths and go stale; N vouchers + the 1:N reconciliation split was rejected because its half-failure state has no UI exit, and begäran, not the invoice, is the unit under fakturamodellen.
[2026-09-06] Utlägg via lön settles claims with an idempotent RPC after the salary verifikat is posted (pre-checked before posting), not with a trigger on salary_runs -> booked: a raise inside that trigger after the entries exist would leave a paid run with posted verifikat and a retry would double-post; the RPC path fails to "booked, claims still open, re-runnable".
[2026-09-06] A privately paid supplier invoice is booked through registerExpenseClaim (verifikat + expense_claims row, source_type expense_claim) with the invoice's kontering as custom lines, and a person-paid inbox document goes to the core route with inbox_item_id instead of the extension's convert endpoint: the form's switch, the second entry generator and the convert bypass were three write paths for one fact, so one writer wins over adding a claims insert beside the old generator (the issue's shape) or copying the branch into the convert handler.
[2026-09-07] Draft stamp moved to the page margin (absolute + fixed) instead of the reporter's position:fixed corner badge: the 40pt top margin is the only place that is guaranteed empty on every page, and the stamp must not overlap the header title on the right.
[2026-09-07] Hyphenation disabled per Text node in the invoice template, not via a global Font.registerHyphenationCallback: the global hook would also change line breaking in årsredovisning, payslips and every report PDF; that is a separate decision.
[2026-09-07] Invoice PDF word wrapping keeps a word whole whenever an ink-width estimate says it fits its column (ordinary Swedish compounds of 17 to 25 characters always do); only a wider token (URL, e-mail, reference) gets break points, after separators and where the column is full, with react-pdf's hyphen at the break (a break after "/" prints "/-"; unavoidable in textkit, accepted over a dropped token). A flat character cap was tried first and rejected: it split ordinary compounds at arbitrary positions. Free-text rows and notes are kept on one page only while a rendered-line estimate (chunks counted, 12 lines max, safe for any font) says they fit; past that they may split, because a non-splittable block taller than a page is clipped silently.
[2026-09-07] Line breaks in line descriptions are collapsed by every single-line consumer (Peppol cbc:Name, accrual voucher text) and additionally in the SIE writer's quoted-text escaper: SIE is one record per line by spec, so the writer guards the format regardless of where the text came from.
[2026-09-07] Enable Banking callbacks return to the initiating white-label host by recording the allowlisted request origin on the pending row and replaying the callback there, instead of extending the provider_otc handoff from PR #2305: the brand host already holds the session, so a /login?next=<callback> bounce on that host forwards straight back into the callback with cookies, needing one nullable column and no encrypted payload, no second table and no cron. The provider redirect URI stays canonical, so nothing changes in the Enable Banking console. Stripe, Gmail and cloud backup have the same shape but no partner-domain users yet; tracked as a follow-up issue rather than built speculatively.
[2026-09-07] Stripe checkout and portal return URLs resolve through the existing resolveRequestAppOrigin allowlist (NEXT_PUBLIC_WHITELABEL_DOMAINS), not a DB brand lookup: it is the same trust boundary invites and email-change links already use, so one allowlist governs every host we redirect a browser to. Return paths stay fixed literals; no caller-supplied URL is accepted. Session-expiry and company-switch handling were left alone: the middleware already bounces to /login on the same host with the path preserved, and the webhook keys on company_id metadata.
+4 -4
View File
@@ -2256,13 +2256,13 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
{invoice.items.map((item) =>
isTextLikeLine(item) ? (
<tr key={item.id}>
<td colSpan={5} className={cn(TD_CLASS, 'pl-0 pr-0 text-muted-foreground')}>
<td colSpan={5} className={cn(TD_CLASS, 'whitespace-pre-line pl-0 pr-0 text-muted-foreground')}>
{item.description || ' '}
</td>
</tr>
) : (
<tr key={item.id}>
<td className={cn(TD_CLASS, 'pl-0')}>
<td className={cn(TD_CLASS, 'whitespace-pre-line pl-0')}>
{item.description}
{lineSubInfo(item)}
</td>
@@ -2284,11 +2284,11 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
<div className="divide-y divide-border text-sm sm:hidden">
{invoice.items.map((item) =>
isTextLikeLine(item) ? (
<p key={item.id} className="py-3 text-muted-foreground">{item.description || ' '}</p>
<p key={item.id} className="whitespace-pre-line py-3 text-muted-foreground">{item.description || ' '}</p>
) : (
<div key={item.id} className="flex items-start justify-between gap-4 py-3">
<div className="min-w-0">
<p>{item.description}</p>
<p className="whitespace-pre-line">{item.description}</p>
<p className="text-xs text-muted-foreground tabular-nums">
{item.quantity} {item.unit} × {formatCurrency(item.unit_price, invoice.currency)}
</p>
+51
View File
@@ -0,0 +1,51 @@
'use client'
import { forwardRef, useCallback, useLayoutEffect, useRef, type TextareaHTMLAttributes } from 'react'
import { cn } from '@/lib/utils'
/**
* A single-line-looking textarea that grows with its content.
*
* Invoice line descriptions used to be `<input>`s, so a user could never put
* a line break where they wanted one; the PDF then wrapped wherever the
* column ran out. This keeps the dense one-row look of the editor grid while
* letting Enter insert a newline, and re-measures on every render so a
* prefilled multi-line description (edit flow) opens at the right height.
*/
export const AutoGrowTextarea = forwardRef<HTMLTextAreaElement, TextareaHTMLAttributes<HTMLTextAreaElement>>(
function AutoGrowTextarea({ className, onInput, ...props }, ref) {
const inner = useRef<HTMLTextAreaElement | null>(null)
const setRef = useCallback(
(el: HTMLTextAreaElement | null) => {
inner.current = el
if (typeof ref === 'function') ref(el)
else if (ref) ref.current = el
},
[ref],
)
useLayoutEffect(() => {
resize(inner.current)
})
return (
<textarea
{...props}
ref={setRef}
rows={1}
onInput={(e) => {
resize(e.currentTarget)
onInput?.(e)
}}
className={cn('block resize-none overflow-hidden leading-5', className)}
/>
)
},
)
function resize(el: HTMLTextAreaElement | null) {
if (!el) return
el.style.height = 'auto'
el.style.height = `${el.scrollHeight}px`
}
+3 -2
View File
@@ -7,6 +7,7 @@ import { createClient } from '@/lib/supabase/client'
import { useForm, useFieldArray, Controller, type FieldErrors } from 'react-hook-form'
import { Reorder } from 'framer-motion'
import { SortableRow } from '@/components/ui/sortable-row'
import { AutoGrowTextarea } from '@/components/invoices/AutoGrowTextarea'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { addDays, format } from 'date-fns'
@@ -2295,7 +2296,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
className={cn('group border-b border-border', settleIndex === index && 'row-settle')}
>
<div className="flex items-center gap-1 py-1">
<input
<AutoGrowTextarea
{...register(`items.${index}.description`)}
placeholder={t('text_row_placeholder')}
aria-label={t('text_row_label')}
@@ -2356,7 +2357,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
className={cn('group border-b border-border', settleIndex === index && 'row-settle')}
>
<div className={cn(rowGridClass, 'py-1')}>
<input
<AutoGrowTextarea
{...register(`items.${index}.description`)}
placeholder={t('description_placeholder')}
aria-label={t('description_label')}
+4 -4
View File
@@ -168,13 +168,13 @@ export function InvoiceReviewContent({
{items.map((item, index) =>
isTextLikeLine(item) ? (
<tr key={index} className="border-b last:border-0">
<td className="py-2 text-muted-foreground" colSpan={showVatColumn ? 6 : 5}>
<td className="whitespace-pre-line py-2 text-muted-foreground" colSpan={showVatColumn ? 6 : 5}>
{item.description || ' '}
</td>
</tr>
) : (
<tr key={index} className="border-b last:border-0">
<td className="py-2">
<td className="whitespace-pre-line py-2">
{item.description}
{itemHasAccrual(item) && (
<p className="mt-1 flex items-center gap-1 text-xs text-muted-foreground">
@@ -213,10 +213,10 @@ export function InvoiceReviewContent({
<div className="sm:hidden space-y-2">
{items.map((item, index) =>
isTextLikeLine(item) ? (
<p key={index} className="text-sm text-muted-foreground px-1">{item.description || ' '}</p>
<p key={index} className="whitespace-pre-line text-sm text-muted-foreground px-1">{item.description || ' '}</p>
) : (
<div key={index} className="border rounded-lg p-3 text-sm space-y-1.5">
<p className="font-medium">{item.description}</p>
<p className="whitespace-pre-line font-medium">{item.description}</p>
{itemHasAccrual(item) && (
<p className="flex items-center gap-1 text-xs text-muted-foreground">
<CalendarClock className="h-3 w-3 shrink-0" />
@@ -154,4 +154,29 @@ describe('createSchedulesForCustomerInvoice', () => {
expect.anything(),
)
})
it('collapses a multi-line description to one line of voucher text', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([{ data: [] }])
const invoice = makeInvoice({ id: 'inv-1', status: 'sent', invoice_number: '1042' })
const item = makeInvoiceItem({ description: 'Konsultation\nSeptember 2026' })
await createSchedulesForCustomerInvoice(
supabase as unknown as SupabaseClient,
COMPANY,
USER,
invoice,
[item],
'je-origin',
)
expect(mockCreateAccrualSchedule).toHaveBeenCalledWith(
expect.anything(),
COMPANY,
USER,
expect.objectContaining({ description: 'Konsultation September 2026 (faktura 1042)' }),
expect.anything(),
)
})
})
+5 -2
View File
@@ -24,6 +24,7 @@ import {
} from '@/lib/bookkeeping/accruals/account-suggestions'
import { getRevenueAccount } from '@/lib/bookkeeping/invoice-entries'
import { getVatTreatmentForRate } from '@/lib/invoices/vat-rules'
import { toSingleLine } from '@/lib/invoices/display'
import type { EntityType } from '@/types'
import { createLogger } from '@/lib/logger'
@@ -85,7 +86,7 @@ export async function createSchedulesForSupplierInvoice(
totalAmountSek: totalSek,
periodStart: item.accrual_period_start as string,
periodEnd: item.accrual_period_end as string,
description: `${item.description} (leverantörsfaktura ${invoice.supplier_invoice_number})`,
description: `${toSingleLine(item.description)} (leverantörsfaktura ${invoice.supplier_invoice_number})`,
dimensions: mergeDimensionBags(defaultDimensions, item.dimensions),
},
{
@@ -166,7 +167,9 @@ export async function createSchedulesForCustomerInvoice(
totalAmountSek: totalSek,
periodStart: item.accrual_period_start as string,
periodEnd: item.accrual_period_end as string,
description: `${item.description} (faktura ${invoice.invoice_number ?? ''})`.trim(),
// Voucher text is one line by format (SIE #VER is one record per
// line); the line description may carry line breaks.
description: `${toSingleLine(item.description)} (faktura ${invoice.invoice_number ?? ''})`.trim(),
dimensions: mergeDimensionBags(defaultDimensions, item.dimensions),
},
{
+17
View File
@@ -265,6 +265,23 @@ describe('parseSIEFile', () => {
expect(v1.description).toBe('Faktura 1001')
})
it('unescapes \\" and \\\\ in quoted text, as the export writes them', () => {
const content = [
'#FLAGGA 0',
'#SIETYP 4',
'#FORMAT PC8',
'#FNAMN "Test AB"',
'#RAR 0 20240101 20241231',
'#VER A 1 20240115 "Sökväg C:\\\\temp\\\\\\"fil\\""',
'{',
'#TRANS 1930 {} 100.00',
'#TRANS 3001 {} -100.00',
'}',
].join('\n')
const result = parseSIEFile(content)
expect(result.vouchers[0].description).toBe('Sökväg C:\\temp\\"fil"')
})
it('parses #TRANS lines within a voucher', () => {
const result = parseSIEFile(SIE_WITH_VOUCHERS)
const v1 = result.vouchers[0]
+3 -1
View File
@@ -265,7 +265,9 @@ function parseStringField(field: string): string {
// Remove surrounding quotes if present
if (field.startsWith('"') && field.endsWith('"')) {
return field.slice(1, -1).replace(/\\"/g, '"')
// `\"` is a literal quote and `\\` a literal backslash (what the export
// in lib/reports/sie-export.ts writes).
return field.slice(1, -1).replace(/\\(["\\])/g, '$1')
}
return field
@@ -0,0 +1,536 @@
/**
* 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')
})
})
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest'
import { unitLabel } from '@/lib/invoices/unit-labels'
describe('unitLabel', () => {
it('maps the editor units to English on an English document', () => {
expect(unitLabel('st', 'en')).toBe('pcs')
expect(unitLabel('tim', 'en')).toBe('h')
expect(unitLabel('dag', 'en')).toBe('day')
expect(unitLabel('månad', 'en')).toBe('month')
})
it('leaves units that are the same in both languages alone', () => {
expect(unitLabel('km', 'en')).toBe('km')
expect(unitLabel('kg', 'en')).toBe('kg')
})
it('prints a Swedish document exactly as stored', () => {
expect(unitLabel('st', 'sv')).toBe('st')
expect(unitLabel('tim', 'sv')).toBe('tim')
})
it('passes user-typed units through untouched', () => {
expect(unitLabel('paket', 'en')).toBe('paket')
expect(unitLabel('m2', 'en')).toBe('m2')
})
it('matches case- and whitespace-insensitively', () => {
expect(unitLabel(' St ', 'en')).toBe('pcs')
expect(unitLabel('TIM', 'en')).toBe('h')
})
it('tolerates a missing unit', () => {
expect(unitLabel(null, 'en')).toBe('')
expect(unitLabel(undefined, 'sv')).toBe('')
})
})
+11
View File
@@ -40,6 +40,17 @@ export function creditConfirmNumber(invoice: {
* lines via the article picker's "Egen rad (fri text)" and leave antal/pris
* at zero; printing "0 / 0,00 SEK / 0,00 SEK" on those is noise (issue #1053).
*/
/**
* Collapse a possibly multi-line description to one line.
*
* Line descriptions may carry line breaks (the editor allows them so the PDF
* breaks where the user wants). Every consumer that is a single-line field by
* format (Peppol cbc:Name, journal entry and SIE texts) goes through this.
*/
export function toSingleLine(text: string | null | undefined): string {
return (text ?? '').replace(/\s+/g, ' ').trim()
}
export function isTextLikeLine(item: {
line_type?: 'product' | 'text' | null
quantity?: number | null
+41
View File
@@ -0,0 +1,41 @@
/**
* Helvetica advance widths in 1/1000 em, keyed by code point, as measured
* through react-pdf's own pdfkit metrics (the WinAnsi glyph set plus the
* common typographic extras). Used to estimate whether a word fits its
* column before react-pdf lays the text out; see approxWidthPt in
* pdf-template.tsx.
*
* Generated once with `new PDFDocument().font('Helvetica').fontSize(1000)
* .widthOfString(ch)` for U+0020..U+017F and a few U+20xx code points.
* Glyphs Helvetica does not carry are omitted and fall back to a
* conservative default at the call site.
*/
export const HELVETICA_WIDTHS: ReadonlyMap<number, number> = new Map<number, number>([
[32, 278], [33, 278], [34, 355], [35, 556], [36, 556], [37, 889], [38, 667], [39, 191],
[40, 333], [41, 333], [42, 389], [43, 584], [44, 278], [45, 333], [46, 278], [47, 278],
[48, 556], [49, 556], [50, 556], [51, 556], [52, 556], [53, 556], [54, 556], [55, 556],
[56, 556], [57, 556], [58, 278], [59, 278], [60, 584], [61, 584], [62, 584], [63, 556],
[64, 1015], [65, 667], [66, 667], [67, 722], [68, 722], [69, 667], [70, 611], [71, 778],
[72, 722], [73, 278], [74, 500], [75, 667], [76, 556], [77, 833], [78, 722], [79, 778],
[80, 667], [81, 778], [82, 722], [83, 667], [84, 611], [85, 722], [86, 667], [87, 944],
[88, 667], [89, 667], [90, 611], [91, 278], [92, 278], [93, 278], [94, 469], [95, 556],
[96, 333], [97, 556], [98, 556], [99, 500], [100, 556], [101, 556], [102, 278], [103, 556],
[104, 556], [105, 222], [106, 222], [107, 500], [108, 222], [109, 833], [110, 556], [111, 556],
[112, 556], [113, 556], [114, 333], [115, 500], [116, 278], [117, 556], [118, 500], [119, 722],
[120, 500], [121, 500], [122, 500], [123, 334], [124, 260], [125, 334], [126, 584],
[160, 278], [161, 333], [162, 556], [163, 556], [164, 556], [165, 556], [166, 260], [167, 556],
[168, 333], [169, 737], [170, 370], [171, 556], [172, 584], [174, 737], [175, 333], [176, 400],
[177, 584], [178, 333], [179, 333], [180, 333], [181, 556], [182, 537], [183, 278], [184, 333],
[185, 333], [186, 365], [187, 556], [188, 834], [189, 834], [190, 834], [191, 611],
[192, 667], [193, 667], [194, 667], [195, 667], [196, 667], [197, 667], [198, 1000], [199, 722],
[200, 667], [201, 667], [202, 667], [203, 667], [204, 278], [205, 278], [206, 278], [207, 278],
[208, 722], [209, 722], [210, 778], [211, 778], [212, 778], [213, 778], [214, 778], [215, 584],
[216, 778], [217, 722], [218, 722], [219, 722], [220, 722], [221, 667], [222, 667], [223, 611],
[224, 556], [225, 556], [226, 556], [227, 556], [228, 556], [229, 556], [230, 889], [231, 500],
[232, 556], [233, 556], [234, 556], [235, 556], [236, 278], [237, 278], [238, 278], [239, 278],
[240, 556], [241, 556], [242, 556], [243, 556], [244, 556], [245, 556], [246, 556], [247, 584],
[248, 611], [249, 556], [250, 556], [251, 556], [252, 556], [253, 500], [254, 556], [255, 500],
[338, 1000], [339, 944], [352, 667], [353, 500], [376, 500], [381, 611], [382, 500],
[8211, 556], [8212, 1000], [8216, 222], [8217, 222], [8220, 333], [8221, 333], [8226, 350],
[8230, 1000], [8240, 1000], [8364, 556], [8482, 1000],
])
+182 -37
View File
@@ -23,6 +23,120 @@ import { isTextLikeLine } from '@/lib/invoices/display'
import { maskedDeductionPersonnummer } from '@/lib/invoices/deduction-personnummer'
import { getCountryName } from '@/lib/vat/country-codes'
import { EXPORT_NOTICE_SV } from '@/lib/invoices/vat-rules'
import { unitLabel } from '@/lib/invoices/unit-labels'
import { HELVETICA_WIDTHS } from '@/lib/invoices/pdf-glyph-widths'
/**
* react-pdf hyphenates long words with English patterns by default, which
* split Swedish words at English syllable boundaries ("Septem-ber"). The
* callbacks below keep every word whole that can fit its column, judged by a
* rough ink-width estimate. Only a token that cannot fit (a URL, an e-mail
* address, an order reference) gets break opportunities, first after its
* natural separators and then wherever the estimate says the column is
* full: react-pdf cannot break inside a word it was not given parts for, so a
* token wider than its column would otherwise overprint the next column or
* be dropped from the page entirely.
*
* Widths are the real Helvetica advances at 10pt (HELVETICA_WIDTHS) with a
* 10% margin for the bundled fonts (Source Sans 3, Source Serif 4). Those
* are narrower than Helvetica on most letters but up to 20% wider on the
* narrow ones (i, l, I, r); ordinary words come out at or under the
* estimate, and only a token of some 40 consecutive narrow glyphs could
* get past it. A glyph Helvetica does not carry (Cyrillic, Greek, symbols)
* is counted at the widest Latin advance so it can only be over-estimated.
* Ordinary Swedish compounds up to about 28 characters come out under
* 160pt and are never split in the narrowest column.
*/
const FONT_SIZE_PT = 10
const WIDTH_SAFETY_FACTOR = 1.1
const UNKNOWN_GLYPH_WIDTH = 1100
export function approxWidthPt(word: string): number {
let units = 0
for (const ch of word) {
units += HELVETICA_WIDTHS.get(ch.codePointAt(0) ?? 0) ?? UNKNOWN_GLYPH_WIDTH
}
return (units / 1000) * FONT_SIZE_PT * WIDTH_SAFETY_FACTOR
}
/**
* The narrowest description column (discount and VAT columns shown) is about
* 172pt at 10pt; a full-width notice box is about 490pt at 9pt. The budgets
* sit under those so an estimate error still lands inside the column.
*/
export const DESCRIPTION_COLUMN_PT = 160
export const FULL_WIDTH_BOX_PT = 440
export function wrapWholeWordsWithin(budgetPt: number): (word: string) => string[] {
return (word: string): string[] => {
if (approxWidthPt(word) <= budgetPt) return [word]
const parts: string[] = []
for (const piece of word.split(/(?<=[/\-.@_?&=:])/)) {
let chunk = ''
let chunkWidth = 0
for (const ch of piece) {
const w = approxWidthPt(ch)
if (chunk.length > 0 && chunkWidth + w > budgetPt) {
parts.push(chunk)
chunk = ''
chunkWidth = 0
}
chunk += ch
chunkWidth += w
}
if (chunk.length > 0) parts.push(chunk)
}
return parts
}
}
export const wrapDescriptionWords = wrapWholeWordsWithin(DESCRIPTION_COLUMN_PT)
export const wrapFullWidthWords = wrapWholeWordsWithin(FULL_WIDTH_BOX_PT)
/**
* Whether a free-text block is short enough to be kept on one page.
*
* `wrap={false}` keeps a block from splitting across pages, but react-pdf
* places a non-splittable block that is taller than a page anyway and
* everything past the page edge is lost. Blocks that could plausibly be that
* tall (line descriptions and notes, both multi-line) are only kept together
* when a line estimate says they fit comfortably; past that they are allowed
* to split, which is the lesser evil.
*
* The estimate counts rendered lines, not source lines: a token wider than
* the column is chunked by the wrap callback and each chunk can take a line
* of its own. The cap is small enough that even the tallest font a company
* can pick (bundled Source Serif 4 at about 13.7pt per line, or an uploaded
* font at 20pt) keeps 12 lines under 250pt, a third of the usable page
* height, so a kept-together block can never be taller than a page.
*/
export const MAX_KEEP_TOGETHER_LINES = 12
export function fitsOnOnePage(text: string | null | undefined, budgetPt: number): boolean {
if (!text) return true
let lines = 0
for (const line of text.split('\n')) {
let chunkLines = 0
let flowingWidth = 0
for (const word of line.split(/\s+/)) {
if (word.length === 0) continue
const width = approxWidthPt(word)
if (width > budgetPt) chunkLines += Math.ceil(width / budgetPt)
else flowingWidth += width + 4
}
const flowingLines = Math.ceil(flowingWidth / budgetPt)
lines += chunkLines + Math.max(chunkLines === 0 ? 1 : 0, flowingLines)
if (lines > MAX_KEEP_TOGETHER_LINES) return false
}
return true
}
/**
* How much content (in pt) must fit below a heading on the same page before
* react-pdf may leave the heading there; otherwise the heading moves to the
* next page together with what follows it. Roughly two table rows.
*/
export const HEADING_MIN_PRESENCE_AHEAD = 40
type PdfLang = 'sv' | 'en'
@@ -529,23 +643,32 @@ function createStyles(branding?: InvoiceBranding) {
creditNoteTitle: {
color: '#721c24',
},
// Draft stamp: lives in the page's top margin (page padding is 40pt; the
// stamp is at most about 32pt tall when the English no-number text wraps
// to two lines) and is taken out of the flow, so a draft previews exactly
// as the final invoice will print. `fixed` repeats it on every page. It
// used to be a full-size banner in the flow, which pushed the whole
// document down and made the preview lie about page breaks.
draftBanner: {
marginBottom: 16,
padding: 10,
position: 'absolute',
top: 5,
left: 40,
right: 40,
paddingVertical: 2,
paddingHorizontal: 8,
backgroundColor: '#fff3cd',
borderWidth: 2,
borderWidth: 1,
borderColor: '#856404',
borderRadius: 4,
borderRadius: 3,
},
draftBannerTitle: {
fontSize: 14,
fontSize: 9,
fontWeight: 'bold',
color: '#856404',
textAlign: 'center',
marginBottom: 2,
},
draftBannerText: {
fontSize: 9,
fontSize: 7,
color: '#856404',
textAlign: 'center',
},
@@ -883,9 +1006,9 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
</Text>
</View>
) : isPreview ? null : (invoice.status === 'draft' || !invoice.invoice_number) ? (
<View style={styles.draftBanner}>
<View style={styles.draftBanner} fixed>
<Text style={styles.draftBannerTitle}>{isQuote ? L.draftTitleQuote : L.draftTitle}</Text>
<Text style={styles.draftBannerText}>
<Text style={styles.draftBannerText} hyphenationCallback={wrapFullWidthWords}>
{isQuote
? L.draftTextQuote
: invoice.invoice_number
@@ -958,7 +1081,7 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
<View style={styles.twoColumn}>
{/* Invoice details */}
<View style={styles.column}>
<Text style={styles.sectionTitle}>{isQuote ? L.quoteInfoHeading : L.invoiceInfoHeading}</Text>
<Text style={styles.sectionTitle} minPresenceAhead={HEADING_MIN_PRESENCE_AHEAD}>{isQuote ? L.quoteInfoHeading : L.invoiceInfoHeading}</Text>
<View style={styles.row}>
<Text style={styles.label}>{isQuote ? L.quoteDate : L.invoiceDate}</Text>
<Text style={styles.value}>{formatDate(invoice.invoice_date)}</Text>
@@ -1015,7 +1138,7 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
{/* Customer */}
<View style={styles.column}>
<Text style={styles.sectionTitle}>{L.billedToHeading}</Text>
<Text style={styles.sectionTitle} minPresenceAhead={HEADING_MIN_PRESENCE_AHEAD}>{L.billedToHeading}</Text>
<View style={styles.customerBox}>
<Text style={styles.customerName}>{customer.name}</Text>
{customer.address_line1 && <Text>{customer.address_line1}</Text>}
@@ -1054,10 +1177,10 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
{/* Items table */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>{L.itemsHeading}</Text>
<Text style={styles.sectionTitle} minPresenceAhead={HEADING_MIN_PRESENCE_AHEAD}>{L.itemsHeading}</Text>
<View style={styles.table}>
{/* Table header */}
<View style={styles.tableHeader}>
<View style={styles.tableHeader} minPresenceAhead={HEADING_MIN_PRESENCE_AHEAD}>
<Text style={[styles.colDescription, styles.tableHeaderText]}>{L.colDescription}</Text>
<Text style={[styles.colQty, styles.tableHeaderText]}>{L.colQty}</Text>
<Text style={[styles.colUnit, styles.tableHeaderText]}>{L.colUnit}</Text>
@@ -1080,16 +1203,24 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
isTextLikeLine(item) ? (
// Free-text / blank row: description spans the full width, no
// numeric columns. An empty description renders as a spacer.
<View key={index} style={styles.tableRow}>
<Text style={[styles.colDescription, { width: '100%' }]}>
<View
key={index}
style={styles.tableRow}
wrap={!fitsOnOnePage(item.description, FULL_WIDTH_BOX_PT)}
>
<Text style={[styles.colDescription, { width: '100%' }]} hyphenationCallback={wrapFullWidthWords}>
{item.description || ' '}
</Text>
</View>
) : (
<View key={index} style={styles.tableRow}>
<Text style={styles.colDescription}>{item.description}</Text>
<View
key={index}
style={styles.tableRow}
wrap={!fitsOnOnePage(item.description, DESCRIPTION_COLUMN_PT)}
>
<Text style={styles.colDescription} hyphenationCallback={wrapDescriptionWords}>{item.description}</Text>
<Text style={styles.colQty}>{item.quantity}</Text>
<Text style={styles.colUnit}>{item.unit}</Text>
<Text style={styles.colUnit}>{unitLabel(item.unit, lang)}</Text>
{!isDeliveryNote && (
<Text style={styles.colPrice}>{formatPdfCurrency(item.unit_price, invoice.currency, lang)}</Text>
)}
@@ -1112,7 +1243,7 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
{/* Totals - hidden for delivery notes */}
{!isDeliveryNote && (
<View style={styles.totalsSection}>
<View style={styles.totalsSection} wrap={false}>
<View style={styles.totalRow}>
<Text style={styles.totalLabel}>{L.subtotal}</Text>
<Text style={styles.totalValue}>{formatPdfCurrency(invoice.subtotal, invoice.currency, lang)}</Text>
@@ -1216,7 +1347,21 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
about fakturamodellen. Suppressed on delivery notes (no payment
info at all). */}
{!isDeliveryNote && !isCreditNote && (invoice.deduction_total ?? 0) > 0 && (
<View style={styles.deductionBox} wrap={false}>
// Kept on one page while the per-line breakdown (which carries the
// line descriptions, possibly multi-line) is short enough; past
// that it may split rather than be clipped.
<View
style={styles.deductionBox}
wrap={
!fitsOnOnePage(
items
.filter((i) => i.deduction_type)
.map((i) => i.description)
.join('\n'),
FULL_WIDTH_BOX_PT,
)
}
>
<Text style={styles.deductionTitle}>{L.deductionInfoHeading}</Text>
{deductionPersonnummerMasked && (
<View style={styles.deductionRow}>
@@ -1259,7 +1404,7 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
const kind = i.deduction_type === 'rot' ? 'ROT' : 'RUT'
const work = i.work_type ? `, ${i.work_type}` : ''
return (
<Text key={idx} style={styles.deductionLineItem}>
<Text key={idx} style={styles.deductionLineItem} hyphenationCallback={wrapFullWidthWords}>
{`${kind}${work}: ${i.description}, ${formatPdfCurrency(i.deduction_amount ?? 0, invoice.currency, lang)}`}
</Text>
)
@@ -1270,8 +1415,8 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
{/* Proforma notice */}
{isProforma && (
<View style={styles.noticeBox}>
<Text style={styles.noticeText}>
<View style={styles.noticeBox} wrap={false}>
<Text style={styles.noticeText} hyphenationCallback={wrapFullWidthWords}>
{L.proformaNotice}
</Text>
</View>
@@ -1279,8 +1424,8 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
{/* Quote notice */}
{isQuote && (
<View style={styles.noticeBox}>
<Text style={styles.noticeText}>
<View style={styles.noticeBox} wrap={false}>
<Text style={styles.noticeText} hyphenationCallback={wrapFullWidthWords}>
{L.quoteNotice}
</Text>
</View>
@@ -1288,7 +1433,7 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
{/* Payment information - not shown for credit notes, proformas, quotes, or delivery notes */}
{!isCreditNote && !isProforma && !isQuote && !isDeliveryNote && (
<View style={styles.paymentSection}>
<View style={styles.paymentSection} wrap={false}>
<Text style={styles.paymentTitle}>{L.paymentHeading}</Text>
{invoice.payment_link_url && (
<View style={styles.paymentRow}>
@@ -1404,19 +1549,19 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
since the "ej momsregistrerad" line would contradict the VAT
shown in the totals block. */}
{company.vat_registered === false && invoice.vat_amount === 0 ? (
<View style={styles.noticeBox}>
<Text style={styles.noticeText}>{L.notVatRegisteredNotice}</Text>
<View style={styles.noticeBox} wrap={false}>
<Text style={styles.noticeText} hyphenationCallback={wrapFullWidthWords}>{L.notVatRegisteredNotice}</Text>
</View>
) : (
<>
{invoice.reverse_charge_text && (
<View style={styles.noticeBox}>
<Text style={styles.noticeText}>{localizeVatNotice(invoice.reverse_charge_text, lang)}</Text>
<View style={styles.noticeBox} wrap={false}>
<Text style={styles.noticeText} hyphenationCallback={wrapFullWidthWords}>{localizeVatNotice(invoice.reverse_charge_text, lang)}</Text>
</View>
)}
{invoice.vat_treatment === 'exempt' && !invoice.reverse_charge_text && (
<View style={styles.noticeBox}>
<Text style={styles.noticeText}>{L.exemptNotice}</Text>
<View style={styles.noticeBox} wrap={false}>
<Text style={styles.noticeText} hyphenationCallback={wrapFullWidthWords}>{L.exemptNotice}</Text>
</View>
)}
</>
@@ -1424,14 +1569,14 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
{/* Notes */}
{invoice.notes && (
<View style={styles.noticeBox}>
<Text style={styles.noticeText}>{invoice.notes}</Text>
<View style={styles.noticeBox} wrap={!fitsOnOnePage(invoice.notes, FULL_WIDTH_BOX_PT)}>
<Text style={styles.noticeText} hyphenationCallback={wrapFullWidthWords}>{invoice.notes}</Text>
</View>
)}
{/* Late fee & credit terms: payment terms, so never on a quote */}
{!isQuote && (company.invoice_late_fee_text || company.invoice_credit_terms_text) && (
<View style={{ marginTop: 10, marginBottom: 10 }}>
<View style={{ marginTop: 10, marginBottom: 10 }} wrap={false}>
{company.invoice_late_fee_text && (
<Text style={{ fontSize: 8, color: '#666', marginBottom: 2 }}>{company.invoice_late_fee_text}</Text>
)}
@@ -1447,9 +1592,9 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
in its own Text node, not inside the join). */}
<View style={styles.footer}>
{footerText && (
<Text style={styles.brandingFooterText}>{footerText}</Text>
<Text style={styles.brandingFooterText} hyphenationCallback={wrapFullWidthWords}>{footerText}</Text>
)}
<Text style={styles.footerText}>
<Text style={styles.footerText} hyphenationCallback={wrapFullWidthWords}>
{[
(company.invoice_show_company_name ?? true) &&
(company.invoice_company_name_position ?? 'header') === 'footer'
+4 -1
View File
@@ -8,6 +8,7 @@ import { isSaneDateString, normalizeOrgNumber } from '@/lib/invariants'
import { resolveInvoicePaymentAccount } from '@/lib/invoices/payment-accounts'
import { computeLineAmounts, hasLineDiscount } from '@/lib/invoices/line-amounts'
import { getDisplayTotal } from '@/lib/invoices/rounding'
import { toSingleLine } from '@/lib/invoices/display'
import { equalOre, roundOre } from '@/lib/money'
import type { CompanySettings, Customer, Invoice, InvoiceItem } from '@/types'
@@ -586,7 +587,9 @@ function renderInvoiceXml(input: PeppolInvoiceInput, prepared: PreparedInvoice):
` <cbc:LineExtensionAmount currencyID="SEK">${formatMoney(item.line_total)}</cbc:LineExtensionAmount>`,
...allowance,
' <cac:Item>',
` <cbc:Name>${escapeXml(item.description.trim())}</cbc:Name>`,
// The UBL item name is a single-line field; descriptions may carry
// line breaks.
` <cbc:Name>${escapeXml(toSingleLine(item.description))}</cbc:Name>`,
' <cac:ClassifiedTaxCategory>',
' <cbc:ID>S</cbc:ID>',
` <cbc:Percent>${formatDecimal(item.vat_rate)}</cbc:Percent>`,
+25
View File
@@ -0,0 +1,25 @@
/**
* Unit labels on customer-facing documents.
*
* `invoice_items.unit` is stored as the free-text string the user typed
* (Swedish by default: the editor offers st/tim/dag/månad/km/kg). An invoice
* rendered in English printed that Swedish unit verbatim, so "1 st" sat next
* to "Description" and "Qty". This maps the editor's known units to their
* English label at render time; anything else (a user-typed unit we cannot
* know) prints as stored. Nothing is rewritten in the database or the API.
*/
const EN_UNIT_LABELS: Record<string, string> = {
st: 'pcs',
tim: 'h',
dag: 'day',
månad: 'month',
mån: 'month',
}
export function unitLabel(unit: string | null | undefined, lang: 'sv' | 'en'): string {
const raw = unit ?? ''
if (lang !== 'en') return raw
const key = raw.trim().toLowerCase()
return EN_UNIT_LABELS[key] ?? raw
}
+63
View File
@@ -524,6 +524,69 @@ describe('generateSIEExport', () => {
expect(output).toContain('#VER "A" 1 20240115 "Invoice for \\"consulting\\""')
})
it('keeps a description with line breaks on one record line', async () => {
results = [
{ data: { id: 'period-1', period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
{ data: null, error: null }, // prevPeriod
{ data: [], error: null }, // accounts
{
data: [
{ id: 'e1', entry_date: '2024-01-15', voucher_number: 1, voucher_series: 'A', description: 'Periodisering: Konsultation\nSeptember 2026', status: 'posted' },
],
error: null,
},
{
data: [
{ journal_entry_id: 'e1', account_number: '1930', debit_amount: 100, credit_amount: 0, line_description: 'Rad 1\r\nRad 2', dimensions: {} },
{ journal_entry_id: 'e1', account_number: '3001', debit_amount: 0, credit_amount: 100, line_description: null, dimensions: {} },
],
error: null,
},
{ data: [], error: null }, // dimensions
{ data: [], error: null }, // dimension_values
{ data: [], error: null }, // RPC fallback
]
const output = await generateSIEExport(supabase, 'company-1', baseOptions)
expect(output).toContain('#VER "A" 1 20240115 "Periodisering: Konsultation September 2026"')
expect(output).toContain('"Rad 1 Rad 2"')
})
it('escapes backslashes so the escape character itself round-trips', async () => {
results = [
{ data: { id: 'period-1', period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
{ data: null, error: null }, // prevPeriod
{ data: [], error: null }, // accounts
{
data: [
{ id: 'e1', entry_date: '2024-01-15', voucher_number: 1, voucher_series: 'A', description: 'Sökväg C:\\temp\\"fil"', status: 'posted' },
],
error: null,
},
{
data: [
{ journal_entry_id: 'e1', account_number: '1930', debit_amount: 100, credit_amount: 0, line_description: null, dimensions: {} },
{ journal_entry_id: 'e1', account_number: '3001', debit_amount: 0, credit_amount: 100, line_description: null, dimensions: {} },
],
error: null,
},
{ data: [], error: null }, // dimensions
{ data: [], error: null }, // dimension_values
{ data: [], error: null }, // RPC fallback
]
const output = await generateSIEExport(supabase, 'company-1', baseOptions)
expect(output).toContain('#VER "A" 1 20240115 "Sökväg C:\\\\temp\\\\\\"fil\\""')
// Every record is one line: no line starts with something other than a
// (possibly indented) record tag or a brace.
for (const line of output.split('\r\n')) {
if (line.length === 0) continue
expect(line).toMatch(/^\s*(#|\{|\})/)
}
})
it('uses \\r\\n line endings', async () => {
results = [
{ data: { id: 'period-1', period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
+6 -1
View File
@@ -335,8 +335,13 @@ function formatAmount(amount: number): string {
* CodeQL flags this as js/incomplete-sanitization; it is a false positive here,
* because the rule assumes a grammar in which backslash escapes itself.
*/
// SIE is one record per line: a line break inside a quoted text would end
// the record early and orphan the rest, so it is collapsed to a space.
// Backslash is the escape character (`\"` is a literal quote), so a literal
// backslash is written as `\\`; the parser in lib/import/sie-parser.ts
// unescapes both.
function escapeQuotes(str: string): string {
return str.replace(/"/g, '\\"')
return str.replace(/[\r\n]+/g, ' ').replace(/\\/g, '\\\\').replace(/"/g, '\\"')
}
// ── Dimensions (#DIM / #UNDERDIM / #OBJEKT) ─────────────────────────────────