write_pdfa2a.rs

Die Rust-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.

Rust 436 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
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
//! 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: `cargo run --example write_pdfa2a -- tmp/pdfa2a.pdf [font.ttf]`
//!        `HQF_PDF_LANG=fr cargo run --example write_pdfa2a -- tmp/pdfa2a_fr.pdf`

use std::env;
use std::fs;
use std::path::{Path, PathBuf};

use hqf_pdf::content::{Artifact, Content};
use hqf_pdf::cos::Name;
use hqf_pdf::layout::FirstBaseline;
use hqf_pdf::metadata::xmp::{Metadata, PdfA};
use hqf_pdf::structure::{Marks, StructElement, StructureTree};
use hqf_pdf::{Document, Error, Font, FontHandle, Page, TextFlow};

#[path = "shared/out.rs"]
mod out;

#[path = "shared/licence.rs"]
mod licence;

#[path = "shared/language.rs"]
mod language;

#[path = "shared/failure.rs"]
mod failure;

use language::Language;

/// The font the example draws with when none is given on the command line.
fn default_font() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("tests")
        .join("fonts")
        .join("DejaVuSans.ttf")
}

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

/// Who the file says wrote it, and what produced it, in every language alike.
const AUTHOR: &str = "HQF Development";
const PRODUCER: &str = "hqf-pdf-server";

/// The words the notice is written in, one set per language.
#[derive(Debug)]
struct Words {
    /// What the file says it is, in its metadata.
    title: &'static str,
    /// What the file says it is about, in its metadata.
    subject: &'static str,
    /// The language the tree says the page is written in, which a screen reader
    /// reads it in.
    lang: &'static str,
    /// The heading, tagged `H1`.
    heading: &'static str,
    /// The two paragraphs under the heading, each tagged `P`.
    paragraphs: [&'static str; 2],
    /// The page number at the foot, marked as furniture.
    foot: &'static str,
}

impl Words {
    /// The words the notice is written in, in `language`.
    fn of(language: Language) -> &'static Self {
        language::pick(&WORDS, language)
    }
}

/// The notice in English.
const ENGLISH: Words = 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.
const FRENCH: Words = 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.
const GERMAN: Words = 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.
const SPANISH: Words = 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.
const ITALIAN: Words = 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.
const PORTUGUESE: Words = 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.
const DUTCH: Words = 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.
static WORDS: [(Language, &Words); 7] = [
    (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.
type Placed = (&'static str, f64, f64, f64, f64, Option<f64>);

/// The heading, then the two paragraphs.
const READ: [Placed; 3] = [
    ("H1", 20.0, 72.0, 782.0, 451.0, None),
    ("P", 11.0, 72.0, 730.0, 451.0, Some(16.0)),
    ("P", 11.0, 72.0, 580.0, 451.0, Some(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.
const RULE: (f64, f64, f64, f64) = (72.0, 70.0, 451.0, 1.0);
const FOOT: (f64, f64, f64, f64) = (9.0, 72.0, 68.0, 451.0);

fn main() -> std::process::ExitCode {
    failure::reported(run())
}

/// Writes the file, and says where it went.
fn run() -> Result<(), Box<dyn std::error::Error>> {
    let language = Language::from_environment()?;
    let words = Words::of(language);

    let mut args = env::args().skip(1);
    // A named file is written as named; the default one carries the language,
    // so the languages do not overwrite each other in `tmp/`.
    let path = args
        .next()
        .unwrap_or_else(|| language.file_name(&out::default_path("pdfa2a")));
    let font_path = args.next().map_or_else(default_font, PathBuf::from);

    let mut doc = Document::new();
    doc.set_license(licence::licensed());
    doc.set_conformance(PdfA::A2A);

    let mut facts = Metadata::default();
    facts.title = Some(words.title.to_owned());
    facts.author = Some(AUTHOR.to_owned());
    facts.subject = Some(words.subject.to_owned());
    facts.producer = Some(PRODUCER.to_owned());
    facts.created = Some(ISSUED.to_owned());
    doc.set_metadata(facts);

    let font = doc.add_font(Font::parse(fs::read(&font_path)?)?);
    let (page, sect) = sheet(&font, words)?;
    doc.add_page(page)?;
    doc.set_structure(
        StructureTree::new()
            .lang(words.lang)
            .child(StructElement::new("Document").child(sect)),
    );

    let bytes = doc.to_bytes()?;
    if let Some(parent) = Path::new(&path).parent() {
        fs::create_dir_all(parent)?;
    }
    fs::write(&path, &bytes)?;
    println!("wrote {path}: {} bytes", bytes.len());
    Ok(())
}

/// The one page, and the branch of the tree that says what is on it.
fn sheet(font: &FontHandle, words: &Words) -> Result<(Page, StructElement), Error> {
    let mut c = Content::new();
    let mut marks = Marks::new();
    let mut sect = StructElement::new("Sect");

    let said = [words.heading, words.paragraphs[0], words.paragraphs[1]];
    for ((tag, size, left, top, width, leading), text) in READ.into_iter().zip(said) {
        let mut flow = TextFlow::new(font, size).first_baseline(FirstBaseline::Points(0.0));
        if let Some(leading) = leading {
            flow = flow.leading(leading);
        }
        let lines = flow.break_lines(text, width);
        c.begin_text();
        let runs = flow.draw_tagged(
            &mut c,
            &lines,
            left,
            top,
            width,
            &Name::new(tag),
            &mut marks,
        )?;
        c.end_text();

        let mut element = StructElement::new(tag);
        for run in runs {
            element = element.content(0, run);
        }
        sect = sect.child(element);
    }

    foot(&mut c, font, words.foot)?;

    let mut page = Page::a4();
    page.content = c.into_bytes();
    Ok((page, sect))
}

/// The rule and the page number at the foot, both marked as furniture so that
/// nothing reading the document aloud says them.
fn foot(c: &mut Content, font: &FontHandle, text: &str) -> Result<(), Error> {
    let (left, low, width, height) = RULE;
    c.begin_artifact(Artifact::Footer);
    c.save_state();
    c.set_fill_rgb(0.0, 0.0, 0.0)?;
    c.rect(left, low, width, height)?;
    c.fill();
    c.restore_state();
    c.end_marked();

    let (size, left, top, width) = FOOT;
    let flow = TextFlow::new(font, size).first_baseline(FirstBaseline::Points(0.0));
    let lines = flow.break_lines(text, width);
    c.begin_artifact(Artifact::Footer);
    c.begin_text();
    flow.draw(c, &lines, left, top, width)?;
    c.end_text();
    c.end_marked();
    Ok(())
}

#[cfg(test)]
mod tests {
    use hqf_pdf::{Document, Font, TextFlow};

    use super::{FOOT, READ, RULE, WORDS, default_font, language};

    /// The lines two languages are allowed to write the same way: Spanish and
    /// Portuguese number a page with the same words.
    const SPARED: [&str; 1] = [r#"foot: "Página 1 de 1""#];

    #[test]
    fn every_language_writes_the_record_in_its_own_words() {
        let untranslated = language::untranslated_lines(&WORDS, &SPARED);

        assert!(
            untranslated.is_empty(),
            "the record says these in more than one language: {untranslated:?}"
        );
    }

    /// The heading and the page number each hold one line, and each paragraph
    /// ends a line above what stands under it: the second paragraph, then the
    /// rule at the foot. A paragraph one line too long is drawn over the next
    /// block, and nothing else on the page says so.
    #[test]
    fn every_language_fits_the_boxes_the_page_sets_it_in() {
        let mut doc = Document::new();
        let font = doc.add_font(
            Font::parse(std::fs::read(default_font()).expect("the committed font is there"))
                .expect("the committed font parses"),
        );
        let (_, heading_size, _, _, heading_width, _) = READ[0];
        let (foot_size, _, _, foot_width) = FOOT;
        let floors = [READ[2].3, RULE.1];

        for (named, words) in WORDS {
            let code = named.code();
            for (size, width, text) in [
                (heading_size, heading_width, words.heading),
                (foot_size, foot_width, words.foot),
            ] {
                let lines = TextFlow::new(&font, size).break_lines(text, width).len();
                assert_eq!(
                    lines, 1,
                    "the {code} record breaks {text:?} onto {lines} lines"
                );
            }

            for ((placed, text), floor) in READ[1..].iter().zip(words.paragraphs).zip(floors) {
                let (_, size, _, top, width, leading) = *placed;
                let leading = leading.expect("a paragraph states its leading");
                let lines = TextFlow::new(&font, size)
                    .leading(leading)
                    .break_lines(text, width)
                    .len();
                let count = f64::from(u32::try_from(lines).expect("a paragraph is a few lines"));
                assert!(
                    count * leading <= top - floor,
                    "the {code} record sets a paragraph on {lines} lines from {top}, \
                     and it has down to {floor}"
                );
            }
        }
    }
}