fix(pdf): keep the minus sign on losses in standard-font PDFs (#1982) (#1987)

Intl sv-SE formats negatives with U+2212, which the bundled react-pdf Helvetica/Courier fonts cannot render, so a loss printed as a profit. lib/pdf/number-text.ts pdfNumberText maps U+2212 to an ASCII hyphen and prints negative zero unsigned; routed through financial-statement, kassaflodesanalys, reskontra, momsdeklaration, payslip (fmt, the literal Preliminar skatt sign and the calculation formula text) and operational-report templates, with content-stream regression tests. The K2/K3 arsredovisning templates keep main's formatPdfKronor from #2013.

Closes #1982
This commit is contained in:
Jakob Wennberg
2026-08-30 11:22:05 +02:00
committed by GitHub
parent 4b7343d5ec
commit cc7050f6cb
16 changed files with 592 additions and 25 deletions
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'
import { renderToBuffer } from '@react-pdf/renderer'
import { FinancialStatementPDF } from '../financial-statement-pdf-template'
import type { CompanySettings } from '@/types'
import { pdfTextStrings } from '@/tests/pdf-text'
function fakeCompany(): CompanySettings {
return {
@@ -140,4 +141,40 @@ describe('FinancialStatementPDF', () => {
const buffer = await renderToBuffer(doc)
expect(buffer.slice(0, 5).toString()).toBe('%PDF-')
}, RENDER_TIMEOUT)
it('prints a loss with its sign in the rendered bytes (issue #1982)', async () => {
// sv-SE formats negatives with U+2212, which the bundled Helvetica cannot
// draw: Årets resultat -4 684,24 used to print as 4 684,24 in both the
// resultaträkning summary and the balansräkning 2099 row.
const doc = FinancialStatementPDF({
title: 'Balansräkning',
groups: [
{
heading: 'Eget kapital och skulder',
sections: [
{
title: 'Eget kapital',
rows: [
{ account_number: '2081', account_name: 'Aktiekapital', amount: 25_000 },
{ account_number: '2099', account_name: 'Årets resultat', amount: -4684.24 },
],
subtotal: 20_315.76,
},
],
totalLabel: 'Summa eget kapital och skulder',
total: 20_315.76,
},
],
summary: [{ label: 'Årets resultat', amount: -4684.24, emphasis: true }],
period: { start: '2025-10-14', end: '2026-01-31' },
company: fakeCompany(),
generatedAt: '2026-08-27T10:00:00Z',
})
const text = pdfTextStrings(await renderToBuffer(doc)).join('\n')
expect(text).toContain('-4 684,24')
expect(text).not.toContain(String.fromCharCode(0x12))
expect(text).not.toContain('\u2212')
expect(text).toContain('20 315,76')
}, RENDER_TIMEOUT)
})
@@ -0,0 +1,72 @@
import { describe, it, expect } from 'vitest'
import { renderToBuffer } from '@react-pdf/renderer'
import { KassaflodesanalysPDF } from '../kassaflodesanalys-pdf-template'
import type { KassaflodesanalysReport } from '../kassaflodesanalys'
import type { CompanySettings } from '@/types'
import { pdfTextStrings } from '@/tests/pdf-text'
function fakeCompany(): CompanySettings {
return {
company_name: 'Testbolaget AB',
org_number: '5566778899',
} as unknown as CompanySettings
}
function report(): KassaflodesanalysReport {
return {
fiscal_period_id: 'fp-1',
period_start: '2026-01-01',
period_end: '2026-12-31',
lopande: {
resultat_efter_finansiella_poster: -4684.24,
avskrivningar: 1000,
ovriga_ej_kassaflodesposter: 0,
delta_kortfristiga_fordringar: -250.5,
delta_varulager: 0,
delta_kortfristiga_skulder: 300,
skatt_betald: 0,
total: -3634.74,
},
investerings: {
forvarv_anlaggningar: -12000,
avyttring_anlaggningar: 0,
total: -12000,
},
finansierings: {
delta_lan: 0,
utdelningar: 0,
nyemission: 0,
erhallna_aktieagartillskott: 0,
total: 0,
},
total_cash_flow: -15634.74,
reconciliation: {
opening_cash_1xxx: 50000,
closing_cash_1xxx: 34365.26,
delta_actual: -15634.74,
delta_calculated: -15634.74,
mismatch_amount: 0,
is_reconciled: true,
},
}
}
describe('KassaflodesanalysPDF sign rendering (issue #1982)', () => {
it('prints negative cash flows with an ASCII minus the bundled font can draw', async () => {
const text = pdfTextStrings(
await renderToBuffer(
KassaflodesanalysPDF({
report: report(),
company: fakeCompany(),
generatedAt: '2026-08-29T10:00:00Z',
})
)
).join('\n')
expect(text).toContain('-4 684,24')
expect(text).toContain('-12 000,00')
expect(text).toContain('-15 634,74')
expect(text).not.toContain(String.fromCharCode(0x12))
expect(text).not.toContain('−')
}, 30_000)
})
@@ -7,6 +7,7 @@ import {
type ReskontraInvoiceRow,
} from '../reskontra-pdf-template'
import type { CompanySettings } from '@/types'
import { pdfTextStrings } from '@/tests/pdf-text'
/**
* Flatten every string/number leaf of a react-pdf element tree in document
@@ -218,3 +219,20 @@ describe('ReskontraPDF unit disclosure', () => {
// Real react-pdf layout plus font loading: slower than the 5s default.
}, 30000)
})
describe('ReskontraPDF sign rendering (issue #1982)', () => {
it('prints a credit-note balance with an ASCII minus the bundled font can draw', async () => {
const buffer = await renderToBuffer(
build({
aging: [agingRow({ current: -500, total_outstanding: -500 })],
totals: agingRow({ name: 'Summa', current: -500, total_outstanding: -500 }),
invoices: [invoiceRow({ outstanding: -500, outstanding_sek: -500 })],
unpaidCount: 1,
})
)
const text = pdfTextStrings(buffer).join('\n')
expect(text).toContain('-500,00')
expect(text).not.toContain(String.fromCharCode(0x12))
expect(text).not.toContain('\u2212')
}, 30_000)
})
@@ -0,0 +1,40 @@
import { describe, it, expect } from 'vitest'
import { renderToBuffer } from '@react-pdf/renderer'
import { VatDeclarationPDF } from '../vat-declaration-pdf-template'
import type { CompanySettings } from '@/types'
import { pdfTextStrings } from '@/tests/pdf-text'
function fakeCompany(): CompanySettings {
return {
company_name: 'Testbolaget AB',
org_number: '5566778899',
vat_number: 'SE556677889901',
} as unknown as CompanySettings
}
describe('VatDeclarationPDF sign rendering (issue #1982)', () => {
it('prints a negative ruta with an ASCII minus the bundled font can draw', async () => {
const text = pdfTextStrings(
await renderToBuffer(
VatDeclarationPDF({
rows: [
{ ruta: '05', label: 'Momspliktig försäljning', amount: 100000 },
{ ruta: '10', label: 'Utgående moms 25 %', amount: 25000 },
// A credit-note-heavy period can push a ruta below zero.
{ ruta: '48', label: 'Ingående moms att dra av', amount: -1234 },
{ ruta: '49', label: 'Moms att betala', amount: 26234, isNet: true },
],
period: { start: '2026-04-01', end: '2026-06-30' },
periodLabel: 'April till juni 2026',
company: fakeCompany(),
generatedAt: '2026-08-29T10:00:00Z',
})
)
).join('\n')
expect(text).toContain('-1 234')
expect(text).toContain('26 234')
expect(text).not.toContain(String.fromCharCode(0x12))
expect(text).not.toContain('−')
}, 30_000)
})
@@ -5,6 +5,7 @@ import {
View,
StyleSheet,
} from '@react-pdf/renderer'
import { pdfNumberText } from '@/lib/pdf/number-text'
import type { CompanySettings } from '@/types'
const styles = StyleSheet.create({
@@ -209,10 +210,14 @@ const styles = StyleSheet.create({
})
function formatAmount(amount: number): string {
return new Intl.NumberFormat('sv-SE', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(amount)
// pdfNumberText: Intl's U+2212 has no glyph in the bundled Helvetica/Courier,
// so a negative årets resultat would print as a profit (issue #1982).
return pdfNumberText(
new Intl.NumberFormat('sv-SE', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(amount),
)
}
function formatOrgNumber(orgNumber: string): string {
@@ -5,6 +5,7 @@ import {
View,
StyleSheet,
} from '@react-pdf/renderer'
import { pdfNumberText } from '@/lib/pdf/number-text'
import type { KassaflodesanalysReport } from './kassaflodesanalys'
import type { CompanySettings } from '@/types'
@@ -162,10 +163,14 @@ const styles = StyleSheet.create({
})
function formatAmount(n: number): string {
return new Intl.NumberFormat('sv-SE', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(n)
// pdfNumberText: Intl's U+2212 has no glyph in the bundled Helvetica/Courier,
// so a negative cash flow would print as positive (issue #1982).
return pdfNumberText(
new Intl.NumberFormat('sv-SE', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(n),
)
}
function formatOrgNumber(orgNumber: string): string {
@@ -5,6 +5,7 @@ import {
View,
StyleSheet,
} from '@react-pdf/renderer'
import { pdfNumberText } from '@/lib/pdf/number-text'
import type {
CompanySettings,
LatestVoucherPerSeries,
@@ -226,15 +227,14 @@ const styles = StyleSheet.create({
})
function formatAmount(amount: number): string {
// Intl emits U+2212 (true minus). The bundled @react-pdf/renderer
// Helvetica/Courier fonts lack that glyph and silently drop it, so
// negatives would render as positives. Map to ASCII hyphen.
return new Intl.NumberFormat('sv-SE', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
.format(amount)
.replace(/\u2212/g, '-')
// pdfNumberText: Intl emits U+2212 (true minus), which the bundled
// Helvetica/Courier fonts lack, so negatives would render as positives.
return pdfNumberText(
new Intl.NumberFormat('sv-SE', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(amount),
)
}
function formatOrgNumber(orgNumber: string): string {
+9 -4
View File
@@ -5,6 +5,7 @@ import {
View,
StyleSheet,
} from '@react-pdf/renderer'
import { pdfNumberText } from '@/lib/pdf/number-text'
import type { CompanySettings } from '@/types'
const styles = StyleSheet.create({
@@ -201,10 +202,14 @@ const styles = StyleSheet.create({
})
function formatAmount(amount: number): string {
return new Intl.NumberFormat('sv-SE', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(amount)
// pdfNumberText: Intl's U+2212 has no glyph in the bundled Helvetica/Courier,
// so a credit note or overpayment would print unsigned (issue #1982).
return pdfNumberText(
new Intl.NumberFormat('sv-SE', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(amount),
)
}
function formatOrgNumber(orgNumber: string): string {
+4 -1
View File
@@ -5,6 +5,7 @@ import {
View,
StyleSheet,
} from '@react-pdf/renderer'
import { pdfNumberText } from '@/lib/pdf/number-text'
import type { CompanySettings } from '@/types'
import type { ManualFilingRow } from '@/lib/reports/vat-manual-filing'
@@ -94,7 +95,9 @@ const styles = StyleSheet.create({
// already truncated to whole kronor (öretal faller bort, see
// buildManualFilingRows), so format with no decimals.
function formatKr(amount: number): string {
return new Intl.NumberFormat('sv-SE', { maximumFractionDigits: 0 }).format(amount)
// pdfNumberText: Intl's U+2212 has no glyph in the bundled Helvetica/Courier,
// so moms att få tillbaka would print as moms att betala (issue #1982).
return pdfNumberText(new Intl.NumberFormat('sv-SE', { maximumFractionDigits: 0 }).format(amount))
}
function formatOrgNumber(orgNumber: string): string {