Une police derrière la police

Une ligne d'états composée deux fois : avec la police de marque seule, où les marques dont elle n'a pas le dessin sortent en cases vides, puis avec une seconde police derrière elle qui les dessine.

Rust write_font_fallback.rs 471 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
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
//! Sets one line of statuses twice: above in a house font alone, below with a
//! second font behind it, so the characters the house font has no glyph for can
//! be seen appearing.
//!
//! The house font here is IBM Plex Sans, which draws a tick but neither a star,
//! an umbrella nor a cross. Set alone, those three come out as the empty box
//! every font carries for a character it does not have. Set through a chain,
//! each is handed to `DejaVu` Sans, which does have it, and the line is drawn
//! in as many fonts as it takes.
//!
//! The closing note counts the stretches the chain cuts the line into, and
//! names the two fonts as the files themselves name them.
//!
//! The words are held in `Words`, once per language, and `HQF_PDF_LANG` picks
//! which set is drawn. The four symbols are not language: they stand the same
//! in every one.
//!
//! Usage: `cargo run --example write_font_fallback -- tmp/font_fallback.pdf`
//!        `HQF_PDF_LANG=fr cargo run --example write_font_fallback --
//! tmp/police_de_secours.pdf`

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

use hqf_pdf::content::Content;
use hqf_pdf::{Document, Font, Page, TextFlow};

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

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

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

use language::Language;

/// Converts a row number to a float. A page holds far too few rows for a
/// `usize` to lose precision as an `f64`.
#[expect(
    clippy::cast_precision_loss,
    reason = "a row number on a page is a very small whole number"
)]
const fn count_f64(n: usize) -> f64 {
    n as f64
}

/// Where the committed fonts sit.
fn font_dir() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("tests")
        .join("fonts")
}

/// The four symbols the statuses are marked with, in the order they are drawn.
///
/// The tick is in both fonts; the star, the umbrella and the cross are in the
/// second alone, which is what the two settings show.
const MARKS: [&str; 4] = ["\u{2713}", "\u{2605}", "\u{2602}", "\u{2717}"];

/// What stands between two statuses on the line. A run of spaces would be set
/// as one, so the statuses are held apart by a character.
const GAP: &str = " \u{00B7} ";

/// The words the page is written in, one set per language.
///
/// What is not language stays out of it: the four symbols are marks, and the
/// two font names are what the files call themselves.
#[derive(Debug)]
struct Words {
    /// The line across the top of the page.
    title: &'static str,
    /// Why a document needs a font behind its own, in two sentences.
    why: &'static str,
    /// The heading over the setting in the house font alone.
    alone: &'static str,
    /// The heading over the setting that falls back.
    chained: &'static str,
    /// The four statuses, each drawn after its mark.
    statuses: [&'static str; 4],
    /// The closing note, cut where the count and the two font names go into it.
    note_before: &'static str,
    note_between: &'static str,
    note_joiner: &'static str,
    note_after: &'static str,
    /// The three headings over the list of marks.
    head_mark: &'static str,
    head_status: &'static str,
    head_font: &'static str,
}

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

    /// The line of statuses, each after its mark.
    fn line(&self) -> String {
        let mut line = String::new();
        for (mark, status) in MARKS.iter().zip(self.statuses) {
            if !line.is_empty() {
                line.push_str(GAP);
            }
            line.push_str(mark);
            line.push(' ');
            line.push_str(status);
        }
        line
    }

    /// The closing note, with the count of stretches and the two font names in
    /// it.
    fn note(&self, stretches: usize, house: &str, behind: &str) -> String {
        format!(
            "{}{stretches}{}{house}{}{behind}{}",
            self.note_before, self.note_between, self.note_joiner, self.note_after
        )
    }
}

/// The page in English.
const ENGLISH: Words = Words {
    title: "A font behind the font",
    why: "A house font rarely covers every character a document has to draw. \
          Without a second font behind it, a character it has no glyph for comes \
          out as the empty box below, and a reader is left guessing.\n\
          A chain hands that character to the next font instead, so the line is \
          set in as many fonts as it takes and every mark is drawn.",
    alone: "The house font alone: what it has no glyph for, it cannot draw.",
    chained: "The house font, with a second one behind it.",
    statuses: ["Paid", "Priority", "Weather hold", "Refused"],
    note_before: "The line above is cut into ",
    note_between: " stretches, drawn by two fonts: ",
    note_joiner: ", then ",
    note_after: ". The words are in the first; the marks it lacks are in the \
                 second.",
    head_mark: "Mark",
    head_status: "Status",
    head_font: "Drawn by",
};

/// The page in French.
const FRENCH: Words = Words {
    title: "Une police derrière la police",
    why: "Une police de marque couvre rarement tous les caractères qu'un document \
          doit dessiner. Sans une seconde police derrière elle, un caractère \
          qu'elle ne connaît pas sort en case vide, comme ci-dessous, et le \
          lecteur reste devant une devinette.\n\
          Une chaîne confie plutôt ce caractère à la police suivante : la ligne \
          est composée dans autant de polices qu'il le faut, et chaque marque est \
          dessinée.",
    alone: "La police de marque seule : ce qu'elle ne connaît pas, elle ne le dessine pas.",
    chained: "La police de marque, avec une seconde police derrière elle.",
    statuses: ["Payé", "Prioritaire", "Attente météo", "Refusé"],
    note_before: "La ligne du dessus est découpée en ",
    note_between: " morceaux, dessinés par deux polices : ",
    note_joiner: ", puis ",
    note_after: ". Les mots viennent de la première, les marques qui lui manquent \
                 de la seconde.",
    head_mark: "Marque",
    head_status: "État",
    head_font: "Dessinée par",
};

/// 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 of everything on the page.
const X: f64 = 60.0;

/// The width every block is broken to.
const ROOM: f64 = 475.0;

/// The baseline the title sits on.
const TITLE_TOP: f64 = 780.0;

/// The top of the block that says why the page matters.
const WHY_TOP: f64 = 750.0;

/// The top of the heading over the setting in the house font alone.
const ALONE_TOP: f64 = 650.0;

/// The top of the heading over the setting that falls back.
const CHAINED_TOP: f64 = 560.0;

/// The top of the closing note.
const NOTE_TOP: f64 = 470.0;

/// How far a setting sits below the heading over it.
const UNDER_HEADING: f64 = 34.0;

/// The size the statuses are set in.
const LINE_SIZE: f64 = 15.0;

/// The top of the list that names the font behind each mark.
const LIST_TOP: f64 = 400.0;

/// How far apart two rows of that list sit.
const ROW_DROP: f64 = 18.0;

/// Where the three columns of that list begin.
const COLUMNS: [f64; 3] = [X, X + 46.0, X + 176.0];

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let language = Language::from_environment()?;
    let words = Words::of(language);
    let line = words.line();

    let out = env::args()
        .nth(1)
        .unwrap_or_else(|| language.file_name(&out::default_path("font_fallback")));

    let house = Font::parse(fs::read(font_dir().join("IBMPlexSans-Regular.otf"))?)?;
    let behind = Font::parse(fs::read(font_dir().join("DejaVuSans.ttf"))?)?;
    let house_name = house.postscript_name().to_owned();
    let behind_name = behind.postscript_name().to_owned();

    let mut doc = Document::new();
    doc.set_license(licence::licensed());
    let plain = doc.add_font(house);
    let spare = doc.add_font(behind);
    let chain = plain.clone().falling_back_to(spare);

    let mut c = Content::new();

    let title = TextFlow::new(&plain, 18.0);
    let title_lines = title.break_lines(words.title, ROOM);
    c.begin_text();
    title.draw(&mut c, &title_lines, X, TITLE_TOP, ROOM)?;
    c.end_text();

    // The block that says why the page matters is set through the chain: it is
    // ordinary prose, and the chain draws ordinary prose exactly as the house
    // font alone would.
    let why = TextFlow::new(&chain, 10.0).leading(14.0);
    let why_lines = why.break_lines(words.why, ROOM);
    c.begin_text();
    why.draw(&mut c, &why_lines, X, WHY_TOP, ROOM)?;
    c.end_text();

    // The same line twice: once in the house font on its own, once through the
    // chain. Only the font differs.
    for (top, heading, handle) in [
        (ALONE_TOP, words.alone, &plain),
        (CHAINED_TOP, words.chained, &chain),
    ] {
        let label = TextFlow::new(&plain, 9.0);
        let label_lines = label.break_lines(heading, ROOM);
        c.begin_text();
        label.draw(&mut c, &label_lines, X, top, ROOM)?;
        c.end_text();

        let statuses = TextFlow::new(handle, LINE_SIZE);
        let status_lines = statuses.break_lines(&line, ROOM);
        c.begin_text();
        statuses.draw(&mut c, &status_lines, X, top - UNDER_HEADING, ROOM)?;
        c.end_text();
    }

    // What the chain made of the line, counted off the chain itself rather than
    // written down: the note says what the page above it holds.
    let note = words.note(chain.runs(&line).len(), &house_name, &behind_name);
    let closing = TextFlow::new(&chain, 10.0).leading(14.0);
    let closing_lines = closing.break_lines(&note, ROOM);
    c.begin_text();
    closing.draw(&mut c, &closing_lines, X, NOTE_TOP, ROOM)?;
    c.end_text();

    // Which font drew which mark, read off the chain: a mark the house font has
    // is drawn by it, and the rest by the font behind it.
    let heads = [words.head_mark, words.head_status, words.head_font];
    let head_row = TextFlow::new(&plain, 9.0);
    for (column, head) in COLUMNS.iter().zip(heads) {
        let lines = head_row.break_lines(head, ROOM);
        c.begin_text();
        head_row.draw(&mut c, &lines, *column, LIST_TOP, ROOM)?;
        c.end_text();
    }

    for (row, (mark, status)) in MARKS.iter().zip(words.statuses).enumerate() {
        let drawn_by = if chain
            .runs(mark)
            .first()
            .is_some_and(|run| run.font() == plain.name())
        {
            &house_name
        } else {
            &behind_name
        };
        let top = ROW_DROP.mul_add(-count_f64(row + 1), LIST_TOP);
        for (column, cell) in COLUMNS.iter().zip([mark, status, drawn_by.as_str()]) {
            let flow = TextFlow::new(&chain, 10.0);
            let lines = flow.break_lines(cell, ROOM);
            c.begin_text();
            flow.draw(&mut c, &lines, *column, top, ROOM)?;
            c.end_text();
        }
    }

    let mut page = Page::a4();
    page.content = c.into_bytes();
    doc.add_page(page)?;

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

#[cfg(test)]
mod tests {
    use super::{
        ALONE_TOP, CHAINED_TOP, LINE_SIZE, MARKS, NOTE_TOP, ROOM, UNDER_HEADING, WORDS, language,
    };
    use hqf_pdf::{Document, Font, TextFlow};

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

    /// The two committed fonts, added to a document of their own.
    fn fonts() -> (Document, hqf_pdf::FontHandle, hqf_pdf::FontHandle) {
        let dir = super::font_dir();
        let mut doc = Document::new();
        let house = doc.add_font(
            Font::parse(std::fs::read(dir.join("IBMPlexSans-Regular.otf")).expect("the font"))
                .expect("the font parses"),
        );
        let behind = doc.add_font(
            Font::parse(std::fs::read(dir.join("DejaVuSans.ttf")).expect("the font"))
                .expect("the font parses"),
        );
        (doc, house, behind)
    }

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

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

    /// The demonstration rests on the house font lacking three of the four
    /// marks and having the fourth: a page whose every mark were drawn by one
    /// font would show nothing at all.
    #[test]
    fn the_house_font_has_one_of_the_marks_and_not_the_other_three() {
        let (_doc, house, behind) = fonts();

        assert!(house.uncovered(MARKS[0]).is_empty(), "the tick is in both");
        for mark in &MARKS[1..] {
            assert_eq!(
                house.uncovered(mark).len(),
                1,
                "the house font has no glyph for {mark}"
            );
            assert!(
                behind.uncovered(mark).is_empty(),
                "the font behind it has one for {mark}"
            );
        }
    }

    /// The line of statuses fits the room it is given in every language, at the
    /// size it is set in.
    #[test]
    fn every_language_sets_its_statuses_on_one_line() {
        let (_doc, house, behind) = fonts();
        let chain = house.falling_back_to(behind);

        for (language, words) in WORDS {
            let line = words.line();
            let width = chain.measure(&line, LINE_SIZE);
            assert!(
                width <= ROOM,
                "{language:?} sets {width} points of statuses in {ROOM}"
            );
            assert_eq!(
                TextFlow::new(&chain, LINE_SIZE)
                    .break_lines(&line, ROOM)
                    .len(),
                1,
                "{language:?} keeps its statuses on one line"
            );
        }
    }

    /// Every language cuts its line into the same number of stretches: the
    /// marks decide where the chain changes font, and the marks are not
    /// language.
    #[test]
    fn every_language_cuts_its_line_into_the_same_stretches() {
        let (_doc, house, behind) = fonts();
        let chain = house.falling_back_to(behind);

        let counts: Vec<usize> = WORDS
            .iter()
            .map(|(_, words)| chain.runs(&words.line()).len())
            .collect();

        // Three marks the house font lacks, each with the words either side of
        // it: seven stretches in all.
        assert_eq!(counts, [7, 7]);
    }

    /// Nothing the page draws runs off the paper: every block is set at a size
    /// and in a room that hold it.
    #[test]
    fn every_language_writes_blocks_no_wider_than_the_room_they_have() {
        let (_doc, house, behind) = fonts();
        let chain = house.clone().falling_back_to(behind);

        for (language, words) in WORDS {
            for (text, size, handle) in [
                (words.title, 18.0, &house),
                (words.alone, 9.0, &house),
                (words.chained, 9.0, &house),
            ] {
                let width = handle.measure(text, size);
                assert!(
                    width <= ROOM,
                    "{language:?} writes {width} points in {ROOM}: {text}"
                );
            }

            let note = words.note(7, "IBMPlexSans-Regular", "DejaVuSans");
            let lines = TextFlow::new(&chain, 10.0)
                .leading(14.0)
                .break_lines(&note, ROOM);
            for line in &lines {
                assert!(
                    line.natural_width() <= ROOM,
                    "{language:?} sets a line of the note wider than {ROOM}"
                );
            }
        }
    }

    /// The setting that falls back stands clear of the note below it, and the
    /// setting in the house font alone stands clear of the heading under it.
    #[test]
    fn nothing_the_page_draws_lands_on_what_comes_after_it() {
        let (_doc, house, behind) = fonts();
        let chain = house.falling_back_to(behind);

        for (language, words) in WORDS {
            let flow = TextFlow::new(&chain, LINE_SIZE);
            let lines = flow.break_lines(&words.line(), ROOM);
            let bottom = CHAINED_TOP - UNDER_HEADING - flow.height(&lines);
            assert!(
                bottom > NOTE_TOP,
                "{language:?} sets its statuses down to {bottom}, over a note at {NOTE_TOP}"
            );
            assert!(
                ALONE_TOP - UNDER_HEADING - flow.height(&lines) > CHAINED_TOP,
                "{language:?} sets its first line of statuses over the heading below it"
            );
        }
    }
}