Lists made from a hanging indent

Bulleted, numbered and nested lists, each built out of a hanging indent and one tab stop rather than out of list machinery: the marker sits in the room the indent opens, and every line below the first begins where the tabulation put the text.

Rust write_lists.rs 364 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
//! Bulleted, numbered and nested lists, each built out of the indents and one
//! tab stop rather than out of a list engine.
//!
//! A list is a paragraph whose first line hangs out to the left: the marker
//! sits in the room the hanging indent opens, the tabulation after it carries
//! the pen to the stop the body begins at, and every line below the first
//! starts at that same stop. Nesting is the same block again, its marker where
//! the block above put its body.
//!
//! The words are held in `Words`, once per language, and `HQF_PDF_LANG` picks
//! which set the page is set in. The indents are not language: the headings
//! read the very numbers the blocks are built from, so no language can announce
//! a room the page does not open.
//!
//! Usage: `cargo run --example write_lists -- tmp/lists.pdf [font.ttf]`
//!        `HQF_PDF_LANG=fr cargo run --example write_lists -- tmp/listes.pdf`

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

use hqf_pdf::content::Content;
use hqf_pdf::{Document, Font, FontHandle, Page, 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 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 left edge every block is set from.
const X: f64 = 70.0;

/// The room the two lists at the top of the page each have.
const NARROW: f64 = 210.0;

/// The left edge of the second of them.
const RIGHT: f64 = 320.0;

/// The room the nested list at the foot of the page has.
const WIDE: f64 = 460.0;

/// The size every list is set at.
const SIZE: f64 = 10.5;

/// The room left between a heading and the block below it.
const DROP: f64 = 4.0;

/// The top the two lists at the head of the page are set from.
const TOP: f64 = 780.0;

/// The top the nested list is set from, headings and all.
const NEST_TOP: f64 = 640.0;

/// How far into the box the bodies of the bulleted list begin.
const BULLET_BODY: f64 = 14.0;

/// How far into the box the bodies of the numbered list begin, which is what
/// the widest of its markers needs rather than what the narrowest does.
const NUMBER_BODY: f64 = 22.0;

/// Where the markers and the bodies of the inner list stand: its markers begin
/// where the outer bodies do, and its bodies one step further in again.
const INNER_MARKER: f64 = NUMBER_BODY;
/// See [`INNER_MARKER`].
const INNER_BODY: f64 = NUMBER_BODY * 2.0;

/// The words the page is written in, one set per language.
#[derive(Debug)]
struct Words {
    /// The heading over the bulleted list, on either side of the room its
    /// bodies begin at.
    bulleted_before: &'static str,
    /// See [`Words::bulleted_before`].
    bulleted_after: &'static str,
    /// The items of the bulleted list, each behind its bullet and a tabulation.
    bullets: &'static str,
    /// The heading over the numbered list, on either side of the room its
    /// bodies begin at.
    numbered_before: &'static str,
    /// See [`Words::numbered_before`].
    numbered_after: &'static str,
    /// The items of the numbered list, whose markers are wider than a bullet
    /// and so ask for more room in front of the body.
    numbers: &'static str,
    /// The heading over the nested list.
    nested: &'static str,
    /// The outer items of the nested list, the first of which is broken down
    /// further.
    outer: [&'static str; 2],
    /// What the first outer item breaks down into, set again one level in.
    inner: &'static str,
}

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

    /// The heading over the bulleted list, with the room its bodies begin at
    /// read off the very number the block is built from.
    fn bulleted(&self) -> String {
        format!(
            "{}{BULLET_BODY:.0}{}",
            self.bulleted_before, self.bulleted_after
        )
    }

    /// The heading over the numbered list, likewise.
    fn numbered(&self) -> String {
        format!(
            "{}{NUMBER_BODY:.0}{}",
            self.numbered_before, self.numbered_after
        )
    }
}

/// The page in English.
const ENGLISH: Words = Words {
    bulleted_before: "Bulleted: a bullet, a tabulation, and ",
    bulleted_after: " points in front of the body.",
    bullets: "\u{2022}\tEvery line below the first lines up with the words, not \
with the bullet.\n\
\u{2022}\tThe marker hangs in the room the negative first-line indent opens.\n\
\u{2022}\tA long item wraps inside the block and stays inside it.",
    numbered_before: "Numbered: the same block, widened to ",
    numbered_after: " points for the widest marker.",
    numbers: "1.\tA number hangs out to the left exactly as a bullet does.\n\
2.\tThe room in front is what the widest marker needs, not the narrowest.\n\
10.\tA two-figure number still ends before the stop the body begins at.",
    nested: "Nested: the inner list is the same block again, its markers \
standing where the outer bodies begin.",
    outer: [
        "1.\tPrepare the document, the font it draws through and the page it fills.",
        "2.\tAdd each page, with the content stream it was built from.",
    ],
    inner: "a.\tRead the font from its file and hand it to the document.\n\
b.\tName the page size, in points.",
};

/// The page in French.
const FRENCH: Words = Words {
    bulleted_before: "Puces : une puce, une tabulation, et ",
    bulleted_after: " points devant le corps.",
    bullets: "\u{2022}\tToutes les lignes sous la première s'alignent sur les \
mots, pas sur la puce.\n\
\u{2022}\tLe marqueur pend dans la place qu'ouvre le retrait négatif de \
première ligne.\n\
\u{2022}\tUne entrée longue passe à la ligne dans le bloc et y reste.",
    numbered_before: "Numéros : le même bloc, élargi à ",
    numbered_after: " points pour le marqueur le plus large.",
    numbers: "1.\tUn numéro pend à gauche exactement comme une puce.\n\
2.\tLa place devant est celle qu'il faut au plus large des marqueurs, pas au \
plus étroit.\n\
10.\tUn numéro à deux chiffres finit encore avant le taquet où commence le \
corps.",
    nested: "Imbriqué : la liste intérieure est le même bloc, ses marqueurs \
posés là où commencent les corps de la liste extérieure.",
    outer: [
        "1.\tPréparer le document, la police qui le dessine et la page qu'il remplit.",
        "2.\tAjouter chaque page, avec le contenu dont elle a été composée.",
    ],
    inner: "a.\tLire la police dans son fichier et la donner au document.\n\
b.\tDonner la taille de la page, en points.",
};

/// 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)];

/// A list block whose markers begin `marker` points into the box and whose
/// bodies begin `body` points into it.
///
/// One call sets the three settings a list is made of, and there is no list
/// engine under them: the left indent holds every line at the body, the
/// first-line indent hangs that one line back out to the marker, and the single
/// ruler stop is what the tabulation after the marker carries the pen to.
fn list(handle: &FontHandle, marker: f64, body: f64) -> TextFlow<'_> {
    TextFlow::new(handle, SIZE)
        .leading(14.0)
        .hanging_list(marker, body)
}

/// Draws a heading across `width` and hands back the top of the block that goes
/// under it, however many lines the heading itself took.
fn heading(
    c: &mut Content,
    handle: &FontHandle,
    top: f64,
    x: f64,
    width: f64,
    label: &str,
) -> Result<f64, Box<dyn std::error::Error>> {
    let flow = TextFlow::new(handle, 8.0);
    let lines = flow.break_lines(label, width);
    c.begin_text();
    flow.draw(c, &lines, x, top, width)?;
    c.end_text();
    Ok(top - flow.height(&lines) - DROP)
}

/// Draws one block at `top` and hands back the top of whatever follows it.
fn block(
    c: &mut Content,
    flow: &TextFlow<'_>,
    text: &str,
    x: f64,
    top: f64,
    width: f64,
) -> Result<f64, Box<dyn std::error::Error>> {
    let lines = flow.break_lines(text, width);
    c.begin_text();
    flow.draw(c, &lines, x, top, width)?;
    c.end_text();
    Ok(top - flow.height(&lines))
}

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("lists")));
    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 handle = doc.add_font(font);

    let mut c = Content::new();

    // A bullet is narrow, so the body begins close in.
    let top = heading(&mut c, &handle, TOP, X, NARROW, &words.bulleted())?;
    block(
        &mut c,
        &list(&handle, 0.0, BULLET_BODY),
        words.bullets,
        X,
        top,
        NARROW,
    )?;

    // "10." is wider than "1.", and the room in front is what the widest of
    // them needs.
    let top = heading(&mut c, &handle, TOP, RIGHT, NARROW, &words.numbered())?;
    block(
        &mut c,
        &list(&handle, 0.0, NUMBER_BODY),
        words.numbers,
        RIGHT,
        top,
        NARROW,
    )?;

    // Nesting is the same block again with its marker where the block above put
    // its body, each one set from the foot of the one before it.
    let mut top = heading(&mut c, &handle, NEST_TOP, X, WIDE, words.nested)?;
    let outer = list(&handle, 0.0, NUMBER_BODY);
    let inner = list(&handle, INNER_MARKER, INNER_BODY);

    for (index, item) in words.outer.iter().enumerate() {
        top = block(&mut c, &outer, item, X, top, WIDE)?;
        if index == 0 {
            top = block(&mut c, &inner, words.inner, X, top, WIDE)?;
        }
    }

    let mut page = Page::a4();
    page.content = c.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::{Document, Font, TextFlow};

    use super::{
        BULLET_BODY, DROP, NARROW, NEST_TOP, NUMBER_BODY, SIZE, TOP, WORDS, default_font, language,
    };

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

    /// The foot the head of the page reaches when its heading and its block are
    /// set at `width` from `TOP`.
    fn foot_of(heading: &str, body: &str, body_indent: f64, width: f64) -> f64 {
        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 over = TextFlow::new(&handle, 8.0);
        let heading_lines = over.break_lines(heading, width);
        let top = TOP - over.height(&heading_lines) - DROP;

        let flow = TextFlow::new(&handle, SIZE)
            .leading(14.0)
            .hanging_list(0.0, body_indent);
        let lines = flow.break_lines(body, width);
        top - flow.height(&lines)
    }

    #[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 two lists at the head of the page are set from a top of their own,
    /// and the nested one from another: a language whose items run to one line
    /// more would lay the first over the second with nothing on the page to say
    /// so.
    #[test]
    fn every_language_keeps_the_lists_at_the_head_above_the_nested_one() {
        for (named, words) in WORDS {
            let code = named.code();
            for (heading, body, indent) in [
                (words.bulleted(), words.bullets, BULLET_BODY),
                (words.numbered(), words.numbers, NUMBER_BODY),
            ] {
                let foot = foot_of(&heading, body, indent, NARROW);
                assert!(
                    foot >= NEST_TOP,
                    "a list at the head of the {code} page reaches {foot}, \
                     under the nested list at {NEST_TOP}"
                );
            }
        }
    }
}