A sheet of shelf labels

A sheet of die-cut shelf labels, priced and barcoded, with cut marks in the margins.

Rust write_labels.rs 753 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
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
//! Draws a sheet of peel-off labels: a grid of shelf labels, each carrying an
//! article name, its price, and the article number a till reads, closed by a
//! row of warehouse bin labels.
//!
//! This is the document a shop prints by the ream. The grid is driven by a
//! stock description — how many labels across and down, how big one is, and how
//! far apart their corners sit — so a reader retargets the sheet to their own
//! die-cut stock by changing six numbers. Every label is outlined and the page
//! margins carry cut marks, so what is drawn lines up with what is cut.
//!
//! Whether the codes scan is not for the page to say, and not for an eye: a
//! scanner says. `scripts/check_barcode.sh` points a decoder at the rendered
//! sheet and reads back every article number and every bin.
//!
//! Every word the page draws is held in `Words`, once per language, and
//! `HQF_PDF_LANG` picks which one is drawn. The prices, the article numbers,
//! the aisle and rack codes and what a scanner reads off the sheet are the same
//! in both.
//!
//! Usage: `cargo run --example write_labels -- tmp/labels.pdf [font.ttf]`
//!        `HQF_PDF_LANG=fr cargo run --example write_labels -- tmp/etiquettes.pdf`

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

use hqf_pdf::barcode::QUIET_ZONE as BAR_QUIET_ZONE;
use hqf_pdf::content::Content;
use hqf_pdf::cos::Name;
use hqf_pdf::datamatrix::QUIET_ZONE as MATRIX_QUIET_ZONE;
use hqf_pdf::{DataMatrix, Document, Ean13, Font, FontHandle, Page, Rgb, UpcA};

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

/// A4, in points.
const PAGE_WIDTH: f64 = 595.276;
const PAGE_HEIGHT: f64 = 841.89;

/// The die-cut stock the sheet is laid out for: how many labels across and
/// down, how big one is, and how far apart two neighbours' corners sit. The
/// pitch is the label plus the gutter the cutter leaves between two of them.
const COLUMNS: u32 = 3;
const ROWS: u32 = 5;
const LABEL_WIDTH: f64 = 170.0;
const LABEL_HEIGHT: f64 = 120.0;
const COLUMN_PITCH: f64 = 178.0;
const ROW_PITCH: f64 = 128.0;

/// The top edge of the first row of labels.
const SHEET_TOP: f64 = 742.0;

/// How long a cut mark is and how far it stands off the grid, in points.
const MARK_LENGTH: f64 = 11.0;
const MARK_OFFSET: f64 = 5.0;

/// The width of the narrowest bar, in points, and the side of one Data Matrix
/// module. Everything else in a code is a whole number of these.
const MODULE: f64 = 1.0;
const MATRIX_MODULE: f64 = 3.0;

/// The ink the prices are drawn in, the grey of the small print, and the black
/// the names and the codes are drawn in.
const ACCENT: Rgb = Rgb {
    r: 0.11,
    g: 0.33,
    b: 0.55,
};
const MUTED: Rgb = Rgb {
    r: 0.42,
    g: 0.42,
    b: 0.45,
};
const INK: Rgb = Rgb {
    r: 0.1,
    g: 0.1,
    b: 0.12,
};

/// The symbology an article number is drawn in, holding the digits without
/// their check digit.
enum Article {
    /// Twelve digits, drawn as an EAN-13 barcode.
    Ean13(&'static str),
    /// Eleven digits, drawn as a UPC-A barcode.
    UpcA(&'static str),
}

/// How many labels the sheet prints.
const LABEL_COUNT: usize = 15;

/// What a label carries under its name, apart from the words it is written in.
enum Face {
    /// A shelf label: the price of one pack, and the article number the till
    /// reads.
    Shelf { price: f64, article: Article },
    /// A bin label: where in the warehouse the stock stands, and the code a
    /// picker scans on arriving there.
    Bin {
        positions: [&'static str; 3],
        code: &'static str,
    },
}

/// The faces the sheet prints, in reading order: the grid fills across a row
/// before dropping to the next, and `Words::names` names them in that order.
const LABELS: [Face; LABEL_COUNT] = [
    Face::Shelf {
        price: 18.90,
        article: Article::Ean13("347000100013"),
    },
    Face::Shelf {
        price: 11.90,
        article: Article::Ean13("347000100020"),
    },
    Face::Shelf {
        price: 2.40,
        article: Article::Ean13("347000100037"),
    },
    Face::Shelf {
        price: 6.90,
        article: Article::Ean13("347000100044"),
    },
    Face::Shelf {
        price: 2.45,
        article: Article::Ean13("347000100051"),
    },
    Face::Shelf {
        price: 3.20,
        article: Article::Ean13("347000100068"),
    },
    Face::Shelf {
        price: 8.90,
        article: Article::Ean13("347000100075"),
    },
    Face::Shelf {
        price: 5.90,
        article: Article::Ean13("347000100082"),
    },
    Face::Shelf {
        price: 3.90,
        article: Article::Ean13("347000100099"),
    },
    Face::Shelf {
        price: 4.60,
        article: Article::Ean13("347000100105"),
    },
    Face::Shelf {
        price: 1.90,
        article: Article::Ean13("347000100112"),
    },
    Face::Shelf {
        price: 6.90,
        article: Article::Ean13("347000100129"),
    },
    Face::Shelf {
        price: 9.90,
        article: Article::UpcA("01234567890"),
    },
    Face::Bin {
        positions: ["A12", "R04", "S03"],
        code: "LOC:A12-R04-S03",
    },
    Face::Bin {
        positions: ["B07", "R11", "S02"],
        code: "LOC:B07-R11-S02",
    },
];

/// Every word the page draws, in one language.
///
/// What is not language stays out of it: the prices, the article numbers, the
/// aisle and rack codes and what the sheet is laid out for are drawn from
/// constants of their own and read the same in every language.
#[derive(Debug)]
struct Words {
    /// The words the document's title is built from.
    title: &'static str,
    /// The words set at the head of the sheet, over the grid.
    heading: &'static str,
    /// Which sheet of the run this one is.
    sheet_number: &'static str,
    /// What the labels are counted in, and what stands either side of the size
    /// of one, in the line under the heading.
    labels: &'static str,
    stock_before: &'static str,
    stock_after: &'static str,
    /// What stands before the day the sheet was printed, and that day.
    printed_label: &'static str,
    printed_on: &'static str,
    /// What each label names, in the order `LABELS` prints them.
    names: [&'static str; LABEL_COUNT],
    /// The line under each name: what one pack holds and what it comes to by
    /// weight or by volume, and, on a bin label, what the code beneath it is.
    subtitles: [&'static str; LABEL_COUNT],
    /// What stands before each of a bin label's three position codes.
    positions: [&'static str; 3],
    /// The sentence at the foot of the sheet, cut where the day the prices hold
    /// until goes into it.
    valid_before: &'static str,
    valid_until: &'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: "Shelf label sheet, 3 × 5 die-cut",
    heading: "SHELF LABELS",
    sheet_number: "Sheet 3 of 8",
    labels: "labels",
    stock_before: "on",
    stock_after: "pt die-cut stock",
    printed_label: "Printed",
    printed_on: "20 July 2026",
    names: [
        "Espresso beans, whole",
        "Olive oil, extra virgin",
        "Sea salt, coarse",
        "Basmati rice",
        "Dark chocolate, 70 %",
        "Orange juice, pressed",
        "Wildflower honey",
        "Green tea, loose leaf",
        "Sparkling water",
        "Sunflower seed oil",
        "Wholegrain pasta",
        "Roasted almonds",
        "Maple syrup, imported",
        "Dry goods reserve",
        "Chilled reserve",
    ],
    subtitles: [
        "1 kg pack · 18.90 EUR/kg",
        "750 ml bottle · 15.87 EUR/l",
        "1 kg pack · 2.40 EUR/kg",
        "2 kg pack · 3.45 EUR/kg",
        "100 g bar · 24.50 EUR/kg",
        "1 l carton · 3.20 EUR/l",
        "500 g jar · 17.80 EUR/kg",
        "125 g tin · 47.20 EUR/kg",
        "6 × 1 l · 0.65 EUR/l",
        "1 l bottle · 4.60 EUR/l",
        "500 g pack · 3.80 EUR/kg",
        "250 g pack · 27.60 EUR/kg",
        "250 ml bottle · 39.60 EUR/l",
        "BIN LOCATION",
        "BIN LOCATION",
    ],
    positions: ["Aisle", "Rack", "Shelf"],
    valid_before: "Prices valid until",
    valid_until: "31 August 2026",
};

/// The sheet in French.
const FRENCH: Words = Words {
    title: "Planche d'étiquettes de rayon, prédécoupée 3 × 5",
    heading: "ÉTIQUETTES DE RAYON",
    sheet_number: "Feuille 3 sur 8",
    labels: "étiquettes",
    stock_before: "sur planche prédécoupée",
    stock_after: "pt",
    printed_label: "Imprimée le",
    printed_on: "20 juillet 2026",
    names: [
        "Café en grains, espresso",
        "Huile d'olive vierge extra",
        "Gros sel de mer",
        "Riz basmati",
        "Chocolat noir 70 %",
        "Jus d'orange pressé",
        "Miel toutes fleurs",
        "Thé vert en vrac",
        "Eau gazeuse",
        "Huile de tournesol",
        "Pâtes complètes",
        "Amandes grillées",
        "Sirop d'érable importé",
        "Réserve épicerie sèche",
        "Réserve froid positif",
    ],
    subtitles: [
        "sachet de 1 kg · 18.90 EUR/kg",
        "bouteille de 750 ml · 15.87 EUR/l",
        "sachet de 1 kg · 2.40 EUR/kg",
        "sachet de 2 kg · 3.45 EUR/kg",
        "tablette de 100 g · 24.50 EUR/kg",
        "brique de 1 l · 3.20 EUR/l",
        "pot de 500 g · 17.80 EUR/kg",
        "boîte de 125 g · 47.20 EUR/kg",
        "pack de 6 × 1 l · 0.65 EUR/l",
        "bouteille de 1 l · 4.60 EUR/l",
        "paquet de 500 g · 3.80 EUR/kg",
        "sachet de 250 g · 27.60 EUR/kg",
        "bouteille de 250 ml · 39.60 EUR/l",
        "EMPLACEMENT",
        "EMPLACEMENT",
    ],
    positions: ["Allée", "Travée", "Étagère"],
    valid_before: "Prix valables jusqu'au",
    valid_until: "31 août 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); 2] =
    [(Language::English, &ENGLISH), (Language::French, &FRENCH)];

/// The left edge of the first column, which is what centres the grid on the
/// page.
fn sheet_left() -> f64 {
    (PAGE_WIDTH - f64::from(COLUMNS - 1).mul_add(COLUMN_PITCH, LABEL_WIDTH)) / 2.0
}

/// The last digit of an EAN-13 or UPC-A number: the one that makes the weighted
/// sum of the whole a multiple of ten. Read from the right, the digit just
/// before it weighs three and the weights alternate from there.
fn check_digit(digits: &str) -> u32 {
    let weighted: u32 = digits
        .chars()
        .rev()
        .enumerate()
        .filter_map(|(index, digit)| {
            digit
                .to_digit(10)
                .map(|value| if index % 2 == 0 { value * 3 } else { value })
        })
        .sum();
    (10 - weighted % 10) % 10
}

/// The number as it is printed under the bars: the digits given, followed by
/// the check digit they imply.
fn full_number(digits: &str) -> String {
    format!("{digits}{}", check_digit(digits))
}

/// A price, as a shelf label writes it: "18.90 EUR".
fn price_text(value: f64) -> String {
    format!("{value:.2} EUR")
}

/// Draws a line of text, left-aligned, in a colour of its own.
fn text(content: &mut Content, font: &FontHandle, size: f64, x: f64, y: f64, color: Rgb, s: &str) {
    let _ = content.set_fill(color);
    content.begin_text();
    let _ = content.set_font(font.name(), size);
    let _ = content.text_origin(x, y);
    content.show_glyphs(&font.glyphs(s));
    content.end_text();
}

/// Draws a line of text whose right edge sits at `right`.
fn text_right(
    content: &mut Content,
    font: &FontHandle,
    size: f64,
    right: f64,
    y: f64,
    color: Rgb,
    s: &str,
) {
    text(
        content,
        font,
        size,
        right - font.measure(s, size),
        y,
        color,
        s,
    );
}

/// Draws a line of text centred on `centre`.
fn text_centred(
    content: &mut Content,
    font: &FontHandle,
    size: f64,
    centre: f64,
    y: f64,
    color: Rgb,
    s: &str,
) {
    text(
        content,
        font,
        size,
        centre - font.measure(s, size) / 2.0,
        y,
        color,
        s,
    );
}

/// A straight line between two points, in the given ink and thickness.
fn line(
    content: &mut Content,
    from: (f64, f64),
    to: (f64, f64),
    width: f64,
    color: Rgb,
) -> Result<(), hqf_pdf::Error> {
    content.set_stroke(color)?;
    content.set_line_width(width)?;
    content.move_to(from.0, from.1)?;
    content.line_to(to.0, to.1)?;
    content.stroke();
    Ok(())
}

/// Draws the cut marks: a tick in the margin against every column edge and
/// every row edge, which is what a guillotine or a die is registered against.
fn cut_marks(content: &mut Content) -> Result<(), hqf_pdf::Error> {
    let left = sheet_left();
    let grid_bottom = SHEET_TOP - f64::from(ROWS - 1).mul_add(ROW_PITCH, LABEL_HEIGHT);
    let grid_right = f64::from(COLUMNS - 1).mul_add(COLUMN_PITCH, left + LABEL_WIDTH);
    let ink = Rgb::gray(0.45);

    for column in 0..COLUMNS {
        let label_left = f64::from(column).mul_add(COLUMN_PITCH, left);
        for x in [label_left, label_left + LABEL_WIDTH] {
            let above = SHEET_TOP + MARK_OFFSET;
            let below = grid_bottom - MARK_OFFSET;
            line(content, (x, above), (x, above + MARK_LENGTH), 0.5, ink)?;
            line(content, (x, below), (x, below - MARK_LENGTH), 0.5, ink)?;
        }
    }

    for row in 0..ROWS {
        let label_top = f64::from(row).mul_add(-ROW_PITCH, SHEET_TOP);
        for y in [label_top, label_top - LABEL_HEIGHT] {
            let outer = left - MARK_OFFSET;
            let far = grid_right + MARK_OFFSET;
            line(content, (outer, y), (outer - MARK_LENGTH, y), 0.5, ink)?;
            line(content, (far, y), (far + MARK_LENGTH, y), 0.5, ink)?;
        }
    }

    Ok(())
}

/// Draws one label's die-cut outline, from its lower-left corner.
fn outline(content: &mut Content, x: f64, bottom: f64) -> Result<(), hqf_pdf::Error> {
    content.set_stroke(Rgb::gray(0.74))?;
    content.set_line_width(0.4)?;
    content.rect(x, bottom, LABEL_WIDTH, LABEL_HEIGHT)?;
    content.stroke();
    Ok(())
}

/// Draws a shelf label's face: the price, the price per unit, and the article
/// number as bars with its digits under them.
fn shelf_face(
    content: &mut Content,
    font: &FontHandle,
    x: f64,
    bottom: f64,
    subtitle: &str,
    price: f64,
    article: &Article,
) -> Result<(), Box<dyn Error>> {
    text(content, font, 7.5, x + 12.0, bottom + 92.0, MUTED, subtitle);
    text(
        content,
        font,
        18.0,
        x + 12.0,
        bottom + 68.0,
        ACCENT,
        &price_text(price),
    );

    let (modules, printed) = match article {
        Article::Ean13(digits) => {
            let printed = full_number(digits);
            (Ean13::new(&printed)?.module_count(), printed)
        }
        Article::UpcA(digits) => {
            let printed = full_number(digits);
            (UpcA::new(&printed)?.module_count(), printed)
        }
    };

    // A barcode whose margin a neighbouring label eats does not scan. The bars
    // are centred in the label, and what the centring leaves either side is
    // held against the quiet zone the symbology asks for.
    let bars_width = f64::from(u32::try_from(modules)?) * MODULE;
    let side_margin = (LABEL_WIDTH - bars_width) / 2.0;
    let quiet = f64::from(u32::try_from(BAR_QUIET_ZONE)?) * MODULE;
    if side_margin < quiet {
        return Err("the label is narrower than the barcode and its quiet zone".into());
    }

    // Bars are read by the contrast between them and the paper: they are drawn
    // in black whatever ink the price above them was set in.
    let bars_left = x + side_margin;
    content.set_fill(Rgb::gray(0.0))?;
    match article {
        Article::Ean13(digits) => {
            let code = Ean13::new(&full_number(digits))?;
            content.draw_ean13(&code, bars_left, bottom + 22.0, bars_width, 30.0)?;
        }
        Article::UpcA(digits) => {
            let code = UpcA::new(&full_number(digits))?;
            content.draw_upc_a(&code, bars_left, bottom + 22.0, bars_width, 30.0)?;
        }
    }

    text_centred(
        content,
        font,
        7.5,
        x + LABEL_WIDTH / 2.0,
        bottom + 11.0,
        INK,
        &printed,
    );
    Ok(())
}

/// Draws a bin label's face: the Data Matrix code a picker scans, and beside it
/// the aisle, rack and shelf a human reads.
fn bin_face(
    content: &mut Content,
    font: &FontHandle,
    corner: (f64, f64),
    words: &Words,
    subtitle: &str,
    positions: &[&str; 3],
    carried: &str,
) -> Result<(), Box<dyn Error>> {
    let (x, bottom) = corner;
    text(content, font, 7.5, x + 12.0, bottom + 92.0, MUTED, subtitle);

    let code = DataMatrix::new(carried)?;
    content.set_fill(Rgb::gray(0.0))?;
    let side = f64::from(u32::try_from(code.size())?) * MATRIX_MODULE;
    let quiet = f64::from(u32::try_from(MATRIX_QUIET_ZONE)?) * MATRIX_MODULE;

    // The quiet zone is the caller's: the code is drawn that far inside the
    // label's left margin, and the placement lines start clear of it.
    content.draw_datamatrix(&code, x + 12.0 + quiet, bottom + 26.0, side)?;

    let lines_left = x + 12.0 + quiet.mul_add(2.0, side) + 12.0;
    for (index, (label, placed)) in words.positions.iter().zip(positions).enumerate() {
        let y = f64::from(u32::try_from(index)?).mul_add(-13.0, bottom + 70.0);
        text(
            content,
            font,
            9.0,
            lines_left,
            y,
            INK,
            &format!("{label} {placed}"),
        );
    }

    text(content, font, 7.0, x + 12.0, bottom + 11.0, INK, carried);
    Ok(())
}

/// Draws the head of the sheet: who printed it, what stock it is laid out for,
/// and how many labels it carries.
fn header(content: &mut Content, font: &FontHandle, words: &Words) -> Result<(), hqf_pdf::Error> {
    let left = sheet_left();
    let right = f64::from(COLUMNS - 1).mul_add(COLUMN_PITCH, left + LABEL_WIDTH);

    text(content, font, 13.0, left, 800.0, INK, "HQF Development");
    text(
        content,
        font,
        7.5,
        left,
        788.0,
        MUTED,
        "High Quality Foundations · www.hqf.fr",
    );

    text_right(content, font, 16.0, right, 799.0, ACCENT, words.heading);
    text_right(
        content,
        font,
        8.0,
        right,
        785.0,
        MUTED,
        &format!(
            "{} · {} {} · {COLUMNS} × {ROWS} {} {LABEL_WIDTH:.0} × \
             {LABEL_HEIGHT:.0} {}",
            words.sheet_number,
            LABELS.len(),
            words.labels,
            words.stock_before,
            words.stock_after
        ),
    );
    text_right(
        content,
        font,
        8.0,
        right,
        773.0,
        MUTED,
        &format!("{} {}", words.printed_label, words.printed_on),
    );

    line(content, (left, 762.0), (right, 762.0), 1.0, ACCENT)
}

/// Draws the foot of the sheet: how long the prices hold, and who to send the
/// sheet back to.
fn footer(content: &mut Content, font: &FontHandle, words: &Words) -> Result<(), hqf_pdf::Error> {
    let left = sheet_left();
    let right = f64::from(COLUMNS - 1).mul_add(COLUMN_PITCH, left + LABEL_WIDTH);

    line(content, (left, 78.0), (right, 78.0), 0.5, Rgb::gray(0.8))?;
    let mentions = format!(
        "{} {} · HQF Development · 42 lot les Genêts, \
         13480 Calas, France · www.hqf.fr",
        words.valid_before, words.valid_until
    );
    text_centred(
        content,
        font,
        7.0,
        f64::midpoint(left, right),
        66.0,
        MUTED,
        &mentions,
    );
    Ok(())
}

fn main() -> Result<(), Box<dyn 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("labels")));
    let font_path = args.next().map_or_else(default_font, PathBuf::from);

    let mut doc = Document::new();
    doc.set_license(licence::licensed());
    doc.set_info(Name::new("Title"), words.title);

    let font = doc.add_font(Font::parse(fs::read(&font_path)?)?);

    let mut content = Content::new();
    header(&mut content, &font, words)?;
    cut_marks(&mut content)?;

    // The grid fills across a row before dropping to the next, so a label's
    // place on the page follows from its place in the list and the two pitches.
    let left = sheet_left();
    for (index, face) in LABELS.iter().enumerate() {
        let place = u32::try_from(index)?;
        let x = f64::from(place % COLUMNS).mul_add(COLUMN_PITCH, left);
        let bottom = f64::from(place / COLUMNS).mul_add(-ROW_PITCH, SHEET_TOP) - LABEL_HEIGHT;
        let subtitle = words.subtitles[index];

        outline(&mut content, x, bottom)?;
        text(
            &mut content,
            &font,
            10.5,
            x + 12.0,
            bottom + 104.0,
            INK,
            words.names[index],
        );

        match face {
            Face::Shelf { price, article } => {
                shelf_face(&mut content, &font, x, bottom, subtitle, *price, article)?;
            }
            Face::Bin { positions, code } => {
                bin_face(
                    &mut content,
                    &font,
                    (x, bottom),
                    words,
                    subtitle,
                    positions,
                    code,
                )?;
            }
        }
    }

    footer(&mut content, &font, words)?;

    let mut page = Page::new(PAGE_WIDTH, PAGE_HEIGHT);
    page.content = content.into_bytes();
    doc.add_page(page)?;

    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, {} labels",
        bytes.len(),
        LABELS.len()
    );
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::{WORDS, language};

    /// 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] = [];

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

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