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:
@@ -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)
|
||||
})
|
||||
@@ -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('')
|
||||
})
|
||||
})
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user