write_shrink_to_fit.rs

The Rust program of Badges of one size, names of every length. Eight badges cut to the same size, carrying names that run from two short words to a double-barrelled surname. Each name is asked what size it fits its box at, and the size it settled on is printed underneath.

Rust 504 lines
  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
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
//! Prints a sheet of conference badges whose boxes are all one size and whose
//! names are not, letting each name find the size at which it fits.
//!
//! A badge is a rectangle a printer cut before anyone knew who would wear it.
//! The name that goes in it is whatever the register holds: two short words for
//! one guest, a double-barrelled surname for the next. Asking for a size that
//! suits the longest name would leave the short ones lost in their boxes, and
//! asking for one that suits the short names would push the long ones out of
//! theirs.
//!
//! Each name here is therefore handed its box and asked what size it fits at.
//! The sizes step down by a quarter of a point until the whole of the name
//! stays inside the box on both counts, and the badge is set at the first size
//! that holds. The size each one settled on is printed under its badge, so the
//! sheet says what it did.
//!
//! A floor stops the shrinking, and the last panel shows what happens at it: a
//! name too long for a luggage tag comes back at the floor whether it fits
//! there or not, the tag is drawn clipped to its own edges, and the caller
//! decides what to do about it.
//!
//! The words are held in `Words`, once per language, and `HQF_PDF_LANG` picks
//! which set is drawn. The guests' names are the same in both.
//!
//! Usage: `cargo run --example write_shrink_to_fit -- [out.pdf] [face.ttf]`
//!        `HQF_PDF_LANG=fr cargo run --example write_shrink_to_fit`

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

use hqf_pdf::content::Content;
use hqf_pdf::{Align, Document, Font, FontHandle, Page, Rgb, 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 sheet is set in when none is given on the command line: the one
/// committed for the tests, so the example runs on any machine.
fn default_font() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("tests")
        .join("fonts")
        .join("DejaVuSans.ttf")
}

/// How many badges the sheet carries.
const BADGE_COUNT: usize = 8;

/// The guests the sheet is printed for, in the order the badges are laid out.
///
/// A name is a name in every language, so these stay out of `Words`. They run
/// from two short words to a double-barrelled surname longer than the box.
const GUESTS: [&str; BADGE_COUNT] = [
    "Ana Ruiz",
    "Wei Chen",
    "Marguerite Vandenbossche",
    "Jean-Baptiste Delaunay",
    "Konstantinos Papadopoulos",
    "Aleksandra Wiśniewska-Kowalczyk",
    "Åsa Lindqvist",
    "Mohammed Al-Rashid ibn Saleh",
];

/// The name on the luggage tag, which no size fits.
const TAGGED: &str = "Bartholomew Christopher Fitzwilliam-Harrington";

/// The words the sheet is written in, one set per language.
#[derive(Debug)]
struct Words {
    /// The sheet's title.
    title: &'static str,
    /// What the sheet is about.
    lead: &'static str,
    /// The label over the eight badges.
    sheet: &'static str,
    /// What each guest is at the conference, in the order `GUESTS` names them.
    roles: [&'static str; BADGE_COUNT],
    /// The line under a badge saying what size its name came out at.
    settled: &'static str,
    /// The label over the luggage tag.
    floor: &'static str,
    /// What the floor does.
    floored: &'static str,
    /// The line under the luggage tag.
    clipped: &'static str,
    /// What the sheet leaves the reader with.
    caveat: &'static str,
}

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

/// The sheet in English.
const ENGLISH: Words = Words {
    title: "Badges of one size, names of every length",
    lead: "The eight boxes below are the same box, cut before anyone knew who \
           would wear it. Each name is handed the box it has to fit and asked \
           what size it fits at: the sizes step down by a quarter of a point, \
           each one broken to the width of the box and weighed against its \
           height, and the name is set at the first size that holds. Nothing \
           here is measured by hand, and no name is shortened.",
    sheet: "One box, eight names",
    roles: [
        "Speaker",
        "Attendee",
        "Session chair",
        "Press",
        "Workshop host",
        "Organiser",
        "Volunteer",
        "Exhibitor",
    ],
    settled: "Set at %size% points",
    floor: "Where the shrinking stops",
    floored: "A floor is given along with the box, and the size never goes \
              under it: a name shrunk until it disappears is worse than a name \
              that runs over. The luggage tag below is far too small for the \
              name it carries, so the floor is what comes back, and the tag is \
              drawn at the floor and clipped to its own edges. What to do about \
              a box no size fills is the caller's to decide: draw it clipped, as \
              here, print the name on a second line, or hand the register back \
              a name it has to shorten.",
    clipped: "At the floor, clipped to the tag",
    caveat: "The size that comes back is never larger than the one asked for, \
             so a short name is set at the size the design chose and only a long \
             one comes out smaller. Every badge on this sheet went through the \
             same call, and the size printed under each is the one it handed \
             back.",
};

/// The sheet in French.
const FRENCH: Words = Words {
    title: "Des badges d'une seule taille, des noms de toutes les longueurs",
    lead: "Les huit cadres ci-dessous sont le même cadre, découpé avant qu'on \
           sache qui le porterait. Chaque nom reçoit le cadre qu'il doit \
           remplir et on lui demande à quelle taille il y tient : les tailles \
           descendent d'un quart de point, chacune coupée à la largeur du cadre \
           et pesée contre sa hauteur, et le nom est composé à la première \
           taille qui tient. Rien ici n'est mesuré à la main, et aucun nom \
           n'est raccourci.",
    sheet: "Un cadre, huit noms",
    roles: [
        "Conférencière",
        "Participant",
        "Présidente de séance",
        "Presse",
        "Animateur d'atelier",
        "Organisatrice",
        "Bénévole",
        "Exposant",
    ],
    settled: "Composé à %size% points",
    floor: "Là où la réduction s'arrête",
    floored: "Un plancher est donné avec le cadre, et la taille ne descend \
              jamais dessous : un nom réduit jusqu'à disparaître vaut moins \
              qu'un nom qui déborde. L'étiquette de bagage ci-dessous est bien \
              trop petite pour le nom qu'elle porte : c'est donc le plancher qui \
              revient, et l'étiquette est dessinée au plancher puis coupée à ses \
              propres bords. Ce qu'on fait d'un cadre qu'aucune taille ne \
              remplit appartient à l'appelant : le dessiner coupé, comme ici, \
              porter le nom sur une seconde ligne, ou rendre au registre un nom \
              qu'il lui faut abréger.",
    clipped: "Au plancher, coupé à l'étiquette",
    caveat: "La taille qui revient n'est jamais plus grande que celle demandée : \
             un nom court est composé à la taille que la maquette a choisie, et \
             seul un nom long sort plus petit. Chaque badge de cette planche est \
             passé par le même appel, et la taille imprimée sous chacun est celle \
             qu'il a rendue.",
};

/// 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 LEFT: f64 = 72.0;

/// How wide a block of text is, and how wide the two columns of badges are
/// together.
const WIDTH: f64 = 451.0;

/// How wide one badge is.
const BADGE_WIDTH: f64 = 216.0;

/// How tall one badge is.
const BADGE_HEIGHT: f64 = 58.0;

/// How far apart the two columns of badges stand.
const BADGE_GAP: f64 = WIDTH - 2.0 * BADGE_WIDTH;

/// How far a badge's contents sit inside its edges.
const INSET: f64 = 10.0;

/// How wide a badge's contents are, between its two insets.
const INNER_WIDTH: f64 = BADGE_WIDTH - INSET - INSET;

/// The box a name is fitted into, inside the badge.
const NAME_HEIGHT: f64 = 27.0;

/// The size a name is asked for before it is asked to fit.
const NAME_SIZE: f64 = 22.0;

/// The size a name is never set below.
const FLOOR: f64 = 8.0;

/// How wide the luggage tag is.
const TAG_WIDTH: f64 = 100.0;

/// How tall the luggage tag is.
const TAG_HEIGHT: f64 = 20.0;

/// The grey the sheet draws its second-rank words in.
const GREY: Rgb = Rgb::gray(0.42);

/// The grey the badges are outlined in.
const OUTLINE: Rgb = Rgb::gray(0.55);

/// Sets a block of words with its first baseline at `top`, and hands back the
/// ordinate the block ends at.
fn block(
    c: &mut Content,
    flow: &TextFlow,
    x: f64,
    top: f64,
    width: f64,
    text: &str,
) -> Result<f64, Box<dyn std::error::Error>> {
    let lines = flow.break_lines(text, width);
    c.begin_text();
    flow.draw(c, &lines, x, top, width)?;
    c.end_text();
    Ok(top - flow.height(&lines))
}

/// The size the name of guest `index` is set at, inside a badge.
fn badge_size(handle: &FontHandle, index: usize) -> f64 {
    TextFlow::new(handle, NAME_SIZE)
        .align(Align::Center)
        .fit_size(GUESTS[index], INNER_WIDTH, NAME_HEIGHT, FLOOR)
}

/// Draws one badge, its box at `(x, top)`, and hands back the ordinate its
/// caption ends at.
fn badge(
    c: &mut Content,
    handle: &FontHandle,
    words: &Words,
    index: usize,
    x: f64,
    top: f64,
) -> Result<f64, Box<dyn std::error::Error>> {
    c.set_stroke(OUTLINE)?;
    c.set_line_width(0.5)?;
    c.rect(x, top - BADGE_HEIGHT, BADGE_WIDTH, BADGE_HEIGHT)?;
    c.stroke();

    let size = badge_size(handle, index);
    let name = TextFlow::new(handle, size).align(Align::Center);
    block(c, &name, x + INSET, top - INSET, INNER_WIDTH, GUESTS[index])?;

    let role = TextFlow::new(handle, 9.0).align(Align::Center).color(GREY);
    block(
        c,
        &role,
        x + INSET,
        top - INSET - NAME_HEIGHT,
        INNER_WIDTH,
        words.roles[index],
    )?;

    let caption = TextFlow::new(handle, 8.0).align(Align::Center).color(GREY);
    let said = words.settled.replace("%size%", &points(size));
    block(c, &caption, x, top - BADGE_HEIGHT - 4.0, BADGE_WIDTH, &said)
}

/// Draws the luggage tag no size fits, its box at `(x, top)`, and hands back
/// the ordinate its caption ends at.
fn tag(
    c: &mut Content,
    handle: &FontHandle,
    words: &Words,
    x: f64,
    top: f64,
) -> Result<f64, Box<dyn std::error::Error>> {
    let size = TextFlow::new(handle, NAME_SIZE)
        .align(Align::Center)
        .fit_size(TAGGED, TAG_WIDTH - 2.0, TAG_HEIGHT, FLOOR);

    c.save_state();
    c.rect(x, top - TAG_HEIGHT, TAG_WIDTH, TAG_HEIGHT)?;
    c.clip().end_path();
    let name = TextFlow::new(handle, size).align(Align::Center);
    block(c, &name, x + 1.0, top, TAG_WIDTH - 2.0, TAGGED)?;
    c.restore_state();

    c.set_stroke(OUTLINE)?;
    c.set_line_width(0.5)?;
    c.rect(x, top - TAG_HEIGHT, TAG_WIDTH, TAG_HEIGHT)?;
    c.stroke();

    let caption = TextFlow::new(handle, 8.0).color(GREY);
    block(
        c,
        &caption,
        x + TAG_WIDTH + 12.0,
        top - 4.0,
        WIDTH - TAG_WIDTH - 12.0,
        words.clipped,
    )
}

/// Draws the whole sheet.
fn build(words: &Words, font: Font) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
    let mut doc = Document::new();
    doc.set_license(licence::licensed());
    let handle = doc.add_font(font);

    let title = TextFlow::new(&handle, 18.0);
    let lead = TextFlow::new(&handle, 10.0);
    let label = TextFlow::new(&handle, 12.0);

    let mut c = Content::new();
    let mut top = 790.0;
    top = block(&mut c, &title, LEFT, top, WIDTH, words.title)? - 12.0;
    top = block(&mut c, &lead, LEFT, top, WIDTH, words.lead)? - 22.0;
    top = block(&mut c, &label, LEFT, top, WIDTH, words.sheet)? - 14.0;

    for pair in 0..BADGE_COUNT / 2 {
        let mut lowest = top;
        for column in 0..2 {
            let x = (BADGE_WIDTH + BADGE_GAP).mul_add(f64::from(column), LEFT);
            let index = pair * 2 + usize::try_from(column).unwrap_or(0);
            lowest = badge(&mut c, &handle, words, index, x, top)?.min(lowest);
        }
        top = lowest - 12.0;
    }

    top -= 10.0;
    top = block(&mut c, &label, LEFT, top, WIDTH, words.floor)? - 12.0;
    top = block(&mut c, &lead, LEFT, top, WIDTH, words.floored)? - 20.0;
    top = tag(&mut c, &handle, words, LEFT, top)? - 22.0;

    let closing = TextFlow::new(&handle, 9.0).color(GREY);
    block(&mut c, &closing, LEFT, top, WIDTH, words.caveat)?;

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

/// A size in points, as the sheet prints it: two decimals, so the quarter-point
/// step the sizes come in is visible.
fn points(size: f64) -> String {
    format!("{size:.2}")
}

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

    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 out = args
        .next()
        .unwrap_or_else(|| language.file_name(&out::default_path("shrink_to_fit")));
    let face = args.next().map_or_else(default_font, PathBuf::from);

    let font = Font::parse(fs::read(&face)?)?;
    let drawn = build(words, font)?;

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

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

    use super::{
        BADGE_COUNT, FLOOR, GUESTS, INNER_WIDTH, NAME_HEIGHT, NAME_SIZE, TAG_HEIGHT, TAG_WIDTH,
        TAGGED, WORDS, badge_size, default_font, language, points,
    };

    /// The lines two languages are allowed to write the same way. There are
    /// none: every word is a word of the language it is written in.
    const SPARED: [&str; 0] = [];

    /// The committed font, parsed.
    fn fixture() -> Font {
        Font::parse(std::fs::read(default_font()).expect("the committed font is there"))
            .expect("the committed font parses")
    }

    #[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 size a badge settles on holds the whole of the name: every line
    /// stays inside the width, and the block ends above the bottom.
    #[test]
    fn every_name_stays_inside_the_badge_at_the_size_it_settled_on() {
        let mut doc = Document::new();
        let handle = doc.add_font(fixture());

        for (index, guest) in GUESTS.iter().enumerate() {
            let size = badge_size(&handle, index);
            let flow = TextFlow::new(&handle, size).align(Align::Center);
            let lines = flow.break_lines(guest, INNER_WIDTH);

            assert!(
                flow.height(&lines) <= NAME_HEIGHT,
                "{guest} is {:.2} points tall at {size:.2}, and the badge has {NAME_HEIGHT:.2}",
                flow.height(&lines)
            );
            for line in &lines {
                assert!(
                    line.natural_width() <= INNER_WIDTH,
                    "{guest} draws a line {:.2} points wide at {size:.2}, and the badge \
                     has {INNER_WIDTH:.2}",
                    line.natural_width()
                );
            }
        }
    }

    /// The sheet is only worth printing if the boxes really do disagree: a
    /// short name keeps the size the design asked for, and a long one comes
    /// back smaller.
    #[test]
    fn the_shortest_name_keeps_the_size_asked_for_and_the_longest_does_not() {
        let mut doc = Document::new();
        let handle = doc.add_font(fixture());
        let sizes: Vec<f64> = (0..BADGE_COUNT).map(|i| badge_size(&handle, i)).collect();

        assert!(
            (sizes[0] - NAME_SIZE).abs() < f64::EPSILON,
            "{} came back at {:.2}, and it fits at {NAME_SIZE:.2}",
            GUESTS[0],
            sizes[0]
        );
        let longest = sizes.last().copied().expect("the sheet carries badges");
        assert!(
            longest < NAME_SIZE && longest > FLOOR,
            "{} came back at {longest:.2}, which is neither shrunk nor floored",
            GUESTS[BADGE_COUNT - 1]
        );
    }

    /// The luggage tag is the box no size fills, which is what makes the last
    /// panel worth drawing.
    #[test]
    fn the_name_on_the_luggage_tag_comes_back_at_the_floor() {
        let mut doc = Document::new();
        let handle = doc.add_font(fixture());
        let size = TextFlow::new(&handle, NAME_SIZE)
            .align(Align::Center)
            .fit_size(TAGGED, TAG_WIDTH - 2.0, TAG_HEIGHT, FLOOR);

        assert!(
            (size - FLOOR).abs() < f64::EPSILON,
            "the tag came back at {size:.2}, and no size fits it"
        );
    }

    #[test]
    fn a_size_is_printed_with_the_quarter_point_it_came_in() {
        assert_eq!(points(22.0), "22.00");
        assert_eq!(points(10.25), "10.25");
        assert_eq!(points(8.5), "8.50");
    }
}