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