quints 0.6.0__tar.gz → 0.7.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. {quints-0.6.0 → quints-0.7.0}/PKG-INFO +1 -1
  2. {quints-0.6.0 → quints-0.7.0}/pyproject.toml +1 -1
  3. quints-0.7.0/src/quints/invoice/fonts/LICENSE +102 -0
  4. quints-0.7.0/src/quints/invoice/fonts/LiberationSans-Bold.ttf +0 -0
  5. quints-0.7.0/src/quints/invoice/fonts/LiberationSans-Regular.ttf +0 -0
  6. quints-0.7.0/src/quints/invoice/fonts/README.md +15 -0
  7. {quints-0.6.0 → quints-0.7.0}/src/quints/invoice/model.py +37 -3
  8. {quints-0.6.0 → quints-0.7.0}/src/quints/invoice/qr.py +7 -1
  9. {quints-0.6.0 → quints-0.7.0}/src/quints/invoice/render.py +43 -11
  10. quints-0.7.0/src/quints/invoice/template.typ +184 -0
  11. {quints-0.6.0 → quints-0.7.0}/tests/test_invoice.py +116 -0
  12. quints-0.6.0/src/quints/invoice/template.typ +0 -140
  13. {quints-0.6.0 → quints-0.7.0}/.gitignore +0 -0
  14. {quints-0.6.0 → quints-0.7.0}/LICENSE +0 -0
  15. {quints-0.6.0 → quints-0.7.0}/README.md +0 -0
  16. {quints-0.6.0 → quints-0.7.0}/examples/.gitignore +0 -0
  17. {quints-0.6.0 → quints-0.7.0}/examples/AGENTS.md +0 -0
  18. {quints-0.6.0 → quints-0.7.0}/examples/CLAUDE.md +0 -0
  19. {quints-0.6.0 → quints-0.7.0}/examples/accounts.bean +0 -0
  20. {quints-0.6.0 → quints-0.7.0}/examples/answers.toml +0 -0
  21. {quints-0.6.0 → quints-0.7.0}/examples/books/2026.bean +0 -0
  22. {quints-0.6.0 → quints-0.7.0}/examples/commodities.bean +0 -0
  23. {quints-0.6.0 → quints-0.7.0}/examples/documents/.gitkeep +0 -0
  24. {quints-0.6.0 → quints-0.7.0}/examples/inbox/.gitkeep +0 -0
  25. {quints-0.6.0 → quints-0.7.0}/examples/invoicing/acme-2026-07.yaml +0 -0
  26. {quints-0.6.0 → quints-0.7.0}/examples/invoicing/customers.yaml +0 -0
  27. {quints-0.6.0 → quints-0.7.0}/examples/invoicing/globex-2026-08.yaml +0 -0
  28. {quints-0.6.0 → quints-0.7.0}/examples/invoicing/issuer.yaml +0 -0
  29. {quints-0.6.0 → quints-0.7.0}/examples/main.bean +0 -0
  30. {quints-0.6.0 → quints-0.7.0}/examples/prices.bean +0 -0
  31. {quints-0.6.0 → quints-0.7.0}/examples/pyproject.toml +0 -0
  32. {quints-0.6.0 → quints-0.7.0}/examples/quints.toml +0 -0
  33. {quints-0.6.0 → quints-0.7.0}/examples/staging/.gitkeep +0 -0
  34. {quints-0.6.0 → quints-0.7.0}/examples/statements/ubs-2026.mt940 +0 -0
  35. {quints-0.6.0 → quints-0.7.0}/examples/statements/yapeal.csv +0 -0
  36. {quints-0.6.0 → quints-0.7.0}/src/quints/__init__.py +0 -0
  37. {quints-0.6.0 → quints-0.7.0}/src/quints/cli.py +0 -0
  38. {quints-0.6.0 → quints-0.7.0}/src/quints/config.py +0 -0
  39. {quints-0.6.0 → quints-0.7.0}/src/quints/fava/__init__.py +0 -0
  40. {quints-0.6.0 → quints-0.7.0}/src/quints/fava/templates/QuintDashboard.html +0 -0
  41. {quints-0.6.0 → quints-0.7.0}/src/quints/fx.py +0 -0
  42. {quints-0.6.0 → quints-0.7.0}/src/quints/importing.py +0 -0
  43. {quints-0.6.0 → quints-0.7.0}/src/quints/inbox.py +0 -0
  44. {quints-0.6.0 → quints-0.7.0}/src/quints/init.py +0 -0
  45. {quints-0.6.0 → quints-0.7.0}/src/quints/invoice/__init__.py +0 -0
  46. {quints-0.6.0 → quints-0.7.0}/src/quints/invoice/draft.py +0 -0
  47. {quints-0.6.0 → quints-0.7.0}/src/quints/invoice/labels.py +0 -0
  48. {quints-0.6.0 → quints-0.7.0}/src/quints/invoice/vatid.py +0 -0
  49. {quints-0.6.0 → quints-0.7.0}/src/quints/invoice/verify.py +0 -0
  50. {quints-0.6.0 → quints-0.7.0}/src/quints/kmu.py +0 -0
  51. {quints-0.6.0 → quints-0.7.0}/src/quints/ledger.py +0 -0
  52. {quints-0.6.0 → quints-0.7.0}/src/quints/match.py +0 -0
  53. {quints-0.6.0 → quints-0.7.0}/src/quints/mwst.py +0 -0
  54. {quints-0.6.0 → quints-0.7.0}/src/quints/plugins/__init__.py +0 -0
  55. {quints-0.6.0 → quints-0.7.0}/src/quints/plugins/kmu.py +0 -0
  56. {quints-0.6.0 → quints-0.7.0}/src/quints/prices.py +0 -0
  57. {quints-0.6.0 → quints-0.7.0}/src/quints/receivables.py +0 -0
  58. {quints-0.6.0 → quints-0.7.0}/src/quints/report.typ +0 -0
  59. {quints-0.6.0 → quints-0.7.0}/src/quints/report_pdf.py +0 -0
  60. {quints-0.6.0 → quints-0.7.0}/src/quints/settlement.py +0 -0
  61. {quints-0.6.0 → quints-0.7.0}/src/quints/ui.py +0 -0
  62. {quints-0.6.0 → quints-0.7.0}/src/quints/vat.py +0 -0
  63. {quints-0.6.0 → quints-0.7.0}/tests/fixtures/transactions.mt940 +0 -0
  64. {quints-0.6.0 → quints-0.7.0}/tests/test_cli_json.py +0 -0
  65. {quints-0.6.0 → quints-0.7.0}/tests/test_config.py +0 -0
  66. {quints-0.6.0 → quints-0.7.0}/tests/test_docs.py +0 -0
  67. {quints-0.6.0 → quints-0.7.0}/tests/test_fava_ext.py +0 -0
  68. {quints-0.6.0 → quints-0.7.0}/tests/test_fx.py +0 -0
  69. {quints-0.6.0 → quints-0.7.0}/tests/test_importing.py +0 -0
  70. {quints-0.6.0 → quints-0.7.0}/tests/test_inbox_match.py +0 -0
  71. {quints-0.6.0 → quints-0.7.0}/tests/test_init.py +0 -0
  72. {quints-0.6.0 → quints-0.7.0}/tests/test_kmu_plugin.py +0 -0
  73. {quints-0.6.0 → quints-0.7.0}/tests/test_kmu_report.py +0 -0
  74. {quints-0.6.0 → quints-0.7.0}/tests/test_ledger.py +0 -0
  75. {quints-0.6.0 → quints-0.7.0}/tests/test_mwst.py +0 -0
  76. {quints-0.6.0 → quints-0.7.0}/tests/test_prices_ecosystem.py +0 -0
  77. {quints-0.6.0 → quints-0.7.0}/tests/test_prices_sync.py +0 -0
  78. {quints-0.6.0 → quints-0.7.0}/tests/test_receivables.py +0 -0
  79. {quints-0.6.0 → quints-0.7.0}/tests/test_report_pdf.py +0 -0
  80. {quints-0.6.0 → quints-0.7.0}/tests/test_settlement.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: quints
3
- Version: 0.6.0
3
+ Version: 0.7.0
4
4
  Summary: Swiss VAT & accounting toolkit for plain-text (beancount) books: MWST reports and quarterly settlement, Bezugsteuer helpers, BAZG daily FX rates, QR-bill invoicing, KMU statutory statements, and statement importers (UBS MT940, Yapeal, Wise, Stripe) that draft into a staging area
5
5
  License: GPL-2.0-only
6
6
  License-File: LICENSE
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "quints"
3
- version = "0.6.0"
3
+ version = "0.7.0"
4
4
  description = "Swiss VAT & accounting toolkit for plain-text (beancount) books: MWST reports and quarterly settlement, Bezugsteuer helpers, BAZG daily FX rates, QR-bill invoicing, KMU statutory statements, and statement importers (UBS MT940, Yapeal, Wise, Stripe) that draft into a staging area"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -0,0 +1,102 @@
1
+ Digitized data copyright (c) 2010 Google Corporation
2
+ with Reserved Font Arimo, Tinos and Cousine.
3
+ Copyright (c) 2012 Red Hat, Inc.
4
+ with Reserved Font Name Liberation.
5
+
6
+ This Font Software is licensed under the SIL Open Font License,
7
+ Version 1.1.
8
+
9
+ This license is copied below, and is also available with a FAQ at:
10
+ http://scripts.sil.org/OFL
11
+
12
+ SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
13
+
14
+ PREAMBLE The goals of the Open Font License (OFL) are to stimulate
15
+ worldwide development of collaborative font projects, to support the font
16
+ creation efforts of academic and linguistic communities, and to provide
17
+ a free and open framework in which fonts may be shared and improved in
18
+ partnership with others.
19
+
20
+ The OFL allows the licensed fonts to be used, studied, modified and
21
+ redistributed freely as long as they are not sold by themselves.
22
+ The fonts, including any derivative works, can be bundled, embedded,
23
+ redistributed and/or sold with any software provided that any reserved
24
+ names are not used by derivative works. The fonts and derivatives,
25
+ however, cannot be released under any other type of license. The
26
+ requirement for fonts to remain under this license does not apply to
27
+ any document created using the fonts or their derivatives.
28
+
29
+
30
+
31
+ DEFINITIONS
32
+ "Font Software" refers to the set of files released by the Copyright
33
+ Holder(s) under this license and clearly marked as such.
34
+ This may include source files, build scripts and documentation.
35
+
36
+ "Reserved Font Name" refers to any names specified as such after the
37
+ copyright statement(s).
38
+
39
+ "Original Version" refers to the collection of Font Software components
40
+ as distributed by the Copyright Holder(s).
41
+
42
+ "Modified Version" refers to any derivative made by adding to, deleting,
43
+ or substituting ? in part or in whole ?
44
+ any of the components of the Original Version, by changing formats or
45
+ by porting the Font Software to a new environment.
46
+
47
+ "Author" refers to any designer, engineer, programmer, technical writer
48
+ or other person who contributed to the Font Software.
49
+
50
+
51
+ PERMISSION & CONDITIONS
52
+
53
+ Permission is hereby granted, free of charge, to any person obtaining a
54
+ copy of the Font Software, to use, study, copy, merge, embed, modify,
55
+ redistribute, and sell modified and unmodified copies of the Font
56
+ Software, subject to the following conditions:
57
+
58
+ 1) Neither the Font Software nor any of its individual components,in
59
+ Original or Modified Versions, may be sold by itself.
60
+
61
+ 2) Original or Modified Versions of the Font Software may be bundled,
62
+ redistributed and/or sold with any software, provided that each copy
63
+ contains the above copyright notice and this license. These can be
64
+ included either as stand-alone text files, human-readable headers or
65
+ in the appropriate machine-readable metadata fields within text or
66
+ binary files as long as those fields can be easily viewed by the user.
67
+
68
+ 3) No Modified Version of the Font Software may use the Reserved Font
69
+ Name(s) unless explicit written permission is granted by the
70
+ corresponding Copyright Holder. This restriction only applies to the
71
+ primary font name as presented to the users.
72
+
73
+ 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
74
+ Software shall not be used to promote, endorse or advertise any
75
+ Modified Version, except to acknowledge the contribution(s) of the
76
+ Copyright Holder(s) and the Author(s) or with their explicit written
77
+ permission.
78
+
79
+ 5) The Font Software, modified or unmodified, in part or in whole, must
80
+ be distributed entirely under this license, and must not be distributed
81
+ under any other license. The requirement for fonts to remain under
82
+ this license does not apply to any document created using the Font
83
+ Software.
84
+
85
+
86
+
87
+ TERMINATION
88
+ This license becomes null and void if any of the above conditions are not met.
89
+
90
+
91
+
92
+ DISCLAIMER
93
+ THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
94
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
95
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
96
+ OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
97
+ COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
98
+ INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
99
+ DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
100
+ FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER
101
+ DEALINGS IN THE FONT SOFTWARE.
102
+
@@ -0,0 +1,15 @@
1
+ # Bundled fallback fonts
2
+
3
+ Liberation Sans, always added to the Typst font search path.
4
+
5
+ It carries two guarantees the invoice template would otherwise only get by
6
+ luck, from whatever the rendering machine happens to have installed:
7
+
8
+ - `Brand.font` defaults to Liberation Sans, and the template names it as the
9
+ fallback behind every issuer-configured family.
10
+ - The Swiss QR-bill Implementation Guidelines permit only Arial, Frutiger,
11
+ Helvetica and Liberation Sans in the payment part. Liberation Sans is the
12
+ one of the four that can be licensed and shipped, and it is metric-
13
+ compatible with Arial, which the `qrbill` library sizes its text boxes for.
14
+
15
+ Liberation Fonts 2.1.5, SIL Open Font License 1.1 — see `LICENSE`.
@@ -14,10 +14,17 @@ import sys
14
14
  from datetime import date
15
15
  from decimal import ROUND_HALF_UP, Decimal
16
16
  from pathlib import Path
17
- from typing import Any, Literal
17
+ from typing import Annotated, Any, Literal
18
18
 
19
19
  import yaml
20
- from pydantic import BaseModel, Field, RootModel, field_validator, model_validator
20
+ from pydantic import (
21
+ BaseModel,
22
+ Field,
23
+ RootModel,
24
+ StringConstraints,
25
+ field_validator,
26
+ model_validator,
27
+ )
21
28
 
22
29
  if sys.version_info >= (3, 11):
23
30
  import tomllib
@@ -249,13 +256,40 @@ class BankAccount(BaseModel):
249
256
  return v.replace(" ", "")
250
257
 
251
258
 
259
+ # Every hex form Typst's `rgb()` takes: RGB, RGBA, RRGGBB, RRGGBBAA. `accent`
260
+ # used to be a bare `str` handed straight to `rgb()`, so anything shorthand that
261
+ # rendered before still loads; only strings Typst would have rejected anyway are
262
+ # caught here, and now at config-load time instead of mid-render.
263
+ HexColor = Annotated[
264
+ str, StringConstraints(pattern=r"^#([0-9A-Fa-f]{3,4}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$")
265
+ ]
266
+
267
+
252
268
  class Brand(BaseModel):
253
- accent: str = "#123c3a"
269
+ """Typography and colour tokens the template renders with.
270
+
271
+ The colour defaults reproduce the neutral greyscale the template used
272
+ before tokens existed, so an issuer config that sets none of them keeps
273
+ its previous look. An issuer with a palette overrides them all.
274
+ """
275
+
276
+ accent: HexColor = "#123c3a"
254
277
  font: str = "Liberation Sans"
255
278
  font_display: str | None = None # title/wordmark family; defaults to `font`
256
279
  font_display_stretch: int = 100 # CSS-style font-stretch % (125 → Expanded cut)
280
+ # Title weight. Not every display family ships every cut — Mona Sans
281
+ # Expanded starts at Medium, so asking for "regular" silently drops back to
282
+ # the normal-width face; a serif title usually wants "regular".
283
+ font_display_weight: Literal["regular", "medium", "semibold", "bold"] = "semibold"
284
+ font_mono: str | None = None # figures, IBAN, reference; defaults to `font`
257
285
  font_dir: str | None = None # bundled fonts dir passed to typst (repo-relative)
258
286
  logo: str | None = None
287
+ logo_height: float = 12.0 # mm, as placed in the header
288
+
289
+ ink: HexColor = "#141414" # body copy
290
+ subtle: HexColor = "#696969" # labels and secondary text
291
+ rule: HexColor = "#d2d2d2" # hairlines
292
+ panel: HexColor = "#f6f6f6" # fill behind bounded blocks
259
293
 
260
294
 
261
295
  class Issuer(BaseModel):
@@ -52,7 +52,7 @@ def build_bill(inv: Invoice, issuer: Issuer, account: BankAccount, grand: Decima
52
52
  raise ValueError(f"Swiss QR-bill supports only CHF or EUR, not {currency!r}")
53
53
  with warnings.catch_warnings():
54
54
  warnings.simplefilter("ignore")
55
- return QRBill(
55
+ bill = QRBill(
56
56
  account=account.qr_iban or account.iban,
57
57
  creditor=_structured(issuer.name, issuer.address, issuer.country),
58
58
  debtor=_structured(customer.name, customer.address, customer.country),
@@ -61,6 +61,12 @@ def build_bill(inv: Invoice, issuer: Issuer, account: BankAccount, grand: Decima
61
61
  reference_number=reference_for(inv, account),
62
62
  additional_information=f"{inv.number}",
63
63
  )
64
+ # The Implementation Guidelines allow Arial, Frutiger, Helvetica and
65
+ # Liberation Sans in the payment part, and nothing else. qrbill asks for
66
+ # Arial/Helvetica only, which are system faces — an issuer that bundles
67
+ # fonts has system faces switched off, so name the OFL one it can ship.
68
+ bill.font_family = "Arial,Helvetica,Liberation Sans"
69
+ return bill
64
70
 
65
71
 
66
72
  def write_svg(bill: QRBill, path: Path) -> None:
@@ -18,6 +18,10 @@ from .labels import labels as get_labels
18
18
  from .model import Invoice, Issuer, Totals, compute, make_scor, money, number
19
19
 
20
20
  TEMPLATE = Path(__file__).parent / "template.typ"
21
+ # Liberation Sans, always on the search path: the default `Brand.font`, the
22
+ # template's last-resort fallback, and the only face the QR-bill payment part
23
+ # is allowed to use that we can ship. See fonts/README.md.
24
+ BUNDLED_FONTS = Path(__file__).parent / "fonts"
21
25
 
22
26
 
23
27
  class InvoiceContext(TypedDict):
@@ -36,7 +40,7 @@ class InvoiceContext(TypedDict):
36
40
  totals: dict[str, str | bool]
37
41
  payment: Mapping[str, str | None]
38
42
  notes: list[str]
39
- brand: dict[str, str | int | None]
43
+ brand: dict[str, str | float | None]
40
44
 
41
45
 
42
46
  def _fmt_iban(iban: str) -> str:
@@ -113,20 +117,45 @@ def build_context(
113
117
  "font": issuer.brand.font,
114
118
  "font_display": issuer.brand.font_display or issuer.brand.font,
115
119
  "display_stretch": issuer.brand.font_display_stretch,
120
+ "display_weight": issuer.brand.font_display_weight,
121
+ "font_mono": issuer.brand.font_mono or issuer.brand.font,
122
+ "ink": issuer.brand.ink,
123
+ "subtle": issuer.brand.subtle,
124
+ "rule": issuer.brand.rule,
125
+ "panel": issuer.brand.panel,
116
126
  "logo": logo,
127
+ "logo_height": issuer.brand.logo_height,
117
128
  },
118
129
  }
119
130
 
120
131
 
121
- def _compile(main: Path, output: Path, root: Path, font_paths: list[Path]) -> None:
122
- """Compile to PDF/A-2b (archival) when supported, else a plain PDF."""
132
+ def _compile(
133
+ main: Path, output: Path, root: Path, font_paths: list[Path], only_bundled: bool
134
+ ) -> None:
135
+ """Compile to PDF/A-2b (archival) when supported, else a plain PDF.
136
+
137
+ `only_bundled` switches machine-installed families off, and is set when the
138
+ issuer ships their own font directory. Without it, a variable font installed
139
+ on the designer's machine shadows the bundled static cuts of the same family
140
+ and every weight silently collapses to the variable font's default instance,
141
+ so the same invoice renders differently on a colleague's machine. Issuers on
142
+ the default font keep system fonts, since their families may live there.
143
+ """
123
144
  fonts = [str(p) for p in font_paths]
124
- try:
125
- typst.compile(
126
- str(main), output=str(output), root=str(root), font_paths=fonts, pdf_standards="a-2b"
127
- )
128
- except (TypeError, ValueError): # older typst-py, or standard unsupported
129
- typst.compile(str(main), output=str(output), root=str(root), font_paths=fonts)
145
+ base: dict[str, object] = {"output": str(output), "root": str(root), "font_paths": fonts}
146
+ # Most capable call first; drop the options an older typst-py may not know.
147
+ attempts = [
148
+ {**base, "ignore_system_fonts": only_bundled, "pdf_standards": "a-2b"},
149
+ {**base, "pdf_standards": "a-2b"},
150
+ base,
151
+ ]
152
+ for i, opts in enumerate(attempts):
153
+ try:
154
+ typst.compile(str(main), **opts) # type: ignore[arg-type]
155
+ return
156
+ except (TypeError, ValueError):
157
+ if i == len(attempts) - 1:
158
+ raise
130
159
 
131
160
 
132
161
  def render(inv: Invoice, issuer: Issuer, out_path: Path) -> tuple[Path, Totals, str | None]:
@@ -184,11 +213,14 @@ def render(inv: Invoice, issuer: Issuer, out_path: Path) -> tuple[Path, Totals,
184
213
  shutil.copy(TEMPLATE, work / "template.typ")
185
214
 
186
215
  font_dir = issuer.brand.font_dir
187
- font_paths = [Path(font_dir)] if font_dir and Path(font_dir).is_dir() else []
216
+ issuer_fonts = [Path(font_dir)] if font_dir and Path(font_dir).is_dir() else []
217
+ # Ours first so the guaranteed fallback is always resolvable; the
218
+ # issuer's own families win on name, not on search order.
219
+ font_paths = [BUNDLED_FONTS, *issuer_fonts]
188
220
 
189
221
  out_path = Path(out_path)
190
222
  out_path.parent.mkdir(parents=True, exist_ok=True)
191
- _compile(work / "template.typ", out_path, work, font_paths)
223
+ _compile(work / "template.typ", out_path, work, font_paths, bool(issuer_fonts))
192
224
  return out_path, totals, qr_payload
193
225
  finally:
194
226
  shutil.rmtree(work, ignore_errors=True)
@@ -0,0 +1,184 @@
1
+ // quints invoice template — data-driven (reads data.json), two modes.
2
+ //
3
+ // Everything issuer-specific arrives through `data.json`: no family name, hex
4
+ // colour or asset path is hard-coded here. The brand tokens are:
5
+ // accent — the one saturated colour: title, rules that lead, the amount due
6
+ // ink — body copy subtle — labels and secondary text
7
+ // rule — hairlines panel — fill behind bounded blocks
8
+ // and three families: `font` (body), `font_display` (title), `font_mono` —
9
+ // every figure, IBAN and reference. Set `font_mono` to a real monospace and the
10
+ // amount columns align on the digit; left unset it follows the body face.
11
+ #let d = json("data.json")
12
+ #let L = d.labels
13
+ #let accent = rgb(d.brand.accent)
14
+ #let ink = rgb(d.brand.ink)
15
+ #let subtle = rgb(d.brand.subtle)
16
+ #let rule = rgb(d.brand.rule)
17
+ #let panel = rgb(d.brand.panel)
18
+ #let body = (d.brand.font, "Liberation Sans", "Arial")
19
+ #let display = (d.brand.font_display, d.brand.font, "Liberation Sans", "Arial")
20
+ #let mono = (d.brand.font_mono, d.brand.font, "Liberation Mono", "Courier New")
21
+ #let lines(a) = a.join(linebreak())
22
+ #let qrbill = d.payment.type == "qrbill"
23
+
24
+ // Display face. `stretch` is only passed when the issuer actually asks for a
25
+ // width other than normal: a font with no wdth axis fails to match against an
26
+ // explicit stretch and silently falls back to the body face.
27
+ #let disp(it, size: 10pt, fill: ink) = text(
28
+ font: display, size: size, weight: d.brand.display_weight, fill: fill,
29
+ ..(if d.brand.display_stretch != 100 { (stretch: d.brand.display_stretch * 1%) } else { (:) }),
30
+ )[#it]
31
+
32
+ // Small tracked capitals: every label in the document, at one size.
33
+ #let label(it) = text(size: 7pt, weight: "medium", tracking: 0.1em, fill: subtle)[#upper(it)]
34
+ // Figures. Tabular by construction — the mono family keeps columns on the digit.
35
+ #let fig(it, weight: "regular", fill: ink, size: 9.5pt) = text(
36
+ font: mono, size: size, weight: weight, fill: fill,
37
+ )[#it]
38
+
39
+ #set document(title: L.invoice + " " + d.invoice.number, author: d.issuer.name)
40
+ #set page(
41
+ paper: "a4",
42
+ margin: (x: 20mm, top: 16mm, bottom: if qrbill { 108mm } else { 24mm }),
43
+ )
44
+ #set text(font: body, size: 9.5pt, fill: ink)
45
+ #set par(justify: false, leading: 0.65em)
46
+
47
+ // ── Header: wordmark left, issuer identity right (Art. 26 Abs. 2 lit. a MWSTG) ─
48
+ #grid(columns: (1fr, auto), column-gutter: 10mm,
49
+ align(left + top,
50
+ if d.brand.logo != none {
51
+ image(d.brand.logo, height: d.brand.logo_height * 1mm)
52
+ } else {
53
+ disp(d.issuer.name, size: 17pt)
54
+ }
55
+ ),
56
+ align(right + top)[
57
+ #set text(size: 7.5pt, fill: subtle)
58
+ #set par(leading: 0.55em)
59
+ #text(fill: ink, weight: "medium")[#d.issuer.name] \
60
+ #lines(d.issuer.address) \
61
+ #d.issuer.vat_id
62
+ #if d.issuer.email != none [ \ #link("mailto:" + d.issuer.email)[#d.issuer.email] ]
63
+ #if d.issuer.phone != none [ \ #d.issuer.phone ]
64
+ ],
65
+ )
66
+ #v(4mm)
67
+ #line(length: 100%, stroke: 0.5pt + rule)
68
+
69
+ // ── Recipient (right, window-envelope position) ───────────────────────
70
+ #place(top + left, dx: 112mm, dy: 30mm, block(width: 78mm)[
71
+ #set par(leading: 0.6em)
72
+ #text(weight: "medium")[#d.customer.name] \
73
+ #lines(d.customer.address)
74
+ #if d.customer.country != d.issuer.country [ \ #d.customer.country ]
75
+ #if d.customer.vat_id != none [ \ #d.customer.vat_id ]
76
+ ])
77
+
78
+ // Clears the recipient block placed above; the QR-bill leaves only ~173mm of
79
+ // body height, so the rhythm below stays deliberately tight.
80
+ #v(17mm)
81
+
82
+ // ── Title + meta ──────────────────────────────────────────────────────
83
+ #disp(L.invoice, size: 25pt, fill: accent)
84
+ #v(4mm)
85
+ #grid(columns: (auto, auto), column-gutter: 7mm, row-gutter: 4pt,
86
+ align(horizon, label(L.invoice_no)), fig(d.invoice.number, weight: "medium"),
87
+ align(horizon, label(L.date)), fig(d.invoice.issue_date),
88
+ ..(if d.invoice.supply != "" {
89
+ (align(horizon, label(L.supply)), text(size: 9.5pt)[#d.invoice.supply])
90
+ } else { () }),
91
+ )
92
+ #v(8mm)
93
+
94
+ // ── Line items ────────────────────────────────────────────────────────
95
+ #table(
96
+ columns: (auto, 1fr, auto, auto, auto),
97
+ inset: (x: 0pt, y: 7pt),
98
+ column-gutter: 6mm,
99
+ align: (x, y) => if x >= 2 { right } else { left },
100
+ stroke: none,
101
+ table.hline(stroke: 1pt + accent),
102
+ table.header(
103
+ label(L.pos), label(L.description), label(L.qty), label(L.unit_price), label(L.line_total),
104
+ ),
105
+ table.hline(stroke: 0.5pt + rule),
106
+ ..d.items.map(it => (
107
+ fig(it.pos, fill: subtle, size: 9pt),
108
+ [#it.description],
109
+ fig[#it.quantity#if it.unit != "" [ #it.unit]],
110
+ fig(it.unit_price),
111
+ fig(it.total),
112
+ )).flatten(),
113
+ table.hline(stroke: 0.5pt + rule),
114
+ )
115
+
116
+ // ── Totals ────────────────────────────────────────────────────────────
117
+ #v(4mm)
118
+ #align(right, block(width: 76mm)[
119
+ #let row(l, v) = grid(
120
+ columns: (1fr, auto), column-gutter: 6mm,
121
+ align(horizon, label(l)), v,
122
+ )
123
+ #row(L.subtotal, fig(d.currency + " " + d.totals.subtotal))
124
+ #if not d.totals.export {
125
+ v(3pt)
126
+ row(L.vat + " " + d.totals.vat_rate + "%", fig(d.currency + " " + d.totals.vat_amount))
127
+ }
128
+ #if d.totals.show_rounding {
129
+ v(3pt)
130
+ row(L.rounding, fig(d.currency + " " + d.totals.rounding))
131
+ }
132
+ #v(5pt)
133
+ // The amount due is the one thing a reader looks for: accent on a tinted band.
134
+ #block(fill: panel, inset: (x: 8pt, y: 7pt), width: 100%,
135
+ grid(columns: (1fr, auto), column-gutter: 6mm,
136
+ align(horizon, text(size: 8pt, weight: "medium", tracking: 0.1em, fill: accent)[
137
+ #upper(L.grand_total)
138
+ ]),
139
+ fig(d.currency + " " + d.totals.grand_total, weight: "medium", fill: accent, size: 12pt),
140
+ ),
141
+ )
142
+ ])
143
+
144
+ // ── Export legal notes ────────────────────────────────────────────────
145
+ #if d.totals.export {
146
+ v(6mm)
147
+ block(
148
+ fill: panel, width: 100%,
149
+ inset: (left: 9pt, right: 9pt, y: 8pt), stroke: (left: 1.5pt + accent),
150
+ )[
151
+ #set text(size: 8.5pt)
152
+ #L.export_note
153
+ #if d.reverse_charge [ \ #text(fill: subtle)[#L.reverse_charge] ]
154
+ ]
155
+ }
156
+
157
+ // ── Payment terms + notes ─────────────────────────────────────────────
158
+ #if d.terms != none or d.notes.len() > 0 {
159
+ v(5mm)
160
+ set text(size: 8.5pt, fill: subtle)
161
+ if d.terms != none [ #d.terms \ ]
162
+ for n in d.notes [ #n \ ]
163
+ }
164
+
165
+ // ── Payment ───────────────────────────────────────────────────────────
166
+ #if qrbill {
167
+ // Full-width Swiss QR-bill payment part, flush at the page bottom. Its
168
+ // layout and typography are prescribed by the Implementation Guidelines —
169
+ // it is rendered by qr.py and deliberately left unstyled here.
170
+ place(bottom + left, dx: -20mm, dy: 108mm, image("qrbill.svg", width: 210mm))
171
+ } else {
172
+ v(8mm)
173
+ block(fill: panel, inset: (x: 10pt, y: 9pt), width: 100%)[
174
+ #text(size: 8pt, weight: "medium", tracking: 0.1em, fill: accent)[#upper(L.payment_to)]
175
+ #v(4pt)
176
+ #grid(columns: (auto, 1fr), column-gutter: 7mm, row-gutter: 4pt,
177
+ align(horizon, label("IBAN")), fig(d.payment.iban),
178
+ ..(if d.payment.bic != none {
179
+ (align(horizon, label("BIC")), fig(d.payment.bic))
180
+ } else { () }),
181
+ align(horizon, label(L.reference)), fig(d.payment.reference),
182
+ )
183
+ ]
184
+ }
@@ -304,6 +304,61 @@ def test_reverse_charge_flag_in_context():
304
304
  assert ctx["reverse_charge"] is True
305
305
 
306
306
 
307
+ # ── brand tokens ──────────────────────────────────────────────────────────────
308
+
309
+
310
+ def test_brand_tokens_reach_the_template_context():
311
+ from quints.invoice.model import Brand
312
+ from quints.invoice.render import build_context
313
+
314
+ brand_in = Brand(accent="#6b1f4a", ink="#1c1618", font="Geist", font_mono="Geist Mono")
315
+ inv = _export()
316
+ brand = build_context(inv, ISSUER.model_copy(update={"brand": brand_in}), compute(inv), {})[
317
+ "brand"
318
+ ]
319
+ assert brand["accent"] == "#6b1f4a"
320
+ assert brand["ink"] == "#1c1618"
321
+ assert brand["font_mono"] == "Geist Mono"
322
+ # Every token the template dereferences must be present: Typst fails on a
323
+ # missing dictionary key, and only when that branch of the layout renders.
324
+ assert {
325
+ "subtle",
326
+ "rule",
327
+ "panel",
328
+ "font_display",
329
+ "display_stretch",
330
+ "logo_height",
331
+ } <= set(brand)
332
+
333
+
334
+ def test_brand_font_families_fall_back_to_the_body_face():
335
+ from quints.invoice.model import Brand
336
+ from quints.invoice.render import build_context
337
+
338
+ issuer = ISSUER.model_copy(update={"brand": Brand(font="Mona Sans")})
339
+ inv = _export()
340
+ brand = build_context(inv, issuer, compute(inv), {})["brand"]
341
+ assert brand["font_display"] == "Mona Sans"
342
+ assert brand["font_mono"] == "Mona Sans"
343
+
344
+
345
+ def test_brand_rejects_a_non_hex_colour():
346
+ from quints.invoice.model import Brand
347
+
348
+ for bad in ("tyrian", "#6b1f4", "6b1f4a", "rgb(107,31,74)"):
349
+ with pytest.raises(ValueError):
350
+ Brand(accent=bad)
351
+
352
+
353
+ def test_brand_accepts_every_hex_form_typst_takes():
354
+ """`accent` was an unconstrained `str` handed straight to Typst's `rgb()`.
355
+ Validating it must not reject a shorthand palette that rendered before."""
356
+ from quints.invoice.model import Brand
357
+
358
+ for good in ("#fff", "#fff8", "#6b1f4a", "#6b1f4aff"):
359
+ assert Brand(accent=good).accent == good
360
+
361
+
307
362
  # ── schema ────────────────────────────────────────────────────────────────────
308
363
 
309
364
 
@@ -433,3 +488,64 @@ def test_render_embeds_bundled_font(tmp_path: Path):
433
488
  # body font and the wide title cut must be embedded, not system fallbacks
434
489
  assert b"MonaSans-Regular" in pdf
435
490
  assert b"MonaSansExpanded-SemiBold" in pdf
491
+
492
+
493
+ def test_bundled_fallback_font_is_always_on_the_search_path(tmp_path: Path):
494
+ """Liberation Sans ships with the package: it is the default `Brand.font`,
495
+ the template's last-resort fallback, and the only face the QR-bill payment
496
+ part may use that we can distribute (its guidelines allow four)."""
497
+ from quints.invoice import render
498
+
499
+ out = tmp_path / "inv.pdf"
500
+ render.render(_domestic(), ISSUER, out) # ISSUER sets no font_dir
501
+ assert b"LiberationSans" in out.read_bytes()
502
+
503
+
504
+ def test_qr_bill_keeps_a_permitted_face_without_system_fonts(tmp_path: Path):
505
+ """With system fonts off, Arial and Helvetica are unreachable, and the QR-bill
506
+ payment part may use only those two, Frutiger, or Liberation Sans. It must
507
+ land on the bundled Liberation Sans and not fall through to a Typst default.
508
+
509
+ The bold cut doubles as the regression test for weight shadowing: the
510
+ payment part sets headings bold, so a real `-Bold` face has to be embedded.
511
+ """
512
+ from quints.invoice import render
513
+ from quints.invoice.model import Brand
514
+
515
+ # Pointing font_dir at our own bundle is the smallest issuer that ships
516
+ # fonts, which is what switches machine-installed families off.
517
+ issuer = ISSUER.model_copy(update={"brand": Brand(font_dir=str(render.BUNDLED_FONTS))})
518
+ out = tmp_path / "inv.pdf"
519
+ render.render(_domestic(), issuer, out)
520
+ pdf = out.read_bytes()
521
+ assert b"LiberationSans-Bold" in pdf
522
+ assert b"DejaVu" not in pdf and b"Libertinus" not in pdf
523
+
524
+
525
+ def test_issuer_bundled_fonts_switch_off_machine_fonts(
526
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
527
+ ):
528
+ """Regression: a variable font installed on the rendering machine shadows
529
+ the bundled static cuts of the same family, collapsing every weight to the
530
+ variable default. Only an issuer that ships fonts opts out of system ones —
531
+ issuers on the default font may legitimately keep a family installed."""
532
+ import typst
533
+
534
+ from quints.invoice import render
535
+ from quints.invoice.model import Brand
536
+
537
+ seen: list[object] = []
538
+
539
+ def spy(_main: str, **kwargs: object) -> None:
540
+ seen.append(kwargs.get("ignore_system_fonts"))
541
+ raise ValueError("stop after capturing the first attempt")
542
+
543
+ monkeypatch.setattr(typst, "compile", spy)
544
+
545
+ fonts = render.BUNDLED_FONTS # any real directory will do
546
+ for brand, expected in [(Brand(font_dir=str(fonts)), True), (Brand(), False)]:
547
+ seen.clear()
548
+ issuer = ISSUER.model_copy(update={"brand": brand})
549
+ with pytest.raises(ValueError):
550
+ render.render(_domestic(), issuer, tmp_path / "inv.pdf")
551
+ assert seen[0] is expected
@@ -1,140 +0,0 @@
1
- // quints invoice template — data-driven (reads data.json), two modes.
2
- #let d = json("data.json")
3
- #let L = d.labels
4
- #let accent = rgb(d.brand.accent)
5
- #let display = (d.brand.font_display, d.brand.font, "Liberation Sans", "Arial")
6
- #let dstretch = d.brand.display_stretch * 1%
7
- #let muted = luma(105)
8
- #let lines(a) = a.join(linebreak())
9
- #let qrbill = d.payment.type == "qrbill"
10
-
11
- #set document(title: L.invoice + " " + d.invoice.number, author: d.issuer.name)
12
- #set page(
13
- paper: "a4",
14
- margin: (x: 20mm, top: 18mm, bottom: if qrbill { 108mm } else { 24mm }),
15
- )
16
- #set text(font: (d.brand.font, "Liberation Sans", "Arial"), size: 10pt, fill: luma(20))
17
- #set par(justify: false, leading: 0.62em)
18
-
19
- // ── Header: wordmark left, issuer identity right (Art. 26 Abs. 2 lit. a MWSTG) ─
20
- #grid(columns: (1fr, auto),
21
- align(left + top,
22
- if d.brand.logo != none {
23
- image(d.brand.logo, height: 12mm)
24
- } else {
25
- text(font: display, stretch: dstretch, size: 15pt, weight: "bold", fill: accent)[#d.issuer.name]
26
- }
27
- ),
28
- align(right + top)[
29
- #set text(size: 8pt, fill: muted)
30
- #set par(leading: 0.5em)
31
- #text(weight: "semibold", fill: luma(60))[#d.issuer.name] \
32
- #lines(d.issuer.address) \
33
- #d.issuer.vat_id
34
- #if d.issuer.email != none [ \ #link("mailto:" + d.issuer.email)[#d.issuer.email] ]
35
- #if d.issuer.phone != none [ \ #d.issuer.phone ]
36
- ],
37
- )
38
-
39
- // ── Recipient (right, window-envelope position) ───────────────────────
40
- #place(top + left, dx: 112mm, dy: 34mm, block(width: 78mm)[
41
- #set text(size: 10pt)
42
- #strong(d.customer.name) \
43
- #lines(d.customer.address)
44
- #if d.customer.country != d.issuer.country [ \ #d.customer.country ]
45
- #if d.customer.vat_id != none [ \ #d.customer.vat_id ]
46
- ])
47
-
48
- #v(38mm)
49
-
50
- // ── Title + meta ──────────────────────────────────────────────────────
51
- #text(font: display, stretch: dstretch, size: 20pt, weight: "semibold", fill: accent)[#L.invoice]
52
- #v(3mm)
53
- #grid(columns: (auto, auto), column-gutter: 8mm, row-gutter: 3pt,
54
- text(fill: muted)[#L.invoice_no], text(weight: "bold")[#d.invoice.number],
55
- text(fill: muted)[#L.date], [#d.invoice.issue_date],
56
- ..(if d.invoice.supply != "" { (text(fill: muted)[#L.supply], [#d.invoice.supply]) } else { () }),
57
- )
58
- #v(7mm)
59
-
60
- // ── Line items ────────────────────────────────────────────────────────
61
- #table(
62
- columns: (auto, 1fr, auto, auto, auto),
63
- inset: (x: 4pt, y: 6.5pt),
64
- align: (x, y) => if x >= 2 { right } else { left },
65
- stroke: none,
66
- table.hline(stroke: 0.7pt + accent),
67
- table.header(
68
- text(size: 9pt, fill: accent, weight: "bold")[#L.pos],
69
- text(size: 9pt, fill: accent, weight: "bold")[#L.description],
70
- text(size: 9pt, fill: accent, weight: "bold")[#L.qty],
71
- text(size: 9pt, fill: accent, weight: "bold")[#L.unit_price],
72
- text(size: 9pt, fill: accent, weight: "bold")[#L.line_total],
73
- ),
74
- table.hline(stroke: 0.7pt + accent),
75
- ..d.items.map(it => (
76
- [#it.pos],
77
- [#it.description],
78
- [#it.quantity#if it.unit != "" [ #it.unit]],
79
- [#it.unit_price],
80
- [#it.total],
81
- )).flatten(),
82
- table.hline(stroke: 0.4pt + luma(210)),
83
- )
84
-
85
- // ── Totals ────────────────────────────────────────────────────────────
86
- #v(3mm)
87
- #align(right, block(width: 72mm)[
88
- #let row(l, v, strong: false) = grid(
89
- columns: (1fr, auto), column-gutter: 6mm,
90
- if strong { text(weight: "bold")[#l] } else { text(fill: muted)[#l] },
91
- if strong { text(weight: "bold")[#v] } else [#v],
92
- )
93
- #row(L.subtotal, [#d.currency #d.totals.subtotal])
94
- #if not d.totals.export {
95
- v(2.2pt)
96
- row(L.vat + " " + d.totals.vat_rate + "%", [#d.currency #d.totals.vat_amount])
97
- }
98
- #if d.totals.show_rounding {
99
- v(2.2pt)
100
- row(L.rounding, [#d.currency #d.totals.rounding])
101
- }
102
- #v(4pt)
103
- #line(length: 100%, stroke: 0.5pt + luma(180))
104
- #v(4pt)
105
- #row(L.grand_total, text(fill: accent)[#d.currency #d.totals.grand_total], strong: true)
106
- ])
107
-
108
- // ── Export legal notes ────────────────────────────────────────────────
109
- #if d.totals.export {
110
- v(6mm)
111
- block(fill: luma(246), inset: 8pt, radius: 3pt, width: 100%)[
112
- #text(size: 9pt)[#L.export_note]
113
- #if d.reverse_charge [ \ #text(size: 9pt, fill: muted)[#L.reverse_charge] ]
114
- ]
115
- }
116
-
117
- // ── Payment terms + notes ─────────────────────────────────────────────
118
- #if d.terms != none or d.notes.len() > 0 {
119
- v(6mm)
120
- set text(size: 9pt, fill: muted)
121
- if d.terms != none [ #d.terms \ ]
122
- for n in d.notes [ #n \ ]
123
- }
124
-
125
- // ── Payment ───────────────────────────────────────────────────────────
126
- #if qrbill {
127
- // Full-width Swiss QR-bill payment part, flush at the page bottom.
128
- place(bottom + left, dx: -20mm, dy: 108mm, image("qrbill.svg", width: 210mm))
129
- } else {
130
- v(8mm)
131
- block(stroke: 0.6pt + accent, inset: 10pt, radius: 3pt, width: 100%)[
132
- #text(fill: accent, weight: "bold")[#L.payment_to] \
133
- #v(2pt)
134
- #grid(columns: (auto, 1fr), column-gutter: 6mm, row-gutter: 3pt,
135
- text(fill: muted)[IBAN], [#d.payment.iban],
136
- ..(if d.payment.bic != none { (text(fill: muted)[BIC], [#d.payment.bic]) } else { () }),
137
- text(fill: muted)[#L.reference], [#d.payment.reference],
138
- )
139
- ]
140
- }
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes