Une feuille d'archive qui porte des champs à elle

La référence d'un service d'archives, le service d'origine, la date jusqu'à laquelle le document doit être conservé et sa classification, dans un fichier PDF/A-3. Aucune norme ne sait ce qu'est une date de conservation : chaque champ arrive donc avec sa propre description — son type, à qui il s'adresse, ce qu'il veut dire — et c'est cela qui fait accepter par un logiciel de contrôle des informations que vous avez inventées. La page imprime la même liste, si bien que ce que lit une personne et ce que lit une machine ne peuvent pas diverger.

Rust write_own_schema.rs 435 lignes
  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
//! Writes an archival cover sheet whose metadata carries fields of its own: a
//! records office's reference, department, retention date and classification.
//!
//! 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: `cargo run --example write_own_schema -- tmp/own_schema.pdf
//! [font.ttf]`
//!        `HQF_PDF_LANG=fr cargo run --example write_own_schema --
//! tmp/schema_propre.pdf`

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

use hqf_pdf::content::Content;
use hqf_pdf::metadata::xmp::{Metadata, PdfA, XmpCategory, XmpProperty, XmpSchema, XmpValueType};
use hqf_pdf::{Align, Document, Font, FontHandle, Page, TabMethod, TextFlow};

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

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

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

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 sheet was drawn up. The library never reads the clock, so the
/// caller says when.
const ISSUED: &str = "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.
const NAMESPACE: &str = "https://hqf-pdf.example/ns/records/1.0/";

/// The prefix the office's properties are written with.
const PREFIX: &str = "rec";

/// 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.
struct Field {
    /// The name the property is written under, which is what a machine looks it
    /// up by.
    name: &'static str,
    /// How a validator is to read the value.
    value_type: XmpValueType,
    /// Who the property is written for.
    category: XmpCategory,
}

/// The fields the sheet carries. The page draws them and the packet declares
/// them, from this one list.
const FIELDS: [Field; 5] = [
    Field {
        name: "Reference",
        value_type: XmpValueType::Text,
        category: XmpCategory::External,
    },
    Field {
        name: "Department",
        value_type: XmpValueType::Text,
        category: XmpCategory::External,
    },
    Field {
        name: "RetainUntil",
        value_type: XmpValueType::Date,
        category: XmpCategory::Internal,
    },
    Field {
        name: "Classification",
        value_type: XmpValueType::Text,
        category: XmpCategory::External,
    },
    Field {
        name: "Sheets",
        value_type: XmpValueType::Integer,
        category: XmpCategory::Internal,
    },
];

/// The words the sheet is drawn up in, one set per language.
#[derive(Debug)]
struct Words {
    /// What the sheet is called, on the page and in the file's own title.
    title: &'static str,
    /// The paragraph under the title.
    prose: &'static str,
    /// The heading over what the sheet says, and the one over what the packet
    /// declares of it.
    headings: [&'static str; 2],
    /// 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: [&'static str; 5],
    /// What each field of [`FIELDS`] means, which is what the packet carries
    /// beside it so that a validator will take it.
    descriptions: [&'static str; 5],
    /// The note at the foot of the sheet.
    note: &'static str,
    /// What the records office calls its own metadata, for a person reading the
    /// packet.
    schema: &'static str,
    /// What the file says it is about.
    subject: &'static str,
}

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

    /// What the file calls itself: what the sheet is, then the reference it is
    /// filed under.
    fn document_title(&self) -> String {
        format!("{} {}", self.title, self.values[0])
    }

    /// The schema the packet declares, built from the fields the page draws.
    fn schema(&self) -> Result<XmpSchema, hqf_pdf::Error> {
        let properties: Vec<XmpProperty> = FIELDS
            .iter()
            .zip(self.values)
            .zip(self.descriptions)
            .map(|((field, value), description)| {
                XmpProperty::new(field.name, value, description)
                    .value_type(field.value_type)
                    .category(field.category)
            })
            .collect();
        XmpSchema::new(self.schema, NAMESPACE, PREFIX, properties)
    }
}

/// The sheet in English.
const ENGLISH: Words = 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.
const FRENCH: Words = 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.
static WORDS: [(Language, &Words); 2] =
    [(Language::English, &ENGLISH), (Language::French, &FRENCH)];

/// The left edge the sheet is set from.
const X: f64 = 72.0;

/// The room the sheet has across the page.
const WIDTH: f64 = 451.0;

/// How far into that room the values stand, leaving the names to their left.
const VALUES: f64 = 150.0;

/// Where the first block starts, down from the top of the page.
const TOP: f64 = 780.0;

/// Draws `text` in `flow` at `top`, and hands back the top of whatever follows
/// it.
fn block(
    content: &mut Content,
    flow: &TextFlow<'_>,
    text: &str,
    top: f64,
) -> Result<f64, hqf_pdf::Error> {
    let lines = flow.break_lines(text, WIDTH);
    content.begin_text();
    flow.draw(content, &lines, X, top, WIDTH)?;
    content.end_text();
    Ok(top - flow.height(&lines))
}

/// Draws the sheet — what it is, what it says about itself, and how a reader
/// may check — and hands back the top of whatever would follow it.
fn draw(content: &mut Content, font: &FontHandle, words: &Words) -> Result<f64, hqf_pdf::Error> {
    let title = TextFlow::new(font, 20.0).space_after(14.0);
    let prose = TextFlow::new(font, 10.5)
        .leading(15.0)
        .align(Align::Justify)
        .space_after(16.0);
    let heading = TextFlow::new(font, 12.0)
        .space_before(20.0)
        .space_after(8.0);
    let row = TextFlow::new(font, 11.0)
        .leading(17.0)
        .tab_method(TabMethod::Ruler)
        .tab_ruler([VALUES]);
    let declared = TextFlow::new(font, 9.5)
        .leading(14.0)
        .hanging_list(0.0, VALUES);
    let note = TextFlow::new(font, 9.0).leading(13.0).space_before(18.0);

    let mut top = block(content, &title, words.title, TOP)?;
    top = block(content, &prose, words.prose, top)?;

    top = block(content, &heading, words.headings[0], top)?;
    let values: String = FIELDS
        .iter()
        .zip(words.values)
        .map(|(field, value)| format!("{}\t{value}", field.name))
        .collect::<Vec<_>>()
        .join("\n");
    top = block(content, &row, &values, top)?;

    top = block(content, &heading, words.headings[1], top)?;
    let rows: String = FIELDS
        .iter()
        .zip(words.descriptions)
        .map(|(field, description)| {
            format!(
                "{PREFIX}:{}\t{} · {} · {description}",
                field.name,
                field.value_type.as_str(),
                field.category.as_str(),
            )
        })
        .collect::<Vec<_>>()
        .join("\n");
    top = block(content, &declared, &rows, top)?;

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

/// The sheet, as a page.
fn cover_sheet(font: &FontHandle, words: &Words) -> Result<Page, hqf_pdf::Error> {
    let mut content = Content::new();
    draw(&mut content, font, words)?;

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

fn main() -> 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 two languages do not overwrite each other in `tmp/`.
    let path = args
        .next()
        .unwrap_or_else(|| language.file_name(&out::default_path("own_schema")));
    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::A3B);
    doc.set_metadata(Metadata {
        title: Some(words.document_title()),
        author: Some("Olivier Pons".to_owned()),
        subject: Some(words.subject.to_owned()),
        producer: Some("hqf-pdf".to_owned()),
        created: Some(ISSUED.to_owned()),
        schemas: vec![words.schema()?],
        ..Metadata::default()
    });

    let font = doc.add_font(Font::parse(fs::read(&font_path)?)?);
    doc.add_page(cover_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(())
}

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

    use super::{FIELDS, WORDS, default_font, draw, language};

    /// The lines two languages are allowed to write the same way.
    ///
    /// Three of the five values are not words at all — a reference the office
    /// files under, the date the dossier may be destroyed, and how many sheets
    /// it holds — and `Public` is the same word in both languages. The names
    /// the properties are written under are not in `Words`: a machine looks
    /// them up by those, so they cannot follow the page.
    const SPARED: [&str; 4] = [
        "\"RO-2026-004318\"",
        "\"2056-12-31T23:59:59+01:00\"",
        "\"Public\"",
        "\"1\"",
    ];

    /// How far down the page the last block may reach. A language whose
    /// descriptions run to more lines than English's pushes the note down, and
    /// nothing on this page is broken over a second sheet.
    const FLOOR: f64 = 40.0;

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

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

    /// The sheet is one page and nothing on it flows onto a second, so a set of
    /// words long enough to fill the paper drops the note off the bottom of it
    /// without a single line saying so.
    #[test]
    fn every_language_lays_the_sheet_out_on_one_page() {
        let mut doc = Document::new();
        let text = 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();
            let mut content = Content::new();
            let foot = draw(&mut content, &text, words).expect("the sheet lays out");

            assert!(
                foot >= FLOOR,
                "the {code} sheet ends {foot:.1} points up the page, and the \
                 paper is done at {FLOOR:.1}"
            );
        }
    }

    /// Every field the packet declares says what it is for, and every field the
    /// page draws has a value under it: a set of words short of either leaves a
    /// property the standard's own rules refuse.
    #[test]
    fn every_language_says_what_each_field_holds_and_means() {
        for (named, words) in WORDS {
            let code = named.code();
            assert_eq!(words.values.len(), FIELDS.len(), "{code} values");
            assert!(
                words.descriptions.iter().all(|line| !line.is_empty()),
                "the {code} sheet declares a field without saying what it means"
            );
        }
    }
}