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}