write_added_to.rs

Le fichier Rust de l'exemple « Un contrat reçu, complété sans déplacer un octet ». Un contrat de maintenance envoyé par quelqu'un d'autre, avec un emplacement de signature, une date et une ligne à soi posés sur sa dernière page et une annexe ajoutée après elle, chaque octet du contrat laissé à sa place.

Rust 387 lignes

À quoi sert cet exemple

Un fournisseur envoie un contrat de maintenance en PDF de deux pages. Il doit repartir avec un emplacement de signature, la date, une ligne qui dit qui signe, et une annexe d'une page à la fin. Ouvrir le contrat et le réécrire rendrait un autre fichier : une signature qu'il portait déjà ne tiendrait plus, et personne ne pourrait prouver que le texte est toujours celui qui a été envoyé.

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
//! Adds a signature area, a date, a line of one's own and a page at the end to
//! an agreement somebody else sent, without moving a byte of it.
//!
//! The agreement arrives as a PDF of two pages. What is added is created as a
//! document of its own: its first page is drawn over the agreement's last page,
//! and its second page is added after it. The file that comes out is the
//! agreement, byte for byte, followed by a section holding what was added — an
//! ordinary signature the agreement already carried still covers the bytes it
//! covered, and a reader that shows the file's earlier versions still shows the
//! agreement as it arrived. A signature certifying the agreement against change
//! does not survive: no permission ISO 32000-2 table 257 gives `/DocMDP` allows
//! a page to be added or drawn over, and nothing here looks for one.
//!
//! The example creates the agreement itself first, so that it runs with nothing
//! handed to it. Every line both documents draw is held in `Words`, once per
//! language, and `HQF_PDF_LANG` picks which set is drawn.
//!
//! Usage: `cargo run --example write_added_to -- tmp/added_to.pdf`
//!        `HQF_PDF_LANG=fr cargo run --example write_added_to -- tmp/ajout.pdf`

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

use hqf_pdf::content::Content;
use hqf_pdf::{Document, Font, FontHandle, IncrementalUpdate, Page, Rgb};

#[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 lines of both documents are set in.
fn default_font() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("tests")
        .join("fonts")
        .join("DejaVuSans.ttf")
}

/// The margin every line starts from.
const MARGIN: f64 = 56.0;

/// The size of a title.
const TITLE_SIZE: f64 = 18.0;

/// The size of a line of the agreement and of the annex.
const BODY_SIZE: f64 = 11.0;

/// How far apart two lines of the agreement and of the annex stand.
const LEADING: f64 = 18.0;

/// The size of what is written in and under the signature area.
const SMALL_SIZE: f64 = 9.0;

/// The signature area: its left edge, its bottom, its width and its height.
const AREA: [f64; 4] = [320.0, 120.0, 219.0, 90.0];

/// The date the agreement is received on, written the same way in every
/// language.
const DATE: &str = "2026-09-12";

/// How many lines each page of the agreement holds.
const CLAUSES: usize = 5;

/// How many lines the annex holds.
const ANNEX_LINES: usize = 4;

/// The words both documents are written in, one set per language.
#[derive(Debug)]
struct Words {
    /// The title of the agreement.
    title: &'static str,
    /// The lines of the agreement's first page.
    first: [&'static str; CLAUSES],
    /// The lines of the agreement's second page.
    second: [&'static str; CLAUSES],
    /// What is written in the corner of the signature area.
    signature: &'static str,
    /// What stands before the date under the signature area.
    received_on: &'static str,
    /// The line of one's own, written above the signature area.
    approval: &'static str,
    /// The title of the page added at the end.
    annex_title: &'static str,
    /// The lines of the page added at the end. `{bytes}` stands for how many
    /// bytes the agreement arrived as.
    annex: [&'static str; ANNEX_LINES],
}

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

/// The documents in English.
const ENGLISH: Words = Words {
    title: "Maintenance agreement",
    first: [
        "1. The supplier services the two lifts of the building every quarter.",
        "2. A fault reported before noon is looked at the same working day.",
        "3. Parts are charged at the price list in force on the day they are fitted.",
        "4. The agreement runs for one year and renews itself unless ended.",
        "5. Either party ends it by letter, three months before it renews.",
    ],
    second: [
        "6. The customer keeps the machine rooms clear and reachable.",
        "7. The supplier's staff sign the logbook at every visit.",
        "8. An invoice is paid within thirty days of the date it bears.",
        "9. Disputes go before the courts where the building stands.",
        "Signed for the supplier, and sent to the customer to sign.",
    ],
    signature: "Signature",
    received_on: "Received on",
    approval: "Read and approved, subject to annex A.",
    annex_title: "Annex A",
    annex: [
        "The lift in the north wing is serviced every month, not every quarter.",
        "This page, the signature area, the date and the line above it were",
        "added after the agreement arrived. The first {bytes} bytes of this file",
        "are the agreement exactly as it was sent.",
    ],
};

/// The documents in French.
const FRENCH: Words = Words {
    title: "Contrat de maintenance",
    first: [
        "1. Le prestataire entretient les deux ascenseurs chaque trimestre.",
        "2. Une panne signalée avant midi est examinée le jour ouvré même.",
        "3. Les pièces sont facturées au tarif en vigueur le jour de leur pose.",
        "4. Le contrat court un an et se renouvelle sauf résiliation.",
        "5. Chaque partie y met fin par lettre, trois mois avant son terme.",
    ],
    second: [
        "6. Le client garde les locaux des machines dégagés et accessibles.",
        "7. Le personnel du prestataire signe le registre à chaque visite.",
        "8. Une facture est réglée dans les trente jours suivant sa date.",
        "9. Les litiges relèvent des tribunaux du lieu de l'immeuble.",
        "Signé pour le prestataire, et envoyé au client pour signature.",
    ],
    signature: "Signature",
    received_on: "Reçu le",
    approval: "Lu et approuvé, sous réserve de l'annexe A.",
    annex_title: "Annexe A",
    annex: [
        "L'ascenseur de l'aile nord est entretenu chaque mois, et non chaque trimestre.",
        "Cette page, la zone de signature, la date et la ligne au-dessus ont été",
        "ajoutées après réception du contrat. Les {bytes} premiers octets de ce",
        "fichier sont le contrat exactement tel qu'il a été envoyé.",
    ],
};

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

/// A whole number with its thousands set apart by a space.
fn grouped(number: usize) -> String {
    let digits = number.to_string();
    let mut out = String::new();
    for (rank, digit) in digits.chars().enumerate() {
        if rank > 0 && (digits.len() - rank) % 3 == 0 {
            out.push(' ');
        }
        out.push(digit);
    }
    out
}

/// Writes `text` at `x`, `y`, in `font` at `size`.
fn line(
    content: &mut Content,
    font: &FontHandle,
    size: f64,
    x: f64,
    y: f64,
    text: &str,
) -> Result<(), hqf_pdf::Error> {
    content.begin_text();
    content.set_font(font, size)?;
    content.text_origin(x, y)?;
    content.show_glyphs(&font.glyphs(text));
    content.end_text();
    Ok(())
}

/// A page holding a title and `lines` under it.
fn page_of_lines(font: &FontHandle, title: &str, lines: &[&str]) -> Result<Page, hqf_pdf::Error> {
    let mut content = Content::new();
    line(&mut content, font, TITLE_SIZE, MARGIN, 770.0, title)?;
    let mut y = 730.0;
    for text in lines {
        line(&mut content, font, BODY_SIZE, MARGIN, y, text)?;
        y -= LEADING;
    }
    let mut page = Page::a4();
    page.content = content.into_bytes();
    Ok(page)
}

/// The agreement as it is sent: two pages, created by somebody else.
fn agreement(words: &Words, font: &Font) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
    let mut doc = Document::new();
    doc.set_license(licence::licensed());
    let font = doc.add_font(font.clone());
    doc.add_page(page_of_lines(&font, words.title, &words.first)?)?;
    doc.add_page(page_of_lines(&font, words.title, &words.second)?)?;
    Ok(doc.to_bytes()?)
}

/// What is added: a page drawn over the agreement's last page, holding the
/// signature area, the date and the line of one's own, and the annex added
/// after it.
fn additions(
    words: &Words,
    font: &Font,
    received: usize,
) -> Result<Document, Box<dyn std::error::Error>> {
    let mut doc = Document::new();
    doc.set_license(licence::licensed());
    let font = doc.add_font(font.clone());

    let [x, y, width, height] = AREA;
    let mut over = Content::new();
    over.save_state();
    over.set_fill(Rgb::new(0.93, 0.96, 1.0))?;
    over.set_stroke(Rgb::new(0.2, 0.35, 0.7))?;
    over.set_line_width(1.0)?;
    over.rect(x, y, width, height)?;
    over.fill_and_stroke();
    over.set_fill(Rgb::new(0.2, 0.35, 0.7))?;
    line(
        &mut over,
        &font,
        SMALL_SIZE,
        x + 8.0,
        y + height - 14.0,
        words.signature,
    )?;
    over.restore_state();
    let date = format!("{} {DATE}", words.received_on);
    line(&mut over, &font, SMALL_SIZE, x, y - 16.0, &date)?;
    line(&mut over, &font, BODY_SIZE, MARGIN, 240.0, words.approval)?;
    let mut page = Page::a4();
    page.content = over.into_bytes();
    doc.add_page(page)?;

    let count = grouped(received);
    let annex: Vec<String> = words
        .annex
        .iter()
        .map(|text| text.replace("{bytes}", &count))
        .collect();
    let annex: Vec<&str> = annex.iter().map(String::as_str).collect();
    doc.add_page(page_of_lines(&font, words.annex_title, &annex)?)?;
    Ok(doc)
}

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

fn run() -> Result<(), Box<dyn std::error::Error>> {
    let language = Language::from_environment()?;
    let words = Words::of(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/`.
    let out = env::args()
        .nth(1)
        .unwrap_or_else(|| language.file_name(&out::default_path("added_to")));

    let font = Font::parse(fs::read(default_font())?)?;
    let received = agreement(words, &font)?;

    let mut update = IncrementalUpdate::new(received.clone())?;
    update.set_license(licence::licensed());
    // The first page of the additions is drawn over the agreement's second
    // page; the annex, which no entry names, comes after it.
    let bytes = update.to_added_bytes(&additions(words, &font, received.len())?, &[1])?;

    if let Some(parent) = Path::new(&out).parent() {
        fs::create_dir_all(parent)?;
    }
    fs::write(&out, &bytes)?;
    println!(
        "wrote {out}: {} bytes, the first {} of them the agreement as it arrived",
        bytes.len(),
        received.len()
    );
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::{
        AREA, BODY_SIZE, Font, MARGIN, SMALL_SIZE, TITLE_SIZE, WORDS, default_font, grouped,
        language,
    };
    use hqf_pdf::Document;

    /// The width of an A4 page, in points.
    const PAGE_WIDTH: f64 = 595.276;

    /// The lines two languages are allowed to write the same way: the word
    /// written in the corner of the signature area is one word in both.
    const SPARED: [&str; 1] = ["signature: \"Signature\""];

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

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

    #[test]
    fn a_number_sets_its_thousands_apart_by_a_space() {
        assert_eq!(grouped(7), "7");
        assert_eq!(grouped(999), "999");
        assert_eq!(grouped(1000), "1 000");
        assert_eq!(grouped(46_512), "46 512");
        assert_eq!(grouped(1_234_567), "1 234 567");
    }

    /// Every line fits the room it is drawn in: the page less its two margins
    /// for a title and a line of the agreement or the annex, the area for what
    /// is written inside and under it.
    #[test]
    fn every_line_fits_the_room_it_is_drawn_in() {
        let mut doc = Document::new();
        let font = doc.add_font(
            Font::parse(std::fs::read(default_font()).expect("the font reads"))
                .expect("the font parses"),
        );
        let page = MARGIN.mul_add(-2.0, PAGE_WIDTH);
        let [_, _, area, _] = AREA;
        for (language, words) in WORDS {
            let code = language.code();
            let annex = words.annex.map(|text| text.replace("{bytes}", "999 999"));
            let body = words
                .first
                .iter()
                .chain(&words.second)
                .copied()
                .chain(annex.iter().map(String::as_str))
                .chain([words.approval]);
            for text in body {
                let width = font.measure(text, BODY_SIZE);
                assert!(
                    width <= page,
                    "in {code}, {text:?} is {width} wide in {page}"
                );
            }
            for text in [words.title, words.annex_title] {
                let width = font.measure(text, TITLE_SIZE);
                assert!(
                    width <= page,
                    "in {code}, {text:?} is {width} wide in {page}"
                );
            }
            let date = format!("{} {}", words.received_on, super::DATE);
            for text in [words.signature, date.as_str()] {
                let width = font.measure(text, SMALL_SIZE);
                assert!(
                    width + 8.0 <= area,
                    "in {code}, {text:?} is {width} wide in {area}"
                );
            }
        }
    }
}