fix(arsredovisning): losses lost their minus sign in the PDF (#2013)

* fix(arsredovisning): losses lost their minus sign in the PDF

toLocaleString('sv-SE') formats negatives with U+2212 MINUS SIGN, which
react-pdf's built-in Helvetica (WinAnsi encoding) has no glyph for, so the
sign was silently dropped: a loss rendered as a profit on 'Arets resultat'
and 'Summa fritt eget kapital' while all totals stayed correct. Reported by
a user whose -4 684,24 kr loss displayed as +4 684 kr.

Add formatPdfKronor (absolute value + ASCII hyphen-minus) and route the K2
and K3 PDF templates and the PDF-bound note builders through it. Display
only; no ledger or iXBRL changes (the iXBRL writer already handles signs
via the sign attribute and its own presentational minus).

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

* fix(arsredovisning): pin PDF group separator to U+00A0 across ICU versions

Newer CLDR data groups sv-SE thousands with U+202F NARROW NO-BREAK SPACE,
which WinAnsi Helvetica also lacks: digits would silently run together the
same way the minus sign was dropped. Normalize the separator to U+00A0 in
formatPdfKronor so the rendering does not depend on the runtime's ICU.

Addresses the PR Agent reviewer-guide finding on #2013.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-28 16:55:41 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent b4b11c33ca
commit 89ce837947
7 changed files with 81 additions and 6 deletions
@@ -0,0 +1,43 @@
import { describe, it, expect } from 'vitest'
import { formatPdfKronor } from '../pdf-format'
// Build the special characters from code points so the assertions cannot be
// corrupted by editor/encoding round-trips: U+00A0 is the sv-SE thousands
// separator, U+2212 is the locale minus that WinAnsi Helvetica cannot render.
const NBSP = String.fromCharCode(0x00a0)
const U2212 = String.fromCharCode(0x2212)
describe('formatPdfKronor', () => {
it('formats a loss with an ASCII hyphen-minus, never U+2212', () => {
const out = formatPdfKronor(-4684.24)
expect(out).toBe(`-4${NBSP}684`)
expect(out).not.toContain(U2212)
})
it('keeps sv-SE thousands grouping for positive amounts', () => {
expect(formatPdfKronor(1234567.89)).toBe(`1${NBSP}234${NBSP}568`)
})
it('pins the group separator to U+00A0 regardless of the ICU CLDR choice', () => {
// Newer CLDR data groups sv-SE with U+202F NARROW NO-BREAK SPACE, which
// is also missing from WinAnsi; the formatter must normalize to U+00A0.
const U202F = String.fromCharCode(0x202f)
const out = formatPdfKronor(-9876543)
expect(out).toBe(`-9${NBSP}876${NBSP}543`)
expect(out).not.toContain(U202F)
})
it('rounds to whole kronor and never renders "-0"', () => {
expect(formatPdfKronor(-0.4)).toBe('0')
expect(formatPdfKronor(-0.6)).toBe('-1')
expect(formatPdfKronor(0)).toBe('0')
})
it('always uses ASCII hyphen for negative amounts', () => {
for (const n of [-1, -999, -1000, -4684.24, -1_000_000.5]) {
const out = formatPdfKronor(n)
expect(out).not.toContain(U2212)
expect(out.startsWith('-')).toBe(true)
}
})
})
@@ -24,6 +24,7 @@
import type { NoteEntry } from './types'
import type { AssetDepreciationFigures } from './asset-note-figures'
import { formatPdfKronor } from './pdf-format'
export interface AnlaggningAsset {
category: string
@@ -161,7 +162,9 @@ export function computeRollforwardTotals(
}
}
const fmt = (n: number) => Math.round(n).toLocaleString('sv-SE')
// ASCII hyphen for negatives: the note body renders in the ÅR PDF with
// Helvetica/WinAnsi, which has no U+2212 glyph.
const fmt = (n: number) => formatPdfKronor(n)
/**
* Build the anläggningstillgångar roll-forward note. Returns null when no
@@ -1,5 +1,6 @@
import { Document, Page, Text, View, StyleSheet } from '@react-pdf/renderer'
import type { ArsredovisningData, StatementRow } from './types'
import { formatPdfKronor } from './pdf-format'
/**
* K3 årsredovisning PDF template (BFNAR 2012:1).
@@ -160,7 +161,8 @@ const styles = StyleSheet.create({
})
function fmt(amount: number): string {
return Math.round(amount).toLocaleString('sv-SE')
// ASCII hyphen for negatives: Helvetica/WinAnsi has no U+2212 glyph.
return formatPdfKronor(amount)
}
/**
@@ -1,5 +1,6 @@
import { Document, Page, Text, View, StyleSheet } from '@react-pdf/renderer'
import type { ArsredovisningData, StatementRow } from './types'
import { formatPdfKronor } from './pdf-format'
const styles = StyleSheet.create({
page: {
@@ -100,7 +101,8 @@ const styles = StyleSheet.create({
function fmt(amount: number): string {
// sv-SE thousands grouping, no decimals: typical for K2 ÅR.
return Math.round(amount).toLocaleString('sv-SE')
// ASCII hyphen for negatives: Helvetica/WinAnsi has no U+2212 glyph.
return formatPdfKronor(amount)
}
/**
@@ -2,6 +2,7 @@ import type {
EgenKapitalRow,
NoteEntry,
} from './types'
import { formatPdfKronor } from './pdf-format'
/**
* K3 noter builder (BFNAR 2012:1).
@@ -161,8 +162,8 @@ export function buildUppskjutenSkattNot(params: {
const { noteNumber, latentTaxOpening, latentTaxChange, latentTaxClosing } =
params
// sv-SE thousand separator, no decimals: typical for ÅR notes.
const fmt = (n: number) =>
Math.round(n).toLocaleString('sv-SE')
// ASCII hyphen for negatives: PDF Helvetica/WinAnsi has no U+2212 glyph.
const fmt = (n: number) => formatPdfKronor(n)
const lines: string[] = [
'Posten avser uppskjuten skatteskuld redovisad på konto 2240.',
'',
@@ -367,7 +368,7 @@ export function buildMateriellaAnlaggningsNot(params: {
}
// If any asset has components, render a per-component breakdown per asset.
const fmt = (n: number) => Math.round(n).toLocaleString('sv-SE')
const fmt = (n: number) => formatPdfKronor(n)
const withComponents = active.filter((a) => isComponentArray(a.k3_components))
if (withComponents.length > 0) {
linesOut.push('', 'Komponentuppdelning per tillgång:')
+23
View File
@@ -0,0 +1,23 @@
/**
* Whole-krona amount formatting for text that react-pdf renders with the
* built-in Helvetica font (the K2/K3 arsredovisning PDFs and their notes).
*
* `toLocaleString('sv-SE')` formats negative numbers with U+2212 MINUS SIGN,
* which WinAnsi-encoded standard fonts cannot encode; react-pdf drops the
* glyph silently, so a loss like -4 684 printed as "−4 684" loses its
* sign and reads as a profit. Format the absolute value and prepend an ASCII
* hyphen-minus instead. The sv-SE thousands separator (U+00A0 NBSP) is in
* WinAnsi and renders fine.
*/
const NBSP = String.fromCharCode(0x00a0)
export function formatPdfKronor(amount: number): string {
const rounded = Math.round(amount)
// Pin the group separator to U+00A0: depending on the Node/ICU CLDR
// version, sv-SE groups with U+00A0 or U+202F NARROW NO-BREAK SPACE, and
// U+202F is missing from WinAnsi too (digits would silently run together).
const grouped = Math.abs(rounded)
.toLocaleString('sv-SE')
.replace(/\s/g, NBSP)
return rounded < 0 ? `-${grouped}` : grouped
}