Skip to main content

pdfrum_text/
object.rs

1//! Typeset representation of a page text object.
2//!
3//! Derives glyph positions, advances, and bounding boxes from a
4//! [`pdfrum_page::TextObject`] for extraction heuristics.
5
6// `pdfrum-page` hands over a `TextObject` holding the *content stream's*
7// view: byte strings with the adjustments between them, one position, one
8// matrix. The extraction heuristics want the *typeset* view: one character
9// code per glyph, the text-space x each one sits at, the adjustment that
10// followed it, and the object's bounding box.
11//
12// Deriving that here rather than storing it in the page crate keeps it a
13// pure function of data the page crate already publishes; it is only ever
14// wanted by this crate, and the page object stays a small record. The
15// derivation is one pass with a running pen —
16// the same accumulation the renderer performs when it draws the run, and
17// the same one `CPDF_TextObject::CalcPositionDataInternal` performs to fill
18// its own arrays.
19//
20// # Form objects and the composed matrix
21//
22// The C++ threads a separate `form_matrix` through the whole pipeline
23// because its text objects carry positions in the *form's* space. Ours do
24// not: `pdfrum-page` composes a form's `/Matrix` into the CTM before
25// interpreting its content, so a text object inside a form already reports
26// page-space geometry. The form matrix is therefore the identity everywhere
27// in this crate — which is exactly what makes a generated character's matrix
28// come out as the identity, as the oracle's `GetMatrix` assertions require.
29
30use crate::charinfo::{ObjectIndex, transform_rect};
31use kurbo::{Affine, Point, Rect};
32use pdfrum_font::{CharCode, Font};
33use pdfrum_page::{Content, PageObject, TextObject, TextRenderMode};
34use std::sync::Arc;
35
36/// One glyph's place in a text object.
37#[derive(Debug, Clone, Copy, PartialEq)]
38pub struct Item {
39    /// The character code.
40    pub code: CharCode,
41    /// The origin in the object's own text space. For horizontal writing
42    /// this is `(pen, 0)`; for a vertical CID font the pen moves down y and
43    /// the glyph's vertical origin shifts it.
44    pub origin: Point,
45}
46
47/// A text object with everything the heuristics ask of it.
48///
49/// Built once per object per page and then read many times, because almost
50/// every decision in the pipeline needs at least the first and last item.
51#[derive(Debug, Clone)]
52pub struct TextRun {
53    /// Where this object sits in the page's flattened walk.
54    pub index: ObjectIndex,
55    /// The font, shared with every other object using the same resource —
56    /// duplicate suppression compares fonts by this pointer's identity.
57    pub font: Arc<Font>,
58    /// The font size. **May be negative**, and is never rescued.
59    pub font_size: f32,
60    /// `|hypot(a, b)| * font_size` of the composed matrix: "the horizontal
61    /// scale of the font in device units", which every space threshold is
62    /// measured in.
63    pub font_size_h: f32,
64    /// `Tc`, the character spacing.
65    pub char_space: f32,
66    /// `Tw`, the word spacing.
67    pub word_space: f32,
68    /// One entry per glyph, in content order.
69    pub items: Vec<Item>,
70    /// The adjustment following each glyph, in thousandths of a text-space
71    /// unit. Same length as [`items`](Self::items); the last is always zero.
72    pub kernings: Vec<f32>,
73    /// The object's position in page space.
74    pub position: Point,
75    /// The composed text matrix: the object's linear transform with its
76    /// position as the translation.
77    pub text_matrix: Affine,
78    /// The object's bounding box in page space, stroke-inflated when the
79    /// render mode strokes.
80    pub rect: Rect,
81    /// Total advance width in page space: `w0` summed over the object's
82    /// glyphs (ISO 32000-1 §9.4.3), measured through the same text matrix the
83    /// bounding box goes through, so the two are comparable against one
84    /// epsilon.
85    ///
86    /// §9.2.2 keeps the glyph *bounding box* and the §9.4.3 *displacement*
87    /// distinct, and only the displacement says whether the pen moved. A
88    /// space, and any glyph whose outline is empty, has an empty box and a
89    /// non-zero `w0`; a zero-width space has both empty.
90    pub advance: f64,
91    /// The marks enclosing the object.
92    pub marks: pdfrum_page::ContentMarks,
93    /// What each shown character's Type 3 glyph procedure declared, empty for
94    /// every other kind of font. A Type 3 glyph's box and advance live inside
95    /// a content stream, so they arrive from the page layer rather than from
96    /// the font.
97    pub type3: std::collections::BTreeMap<u32, pdfrum_page::Type3Metrics>,
98}
99
100impl TextRun {
101    /// How many glyphs the object shows.
102    #[must_use]
103    pub fn count(&self) -> usize {
104        self.items.len()
105    }
106
107    /// One glyph, or `None` past the end.
108    #[must_use]
109    pub fn item(&self, index: usize) -> Option<Item> {
110        self.items.get(index).copied()
111    }
112
113    /// The adjustment following a glyph; zero past the end.
114    #[must_use]
115    pub fn kerning(&self, index: usize) -> f32 {
116        self.kernings.get(index).copied().unwrap_or(0.0)
117    }
118
119    /// The advance width of one character code, already scaled by
120    /// `font_size / 1000`.
121    ///
122    /// A vertical CID font reports its (negative) vertical advance instead.
123    #[must_use]
124    pub fn scaled_char_width(&self, code: CharCode) -> f32 {
125        let scale = self.font_size / 1000.0;
126        if self.font.is_vertical()
127            && let Some(width) = self.font.vert_width(code)
128        {
129            return width * scale;
130        }
131        self.glyph_width(code) * scale
132    }
133
134    /// The advance width of one character code in glyph units.
135    ///
136    /// A Type 3 font whose `/Widths` said nothing defers to what the glyph
137    /// procedure's `d0`/`d1` declared, which is the only place the number
138    /// exists.
139    #[must_use]
140    pub fn glyph_width(&self, code: CharCode) -> f32 {
141        let declared = self.font.char_width(code);
142        if declared != 0.0 || self.font.type3().is_none() {
143            return declared;
144        }
145        self.type3.get(&code.0).map_or(0.0, |m| m.width)
146    }
147
148    /// The bounding box of one character code in glyph units, y-up.
149    #[must_use]
150    pub fn glyph_bbox(&self, code: CharCode) -> Rect {
151        if self.font.type3().is_some()
152            && let Some(metrics) = self.type3.get(&code.0)
153        {
154            return metrics.bbox;
155        }
156        self.font.char_bbox(code)
157    }
158}
159
160/// A width in glyph units as the extractor's ladder yields it: a **whole
161/// number**, because every rung of PDFium's `GetCharWidth` is `int`
162/// (`core/fpdftext/cpdf_textpage.cpp:185-208`).
163///
164/// The type exists so that the integrality is stated once, here, at the
165/// ladder's exit — rather than left implicit in what the width sources
166/// happen to store, or spelled as an `as i32` scattered over the call sites.
167/// Everything downstream (the space threshold, the newline test, the dedup
168/// test) is arithmetic PDFium does on an `int` that stays integral until it
169/// is scaled by the font size, so those callers take [`GlyphWidth::as_f64`]
170/// and cannot see a fraction the C++ does not have.
171///
172/// Our own geometry — the pen advance in [`build`], glyph boxes — keeps its
173/// `f32` and is untouched by this type: there the fraction is real and
174/// PDFium keeps it too.
175///
176/// # Where the truncation lives on each side — and why this is a no-op
177///
178/// PDFium never rounds a float here, because it never holds one: a simple
179/// font's `/Widths` entry is read with `CPDF_Array::GetIntegerAt`
180/// (`core/fpdfapi/parser/cpdf_array.cpp:147-151`), which is
181/// `FX_Number::GetSigned`'s `saturated_cast<int32_t>` over the parsed float
182/// (`core/fxcrt/fx_number.cpp:97-105`) — a **truncation toward zero**, not a
183/// round. A CID font's widths come from an already-integer `width_list_`
184/// (`cpdf_cidfont.cpp:573-585`), and rung three is `FX_RECT::Width()`, an
185/// integer subtraction.
186///
187/// **So does ours, already.** `pdfrum-font` truncates at the same place
188/// PDFium does, at parse: `SimpleWidths` stores `raw: [u16; 256]` filled
189/// from `Array::int_at` (`crates/pdfrum-font/src/widths.rs`), `CidWidths`
190/// keeps `records: Vec<[i32; 3]>`, and the face fallback is
191/// `f32::from(advance_tt(gid) as i16)`
192/// (`crates/pdfrum-font/src/simple/mod.rs:111-130`). Every value that
193/// reaches this ladder is therefore already a whole number in an `f32`, and
194/// truncating it changes nothing.
195///
196/// That was measured, not assumed. With this type's truncation instrumented
197/// to report any fractional input, **zero fired across 1 420 PDFs** — the
198/// 44-file benchmark corpus and the 1 376-file conformance corpus — and the
199/// 1 759-file board came back byte-identical, every row, as did all 44
200/// benchmark text rows.
201///
202/// The type is kept anyway, because it turns that agreement from an accident
203/// of two crates into something the compiler holds: the ladder's output can
204/// no longer acquire a fraction, whatever a future width source does, and a
205/// caller cannot silently multiply one in. It documents the invariant at the
206/// boundary where the two engines have to agree.
207///
208/// # Not the oracle-bug case either
209///
210/// ISO 32000-1 §9.2.4 Table 111 gives `/Widths` as *numbers*, so a
211/// fractional width is legal and PDFium's integer read would lose it. But
212/// PDFium loses it **everywhere**, not only in extraction: the glyph pen in
213/// `CPDF_TextObject::CalcPositionDataInternal` advances by the same
214/// `font->GetCharWidth` int (`core/fpdfapi/page/cpdf_textobject.cpp:185`,
215/// `:250-255`, `:333`). The integer is the width PDFium *positions the
216/// glyphs with*, so the extractor's "is this gap wider than a character"
217/// rule is asking about the geometry actually on the page, and matching it
218/// is matching the input to a heuristic rather than adopting a wrong width.
219/// The question stays hypothetical here regardless: no corpus file has a
220/// fractional declared width to lose.
221#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
222pub struct GlyphWidth(i32);
223
224impl GlyphWidth {
225    /// Zero — the ladder's answer for no code, an invalid code, and a
226    /// nonsense bounding box.
227    pub const ZERO: Self = Self(0);
228
229    /// Truncates a glyph-unit width toward zero, which is what
230    /// `saturated_cast<int32_t>` does to the float a `/Widths` entry parsed
231    /// to. A non-finite width is nonsense and yields zero.
232    #[must_use]
233    fn truncating(width: f32) -> Self {
234        if !width.is_finite() {
235            return Self::ZERO;
236        }
237        #[expect(
238            clippy::cast_possible_truncation,
239            reason = "the saturating cast is `FX_Number::GetSigned`; a width outside i32 is nonsense"
240        )]
241        let truncated = width.trunc() as i32;
242        Self(truncated)
243    }
244
245    /// The width as the `f64` every downstream threshold scales by the font
246    /// size, which is `nLastWidth * GetFontSize() / 1000` on the C++ side
247    /// (`cpdf_textpage.cpp:1234-1238`).
248    #[must_use]
249    pub fn as_f64(self) -> f64 {
250        f64::from(self.0)
251    }
252
253    /// Whether the rung produced a usable width, i.e. `w > 0` — the test
254    /// each of `GetCharWidth`'s first two exits makes before returning.
255    #[must_use]
256    fn is_positive(self) -> bool {
257        self.0 > 0
258    }
259}
260
261/// The width of one character code in glyph units, through the extractor's
262/// own three-rung fallback ladder (`GetCharWidth`).
263///
264/// Distinct from [`Font::char_width`], which is only the first rung and stays
265/// fractional. The second re-encodes the code to bytes and re-decodes them,
266/// so it differs from the first exactly when that round trip is lossy — a
267/// simple font's code above 255, say. The third falls back to the glyph's
268/// bounding box.
269///
270/// Returns a [`GlyphWidth`], an integer, because PDFium's `GetCharWidth`
271/// returns `int` at all three of its exits — see that type for which rung
272/// truncates on each side, why the truncation is measurably a no-op on every
273/// corpus here, and why this is not an oracle bug.
274#[must_use]
275pub fn ladder_char_width(run: &TextRun, code: Option<CharCode>) -> GlyphWidth {
276    let Some(code) = code else {
277        return GlyphWidth::ZERO;
278    };
279    let font = &run.font;
280    // Rung one: the font's own declared width. PDFium's is an int because
281    // `/Widths` was read with `GetIntegerAt`; ours is an `f32` that
282    // `pdfrum-font` has already made integral at the same point. The
283    // truncation here is the type-level restatement of that, not a change.
284    let width = GlyphWidth::truncating(run.glyph_width(code));
285    if width.is_positive() {
286        return width;
287    }
288    // Rung two: the round trip through the encoding. `GetStringWidth` sums
289    // `GetCharWidth` over the re-decoded codes, so the C++ sums *integers*.
290    // Our `string_width` sums the same per-code values, which `pdfrum-font`
291    // already stores as whole numbers, so summing then truncating and
292    // truncating then summing agree — there is no fraction to carry across
293    // the sum.
294    let mut bytes = Vec::new();
295    font.append_char(&mut bytes, code);
296    let width = GlyphWidth::truncating(font.string_width(&bytes));
297    if width.is_positive() {
298        return width;
299    }
300    // Rung three: `std::max(rect.Width(), 0)` over an `FX_RECT`, whose
301    // `Width()` is already an integer subtraction. `FX_RECT::Valid`'s
302    // overflow check has no analogue on an `f64` rect; a non-finite box is
303    // the equivalent nonsense and yields zero, which `truncating` gives.
304    let bbox = run.glyph_bbox(code);
305    #[expect(
306        clippy::cast_possible_truncation,
307        reason = "glyph-unit widths are small integers"
308    )]
309    let width = (bbox.x1 - bbox.x0) as f32;
310    GlyphWidth::truncating(width).max(GlyphWidth::ZERO)
311}
312
313/// Builds the extraction view of one text object.
314///
315/// Returns `None` for an object with no font, which cannot show anything.
316#[must_use]
317pub(crate) fn build(content: &Content<TextObject>, index: ObjectIndex) -> Option<TextRun> {
318    let object = &content.object;
319    let (font, font_size) = object.font.as_ref()?;
320    let state = &content.state;
321
322    // One item per decoded character code, and the adjustment that followed
323    // the *string* attaches to its last character.
324    let mut items: Vec<CharCode> = Vec::new();
325    let mut kernings: Vec<f32> = Vec::new();
326    for segment in &object.segments {
327        let before = items.len();
328        for item in font.decode(&segment.codes) {
329            items.push(item.code);
330            kernings.push(0.0);
331        }
332        if items.len() > before
333            && let Some(last) = kernings.last_mut()
334        {
335            *last = segment.kerning;
336        }
337    }
338    // The C++'s `SetSegments` leaves the final kerning at zero whatever the
339    // array said, because there is no glyph after it to displace.
340    if let Some(last) = kernings.last_mut() {
341        *last = 0.0;
342    }
343
344    let [a, b, ..] = object.matrix.as_coeffs();
345    #[expect(
346        clippy::cast_possible_truncation,
347        reason = "matrix coefficients are page-space floats"
348    )]
349    let font_size_h = (a.hypot(b) as f32 * font_size).abs();
350
351    let mut run = TextRun {
352        index,
353        font: Arc::clone(font),
354        font_size: *font_size,
355        font_size_h,
356        char_space: state.text.char_space,
357        word_space: state.text.word_space,
358        items: Vec::with_capacity(items.len()),
359        kernings,
360        position: object.position,
361        // The object's matrix carries no translation of its own; the
362        // position is the translation, exactly as `GetTextMatrix` assembles
363        // it from the stored four coefficients plus `pos_`.
364        text_matrix: with_translation(object.matrix, object.position),
365        rect: Rect::ZERO,
366        advance: 0.0,
367        marks: content.marks.clone(),
368        type3: object.type3_metrics.clone(),
369    };
370    layout(
371        &mut run,
372        &items,
373        object.render_mode,
374        state.stroke_params.width,
375    );
376    Some(run)
377}
378
379/// Replaces a matrix's translation, leaving its linear part alone.
380fn with_translation(matrix: Affine, position: Point) -> Affine {
381    let [a, b, c, d, ..] = matrix.as_coeffs();
382    Affine::new([a, b, c, d, position.x, position.y])
383}
384
385/// Walks the pen across the run, filling in item origins and both boxes.
386///
387/// This is `CalcPositionDataInternal`: the pen advances by the glyph's width,
388/// then by the word space when the code is a single-byte space, then by the
389/// character space, then *back* by the adjustment. The bounding box grows
390/// from the glyph boxes along the way, and the two writing directions
391/// accumulate their extents in opposite roles.
392fn layout(run: &mut TextRun, codes: &[CharCode], mode: TextRenderMode, line_width: f32) {
393    let vertical = run.font.is_vertical();
394    let font_size = run.font_size;
395    let (mut min_x, mut max_x) = (10000.0f32, -10000.0f32);
396    let (mut min_y, mut max_y) = (10000.0f32, -10000.0f32);
397    let mut pen = 0.0f32;
398
399    for (index, &code) in codes.iter().enumerate() {
400        let bbox = run.glyph_bbox(code);
401        #[expect(
402            clippy::cast_possible_truncation,
403            reason = "glyph boxes are 1000/em integers"
404        )]
405        let (bl, bb, br, bt) = (
406            bbox.x0 as f32,
407            bbox.y0 as f32,
408            bbox.x1 as f32,
409            bbox.y1 as f32,
410        );
411
412        let width = if vertical {
413            let (ox, oy) = run.font.vert_origin(code).unwrap_or((0.0, 880.0));
414            // The vertical origin shifts the glyph box before it is measured.
415            let (left, right) = (bl - ox, br - ox);
416            let (top, bottom) = (bt - oy, bb - oy);
417            min_x = min_x.min(left).min(right);
418            max_x = max_x.max(left).max(right);
419            let char_top = pen + top * font_size / 1000.0;
420            let char_bottom = pen + bottom * font_size / 1000.0;
421            min_y = min_y.min(char_top).min(char_bottom);
422            max_y = max_y.max(char_top).max(char_bottom);
423            run.items.push(Item {
424                code,
425                origin: Point::new(
426                    f64::from(-(font_size * ox / 1000.0)),
427                    f64::from(pen - font_size * oy / 1000.0),
428                ),
429            });
430            run.font.vert_width(code).unwrap_or(-1000.0) * font_size / 1000.0
431        } else {
432            min_y = min_y.min(bt).min(bb);
433            max_y = max_y.max(bt).max(bb);
434            let char_left = pen + bl * font_size / 1000.0;
435            let char_right = pen + br * font_size / 1000.0;
436            min_x = min_x.min(char_left).min(char_right);
437            max_x = max_x.max(char_left).max(char_right);
438            run.items.push(Item {
439                code,
440                origin: Point::new(f64::from(pen), 0.0),
441            });
442            run.glyph_width(code) * font_size / 1000.0
443        };
444
445        pen += width;
446        // Word spacing applies to a **single-byte** space only, which for a
447        // composite font means one whose CMap gives that code one byte.
448        if code.0 == 0x20 && (!vertical || run.font.cid_from_charcode(code).is_none()) {
449            let mut encoded = Vec::new();
450            run.font.append_char(&mut encoded, code);
451            if encoded.len() == 1 {
452                pen += run.word_space;
453            }
454        }
455        pen += run.char_space;
456        pen -= run.kerning(index) * font_size / 1000.0;
457    }
458
459    if vertical {
460        min_x = min_x * font_size / 1000.0;
461        max_x = max_x * font_size / 1000.0;
462    } else {
463        min_y = min_y * font_size / 1000.0;
464        max_y = max_y * font_size / 1000.0;
465    }
466    let original_rect = Rect::new(
467        f64::from(min_x),
468        f64::from(min_y),
469        f64::from(max_x),
470        f64::from(max_y),
471    );
472    let mut rect = transform_rect(run.text_matrix, original_rect);
473    if matches!(
474        mode,
475        TextRenderMode::Stroke
476            | TextRenderMode::FillStroke
477            | TextRenderMode::StrokeClip
478            | TextRenderMode::FillStrokeClip
479    ) {
480        let half = f64::from(line_width) / 2.0;
481        rect = Rect::new(
482            rect.x0 - half,
483            rect.y0 - half,
484            rect.x1 + half,
485            rect.y1 + half,
486        );
487    }
488    run.rect = rect;
489    // The advance the pen actually travelled, in page space through the same
490    // matrix the box goes through. `pen` is signed -- a negative font size or
491    // a leading kern runs it backwards -- so the magnitude is what the "did
492    // this object move the pen" question wants.
493    let m = run.text_matrix.as_coeffs();
494    let (dx, dy) = if vertical {
495        (m[2] * f64::from(pen), m[3] * f64::from(pen))
496    } else {
497        (m[0] * f64::from(pen), m[1] * f64::from(pen))
498    };
499    run.advance = dx.hypot(dy);
500}
501
502/// The width below which a text object is not worth extracting at all, and
503/// the height below which a character's box is rescued. In page space.
504///
505/// `kSizeEpsilon`, `cpdf_textpage.cpp:47`.
506pub(crate) const SIZE_EPSILON: f64 = 0.01;
507
508/// What the degenerate-object gate decides about one text object.
509///
510/// PDFium asks one question — `fabs(GetRect().Width()) < kSizeEpsilon` at
511/// `cpdf_textpage.cpp:886` and `:1081` — and drops everything that fails it.
512/// The box is built from the glyph *bounding boxes*
513/// (`cpdf_textobject.cpp:305-331`), and ISO 32000-1 §9.2.2 keeps that
514/// distinct from the §9.4.3 displacement: a glyph can be shown, advance the
515/// pen and still report an empty box. So the box test alone discards
516/// characters that are on the page, and we split its failing side.
517#[derive(Debug, Clone, Copy, PartialEq, Eq)]
518pub enum ObjectGate {
519    /// The glyphs occupy real width. Kept, as PDFium keeps it.
520    Occupies,
521    /// The box is empty, but the pen moved and the object shows at least one
522    /// character that is neither a space nor a control code. Kept.
523    ///
524    /// `[oracle-bug]` PDFium drops it, and that is a loss of content rather
525    /// than of spacing. On `bug_921.pdf` its `--txt` begins mid-sentence at
526    /// "разве не выражает" where the page draws "И разве не выражает": five
527    /// objects of this shape carry an `И`, an em dash, a `в`, a `я` and a
528    /// second `И` — running Russian prose, silently corrupted rather than
529    /// merely shortened. Reported as `crbug.com/40643656` and
530    /// `crbug.com/444176962`; the draft is
531    /// `docs/upstream/pdfium/text-object-bbox-gate-drops-spaces.md`.
532    ///
533    /// Implement the correct behaviour, cite both sides, and bucket the
534    /// golden as not-achievable rather than match the defect.
535    ShowsCharacters,
536    /// The box is empty and the object shows nothing but spaces and control
537    /// codes. Dropped, as PDFium drops it.
538    ///
539    /// A space here is genuinely redundant: the inter-object rules
540    /// (`GenerateSpace`) already emit a separator from the gap the object
541    /// sits in, so keeping the object emits it twice — that duplication was
542    /// the whole of the "spurious generated space" defect and cost 20 of the
543    /// 44 benchmark files their byte-exact match. A control code is not text
544    /// and the oracle is right to drop it: `text_tcpdf_055.pdf` shows codes
545    /// 0..=31 in one-glyph objects, `bug_651304.pdf` a lone `U+0001`.
546    ///
547    /// The exception is a page that has no other object at all, where there
548    /// is no gap for the heuristic to span; see
549    /// [`keep_spaces_only`](crate::pipeline::Builder::keep_spaces_only).
550    EmptyBox,
551}
552
553impl ObjectGate {
554    /// Whether extraction should see the object at all.
555    ///
556    /// `rescue` is the page-level exception described on [`Self::EmptyBox`].
557    #[must_use]
558    pub fn keeps(self, rescue: bool) -> bool {
559        match self {
560            Self::Occupies | Self::ShowsCharacters => true,
561            Self::EmptyBox => rescue,
562        }
563    }
564}
565
566/// The Unicode scalar an item shows, falling back to the character code.
567///
568/// `cpdf_textpage.cpp:1213-1215` does exactly this — `unicode +=
569/// static_cast<wchar_t>(item.char_code_)` when `UnicodeFromCharCode` comes
570/// back empty — and `pipeline` already reads codes this way in three places.
571/// It matters here because the fonts in question have no usable `ToUnicode`
572/// at all, so the mapping is empty on *every* object and only the code
573/// separates them.
574fn shown_char(run: &TextRun, item: &Item) -> u32 {
575    run.font
576        .unicode_from_charcode(item.code)
577        .first()
578        .map_or(item.code.0, |ch| u32::from(*ch))
579}
580
581/// Classifies one text object for the degenerate-object gate.
582///
583/// The empty-box case is kept when the object **moved the pen** and shows a
584/// character that is neither a space nor a control code.
585///
586/// *Moved the pen*: `w0` summed through the text matrix (§9.4.3), against
587/// the same epsilon the box uses. A glyph that displaces nothing is
588/// degenerate on both of §9.2.2's and §9.4.3's measures, so there is nothing
589/// on the page to recover — `bug_491516663.pdf` draws `U+200B`, a zero-width
590/// space, and `bug_491161396.pdf` a hairline pair at `w0` 0.006.
591///
592/// *Shows a character*: read through [`shown_char`]. This is the test that
593/// separates the letters PDFium loses from the control runs it rightly
594/// drops, and the mapping alone does **not** do it — measured on the
595/// fixtures, `unicode_from_charcode` comes back **empty for both**, because
596/// neither font carries a usable `ToUnicode`. The character *codes*
597/// separate them cleanly, which is why [`shown_char`] falls back to the code
598/// exactly as `cpdf_textpage.cpp:1213-1215` does:
599///
600/// | fixture | font | mapping | codes | verdict |
601/// |---|---|---|---|---|
602/// | `bug_921.pdf` | `FooFont` | empty | 1048 `И`, 8212 `—`, 1074 `в`, 1103 `я` | **kept** |
603/// | `text_tcpdf_055.pdf` | `Courier` | empty | 0..=31 | dropped |
604/// | `bug_651304.pdf` | (none) | empty | 1 | dropped |
605/// | `bug_491516663.pdf` | `Test` | `U+200B` | 1 | dropped (`w0` 0) |
606/// | `annots/annotation_*.pdf` | `ArialMT` | `U+00A0` | 3 | dropped (whitespace) |
607#[must_use]
608pub fn gate(run: &TextRun) -> ObjectGate {
609    if run.rect.width().abs() >= SIZE_EPSILON {
610        return ObjectGate::Occupies;
611    }
612    let shows_text = run.advance >= SIZE_EPSILON
613        && (0..run.count())
614            .filter_map(|index| run.item(index))
615            .any(|item| shows_glyph(shown_char(run, &item)));
616    if shows_text {
617        ObjectGate::ShowsCharacters
618    } else {
619        ObjectGate::EmptyBox
620    }
621}
622
623/// Whether a scalar is a character worth rescuing a degenerate object for.
624///
625/// Two families are not. **Whitespace** — including `U+00A0`, which the
626/// annotation fixtures draw by the dozen — carries only a separator, and the
627/// inter-object rules already emit that separator from the gap the object
628/// sits in, so keeping the object emits it twice. **Control codes**, C0, C1
629/// and `DEL`, are not text at all; `text_tcpdf_055.pdf` shows codes 0..=31
630/// in one-glyph objects and `bug_651304.pdf` a lone `U+0001`, and the oracle
631/// is right to drop both.
632///
633/// Everything else is content the box gate must not silently lose.
634fn shows_glyph(ch: u32) -> bool {
635    if ch < 0x20 || (0x7F..=0x9F).contains(&ch) {
636        return false;
637    }
638    char::from_u32(ch).is_none_or(|c| !c.is_whitespace())
639}
640
641/// Every text object on a page, in the order a pre-order walk reaches them,
642/// paired with the flattened index a [`CharBox`](crate::CharBox) refers to.
643///
644/// Form `XObject`s are walked in place, so a form's text objects sit between
645/// the page-level objects that surround the `Do`.
646#[must_use]
647pub fn walk(objects: &[PageObject]) -> Vec<TextRun> {
648    let mut out = Vec::new();
649    let mut next = 0u32;
650    collect(objects, &mut next, &mut out);
651    out
652}
653
654fn collect(objects: &[PageObject], next: &mut u32, out: &mut Vec<TextRun>) {
655    for object in objects {
656        match object {
657            PageObject::Text(content) => {
658                let index = ObjectIndex(*next);
659                *next += 1;
660                if let Some(run) = build(content, index) {
661                    out.push(run);
662                }
663            }
664            PageObject::Form(content) => {
665                *next += 1;
666                collect(&content.object.objects, next, out);
667            }
668            PageObject::Path(_) | PageObject::Image(_) | PageObject::Shading(_) => {
669                *next += 1;
670            }
671        }
672    }
673}
674
675/// The flattened walk index of each *page-level* text object, ascending.
676///
677/// [`walk`] numbers every object it reaches, descending into forms; the
678/// page-global orientation guess counts only the objects the page itself
679/// lists (a form object is not a text object, so its contents never reach
680/// the mask). This reproduces `collect`'s numbering without building
681/// anything, so the guess can read the runs [`walk`] already built instead
682/// of building them a second time.
683#[must_use]
684pub fn top_level_text_indices(objects: &[PageObject]) -> Vec<ObjectIndex> {
685    let mut out = Vec::new();
686    let mut next = 0u32;
687    for object in objects {
688        let index = ObjectIndex(next);
689        next = next.saturating_add(1);
690        match object {
691            PageObject::Text(_) => out.push(index),
692            PageObject::Form(content) => next = skip(&content.object.objects, next),
693            PageObject::Path(_) | PageObject::Image(_) | PageObject::Shading(_) => {}
694        }
695    }
696    out
697}
698
699/// Advances the walk counter past a subtree without building anything.
700fn skip(objects: &[PageObject], mut next: u32) -> u32 {
701    for object in objects {
702        next = next.saturating_add(1);
703        if let PageObject::Form(content) = object {
704            next = skip(&content.object.objects, next);
705        }
706    }
707    next
708}
709
710#[cfg(test)]
711mod tests {
712    // The widths compared here are whole numbers held exactly in `f64`, and
713    // the point of each assertion is which exact one comes out.
714    #![allow(clippy::float_cmp, reason = "test fixtures pin exact values")]
715
716    use super::GlyphWidth;
717
718    #[test]
719    fn a_width_truncates_toward_zero_as_the_saturated_cast_does() {
720        // `FX_Number::GetSigned` is `saturated_cast<int32_t>`, which rounds
721        // toward zero in both directions rather than to nearest.
722        assert_eq!(GlyphWidth::truncating(722.5).as_f64(), 722.0);
723        assert_eq!(GlyphWidth::truncating(722.9).as_f64(), 722.0);
724        assert_eq!(GlyphWidth::truncating(-722.9).as_f64(), -722.0);
725        // A whole number is untouched, which is every width the corpora hold.
726        assert_eq!(GlyphWidth::truncating(722.0).as_f64(), 722.0);
727        assert_eq!(GlyphWidth::truncating(0.0), GlyphWidth::ZERO);
728    }
729
730    #[test]
731    fn a_nonsense_width_is_zero_rather_than_a_saturated_extreme() {
732        // The `FX_RECT::Valid` analogue: a non-finite box yields no width.
733        assert_eq!(GlyphWidth::truncating(f32::NAN), GlyphWidth::ZERO);
734        assert_eq!(GlyphWidth::truncating(f32::INFINITY), GlyphWidth::ZERO);
735        assert_eq!(GlyphWidth::truncating(f32::NEG_INFINITY), GlyphWidth::ZERO);
736    }
737
738    #[test]
739    fn only_a_strictly_positive_width_ends_the_ladder() {
740        // Each of `GetCharWidth`'s first two exits tests `w > 0`, so a zero
741        // or negative width falls through to the next rung.
742        assert!(GlyphWidth::truncating(1.0).is_positive());
743        assert!(!GlyphWidth::ZERO.is_positive());
744        assert!(!GlyphWidth::truncating(-1.0).is_positive());
745        // A fraction under one truncates to zero and so does *not* stop the
746        // ladder, which is the C++ behaviour it mirrors.
747        assert!(!GlyphWidth::truncating(0.5).is_positive());
748    }
749
750    #[test]
751    fn the_max_of_two_widths_is_taken_on_the_integers() {
752        // `std::max(nLastWidth, nThisWidth)` at `cpdf_textpage.cpp:1303` is
753        // an integer max, which `Ord` on the newtype gives directly.
754        let a = GlyphWidth::truncating(500.0);
755        let b = GlyphWidth::truncating(722.0);
756        assert_eq!(a.max(b), b);
757        assert_eq!(b.max(a), b);
758    }
759}