diff --git a/README.md b/README.md index 1890a6d..09afdd3 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,8 @@ One canonical implementation, consumed by both repos, makes this class of drift **2026-07-25, unpaid-notice layout fix (v0.2.4).** The new unpaid-Vorkasse instruction (v0.2.3) reused `paidBadgeRow`, which lives inside `summary`'s `alignItems: "flex-end"` column (240pt-wide, designed for the short one-line paid confirmation) — so the longer two-sentence instruction shrank to content width and read as squeezed under the narrow summary card instead of a proper full-width note. Moved to its own sibling row (`unpaidNoticeRow`, `width: "100%"`, `marginTop: 20` vs. `paidBadgeRow`'s 10) outside `summary` entirely, left-aligned instead of right-aligned. +**2026-07-28, embedded font (v0.2.6).** An E-Rechnung/PDF-A checker flagged that generated invoice PDFs don't embed their fonts — both `invoicePdf.tsx` and `correctionInvoicePdf.tsx` used react-pdf's built-in `Helvetica`/`Helvetica-Bold`, one of the "standard 14" PostScript fonts react-pdf never actually embeds (it just emits a `/BaseFont` reference and relies on the PDF viewer having a substitute installed). PDF/A-3 — already required by this package's own Factur-X/ZUGFeRD pipeline (see "E-invoicing" below) — has no exemption for standard fonts; every font actually used must be embedded. Fixed by vendoring `LiberationSans-Regular.ttf`/`LiberationSans-Bold.ttf` (SIL OFL-1.1, metrically identical to Helvetica/Arial — no layout shift) under `src/assets/fonts/`, registered once via `Font.register()` in the new `fonts.ts` (imported for its side effect by both PDF modules), with every `fontFamily: "Helvetica"`/`"Helvetica-Bold"` replaced by `fontFamily: "Liberation Sans"` (+ `fontWeight: "bold"` where the bold variant was used). Verified by inspecting the raw PDF bytes of a generated fixture: `/FontFile2` present, `/BaseFont` shows subset tags (e.g. `CWOPQE+LiberationSans`), `/Subtype /Type0` — i.e. actually embedded, not just referenced. + ## How this is consumed Not published to npm — installed as a git dependency: @@ -51,6 +53,7 @@ Ships raw TypeScript/TSX source (no build step) via `main`/`types` pointing stra - `formatters.ts` — `formatPrice()`/`formatDate()`, canonical formatting for every document. - `invoicePdf.tsx` — the original invoice ("Rechnung"): `InvoiceDocument`, `renderInvoicePdf()`, plus `SAMPLE_INVOICE_ORDER` (used by the frontend's Payload Live Preview for company-settings). - `correctionInvoicePdf.tsx` — Stornorechnung/Gutschrift: `renderCorrectionInvoicePdf()`. +- `fonts.ts` — registers the embedded Liberation Sans font (`src/assets/fonts/`) once for both PDF modules — see the 2026-07-28 changelog entry above. - `seller.ts` — the shared `InvoiceSeller` type both document types render in their footer. - `einvoice/` — the ZUGFeRD/Factur-X layer (see "E-invoicing" below). diff --git a/package.json b/package.json index 7c3377e..9df16cc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@einfach-produktiv/invoicing", - "version": "0.2.5", + "version": "0.2.6", "private": true, "description": "Shared invoice / correction-invoice (Stornorechnung, Gutschrift) PDF generation and VAT-breakdown math, consumed as a git dependency by both the einfach-produktiv frontend and the payload backend — not published to npm.", "type": "module", diff --git a/src/assets/fonts/LICENSE-OFL.txt b/src/assets/fonts/LICENSE-OFL.txt new file mode 100644 index 0000000..18ec71b --- /dev/null +++ b/src/assets/fonts/LICENSE-OFL.txt @@ -0,0 +1,130 @@ +Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ +Upstream-Name: Liberation Fonts +Source: https://github.com/liberationfonts + +Files: * +Copyright: + Digitized data copyright (c) 2010 Google Corporation with Reserved Font Arimo, Tinos and Cousine. + Copyright (c) 2012 Red Hat, Inc. with Reserved Font Name Liberation. +License: SIL-OFL-1.1 + This Font Software is licensed under the SIL Open Font License, + Version 1.1. + . + This license is copied below, and is also available with a FAQ at: + http://scripts.sil.org/OFL + . + SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 + . + PREAMBLE The goals of the Open Font License (OFL) are to stimulate + worldwide development of collaborative font projects, to support the font + creation efforts of academic and linguistic communities, and to provide + a free and open framework in which fonts may be shared and improved in + partnership with others. + . + The OFL allows the licensed fonts to be used, studied, modified and + redistributed freely as long as they are not sold by themselves. + The fonts, including any derivative works, can be bundled, embedded, + redistributed and/or sold with any software provided that any reserved + names are not used by derivative works. The fonts and derivatives, + however, cannot be released under any other type of license. The + requirement for fonts to remain under this license does not apply to + any document created using the fonts or their derivatives. + . + . + . + DEFINITIONS + "Font Software" refers to the set of files released by the Copyright + Holder(s) under this license and clearly marked as such. + This may include source files, build scripts and documentation. + . + "Reserved Font Name" refers to any names specified as such after the + copyright statement(s). + . + "Original Version" refers to the collection of Font Software components + as distributed by the Copyright Holder(s). + . + "Modified Version" refers to any derivative made by adding to, deleting, + or substituting ? in part or in whole ? + any of the components of the Original Version, by changing formats or + by porting the Font Software to a new environment. + . + "Author" refers to any designer, engineer, programmer, technical writer + or other person who contributed to the Font Software. + . + . + PERMISSION & CONDITIONS + . + Permission is hereby granted, free of charge, to any person obtaining a + copy of the Font Software, to use, study, copy, merge, embed, modify, + redistribute, and sell modified and unmodified copies of the Font + Software, subject to the following conditions: + . + 1) Neither the Font Software nor any of its individual components,in + Original or Modified Versions, may be sold by itself. + . + 2) Original or Modified Versions of the Font Software may be bundled, + redistributed and/or sold with any software, provided that each copy + contains the above copyright notice and this license. These can be + included either as stand-alone text files, human-readable headers or + in the appropriate machine-readable metadata fields within text or + binary files as long as those fields can be easily viewed by the user. + . + 3) No Modified Version of the Font Software may use the Reserved Font + Name(s) unless explicit written permission is granted by the + corresponding Copyright Holder. This restriction only applies to the + primary font name as presented to the users. + . + 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font + Software shall not be used to promote, endorse or advertise any + Modified Version, except to acknowledge the contribution(s) of the + Copyright Holder(s) and the Author(s) or with their explicit written + permission. + . + 5) The Font Software, modified or unmodified, in part or in whole, must + be distributed entirely under this license, and must not be distributed + under any other license. The requirement for fonts to remain under + this license does not apply to any document created using the Font + Software. + . + . + . + TERMINATION + This license becomes null and void if any of the above conditions are not met. + . + . + . + DISCLAIMER + THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT + OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE + COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL + DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER + DEALINGS IN THE FONT SOFTWARE. + . + +Files: debian/* +Copyright: + 2008 Alan Baghumian + 2008-2018 Holger Levsen + 2009-2012 Christian Perrier + 2011-2023 Fabian Greffrath + 2018 Rene Engelhard +License: GPL-2+ + This package is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + . + This package is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + . + You should have received a copy of the GNU General Public License + along with this program. If not, see + . + On Debian systems, the complete text of the GNU General + Public License version 2 can be found in "/usr/share/common-licenses/GPL-2". diff --git a/src/assets/fonts/LiberationSans-Bold.ttf b/src/assets/fonts/LiberationSans-Bold.ttf new file mode 100644 index 0000000..58c1be7 Binary files /dev/null and b/src/assets/fonts/LiberationSans-Bold.ttf differ diff --git a/src/assets/fonts/LiberationSans-Regular.ttf b/src/assets/fonts/LiberationSans-Regular.ttf new file mode 100644 index 0000000..328ef8b Binary files /dev/null and b/src/assets/fonts/LiberationSans-Regular.ttf differ diff --git a/src/correctionInvoicePdf.tsx b/src/correctionInvoicePdf.tsx index be6a102..8bbb58c 100644 --- a/src/correctionInvoicePdf.tsx +++ b/src/correctionInvoicePdf.tsx @@ -3,6 +3,7 @@ import { Document, Page, View, Text, Image, StyleSheet, renderToBuffer } from "@ import { formatPrice, formatDate } from "./formatters"; import { computeTaxBreakdown } from "./taxBreakdown"; import type { InvoiceSeller } from "./seller"; +import "./fonts"; export type { InvoiceSeller }; @@ -29,15 +30,15 @@ export type { InvoiceSeller }; // simplest defensible policy — no attempt to re-split a percent/fixed // discount across a partial return). // -// Built-in Helvetica, not a registered web font — same reasoning as -// invoicePdf.tsx. +// Liberation Sans (registered by the "./fonts" import above), not built-in +// Helvetica — same reasoning as invoicePdf.tsx. const BRAND = "#f6a701"; const TEXT_MUTED = "#6b6b69"; const BORDER = "#e5e0d8"; const BG_MUTED = "#f8f5f1"; const styles = StyleSheet.create({ - page: { padding: 0, fontSize: 10, fontFamily: "Helvetica", color: "#1a1a18" }, + page: { padding: 0, fontSize: 10, fontFamily: "Liberation Sans", color: "#1a1a18" }, // A rule, not a filled band — a bold brand-colored line rather than a // plain 1pt gray divider. headerBand: { @@ -49,8 +50,8 @@ const styles = StyleSheet.create({ borderBottomWidth: 3, borderBottomColor: BRAND, }, - wordmark: { fontFamily: "Helvetica-Bold", fontSize: 14 }, - kindLabel: { fontFamily: "Helvetica-Bold", fontSize: 22, color: BRAND, letterSpacing: 1 }, + wordmark: { fontFamily: "Liberation Sans", fontWeight: "bold", fontSize: 14 }, + kindLabel: { fontFamily: "Liberation Sans", fontWeight: "bold", fontSize: 22, color: BRAND, letterSpacing: 1 }, body: { padding: 32, paddingBottom: 90 }, refLine: { fontSize: 10, color: TEXT_MUTED, marginBottom: 24 }, addressRow: { flexDirection: "row", justifyContent: "space-between", marginBottom: 24 }, @@ -60,7 +61,7 @@ const styles = StyleSheet.create({ metaRow: { flexDirection: "row", gap: 10, marginBottom: 24 }, metaBox: { borderWidth: 1, borderColor: BORDER, borderRadius: 6, paddingVertical: 8, paddingHorizontal: 12 }, metaLabel: { fontSize: 7, color: TEXT_MUTED, textTransform: "uppercase", marginBottom: 2 }, - metaValue: { fontSize: 10, fontFamily: "Helvetica-Bold" }, + metaValue: { fontSize: 10, fontFamily: "Liberation Sans", fontWeight: "bold" }, table: { borderRadius: 6, overflow: "hidden", borderWidth: 1, borderColor: BORDER, marginBottom: 16 }, tableHeader: { flexDirection: "row", backgroundColor: BG_MUTED, paddingVertical: 8, paddingHorizontal: 10 }, // Same uniform tinted-row background as invoicePdf.tsx now — no more @@ -82,8 +83,8 @@ const styles = StyleSheet.create({ summaryLabel: { fontSize: 10, color: TEXT_MUTED }, summaryValue: { fontSize: 10 }, grandTotalRow: { flexDirection: "row", justifyContent: "space-between", paddingTop: 8, marginTop: 6, borderTopWidth: 1, borderTopColor: BORDER }, - grandTotalLabel: { fontSize: 12, fontFamily: "Helvetica-Bold" }, - grandTotalValue: { fontSize: 12, fontFamily: "Helvetica-Bold" }, + grandTotalLabel: { fontSize: 12, fontFamily: "Liberation Sans", fontWeight: "bold" }, + grandTotalValue: { fontSize: 12, fontFamily: "Liberation Sans", fontWeight: "bold" }, // Netto, right under Gesamt — same as invoicePdf.tsx's own row. nettoRow: { flexDirection: "row", justifyContent: "space-between", paddingVertical: 2, marginTop: 6 }, // Same "enthält X% MwSt." annotation-under-Gesamt framing as diff --git a/src/fonts.ts b/src/fonts.ts new file mode 100644 index 0000000..694e2da --- /dev/null +++ b/src/fonts.ts @@ -0,0 +1,21 @@ +import { Font } from "@react-pdf/renderer"; + +// Registered once here (not inline in invoicePdf.tsx/correctionInvoicePdf.tsx) +// and imported by both for its side effect — avoids double-registering the +// same family when renderEInvoice.ts imports both PDF modules in the same +// process. +// +// Liberation Sans, not built-in Helvetica: PDF/A-3 (required by the +// Factur-X/ZUGFeRD pipeline in src/einvoice/) has no exemption for the +// standard/base-14 fonts react-pdf ships — every font actually used must be +// embedded, and react-pdf only embeds fonts it has real font-file bytes for +// via Font.register. Liberation Sans is metrically identical to Helvetica/ +// Arial (SIL Open Font License, see src/assets/fonts/LICENSE-OFL.txt), so +// embedding it doesn't shift any existing layout. +Font.register({ + family: "Liberation Sans", + fonts: [ + { src: new URL("./assets/fonts/LiberationSans-Regular.ttf", import.meta.url).pathname, fontWeight: "normal" }, + { src: new URL("./assets/fonts/LiberationSans-Bold.ttf", import.meta.url).pathname, fontWeight: "bold" }, + ], +}); diff --git a/src/invoicePdf.tsx b/src/invoicePdf.tsx index 8510cd4..6822fb9 100644 --- a/src/invoicePdf.tsx +++ b/src/invoicePdf.tsx @@ -3,6 +3,7 @@ import { Document, Page, View, Text, Image, StyleSheet, renderToBuffer } from "@ import { formatPrice, formatDate } from "./formatters"; import { computeTaxBreakdown } from "./taxBreakdown"; import type { InvoiceSeller } from "./seller"; +import "./fonts"; export type { InvoiceSeller }; @@ -11,9 +12,10 @@ export type { InvoiceSeller }; // same render call both times, so a re-download always matches what was // emailed. // -// Built-in Helvetica, not a registered web font — this can render inside a -// fire-and-forget email step; a font-fetch failure there is one more way -// to lose the invoice attachment for no real design benefit. +// Liberation Sans (registered by the "./fonts" import above), not built-in +// Helvetica — PDF/A-3 requires every font actually used to be embedded, and +// there's no exemption for react-pdf's standard/base-14 fonts. See +// fonts.ts's own comment for the full reasoning. const BRAND = "#f6a701"; const TEXT_MUTED = "#6b6b69"; const BORDER = "#e5e0d8"; @@ -21,7 +23,7 @@ const BG_MUTED = "#f8f5f1"; const SUCCESS = "#2f8f4e"; const styles = StyleSheet.create({ - page: { padding: 0, fontSize: 10, fontFamily: "Helvetica", color: "#1a1a18" }, + page: { padding: 0, fontSize: 10, fontFamily: "Liberation Sans", color: "#1a1a18" }, // A rule, not a filled band — a bold brand-colored line with a thin // muted second line underneath, rather than a plain 1pt gray divider. headerBand: { @@ -34,8 +36,8 @@ const styles = StyleSheet.create({ borderBottomColor: BRAND, }, headerRuleThin: { height: 1, backgroundColor: BORDER, marginHorizontal: 32 }, - wordmark: { fontFamily: "Helvetica-Bold", fontSize: 14 }, - kindLabel: { fontFamily: "Helvetica-Bold", fontSize: 22, color: BRAND, letterSpacing: 1 }, + wordmark: { fontFamily: "Liberation Sans", fontWeight: "bold", fontSize: 14 }, + kindLabel: { fontFamily: "Liberation Sans", fontWeight: "bold", fontSize: 22, color: BRAND, letterSpacing: 1 }, body: { padding: 32, paddingBottom: 90 }, addressRow: { flexDirection: "row", flexWrap: "wrap", justifyContent: "space-between", rowGap: 12, marginBottom: 24 }, addressBlock: { width: "45%" }, @@ -48,8 +50,8 @@ const styles = StyleSheet.create({ metaRow: { flexDirection: "row", gap: 10, marginBottom: 16, flexWrap: "wrap" }, metaBox: { borderWidth: 1, borderColor: BORDER, borderRadius: 6, paddingVertical: 8, paddingHorizontal: 12 }, metaLabel: { fontSize: 7, color: TEXT_MUTED, textTransform: "uppercase", marginBottom: 2 }, - metaValue: { fontSize: 10, fontFamily: "Helvetica-Bold" }, - paidBadgeText: { fontSize: 10, fontFamily: "Helvetica-Bold", color: SUCCESS }, + metaValue: { fontSize: 10, fontFamily: "Liberation Sans", fontWeight: "bold" }, + paidBadgeText: { fontSize: 10, fontFamily: "Liberation Sans", fontWeight: "bold", color: SUCCESS }, // Same plain-text treatment as paidBadgeText (no pill/box, see that // style's own comment) — muted, not an error/warning color, since an // unpaid Vorkasse invoice isn't a problem, just a pending, expected @@ -98,8 +100,8 @@ const styles = StyleSheet.create({ summaryLabel: { fontSize: 10, color: TEXT_MUTED }, summaryValue: { fontSize: 10 }, grandTotalRow: { flexDirection: "row", justifyContent: "space-between", paddingTop: 8, marginTop: 6, borderTopWidth: 1, borderTopColor: BORDER }, - grandTotalLabel: { fontSize: 12, fontFamily: "Helvetica-Bold" }, - grandTotalValue: { fontSize: 12, fontFamily: "Helvetica-Bold" }, + grandTotalLabel: { fontSize: 12, fontFamily: "Liberation Sans", fontWeight: "bold" }, + grandTotalValue: { fontSize: 12, fontFamily: "Liberation Sans", fontWeight: "bold" }, // Netto, right under Gesamt — a bit of top margin to read as its own // line following the total, not crowded against the divider above it. nettoRow: { flexDirection: "row", justifyContent: "space-between", paddingVertical: 2, marginTop: 6 },