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 | //! Lays a day's meals out in a table whose dish column is narrower than some of
//! the dishes named in it, three times: at the top as the names come, then cut
//! to the column, then in rows of a height of their own that set a dish too
//! wide smaller.
//!
//! The words are held in `Words`, once per language, and `HQF_PDF_LANG` picks
//! which set the page is set in. A dish is not only a word here: two of them
//! are single compounds wider than the column they are put in, which is the
//! whole of what the two tables differ over, so every language has to name its
//! meals with compounds of its own.
//!
//! Usage: `cargo run --example write_cell_overflow -- tmp/cell_overflow.pdf
//! [font.ttf]`
//! `HQF_PDF_LANG=fr cargo run --example write_cell_overflow --
//! tmp/debordement.pdf`
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use hqf_pdf::content::Content;
use hqf_pdf::layout::{
Border, Cell, ColumnWidth, Columns, Margin, Overlong, Padding, Row, Rule, Stroke, Table,
TableFrame, TextFlow, VAlign,
};
use hqf_pdf::{Align, Document, Error, Font, FontHandle, Page, Rgb};
#[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.
fn default_font() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("fonts")
.join("DejaVuSans.ttf")
}
/// When a meal is taken, and what it comes to in kilocalories. What is on the
/// plate is a word, and stands in [`Words`] beside them.
const MEALS: [(&str, u32); 6] = [
("07:30", 420),
("10:00", 210),
("12:30", 540),
("16:00", 180),
("19:30", 610),
("21:00", 0),
];
/// What the energies are counted in, which is the same wherever the page is
/// read.
const UNIT: &str = "kcal";
/// The words the page is set in, one set per language.
#[derive(Debug)]
struct Words {
/// The heading over each of the three tables.
headings: [&'static str; 3],
/// What the time column and the dish column are called. The third heading
/// is the unit the energies are counted in.
columns: [&'static str; 2],
/// What is on the plate at each meal of [`MEALS`]. A compound name is one
/// word to a line breaker, however many words it reads as, and two of these
/// are compounds wider than the column they are put in.
dishes: [&'static str; 6],
/// What the boxed-off row at the foot of the table is called.
total: &'static str,
/// The notes under each table.
notes: [&'static str; 3],
}
impl Words {
/// The words the page is set in, in `language`.
fn of(language: Language) -> &'static Self {
language::pick(&WORDS, language)
}
}
/// The page in English.
const ENGLISH: Words = Words {
headings: [
"Overflow: a dish wider than its column runs past the edge, over the kcal beside it.",
"Break: the same dish is cut after its last glyph that fits, and carries on the next line.",
"Shrink: each row keeps its 17 points, and a dish too wide is set smaller until it fits.",
],
columns: ["Time", "Dish"],
dishes: [
"Wholemeal-sourdough-with-almond-butter",
"Greek yoghurt, walnuts",
"Cauliflower-and-broccoli-gratin-with-hazelnuts",
"Apple, oat biscuits",
"Pan-fried-mackerel-with-buckwheat",
"Camomile infusion",
],
total: "Total",
notes: [
"Drink 1.5–2 litres of water over the day, between the meals rather than with them.",
"Swap any dish for another of ≈ the same energy.",
"Weigh the portions dry, not cooked.",
],
};
/// The page in French.
const FRENCH: Words = Words {
headings: [
"Débordement : un plat plus large que sa colonne en dépasse le bord et empiète \
sur la colonne des kcal.",
"Coupure : le même plat s’interrompt après le dernier caractère qui tient et \
continue à la ligne suivante.",
"Réduction : chaque ligne garde ses 17 points de haut, et un plat trop large \
passe à un corps plus petit.",
],
columns: ["Heure", "Plat"],
dishes: [
"Pain-complet-au-levain-et-purée-d’amande",
"Yaourt grec, noix",
"Gratin-de-chou-fleur-et-brocolis-aux-noisettes",
"Pomme, biscuits à l’avoine",
"Maquereau-poêlé-au-sarrasin",
"Infusion de camomille",
],
total: "Total",
notes: [
"Buvez de 1,5 à 2 litres d’eau au cours de la journée, entre les repas plutôt que pendant.",
"Remplacez un plat par un autre d’environ autant de kcal.",
"Pesez les portions crues, pas cuites.",
],
};
/// The page in Dutch.
const DUTCH: Words = Words {
headings: [
"Overlopen: een gerecht breder dan zijn kolom loopt over de rand, over de kcal ernaast.",
"Afbreken: hetzelfde gerecht wordt na zijn laatste passende glyph afgebroken en loopt door.",
"Verkleinen: elke rij houdt zijn 17 punten, en een te breed gerecht krimpt tot het past.",
],
columns: ["Tijd", "Gerecht"],
dishes: [
"Volkoren-zuurdesembrood-met-amandelboter",
"Griekse yoghurt, walnoten",
"Bloemkool-broccoligratin-met-hazelnoten",
"Appel, havermoutkoekjes",
"Gebakken-makreel-met-boekweit",
"Kamillethee",
],
total: "Totaal",
notes: [
"Drink 1,5 tot 2 liter water per dag, tussen de maaltijden en niet erbij.",
"Ruil een gerecht voor een ander met ongeveer dezelfde energie.",
"Weeg de porties rauw, niet gekookt.",
],
};
/// The page in Portuguese.
const PORTUGUESE: Words = Words {
headings: [
"Transbordar: um prato mais largo que a coluna sai da margem, por cima das kcal ao lado.",
"Quebrar: o mesmo prato é cortado após o último glifo que cabe e continua na linha seguinte.",
"Reduzir: cada linha mantém os seus 17 pontos, e um prato largo demais encolhe até caber.",
],
columns: ["Hora", "Prato"],
dishes: [
"Pão-integral-de-massa-mãe-com-manteiga-de-amêndoa",
"Iogurte grego, nozes",
"Gratinado-de-couve-flor-e-brócolos-com-avelãs",
"Maçã, bolachas de aveia",
"Cavala-na-frigideira-com-trigo-sarraceno",
"Infusão de camomila",
],
total: "Total",
notes: [
"Beba 1,5 a 2 litros de água por dia, entre as refeições e não durante.",
"Troque um prato por outro com ≈ a mesma energia.",
"Pese as porções cruas, não cozinhadas.",
],
};
/// The page in Italian.
const ITALIAN: Words = Words {
headings: [
"Sborda: un piatto più largo della colonna esce dal bordo, sopra le kcal accanto.",
"Spezza: lo stesso piatto è tagliato dopo l’ultimo glifo che ci sta, e continua sotto.",
"Riduci: ogni riga tiene i suoi 17 punti, e un piatto troppo largo si rimpicciolisce fino a starci.",
],
columns: ["Ora", "Piatto"],
dishes: [
"Pane-integrale-a-lievito-madre-con-burro-di-mandorle",
"Yogurt greco, noci",
"Gratin-di-cavolfiore-e-broccoli-con-nocciole",
"Mela, biscotti d’avena",
"Sgombro-in-padella-con-grano-saraceno",
"Infuso di camomilla",
],
total: "Totale",
notes: [
"Bere da 1,5 a 2 litri d’acqua al giorno, tra i pasti piuttosto che durante.",
"Sostituire un piatto con un altro di energia ≈ uguale.",
"Pesare le porzioni da crude, non da cotte.",
],
};
/// The page in Spanish.
const SPANISH: Words = Words {
headings: [
"Desbordar: un plato más ancho que su columna sale del borde, sobre las kcal de al lado.",
"Cortar: el mismo plato se corta tras el último glifo que cabe y sigue en la línea siguiente.",
"Reducir: cada fila guarda sus 17 puntos, y un plato demasiado ancho se reduce hasta caber.",
],
columns: ["Hora", "Plato"],
dishes: [
"Pan-integral-de-masa-madre-con-crema-de-almendra",
"Yogur griego, nueces",
"Gratinado-de-coliflor-y-brócoli-con-avellanas",
"Manzana, galletas de avena",
"Caballa-a-la-sartén-con-trigo-sarraceno",
"Infusión de manzanilla",
],
total: "Total",
notes: [
"Beba de 1,5 a 2 litros de agua al día, entre las comidas y no durante ellas.",
"Cambie un plato por otro de ≈ la misma energía.",
"Pese las raciones en crudo, no cocinadas.",
],
};
/// The page in German.
const GERMAN: Words = Words {
headings: [
"Überlauf: Ein Gericht, breiter als seine Spalte, läuft über den Rand, über die kcal daneben.",
"Umbruch: Dasselbe Gericht wird nach seinem letzten passenden Glyphen getrennt und läuft weiter.",
"Verkleinern: Jede Zeile behält ihre 17 Punkt, ein zu breites Gericht wird kleiner gesetzt.",
],
columns: ["Uhrzeit", "Gericht"],
dishes: [
"Vollkorn-Sauerteigbrot-mit-Mandelmus",
"Griechischer Joghurt, Walnüsse",
"Blumenkohl-Brokkoli-Gratin-mit-Haselnüssen",
"Apfel, Haferkekse",
"Gebratene-Makrele-mit-Buchweizen",
"Kamillentee",
],
total: "Summe",
notes: [
"Über den Tag 1,5–2 Liter Wasser trinken, lieber zwischen den Mahlzeiten als dazu.",
"Jedes Gericht lässt sich gegen ein anderes mit ≈ derselben Energie tauschen.",
"Die Portionen roh wiegen, nicht gekocht.",
],
};
/// 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); 7] = [
(Language::English, &ENGLISH),
(Language::French, &FRENCH),
(Language::German, &GERMAN),
(Language::Spanish, &SPANISH),
(Language::Italian, &ITALIAN),
(Language::Portuguese, &PORTUGUESE),
(Language::Dutch, &DUTCH),
];
/// The left edge of both tables.
const X: f64 = 80.0;
/// The width the plan is fitted across: a card, not a page.
const TABLE_WIDTH: f64 = 250.0;
/// The width of the column the times go in.
const TIME_WIDTH: f64 = 44.0;
/// The width of the column the energies go in.
const ENERGY_WIDTH: f64 = 46.0;
/// How far a cell is set from its own edges.
const PAD: Padding = Padding::symmetric(4.0, 3.0);
/// How far a note is set from the bullet that introduces it.
const BULLET_INDENT: f64 = 10.0;
/// The size everything in the table is set at.
const BODY: f64 = 8.5;
/// The height of a meal's row held to a height of its own: one line at [`BODY`]
/// and its padding fit in it, two do not.
const MEAL_HEIGHT: f64 = 17.0;
/// The smallest size a dish is set down to in a row of a height of its own.
const SMALLEST: f64 = 5.0;
/// The plan, laid out with the given answer to a dish too wide for its column,
/// its meals' rows given a height of their own when `held` says so.
fn plan<'a>(
text: &'a FontHandle,
words: &Words,
overlong: Overlong,
held: bool,
) -> Result<Table<'a>, Error> {
// The dish takes whatever the time and the energy leave it.
let columns = Columns::new(
vec![
ColumnWidth::Points(TIME_WIDTH),
ColumnWidth::Fraction(1.0),
ColumnWidth::Points(ENERGY_WIDTH),
],
TABLE_WIDTH,
)?;
let mut table = Table::new(columns);
table.header(1);
table
.rule(Rule::Frame, Stroke::black(0.8))
.rule(Rule::HorizontalOther, Stroke::new(0.25, Rgb::gray(0.75)))
.rule(Rule::VerticalOther, Stroke::new(0.25, Rgb::gray(0.75)))
.rule(Rule::Horizontal(1), Stroke::black(0.8));
let heading = |label: &str| {
Cell::new(text, BODY, label)
.padding(PAD)
.fill(Rgb::gray(0.88))
.valign(VAlign::Middle)
};
table.push(
Row::new()
.cell(heading(words.columns[0]))
.cell(heading(words.columns[1]))
.cell(heading(UNIT).align(Align::Right))
.min_height(18.0),
);
for ((time, energy), dish) in MEALS.iter().zip(words.dishes) {
let row = Row::new()
.cell(Cell::new(text, BODY, *time).padding(PAD))
.cell(Cell::new(text, BODY, dish).padding(PAD).overlong(overlong))
.cell(
Cell::new(text, BODY, energy.to_string())
.padding(PAD)
.align(Align::Right),
);
table.push(if held {
row.fixed_height(MEAL_HEIGHT, SMALLEST)
} else {
row
});
}
// The day's energy, boxed off the grid so that it reads as a total rather
// than as one more meal. The label carries the box's left edge and the
// figure its right, so the two cells trace one box between them.
let rule = Stroke::black(0.8);
let box_margin = Margin::symmetric(0.0, 2.0);
let total: u32 = MEALS.iter().map(|(_, energy)| energy).sum();
table.push(
Row::new()
.cell(
Cell::new(text, BODY, words.total)
.span(2)
.align(Align::Right)
.padding(PAD)
.margin(box_margin)
.border(Border::none().top(&rule).bottom(&rule).left(&rule)),
)
.cell(
Cell::new(text, BODY, total.to_string())
.align(Align::Right)
.padding(PAD)
.margin(box_margin)
.border(Border::none().top(&rule).bottom(&rule).right(&rule)),
)
.min_height(16.0),
);
Ok(table)
}
/// Draws the notes under the plan as a bullet list, the first baseline one
/// leading below `top`.
///
/// The bullet sits at the left edge and the note is flowed beside it, so a note
/// long enough to run on lines up under itself rather than under its bullet.
///
/// # Errors
///
/// If a coordinate cannot be written.
fn draw_notes(
content: &mut Content,
text: &FontHandle,
words: &Words,
top: f64,
) -> Result<(), Error> {
let flow = TextFlow::new(text, 7.5).leading(10.0);
let bullet = flow.break_lines("•", BULLET_INDENT);
let width = TABLE_WIDTH - BULLET_INDENT;
let mut y = top;
for note in words.notes {
let lines = flow.break_lines(note, width);
content.begin_text();
flow.draw(content, &bullet, X, y, BULLET_INDENT)?;
content.end_text();
content.begin_text();
flow.draw(content, &lines, X + BULLET_INDENT, y, width)?;
content.end_text();
y -= flow.height(&lines);
}
Ok(())
}
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("cell_overflow")));
let font_path = args.next().map_or_else(default_font, PathBuf::from);
let font = Font::parse(fs::read(&font_path)?)?;
let mut doc = Document::new();
doc.set_license(licence::licensed());
let text = doc.add_font(font);
let mut content = Content::new();
for ((top, overlong, held), label) in [
(790.0, Overlong::Overflow, false),
(540.0, Overlong::Break, false),
(290.0, Overlong::Break, true),
]
.into_iter()
.zip(words.headings)
{
let heading = TextFlow::new(&text, 8.0);
let lines = heading.break_lines(label, 430.0);
content.begin_text();
heading.draw(&mut content, &lines, X, top, 430.0)?;
content.end_text();
let table_top = top - 30.0;
let placed =
plan(&text, words, overlong, held)?.fit(TableFrame::new(X, table_top, 220.0), 0)?;
placed.draw(&mut content)?;
// The notes follow the table, which is taller when it cuts.
draw_notes(
&mut content,
&text,
words,
table_top - placed.height() - 12.0,
)?;
}
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::content::Content;
use hqf_pdf::layout::{Overlong, TableFrame};
use hqf_pdf::{Document, Font};
use super::{
BODY, ENERGY_WIDTH, MEAL_HEIGHT, MEALS, PAD, TABLE_WIDTH, TIME_WIDTH, WORDS, X,
default_font, language, plan,
};
/// The lines two languages are allowed to write the same way.
///
/// `Total` is the same word in both, and the heading over the figures is
/// the unit they are counted in rather than a word, so it is not in `Words`
/// at all.
const SPARED: [&str; 2] = [
"total: \"Total\"",
// Written the same way in Portuguese.
r#""Hora""#,
];
/// The room a dish has across its column, once the cell's own padding is
/// taken off it.
const DISH_ROOM: f64 = TABLE_WIDTH - TIME_WIDTH - ENERGY_WIDTH - 2.0 * PAD.left;
/// How many of the day's dishes have to be one word wider than the column
/// they are put in.
const OVERLONG: usize = 2;
/// The font the page is measured with, handed back with the document that
/// holds it.
fn measured() -> (Document, hqf_pdf::FontHandle) {
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 text = doc.add_font(font);
(doc, text)
}
#[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 page shows what a table does with a dish it cannot fit, so every
/// language has to name dishes it cannot fit: a set of names that all go
/// into the column draws the same table twice, and the reader is shown
/// nothing.
#[test]
fn every_language_names_dishes_wider_than_the_column() {
let (_doc, text) = measured();
for (named, words) in WORDS {
let code = named.code();
let overlong = words
.dishes
.iter()
.filter(|dish| {
dish.split_whitespace()
.any(|word| text.measure(word, BODY) > DISH_ROOM)
})
.count();
assert!(
overlong >= OVERLONG,
"the {code} page names {overlong} dishes too wide for the \
{DISH_ROOM:.1} points the column gives them, and it takes \
{OVERLONG} to show what the two settings differ over"
);
}
}
/// Cutting a dish to the column costs the row the lines it is cut into, so
/// the lower table stands taller than the upper one. A language whose
/// dishes are cut nowhere leaves the two the same height.
#[test]
fn every_language_cuts_a_table_taller_than_the_one_that_runs_over() {
let (_doc, text) = measured();
for (named, words) in WORDS {
let code = named.code();
let height = |overlong| {
plan(&text, words, overlong, false)
.expect("the columns fit the width they are given")
.fit(TableFrame::new(X, 700.0, 220.0), 0)
.expect("the plan fits the height it is given")
.height()
};
let running_over = height(Overlong::Overflow);
let cut = height(Overlong::Break);
assert!(
cut > running_over,
"the {code} page draws both tables {cut:.1} points tall, so \
nothing on it says what cutting a dish costs"
);
}
}
/// Setting a dish smaller costs the row nothing: the table whose meals keep
/// a height of their own stands as tall as their rows say, however wide the
/// dishes, and shorter than the one that cuts them. The dishes that do not
/// fit one line of the column are the only text set below the body size:
/// the times and the energies keep it.
#[test]
fn every_language_holds_its_rows_to_their_height_and_sets_the_dish_smaller() {
let (_doc, text) = measured();
for (named, words) in WORDS {
let code = named.code();
let fitted = |overlong, held| {
plan(&text, words, overlong, held)
.expect("the columns fit the width they are given")
.fit(TableFrame::new(X, 700.0, 220.0), 0)
.expect("the plan fits the height it is given")
};
let held = fitted(Overlong::Break, true);
for meal in 1..=MEALS.len() {
let height = held.row_height(meal).expect("every meal is placed");
assert!(
(height - MEAL_HEIGHT).abs() < 1e-9,
"the {code} page draws meal {meal} {height:.2} points tall"
);
}
assert!(held.height() < fitted(Overlong::Break, false).height());
// The size is written again only where it changes, so each dish set
// smaller between a time and an energy at the body size writes
// exactly one size below it.
let mut content = Content::new();
held.draw(&mut content).expect("the table draws");
let stream = String::from_utf8_lossy(content.as_bytes()).into_owned();
let tokens: Vec<&str> = stream.split_whitespace().collect();
let sizes: Vec<f64> = tokens
.windows(2)
.filter(|pair| pair[1] == "Tf")
.map(|pair| pair[0].parse().expect("a size is a number"))
.collect();
let smaller = sizes.iter().filter(|size| **size < BODY).count();
let wide = words
.dishes
.iter()
.filter(|dish| text.measure(dish, BODY) > DISH_ROOM)
.count();
assert!(wide >= OVERLONG, "the {code} page names {wide} wide dishes");
assert_eq!(
smaller, wide,
"the {code} page sets text at {sizes:?}, where only its {wide} \
wide dishes go below {BODY}"
);
assert!(sizes.iter().any(|size| (size - BODY).abs() < 1e-9));
}
}
}
|