write_soft_hyphen.rs

Die Rust-Datei des Beispiels „Weiche Trennstriche und geschützte Leerzeichen“. Eine Blocksatzspalte, zweimal gesetzt: die langen Wörter an ihren weichen Trennstrichen mit sichtbarem Strich umbrochen oder ganz gelassen, und eine Zahl, die ein geschütztes Leerzeichen zusammenhält.

Rust 272 Zeilen

Wofür dieses Beispiel da ist

Eine schmale Blocksatzspalte wird locker, sobald ihre Wörter lang sind: Die Leerräume dehnen sich, weiße Gassen laufen die Seite hinab, und das Auge fällt hinein, statt zu lesen. Was die Seite heute richtig setzt und nichts kostet, ist, im Text selbst anzugeben, wo ein bestimmtes Wort getrennt werden darf (eine automatische Silbentrennung mit einem Wörterbuch für jede Sprache ist für eine künftige Version geplant).

Was dieses Beispiel zeigt

  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
//! Sets one justified paragraph in a narrow column twice: above without its
//! soft hyphens, below with them, so the breaks and the hyphens they draw can
//! be seen against the loose lines above.
//!
//! The text carries soft hyphens (U+00AD) inside its long words and a no-break
//! space (U+00A0) inside the figure "1 200 EUR". The upper setting is the same
//! text with the soft hyphens taken out, so only the breaking differs between
//! the two; the no-break space holds the figure together in both.
//!
//! The paragraph and the two headings are held in `Words`, once per language,
//! and `HQF_PDF_LANG` picks which set is drawn. Every language has to carry its
//! own soft hyphens, in words its own spelling allows to break: a paragraph
//! translated without them would show nothing.
//!
//! Usage: `cargo run --example write_soft_hyphen -- tmp/soft_hyphen.pdf
//! [font.ttf]`
//!        `HQF_PDF_LANG=fr cargo run --example write_soft_hyphen --
//! tmp/cesures.pdf`

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

use hqf_pdf::content::Content;
use hqf_pdf::layout::Align;
use hqf_pdf::{Document, Font, 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 page is written in, one set per language.
#[derive(Debug)]
struct Words {
    /// What stands over the setting whose soft hyphens have been taken out.
    without: &'static str,
    /// What stands over the setting that keeps them.
    with: &'static str,
    /// The paragraph, with soft hyphens marking where its long words may break
    /// and a no-break space inside the figure so it never falls across two
    /// lines. Each language marks the breaks its own spelling allows.
    hyphenated: &'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 {
    without: "Without soft hyphens: justify opens the lines to fill the column.",
    with: "With soft hyphens: the long words break, each break drawing a hyphen.",
    hyphenated: "Our inter\u{00AD}national sub\u{00AD}scription, billed at \
1\u{00A0}200\u{00A0}EUR, covers unlimited docu\u{00AD}ments, prior\u{00AD}ity \
support and quarterly state\u{00AD}ments.",
};

/// The page in French.
const FRENCH: Words = Words {
    without: "Sans césures conditionnelles : la justification écarte les mots pour remplir \
la colonne.",
    with: "Avec césures conditionnelles : les mots longs se coupent, chaque coupure \
ajoutant un trait d’union.",
    hyphenated: "Notre abonne\u{00AD}ment inter\u{00AD}national, factu\u{00AD}ré 1 200 EUR, \
comprend un nombre illi\u{00AD}mité de docu\u{00AD}ments, une \
assis\u{00AD}tance prio\u{00AD}ritaire et des rele\u{00AD}vés \
trimes\u{00AD}triels.",
};

/// The page in Dutch.
const DUTCH: Words = Words {
    without: "Zonder zachte afbreekstreepjes: uitvullen trekt de regels open om de kolom te vullen.",
    with: "Met zachte afbreekstreepjes: de lange woorden breken, en elke breuk tekent een streepje.",
    hyphenated: "Ons inter\u{ad}nationale abonne\u{ad}ment, gefac\u{ad}tureerd tegen EUR\u{a0}1.200, omvat onbe\u{ad}perkte docu\u{ad}menten, voorrangs\u{ad}ondersteuning en driemaan\u{ad}delijkse overzichten.",
};

/// The page in Portuguese.
const PORTUGUESE: Words = Words {
    without: "Sem hífenes condicionais: justificar abre as linhas para encher a coluna.",
    with: "Com hífenes condicionais: as palavras longas partem-se, e cada corte desenha um hífen.",
    hyphenated: "A nossa assi\u{ad}natura inter\u{ad}nacional, faturada a 1\u{a0}200\u{a0}EUR, cobre docu\u{ad}mentos ilimi\u{ad}tados, assis\u{ad}tência priori\u{ad}tária e extratos trimes\u{ad}trais.",
};

/// The page in Italian.
const ITALIAN: Words = Words {
    without: "Senza trattini facoltativi: la giustificazione allarga le righe per riempire la colonna.",
    with: "Con trattini facoltativi: le parole lunghe si spezzano, e ogni taglio disegna un trattino.",
    hyphenated: "Il nostro abbona\u{ad}mento inter\u{ad}nazionale, fatturato a 1.200\u{a0}EUR, copre docu\u{ad}menti illimi\u{ad}tati, assi\u{ad}stenza priori\u{ad}taria ed estratti trime\u{ad}strali.",
};

/// The page in Spanish.
const SPANISH: Words = Words {
    without: "Sin guiones condicionales: justificar abre las líneas para llenar la columna.",
    with: "Con guiones condicionales: las palabras largas se cortan, y cada corte dibuja un guion.",
    hyphenated: "Nuestra sus\u{ad}crip\u{ad}ción inter\u{ad}nacional, factu\u{ad}rada a 1\u{a0}200\u{a0}EUR, cubre docu\u{ad}mentos ilimi\u{ad}tados, asis\u{ad}tencia prio\u{ad}ritaria y extractos trimes\u{ad}trales.",
};

/// The page in German.
const GERMAN: Words = Words {
    without: "Ohne weiche Trennstriche: Der Blocksatz öffnet die Zeilen, um die Spalte zu füllen.",
    with: "Mit weichen Trennstrichen: Die langen Wörter brechen, jeder Bruch zeichnet einen Bindestrich.",
    hyphenated: "Unser inter\u{ad}nationales Abon\u{ad}nement, abge\u{ad}rechnet mit 1\u{a0}200\u{a0}EUR, umfasst unbe\u{ad}grenzte Doku\u{ad}mente, bevor\u{ad}zugten Support und viertel\u{ad}jährliche Konto\u{ad}auszüge.",
};

/// 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 column both settings are broken to, narrow enough that the long words
/// reach its edge.
const COLUMN: f64 = 150.0;

/// The left edge of both settings, stacked one above the other.
const X: f64 = 80.0;

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("soft_hyphen")));
    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();

    // The upper setting is the same text with its soft hyphens removed, so the
    // two panels differ only in whether the words may break.
    let plain = words.hyphenated.replace('\u{00AD}', "");

    for (top, label, paragraph) in [
        (760.0, words.without, plain.as_str()),
        (600.0, words.with, words.hyphenated),
    ] {
        let heading = TextFlow::new(&handle, 8.0);
        let heading_lines = heading.break_lines(label, 400.0);
        c.begin_text();
        heading.draw(&mut c, &heading_lines, X, top, 400.0)?;
        c.end_text();

        let text_top = top - 20.0;
        let flow = TextFlow::new(&handle, 11.0)
            .leading(15.0)
            .align(Align::Justify);
        let lines = flow.break_lines(paragraph, COLUMN);
        c.begin_text();
        flow.draw(&mut c, &lines, X, text_top, COLUMN)?;
        c.end_text();

        // The column's edge, so the lines can be read against it.
        let height = flow.height(&lines);
        c.save_state();
        c.set_stroke_rgb(0.8, 0.8, 0.8)?
            .set_line_width(0.5)?
            .rect(X, text_top - height, COLUMN, height)?
            .stroke();
        c.restore_state();
    }

    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 super::{COLUMN, WORDS, language};

    /// The lines two languages are allowed to write the same way. There are
    /// none.
    const SPARED: [&str; 0] = [];

    #[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 exists to show soft hyphens breaking long words and a no-break
    /// space holding a figure together. A paragraph translated without them
    /// would draw two settings that read the same.
    #[test]
    fn every_language_marks_its_own_breaks() {
        for (code, words) in WORDS {
            let breaks = words.hyphenated.matches('\u{00AD}').count();
            assert!(
                breaks >= 4,
                "the {} paragraph marks {breaks} places to break",
                code.code()
            );
            assert!(
                words.hyphenated.contains('\u{00A0}'),
                "the {} paragraph holds no figure together",
                code.code()
            );
        }
    }

    /// A word that fits the column whole never breaks, so a paragraph whose
    /// longest word is short would draw the same setting twice.
    #[test]
    fn every_language_carries_a_word_the_column_cannot_hold() {
        for (code, words) in WORDS {
            let longest = words
                .hyphenated
                .split_whitespace()
                .map(|word| word.chars().filter(|c| *c != '\u{00AD}').count())
                .max()
                .unwrap_or(0);
            assert!(
                longest >= 12,
                "the longest {} word runs to {longest} characters, and a {COLUMN}\
                 point column takes it whole",
                code.code()
            );
        }
    }
}