write_overlay.py

Die Python-Datei des Beispiels „Eine auf eine Briefkopfvorlage gelegte Rechnung“. Eine Rechnung, über einen als PDF-Datei gelieferten Briefkopf gelegt.

Python 251 Zeilen

Wofür dieses Beispiel da ist

Jeder Kunde hat seinen eigenen Briefkopf, und keiner davon schickt Ihnen die Schriften, die Logodatei und die genaue Farbe, in der er gedruckt wurde. Was er Ihnen schickt, ist ein PDF davon. Diesen Briefkopf von Hand nachzubauen heißt, dass er fast richtig herauskommt — auf jedem Dokument, das Sie für ihn erzeugen, solange er Kunde ist.

Was dieses Beispiel zeigt

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
"""Lays an invoice on a letterhead somebody else made.

The Python twin of the `write_overlay` example in Rust. The letterhead is a PDF:
it is read, brought in as a form, drawn under the invoice, and nothing about it
is rewritten. Under the invoice, the page lists what the letterhead says, read back
off it: each run of text with where it begins and the size it is set at.

The invoice's labels, and the one label the stand-in letterhead carries, are held in
`Words`, once per language, and `HQF_PDF_LANG` picks which set is drawn. The leader
dots are counted rather than typed, so a longer label in another language still ends
its amount on the same character.

Usage: python examples/write_overlay.py [out.pdf] [letterhead.pdf]
       HQF_PDF_LANG=fr python examples/write_overlay.py
"""

from __future__ import annotations

import sys
from dataclasses import dataclass
from pathlib import Path

import _language
import _licence
import _out

import hqf_pdf

# How many characters a billed line runs to: its label, a space, the leader dots, a
# space, then its amount. The dots take whatever is left, so the amounts end on the same
# character however long the label before them is.
LINE_CHARS = 56

# The invoice's number, and what each billed line comes to. Neither is language.
NUMBER = "1964413"
AMOUNTS = ("4 250.00 EUR", "850.00 EUR", "5 100.00 EUR")

# The letterhead's own name, address and registration number. None of them is language:
# they belong to whoever the letterhead is for.
COMPANY = "ACME Ltd"
COMPANY_ADDRESS = "12 Fleet Street — London EC4Y 1AA"
COMPANY_NUMBER = "123 456 789"

# The size each of the five invoice lines is set at, in the order they are laid down.
SIZES = (16.0, 11.0, 11.0, 11.0, 12.0)


@dataclass(frozen=True)
class Words:
    """Every word the invoice draws, in one language.

    What is not language stays out of it: the invoice's number, the three amounts, and
    the letterhead's name, address and number.
    """

    # What stands on the letterhead before its registration number.
    company_number_label: str
    # What stands before the invoice's number.
    number_label: str
    # The line that gives the day the invoice was issued.
    issued: str
    # What each billed line is for, in the order they are set.
    items: tuple[str, str, str]
    # What stands above the runs read back off the letterhead.
    read_back: str
    # What stands for a run whose characters the letterhead's file does not state.
    unread: str

    def lines(self) -> list[str]:
        """The five lines the invoice sets, in order.

        Each billed line is padded out with leader dots until it runs to `LINE_CHARS`.
        """
        lines = [f"{self.number_label} {NUMBER}", self.issued]
        for item, amount in zip(self.items, AMOUNTS):
            dots = max(0, LINE_CHARS - len(item) - len(amount) - 2)
            lines.append(f"{item} {'.' * dots} {amount}")
        return lines

    def letterhead_line(self) -> str:
        """The second line of the stand-in letterhead: an address, then the number the
        company registers under, under whatever this language calls it."""
        return f"{COMPANY_ADDRESS} — {self.company_number_label} {COMPANY_NUMBER}"


# The invoice in English.
ENGLISH = Words(
    company_number_label="Company No.",
    number_label="Invoice No.",
    issued="Issued 14 July 2026",
    items=("Rendering engine development", "VAT at 20 %", "Total due"),
    read_back="What the letterhead says, read back off its page:",
    unread="(a run whose characters the file does not state)",
)

# The invoice in French.
FRENCH = Words(
    company_number_label="N° d’entreprise",
    number_label="Facture n°",
    issued="Émise le 14 juillet 2026",
    items=("Développement du moteur de rendu", "TVA 20 %", "Total à payer"),
    read_back="Ce que dit le papier à en-tête, relu sur sa page\u00a0:",
    unread="(un passage dont le fichier ne donne pas les caractères)",
)

# The invoice in Dutch.
DUTCH = Words(
    company_number_label="KvK-nr.",
    number_label="Factuur nr.",
    issued="Uitgegeven op 14 juli 2026",
    items=("Ontwikkeling van de opmaakmotor", "Btw 20 %", "Te betalen"),
    read_back="Wat het briefpapier zegt, teruggelezen van de pagina:",
    unread="(een stuk tekst waarvan het bestand de tekens niet vermeldt)",
)

# The invoice in Portuguese.
PORTUGUESE = Words(
    company_number_label="NIPC",
    number_label="Fatura n.º",
    issued="Emitida a 14 de julho de 2026",
    items=("Desenvolvimento do motor de paginação", "IVA a 20 %", "Total a pagar"),
    read_back="O que diz o papel timbrado, relido na sua página:",
    unread="(um trecho cujos caracteres o ficheiro não indica)",
)

# The invoice in Italian.
ITALIAN = Words(
    company_number_label="N. REA",
    number_label="Fattura n.",
    issued="Emessa il 14 luglio 2026",
    items=("Sviluppo del motore di impaginazione", "IVA al 20 %", "Totale dovuto"),
    read_back="Ciò che dice la carta intestata, riletto sulla sua pagina:",
    unread="(un tratto di cui il file non indica i caratteri)",
)

# The invoice in Spanish.
SPANISH = Words(
    company_number_label="N.º de empresa",
    number_label="Factura n.º",
    issued="Emitida el 14 de julio de 2026",
    items=("Desarrollo del motor de maquetación", "IVA al 20 %", "Total a pagar"),
    read_back="Lo que dice el membrete, leído de nuevo en su página:",
    unread="(un fragmento cuyos caracteres el archivo no indica)",
)

# The invoice in German.
GERMAN = Words(
    company_number_label="Handelsregister Nr.",
    number_label="Rechnung Nr.",
    issued="Ausgestellt am 14. Juli 2026",
    items=("Entwicklung der Rendering-Engine", "MwSt. 20 %", "Zu zahlen"),
    read_back="Was der Briefkopf sagt, von seiner Seite zurückgelesen:",
    unread="(ein Abschnitt, dessen Zeichen die Datei nicht angibt)",
)


# Every language the example is written in. A language is added by writing its own set
# of words and naming it here.
WORDS = {_language.ENGLISH: ENGLISH, _language.FRENCH: FRENCH, _language.GERMAN: GERMAN, _language.SPANISH: SPANISH, _language.ITALIAN: ITALIAN, _language.PORTUGUESE: PORTUGUESE, _language.DUTCH: DUTCH}


def a_letterhead(words: Words) -> bytes:
    """A letterhead, for when the caller has none to hand."""
    doc = hqf_pdf.Document()
    doc.set_license(_licence.licensed())
    font = doc.add_font(hqf_pdf.Font.from_path(_out.DEFAULT_FONT))

    content = hqf_pdf.Content()
    content.save_state()
    content.set_fill(hqf_pdf.Rgb(0.91, 0.94, 0.98))
    content.rect(0.0, 742.0, 595.276, 100.0)
    content.fill()
    content.set_fill(hqf_pdf.Rgb(0.2, 0.35, 0.6))
    content.rect(0.0, 738.0, 595.276, 4.0)
    content.fill()
    content.restore_state()

    content.draw_text(font, 22.0, 56.0, 786.0, COMPANY)
    content.draw_text(font, 9.0, 56.0, 764.0, words.letterhead_line())

    page = hqf_pdf.Page.a4()
    page.set_content(content)
    doc.add_page(page)
    return doc.to_bytes()


def run_line(run: hqf_pdf.TextRun, words: Words) -> str:
    """One run read back off the letterhead, as the page lists it: its text, then where
    it begins and the size it is set at, where the file says so."""
    text = run.text if run.text is not None else words.unread
    if run.origin is None or run.size is None:
        return text
    x, y = run.origin
    return f"{text} · {x:.0f}, {y:.0f} · {run.size:.0f} pt"


def main() -> None:
    language = _language.from_environment()
    words = _language.words_of(WORDS, language)

    # A named file is written as named; the default one carries the language, so the two
    # languages do not overwrite each other in `tmp/`.
    out = _out.output_path(Path(_language.file_name("overlay.pdf", language)).stem)

    # The letterhead: the caller's, or one made here to stand in for it.
    if len(sys.argv) > 2:
        letterhead = open(sys.argv[2], "rb").read()
    else:
        letterhead = a_letterhead(words)

    reader = hqf_pdf.Reader(letterhead)
    print(f"the letterhead has {reader.page_count} page(s)")

    doc = hqf_pdf.Document()
    doc.set_license(_licence.licensed())

    template = doc.import_page(reader, 0)
    print(f"imported it: {template.width:.0f} × {template.height:.0f} points")

    font = doc.add_font(hqf_pdf.Font.from_path(_out.DEFAULT_FONT))

    content = hqf_pdf.Content()
    content.draw_form(template)

    top = 680.0
    for line, size in zip(words.lines(), SIZES):
        content.draw_text(font, size, 56.0, top, line)
        top -= size * 2.0

    # What the letterhead says, read back off its page, under a line of its own: each
    # run with where it begins and the size it is set at.
    said = reader.page_text(0)
    read_back = [(11.0, words.read_back)]
    read_back.extend((9.0, run_line(run, words)) for run in said.runs)
    top -= 12.0
    for size, line in read_back:
        content.draw_text(font, size, 56.0, top, line)
        print(line)
        top -= size * 1.6

    page = hqf_pdf.Page(template.width, template.height)
    page.set_content(content)
    template.add_to(page)
    doc.add_page(page)

    written = doc.write(out)
    print(f"wrote {out}: {written} bytes")


if __name__ == "__main__":
    main()