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 | //! Sets text in an embedded font, and writes the result to disk.
//!
//! `pdftotext` must be able to pull the words back out of the result: if it
//! cannot, the `ToUnicode` map is wrong and the text is unsearchable and
//! uncopyable even though the page looks right.
//!
//! Under the invoice, four lines are set with the text state rather than with
//! coordinates: they step down by the leading, one has its letters pushed
//! apart, one its glyphs narrowed, and the rule under them is a stream of its
//! own appended to the page's.
//!
//! Once the file is written, the font is told to keep every glyph, and the
//! terminal says what the same document weighs with the face carried whole
//! rather than cut down to what the page draws.
//!
//! The words are held in `Words`, once per language, and `HQF_PDF_LANG` picks
//! which set is drawn. The number, the issuer and the three amounts are the
//! same in every language.
//!
//! Usage: `cargo run --example write_text -- tmp/text.pdf [font.ttf]`
//! `HQF_PDF_LANG=fr cargo run --example write_text -- tmp/texte.pdf`
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use hqf_pdf::content::Content;
use hqf_pdf::cos::Name;
use hqf_pdf::{Document, Font, Page};
#[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 the caller names none: 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 invoice's number.
const NUMBER: &str = "1964413";
/// Who issued it.
const ISSUER: &str = "HQF Development, Cabriès";
/// What each of the three billed lines comes to, in the order they are set.
const AMOUNTS: [&str; 3] = ["4 250.00 EUR", "850.00 EUR", "5 100.00 EUR"];
/// How many characters a billed line runs to: its label, a space, the leader
/// dots, a space, then its amount. The dots take whatever is left, so the
/// amounts end on the same character however long the label before them is.
const LINE_CHARS: usize = 57;
/// The words the page is written in, one set per language.
///
/// What is not language stays out of it: the invoice's number, who issued it
/// and the three amounts read the same wherever the page is read.
#[derive(Debug)]
struct Words {
/// The document's title, which reading software shows over the page.
title: &'static str,
/// What stands before the invoice's number.
number_label: &'static str,
/// The line that gives the day it was issued and the term it falls due in.
dates: &'static str,
/// What each billed line is called, in the order `AMOUNTS` prices them.
items: [&'static str; AMOUNTS.len()],
/// The line that says the lines under it step down by the leading.
leading: &'static str,
/// The line set with its letters pushed apart.
spaced: &'static str,
/// The line set with its glyphs narrowed.
narrowed: &'static str,
/// The line that keeps the leading the move above it set.
stepped: &'static str,
}
impl Words {
/// The words the page is written in, in `language`.
fn of(language: Language) -> &'static Self {
language::pick(&WORDS, language)
}
/// The lines the sample page sets, and which `pdftotext` must find again.
///
/// They are not all spelled out of the ASCII range for the look of it: an
/// accent and a dash are two glyphs a wrong `ToUnicode` map loses first,
/// and a page that only ever said "Total" would never catch it.
fn lines(&self) -> Vec<String> {
let mut lines = vec![
format!("{} {NUMBER} — {ISSUER}", self.number_label),
self.dates.to_owned(),
];
lines.extend(self.items.iter().zip(AMOUNTS).map(|(item, amount)| {
let dots = LINE_CHARS.saturating_sub(item.chars().count() + amount.chars().count() + 2);
format!("{item} {} {amount}", ".".repeat(dots))
}));
lines
}
}
/// The page in English.
const ENGLISH: Words = Words {
title: "hqf-pdf text sample",
number_label: "Invoice No.",
dates: "Issued 14 July 2026 — due within 30 days",
items: ["Rendering engine development", "VAT at 20 %", "Total due"],
leading: "Each line below steps down by the leading, not by a coordinate.",
spaced: "These letters stand one and a half points apart.",
narrowed: "These glyphs are narrowed to eighty per cent of their width.",
stepped: "This line keeps the leading the move above it set: twenty-two points.",
};
/// The page in French.
const FRENCH: Words = Words {
title: "Échantillon de texte hqf-pdf",
number_label: "Facture n°",
dates: "Émise le 14 juillet 2026 — payable sous 30 jours",
items: [
"Développement du moteur de rendu",
"TVA 20 %",
"Total à payer",
],
leading: "Chaque ligne ci-dessous descend de l’interligne, et non d’une coordonnée.",
spaced: "Ces lettres sont écartées d’un point et demi.",
narrowed: "Ces glyphes sont ramenés à quatre-vingts pour cent de leur largeur.",
stepped: "Cette ligne garde l’interligne fixé par le déplacement du dessus : \
vingt-deux points.",
};
/// The page in Dutch.
const DUTCH: Words = Words {
title: "hqf-pdf-tekstvoorbeeld",
number_label: "Factuur nr.",
dates: "Uitgegeven op 14 juli 2026 — te betalen binnen 30 dagen",
items: ["Ontwikkeling van de opmaakmotor", "Btw 20 %", "Te betalen"],
leading: "Elke regel hieronder zakt met de regelafstand, niet met een coördinaat.",
spaced: "Deze letters staan anderhalve punt uit elkaar.",
narrowed: "Deze glyphs zijn versmald tot tachtig procent van hun breedte.",
stepped: "Deze regel houdt de regelafstand van de stap erboven: tweeëntwintig punten.",
};
/// The page in Portuguese.
const PORTUGUESE: Words = Words {
title: "Amostra de texto hqf-pdf",
number_label: "Fatura n.º",
dates: "Emitida a 14 de julho de 2026 — vence a 30 dias",
items: [
"Desenvolvimento do motor de paginação",
"IVA a 20 %",
"Total a pagar",
],
leading: "Cada linha abaixo desce pelo entrelinhamento, não por uma coordenada.",
spaced: "Estas letras estão a ponto e meio umas das outras.",
narrowed: "Estes glifos são estreitados para oitenta por cento da sua largura.",
stepped: "Esta linha mantém o entrelinhamento do passo acima: vinte e dois pontos.",
};
/// The page in Italian.
const ITALIAN: Words = Words {
title: "Campione di testo hqf-pdf",
number_label: "Fattura n.",
dates: "Emessa il 14 luglio 2026 — scadenza a 30 giorni",
items: [
"Sviluppo del motore di impaginazione",
"IVA al 20 %",
"Totale dovuto",
],
leading: "Ogni riga qui sotto scende dell’interlinea, non di una coordinata.",
spaced: "Queste lettere stanno a un punto e mezzo l’una dall’altra.",
narrowed: "Questi glifi sono ristretti all’ottanta per cento della loro larghezza.",
stepped: "Questa riga conserva l’interlinea fissata sopra: ventidue punti.",
};
/// The page in Spanish.
const SPANISH: Words = Words {
title: "Muestra de texto de hqf-pdf",
number_label: "Factura n.º",
dates: "Emitida el 14 de julio de 2026 — vence a 30 días",
items: [
"Desarrollo del motor de maquetación",
"IVA al 20 %",
"Total a pagar",
],
leading: "Cada línea de abajo baja según el interlineado, no según una coordenada.",
spaced: "Estas letras están a punto y medio unas de otras.",
narrowed: "Estos glifos se estrechan al ochenta por ciento de su ancho.",
stepped: "Esta línea conserva el interlineado del paso de arriba: veintidós puntos.",
};
/// The page in German.
const GERMAN: Words = Words {
title: "hqf-pdf-Textbeispiel",
number_label: "Rechnung Nr.",
dates: "Ausgestellt am 14. Juli 2026 — zahlbar innerhalb von 30 Tagen",
items: [
"Entwicklung der Rendering-Engine",
"MwSt. 20 %",
"Zu zahlen",
],
leading: "Jede Zeile unten rückt um den Zeilenabstand, nicht um eine Koordinate.",
spaced: "Diese Buchstaben stehen anderthalb Punkt auseinander.",
narrowed: "Diese Glyphen sind auf achtzig Prozent ihrer Breite verengt.",
stepped: "Diese Zeile behält den Abstand des Schritts darüber: zweiundzwanzig Punkt.",
};
/// 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),
];
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 lines = words.lines();
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("text")));
let font_path = args.next().map_or_else(default_font, PathBuf::from);
let font = Font::parse(fs::read(&font_path)?)?;
println!(
"font: {} ({} glyphs, {} units per em, {:?} outlines)",
font.postscript_name(),
font.glyph_count(),
font.units_per_em(),
font.outlines()
);
let mut doc = Document::new();
doc.set_license(licence::licensed()); // no notice, so the text stands alone
doc.set_info(Name::new("Title"), words.title);
let handle = doc.add_font(font);
let mut c = Content::new();
c.begin_text();
c.set_font(&handle, 14.0)?;
c.text_position(60.0, 760.0)?;
for line in &lines {
c.show_glyphs(&handle.glyphs(line));
// Move down one line. Td is relative to the start of the previous line.
c.text_position(0.0, -28.0)?;
}
c.end_text();
// A rule under the total, positioned from the measured width of the text,
// to prove the metrics agree with what was drawn.
let last = lines.last().map_or("", String::as_str);
let width = handle.measure(last, 14.0);
c.set_line_width(0.8)?;
let baseline = 28.0_f64.mul_add(-5.0, 760.0);
c.rect(60.0, baseline, width, 0.0)?;
c.stroke();
// The same kind of lines set with the text state: the leading carries each
// line down, the second is spaced and the third narrowed, and a move that
// sets the leading changes the step of every line after it.
c.begin_text();
c.set_font(&handle, 11.0)?;
c.set_leading(16.0)?;
c.text_position(60.0, 560.0)?;
c.show_glyphs(&handle.glyphs(words.leading));
c.next_line();
c.set_character_spacing(1.5)?;
c.show_glyphs(&handle.glyphs(words.spaced));
c.set_character_spacing(0.0)?;
c.next_line();
c.set_horizontal_scaling(80.0)?;
c.show_glyphs(&handle.glyphs(words.narrowed));
c.set_horizontal_scaling(100.0)?;
c.text_position_with_leading(0.0, -22.0)?;
c.show_glyphs(&handle.glyphs(words.stepped));
c.end_text();
// The rule under them is built as a stream of its own, then appended.
let mut rule = Content::new();
rule.set_line_width(0.4)?;
rule.move_to(60.0, 494.0)?.line_to(535.0, 494.0)?;
rule.stroke();
c.append(&rule);
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());
println!("the last line measures {width:.1} pt at 14 pt");
handle.keep_all();
let whole = doc.to_bytes()?;
println!("with every glyph kept: {} bytes", whole.len());
Ok(())
}
#[cfg(test)]
mod tests {
use hqf_pdf::{Document, Font};
use super::{WORDS, default_font, language};
/// The lines two languages are allowed to write the same way, where two
/// languages spell a word alike. The number, the issuer and the amounts are
/// held outside the words.
const SPARED: [&str; 2] = [
// Written the same way in Italian.
r#""IVA al 20 %""#,
// Written the same way in Portuguese.
r#""Total a pagar""#,
];
#[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:?}"
);
}
/// Each line set with the text state runs from the left margin to the
/// rule's right end at most, spaced and narrowed as it is set.
#[test]
fn every_line_set_with_the_text_state_stops_short_of_the_rule_end() {
let mut doc = Document::new();
let handle = doc.add_font(
Font::parse(std::fs::read(default_font()).expect("the committed font is there"))
.expect("the committed font parses"),
);
let room = 535.0 - 60.0;
for (_, words) in WORDS {
let letters = f64::from(u32::try_from(words.spaced.chars().count()).expect("short"));
let spaced = 1.5f64.mul_add(letters, handle.measure(words.spaced, 11.0));
let narrowed = handle.measure(words.narrowed, 11.0) * 0.8;
for width in [
handle.measure(words.leading, 11.0),
spaced,
narrowed,
handle.measure(words.stepped, 11.0),
] {
assert!(width <= room, "a line needs {width} of {room}");
}
}
}
}
|