Embed Liberation Sans in invoice PDFs for PDF/A-3 compliance (v0.2.6)
Validate e-invoices / mustang (push) Successful in 41s
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:
@@ -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
@@ -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",
|
||||
|
||||
@@ -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.
@@ -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
|
||||
|
||||
@@ -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
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user