write_pdfa2b.py

Die Python-Datei des Beispiels „Ein Betriebsratsprotokoll in PDF/A-2b für dreißig Jahre“. Das Protokoll einer Betriebsratssitzung nach dem zweiten Teil der Archivnorm, auf der Stufe, die nur verspricht, dass die Seite in dreißig Jahren genauso aussieht.

Python 375 Zeilen

Wofür dieses Beispiel da ist

Ein Protokoll wird aufbewahrt, weil eines Tages jemand zeigen muss, was beschlossen wurde, und an diesem Tag muss sich die Datei öffnen lassen und so aussehen wie bei der Unterschrift. Eine Schrift, die dem Rechner von morgen fehlt, eine Farbe, deren Bedeutung niemand mehr angeben kann – und das Protokoll sagt etwas anderes.

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
"""Writes the minutes of a works council claiming the second part of the
archival standard, at level B.

Level B promises one thing and one thing only: the page will look the same
in thirty years as it does today. Every face the page draws with is carried
inside the file, so nothing on it waits on a font that happens to be
installed. It says nothing about the words being searchable — that is level
U — nor about the page saying what it is made of, which is level A.

Whether the result really is PDF/A-2B 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.

Usage: `python examples/write_pdfa2b.py [out.pdf] [font.ttf]`
       `HQF_PDF_LANG=fr python examples/write_pdfa2b.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 — a document that
# stamped itself with the time would be a different file on every build — so the caller
# says when.
ISSUED = "2026-03-12T18:05:00+01: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 minutes 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 heading at the top of the page.
    heading: str
    # The line under the heading saying what the record is filed under.
    reference: str
    # What the page says of the part it claims, in two runs set end to end with a space
    # between them.
    body: tuple[str, str]
    # What each line of the record asks, one a line.
    labels: str
    # What each line of the record answers, beside its label.
    answers: str


# The minutes in English.
ENGLISH = Words(
    title="Minutes of the works council, 12 March 2026",
    subject="A record kept for thirty years, carrying nothing but itself",
    heading="Minutes of the works council",
    reference="Thursday 12 March 2026, 17:00, meeting room 2",
    body=(
        (
            "This sheet claims the second part of the archival standard, at the level "
            "that promises one thing and one thing only: the page will look the same "
            "in thirty years as it does today. Every face it draws with is carried "
            "inside the file, every colour says what it means, and nothing on the page "
            "waits on a program, a network or a font that happens to be installed."
        ),
        (
            "The second part names no carried file the way the third does, so a record "
            "that has to travel with a spreadsheet or an invoice claims the third part "
            "instead."
        ),
    ),
    labels="Present\nApologies\nNext sitting",
    answers=(
        "Nine members of the council and two of the board\nOne member, on "
        "leave\nThursday 9 April 2026, 17:00"
    ),
)


# The minutes in French.
FRENCH = Words(
    title="Procès-verbal du comité social et économique, 12 mars 2026",
    subject=("Un document conservé trente ans, qui ne contient rien d’autre que "
             "lui-même"),
    heading="Procès-verbal du CSE",
    reference="Jeudi 12 mars 2026, 17 h, salle de réunion 2",
    body=(
        (
            ("Cette feuille déclare suivre la deuxième partie de la norme d’archivage,"
             " au niveau qui ne promet qu’une chose : dans trente ans, la page aura le"
             " même aspect qu’aujourd’hui. Chaque police employée est incorporée au "
             "fichier, chaque couleur dit ce qu’elle signifie, et rien ne dépend d’un "
             "programme, d’un réseau ou une police installée par hasard.")
        ),
        (
            ("La deuxième partie n’admet pas les fichiers joints que prévoit la "
             "troisième : un document qui doit voyager avec un tableur ou une facture "
             "se réclame donc de la troisième.")
        ),
    ),
    labels="Présents\nExcusé\nProchaine séance",
    answers=(
        "Neuf membres du comité et deux de la direction\nUn membre, en congé\nJeudi 9 "
        "avril 2026, 17 h"
    ),
)


# The minutes in German.
GERMAN = Words(
    title="Protokoll des Betriebsrats, 12. März 2026",
    subject=(
        ("Ein Dokument, das dreißig Jahre aufbewahrt wird und nichts als sich selbst "
         "enthält")
    ),
    heading="Protokoll des Betriebsrats",
    reference="Donnerstag, 12. März 2026, 17:00 Uhr, Besprechungsraum 2",
    body=(
        (
            ("Dieses Blatt erklärt Konformität mit dem zweiten Teil der Archivnorm, "
             "auf der Stufe, die genau eines zusagt: Die Seite sieht in dreißig Jahren"
             " so aus wie heute. Jede verwendete Schrift steckt in der Datei, jede "
             "Farbe gibt an, was sie bedeutet, und nichts auf der Seite wartet auf ein"
             " Programm, ein Netzwerk oder eine zufällig installierte Schrift.")
        ),
        (
            ("Der zweite Teil sieht mitgeführte Dateien nicht so vor wie der dritte; "
             "ein Dokument, das mit einer Tabelle oder einer Rechnung reisen muss, "
             "erklärt deshalb Konformität mit dem dritten Teil.")
        ),
    ),
    labels="Anwesend\nEntschuldigt\nNächste Sitzung",
    answers=(
        (("Neun Ratsmitglieder und zwei aus der Leitung\nEin Mitglied, im "
          "Urlaub\nDonnerstag, 9. April 2026, 17:00 Uhr"))
    ),
)


# The minutes in Spanish.
SPANISH = Words(
    title="Acta del comité de empresa, 12 de marzo de 2026",
    subject=(
        "Un documento que se conserva treinta años, sin nada adjunto"
    ),
    heading="Acta del comité de empresa",
    reference="Jueves 12 de marzo de 2026, 17:00, sala de reuniones 2",
    body=(
        (
            ("Esta hoja declara cumplir la segunda parte de la norma de archivo, en el"
             " nivel que promete una sola cosa: dentro de treinta años la página se "
             "verá igual que hoy. Cada fuente que usa va dentro del fichero, cada "
             "color dice lo que significa y nada depende de un programa, de una red ni"
             " de una fuente instalada en el equipo.")
        ),
        (
            ("La segunda parte no admite adjuntar ficheros de cualquier tipo, como sí "
             "hace la tercera; por eso un documento que deba viajar con una hoja de "
             "cálculo o una factura declara la tercera.")
        ),
    ),
    labels="Presentes\nExcusados\nPróxima sesión",
    answers=(
        "Nueve miembros del comité y dos de la dirección\nUn miembro, de "
        "permiso\nJueves 9 de abril de 2026, 17:00"
    ),
)


# The minutes in Italian.
ITALIAN = Words(
    title="Verbale del comitato aziendale, 12 marzo 2026",
    subject="Un documento conservato trent’anni, che non contiene altro che sé stesso",
    heading="Verbale del comitato aziendale",
    reference="Giovedì 12 marzo 2026, ore 17:00, sala riunioni 2",
    body=(
        (
            ("Questo foglio dichiara di rispettare la seconda parte della norma di "
             "archiviazione, al livello che promette una cosa sola: tra trent’anni la "
             "pagina avrà lo stesso aspetto di oggi. Ogni carattere usato è contenuto "
             "nel file, ogni colore dice che cosa significa e nulla sulla pagina "
             "dipende da un programma, da una rete o da un font che si trovi "
             "installato.")
        ),
        (
            ("La seconda parte non prevede, come la terza, allegati di qualsiasi tipo:"
             " un documento che deve viaggiare con un foglio di calcolo o una fattura "
             "dichiara quindi la terza.")
        ),
    ),
    labels="Presenti\nGiustificati\nProssima seduta",
    answers=(
        "Nove membri del comitato e due della direzione\nUn membro, in ferie\nGiovedì "
        "9 aprile 2026, ore 17:00"
    ),
)


# The minutes in Portuguese.
PORTUGUESE = Words(
    title="Ata da comissão de trabalhadores, 12 de março de 2026",
    subject=("Um documento guardado durante trinta anos, que não contém nada além de "
             "si mesmo"),
    heading="Ata da comissão de trabalhadores",
    reference="Quinta-feira, 12 de março de 2026, 17:00, sala de reuniões 2",
    body=(
        (
            "Esta folha declara cumprir a segunda parte da norma de arquivo, no nível "
            "que promete uma única coisa: daqui a trinta anos a página terá o mesmo "
            "aspeto que hoje. Cada tipo de letra está dentro do ficheiro, cada cor diz "
            "o que significa e nada depende de um programa, de uma rede ou de um tipo "
            "de letra instalado por acaso."
        ),
        (
            ("A segunda parte não prevê os ficheiros anexos que a terceira prevê, por "
             "isso um documento que tenha de viajar com uma folha de cálculo ou uma "
             "fatura declara a terceira parte.")
        ),
    ),
    labels="Presentes\nAusências\nPróxima reunião",
    answers=(
        "Nove membros da comissão e dois da direção\nUm membro, de "
        "férias\nQuinta-feira, 9 de abril de 2026, 17:00"
    ),
)


# The minutes in Dutch.
DUTCH = Words(
    title="Notulen van de ondernemingsraad, 12 maart 2026",
    subject="Een document dat dertig jaar bewaard wordt en niets dan zichzelf bevat",
    heading="Notulen van de ondernemingsraad",
    reference="Donderdag 12 maart 2026, 17.00 uur, vergaderzaal 2",
    body=(
        (
            ("Dit blad verklaart te voldoen aan het tweede deel van de archiefnorm, op"
             " het niveau dat maar één ding belooft: over dertig jaar ziet de pagina "
             "er net zo uit als vandaag. Elk gebruikt lettertype zit in het bestand, "
             "elke kleur zegt wat ze betekent, en niets wacht op een programma, een "
             "netwerk of een toevallig geïnstalleerd lettertype.")
        ),
        (
            ("Het tweede deel kent geen meegevoerd bestand zoals het derde; een "
             "document dat met een spreadsheet of factuur moet meereizen, verklaart "
             "daarom te voldoen aan het derde deel.")
        ),
    ),
    labels="Aanwezig\nAfgemeld\nVolgende zitting",
    answers=(
        "Negen leden van de raad en twee van de directie\nEén lid, met "
        "verlof\nDonderdag 9 april 2026, 17.00 uur"
    ),
)

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


# The blocks of words the page carries, in the order it draws them: the box each goes
# in, the size it is set at, and the leading and alignment where it states them. A block
# the request states in several runs is one run of words here.
BLOCKS = (
    {
        "text": lambda words: words.heading,
        "size": 20.0,
        "left": 72.0,
        "top": 782.0,
        "width": 451.0,
        "set": None,
    },
    {
        "text": lambda words: words.reference,
        "size": 10.5,
        "left": 72.0,
        "top": 750.0,
        "width": 451.0,
        "set": None,
    },
    {
        "text": lambda words: " ".join(words.body),
        "size": 11.0,
        "left": 72.0,
        "top": 716.0,
        "width": 451.0,
        "set": (16.0, hqf_pdf.Align.Justify),
    },
    {
        "text": lambda words: words.labels,
        "size": 11.0,
        "left": 72.0,
        "top": 596.0,
        "width": 451.0,
        "set": (17.0, hqf_pdf.Align.Left),
    },
    {
        "text": lambda words: words.answers,
        "size": 11.0,
        "left": 200.0,
        "top": 596.0,
        "width": 323.0,
        "set": (17.0, hqf_pdf.Align.Left),
    },
)


def sheet(handle: hqf_pdf.FontHandle, words: Words) -> hqf_pdf.Page:
    """The one page, block by block."""
    content = hqf_pdf.Content()
    for block in BLOCKS:
        settings = {}
        if block["set"] is not None:
            leading, align = block["set"]
            settings = {"leading": leading, "align": align}
        flow = hqf_pdf.TextFlow(handle, block["size"], **settings)
        lines = flow.break_lines(block["text"](words), block["width"])
        # The Python side opens and closes the text object itself, where the Rust side
        # leaves that to the caller.
        flow.draw(content, lines, block["left"], block["top"], block["width"])

    page = hqf_pdf.Page.a4()
    page.set_content(content)
    return page


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("pdfa2b.pdf", language)).stem)

    document = hqf_pdf.Document()
    document.set_license(_licence.licensed())
    document.set_conformance(hqf_pdf.PdfA.A2B)
    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()))
    document.add_page(sheet(handle, words))

    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()