Pictures inside table cells

A catalogue whose first column is a picture rather than text: four pictures of very different sizes and proportions, each fitted whole and undistorted into the cell the table gives it and standing against a different corner of it, claiming no width from its column and no height from its row.

Rust write_picture_cells.rs 334 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
//! Draws a catalogue whose first column is a picture rather than text.
//!
//! The four pictures are wildly different sizes — one is nine hundred pixels
//! across, another sixteen — and no two share the same proportions, yet the
//! column they sit in is the width the table gave it and every row is as tall
//! as its own text. A picture asks its column for no width and its row for no
//! height: it is fitted, whole and undistorted, into whatever rectangle it ends
//! up with, and where it sits in that rectangle is the alignment the cell
//! already had.
//!
//! The catalogue is held in `Words`, once per language, and `HQF_PDF_LANG`
//! picks which set is printed. What a picture is called is also what a reader
//! hears in its place: the alternative text of a picture cell reads the same
//! field as the line beside it, so the two cannot disagree.
//!
//! Usage: `cargo run --example write_picture_cells -- tmp/picture_cells.pdf
//! [font.ttf]`
//!        `HQF_PDF_LANG=fr cargo run --example write_picture_cells --
//! tmp/images.pdf`

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

use hqf_pdf::content::Content;
use hqf_pdf::layout::{Cell, ColumnWidth, Columns, Padding, Row, Rule, Stroke, Table, VAlign};
use hqf_pdf::{Align, Document, Font, Image, Page, Rgb};

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

/// The pictures the catalogue shows, committed with the tests.
fn pictures() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("tests")
        .join("images")
}

/// The margin around the table, in points.
const MARGIN: f64 = 56.0;
/// The top of the table on the page.
const TOP: f64 = 760.0;
/// The lowest the table may reach.
const BOTTOM: f64 = 64.0;

/// A4's width, which the table is fitted across.
const PAGE_WIDTH: f64 = 595.276;

/// The width left to the table between the margins.
const TABLE_WIDTH: f64 = PAGE_WIDTH - 2.0 * MARGIN;

/// The colour the heading is painted on.
const HEAD_FILL: Rgb = Rgb::gray(0.86);

/// The height every row of the catalogue takes, whatever its text needs.
const ROW_HEIGHT: f64 = 54.0;

/// How many lines the catalogue holds.
const LINES: usize = 4;

/// One line of the catalogue, save what it is called and what is said about it.
///
/// A picture, a corner and a price are the same in every language; the line
/// reads its words from [`Words`] instead.
struct Item {
    /// The file the picture is read from.
    picture: &'static str,
    /// Where the picture stands across its cell.
    across: Align,
    /// Where it stands down its cell.
    down: VAlign,
    /// The catalogue reference.
    reference: &'static str,
    /// What it costs.
    price: &'static str,
}

/// The words the catalogue is printed in, one set per language.
#[derive(Debug)]
struct Words {
    /// The heading of each column, in the order the columns are set.
    headings: [&'static str; 4],
    /// What each item is called, in the order the lines are printed. A picture
    /// cell carries the same words as the words a reader hears in its place.
    names: [&'static str; LINES],
    /// What is said about each item.
    notes: [&'static str; LINES],
}

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

/// The catalogue in English.
const ENGLISH: Words = Words {
    headings: ["Picture", "Item", "Reference", "Price, €"],
    names: [
        "Wide banner",
        "Photograph",
        "Square mark",
        "Two-colour rule",
    ],
    notes: [
        "Nine hundred pixels across and half as tall, drawn against the \
top left of its cell.",
        "Four to three, in the middle of its cell — the room it does not \
fill is shared either side.",
        "As tall as it is wide, against the bottom right. The height runs \
out first, so nothing is left below it.",
        "Sixteen pixels by eight, drawn as large as the cell allows: a \
picture is fitted to its cell, not to its own pixels.",
    ],
};

/// The catalogue in French.
const FRENCH: Words = Words {
    headings: ["Image", "Article", "Référence", "Prix, €"],
    names: [
        "Bandeau large",
        "Photographie",
        "Marque carrée",
        "Filet bicolore",
    ],
    notes: [
        "Neuf cents pixels de large et deux fois moins haut, calé en haut \
à gauche de sa cellule.",
        "Quatre pour trois, au milieu de sa cellule : la place qu'elle ne \
prend pas se partage des deux côtés.",
        "Aussi haute que large, calée en bas à droite. C'est la hauteur qui \
manque en premier, donc rien ne reste en dessous.",
        "Seize pixels sur huit, dessiné aussi grand que la cellule le \
permet : une image s'ajuste à sa cellule, pas à ses propres pixels.",
    ],
};

/// 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 catalogue's lines, in the order they are printed. The pictures are shown
/// against each edge of their cells in turn, so that the room a picture does
/// not fill can be seen going to one side or the other.
const ITEMS: [Item; LINES] = [
    Item {
        picture: "hqf_development.png",
        across: Align::Left,
        down: VAlign::Top,
        reference: "BN-900",
        price: "148.00",
    },
    Item {
        picture: "photo.jpg",
        across: Align::Center,
        down: VAlign::Middle,
        reference: "PH-160",
        price: "24.50",
    },
    Item {
        picture: "logo.png",
        across: Align::Right,
        down: VAlign::Bottom,
        reference: "SQ-96",
        price: "9.90",
    },
    Item {
        picture: "bilevel.png",
        across: Align::Center,
        down: VAlign::Middle,
        reference: "RL-16",
        price: "3.75",
    },
];

/// The catalogue: a heading, then one row per item, the first cell of each a
/// picture.
fn catalogue<'f>(
    document: &mut Document,
    text: &'f hqf_pdf::FontHandle,
    words: &'static Words,
) -> Result<Table<'f>, Box<dyn std::error::Error>> {
    // The picture column is a fixed width, the note takes what is left, and the
    // reference and the price are each as wide as their own widest cell.
    let columns = Columns::new(
        vec![
            ColumnWidth::Points(96.0),
            ColumnWidth::Fraction(1.0),
            ColumnWidth::Content,
            ColumnWidth::Content,
        ],
        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 pad = Padding::symmetric(6.0, 5.0);

    table.push(
        Row::new()
            .cell(
                Cell::new(text, 9.0, words.headings[0])
                    .padding(pad)
                    .fill(HEAD_FILL),
            )
            .cell(
                Cell::new(text, 9.0, words.headings[1])
                    .padding(pad)
                    .fill(HEAD_FILL),
            )
            .cell(
                Cell::new(text, 9.0, words.headings[2])
                    .padding(pad)
                    .fill(HEAD_FILL),
            )
            .cell(
                Cell::new(text, 9.0, words.headings[3])
                    .align(Align::Right)
                    .padding(pad)
                    .fill(HEAD_FILL),
            )
            .min_height(20.0),
    );

    let folder = pictures();
    for ((item, name), note) in ITEMS.iter().zip(words.names).zip(words.notes) {
        let image = document.add_image(Image::parse(fs::read(folder.join(item.picture))?)?);
        table.push(
            Row::new()
                .cell(
                    Cell::image(&image)
                        .align(item.across)
                        .valign(item.down)
                        .alt(name)
                        .padding(pad),
                )
                .cell(Cell::new(text, 9.0, format!("{name}\n{note}")).padding(pad))
                .cell(Cell::new(text, 9.0, item.reference).padding(pad))
                .cell(
                    Cell::new(text, 9.0, item.price)
                        .align(Align::Right)
                        .align_on('.')
                        .padding(pad),
                )
                .min_height(ROW_HEIGHT),
        );
    }

    Ok(table)
}

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("picture_cells")));
    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 table = catalogue(&mut doc, &text, words)?;
    let placed = table.fit(MARGIN, TOP, TOP - BOTTOM, 0)?;

    let mut content = Content::new();
    placed.draw(&mut content)?;

    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, {} rows",
        bytes.len(),
        table.row_count()
    );
    Ok(())
}

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

    /// The lines two languages are allowed to write the same way. Every line
    /// here is a heading, a name or a sentence, and no two languages write one
    /// alike.
    const SPARED: [&str; 0] = [];

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

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