write_pdfa2u.rs

Le fichier Rust de l’exemple « Acte de cession recherchable en PDF/A-2u ». Le niveau au-dessus du niveau de base : chaque lettre de la page peut être recherchée et copiée, si bien qu’un registre retrouve l’acte et qu’un greffier en copie une clause telle quelle.

Rust 428 lignes

À quoi sert cet exemple

Un registre qui détient trente mille actes en retrouve un en cherchant sa référence. Si le fichier sait seulement à quoi ressemblent ses lettres et non quelles lettres elles sont, la recherche ne trouve rien, et le greffier qui en copie une clause obtient une suite de points d’interrogation.

Ce que montre cet exemple

  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
//! Writes a deed of assignment claiming the second part of the archival
//! standard, at level U.
//!
//! Level U adds one promise to level B: every mark of ink drawn on the page
//! says which letter it stands for, so a registry searching thirty thousand
//! deeds finds this one, and a clerk copying a clause out of it gets the
//! clause. Nothing about the layout changes for it.
//!
//! Whether the result really is PDF/A-2U 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: `cargo run --example write_pdfa2u -- tmp/pdfa2u.pdf [font.ttf]`
//!        `HQF_PDF_LANG=fr cargo run --example write_pdfa2u -- tmp/pdfa2u_fr.pdf`

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

use hqf_pdf::content::Content;
use hqf_pdf::layout::Align;
use hqf_pdf::metadata::xmp::{Metadata, PdfA};
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 — a document
/// that stamped itself with the time would be a different file on every build —
/// so the caller says when.
const ISSUED: &str = "2026-04-17T10:15: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 deed 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 heading at the top of the page.
    heading: &'static str,
    /// The line under the heading saying what the record is filed under.
    reference: &'static str,
    /// What the page says of the part it claims.
    body: &'static str,
    /// What each line of the record asks, one a line.
    labels: &'static str,
    /// What each line of the record answers, beside its label.
    answers: &'static str,
}

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

/// The deed in English.
const ENGLISH: Words = Words {
    title: "Deed of assignment, ACME/2026/0417",
    subject: "A record whose every word can be searched for and copied out",
    heading: "Deed of assignment",
    reference: "Reference ACME/2026/0417, filed 17 April 2026",
    body: "This deed claims the second part of the archival standard at the level above the \
        plain one: on top of the promise that the page keeps its looks, every mark of ink drawn \
        on it says which letter it stands for. A registry searching thirty thousand deeds for a \
        reference finds this one; a clerk copying a clause out of it gets the clause, not a row \
        of question marks. That is the whole of the difference between the two levels. Nothing \
        about the layout changes, and nothing is added to the page.",
    labels: "Assignor\nAssignee\nEffective",
    answers: "Ateliers du Nord, Lille\nACME Industries, Rouen\n1 May 2026",
};

/// The deed in French.
const FRENCH: Words = Words {
    title: "Acte de cession, ACME/2026/0417",
    subject: "Un document dont chaque mot peut être recherché et recopié",
    heading: "Acte de cession",
    reference: "Référence ACME/2026/0417, enregistrée le 17 avril 2026",
    body: "Cet acte déclare suivre la deuxième partie de la norme d’archivage, au niveau \
situé juste au-dessus du plus simple : en plus de la promesse que la page \
garde son apparence, chaque trace d’encre dit quelle lettre elle représente. \
Un greffe qui cherche une référence parmi trente mille actes trouve celui-ci ; \
un clerc qui en recopie une clause obtient la clause, et non une rangée de \
points d’interrogation. C’est toute la différence entre les deux niveaux : la \
mise en page ne change en rien, et rien n’est ajouté à la page.",
    labels: "Cédant\nCessionnaire\nPrise d’effet",
    answers: "Ateliers du Nord, Lille\nACME Industries, Rouen\n1er mai 2026",
};

/// The deed in German.
const GERMAN: Words = Words {
    title: "Abtretungsurkunde, ACME/2026/0417",
    subject: "Ein Dokument, in dem sich jedes Wort suchen und herauskopieren lässt",
    heading: "Abtretungsurkunde",
    reference: "Aktenzeichen ACME/2026/0417, eingereicht am 17. April 2026",
    body: "Diese Urkunde erklärt Konformität mit dem zweiten Teil der Archivnorm, auf der \
Stufe über der einfachen: Zusätzlich zur Zusage, dass die Seite ihr Aussehen \
behält, gibt jede darauf gesetzte Druckspur an, für welchen Buchstaben sie \
steht. Ein Register, das dreißigtausend Urkunden nach einem Aktenzeichen \
durchsucht, findet diese; wer eine Klausel daraus herauskopiert, erhält die \
Klausel und keine Reihe von Fragezeichen. Das ist der ganze Unterschied \
zwischen den beiden Stufen. Am Layout ändert sich nichts, und der Seite wird \
nichts hinzugefügt.",
    labels: "Zedent\nZessionar\nWirksam ab",
    answers: "Ateliers du Nord, Lille\nACME Industries, Rouen\n1. Mai 2026",
};

/// The deed in Spanish.
const SPANISH: Words = Words {
    title: "Escritura de cesión, ACME/2026/0417",
    subject: "Un documento en el que cada palabra se puede buscar y copiar",
    heading: "Escritura de cesión",
    reference: "Referencia ACME/2026/0417, registrada el 17 de abril de 2026",
    body: "Esta escritura declara cumplir la segunda parte de la norma de archivo, en el nivel \
        por encima del básico: además de prometer que la página conserva su aspecto, cada trazo \
        de tinta dice qué letra representa. Un registro que busca una referencia entre treinta \
        mil escrituras encuentra esta; un oficial que copia una cláusula obtiene la cláusula, no \
        una hilera de signos de interrogación. Esa es toda la diferencia entre los dos niveles. \
        La maquetación no cambia en nada y no se añade nada a la página.",
    labels: "Cedente\nCesionario\nFecha de efecto",
    answers: "Ateliers du Nord, Lille\nACME Industries, Rouen\n1 de mayo de 2026",
};

/// The deed in Italian.
const ITALIAN: Words = Words {
    title: "Atto di cessione, ACME/2026/0417",
    subject: "Un documento di cui ogni parola si può cercare e copiare",
    heading: "Atto di cessione",
    reference: "Riferimento ACME/2026/0417, depositato il 17 aprile 2026",
    body: "Quest’atto dichiara di rispettare la seconda parte della norma di \
archiviazione, al livello sopra quello di base: oltre alla promessa che la \
pagina conservi il suo aspetto, ogni segno d’inchiostro dice quale lettera \
rappresenta. Un ufficio che cerca un riferimento fra trentamila atti trova \
quest’atto; un impiegato che ne copia una clausola ottiene la clausola, non \
una fila di punti interrogativi. Tutta la differenza fra i due livelli è qui. \
L’impaginazione non cambia in nulla e alla pagina non si aggiunge niente.",
    labels: "Cedente\nCessionario\nDecorrenza",
    answers: "Ateliers du Nord, Lille\nACME Industries, Rouen\n1° maggio 2026",
};

/// The deed in Portuguese.
const PORTUGUESE: Words = Words {
    title: "Escritura de cessão, ACME/2026/0417",
    subject: "Um documento em que cada palavra pode ser pesquisada e copiada",
    heading: "Escritura de cessão",
    reference: "Referência ACME/2026/0417, registada a 17 de abril de 2026",
    body: "Esta escritura declara cumprir a segunda parte da norma de arquivo, no nível \
acima do básico: além da promessa de que a página mantém o seu aspeto, cada \
traço de tinta diz que letra representa. Uma conservatória que procure uma \
referência entre trinta mil escrituras encontra esta; um funcionário que copia \
uma cláusula obtém a cláusula, e não uma fila de pontos de interrogação. É \
essa toda a diferença entre os dois níveis. A paginação não muda em nada e \
nada é acrescentado à página.",
    labels: "Cedente\nCessionário\nEm vigor a partir de",
    answers: "Ateliers du Nord, Lille\nACME Industries, Rouen\n1 de maio de 2026",
};

/// The deed in Dutch.
const DUTCH: Words = Words {
    title: "Akte van overdracht, ACME/2026/0417",
    subject: "Een document waarvan elk woord doorzocht en overgenomen kan worden",
    heading: "Akte van overdracht",
    reference: "Kenmerk ACME/2026/0417, ingediend op 17 april 2026",
    body: "Deze akte verklaart te voldoen aan het tweede deel van de archiefnorm, op het \
niveau boven het eenvoudige: bovenop de belofte dat de pagina haar uiterlijk \
behoudt, zegt elk inktspoor voor welke letter het staat. Een register dat \
dertigduizend akten naar een kenmerk doorzoekt, vindt deze; een griffier die \
er een clausule uit overneemt, krijgt de clausule en geen rij vraagtekens. Dat \
is het hele verschil tussen de twee niveaus. Aan de opmaak verandert niets, en \
er komt niets bij op de pagina.",
    labels: "Overdrager\nVerkrijger\nIngangsdatum",
    answers: "Ateliers du Nord, Lille\nACME Industries, Rouen\n1 mei 2026",
};

/// 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),
];

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("pdfa2u")));
    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::A2U);

    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)?)?);
    doc.add_page(sheet(&font, words)?)?;

    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 box the heading is set in.
const HEADING_BOX: Block = Block {
    size: 20.0,
    left: 72.0,
    top: 782.0,
    width: 451.0,
    set: None,
};

/// The box the line under the heading is set in.
const REFERENCE_BOX: Block = Block {
    size: 10.5,
    left: 72.0,
    top: 750.0,
    width: 451.0,
    set: None,
};

/// The box the body is set in.
const BODY_BOX: Block = Block {
    size: 11.0,
    left: 72.0,
    top: 716.0,
    width: 451.0,
    set: Some((16.0, Align::Justify)),
};

/// The box the labels of the record are set in, one a line.
const LABELS_BOX: Block = Block {
    size: 11.0,
    left: 72.0,
    top: 576.0,
    width: 451.0,
    set: Some((17.0, Align::Left)),
};

/// The box the answers are set in, beside the labels.
const ANSWERS_BOX: Block = Block {
    size: 11.0,
    left: 200.0,
    top: 576.0,
    width: 323.0,
    set: Some((17.0, Align::Left)),
};

/// The one page, block by block.
fn sheet(font: &FontHandle, words: &Words) -> Result<Page, Error> {
    let mut c = Content::new();

    block(&mut c, font, &HEADING_BOX, words.heading)?;
    block(&mut c, font, &REFERENCE_BOX, words.reference)?;
    block(&mut c, font, &BODY_BOX, words.body)?;
    block(&mut c, font, &LABELS_BOX, words.labels)?;
    block(&mut c, font, &ANSWERS_BOX, words.answers)?;

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

/// The box one block of words goes in: its upper-left corner, how far it runs,
/// the size it is set at, and the leading and alignment where the block states
/// them.
struct Block {
    size: f64,
    left: f64,
    top: f64,
    width: f64,
    set: Option<(f64, Align)>,
}

/// Draws one block of words in the box it is given.
fn block(c: &mut Content, font: &FontHandle, at: &Block, text: &str) -> Result<(), Error> {
    let mut flow = TextFlow::new(font, at.size);
    if let Some((leading, align)) = at.set {
        flow = flow.leading(leading).align(align);
    }
    let lines = flow.break_lines(text, at.width);
    c.begin_text();
    flow.draw(c, &lines, at.left, at.top, at.width)?;
    c.end_text();
    Ok(())
}

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

    use super::{
        ANSWERS_BOX, BODY_BOX, Block, HEADING_BOX, LABELS_BOX, REFERENCE_BOX, WORDS, default_font,
        language,
    };

    /// The lines two languages are allowed to write the same way. No two
    /// languages write one alike.
    const SPARED: [&str; 0] = [];

    /// The least room left between a label and the column beside it.
    const GAP: f64 = 8.0;

    /// How many lines `text` takes in the box `at`.
    fn lines(font: &FontHandle, at: &Block, text: &str) -> usize {
        let mut flow = TextFlow::new(font, at.size);
        if let Some((leading, _)) = at.set {
            flow = flow.leading(leading);
        }
        flow.break_lines(text, at.width).len()
    }

    #[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:?}"
        );
    }

    /// Every box on the page holds its words: a line meant to stand alone stays
    /// on one line, and the body ends a line above the block under it. Nothing
    /// else on the page says so when a language runs over.
    #[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"),
        );

        for (named, words) in WORDS {
            let code = named.code();
            for (at, text) in [
                (HEADING_BOX, words.heading),
                (REFERENCE_BOX, words.reference),
            ] {
                assert_eq!(
                    lines(&font, &at, text),
                    1,
                    "the {code} record breaks {text:?} over two lines"
                );
            }

            let count = lines(&font, &BODY_BOX, words.body);
            let (leading, _) = BODY_BOX.set.expect("the body states its leading");
            let held = f64::from(u32::try_from(count).expect("a body is a few lines")) * leading;
            assert!(
                held <= BODY_BOX.top - LABELS_BOX.top,
                "the {code} body takes {count} lines from {}, and the next block \
                 starts at {}",
                BODY_BOX.top,
                LABELS_BOX.top
            );

            let room = ANSWERS_BOX.left - LABELS_BOX.left - GAP;
            for label in words.labels.lines() {
                let measured = font.measure(label, LABELS_BOX.size);
                assert!(
                    measured <= room,
                    "the {code} record sets {label:?} over {measured:.1} points, \
                     and it has {room:.1} before the column beside it"
                );
            }

            for answer in words.answers.lines() {
                assert_eq!(
                    lines(&font, &ANSWERS_BOX, answer),
                    1,
                    "the {code} record breaks {answer:?} over two lines"
                );
            }
        }
    }
}