Embed Liberation Sans in invoice PDFs for PDF/A-3 compliance (v0.2.6)
Validate e-invoices / mustang (push) Successful in 41s

An E-Rechnung checker flagged that generated invoices don't embed their
fonts. Both invoicePdf.tsx and correctionInvoicePdf.tsx used react-pdf's
built-in Helvetica/Helvetica-Bold, which is never embedded — PDF/A-3
(already required by this package's own Factur-X/ZUGFeRD pipeline) has
no exemption for standard fonts. Vendors Liberation Sans (SIL OFL-1.1,
metrically identical to Helvetica) and registers it via Font.register in
a new fonts.ts, imported by both PDF modules.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Marco
2026-07-28 23:37:28 +00:00
parent bcdf8d2889
commit d6e83db5cf
8 changed files with 176 additions and 19 deletions
+3
View File
@@ -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).
+1 -1
View File
@@ -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",
+130
View File
@@ -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 <alan@technotux.org>
2008-2018 Holger Levsen <holger@debian.org>
2009-2012 Christian Perrier <bubulle@debian.org>
2011-2023 Fabian Greffrath <fabian@debian.org>
2018 Rene Engelhard <rene@debian.org>
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 <https://www.gnu.org/licenses/>
.
On Debian systems, the complete text of the GNU General
Public License version 2 can be found in "/usr/share/common-licenses/GPL-2".
Binary file not shown.
Binary file not shown.
+9 -8
View File
@@ -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
+21
View File
@@ -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" },
],
});
+12 -10
View File
@@ -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 },