An archival sheet carrying fields of its own

A records office's reference, the department it came from, the date until which the document must be kept and its classification, in a PDF/A-3 file. No standard knows what a retention date is, so each field arrives with a description of itself — its type, who it is written for, what it means — which is what makes a checker accept information you invented. The page prints the same list, so what a person reads and what a machine reads cannot drift apart.

Python write_own_schema.py 301 lines
  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
"""Writes an archival cover sheet whose metadata carries fields of its own: a
records office's reference, department, retention date and classification.

The Python twin of the `write_own_schema` example in Rust: the same document,
through the binding rather than through the library directly.

PDF/A accepts metadata no standard ever heard of, but not silently. A property
from outside the standard schemas travels with a description of itself — what it
is called, how it is to be read, who it is written for, and what it means — or
the file is not conformant. One schema says all of that, and the library writes
both halves: the properties into the packet, the description into the extension
block.

The page prints the very fields the packet declares, from one list read twice, so
that what a person reads and what a machine reads cannot drift apart.

The words are held in `Words`, once per language, and `HQF_PDF_LANG` picks which
set the sheet is drawn up in. The names the properties are written under are not
words: they are what a machine looks them up by, and they are spelled the same
way whoever reads the sheet. What each of them says and what each of them means
are words, and travel into the packet with the page.

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

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

from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path
from typing import NamedTuple

import _language
import _licence
import _out

import hqf_pdf

# When the sheet was drawn up. The library never reads the clock, so the caller says
# when.
ISSUED = "2026-07-14T09:30:00+02:00"

# The namespace the office's properties live in. It is the office's own, and resolves to
# nothing: a namespace names a set of properties, it is not an address anything fetches.
NAMESPACE = "https://hqf-pdf.example/ns/records/1.0/"

# The prefix the office's properties are written with.
PREFIX = "rec"


class Field(NamedTuple):
    """One field of the office's own: what it is written under, how a validator is to
    read it, and who it is written for.

    What it says and what it means are words, and stand in `Words` under the same order.
    """

    name: str
    value_type: hqf_pdf.XmpValueType
    category: hqf_pdf.XmpCategory


# The fields the sheet carries. The page draws them and the packet declares them, from
# this one list.
FIELDS = (
    Field("Reference", hqf_pdf.XmpValueType.Text, hqf_pdf.XmpCategory.External),
    Field("Department", hqf_pdf.XmpValueType.Text, hqf_pdf.XmpCategory.External),
    Field("RetainUntil", hqf_pdf.XmpValueType.Date, hqf_pdf.XmpCategory.Internal),
    Field(
        "Classification", hqf_pdf.XmpValueType.Text, hqf_pdf.XmpCategory.External
    ),
    Field("Sheets", hqf_pdf.XmpValueType.Integer, hqf_pdf.XmpCategory.Internal),
)


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

    # What the sheet is called, on the page and in the file's own title.
    title: str
    # The paragraph under the title.
    prose: str
    # The heading over what the sheet says, and the one over what the packet declares of
    # it.
    headings: tuple[str, str]
    # What this sheet says for each field of `FIELDS`. A reference, a date and a count
    # are not words; a department and how widely a dossier may be circulated are.
    values: tuple[str, str, str, str, str]
    # What each field of `FIELDS` means, which is what the packet carries beside it so
    # that a validator will take it.
    descriptions: tuple[str, str, str, str, str]
    # The note at the foot of the sheet.
    note: str
    # What the records office calls its own metadata, for a person reading the packet.
    schema: str
    # What the file says it is about.
    subject: str

    def document_title(self) -> str:
        """What the file calls itself: what the sheet is, then the reference it is filed
        under."""
        return f"{self.title} {self.values[0]}"

    def declared_schema(self) -> hqf_pdf.XmpSchema:
        """The schema the packet declares, built from the fields the page draws."""
        properties = [
            hqf_pdf.XmpProperty(
                field.name,
                value,
                description,
                value_type=field.value_type,
                category=field.category,
            )
            for field, value, description in zip(
                FIELDS, self.values, self.descriptions
            )
        ]
        return hqf_pdf.XmpSchema(self.schema, NAMESPACE, PREFIX, properties)


# The sheet in English.
ENGLISH = Words(
    title="Records office cover sheet",
    prose="The fields below are the records office's own. No part of the PDF "
    "standard knows what a retention date is, so the file explains itself: "
    "each field arrives in the metadata packet with its name, the way it is "
    "to be read, who it is written for, and what it means. An archival "
    "validator accepts them on that description, and refuses metadata that "
    "turns up without one.",
    headings=(
        "What the sheet says",
        "What the packet declares of each of them",
    ),
    values=(
        "RO-2026-004318",
        "Planning",
        "2056-12-31T23:59:59+01:00",
        "Public",
        "1",
    ),
    descriptions=(
        "The reference the records office files the dossier under",
        "The department the dossier came from",
        "The date the dossier may be destroyed, and not before",
        "How widely the dossier may be circulated",
        "How many sheets the dossier holds",
    ),
    note="Every value above is written twice: once on this page, and once in the "
    "packet a machine reads, under the prefix the schema names. Reading the "
    "file's metadata gives back this table, field for field.",
    schema="Records Office Schema",
    subject="An archival sheet carrying metadata of its own",
)

# The sheet in French.
FRENCH = Words(
    title="Bordereau du service d'archives",
    prose="Les champs ci-dessous appartiennent au service d'archives. Aucune "
    "partie de la norme PDF ne sait ce qu'est une date de conservation, donc le "
    "fichier s'explique : chaque champ arrive dans le paquet de métadonnées avec "
    "son nom, la façon dont il doit être lu, pour qui il est écrit et ce qu'il "
    "veut dire. Un validateur d'archivage les accepte sur cette description, et "
    "refuse une donnée qui se présente sans elle.",
    headings=(
        "Ce que dit le bordereau",
        "Ce que le paquet déclare de chacun",
    ),
    values=(
        "RO-2026-004318",
        "Urbanisme",
        "2056-12-31T23:59:59+01:00",
        "Public",
        "1",
    ),
    descriptions=(
        "La référence sous laquelle le service d'archives classe le dossier",
        "Le service d'où vient le dossier",
        "La date à partir de laquelle le dossier peut être détruit",
        "Jusqu'où le dossier peut être diffusé",
        "Combien de feuilles contient le dossier",
    ),
    note="Chaque valeur ci-dessus est écrite deux fois : une fois sur cette page, "
    "une fois dans le paquet que lit une machine, sous le préfixe que nomme le "
    "schéma. Relire les métadonnées du fichier redonne ce tableau, champ pour "
    "champ.",
    schema="Schéma du service d'archives",
    subject="Un bordereau d'archives qui porte ses propres métadonnées",
)

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

# The left edge the sheet is set from.
X = 72.0

# The room the sheet has across the page.
WIDTH = 451.0

# How far into that room the values stand, leaving the names to their left.
VALUES = 150.0

# Where the first block starts, down from the top of the page.
TOP = 780.0


def block(
    content: hqf_pdf.Content,
    flow: hqf_pdf.TextFlow,
    text: str,
    top: float,
) -> float:
    """Draws `text` in `flow` at `top`, and hands back the top of whatever follows
    it."""
    lines = flow.break_lines(text, WIDTH)
    flow.draw(content, lines, X, top, WIDTH)
    return top - flow.height(lines)


def cover_sheet(handle: hqf_pdf.FontHandle, words: Words) -> hqf_pdf.Page:
    """The sheet: what it is, what it says about itself, and how a reader may check."""
    content = hqf_pdf.Content()

    title = hqf_pdf.TextFlow(handle, 20.0, space_after=14.0)
    prose = hqf_pdf.TextFlow(
        handle,
        10.5,
        leading=15.0,
        align=hqf_pdf.Align.Justify,
        space_after=16.0,
    )
    heading = hqf_pdf.TextFlow(handle, 12.0, space_before=20.0, space_after=8.0)
    row = hqf_pdf.TextFlow(
        handle,
        11.0,
        leading=17.0,
        tab_method=hqf_pdf.TabMethod.Ruler,
        tab_ruler=[VALUES],
    )
    declared = hqf_pdf.TextFlow(handle, 9.5, leading=14.0).hanging_list(0.0, VALUES)
    note = hqf_pdf.TextFlow(handle, 9.0, leading=13.0, space_before=18.0)

    top = block(content, title, words.title, TOP)
    top = block(content, prose, words.prose, top)

    top = block(content, heading, words.headings[0], top)
    values = "\n".join(
        f"{field.name}\t{value}" for field, value in zip(FIELDS, words.values)
    )
    top = block(content, row, values, top)

    top = block(content, heading, words.headings[1], top)
    rows = "\n".join(
        f"{PREFIX}:{field.name}\t{field.value_type.as_str()} · "
        f"{field.category.as_str()} · {description}"
        for field, description in zip(FIELDS, words.descriptions)
    )
    top = block(content, declared, rows, top)

    block(content, note, words.note, top)

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


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

    document = hqf_pdf.Document()
    document.set_license(_licence.licensed())
    document.set_conformance(hqf_pdf.PdfA.A3B)
    document.set_metadata(
        hqf_pdf.Metadata(
            title=words.document_title(),
            author="Olivier Pons",
            subject=words.subject,
            producer="hqf-pdf",
            created=ISSUED,
            schemas=[words.declared_schema()],
        )
    )

    handle = document.add_font(hqf_pdf.Font.from_path(_out.font_path()))
    document.add_page(cover_sheet(handle, words))

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


if __name__ == "__main__":
    main()