pdfrum-doc 0.1.0

Bookmarks, annotations, AcroForm data model, structure tree
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
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
//! Turning a layout into `Td` / `Tf` / `Tj` operators.
//!
//! The emitter tries to write the smallest text-positioning stream it can:
//! consecutive characters on one line become a single `Tj`, and a `Td` is
//! written only when the pen actually has to move.
//!
//! # The two buffers
//!
//! There are two staging buffers, `line` and `words`, and *which one gets
//! flushed where* is observable in the output ordering — so the buffering is
//! reproduced rather than simplified away.
//!
//! In the grouped path the position and the font operator go into `line`
//! while the characters accumulate in `words`; `words` is flushed into `line`
//! at a font change, and `line` is flushed into the stream only at a line
//! boundary. In the per-character path both go straight into the stream. A
//! right-to-left character always takes the per-character path, whatever the
//! caller asked for, because its position does not follow from the previous
//! one.

use crate::ap::emit::Float;
use crate::ap::fmt;
use crate::vt::{Config, Layout, Metrics, Word, word_width};

/// How the emitter groups characters.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Grouping {
    /// One `Tj` per run of same-line, same-font characters.
    Continuous,
    /// One `Td` and one `Tj` per character — what a comb field needs, since
    /// every character sits in its own cell.
    PerCharacter,
}

/// How one code point is written: which font sets it, under which resource
/// name, and as which bytes.
///
/// A field is not always set in one face. A character its `/DA` font's charset
/// does not cover is set in a second face the field adds for that charset, and
/// the stream switches between them with a `Tf` per run — which is why the
/// resource name is answered **per code point** rather than fixed for the
/// whole text.
///
/// `index` is what decides where a run ends: two adjacent characters with
/// different indices are two runs, and the `Tf` between them names the second
/// one's `alias`. The index is otherwise opaque here; the caller assigns it
/// and only its equality is read.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Face {
    /// Which font in the field's map. Only equality is read.
    pub index: i32,
    /// The resource name a `Tf` naming this font carries. An empty one
    /// suppresses the `Tf`.
    pub alias: Vec<u8>,
    /// The bytes the code point is written as, through this font.
    pub bytes: Vec<u8>,
}

impl Face {
    /// The face of a text set entirely in one font, which is every generator
    /// but the one that adds a second face for a charset its `/DA` cannot
    /// write.
    ///
    /// The index is a constant, so no run ever ends on a font change and the
    /// stream carries exactly one `Tf` — which is what the appearances that
    /// predate the second face already contain.
    #[must_use]
    pub fn single(alias: &[u8], bytes: Vec<u8>) -> Face {
        Face {
            index: 0,
            alias: alias.to_vec(),
            bytes,
        }
    }
}

/// Writes a laid-out text's operators.
///
/// `offset` shifts every position, in PDF space. `face` answers, for one code
/// point, which font writes it and as what — see [`Face`]. A face whose alias
/// is empty, or a size at or below zero, suppresses the `Tf` entirely and the
/// text inherits whatever font the enclosing stream had set.
#[must_use]
pub fn generate<F>(
    layout: &Layout,
    config: &Config,
    metrics: &Metrics<'_>,
    offset: (f32, f32),
    grouping: Grouping,
    face: F,
) -> String
where
    F: Fn(u32) -> Face,
{
    let mut out = String::new();
    let mut line = String::new();
    let mut words: Vec<u8> = Vec::new();
    let (mut old_x, mut old_y) = (0.0_f32, 0.0_f32);
    let mut current_font: i32 = -1;
    // `(-1, -1)` so the first character always reads as a new line.
    let mut previous_place = (-1_i32, -1_i32);

    for (section, line_index, word) in layout.words() {
        let place = (
            i32::try_from(section).unwrap_or(0),
            i32::try_from(line_index).unwrap_or(0),
        );
        let (x, y) = position(layout, config, word, offset);
        // The face is asked for the character that is actually written, so a
        // password field's substitute decides the run rather than the value
        // it hides.
        let face = face(shown(word, config));

        if grouping == Grouping::Continuous && !word.is_rtl {
            if place != previous_place {
                if !words.is_empty() {
                    line.push_str(&render(&words));
                    out.push_str(&line);
                    line.clear();
                    words.clear();
                }
                if let Some(step) = step(x, y, old_x, old_y) {
                    line.push_str(&step);
                    old_x = x;
                    old_y = y;
                }
            } else if words.is_empty()
                && let Some(step) = step(x, y, old_x, old_y)
            {
                line.push_str(&step);
                old_x = x;
                old_y = y;
            }
            if face.index != current_font {
                // The pending characters flush into `line`, but `line` does
                // **not** flush into the stream — which is what makes a line
                // ending right after a font change order the way it does.
                if !words.is_empty() {
                    line.push_str(&render(&words));
                    words.clear();
                }
                line.push_str(&font_op(&face.alias, layout.font_size));
                current_font = face.index;
            }
            words.extend(face.bytes);
        } else {
            if !words.is_empty() {
                line.push_str(&render(&words));
                out.push_str(&line);
                line.clear();
                words.clear();
            }
            if let Some(step) = step(x, y, old_x, old_y) {
                out.push_str(&step);
                old_x = x;
                old_y = y;
            }
            if face.index != current_font {
                out.push_str(&font_op(&face.alias, layout.font_size));
                current_font = face.index;
            }
            out.push_str(&render(&face.bytes));
        }
        previous_place = place;
        let _ = word_width(word, config, metrics, layout.font_size);
    }

    // Whatever is still pending flushes into `line`, and `line` into the
    // stream — in that order, so a trailing run lands after the position and
    // font operators that precede it.
    line.push_str(&render(&words));
    out.push_str(&line);
    out
}

/// The character that is actually written, which a password field replaces.
fn shown(word: &Word, config: &Config) -> u32 {
    config.sub_word.map_or(word.ch, |sub| sub as u32)
}

/// A character's position in PDF space, offset.
fn position(layout: &Layout, config: &Config, word: &Word, offset: (f32, f32)) -> (f32, f32) {
    let _ = layout;
    let (x, y) = crate::vt::Layout::to_pdf(config.plate, word.x, word.y);
    (x + offset.0, y + offset.1)
}

/// The `Td` for a move, or nothing when the pen is already there.
///
/// `Td` is **relative**, and the comparison is exact float equality on both
/// components — not an epsilon. A position that differs in the last bit
/// writes a `Td` of very nearly zero rather than none, which is what the
/// bytes being matched contain.
#[allow(clippy::float_cmp)]
fn step(x: f32, y: f32, old_x: f32, old_y: f32) -> Option<String> {
    if x == old_x && y == old_y {
        return None;
    }
    Some(format!(
        "{} {} Td\n",
        Float::Shortest.write(x - old_x),
        Float::Shortest.write(y - old_y)
    ))
}

/// The `Tf`, or nothing when there is no usable name or size.
///
/// A size of zero — which automatic sizing produces for a plate with no width
/// — suppresses it, and the text then inherits the enclosing font.
fn font_op(alias: &[u8], size: f32) -> String {
    if alias.is_empty() || size <= 0.0 {
        return String::new();
    }
    format!(
        "/{} {} Tf\n",
        String::from_utf8_lossy(alias),
        fmt::shortest(size)
    )
}

/// The `Tj` for a run of encoded bytes.
/// The `Tj` for a run of encoded bytes.
///
/// # Why the high bytes are spelled in octal
///
/// A show operand is a byte string, and a character code above 127 is an
/// ordinary byte in it. This stream is assembled as text, though, and a byte
/// above 127 is not valid UTF-8 on its own — writing it raw and reading the
/// buffer back as a string replaces it with U+FFFD, three bytes that name a
/// different code and draw a different glyph. That is silent: it costs
/// nothing until a field is set in a face whose codes run past 127, and then
/// every one of them is wrong.
///
/// `\ooo` is the literal syntax's own spelling for such a byte
/// (ISO 32000 §7.3.4.2), it is exactly what the byte means, and it keeps the
/// whole stream inside ASCII. A run that is already ASCII is written
/// unchanged, so no existing appearance moves a byte.
fn render(words: &[u8]) -> String {
    if words.is_empty() {
        return String::new();
    }
    let literal = pdfrum_object::encode_string_literal(words);
    let mut out = String::with_capacity(literal.len() + 8);
    for byte in literal {
        if byte.is_ascii() {
            out.push(char::from(byte));
        } else {
            use std::fmt::Write;
            let _ = write!(out, "\\{byte:03o}");
        }
    }
    out.push_str(" Tj\n");
    out
}

#[cfg(test)]
mod tests {
    use super::{Face, Grouping, generate};
    use crate::geom;
    use crate::vt::{Config, Layout, layout, stub};

    /// One byte per character, which is what a simple font does.
    fn one_byte(code: u32) -> Vec<u8> {
        vec![u8::try_from(code).unwrap_or(b'?')]
    }

    fn laid(text: &str, config: &Config) -> Layout {
        layout(text, config, &stub::metrics())
    }

    fn plate() -> Config {
        Config {
            plate: geom::rect(0.0, 0.0, 100.0, 100.0),
            font_size: 10.0,
            ..Config::default()
        }
    }

    #[test]
    fn a_run_on_one_line_becomes_a_single_show_operator() {
        let config = plate();
        let got = generate(
            &laid("hi", &config),
            &config,
            &stub::metrics(),
            (0.0, 0.0),
            Grouping::Continuous,
            |code| Face::single(b"Helv", one_byte(code)),
        );
        assert_eq!(got.matches(" Tj\n").count(), 1);
        assert!(got.contains("(hi) Tj\n"), "{got}");
        assert!(got.contains("/Helv 10 Tf\n"), "{got}");
    }

    #[test]
    fn per_character_grouping_writes_one_show_operator_each() {
        let config = plate();
        let got = generate(
            &laid("hi", &config),
            &config,
            &stub::metrics(),
            (0.0, 0.0),
            Grouping::PerCharacter,
            |code| Face::single(b"Helv", one_byte(code)),
        );
        assert_eq!(got.matches(" Tj\n").count(), 2);
    }

    #[test]
    fn no_font_name_or_a_zero_size_suppresses_the_font_operator() {
        let config = plate();
        let nameless = generate(
            &laid("hi", &config),
            &config,
            &stub::metrics(),
            (0.0, 0.0),
            Grouping::Continuous,
            |code| Face::single(b"", one_byte(code)),
        );
        assert!(!nameless.contains(" Tf\n"), "{nameless}");

        let mut zero = plate();
        zero.plate = geom::rect(0.0, 0.0, 0.0, 100.0);
        zero.font_size = 0.0;
        let sized = generate(
            &laid("hi", &zero),
            &zero,
            &stub::metrics(),
            (0.0, 0.0),
            Grouping::Continuous,
            |code| Face::single(b"Helv", one_byte(code)),
        );
        assert!(!sized.contains(" Tf\n"), "{sized}");
    }

    #[test]
    fn a_password_field_writes_its_substitute_rather_than_the_text() {
        let config = Config {
            sub_word: Some('*'),
            ..plate()
        };
        let got = generate(
            &laid("hi", &config),
            &config,
            &stub::metrics(),
            (0.0, 0.0),
            Grouping::Continuous,
            |code| Face::single(b"Helv", one_byte(code)),
        );
        assert!(got.contains("(**) Tj\n"), "{got}");
        assert!(!got.contains('h'), "{got}");
    }

    #[test]
    fn the_position_operator_is_relative_and_is_skipped_when_nothing_moves() {
        let config = plate();
        let got = generate(
            &laid("hi", &config),
            &config,
            &stub::metrics(),
            (0.0, 0.0),
            Grouping::Continuous,
            |code| Face::single(b"Helv", one_byte(code)),
        );
        // One move to the start of the only line, and no more.
        assert_eq!(got.matches(" Td\n").count(), 1);
    }

    #[test]
    fn each_line_gets_its_own_relative_move() {
        let config = Config {
            plate: geom::rect(0.0, 0.0, 0.25, 100.0),
            auto_return: true,
            ..plate()
        };
        let got = generate(
            &laid("hello", &config),
            &config,
            &stub::metrics(),
            (0.0, 0.0),
            Grouping::Continuous,
            |code| Face::single(b"Helv", one_byte(code)),
        );
        assert_eq!(got.matches(" Td\n").count(), 3);
        assert_eq!(got.matches(" Tj\n").count(), 3);
    }

    #[test]
    fn the_offset_shifts_the_first_move_and_nothing_else() {
        let config = plate();
        let text = laid("hi", &config);
        let plain = generate(
            &text,
            &config,
            &stub::metrics(),
            (0.0, 0.0),
            Grouping::Continuous,
            |code| Face::single(b"Helv", one_byte(code)),
        );
        let shifted = generate(
            &text,
            &config,
            &stub::metrics(),
            (3.0, -3.0),
            Grouping::Continuous,
            |code| Face::single(b"Helv", one_byte(code)),
        );
        assert_ne!(plain, shifted);
        assert_eq!(
            plain.matches(" Td\n").count(),
            shifted.matches(" Td\n").count()
        );
    }

    #[test]
    fn empty_text_writes_nothing() {
        let config = plate();
        let got = generate(
            &laid("", &config),
            &config,
            &stub::metrics(),
            (0.0, 0.0),
            Grouping::Continuous,
            |code| Face::single(b"Helv", one_byte(code)),
        );
        assert_eq!(got, "");
    }
}