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
@@ -0,0 +1,151 @@
/**
* Issue #1982: a loss printed as a profit in the årsredovisning PDF.
*
* sv-SE number formatting writes negatives with U+2212 MINUS SIGN. The
* bundled Helvetica has no glyph for it, so the rendered document carried an
* unmapped byte where the sign should be and "Årets resultat" read as
* +4 684 while "Summa eget kapital" (20 316) only reconciled with -4 684.
* These tests decode the content streams of the real render, which is the
* only place the bug is visible: the element tree always had the sign.
*/
import { describe, it, expect } from 'vitest'
import { renderToBuffer } from '@react-pdf/renderer'
import { ArsredovisningPDF } from '../arsredovisning-pdf'
import { ArsredovisningK3PDF } from '../arsredovisning-k3-pdf'
import type { ArsredovisningData } from '../types'
import { pdfTextStrings } from '@/tests/pdf-text'
const RENDER_TIMEOUT = 30_000
/** Unmapped-glyph byte react-pdf emits for U+2212 in a WinAnsi font. */
const UNMAPPED = String.fromCharCode(0x12)
function makeLossYearData(framework: 'k2' | 'k3'): ArsredovisningData {
return {
company: {
name: 'Testbolaget AB',
org_number: '556677-8899',
entity_type: 'aktiebolag',
city: 'Stockholm',
},
fiscal_period: {
id: 'fp1',
name: 'Första räkenskapsåret 2025/2026',
period_start: '2025-10-14',
period_end: '2026-01-31',
},
previous_period: null,
accounting_framework: framework,
forvaltningsberattelse: {
description: 'Bolaget bedriver konsultverksamhet inom IT.',
important_events: 'Inga väsentliga händelser.',
kontrollbalans_required: false,
flerarsoversikt: [
{ year: '2025/2026', net_revenue: 0, result_after_financial: -4684, soliditet_pct: 100 },
],
egen_kapital_changes: [
{ label: 'Aktiekapital', amount: 25_000 },
{ label: 'Årets resultat', amount: -4684 },
{ label: 'Summa eget kapital', amount: 20_316 },
],
resultatdisposition: 'Styrelsen föreslår att årets förlust balanseras i ny räkning.',
proposed_dividend: 0,
resultatdisposition_amounts: {
retained_earnings: 0,
share_premium_reserve: 0,
current_year_result: -4684,
total: -4684,
proposed_dividend: 0,
carried_forward: -4684,
},
agm_date: '2026-06-15',
agm_disposition_outcome: 'proposal_approved',
agm_disposition_decision: null,
},
resultatrakning: [
{ label: 'Rörelsekostnader', current: null, previous: null, is_heading: true },
{ label: 'Övriga externa kostnader', current: -4684, previous: null, indent: 1 },
{ label: 'Summa rörelsekostnader', current: -4684, previous: null, is_total: true },
{ label: 'Rörelseresultat', current: -4684, previous: null, is_total: true },
{
label: 'Årets resultat',
current: -4684,
previous: null,
is_total: true,
semantic_key: 'income_statement_result',
},
],
balansrakning: {
assets: [
{ label: 'Kassa och bank', current: 20_316, previous: null, indent: 2 },
{ label: 'Summa tillgångar', current: 20_316, previous: null, is_total: true },
],
total_assets: 20_316,
total_assets_previous: null,
equity_liabilities: [
{ label: 'Eget kapital', current: null, previous: null, is_heading: true },
{ label: 'Aktiekapital', current: 25_000, previous: null, indent: 2 },
{ label: 'Balanserat resultat', current: 0, previous: null, indent: 2 },
{
label: 'Årets resultat',
current: -4684,
previous: null,
indent: 2,
semantic_key: 'balance_sheet_current_year_result',
},
{ label: 'Summa fritt eget kapital', current: -4684, previous: null, is_total: true, indent: 1 },
{ label: 'Summa eget kapital', current: 20_316, previous: null, is_total: true },
{ label: 'Summa eget kapital och skulder', current: 20_316, previous: null, is_total: true },
],
total_equity_liabilities: 20_316,
total_equity_liabilities_previous: null,
},
noter: [
{
number: 1,
title: 'Redovisnings- och värderingsprinciper',
body: 'Årsredovisningen är upprättad enligt BFNAR 2016:10.',
},
],
signatures: [],
warnings: [],
disclosures: {
long_term_debt_over_five_years: null,
securities_pledged: null,
contingent_liabilities: null,
parent_company_name: null,
parent_company_org_number: null,
parent_company_city: null,
confirmations: {
long_term_debt_over_five_years: true,
securities_pledged: true,
contingent_liabilities: true,
parent_company: true,
},
},
}
}
function expectSignedLoss(pages: string[]): void {
const text = pages.join('\n')
// The sign reaches the file as an ASCII hyphen the font can draw ...
expect(text).toContain('-4 684')
// ... and never as the unmapped byte a WinAnsi font draws as nothing.
expect(text).not.toContain(UNMAPPED)
expect(text).not.toContain('−')
// The positive totals are untouched.
expect(text).toContain('20 316')
expect(text).toContain('25 000')
}
describe('årsredovisning PDF keeps the sign of a loss (issue #1982)', () => {
it('K2: Årets resultat and Summa fritt eget kapital print as -4 684', async () => {
const buffer = await renderToBuffer(ArsredovisningPDF({ data: makeLossYearData('k2') }))
expectSignedLoss(pdfTextStrings(buffer))
}, RENDER_TIMEOUT)
it('K3: the same rows print signed', async () => {
const buffer = await renderToBuffer(ArsredovisningK3PDF({ data: makeLossYearData('k3') }))
expectSignedLoss(pdfTextStrings(buffer))
}, RENDER_TIMEOUT)
})
+44
View File
@@ -0,0 +1,44 @@
import { describe, it, expect } from 'vitest'
import { pdfNumberText, pdfText, UNICODE_MINUS } from '../number-text'
describe('pdfNumberText', () => {
it('maps every U+2212 to the ASCII hyphen-minus', () => {
const formatted = (-4684.24).toLocaleString('sv-SE', { minimumFractionDigits: 2 })
expect(formatted).toContain(UNICODE_MINUS)
const safe = pdfNumberText(formatted)
expect(safe).not.toContain(UNICODE_MINUS)
expect(safe.startsWith('-4')).toBe(true)
expect(pdfNumberText(`${UNICODE_MINUS}1 ${UNICODE_MINUS}2`)).toBe('-1 -2')
})
it('prints a negative zero unsigned', () => {
expect(pdfNumberText((-0).toLocaleString('sv-SE'))).toBe('0')
expect(pdfNumberText(`${UNICODE_MINUS}0,00`)).toBe('0,00')
expect(pdfNumberText('-0.00')).toBe('0.00')
// A real small negative keeps its sign.
expect(pdfNumberText(`${UNICODE_MINUS}0,01`)).toBe('-0,01')
})
it('leaves text without a minus sign untouched', () => {
expect(pdfNumberText('20 316')).toBe('20 316')
expect(pdfNumberText('')).toBe('')
})
})
describe('pdfText', () => {
it('maps every U+2212 in free text to the ASCII hyphen-minus', () => {
expect(pdfText(`bruttolön ${UNICODE_MINUS} skatt ${UNICODE_MINUS} nettoavdrag`)).toBe(
'bruttolön - skatt - nettoavdrag'
)
expect(pdfText(`${UNICODE_MINUS}(full lön ${UNICODE_MINUS} sjuklön + karensavdrag)`)).toBe(
'-(full lön - sjuklön + karensavdrag)'
)
})
it('does not treat the text as a number', () => {
// A formula that happens to start with "-0" keeps its sign.
expect(pdfText(`${UNICODE_MINUS}0,5 × lön`)).toBe('-0,5 × lön')
expect(pdfText('grundlön + tillägg')).toBe('grundlön + tillägg')
expect(pdfText('')).toBe('')
})
})
+40
View File
@@ -0,0 +1,40 @@
/**
* Sign guard for numbers rendered with @react-pdf/renderer.
*
* `Intl.NumberFormat('sv-SE')` and `toLocaleString('sv-SE')` write negatives
* with U+2212 MINUS SIGN, not the ASCII hyphen-minus. The bundled standard
* PDF fonts (Helvetica, Courier, Times: WinAnsi encoding) have no glyph for
* U+2212, so react-pdf emits an unmapped byte and the viewer draws nothing:
* a loss prints as a profit, a credit note as a charge (issue #1982, first
* seen on Årets resultat in the årsredovisning PDF).
*
* Run every locale-formatted amount through this before it reaches a <Text>
* that uses a standard font. Embedded TrueType fonts do carry the glyph, but
* the ASCII hyphen renders identically there, so applying it everywhere is
* safe.
*/
/** Built via fromCharCode so no editor or transport can normalise it away. */
export const UNICODE_MINUS = String.fromCharCode(0x2212)
/** "-0", "-0,00", "-0.00": a negated zero or sub-öre rounding noise. */
const NEGATIVE_ZERO = /^-0(?:[.,]0+)?$/
/**
* Locale-formatted number text made safe for a standard PDF font: U+2212
* becomes the ASCII hyphen-minus, and a negative zero (which the dropped
* glyph used to hide) prints unsigned, as it does on screen.
*/
export function pdfNumberText(text: string): string {
const ascii = text.replaceAll(UNICODE_MINUS, '-')
return NEGATIVE_ZERO.test(ascii) ? ascii.slice(1) : ascii
}
/**
* Free text (a formula, a label) made safe for a standard PDF font: U+2212
* becomes the ASCII hyphen-minus. Unlike pdfNumberText it never touches a
* leading "-0", because the text is not a single number.
*/
export function pdfText(text: string): string {
return text.replaceAll(UNICODE_MINUS, '-')
}
@@ -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 {
@@ -0,0 +1,91 @@
import { describe, it, expect } from 'vitest'
import { renderToBuffer } from '@react-pdf/renderer'
import { PayslipPDF, type PayslipData } from '../payslip-template'
import { pdfTextStrings } from '@/tests/pdf-text'
const UNICODE_MINUS = String.fromCharCode(0x2212)
function payslip(over: Partial<PayslipData> = {}): PayslipData {
return {
companyName: 'Testbolaget AB',
companyOrgNumber: '556677-8899',
employeeName: 'Anna Andersson',
personnummerMasked: '19850101-XXXX',
employmentType: 'tillsvidare',
periodYear: 2026,
periodMonth: 8,
paymentDate: '2026-08-25',
lineItems: [
{ description: 'Månadslön', quantity: 1, unitPrice: 35000, amount: 35000 },
{ description: 'Sjukfrånvaro', quantity: 1, unitPrice: -1234.56, amount: -1234.56 },
],
grossSalary: 33765.44,
taxWithheld: 7654.32,
netSalary: 26111.12,
taxReference: 'Tabell 33, kolumn 1',
avgifterRate: 0.3142,
avgifterAmount: 10609.1,
vacationAccrual: 4051.85,
vacationAccrualAvgifter: 1273.09,
totalEmployerCost: 49699.48,
ytdGross: 270123.52,
ytdTax: 61234.56,
ytdNet: 208888.96,
...over,
}
}
// Real @react-pdf/renderer layout is CPU-heavy; under a fully parallel test
// run these can exceed the 5s default on a saturated machine.
const RENDER_TIMEOUT = 30_000
describe('PayslipPDF sign rendering (issue #1982)', () => {
it('prints the tax deduction and a negative line with an ASCII minus the bundled font can draw', async () => {
const text = pdfTextStrings(await renderToBuffer(PayslipPDF({ data: payslip() }))).join('\n')
// The Preliminär skatt row used to prefix a literal U+2212 outside fmt(),
// which Helvetica has no glyph for: the content stream carried an unmapped
// 0x12 byte and the viewer drew "7 654,32" with no sign.
expect(text).toContain('-7 654,32')
// A negative line item (absence, deduction) goes through fmt().
expect(text).toContain('-1 234,56')
expect(text).not.toContain(String.fromCharCode(0x12))
expect(text).not.toContain(UNICODE_MINUS)
}, RENDER_TIMEOUT)
it('prints the calculation-engine formula strings with an ASCII minus', async () => {
// The engine builds these with U+2212 (they are shown on screen too, where
// the glyph exists); the PDF must map them at the render site.
const text = pdfTextStrings(
await renderToBuffer(
PayslipPDF({
data: payslip({
breakdownSteps: [
{
label: 'Bruttolön',
formula: `grundlön + tillägg + frånvaro ${UNICODE_MINUS} bruttoavdrag`,
output: 33765.44,
},
{
label: 'Nettolön',
formula: `bruttolön ${UNICODE_MINUS} skatt ${UNICODE_MINUS} nettoavdrag`,
output: 26111.12,
},
{
label: 'Sjukavdrag',
formula: `${UNICODE_MINUS}(full lön ${UNICODE_MINUS} sjuklön + karensavdrag)`,
output: -1234.56,
},
],
}),
})
)
).join('\n')
expect(text).toContain('grundlön + tillägg + frånvaro - bruttoavdrag')
expect(text).toContain('bruttolön - skatt - nettoavdrag')
expect(text).toContain('-(full lön - sjuklön + karensavdrag)')
expect(text).not.toContain(String.fromCharCode(0x12))
expect(text).not.toContain(UNICODE_MINUS)
}, RENDER_TIMEOUT)
})
+11 -3
View File
@@ -1,4 +1,5 @@
import { Document, Page, Text, View, StyleSheet } from '@react-pdf/renderer'
import { pdfNumberText, pdfText } from '@/lib/pdf/number-text'
import { getBranding } from '@/lib/branding/service'
/**
@@ -247,7 +248,14 @@ export interface PayslipLineItem {
}
function fmt(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, so a
// negative line (deduction, absence) would print as an addition (issue
// #1982). The two other U+2212 sources on this page are handled at their
// render sites: the Preliminär skatt prefix (an ASCII hyphen) and the
// breakdown formula strings from the calculation engine (pdfText).
return pdfNumberText(
new Intl.NumberFormat('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(amount),
)
}
const MONTH_NAMES = [
@@ -327,7 +335,7 @@ export function PayslipPDF({ data }: { data: PayslipData }) {
</View>
<View style={styles.summaryRow}>
<Text style={styles.summaryLabel}>Preliminär skatt ({data.taxReference})</Text>
<Text style={styles.summaryValue}>−{fmt(data.taxWithheld)}</Text>
<Text style={styles.summaryValue}>-{fmt(data.taxWithheld)}</Text>
</View>
<View style={styles.totalRow}>
<Text style={styles.totalLabel}>Nettolön (utbetalas)</Text>
@@ -380,7 +388,7 @@ export function PayslipPDF({ data }: { data: PayslipData }) {
{data.breakdownSteps.map((step, i) => (
<View key={i} style={styles.breakdownRow}>
<Text style={styles.breakdownLabel}>{step.label}</Text>
<Text style={styles.breakdownFormula}>{step.formula}</Text>
<Text style={styles.breakdownFormula}>{pdfText(step.formula)}</Text>
<Text style={styles.breakdownValue}>{fmt(step.output)}</Text>
</View>
))}