write_pdfa2a.py

Die Python-Datei des Beispiels „Eine getaggte Mitteilung zur Tarifänderung in PDF/A-2a“. Eine Mitteilung nach dem zweiten Teil der Archivnorm auf seiner höchsten Stufe: Vorlesesoftware folgt dem Text in der Reihenfolge, in der er gelesen werden soll.

Python 383 Zeilen

Wofür dieses Beispiel da ist

Eine Mitteilung über einen neuen Tarif muss jeden Kunden erreichen, auch die, denen eine Software die Seite vorliest, und eine öffentliche Stelle muss sie womöglich jahrelang aufbewahren. Eine einzige Datei muss beides leisten: Bestand haben und in der richtigen Reihenfolge gelesen werden.

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
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
"""Writes a notice of a rate change claiming the second part of the archival
standard, at level A.

Level A is the one that asks the page to say what it is made of: which run
of words is the heading, which are paragraphs, and which marks are no part
of the reading at all. A screen reader follows that tree; a page that only
looks right has nothing for it to follow.

The rule and the page number at the foot are marked as what they are —
furniture, not text — so nothing reading the document aloud says them.

Whether the result really is PDF/A-2A is not for us to say. `veraPDF` says,
and `scripts/check_archival.sh` asks it.

The words of the page and of its metadata are held in `Words`, once per language,
and `HQF_PDF_LANG` picks which set the record is written in. The tree names the same
language, so a screen reader reads the page in it.

Usage: `python examples/write_pdfa2a.py [out.pdf] [font.ttf]`
       `HQF_PDF_LANG=fr python examples/write_pdfa2a.py`
"""

from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path

import _language
import _licence
import _out

import hqf_pdf

# When the record was drawn up. The library never reads the clock, so the caller says
# when.
ISSUED = "2026-04-02T08:00:00+02:00"

# Who the file says wrote it, and what produced it, in every language alike.
AUTHOR = "HQF Development"
PRODUCER = "hqf-pdf-server"

@dataclass(frozen=True)
class Words:
    """Every word the notice is written in, in one language."""

    # What the file says it is, in its metadata.
    title: str
    # What the file says it is about, in its metadata.
    subject: str
    # The language the tree says the page is written in, which a screen reader reads it
    # in.
    lang: str
    # The heading, tagged `H1`.
    heading: str
    # The two paragraphs under the heading, each tagged `P`.
    paragraphs: tuple[str, str]
    # The page number at the foot, marked as furniture.
    foot: str


# The notice in English.
ENGLISH = Words(
    title="Notice of rate change, April 2026",
    subject="A record that says what it is made of, and keeps its looks for good",
    lang="en-GB",
    heading="Notice of rate change",
    paragraphs=(
        (
            "This notice claims the second part of the archival standard at its "
            "highest level. On top of the promise that the page keeps its looks, and "
            "on top of every mark of ink saying which letter it stands for, the file "
            "says what it is made of: this line is a heading, that block is a "
            "paragraph, and the rule across the foot is furniture that says nothing at "
            "all.\nSomething reading the notice aloud follows the order the tree gives "
            "rather than the order the ink was laid down in, and reading software on a "
            "telephone has something to reflow."
        ),
        (
            "The new rate applies to every order acknowledged on or after 1 May 2026. "
            "Orders already acknowledged stand at the rate they were acknowledged at."
        ),
    ),
    foot="Page 1 of 1",
)


# The notice in French.
FRENCH = Words(
    title="Avis de changement de tarif, avril 2026",
    subject=(
        "Un document qui dit de quoi il est fait et garde durablement son apparence"
    ),
    lang="fr-FR",
    heading="Avis de changement de tarif",
    paragraphs=(
        (
            ("Cet avis déclare suivre la deuxième partie de la norme d’archivage, à "
             "son niveau le plus exigeant. En plus de la promesse que la page garde "
             "son apparence, et de chaque trace d’encre qui dit quelle lettre elle "
             "représente, le fichier dit de quoi il est fait : cette ligne est un "
             "titre, ce bloc est un paragraphe, et le filet en pied de page est un "
             "ornement qui ne dit rien.\nUn logiciel qui lit l’avis à voix haute suit "
             "l’ordre que donne l’arbre de structure, et non celui dans lequel l’encre"
             " a été posée ; sur un téléphone, une application de lecture a de quoi "
             "recomposer le texte.")
        ),
        (
            "Le nouveau tarif s’applique à toute commande confirmée à partir du 1er "
            "mai 2026. Les commandes déjà confirmées restent au tarif en vigueur lors "
            "de leur confirmation."
        ),
    ),
    foot="Page 1 sur 1",
)


# The notice in German.
GERMAN = Words(
    title="Mitteilung über eine Preisänderung, April 2026",
    subject=(
        ("Ein Dokument, das angibt, woraus es besteht, und sein Aussehen dauerhaft "
         "behält")
    ),
    lang="de-DE",
    heading="Mitteilung über eine Preisänderung",
    paragraphs=(
        (
            ("Diese Mitteilung erklärt Konformität mit dem zweiten Teil der "
             "Archivnorm, auf dessen höchster Stufe. Zugesagt ist, dass die Seite ihr "
             "Aussehen behält und jede Druckspur angibt, für welchen Buchstaben sie "
             "steht; darüber hinaus gibt die Datei an, woraus sie besteht: Diese Zeile"
             " ist eine Überschrift, jener Block ein Absatz, und die Linie am Fuß ist "
             "Zierrat ohne jede Aussage.\nEin Vorleseprogramm folgt der Ordnung des "
             "Strukturbaums statt der Reihenfolge, in der die Tinte aufgetragen wurde,"
             " und eine Lese-App auf dem Smartphone kann den Text neu umbrechen.")
        ),
        (
            "Der neue Preis gilt für jede Bestellung, die ab dem 1. Mai 2026 bestätigt "
            "wird. Bereits bestätigte Bestellungen bleiben bei dem Preis, zu dem sie "
            "bestätigt wurden."
        ),
    ),
    foot="Seite 1 von 1",
)


# The notice in Spanish.
SPANISH = Words(
    title="Aviso de cambio de tarifa, abril de 2026",
    subject=(
        "Un documento que dice de qué está hecho y conserva su aspecto para siempre"
    ),
    lang="es-ES",
    heading="Aviso de cambio de tarifa",
    paragraphs=(
        (
            ("Este aviso declara cumplir la segunda parte de la norma de archivo en su"
             " nivel más alto. Además de prometer que la página conserva su aspecto y "
             "que cada trazo de tinta dice qué letra representa, el fichero dice de "
             "qué está hecho: esta línea es un título, ese bloque es un párrafo y el "
             "filete del pie es un adorno que no dice nada.\nUn programa que lea el "
             "aviso en voz alta sigue el orden que da el árbol, no el orden en que se "
             "puso la tinta, y una aplicación de lectura en el móvil tiene con qué "
             "recomponer el texto.")
        ),
        (
            ("La nueva tarifa se aplica a todo pedido confirmado a partir del 1 de "
             "mayo de 2026. Los pedidos ya confirmados mantienen la tarifa vigente en "
             "el momento de su confirmación.")
        ),
    ),
    foot="Página 1 de 1",
)


# The notice in Italian.
ITALIAN = Words(
    title="Avviso di variazione tariffaria, aprile 2026",
    subject=(
        "Un documento che dice di che cosa è fatto e conserva il suo aspetto per sempre"
    ),
    lang="it-IT",
    heading="Avviso di variazione tariffaria",
    paragraphs=(
        (
            ("Quest’avviso dichiara di rispettare la seconda parte della norma di "
             "archiviazione al suo livello più alto. Oltre alla promessa che la pagina"
             " conservi il suo aspetto e che ogni segno d’inchiostro dica quale "
             "lettera rappresenta, il file dice di che cosa è fatto: questa riga è un "
             "titolo, quel blocco è un paragrafo e il filetto a piè di pagina è un "
             "ornamento che non dice nulla.\nUna sintesi vocale legge l’avviso "
             "seguendo l’ordine dato dall’albero della struttura, non quello in cui è "
             "stato steso l’inchiostro, e un’app di lettura sul telefono può "
             "reimpaginare il testo.")
        ),
        (
            "La nuova tariffa si applica a ogni ordine confermato dal 1° maggio 2026 "
            "in poi. Gli ordini già confermati restano alla tariffa in vigore al "
            "momento della conferma."
        ),
    ),
    foot="Pagina 1 di 1",
)


# The notice in Portuguese.
PORTUGUESE = Words(
    title="Aviso de alteração de tarifa, abril de 2026",
    subject="Um documento que diz de que é feito e mantém o seu aspeto para sempre",
    lang="pt-PT",
    heading="Aviso de alteração de tarifa",
    paragraphs=(
        (
            ("Este aviso declara cumprir a segunda parte da norma de arquivo, no seu "
             "nível mais alto. Além da promessa de que a página mantém o seu aspeto, e"
             " de que cada traço de tinta diz que letra representa, o ficheiro diz de "
             "que é feito: esta linha é um título, aquele bloco é um parágrafo e o "
             "filete do rodapé é um ornamento que nada diz.\nUm programa que lê o "
             "aviso em voz alta segue a ordem dada pela árvore, e não a ordem em que a"
             " tinta foi posta, e uma aplicação de leitura no telemóvel tem com que "
             "recompor o texto.")
        ),
        (
            "A nova tarifa aplica-se a todas as encomendas confirmadas a partir de 1 "
            "de maio de 2026. As encomendas já confirmadas mantêm a tarifa em vigor à "
            "data da confirmação."
        ),
    ),
    foot="Página 1 de 1",
)


# The notice in Dutch.
DUTCH = Words(
    title="Bericht van tariefwijziging, april 2026",
    subject=(
        "Een document dat zegt waaruit het bestaat en zijn uiterlijk voorgoed behoudt"
    ),
    lang="nl-NL",
    heading="Bericht van tariefwijziging",
    paragraphs=(
        (
            ("Dit bericht verklaart te voldoen aan het tweede deel van de archiefnorm,"
             " op het hoogste niveau. Naast de belofte dat de pagina haar uiterlijk "
             "behoudt en dat elk inktspoor aangeeft voor welke letter het staat, "
             "vertelt het bestand waaruit het bestaat: deze regel is een kop, dat blok"
             " is een alinea en de lijn onderaan is opsmuk die niets zegt.\nEen "
             "voorleesprogramma houdt de volgorde van de boom aan en niet die waarin "
             "de inkt is neergezet, en een leesapp op een telefoon kan de tekst "
             "herschikken.")
        ),
        (
            ("Het nieuwe tarief geldt voor elke bestelling die vanaf 1 mei 2026 wordt "
             "bevestigd. Voor al bevestigde bestellingen blijft het tarief gelden "
             "waartegen ze zijn bevestigd.")
        ),
    ),
    foot="Pagina 1 van 1",
)

# 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,
}


# Where each block of what the page says goes, in the order it is read: the tag it
# stands under, the size it is set at, the box it goes in, and the leading where it
# states one. The blocks say the heading and the two paragraphs of `Words`, in that
# order.
READ = (
    ("H1", 20.0, 72.0, 782.0, 451.0, None),
    ("P", 11.0, 72.0, 730.0, 451.0, 16.0),
    ("P", 11.0, 72.0, 580.0, 451.0, 16.0),
)

# The rule at the foot of the page, and the box of the line under it. Neither is part of
# the reading, so both are marked as furniture.
RULE = (72.0, 70.0, 451.0, 1.0)
FOOT = (9.0, 72.0, 68.0, 451.0)


def foot_of_page(
    content: hqf_pdf.Content, handle: hqf_pdf.FontHandle, text: str
) -> None:
    """Draws the rule and the page number, both marked as furniture so that
    nothing reading the document aloud says them."""
    left, low, width, height = RULE
    content.begin_artifact(hqf_pdf.Artifact.Footer)
    content.save_state()
    # Rust says this as `set_fill_rgb`; Python takes the colour itself.
    content.set_fill(hqf_pdf.Rgb(0.0, 0.0, 0.0))
    content.rect(left, low, width, height)
    content.fill()
    content.restore_state()
    content.end_marked()

    size, left, top, width = FOOT
    flow = hqf_pdf.TextFlow(
        handle, size, first_baseline=hqf_pdf.FirstBaseline.points(0.0)
    )
    lines = flow.break_lines(text, width)
    content.begin_artifact(hqf_pdf.Artifact.Footer)
    # The Python side opens and closes the text object itself.
    flow.draw(content, lines, left, top, width)
    content.end_marked()


def sheet(handle: hqf_pdf.FontHandle, words: Words):
    """The one page, and the branch of the tree that says what is on it."""
    content = hqf_pdf.Content()
    marks = hqf_pdf.Marks()
    branches = []

    said = (words.heading, words.paragraphs[0], words.paragraphs[1])
    for (tag, size, left, top, width, leading), text in zip(READ, said):
        settings = {} if leading is None else {"leading": leading}
        flow = hqf_pdf.TextFlow(
            handle,
            size,
            first_baseline=hqf_pdf.FirstBaseline.points(0.0),
            **settings,
        )
        lines = flow.break_lines(text, width)
        runs = flow.draw_tagged(content, lines, left, top, width, tag, marks)
        branches.append(
            hqf_pdf.StructElement(tag, children=[(0, run) for run in runs])
        )

    foot_of_page(content, handle, words.foot)

    page = hqf_pdf.Page.a4()
    page.set_content(content)
    return page, hqf_pdf.StructElement("Sect", children=branches)


def main() -> None:
    """Writes the file, and says where it went."""
    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
    # languages do not overwrite each other in `tmp/`.
    out = _out.output_path(Path(_language.file_name("pdfa2a.pdf", language)).stem)

    document = hqf_pdf.Document()
    document.set_license(_licence.licensed())
    document.set_conformance(hqf_pdf.PdfA.A2A)
    document.set_metadata(
        hqf_pdf.Metadata(
            title=words.title,
            author=AUTHOR,
            subject=words.subject,
            producer=PRODUCER,
            created=ISSUED,
        )
    )

    handle = document.add_font(hqf_pdf.Font.from_path(_out.font_path()))
    page, sect = sheet(handle, words)
    document.add_page(page)
    document.set_structure(
        hqf_pdf.StructureTree(
            children=[hqf_pdf.StructElement("Document", children=[sect])],
            lang=words.lang,
        )
    )

    data = document.to_bytes()
    out.parent.mkdir(parents=True, exist_ok=True)
    out.write_bytes(data)
    print(f"wrote {out}: {len(data)} bytes")


if __name__ == "__main__":
    main()