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:
co-authored by
Claude Fable 5.1
parent
c634cf9ae0
commit
7a30f623ba
@@ -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(),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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('')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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'
|
||||
|
||||
@@ -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>`,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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) ─────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user