Le rapport du mois, dessiné en courbes

Un camembert de l'emploi des heures, une jauge en anneau de l'avancement de l'année, une carte aux coins adoucis, et des arcs à qui l'on dit où aller plutôt que de combien tourner.

Rust write_shapes.rs 555 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
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
//! Draws a month's activity report out of the shapes a page has no operator
//! for: a pie, a ring gauge, a card with rounded corners and a line whose
//! corners are eased off.
//!
//! PDF states a path with straight segments and cubic curves and nothing else,
//! so a circle is four curves and a slice of a pie is an arc with two straight
//! sides. Everything on this page is asked for by name — a centre and a radius,
//! two angles, three points — and the curves are worked out for it.
//!
//! Both sets of words are held in `Words`, once per language, and
//! `HQF_PDF_LANG` picks which set is drawn.
//!
//! Usage: `cargo run --example write_shapes -- tmp/shapes.pdf [font.ttf]`
//!        `HQF_PDF_LANG=fr cargo run --example write_shapes`

use std::env;
use std::f64::consts::{FRAC_PI_2, TAU};
use std::fs;
use std::path::{Path, PathBuf};

use hqf_pdf::content::Content;
use hqf_pdf::{Document, EllipticalArc, Font, FontHandle, Page, Rgb, 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 page is set in when the caller names none: 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")
}

/// The words the page is written in, one set per language.
#[derive(Debug)]
struct Words {
    /// The page's title.
    title: &'static str,
    /// What the page is about.
    lead: &'static str,
    /// The heading over the pie.
    pie: &'static str,
    /// What the four slices of the pie are called.
    slices: [&'static str; 4],
    /// The heading over the ring gauge.
    gauge: &'static str,
    /// What the ring gauge counts.
    gauge_label: &'static str,
    /// The heading over the card.
    card: &'static str,
    /// What the line inside the card shows.
    card_label: &'static str,
    /// The heading over the two arcs.
    arcs: &'static str,
    /// What the arc through three points is called.
    through: &'static str,
    /// What the four arcs of one ellipse are called.
    four: &'static str,
    /// What a curve costs, and what it does not.
    caveat: &'static str,
}

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

/// The page in English.
const ENGLISH: Words = Words {
    title: "The month, drawn in curves",
    lead: "A page states a path with straight segments and cubic curves, and \
           with nothing else: there is no circle operator and no arc operator. \
           Every shape below was asked for by name — a centre and a radius, \
           two angles, three points to pass through — and the curves were \
           worked out for it. No piece bends further than a quarter turn, so a \
           circle is four curves and the outline is out by a fortieth of a \
           point on a circle an inch across.",
    pie: "Where the hours went",
    slices: ["Drawing office", "Plates and proofs", "Delivery", "Storage"],
    gauge: "How far into the year the work is",
    gauge_label: "of the year's work is behind us",
    card: "A card whose corners are eased off",
    card_label: "Orders taken, week by week",
    arcs: "Arcs told where to go, not how far to turn",
    through: "One arc, through three points",
    four: "Four arcs, between the same two points",
    caveat: "A curve costs six numbers and a straight segment costs two, so a \
             circle is five lines of a page rather than one. What it does not \
             cost is a picture: the shapes here are drawn by the reader at \
             whatever size the page is shown, and they stay sharp at any of \
             them.",
};

/// The page in French.
const FRENCH: Words = Words {
    title: "Le mois, dessiné en courbes",
    lead: "Une page décrit un tracé avec des segments droits et des courbes \
           cubiques, et avec rien d'autre : il n'y a ni opérateur de cercle ni \
           opérateur d'arc. Chaque forme ci-dessous a été demandée par son nom \
           — un centre et un rayon, deux angles, trois points par où passer — \
           et les courbes ont été calculées pour elle. Aucun morceau ne tourne \
           de plus d'un quart de tour : un cercle fait donc quatre courbes, et \
           le contour s'écarte d'un quarantième de point sur un cercle de deux \
           centimètres et demi.",
    pie: "Où sont passées les heures",
    slices: [
        "Bureau d'études",
        "Plaques et épreuves",
        "Livraison",
        "Stockage",
    ],
    gauge: "Où en est l'année de travail",
    gauge_label: "du travail de l'année est derrière nous",
    card: "Une carte aux coins adoucis",
    card_label: "Commandes prises, semaine par semaine",
    arcs: "Des arcs à qui l'on dit où aller, pas de combien tourner",
    through: "Un arc, par trois points",
    four: "Quatre arcs, entre les deux mêmes points",
    caveat: "Une courbe coûte six nombres et un segment droit en coûte deux : \
             un cercle fait donc cinq lignes de page au lieu d'une. Ce qu'il ne \
             coûte pas, c'est une image : les formes d'ici sont tracées par le \
             lecteur à la taille où la page est affichée, et elles restent \
             nettes à toutes.",
};

/// 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, and the card, are.
const WIDTH: f64 = 451.0;

/// The hours the four slices of the pie stand for.
const HOURS: [i32; 4] = [64, 41, 27, 18];

/// The orders taken in each of seven weeks, which the line in the card draws.
const ORDERS: [i32; 7] = [26, 38, 31, 52, 44, 61, 57];

/// The most orders the line in the card leaves room for.
const CEILING: f64 = 70.0;

/// How far round the year the gauge has come.
const DONE: f64 = 0.62;

/// The four colours the slices and the line are drawn in.
const fn palette() -> [Rgb; 4] {
    [
        Rgb::new(0.16, 0.33, 0.62),
        Rgb::new(0.30, 0.57, 0.75),
        Rgb::new(0.55, 0.73, 0.82),
        Rgb::new(0.79, 0.86, 0.90),
    ]
}

/// 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,
    handle: &FontHandle,
    size: f64,
    top: f64,
    text: &str,
) -> Result<f64, hqf_pdf::Error> {
    let flow = TextFlow::new(handle, size);
    let lines = flow.break_lines(text, WIDTH);
    c.begin_text();
    flow.draw(c, &lines, LEFT, top, WIDTH)?;
    c.end_text();
    Ok(top - flow.height(&lines))
}

/// Sets one line of words at `(x, y)`.
fn label(
    c: &mut Content,
    handle: &FontHandle,
    size: f64,
    x: f64,
    y: f64,
    text: &str,
    room: f64,
) -> Result<(), hqf_pdf::Error> {
    let flow = TextFlow::new(handle, size);
    c.begin_text();
    flow.draw(c, &flow.break_lines(text, room), x, y, room)?;
    c.end_text();
    Ok(())
}

/// What share of the hours each slice of the pie stands for.
fn shares() -> [f64; 4] {
    let mut total = 0;
    for hours in HOURS {
        total += hours;
    }
    let mut out = [0.0; 4];
    for (share, hours) in out.iter_mut().zip(HOURS) {
        *share = f64::from(hours) / f64::from(total);
    }
    out
}

/// Draws the pie about `(cx, cy)`, and names each slice beside it.
///
/// A slice is one arc with two straight sides: the arc writes where it begins,
/// a segment runs back to the centre, and closing the subpath draws the other
/// side.
fn pie(
    c: &mut Content,
    handle: &FontHandle,
    words: &Words,
    cx: f64,
    cy: f64,
) -> Result<(), hqf_pdf::Error> {
    let radius = 68.0;
    let mut start = FRAC_PI_2;
    for (share, colour) in shares().into_iter().zip(palette()) {
        let sweep = TAU * share;
        c.save_state();
        c.set_fill(colour)?;
        c.arc(cx, cy, radius, start, -sweep)?;
        c.line_to(cx, cy)?;
        c.close_path();
        c.fill();
        c.restore_state();
        start -= sweep;
    }

    // The names sit to the right of the pie, one under the other, each behind a
    // swatch of its own colour.
    let mut y = cy + 46.0;
    for (colour, name) in palette().into_iter().zip(words.slices) {
        c.save_state();
        c.set_fill(colour)?;
        c.rounded_rect(cx + 96.0, y, 12.0, 12.0, 3.0)?;
        c.fill();
        c.restore_state();
        label(c, handle, 9.5, cx + 116.0, y + 3.0, name, 180.0)?;
        y -= 26.0;
    }
    Ok(())
}

/// Draws the ring gauge about `(cx, cy)`, the part that is done in the darkest
/// colour of the palette.
///
/// The pale track is one circle laid inside another and filled by the even-odd
/// rule, which leaves the ring between them. The part that is done is an arc
/// drawn with a thick pen over that ring.
fn gauge(c: &mut Content, cx: f64, cy: f64) -> Result<(), hqf_pdf::Error> {
    c.save_state();
    c.set_fill(Rgb::new(0.90, 0.92, 0.95))?;
    c.circle(cx, cy, 46.0)?;
    c.circle(cx, cy, 30.0)?;
    c.fill_even_odd();
    c.restore_state();

    c.save_state();
    c.set_line_width(16.0)?;
    c.set_stroke(palette()[0])?;
    c.arc(cx, cy, 38.0, FRAC_PI_2, -(TAU * DONE))?;
    c.stroke();
    c.restore_state();
    Ok(())
}

/// Draws the card and the line of orders inside it, and hands back the ordinate
/// the card ends at.
fn card(
    c: &mut Content,
    handle: &FontHandle,
    words: &Words,
    top: f64,
) -> Result<f64, hqf_pdf::Error> {
    let height = 96.0;
    let bottom = top - height;

    c.save_state();
    c.set_fill(Rgb::new(0.96, 0.97, 0.98))?;
    c.set_stroke(Rgb::gray(0.72))?;
    c.set_line_width(0.8)?;
    c.rounded_rect(LEFT, bottom, WIDTH, height, 14.0)?;
    c.fill_and_stroke();
    c.restore_state();

    label(
        c,
        handle,
        9.0,
        LEFT + 18.0,
        top - 20.0,
        words.card_label,
        300.0,
    )?;

    // The seven weeks are spaced evenly across the card, and each order count
    // is measured up from the floor of the plot.
    let floor = bottom + 18.0;
    let plot = 52.0;
    let step = (WIDTH - 36.0) / 6.0;
    let mut points = Vec::with_capacity(ORDERS.len());
    let mut x = LEFT + 18.0;
    for orders in ORDERS {
        let share = f64::from(orders) / CEILING;
        points.push((x, floor + plot * share));
        x += step;
    }

    c.save_state();
    c.set_line_width(2.2)?;
    c.set_stroke(palette()[1])?;
    c.rounded_polyline(&points, 14.0, false)?;
    c.stroke();
    c.restore_state();
    Ok(bottom)
}

/// Draws the arc through three points and the four arcs of one ellipse, and
/// names both.
fn curves(
    c: &mut Content,
    handle: &FontHandle,
    words: &Words,
    top: f64,
) -> Result<(), hqf_pdf::Error> {
    // The two figures bulge above and below the line they are drawn on, so both
    // are named over their own drawing rather than under it.
    let base = top - 74.0;

    label(c, handle, 8.5, LEFT, top - 12.0, words.through, 220.0)?;
    c.save_state();
    c.set_line_width(1.6)?;
    c.set_stroke(palette()[0])?;
    c.move_to(LEFT, base)?;
    c.arc_through(LEFT, base, LEFT + 100.0, base + 26.0, LEFT + 200.0, base)?;
    c.stroke();
    c.restore_state();

    // The same two ends, the same two radii, and the four arcs that answer
    // them: the long way round and the short, each walked either way.
    let from_x = LEFT + 300.0;
    let to_x = LEFT + 400.0;
    let shapes = [
        EllipticalArc::new(),
        EllipticalArc::new().clockwise(),
        EllipticalArc::new().large(),
        EllipticalArc::new().large().clockwise(),
    ];
    label(c, handle, 8.5, from_x, top - 12.0, words.four, 220.0)?;
    for (shape, colour) in shapes.into_iter().zip(palette()) {
        c.save_state();
        c.set_line_width(1.6)?;
        c.set_stroke(colour)?;
        c.move_to(from_x, base)?;
        c.elliptical_arc(from_x, base, to_x, base, 62.0, 18.0, shape)?;
        c.stroke();
        c.restore_state();
    }
    Ok(())
}

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

    let mut c = Content::new();
    let mut top = 790.0;
    top = block(&mut c, &text, 17.0, top, words.title)? - 12.0;
    top = block(&mut c, &text, 9.5, top, words.lead)? - 22.0;

    top = block(&mut c, &text, 11.0, top, words.pie)? - 12.0;
    pie(&mut c, &text, words, LEFT + 74.0, top - 74.0)?;
    top -= 152.0;

    top = block(&mut c, &text, 11.0, top, words.gauge)? - 12.0;
    gauge(&mut c, LEFT + 52.0, top - 50.0)?;
    label(
        &mut c,
        &text,
        9.5,
        LEFT + 118.0,
        top - 46.0,
        words.gauge_label,
        280.0,
    )?;
    top -= 100.0;

    top = block(&mut c, &text, 11.0, top, words.card)? - 12.0;
    top = card(&mut c, &text, words, top)? - 24.0;

    top = block(&mut c, &text, 11.0, top, words.arcs)? - 12.0;
    curves(&mut c, &text, words, top)?;
    top -= 118.0;

    c.set_fill(Rgb::gray(0.35))?;
    block(&mut c, &text, 8.5, top, words.caveat)?;

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

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

    let bytes = build(words, &font_path)?;

    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 hqf_pdf::{Content, EllipticalArc, Font, FontHandle};

    use super::{CEILING, LEFT, ORDERS, WIDTH, WORDS, default_font, language, shares};

    /// The font the page is set in, to measure what it draws.
    fn handle() -> FontHandle {
        let mut doc = hqf_pdf::Document::new();
        doc.add_font(
            Font::parse(std::fs::read(default_font()).expect("the test font")).expect("a font"),
        )
    }

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

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

    #[test]
    fn the_four_slices_of_the_pie_make_one_whole_turn() {
        let mut total = 0.0;
        for share in shares() {
            total += share;
        }

        assert!(
            (total - 1.0).abs() < 1e-12,
            "the slices come to {total} of a turn"
        );
    }

    #[test]
    fn no_week_of_orders_runs_over_the_ceiling_the_plot_leaves() {
        for orders in ORDERS {
            assert!(
                f64::from(orders) <= CEILING,
                "a week of {orders} orders is over the {CEILING} the plot draws"
            );
        }
    }

    #[test]
    fn every_name_beside_the_pie_fits_the_room_left_beside_it() {
        let font = handle();
        // A slice is named to the right of the pie, past its swatch.
        let room = 180.0;
        for (_, words) in &WORDS {
            for name in words.slices {
                assert!(
                    font.measure(name, 9.5) <= room,
                    "{name:?} needs {} of {room}",
                    font.measure(name, 9.5)
                );
            }
        }
    }

    #[test]
    fn the_page_stays_inside_its_own_right_margin() {
        // The four arcs of one ellipse are the rightmost thing drawn. They are
        // cut from an ellipse standing midway between their two ends, so they
        // run out one radius past that middle and no further.
        let middle = LEFT + 350.0;
        let widest = middle + 62.0;

        assert!(widest <= LEFT + WIDTH, "the arcs run to {widest}");
    }

    #[test]
    fn a_slice_of_the_pie_is_one_arc_shut_by_two_straight_sides() {
        let mut slice = Content::new();
        slice.arc(100.0, 100.0, 76.0, 0.0, -1.0).expect("finite");
        slice.line_to(100.0, 100.0).expect("finite");
        slice.close_path();
        let written = String::from_utf8(slice.as_bytes().to_vec()).expect("operators are text");

        assert!(written.starts_with("176 100 m\n"), "the slice is {written}");
        assert!(
            written.ends_with("100 100 l\nh\n"),
            "the slice is {written}"
        );
    }

    #[test]
    fn the_four_arcs_of_one_ellipse_are_four_different_shapes() {
        let shapes = [
            EllipticalArc::new(),
            EllipticalArc::new().clockwise(),
            EllipticalArc::new().large(),
            EllipticalArc::new().large().clockwise(),
        ];
        let mut drawn = Vec::new();
        for shape in shapes {
            let mut content = Content::new();
            content
                .elliptical_arc(0.0, 0.0, 100.0, 0.0, 62.0, 30.0, shape)
                .expect("the radii reach");
            drawn.push(content.as_bytes().to_vec());
        }

        for (index, one) in drawn.iter().enumerate() {
            for other in drawn.iter().skip(index + 1) {
                assert_ne!(one, other, "two of the four arcs are the same shape");
            }
        }
    }
}