Un récit qui se poursuit plus loin

Deux récits sur deux pages : chacun commence dans une colonne de la première page et se poursuit dans une colonne de la seconde, et les deux changent de côté en chemin. Le document dit quelle colonne suit quelle autre, et le lecteur continue de lire le récit qu'il a commencé.

Cette page est le programme entier, pour celui qui écrit vos logiciels. Il n'y a rien d'autre à y lire. Revenir au document qu'il écrit.

Rust write_articles.rs 497 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
//! Writes a two-story newsletter whose reading order is not its page order.
//!
//! Each story begins in a column of the front page and carries on in a column
//! of the second — and the two swap sides on the way, so the column below the
//! one being read is the other story. Nothing drawn on the page says which
//! carries on which. The articles say it: each names the patches of page it is
//! read through, in order, and a reader that follows one goes from the foot of
//! a patch to the head of the next wherever it falls.
//!
//! Both stories are held in `Words`, once per language, and `HQF_PDF_LANG`
//! picks which set is set. The grid is not language: the same four columns hold
//! whichever words are poured into them.
//!
//! Usage: `cargo run --example write_articles -- tmp/articles.pdf [font.ttf]`
//!        `HQF_PDF_LANG=fr cargo run --example write_articles`

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

use hqf_pdf::content::Content;
use hqf_pdf::cos::Name;
use hqf_pdf::layout::Align;
use hqf_pdf::{Article, Document, Font, FontHandle, Page, 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 example draws with when none is given on the command line: the
/// one committed for the tests, so that 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 newsletter is written in, one set per language.
#[derive(Debug)]
struct Words {
    /// What stands across the head of the front page.
    masthead: &'static str,
    /// The line under it, which says what the file is showing.
    standfirst: &'static str,
    /// The first story: its heading and the passage it is told in.
    first: Story,
    /// The second story, told beside it.
    second: Story,
    /// What stands after a heading where a story is taken up again.
    continued: &'static str,
}

/// One story of the newsletter.
#[derive(Debug)]
struct Story {
    /// What the reader sees over it, and what the article is called in the
    /// reader's panel.
    heading: &'static str,
    /// Who wrote it.
    author: &'static str,
    /// The passage poured through the story's two columns.
    passage: &'static str,
}

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

    /// The two stories, in the order they are laid out.
    const fn stories(&self) -> [&Story; 2] {
        [&self.first, &self.second]
    }
}

/// The newsletter in English.
const ENGLISH: Words = Words {
    masthead: "The Thursday Sheet",
    standfirst: "Two stories, four columns, and a reading order of their own.",
    first: Story {
        heading: "The bridge reopens",
        author: "Claire Vasseur",
        passage: "The bridge reopened on Tuesday, eleven months after the \
river took the middle of it. The county surveyor walked the deck at dawn and \
declared it sound, which is the only ceremony anyone asked for. Traffic \
crossed within the hour. What took the eleven months was not the deck but the \
two piers under it, each rebuilt from the bed up while the water ran round \
them. The contractor counted nineteen days lost to the weather and none at all \
to the design, which is a rarer thing than it sounds. The old approach road \
stays shut until the spring, so the lorries still go the long way round by the \
mill. The parish council has asked for a footway on the upstream side and been \
told it will wait its turn. Meanwhile the bus takes four minutes off its run \
and the school on the far bank has moved its bell back to where it stood \
before the flood. Nobody has yet said what becomes of the \
temporary crossing downstream, which cost more to keep open than to build and \
which the anglers would like left where it is.",
    },
    second: Story {
        heading: "The harvest, county by county",
        author: "Martin Aubert",
        passage: "The wheat came in ten days early and heavier than anyone \
had allowed for, and the barns of three parishes were full before the second \
week of August. The dry June is what did it, say the growers, and the wet \
April before that. Not every field agrees: the high ground east of the ridge \
lost a third of what it stood to a wind that came through on a single \
afternoon. The merchants have held their price so far, which nobody expects to \
last past the sales, and the millers are already asking what the winter sowing \
will look like on ground this hard. The straw is the other half of the story: \
there is a great deal of it, it is dry, and the men who bale it have been \
working the fields two counties away as well as their own. Two of the larger \
farms have put their second cut off a fortnight in the hope of rain. The \
weighbridge at the co-operative has been open since six every morning and the \
queue at seven is longer than it was last year at nine.",
    },
    continued: "(continued)",
};

/// The newsletter in French.
const FRENCH: Words = Words {
    masthead: "La Feuille du jeudi",
    standfirst: "Deux récits, quatre colonnes, et un ordre de lecture à eux.",
    first: Story {
        heading: "Le pont rouvre",
        author: "Claire Vasseur",
        passage: "Le pont a rouvert mardi, onze mois après que la rivière en \
a emporté le milieu. L'ingénieur du département a parcouru le tablier à \
l'aube et l'a déclaré sûr, seule cérémonie que quiconque avait réclamée. Les \
voitures passaient une heure plus tard. Ce qui a pris onze mois, ce n'est pas \
le tablier mais les deux piles en dessous, refaites depuis le lit pendant que \
l'eau courait autour. L'entreprise a compté dix-neuf jours perdus au mauvais \
temps et aucun aux plans, ce qui est plus rare qu'il n'y paraît. L'ancienne \
route d'accès reste fermée jusqu'au printemps, et les camions font encore le \
tour par le moulin. Le conseil municipal a demandé un trottoir du côté amont \
et s'est entendu répondre qu'il attendrait son tour. En attendant, le car \
gagne quatre minutes sur son trajet et l'école de l'autre rive a remis sa \
sonnerie à l'heure qu'elle avait avant la crue. Personne n'a encore dit ce que \
devient la passerelle provisoire en aval, qui a coûté plus cher à entretenir \
qu'à monter et que les pêcheurs aimeraient voir rester là.",
    },
    second: Story {
        heading: "La moisson, canton par canton",
        author: "Martin Aubert",
        passage: "Le blé est rentré dix jours en avance et plus lourd que \
personne ne l'avait prévu, et les granges de trois communes étaient pleines \
avant la deuxième semaine d'août. C'est le mois de juin sec qui a fait cela, \
disent les cultivateurs, et l'avril humide qui l'a précédé. Toutes les \
parcelles n'en disent pas autant : les hauteurs à l'est de la crête ont perdu \
un tiers de ce qu'elles portaient à cause d'un vent passé en un seul \
après-midi. Les négociants tiennent leur prix pour l'instant, ce que personne \
n'attend au-delà des ventes, et les meuniers demandent déjà de quoi auront \
l'air les semis d'hiver sur une terre aussi dure. La paille est l'autre moitié \
de l'affaire : il y en a beaucoup, elle est sèche, et ceux qui la pressent \
travaillent les champs de deux départements en plus des leurs. Deux des plus \
grosses fermes ont repoussé leur seconde coupe de quinze jours dans l'espoir \
de la pluie. Le pont-bascule de la coopérative ouvre à six heures tous les \
matins et la file d'attente à sept est plus longue qu'elle ne l'était l'an \
dernier à neuf.",
    },
    continued: "(suite)",
};

/// 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 column on the left of a page.
const LEFT: f64 = 70.0;
/// The left edge of the column on the right of a page.
const RIGHT: f64 = 310.0;
/// The width of one column.
const COLUMN: f64 = 215.0;
/// The top edge of the two columns of the front page, which the masthead stands
/// above.
const FRONT_TOP: f64 = 700.0;
/// The top edge of the two columns of the second page.
const INSIDE_TOP: f64 = 760.0;
/// How far down from its top edge a column reaches.
const HEIGHT: f64 = 190.0;
/// The size the passages are set at, in points.
const SIZE: f64 = 9.0;
/// The distance from one baseline to the next, in points.
const LEADING: f64 = 12.5;
/// The size a story's heading is set at, in points.
const HEADING_SIZE: f64 = 12.0;
/// How much of a column's height the heading and the line under it take.
const HEADING_ROOM: f64 = 26.0;

/// One patch of page a story is read through: which page, and where on it.
#[derive(Debug, Clone, Copy)]
struct Patch {
    /// The page it is on, counting from zero.
    page: usize,
    /// The left edge of the column, in points.
    x: f64,
    /// The top edge of the column, in points up from the foot of the sheet.
    top: f64,
}

impl Patch {
    /// Where the passage starts, under the room the heading takes.
    const fn text_top(self) -> f64 {
        self.top - HEADING_ROOM
    }

    /// How much of the column the passage has, once the heading has had its
    /// room.
    const fn text_height() -> f64 {
        HEIGHT - HEADING_ROOM
    }
}

/// The two patches the first story is read through: the left of the front page,
/// then the right of the second.
const FIRST: [Patch; 2] = [
    Patch {
        page: 0,
        x: LEFT,
        top: FRONT_TOP,
    },
    Patch {
        page: 1,
        x: RIGHT,
        top: INSIDE_TOP,
    },
];

/// The two patches the second story is read through, which cross the first
/// story's on the way: the right of the front page, then the left of the
/// second.
const SECOND: [Patch; 2] = [
    Patch {
        page: 0,
        x: RIGHT,
        top: FRONT_TOP,
    },
    Patch {
        page: 1,
        x: LEFT,
        top: INSIDE_TOP,
    },
];

/// The patches each story is read through, in the order the stories are laid
/// out.
const fn patches() -> [[Patch; 2]; 2] {
    [FIRST, SECOND]
}

/// The heading one patch carries: the story's own on the patch it begins in,
/// and the story's own with a word after it on every later patch.
fn heading(words: &Words, story: &Story, first: bool) -> String {
    if first {
        story.heading.to_owned()
    } else {
        format!("{} {}", story.heading, words.continued)
    }
}

/// Draws the masthead across the head of the front page.
fn masthead(content: &mut Content, font: &FontHandle, words: &Words) -> Result<(), hqf_pdf::Error> {
    content.begin_text();
    content.set_font(font.name(), 22.0)?;
    content.text_origin(LEFT, 780.0)?;
    content.show_glyphs(&font.glyphs(words.masthead));
    content.end_text();

    content.begin_text();
    content.set_font(font.name(), SIZE)?;
    content.text_origin(LEFT, 762.0)?;
    content.show_glyphs(&font.glyphs(words.standfirst));
    content.end_text();

    content
        .set_line_width(1.0)?
        .move_to(LEFT, 752.0)?
        .line_to(RIGHT + COLUMN, 752.0)?
        .stroke();
    Ok(())
}

/// Draws one patch: its frame, its heading, and the slice of the passage it
/// holds. Returns how many lines it took.
fn draw_patch(
    content: &mut Content,
    font: &FontHandle,
    flow: &TextFlow<'_>,
    patch: Patch,
    title: &str,
    lines: &[hqf_pdf::layout::Line],
) -> Result<usize, hqf_pdf::Error> {
    content.save_state();
    content
        .set_stroke_rgb(0.75, 0.75, 0.75)?
        .set_line_width(0.5)?
        .rect(patch.x, patch.top - HEIGHT, COLUMN, HEIGHT)?
        .stroke();
    content.restore_state();

    content.begin_text();
    content.set_font(font.name(), HEADING_SIZE)?;
    content.text_origin(patch.x + 6.0, patch.top - 16.0)?;
    content.show_glyphs(&font.glyphs(title));
    content.end_text();

    let taken = flow.fit(lines, Patch::text_height(), 0);
    content.begin_text();
    flow.draw(
        content,
        &lines[..taken],
        patch.x + 6.0,
        patch.text_top(),
        COLUMN - 12.0,
    )?;
    content.end_text();
    Ok(taken)
}

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("articles")));
    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.masthead);
    let font = doc.add_font(Font::parse(fs::read(&font_path)?)?);
    let flow = TextFlow::new(&font, SIZE)
        .leading(LEADING)
        .align(Align::Justify);

    // One content stream per page, drawn into as each story reaches it.
    let mut sheets = [Content::new(), Content::new()];
    masthead(&mut sheets[0], &font, words)?;

    for (story, patches) in words.stories().into_iter().zip(patches()) {
        let broken = flow.break_lines(story.passage, COLUMN - 12.0);
        let mut from = 0;
        for (index, patch) in patches.into_iter().enumerate() {
            let taken = draw_patch(
                &mut sheets[patch.page],
                &font,
                &flow,
                patch,
                &heading(words, story, index == 0),
                &broken[from..],
            )?;
            from += taken;
        }
        doc.add_article(patches.into_iter().fold(
            Article::new(story.heading).author(story.author),
            |article, patch| {
                article.on_page(patch.page, patch.x, patch.top - HEIGHT, COLUMN, HEIGHT)
            },
        ));
    }

    for content in sheets {
        let mut page = Page::a4();
        page.content = content.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 hqf_pdf::layout::Align;
    use hqf_pdf::{Document, Font, TextFlow};

    use super::{COLUMN, LEADING, Patch, SIZE, WORDS, Words, default_font, language, patches};

    /// The lines two languages are allowed to write the same way: the two
    /// bylines, which are the names of people rather than words to translate.
    const SPARED: [&str; 2] = ["author: \"Claire Vasseur\"", "author: \"Martin Aubert\""];

    /// How many of a passage's lines each of a story's patches takes, and how
    /// many the passage broke into.
    fn poured(passage: &str) -> (usize, Vec<usize>) {
        let mut doc = Document::new();
        let font = Font::parse(std::fs::read(default_font()).expect("the committed font is there"))
            .expect("the committed font parses");
        let handle = doc.add_font(font);
        let flow = TextFlow::new(&handle, SIZE)
            .leading(LEADING)
            .align(Align::Justify);
        let lines = flow.break_lines(passage, COLUMN - 12.0);

        let mut from = 0;
        let mut taken = Vec::new();
        for _ in 0..2 {
            let fitted = flow.fit(&lines[from..], Patch::text_height(), 0);
            taken.push(fitted);
            from += fitted;
        }
        (lines.len(), taken)
    }

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

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

    /// A story that stops inside its first patch never reaches the second, and
    /// the article would lead a reader to an empty column; one longer than the
    /// two loses its last lines with nothing on the page to say so.
    #[test]
    fn every_story_fills_both_of_its_patches_and_no_more() {
        for (named, words) in WORDS {
            for story in words.stories() {
                let (lines, taken) = poured(story.passage);
                let code = named.code();

                assert!(
                    taken[1] > 0,
                    "the {code} story {:?} never reaches its second column",
                    story.heading
                );
                assert_eq!(
                    taken.iter().sum::<usize>(),
                    lines,
                    "the {code} story {:?} leaves lines under the foot of its \
                     last column",
                    story.heading
                );
            }
        }
    }

    /// The two stories cross on the way: the column a story carries on in is
    /// not the column under the one it began in, which is the whole reason the
    /// file states a reading order at all.
    #[test]
    fn the_two_stories_swap_sides_between_the_pages() {
        let [first, second] = patches();

        assert_eq!(first[0].page, second[0].page);
        assert_eq!(first[1].page, second[1].page);
        assert!((first[0].x - second[1].x).abs() < f64::EPSILON);
        assert!((second[0].x - first[1].x).abs() < f64::EPSILON);
    }

    /// Every story names an author, which the thread carries beside its title.
    #[test]
    fn every_story_is_signed() {
        for (_, words) in WORDS {
            for story in words.stories() {
                assert!(!story.author.is_empty());
            }
        }
    }

    /// The words a language writes are the words the file is set in, whichever
    /// language that is.
    #[test]
    fn every_language_names_both_stories() {
        for (_, words) in WORDS {
            let [first, second]: [&super::Story; 2] = Words::stories(words);
            assert_ne!(first.heading, second.heading);
        }
    }
}