write_clipping_text.rs

The Rust file of the “Letters That Clip What Follows Them” example. One word painted four ways, each time letting the stripes laid after it show only inside its letters: the way a pattern or a photograph is poured into a headline.

Rust 385 lines

What This Example Is For

A headline filled with a photograph, a word with a texture running through its letters, a stamp whose lettering lets the paper show through: each is the same trick. The letters of a word are laid down first, and whatever is drawn after them shows only inside their outlines. The word is not a picture cut out by hand; it stays text, set in its font, and it can still be searched for and copied.

What This Example Shows

  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
//! Writes a page of one word painted the four ways a text can clip what follows
//! it.
//!
//! A text set in a clipping rendering mode adds the outlines of its letters to
//! the clipping path, and whatever is drawn after it, until the graphics state
//! is restored, shows only inside those letters. The word is filled, outlined,
//! both, or not painted at all, and each time stripes are laid across it after
//! it is set: they show only where its letters are.
//!
//! Every word is held in `Words`, once per language, and `HQF_PDF_LANG` picks
//! which set is set.
//!
//! Usage: `cargo run --example write_clipping_text -- tmp/clipping_text.pdf
//! [font.ttf]`
//!        `HQF_PDF_LANG=fr cargo run --example write_clipping_text`

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

use hqf_pdf::color::Rgb;
use hqf_pdf::content::Content;
use hqf_pdf::cos::Name;
use hqf_pdf::{Document, Font, FontHandle, Page, TextRendering};

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

/// Where the page's text begins, in points from the left edge of the page.
const LEFT: f64 = 56.0;
/// How wide it runs.
const WIDTH: f64 = 483.0;
/// The size the heading is set in.
const TITLE_SIZE: f64 = 22.0;
/// The baseline of the heading.
const TITLE_TOP: f64 = 800.0;
/// The size the line under the heading and the name of each way are set in.
const NOTE_SIZE: f64 = 10.5;
/// The baseline of the line under the heading.
const STANDFIRST_TOP: f64 = 776.0;
/// The gutter between the two words of a row.
const GUTTER: f64 = 24.0;
/// The width of the pen that outlines the letters.
const HOLLOW_PEN: f64 = 0.9;

/// The four ways a text clips what follows it, in the order the page sets them.
const MODES: [TextRendering; 4] = [
    TextRendering::FillAndClip,
    TextRendering::StrokeAndClip,
    TextRendering::FillStrokeAndClip,
    TextRendering::Clip,
];

/// Where the name of the first row's ways sits, and the drop from one row to
/// the next.
const MODE_TOP: f64 = 740.0;
const MODE_STEP: f64 = 150.0;

/// The size the word is painted at.
const MODE_SIZE: f64 = 54.0;

/// How far under its name a painted word's baseline sits.
const MODE_DROP: f64 = 70.0;

/// How far under the baseline the first stripe starts, how far apart the
/// stripes are, how thick each is, and how many there are.
const STRIPE_DROP: f64 = 14.0;
const STRIPE_STEP: f64 = 4.0;
const STRIPE: f64 = 2.0;
const STRIPES: u32 = 16;

/// The words the page is written in, one set per language.
#[derive(Debug)]
struct Words {
    /// What stands across the head of the page, and what the file says of
    /// itself.
    title: &'static str,
    /// The line under the heading, which says what the page shows.
    standfirst: &'static str,
    /// The word the page paints each way.
    painted: &'static str,
    /// What each way is called, in the order `MODES` lists them.
    modes: [&'static str; 4],
}

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 {
    title: "Letters that clip what follows them",
    standfirst: "Each word is painted, then the stripes laid after it show only inside its letters.",
    painted: "Ink",
    modes: [
        "filled, then clipping the stripes",
        "outlined, then clipping the stripes",
        "filled, outlined, then clipping",
        "clipping alone: only the stripes show",
    ],
};

/// The page in French.
const FRENCH: Words = Words {
    title: "Lettres qui détourent ce qui les suit",
    standfirst: "Chaque mot est peint, puis les rayures posées après lui ne se voient que dans ses lettres.",
    painted: "Encre",
    modes: [
        "rempli, puis détoure les rayures",
        "contouré, puis détoure les rayures",
        "rempli, contouré, puis détoure",
        "invisible\u{a0}: seules les rayures se voient",
    ],
};

/// The page in Dutch.
const DUTCH: Words = Words {
    title: "Letters die uitsnijden wat erna komt",
    standfirst: "Elk woord wordt ingekleurd; de strepen daarna zijn alleen binnen de letters te zien.",
    painted: "Inkt",
    modes: [
        "gevuld, dan de strepen uitsnijden",
        "omlijnd, dan de strepen uitsnijden",
        "gevuld, omlijnd, dan uitsnijden",
        "alleen uitknippen: je ziet enkel de strepen",
    ],
};

/// The page in Portuguese.
const PORTUGUESE: Words = Words {
    title: "Letras que recortam o que as segue",
    standfirst: "Cada palavra é pintada, e as riscas postas depois só se veem dentro das suas letras.",
    painted: "Tinta",
    modes: [
        "preenchida, depois recorta as riscas",
        "contornada, depois recorta as riscas",
        "preenchida, contornada, depois recorta",
        "só recorte: só se veem as riscas",
    ],
};

/// The page in Italian.
const ITALIAN: Words = Words {
    title: "Lettere che ritagliano ciò che segue",
    standfirst: "Ogni parola è dipinta, poi le strisce posate dopo si vedono solo dentro le sue lettere.",
    painted: "Colore",
    modes: [
        "pieno, poi ritaglia le strisce",
        "contornato, poi ritaglia le strisce",
        "pieno, contornato, poi ritaglia",
        "solo ritaglio: si vedono solo le strisce",
    ],
};

/// The page in Spanish.
const SPANISH: Words = Words {
    title: "Letras que recortan lo que les sigue",
    standfirst: "Cada palabra se pinta y las rayas puestas después solo se ven dentro de sus letras.",
    painted: "Tinta",
    modes: [
        "rellena, luego recorta las rayas",
        "contorneada, luego recorta las rayas",
        "rellena, contorneada, luego recorta",
        "solo recorte: solo se ven las rayas",
    ],
};

/// The page in German.
const GERMAN: Words = Words {
    title: "Buchstaben als Schnittmaske",
    standfirst: "Jedes Wort wird gemalt, dann zeigen sich die folgenden Streifen nur in seinen Buchstaben.",
    painted: "Tinte",
    modes: [
        "gefüllt, dann die Streifen beschneidend",
        "umrandet, dann die Streifen beschneidend",
        "gefüllt, umrandet, dann beschneidend",
        "nur beschneidend: nur Streifen",
    ],
};

/// Every language the page is written in.
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 colour the word is filled in.
const fn word_ink() -> Rgb {
    Rgb::new(0.72, 0.16, 0.16)
}

/// The colour of the stripes a clipping word lets through.
const fn stripe_ink() -> Rgb {
    Rgb::new(0.95, 0.55, 0.1)
}

/// Sets `text` on one line from `x`, its baseline at `y`.
fn line_at(
    content: &mut Content,
    font: &FontHandle,
    text: &str,
    size: f64,
    (x, y): (f64, f64),
) -> Result<(), hqf_pdf::Error> {
    content.begin_text();
    content.text_position(x, y)?;
    content.set_font(font, size)?;
    content.show_runs(&font.runs(text), size)?;
    content.end_text();
    Ok(())
}

/// Draws the page: the heading, the line under it, and the word painted each of
/// the four ways, two to a row, each under what the way is called and followed
/// by stripes across it, held to its letters.
fn clipped(content: &mut Content, font: &FontHandle, words: &Words) -> Result<(), hqf_pdf::Error> {
    line_at(content, font, words.title, TITLE_SIZE, (LEFT, TITLE_TOP))?;
    content.save_state();
    content.set_fill(Rgb::gray(0.35))?;
    line_at(
        content,
        font,
        words.standfirst,
        NOTE_SIZE,
        (LEFT, STANDFIRST_TOP),
    )?;
    content.restore_state();

    let half = WIDTH / 2.0;
    let mut top = MODE_TOP;
    for (index, (mode, label)) in MODES.into_iter().zip(words.modes).enumerate() {
        let x = if index % 2 == 0 { LEFT } else { LEFT + half };
        content.save_state();
        content.set_fill(Rgb::gray(0.35))?;
        line_at(content, font, label, NOTE_SIZE, (x, top))?;
        content.restore_state();

        // The clipping path the letters add holds until the state is restored,
        // so the stripes are drawn inside the same saved state.
        let baseline = top - MODE_DROP;
        content.save_state();
        content.set_fill(word_ink())?;
        content.set_stroke(Rgb::gray(0.1))?;
        content.set_line_width(HOLLOW_PEN)?;
        content.begin_text();
        content.text_position(x, baseline)?;
        content.set_font(font, MODE_SIZE)?;
        content.set_text_rendering(mode);
        content.show_runs(&font.runs(words.painted), MODE_SIZE)?;
        content.end_text();
        content.set_fill(stripe_ink())?;
        let mut y = baseline - STRIPE_DROP;
        for _ in 0..STRIPES {
            content.rect(x, y, half - GUTTER, STRIPE)?;
            y += STRIPE_STEP;
        }
        content.fill();
        content.restore_state();
        if index % 2 == 1 {
            top -= MODE_STEP;
        }
    }
    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("clipping_text")));
    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.title);
    let font = doc.add_font(Font::parse(fs::read(&font_path)?)?);

    let mut content = Content::new();
    clipped(&mut content, &font, words)?;
    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::{Document, Font};

    use super::{GUTTER, MODE_SIZE, NOTE_SIZE, TITLE_SIZE, WIDTH, WORDS, default_font, language};

    /// The lines two languages are allowed to write the same way, where two
    /// languages spell a word alike.
    const SPARED: [&str; 1] = [
        // Written the same way in Portuguese.
        r#"painted: "Tinta""#,
    ];

    #[test]
    fn every_language_writes_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:?}"
        );
    }

    /// Nothing on the page is broken to a width: the heading and the line under
    /// it run across the page, each name stays in its half of it, and each
    /// painted word stays over its stripes.
    #[test]
    fn every_line_fits_the_room_it_has() {
        let mut doc = Document::new();
        let font = doc.add_font(
            Font::parse(std::fs::read(default_font()).expect("the committed font is there"))
                .expect("the committed font parses"),
        );
        let half = WIDTH / 2.0;

        for (named, words) in WORDS {
            let code = named.code();
            let lines = [
                (words.title, TITLE_SIZE, WIDTH),
                (words.standfirst, NOTE_SIZE, WIDTH),
                (words.painted, MODE_SIZE, half - GUTTER),
            ]
            .into_iter()
            .chain(words.modes.map(|label| (label, NOTE_SIZE, half)));

            for (line, size, room) in lines {
                let measured = font.measure(line, size);
                assert!(
                    measured <= room,
                    "the {code} page sets {line:?} over {measured:.1} points, \
                     and it has {room:.1}"
                );
            }
        }
    }
}