Skip to main content

pdf_inspector/
types.rs

1//! Shared types used across the extraction and markdown pipelines.
2//!
3//! Centralises `TextItem`, `TextLine`, `PdfRect`, font-width / encoding
4//! type aliases, and the `ItemType` enum so that every module can import
5//! them from one place.
6
7use std::collections::HashMap;
8
9use crate::text_utils::should_join_items;
10
11/// Result tuple returned by page-level text extraction: text items, rectangles, line segments,
12/// and whether fonts with unresolvable gid-encoded glyphs were encountered.
13pub(crate) type PageExtraction = (Vec<TextItem>, Vec<PdfRect>, Vec<PdfLine>);
14
15/// Per font (its `/BaseFont` name, or its resource name without one), how
16/// the codes shown through the font's CMap fared: the codes shown, the ones
17/// read from the mapped codes around them and the ones left unmapped.
18/// Ordered by name so documents report their fonts the same way.
19pub(crate) type CMapCoverageByFont =
20    std::collections::BTreeMap<String, crate::tounicode::CidDecodeStats>;
21
22/// The CMap coverage of one run of text of one font, with the geometry of
23/// the item a content stream walker attached it to — the item the run
24/// made, or the page's next item for a run that made none — once the
25/// page's frame was settled, so a run the page box leaves out takes its
26/// codes with it.
27/// The name a font's coverage is counted under — its `/BaseFont` name, or
28/// its resource name without one — shared by every run of the font on the
29/// page rather than copied for each.
30pub(crate) type FontLabel = std::rc::Rc<str>;
31
32#[derive(Debug, Clone, PartialEq)]
33pub(crate) struct RunCoverage {
34    /// The run's `(x, y, width)` in the items' frame; `None` for the
35    /// coverage of show operators that no item followed on the page (a
36    /// trailing run of blank codes, of codes that read as nothing), which
37    /// has no place to be judged by and is counted wherever it lies.
38    pub(crate) position: Option<(f32, f32, f32)>,
39    /// The font's `/BaseFont` name, or its resource name without one.
40    pub(crate) font: FontLabel,
41    pub(crate) stats: crate::tounicode::CidDecodeStats,
42}
43
44/// The coverage waiting to be attached to an item: the decodes of the
45/// show operators since the last attachment, summed per font in the order
46/// the fonts were used, so a blank run of one font waiting beside the next
47/// run of another keeps its own name.
48pub(crate) type PendingCoverage = Vec<(FontLabel, crate::tounicode::CidDecodeStats)>;
49
50/// Per item of a content stream walker, parallel to its items like their
51/// clips: the coverage of the decodes that produced the item. The coverage
52/// waiting when a show operator appends items goes to the first of them,
53/// nothing to the rest (one TJ array can split into several).
54pub(crate) type ItemCoverage = Vec<PendingCoverage>;
55
56/// Attach the coverage the show operators since the last attachment
57/// recorded — `take` hands it over and leaves none behind — to the first of
58/// the items they appended, bringing `item_coverage` up to `items_len`
59/// entries. An operator that appended no item — a run of blank codes, of
60/// codes that read as nothing — leaves its coverage waiting for the next
61/// item appended on the page, so that its codes count where that item is;
62/// the page walker keeps what is still waiting at the end of the page as a
63/// run without a position.
64pub(crate) fn attach_run_coverage(
65    item_coverage: &mut ItemCoverage,
66    items_len: usize,
67    take: impl FnOnce() -> PendingCoverage,
68) {
69    if items_len > item_coverage.len() {
70        item_coverage.push(take());
71        item_coverage.resize(items_len, Vec::new());
72    }
73}
74
75#[cfg(test)]
76mod run_coverage_tests {
77    use super::{attach_run_coverage, FontLabel, ItemCoverage, PendingCoverage};
78    use crate::tounicode::CidDecodeStats;
79
80    #[test]
81    fn coverage_of_an_operator_without_an_item_waits_for_the_next_item() {
82        let stats = |codes: u32| CidDecodeStats {
83            codes,
84            interpolated: 0,
85            unmapped: 0,
86        };
87        let label: FontLabel = FontLabel::from("F");
88        let mut pending: PendingCoverage = vec![(label.clone(), stats(1))];
89        let mut coverage = ItemCoverage::new();
90        // No item appended: the coverage is not taken.
91        attach_run_coverage(&mut coverage, 0, || std::mem::take(&mut pending));
92        assert!(coverage.is_empty());
93        assert_eq!(pending.len(), 1);
94        // Two items appended by the next operator: the first gets it.
95        attach_run_coverage(&mut coverage, 2, || std::mem::take(&mut pending));
96        assert_eq!(coverage, vec![vec![(label, stats(1))], vec![]]);
97        assert!(pending.is_empty());
98    }
99}
100
101// ── Font types (crate-internal) ──────────────────────────────────────
102
103/// Font encoding map: maps byte codes to Unicode characters
104pub(crate) type FontEncodingMap = HashMap<u8, char>;
105
106/// A single-byte encoding a simple font's codes read through where its
107/// `/Differences` say nothing: one of the predefined encodings named by an
108/// encoding dictionary's `/BaseEncoding`, or the built-in encoding of the
109/// standard Symbol and ZapfDingbats fonts, whose glyphs sit at positions
110/// that have nothing to do with the Latin encodings.
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112pub(crate) enum BaseEncoding {
113    Standard,
114    WinAnsi,
115    MacRoman,
116    MacExpert,
117    Symbol,
118    ZapfDingbats,
119}
120
121/// Explicit glyph encodings and narrowly verified repairs for a stale CMap.
122pub(crate) struct FontEncoding {
123    /// The character of each code the font's `/Differences` name by a name
124    /// that reads as one (through the embedded program for a numbered
125    /// name), and, beneath them, of each code the built-in encoding of an
126    /// embedded Type 1 program names, for a font whose `/Encoding` names no
127    /// base (see `fonts::type1_builtin_encoding`); a longer reading is in
128    /// `sequences`.
129    pub(crate) differences: FontEncodingMap,
130    /// Codes whose entry in a stale ToUnicode CMap describes the slot's
131    /// original occupant rather than the glyph the font's `/Differences`
132    /// put there, and what that glyph reads as by its name: a character,
133    /// the letters of a ligature, or nothing (an empty string) for a glyph
134    /// whose name spells no character (see
135    /// `fonts::stale_identity_cmap_overrides`).
136    pub(crate) identity_overrides: HashMap<u8, String>,
137    /// Codes whose embedded glyph has no outline but a positive advance:
138    /// painted, they leave a gap and nothing else, so they read as spaces
139    /// whatever the font's ToUnicode claims (see `blank_glyph_codes`).
140    pub(crate) blank_codes: std::collections::HashSet<u8>,
141    /// The encoding the codes read through where `differences` say nothing
142    /// (see [`BaseEncoding`]); `None` leaves them to the standard decode.
143    pub(crate) base: Option<BaseEncoding>,
144    /// The predefined encoding the font declares by name (`/Encoding
145    /// /WinAnsiEncoding`, written in place or as an indirect name object),
146    /// which names the glyph of each code — read for a code whose ToUnicode
147    /// entry is a control destination, where the standard decode reads the
148    /// rest as before.
149    pub(crate) named: Option<BaseEncoding>,
150    /// Every code the `/Differences` array names, mapped or not, and each
151    /// code other than the word space that the encoding array of an
152    /// embedded Type 1 program leaves at `.notdef`, for a font whose
153    /// `/Encoding` names no base (see `fonts::type1_builtin_encoding`): a
154    /// code named there is that glyph, whatever the base encoding puts at
155    /// it, and one that `differences` and `sequences` do not read reads as
156    /// nothing.
157    pub(crate) named_codes: std::collections::HashSet<u8>,
158    /// Codes whose glyph stands for several characters, named by the
159    /// `/Differences` or, beneath them, by the encoding array of an
160    /// embedded Type 1 program: a ligature named by its components (`f_t`,
161    /// `f_f_i`) or by a `uni` sequence, read as the letters it joins.
162    pub(crate) sequences: HashMap<u8, String>,
163}
164
165/// All font encodings for a page
166pub(crate) type PageFontEncodings = HashMap<String, FontEncoding>;
167
168/// Font width information extracted from PDF font dictionaries
169#[derive(Debug, Clone)]
170#[allow(dead_code)]
171pub(crate) struct FontWidthInfo {
172    /// Glyph widths: maps character code to width in font units
173    pub(crate) widths: HashMap<u16, u16>,
174    /// Default width for glyphs not in the widths table
175    pub(crate) default_width: u16,
176    /// Width of the space character (code 32) if known
177    pub(crate) space_width: u16,
178    /// Whether this is a CID font (2-byte character codes)
179    pub(crate) is_cid: bool,
180    /// Scale factor to convert font units to text space units.
181    /// For Type1/TrueType: 0.001 (widths in 1000ths of em)
182    /// For Type3: FontMatrix[0] (e.g., 0.00048828125 for 2048-unit grid)
183    pub(crate) units_scale: f32,
184    /// Writing mode: 0 = horizontal (default), 1 = vertical
185    pub(crate) wmode: u8,
186}
187
188/// Glyphs a width table has to carry, all sharing one advance, before it
189/// counts as fixed-pitch on the evidence of its advances alone: a
190/// proportional face's ten tabular digits share theirs too.
191pub(crate) const FIXED_PITCH_MIN_GLYPHS: usize = 12;
192
193impl FontWidthInfo {
194    /// Whether the glyphs in the width table share one advance: `Some(true)`
195    /// when at least [`FIXED_PITCH_MIN_GLYPHS`] codes carry a positive
196    /// width and every one of them is the same (a hair's breadth of
197    /// rounding apart), `Some(false)` when two positive widths differ,
198    /// `None` when the table is too small to say. Zero widths are codes
199    /// without a glyph (or combining marks) and do not count either way;
200    /// a CID font's default width covers glyphs the table does not list
201    /// and is not read.
202    pub(crate) fn fixed_pitch_by_advance(&self) -> Option<bool> {
203        let mut count = 0usize;
204        let mut low = u16::MAX;
205        let mut high = 0u16;
206        for &width in self.widths.values() {
207            if width == 0 {
208                continue;
209            }
210            count += 1;
211            low = low.min(width);
212            high = high.max(width);
213        }
214        if count == 0 {
215            return None;
216        }
217        // Widths are written in thousandths of the em (or the Type3 glyph
218        // space): a unit of rounding between two writings of one advance is
219        // still one advance.
220        let tolerance = (f32::from(high) * 0.002).max(1.0);
221        if f32::from(high - low) > tolerance {
222            return Some(false);
223        }
224        (count >= FIXED_PITCH_MIN_GLYPHS).then_some(true)
225    }
226}
227
228/// All font width info for a page, keyed by font resource name
229pub(crate) type PageFontWidths = HashMap<String, FontWidthInfo>;
230
231/// Whether each font resource of a page is composite (`/Subtype /Type0`): a
232/// composite font's codes are as wide as its CMap says, every other font
233/// shows one byte per code (PDF 32000-1:2008, 9.6). A font whose subtype
234/// cannot be read has no entry.
235pub(crate) type PageFontKinds = HashMap<String, bool>;
236
237// ── Public types ─────────────────────────────────────────────────────
238
239/// Type of extracted item
240#[derive(Debug, Clone, Default)]
241pub enum ItemType {
242    /// Regular text content
243    #[default]
244    Text,
245    /// Image placeholder
246    Image,
247    /// Hyperlink (with URL)
248    Link(String),
249    /// Form field (name: value)
250    FormField,
251}
252
253/// Layout complexity analysis result.
254///
255/// Callers can use this to decide whether the extracted markdown is reliable
256/// or whether the PDF should be routed to an OCR pipeline instead.
257#[derive(Debug, Clone, Default)]
258pub struct LayoutComplexity {
259    /// True if any page has tables or multi-column text.
260    pub is_complex: bool,
261    /// 1-indexed pages where table borders were detected (rect count > 6).
262    pub pages_with_tables: Vec<u32>,
263    /// 1-indexed pages where 2+ text columns were detected.
264    pub pages_with_columns: Vec<u32>,
265}
266
267/// A line segment from PDF path operators (`m`/`l`/`S`).
268#[derive(Debug, Clone)]
269pub struct PdfLine {
270    pub x1: f32,
271    pub y1: f32,
272    pub x2: f32,
273    pub y2: f32,
274    pub page: u32,
275}
276
277/// A rectangle from a PDF `re` operator (cell boundary, border, etc.)
278#[derive(Debug, Clone)]
279pub struct PdfRect {
280    pub x: f32,
281    pub y: f32,
282    pub width: f32,
283    pub height: f32,
284    pub page: u32,
285}
286
287/// Where [`TextItem::is_bold`] came from.
288///
289/// The extraction reads bold from several places and reports the first of
290/// them, in this order, that says the text is bold; `TextItem::bold_source`
291/// carries it so a caller can tell a face whose name says bold apart from
292/// one whose weight class does, and both from text merely stroked to look
293/// heavier.
294#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
295pub enum BoldSource {
296    /// A bold word or foundry style abbreviation in the font name ("Bold",
297    /// "-Bd", "Black", "Demi", "-Hv", "W7"; see
298    /// [`is_bold_font`](crate::extractor::is_bold_font)).
299    FontName,
300    /// The FontDescriptor's ForceBold flag, or the embedded font program's
301    /// own bold selection (OS/2 `fsSelection`, the `head` table's
302    /// `macStyle`).
303    FontFlags,
304    /// The weight class: `TextItem::font_weight` at or above the threshold
305    /// of [`PositionOptions::bold_from_weight`](crate::PositionOptions).
306    WeightClass,
307    /// Text filled and stroked to look heavier (render mode 2 or 6 with a
308    /// visible stroke of the fill colour) in a face that is not bold itself.
309    Painted,
310}
311
312impl BoldSource {
313    /// The snake_case name the JSON outputs and the Python binding use:
314    /// `"font_name"`, `"font_flags"`, `"weight_class"`, `"painted"`.
315    pub fn as_str(self) -> &'static str {
316        match self {
317            BoldSource::FontName => "font_name",
318            BoldSource::FontFlags => "font_flags",
319            BoldSource::WeightClass => "weight_class",
320            BoldSource::Painted => "painted",
321        }
322    }
323
324    /// The one of two sources an item reports when both say bold: the
325    /// earlier in the order the variants are listed.
326    pub(crate) fn first(a: Option<Self>, b: Option<Self>) -> Option<Self> {
327        fn rank(source: BoldSource) -> u8 {
328            match source {
329                BoldSource::FontName => 0,
330                BoldSource::FontFlags => 1,
331                BoldSource::WeightClass => 2,
332                BoldSource::Painted => 3,
333            }
334        }
335        match (a, b) {
336            (Some(a), Some(b)) => Some(if rank(b) < rank(a) { b } else { a }),
337            (a, b) => a.or(b),
338        }
339    }
340}
341
342/// A text item with position information.
343///
344/// `x`, `y`, `width`, `height` describe the item's axis-aligned box in PDF
345/// points, y-up. For ordinary horizontal text the box runs from the baseline
346/// one em upward and spans the run's advance, which is what every consumer
347/// historically assumed. A run shown with a rotated text matrix gets the
348/// bounding box of its rotated glyph run instead — tall and thin for a
349/// vertical margin stamp — and reports the angle in `rotation`.
350///
351/// # Coordinate frame
352///
353/// Items returned by the public position APIs (`extract_text_with_positions*`)
354/// are relative to the page's **visible page box** —
355/// `CropBox ∩ MediaBox` when the page has a CropBox, else the MediaBox; a
356/// CropBox that does not overlap the MediaBox is ignored, and a page without
357/// a MediaBox is measured against US Letter (see `extractor::page_box`) —
358/// with the box's lower-left corner as the origin and `y` growing upward.
359/// A renderer's page image and the region APIs use the same box from its
360/// top-left corner with `y` growing downward, so flipping `y` by the box
361/// height lets items and rendered regions be intersected directly. Raw
362/// content-stream coordinates differ whenever the CropBox or MediaBox origin
363/// is not `(0, 0)`. A page whose text is predominantly rotated has its frame
364/// turned so the text reads left-to-right (`PageRotation`, reported by
365/// `extract_text_with_positions_and_rotations_mem`) and the shift is turned
366/// the same way; `/Rotate` is not applied. Inside the markdown pipeline
367/// items stay in raw user space.
368///
369/// [`PositionFrame::Display`](crate::PositionFrame) (see
370/// [`extract_text_with_positions_mem_in_frame`](crate::extract_text_with_positions_mem_in_frame))
371/// reports items in the rendered page's frame instead: the visible page box
372/// turned clockwise by the page's inheritable `/Rotate`, with the turn of a
373/// rotated page undone, so the box and `rotation` describe the item as a
374/// renderer draws it.
375#[derive(Debug, Clone)]
376pub struct TextItem {
377    /// The text content
378    pub text: String,
379    /// Left edge of the item's box, in PDF points from the visible page
380    /// box's left edge (see the coordinate frame note on [`TextItem`]).
381    pub x: f32,
382    /// Bottom edge of the item's box, in PDF points from the visible page
383    /// box's bottom edge with `y` growing upward (see the coordinate frame
384    /// note on [`TextItem`]). For horizontal text this is the baseline;
385    /// descenders are not included. Image, link and form-field items carry
386    /// their rect's bottom edge.
387    pub y: f32,
388    /// Horizontal extent of the box: the advance for horizontal text, the
389    /// em size for a vertical run. Zero only for a horizontal run whose font
390    /// carries no width information (advance unknown).
391    pub width: f32,
392    /// Vertical extent of the box: the rendered em size for horizontal
393    /// text, the advance for a vertical run.
394    pub height: f32,
395    /// Rotation of the run's baseline in degrees, counter-clockwise from the
396    /// page's +x axis, normalised to `[0, 360)`: `0` for ordinary
397    /// left-to-right text, `90` for text reading bottom-to-top (a margin
398    /// stamp rotated counter-clockwise), `270` for text reading
399    /// top-to-bottom, `180` for upside-down text. Rotation-only matrices
400    /// report exact multiples of 90; skewed matrices (deskewed OCR layers,
401    /// diagonal watermarks) report fractional angles. A reflected text
402    /// matrix has no rotation — its reading direction and its glyphs'
403    /// orientation differ by a half turn — and reports how its glyphs
404    /// stand: `0` for the mirrored-x matrix some producers paint
405    /// right-to-left text with (upright glyphs reading left), `180` for a
406    /// y-flipped one. A negative `Tf` size turns a run around and reads as
407    /// `180`. `0` for items that don't come from a text matrix (images,
408    /// links, form fields, OCR).
409    /// On a page whose text is predominantly rotated the extractor turns
410    /// the coordinate frame so the dominant runs read as `0`; upright
411    /// strays then report `270` on a counter-clockwise page and `90` on a
412    /// clockwise one.
413    pub rotation: f32,
414    /// Whether the run's advance came from font metrics. `false` when the
415    /// font carries no width information (or, for an ActualText span, when
416    /// the advance could not be recovered from the text matrix): the box's
417    /// extent along the baseline is then an estimate of half an em per
418    /// painted glyph (an ActualText span counts the glyphs it covers, not its
419    /// replacement text), laid in the direction the run reads, rather than
420    /// a measurement. A
421    /// font that reports a genuine zero advance keeps `true`. Items that
422    /// don't come from a text matrix (images, links, form fields, OCR)
423    /// always report `true`.
424    pub advance_known: bool,
425    /// Font name: the `/BaseFont` family name ("ABCDEF+CMMI10"), which
426    /// identifies the actual face (see `extractor::fonts::item_font_name`
427    /// for the CID carve-out).
428    pub font: String,
429    /// The raw font resource tag ("F2", "T22") the item's show operator
430    /// selected. This is exactly what `font` carried before 1.16.0, with
431    /// the same caveats: the tag's namespace is the enclosing page or Form
432    /// XObject's `/Resources` (the same tag on another page may name a
433    /// different face), and an item merged from multiple runs keeps the
434    /// first run's tag. Within one page it distinguishes font *programs*
435    /// that share a family name (two subsets of the same face keep
436    /// distinct tags), which family-keyed `font` cannot. Empty for items
437    /// that don't originate from a content-stream show operator (images,
438    /// links, form fields, OCR).
439    pub font_tag: String,
440    /// At least one source character was changed by the legacy private-use
441    /// symbol cleanup. This is decoding provenance, not an OCR verdict:
442    /// the rewritten value must not be assumed to be an authoritative
443    /// Unicode alias. Merged items retain evidence from every contributing run;
444    /// later text splits conservatively retain their source run's evidence.
445    pub legacy_symbol_rewrite: bool,
446    /// Font size
447    pub font_size: f32,
448    /// Page number (1-indexed)
449    pub page: u32,
450    /// Whether the font is bold: a bold word in the font name, the
451    /// FontDescriptor's ForceBold flag or the embedded program's bold
452    /// selection, or text filled and stroked to look heavier. With
453    /// [`PositionOptions::bold_from_weight`](crate::PositionOptions) it is
454    /// also `true` when `font_weight` is 600 or more.
455    pub is_bold: bool,
456    /// Whether the font is italic
457    pub is_italic: bool,
458    /// The font's weight class on the 100..=900 scale shared by CSS
459    /// `font-weight` and the OS/2 `usWeightClass` field (400 regular, 700
460    /// bold), read from the embedded font program's OS/2 table, else the
461    /// FontDescriptor's `/FontWeight`, else a weight word in the font name
462    /// ("Light", "Medium", "-Md", "Black", "W6"). `None` when none of them
463    /// says, and for items that don't come from a font (images, links, form
464    /// fields, OCR). Independent of `is_bold`, which stays as it was: a
465    /// medium face reports `Some(500)` and `is_bold: false`.
466    pub font_weight: Option<u16>,
467    /// Where `is_bold` came from, so a caller can weigh the verdict against
468    /// `font_weight`: the font name, the font's flags, the weight class or
469    /// the way the text was painted (see [`BoldSource`]). When more than one
470    /// says bold the first of them in that order is reported. `None` when
471    /// `is_bold` is `false`, and for items that don't come from a font. An
472    /// item merged from several runs keeps its first run's, like `font` and
473    /// `font_weight`.
474    pub bold_source: Option<BoldSource>,
475    /// Whether the font is fixed-pitch (monospaced): `Some(true)` when the
476    /// FontDescriptor's FixedPitch flag or the embedded program's `post`
477    /// table says so, else measured from the font's width table — `Some(true)`
478    /// when at least a dozen of its glyphs share one advance, `Some(false)`
479    /// when two of them differ. `None` when the font declares nothing and no
480    /// two advances differ but fewer than a dozen share one, and for items
481    /// that don't come from a font. Many producers write `/Flags 4` whatever
482    /// the face, so the
483    /// flag is only ever read as a yes. Runs are not kept apart by it: an
484    /// item merged from several runs keeps its first run's value, like
485    /// `font` and `font_weight`.
486    pub fixed_pitch: Option<bool>,
487    /// The fill (non-stroking) colour in force when the run was shown, as
488    /// 8-bit sRGB `[red, green, blue]`: the colour its glyphs are filled
489    /// with in the render modes that fill (0, 2, 4, 6; see `render_mode`).
490    /// DeviceRGB is read as sRGB and DeviceGray as three equal components;
491    /// DeviceCMYK is converted the way the PDF specification converts it to
492    /// DeviceRGB, each of red, green and blue `1 - min(1, ink + black)`. An
493    /// ICCBased space is read as the device space of its component count
494    /// (1, 3 or 4) without applying the profile, and an Indexed space
495    /// (`/Indexed`, or its abbreviation `/I`) through its palette's base
496    /// space, which may be named by another colour space resource.
497    /// Components outside their range are clamped to it, and selecting a
498    /// space with `cs`/`CS` sets the initial colour the PDF specification
499    /// gives it: black, except a four-component ICCBased space, whose
500    /// components all start at 0, which reads as white. `None` for any
501    /// other colour space (Separation,
502    /// DeviceN, Pattern, CalGray, CalRGB, Lab), for a colour operator whose
503    /// operands do not fit its space, and for items that don't come from a
504    /// content-stream show operator (images, links, form fields, OCR). The
505    /// colour is graphics state: `q`/`Q` save and restore it and a Form
506    /// XObject starts with the colour it was invoked under. Runs are not
507    /// kept apart by it: an item merged from several runs keeps its first
508    /// run's value, like `font`.
509    pub fill_color: Option<[u8; 3]>,
510    /// The stroking colour in force when the run was shown, as 8-bit sRGB,
511    /// read like `fill_color`: the colour the glyph outlines are stroked
512    /// with in the render modes that stroke (1, 2, 5, 6). `None` when
513    /// unknown or not from a show operator, as for `fill_color`, and an
514    /// item merged from several runs keeps its first run's value.
515    pub stroke_color: Option<[u8; 3]>,
516    /// The text render mode (`Tr`) the run was shown with, `0..=7`: 0 fill,
517    /// 1 stroke, 2 fill then stroke, 3 neither (invisible text, the mode OCR
518    /// text layers use), 4 to 6 as 0 to 2 while also adding the glyphs to
519    /// the clipping path, 7 clipping only. Runs in modes 3 and 7 put no
520    /// glyphs on the page, so callers can tell visible text from invisible
521    /// text by it. The mode is graphics state: it holds across text objects,
522    /// `q`/`Q` save and restore it, and a Form XObject starts with the mode
523    /// it was invoked under; a `Tr` whose operand is not an integer in
524    /// `0..=7` is ignored, as renderers ignore it. Reporting the mode leaves
525    /// which runs are extracted exactly as before: invisible text is neither
526    /// dropped nor added because of it. `None` for items that don't come
527    /// from a content-stream show operator (images, links, form fields,
528    /// OCR); an item merged from several runs keeps its first run's value.
529    pub render_mode: Option<u8>,
530    /// Whether the text is underlined (drawn rule/thin rect under the
531    /// baseline — PDFs have no underline font flag, so this is detected
532    /// geometrically after extraction; see `extractor::underline`).
533    pub is_underline: bool,
534    /// Whether the text is struck out (drawn rule/thin rect crossing the
535    /// glyphs at mid x-height). Same geometric detection as underline,
536    /// different vertical window; see `extractor::underline`.
537    pub is_strikeout: bool,
538    /// Type of item (text, image, link)
539    pub item_type: ItemType,
540    /// Marked Content ID from the content stream's BDC/BMC operator.
541    /// Used to link this item to the PDF structure tree for tagged PDFs.
542    pub mcid: Option<i64>,
543    /// Signed baseline offset, in points, of a superscript/subscript glyph
544    /// run from the baseline of the body text it is attached to. Zero for
545    /// normal text. Positive = raised above the anchor's baseline
546    /// (superscript: footnote and affiliation markers, exponents), negative =
547    /// lowered (subscript: chemistry indices, math). Extraction sets it when
548    /// a short run is small relative to a tightly adjacent larger neighbor
549    /// and sits at a real baseline offset from it; a digit-only run beside a
550    /// word is instead fused into that word as Unicode super/subscript
551    /// characters ("H₂O", "word²") and never carries a shift. `y` stays the
552    /// glyph's own baseline; [`TextItem::line_y`] gives the anchor's.
553    pub baseline_shift: f32,
554}
555
556impl TextItem {
557    /// Baseline of the visual line this item belongs to: the glyphs'
558    /// baseline for normal text, the anchor's baseline for a super/subscript
559    /// glyph run (`baseline_shift` below it). Line grouping compares this
560    /// instead of `y` so raised and lowered markers stay on their body line
561    /// and upside-down runs of different sizes share theirs.
562    pub fn line_y(&self) -> f32 {
563        self.baseline_y() - self.baseline_shift
564    }
565
566    /// The y of the edge the glyphs stand on or hang from: `y` (the box's
567    /// bottom edge) for a run within 45° of upright, `y + height` (the top
568    /// edge) for one within 45° of upside-down (`is_upside_down()`, glyph
569    /// orientation included for reflected matrices). Exact for level runs;
570    /// for oblique ones the baseline is not horizontal and the edge is only
571    /// an approximation of it, which is what line grouping then compares.
572    /// Vertical runs return the box bottom `y`.
573    pub fn baseline_y(&self) -> f32 {
574        if self.is_upside_down() {
575            self.y + self.height
576        } else {
577            self.y
578        }
579    }
580
581    /// `true` for a glyph run flagged as a super- or subscript of a larger
582    /// neighbor (non-zero `baseline_shift`).
583    pub fn is_script(&self) -> bool {
584        self.baseline_shift != 0.0
585    }
586}
587
588impl TextItem {
589    /// Whether the run reads along the page's x axis rather than its y
590    /// axis: `rotation` closer to `0`/`180` than to `90`/`270`, the same
591    /// 45° split the extractor uses to vote on page rotation. Layout
592    /// heuristics that reason about baselines, word gaps, and column spans
593    /// walk the x axis and assume this; rotated runs (margin stamps, chart
594    /// axis titles, rotated table headers) return `false` and are kept out
595    /// of them. Oblique runs (diagonal watermarks, deskewed OCR lines) are
596    /// deliberately still `true`: the x-axis heuristics are the closest fit
597    /// the pipeline has for them, exactly as before `rotation` existed, and
598    /// callers needing the precise angle read `rotation` directly.
599    pub fn is_horizontal(&self) -> bool {
600        let r = self.rotation.rem_euclid(360.0);
601        let vertical = (r > 45.0 && r < 135.0) || (r > 225.0 && r < 315.0);
602        !vertical
603    }
604
605    /// Whether the run reads along +x: `rotation` within 45° of `0`. The
606    /// x-ascending walks (item merging, line assembly) assume this; an
607    /// upside-down run is `is_horizontal()` but reads towards -x.
608    pub fn is_upright(&self) -> bool {
609        let r = self.rotation.rem_euclid(360.0);
610        r <= 45.0 || r >= 315.0
611    }
612
613    /// Whether the run reads towards -x: `rotation` within 45° of `180`.
614    pub fn is_upside_down(&self) -> bool {
615        self.is_horizontal() && !self.is_upright()
616    }
617
618    /// The item's extent perpendicular to its reading direction: `height`
619    /// for an unrotated item — identical to the historical value, and the
620    /// only meaningful extent for image, link, and OCR boxes — and the
621    /// rendered em (`font_size`) for any rotated run, whose axis-aligned
622    /// box mixes the advance into both dimensions (a long diagonal run is
623    /// not a tall line). Only content-stream runs carry a non-zero
624    /// `rotation`, and they set `font_size` to exactly the em height the
625    /// unrotated case reports.
626    pub(crate) fn cross_extent(&self) -> f32 {
627        if self.rotation == 0.0 {
628            self.height
629        } else {
630            self.font_size
631        }
632    }
633}
634
635/// A line of text (grouped text items)
636#[derive(Debug, Clone)]
637pub struct TextLine {
638    pub items: Vec<TextItem>,
639    pub y: f32,
640    pub page: u32,
641    /// Adaptive join threshold from page-level letter-spacing detection.
642    /// Default 0.10 for normal PDFs; higher for Canva-style PDFs.
643    #[doc(hidden)]
644    pub adaptive_threshold: f32,
645}
646
647/// Gap, as a fraction of the larger font size, from which a script glyph and
648/// its normal-sized neighbor are separate words. Attached markers sit at
649/// ~0 gap (kerned ones slightly negative); a word space is ≥ 0.2 em.
650const SCRIPT_WORD_GAP: f32 = 0.12;
651/// Spacing at the edge of a super/subscript run — the single policy shared
652/// by line rendering (`TextLine::text`) and table-cell joining. `None` when
653/// neither item is a script run, so the caller's ordinary rules apply.
654///
655/// A run's glyphs arrive pre-joined by extraction, so only the boundary
656/// between a run and its neighbor is decided here, by the measured gap: a
657/// footnote marker hugs the word before it ("word<sup>1</sup>"), a leading
658/// affiliation marker hugs the word after it ("<sup>1,2</sup>Hong Kong"),
659/// and a word space after a marker survives ("<sup>2</sup> next"). Existing
660/// whitespace, hyphen junctions, open brackets before and closing
661/// punctuation after a run never take a space.
662pub(crate) fn script_edge_needs_space(
663    prev: &TextItem,
664    item: &TextItem,
665    result: &str,
666    text: &str,
667) -> Option<bool> {
668    if !(prev.is_script() || item.is_script()) {
669        return None;
670    }
671    if stacked_fraction_slash(prev, item) {
672        return Some(false);
673    }
674    // Different visual lines (a wrapped table cell): a run at the end of
675    // one line never attaches to the start of the next, whatever the x
676    // overlap says.
677    if (prev.line_y() - item.line_y()).abs() > prev.font_size.max(item.font_size) * 0.5 {
678        return Some(true);
679    }
680    let curr = text.trim_start();
681    // `result` ends with the closing tag when the previous item is a run,
682    // so its raw text is inspected too for hyphens and open brackets.
683    if result.ends_with([' ', '-', '(', '[', '{'])
684        || prev.text.ends_with(' ')
685        || prev.text.trim_end().ends_with(['-', '(', '[', '{'])
686        || text.starts_with(' ')
687        || curr.starts_with('-')
688        || curr
689            .chars()
690            .next()
691            .is_some_and(|c| matches!(c, '.' | ',' | ';' | ':' | '!' | '?' | ')' | ']' | '}'))
692    {
693        return Some(false);
694    }
695    let gap = if prev.x <= item.x {
696        item.x - (prev.x + prev.width)
697    } else {
698        prev.x - (item.x + item.width)
699    };
700    Some(gap >= prev.font_size.max(item.font_size) * SCRIPT_WORD_GAP)
701}
702
703/// A stacked case fraction: a digit-only superscript run directly followed
704/// by a digit-only subscript run that overlaps it horizontally — the
705/// numerator over the denominator, as TeX sets "3⅓". Rendered with a slash
706/// between the runs (`3 <sup>1</sup>/<sub>3</sub>`) instead of the runs
707/// being glued into one number.
708pub(crate) fn stacked_fraction_slash(prev: &TextItem, item: &TextItem) -> bool {
709    let digits = |t: &str| !t.is_empty() && t.chars().all(char::is_numeric);
710    prev.baseline_shift > 0.0
711        && item.baseline_shift < 0.0
712        && (prev.line_y() - item.line_y()).abs() <= prev.font_size.max(item.font_size) * 0.5
713        && digits(prev.text.trim())
714        && digits(item.text.trim())
715        && item.x < prev.x + prev.width
716        && prev.x < item.x + item.width
717}
718
719/// Append an item's text, wrapping a super/subscript run in its tag.
720/// Shared by line rendering and table-cell joining so both emit the same
721/// markup for a run.
722pub(crate) fn push_item_text(result: &mut String, item: &TextItem, text: &str) {
723    let tag = if item.baseline_shift > 0.0 {
724        "sup"
725    } else if item.baseline_shift < 0.0 {
726        "sub"
727    } else {
728        result.push_str(text);
729        return;
730    };
731    result.push('<');
732    result.push_str(tag);
733    result.push('>');
734    result.push_str(text);
735    result.push_str("</");
736    result.push_str(tag);
737    result.push('>');
738}
739
740impl TextLine {
741    pub fn text(&self) -> String {
742        self.text_with_formatting(false, false, false)
743    }
744
745    /// Get text with optional bold/italic/decorative markdown formatting.
746    ///
747    /// `format_decorations` enables both geometrically detected source
748    /// decorations: underline (`<u>`) and strikeout (`<s>`).
749    pub fn text_with_formatting(
750        &self,
751        format_bold: bool,
752        format_italic: bool,
753        format_decorations: bool,
754    ) -> String {
755        if !format_bold && !format_italic && !format_decorations {
756            return self.text_plain();
757        }
758
759        let single_char_threshold = self.adaptive_threshold;
760
761        let mut result = String::new();
762        let mut current_bold = false;
763        let mut current_italic = false;
764        let mut current_underline = false;
765        let mut current_strikeout = false;
766
767        for (i, item) in self.items.iter().enumerate() {
768            let text = item.text.as_str();
769            let text_trimmed = text.trim();
770
771            // Skip empty items
772            if text_trimmed.is_empty() {
773                continue;
774            }
775
776            // Determine spacing
777            let needs_space = if i == 0 || result.is_empty() {
778                false
779            } else {
780                let prev_item = &self.items[i - 1];
781                self.needs_space_between(prev_item, item, &result, single_char_threshold)
782            };
783
784            // Preserve leading whitespace from the item text.
785            // Items like " means any person" have a leading space that indicates
786            // a word boundary. needs_space_between returns false for these (because
787            // space_already_exists), but we still need to emit the space since
788            // we push text_trimmed below (which strips it).
789            let has_leading_space = text.starts_with(' ');
790            let emit_space =
791                needs_space || (has_leading_space && !result.is_empty() && !result.ends_with(' '));
792
793            // A super/subscript run is wrapped in `<sup>`/`<sub>` (see
794            // `text_plain`). It neither opens nor closes the other styles: a
795            // footnote marker inside a bold name keeps the bold run intact
796            // ("**Yibo Yan<sup>1</sup>, Jiahao Huo**") instead of splitting
797            // it around the marker.
798            let is_script = item.is_script();
799
800            // Check for style changes. Source decorations are exclusive:
801            // `<u>`/`<s>` content stays free of `**`/`*` markers — consumers
802            // (and the eval harnesses this feeds) match tag content literally,
803            // and mixed nesting breaks that. A struck-and-underlined item is
804            // emitted as struck text because deletion is the stronger semantic
805            // distinction in redline documents.
806            let own_strikeout = format_decorations && item.is_strikeout;
807            let own_underline = format_decorations && item.is_underline && !own_strikeout;
808            let own_bold = format_bold && item.is_bold && !own_underline && !own_strikeout;
809            let own_italic = format_italic && item.is_italic && !own_underline && !own_strikeout;
810            // A script run inherits whatever body style is open around it
811            // (see above) — its own bold/italic is noise (italic math indices
812            // would shatter into `*<sub>t</sub>*` fragments) — but a run
813            // carrying its own DECORATION, an underlined link marker in plain
814            // text, keeps it: decorations are drawn ink, not font styling.
815            let (item_strikeout, item_underline, item_bold, item_italic) = if is_script {
816                (
817                    current_strikeout,
818                    current_underline,
819                    current_bold,
820                    current_italic,
821                )
822            } else {
823                (own_strikeout, own_underline, own_bold, own_italic)
824            };
825            // A run's own decoration is emitted around the run itself and
826            // closed right after it, so it never leaks onto the next run.
827            let own_script_tag = if !is_script {
828                None
829            } else if own_strikeout && !current_strikeout {
830                Some("s")
831            } else if own_underline && !current_underline && !current_strikeout {
832                Some("u")
833            } else {
834                None
835            };
836
837            // Close previous styles if they change
838            if current_italic && !item_italic {
839                result.push('*');
840                current_italic = false;
841            }
842            if current_bold && !item_bold {
843                result.push_str("**");
844                current_bold = false;
845            }
846            if current_underline && !item_underline {
847                result.push_str("</u>");
848                current_underline = false;
849            }
850            if current_strikeout && !item_strikeout {
851                result.push_str("</s>");
852                current_strikeout = false;
853            }
854
855            // Add space: either from spacing logic or preserved from item text
856            if emit_space {
857                result.push(' ');
858            }
859
860            // Open new styles
861            if item_underline && !current_underline {
862                result.push_str("<u>");
863                current_underline = true;
864            }
865            if item_strikeout && !current_strikeout {
866                result.push_str("<s>");
867                current_strikeout = true;
868            }
869            if item_bold && !current_bold {
870                result.push_str("**");
871                current_bold = true;
872            }
873            if item_italic && !current_italic {
874                result.push('*');
875                current_italic = true;
876            }
877
878            if i > 0 && stacked_fraction_slash(&self.items[i - 1], item) {
879                result.push('/');
880            }
881            match own_script_tag {
882                Some(tag) => {
883                    result.push('<');
884                    result.push_str(tag);
885                    result.push('>');
886                    push_item_text(&mut result, item, text_trimmed);
887                    result.push_str("</");
888                    result.push_str(tag);
889                    result.push('>');
890                }
891                None => push_item_text(&mut result, item, text_trimmed),
892            }
893        }
894
895        // Close any remaining open styles
896        if current_italic {
897            result.push('*');
898        }
899        if current_bold {
900            result.push_str("**");
901        }
902        if current_underline {
903            result.push_str("</u>");
904        }
905        if current_strikeout {
906            result.push_str("</s>");
907        }
908
909        result
910    }
911
912    /// Get plain text without formatting.
913    ///
914    /// A super/subscript run (an item with a non-zero `baseline_shift`;
915    /// extraction materializes each run as one item) is wrapped in
916    /// `<sup>…</sup>` / `<sub>…</sub>`: without the tags the marker digits
917    /// would be indistinguishable from the body text they follow
918    /// ("Yibo Yan1,2,3" vs "Yibo Yan<sup>1,2,3</sup>").
919    fn text_plain(&self) -> String {
920        let single_char_threshold = self.adaptive_threshold;
921
922        let mut result = String::new();
923        for (i, item) in self.items.iter().enumerate() {
924            if i > 0
925                && self.needs_space_between(
926                    &self.items[i - 1],
927                    item,
928                    &result,
929                    single_char_threshold,
930                )
931            {
932                result.push(' ');
933            }
934            if i > 0 && stacked_fraction_slash(&self.items[i - 1], item) {
935                result.push('/');
936            }
937            push_item_text(&mut result, item, item.text.as_str());
938        }
939        result
940    }
941
942    /// Determine if a space is needed between two items
943    fn needs_space_between(
944        &self,
945        prev_item: &TextItem,
946        item: &TextItem,
947        result: &str,
948        single_char_threshold: f32,
949    ) -> bool {
950        let text = item.text.as_str();
951
952        // Don't add space before/after hyphens for hyphenated words
953        let prev_ends_with_hyphen = result.ends_with('-');
954        let curr_is_hyphen = text.trim() == "-";
955        let curr_starts_with_hyphen = text.starts_with('-');
956
957        // Check if space already exists
958        let prev_ends_with_space = result.ends_with(' ');
959        let curr_starts_with_space = text.starts_with(' ');
960        let space_already_exists = prev_ends_with_space || curr_starts_with_space;
961
962        // Script runs flagged by extraction share one edge-spacing policy
963        // with table cells (see `script_edge_needs_space`). The blanket
964        // suppression below is for unflagged size changes only.
965        if let Some(needs_space) = script_edge_needs_space(prev_item, item, result, text) {
966            return needs_space;
967        }
968
969        // Detect subscript/superscript: smaller font size and/or Y offset
970        let font_ratio = item.font_size / prev_item.font_size;
971        let reverse_font_ratio = prev_item.font_size / item.font_size;
972        let y_diff = (item.y - prev_item.y).abs();
973
974        let is_sub_super = font_ratio < 0.85 && y_diff > 1.0;
975        let was_sub_super = reverse_font_ratio < 0.85 && y_diff > 1.0;
976
977        // Use position-based spacing detection
978        let should_join = should_join_items(prev_item, item, single_char_threshold);
979
980        // Add space unless one of these conditions applies
981        !(prev_ends_with_hyphen
982            || curr_is_hyphen
983            || curr_starts_with_hyphen
984            || is_sub_super
985            || was_sub_super
986            || should_join
987            || space_already_exists)
988    }
989}
990
991#[cfg(test)]
992mod formatting_tests {
993    use super::{ItemType, TextItem, TextLine};
994
995    fn item(text: &str, x: f32, width: f32, strikeout: bool) -> TextItem {
996        TextItem {
997            text: text.to_string(),
998            x,
999            y: 100.0,
1000            width,
1001            height: 12.0,
1002            font: "F1".to_string(),
1003            font_tag: String::new(),
1004            legacy_symbol_rewrite: false,
1005            font_size: 12.0,
1006            page: 1,
1007            is_bold: false,
1008            is_italic: false,
1009            font_weight: None,
1010            bold_source: None,
1011            fixed_pitch: None,
1012            fill_color: None,
1013            stroke_color: None,
1014            render_mode: None,
1015            is_underline: false,
1016            is_strikeout: strikeout,
1017            rotation: 0.0,
1018            advance_known: true,
1019            item_type: ItemType::Text,
1020            mcid: None,
1021            baseline_shift: 0.0,
1022        }
1023    }
1024
1025    fn line(items: Vec<TextItem>) -> TextLine {
1026        TextLine {
1027            items,
1028            y: 100.0,
1029            page: 1,
1030            adaptive_threshold: 0.1,
1031        }
1032    }
1033
1034    /// A body-text item at 12pt on the shared baseline.
1035    fn body(text: &str, x: f32, width: f32) -> TextItem {
1036        item(text, x, width, false)
1037    }
1038
1039    /// A script run at 8pt, `shift` points off the 12pt body baseline
1040    /// (positive = raised), as `merge_subscript_items` materializes it: one
1041    /// item per run.
1042    fn script(text: &str, x: f32, width: f32, shift: f32) -> TextItem {
1043        let mut it = item(text, x, width, false);
1044        it.font_size = 8.0;
1045        it.height = 8.0;
1046        it.y += shift;
1047        it.baseline_shift = shift;
1048        it
1049    }
1050
1051    #[test]
1052    fn script_run_is_wrapped_in_one_sup_span() {
1053        // Author block: "Yibo Yan" + the raised "1,2" run + body ", " +
1054        // "Jiahao Huo". The marker run is one <sup> span attached to the
1055        // name, and the body comma follows without a space.
1056        let line = line(vec![
1057            body("Yibo Yan", 10.0, 48.0),
1058            script("1,2", 58.0, 10.0, 4.3),
1059            body(",", 68.0, 3.0),
1060            body("Jiahao Huo", 74.5, 60.0),
1061        ]);
1062        assert_eq!(line.text(), "Yibo Yan<sup>1,2</sup>, Jiahao Huo");
1063        assert_eq!(
1064            line.text_with_formatting(true, true, true),
1065            "Yibo Yan<sup>1,2</sup>, Jiahao Huo"
1066        );
1067    }
1068
1069    #[test]
1070    fn word_space_after_script_run_follows_geometry() {
1071        let line = line(vec![
1072            body("word", 10.0, 24.0),
1073            script("2", 34.0, 4.0, 4.0),
1074            body("next", 41.5, 24.0),
1075        ]);
1076        assert_eq!(line.text(), "word<sup>2</sup> next");
1077
1078        // Tight junction after the marker: no space ("x<sup>2</sup>y").
1079        let line = super::TextLine {
1080            items: vec![
1081                body("x", 10.0, 6.0),
1082                script("2", 16.2, 4.0, 4.0),
1083                body("y", 20.4, 6.0),
1084            ],
1085            y: 100.0,
1086            page: 1,
1087            adaptive_threshold: 0.1,
1088        };
1089        assert_eq!(line.text(), "x<sup>2</sup>y");
1090    }
1091
1092    #[test]
1093    fn leading_script_run_attaches_to_following_word() {
1094        // Affiliation line: markers lead their institution, and a word space
1095        // before the run (after the previous institution's comma) survives.
1096        let line = line(vec![
1097            body("University,", 10.0, 60.0),
1098            script("1,2", 73.4, 10.2, 3.5),
1099            body("Hong Kong", 83.6, 54.0),
1100        ]);
1101        assert_eq!(line.text(), "University, <sup>1,2</sup>Hong Kong");
1102    }
1103
1104    #[test]
1105    fn lowered_run_uses_sub_tag() {
1106        let line = line(vec![body("x", 10.0, 6.0), script("max", 16.0, 12.0, -2.4)]);
1107        assert_eq!(line.text(), "x<sub>max</sub>");
1108    }
1109
1110    #[test]
1111    fn script_span_does_not_split_a_bold_run() {
1112        let mut name = body("Yibo Yan", 10.0, 48.0);
1113        name.is_bold = true;
1114        let mut rest = body(", Jiahao Huo", 62.0, 66.0);
1115        rest.is_bold = true;
1116        let line = line(vec![name, script("1", 58.0, 4.0, 4.3), rest]);
1117        assert_eq!(
1118            line.text_with_formatting(true, false, false),
1119            "**Yibo Yan<sup>1</sup>, Jiahao Huo**"
1120        );
1121        assert_eq!(line.text(), "Yibo Yan<sup>1</sup>, Jiahao Huo");
1122    }
1123
1124    #[test]
1125    fn line_y_of_an_upside_down_run_is_its_baseline() {
1126        // A 180° run hangs from its box top: that is the baseline its line
1127        // groups by, a script offset still applies below it, and an upright
1128        // run keeps `y`.
1129        let mut run = item("x", 100.0, 20.0, false);
1130        run.y = 500.0;
1131        run.height = 10.0;
1132        run.rotation = 180.0;
1133        assert_eq!(run.baseline_y(), 510.0);
1134        assert_eq!(run.line_y(), 510.0);
1135        run.baseline_shift = 2.0;
1136        assert_eq!(run.line_y(), 508.0);
1137        run.rotation = 0.0;
1138        assert_eq!(run.baseline_y(), 500.0);
1139        assert_eq!(run.line_y(), 498.0);
1140    }
1141
1142    #[test]
1143    fn separated_script_items_get_separate_spans() {
1144        // Two runs with a real gap between them (nothing else on the line)
1145        // are two spans with a space, never "<sup>1 2</sup>".
1146        let line = line(vec![
1147            script("1", 10.0, 4.0, 4.0),
1148            script("2", 40.0, 4.0, 4.0),
1149        ]);
1150        assert_eq!(line.text(), "<sup>1</sup> <sup>2</sup>");
1151    }
1152
1153    #[test]
1154    fn touching_runs_of_different_size_are_separate_spans_without_space() {
1155        // Nested script: "n" (6pt) attached to the superscript "2" (8pt).
1156        let mut nested = script("n", 20.2, 3.0, 6.0);
1157        nested.font_size = 6.0;
1158        let line = line(vec![
1159            body("x", 10.0, 6.0),
1160            script("2", 16.2, 4.0, 4.0),
1161            nested,
1162        ]);
1163        assert_eq!(line.text(), "x<sup>2</sup><sup>n</sup>");
1164    }
1165
1166    #[test]
1167    fn stacked_digit_fraction_renders_with_a_slash() {
1168        // "3 1/3 bits" set as a case fraction: numerator raised, denominator
1169        // lowered, both at the same x. Never "3 <sup>13</sup>".
1170        let mut num = script("1", 52.5, 3.7, 3.96);
1171        num.font_size = 7.4;
1172        let mut den = script("3", 52.5, 3.7, -4.0);
1173        den.font_size = 7.4;
1174        let line = line(vec![
1175            body("about 3", 10.0, 40.8),
1176            num,
1177            den,
1178            body("bits", 58.0, 20.0),
1179        ]);
1180        assert_eq!(line.text(), "about 3 <sup>1</sup>/<sub>3</sub> bits");
1181    }
1182
1183    #[test]
1184    fn decorated_script_run_keeps_its_own_underline() {
1185        // An underlined (hyperlinked) footnote marker in plain text.
1186        let mut marker = script("1", 58.0, 4.0, 4.3);
1187        marker.is_underline = true;
1188        let line = line(vec![body("word", 10.0, 48.0), marker]);
1189        assert_eq!(
1190            line.text_with_formatting(false, false, true),
1191            "word<u><sup>1</sup></u>"
1192        );
1193    }
1194
1195    #[test]
1196    fn own_decoration_does_not_leak_onto_the_next_run() {
1197        let mut first = script("1", 58.0, 4.0, 4.3);
1198        first.is_underline = true;
1199        let second = script("2", 80.0, 4.0, 4.3);
1200        let line = line(vec![body("word", 10.0, 48.0), first, second]);
1201        assert_eq!(
1202            line.text_with_formatting(false, false, true),
1203            "word<u><sup>1</sup></u> <sup>2</sup>"
1204        );
1205    }
1206
1207    #[test]
1208    fn own_decoration_nests_inside_an_open_body_decoration() {
1209        // Underlined body text with a struck footnote marker: the strike
1210        // nests inside the underline instead of being dropped.
1211        let mut word = body("word", 10.0, 48.0);
1212        word.is_underline = true;
1213        let mut marker = script("1", 58.0, 4.0, 4.3);
1214        marker.is_strikeout = true;
1215        let line = line(vec![word, marker]);
1216        assert_eq!(
1217            line.text_with_formatting(false, false, true),
1218            "<u>word<s><sup>1</sup></s></u>"
1219        );
1220    }
1221
1222    #[test]
1223    fn fraction_slash_needs_one_visual_line() {
1224        // Opposite-sign digit runs on different lines are not a fraction.
1225        let mut num = script("1", 52.5, 3.7, 3.96);
1226        num.font_size = 7.4;
1227        let mut den = script("3", 52.5, 3.7, -4.0);
1228        den.font_size = 7.4;
1229        den.y -= 12.0; // anchored to the next line's body
1230        assert!(!super::stacked_fraction_slash(&num, &den));
1231    }
1232
1233    #[test]
1234    fn line_y_snaps_scripts_to_the_anchor_baseline() {
1235        let raised = script("1", 58.0, 4.0, 4.3);
1236        assert!((raised.y - 104.3).abs() < 1e-4);
1237        assert!((raised.line_y() - 100.0).abs() < 1e-4);
1238        assert!(raised.is_script());
1239        assert!(!body("Yibo Yan", 10.0, 48.0).is_script());
1240    }
1241
1242    #[test]
1243    fn formatting_emits_semantic_strikeout() {
1244        let line = line(vec![item("deleted", 10.0, 42.0, true)]);
1245
1246        assert_eq!(
1247            line.text_with_formatting(true, true, true),
1248            "<s>deleted</s>"
1249        );
1250    }
1251
1252    #[test]
1253    fn formatting_closes_strikeout_before_live_text() {
1254        let line = line(vec![
1255            item("keep", 10.0, 24.0, false),
1256            item("remove", 40.0, 42.0, true),
1257            item("keep", 88.0, 24.0, false),
1258        ]);
1259
1260        assert_eq!(
1261            line.text_with_formatting(true, true, true),
1262            "keep <s>remove</s> keep"
1263        );
1264    }
1265
1266    #[test]
1267    fn formatting_coalesces_adjacent_struck_items() {
1268        let line = line(vec![
1269            item("deleted", 10.0, 42.0, true),
1270            item("words", 58.0, 30.0, true),
1271        ]);
1272
1273        assert_eq!(
1274            line.text_with_formatting(true, true, true),
1275            "<s>deleted words</s>"
1276        );
1277    }
1278
1279    #[test]
1280    fn strikeout_takes_precedence_over_other_styles() {
1281        let mut decorated = item("deleted", 10.0, 42.0, true);
1282        decorated.is_bold = true;
1283        decorated.is_italic = true;
1284        decorated.is_underline = true;
1285        let line = line(vec![decorated]);
1286
1287        assert_eq!(
1288            line.text_with_formatting(true, true, true),
1289            "<s>deleted</s>"
1290        );
1291        assert_eq!(line.text(), "deleted");
1292    }
1293
1294    #[test]
1295    fn is_horizontal_follows_the_baseline_quadrant() {
1296        let cases = [
1297            (0.0, true),
1298            (180.0, true),
1299            (90.0, false),
1300            (270.0, false),
1301            (44.0, true),
1302            (46.0, false),
1303            (134.0, false),
1304            (136.0, true),
1305            (359.5, true),
1306            (-90.0, false),
1307            (450.0, false),
1308        ];
1309        for (rotation, horizontal) in cases {
1310            let mut probe = item("x", 0.0, 10.0, false);
1311            probe.rotation = rotation;
1312            assert_eq!(
1313                probe.is_horizontal(),
1314                horizontal,
1315                "rotation {rotation} should be horizontal={horizontal}"
1316            );
1317        }
1318    }
1319
1320    #[test]
1321    fn cross_extent_is_the_em_box_whatever_the_orientation() {
1322        let mut probe = item("x", 0.0, 10.0, false);
1323        probe.height = 12.0;
1324        assert_eq!(probe.cross_extent(), 12.0);
1325        probe.rotation = 90.0;
1326        probe.width = 12.0;
1327        probe.height = 200.0;
1328        assert_eq!(probe.cross_extent(), 12.0);
1329        // A long diagonal run: both box extents carry the advance, the em
1330        // is the font size.
1331        probe.rotation = 30.0;
1332        probe.width = 180.0;
1333        probe.height = 110.0;
1334        assert_eq!(probe.cross_extent(), 12.0);
1335    }
1336
1337    #[test]
1338    fn upright_and_upside_down_split_the_horizontal_half_plane() {
1339        let mut probe = item("x", 0.0, 10.0, false);
1340        for (rotation, upright, upside_down) in [
1341            (0.0, true, false),
1342            (44.0, true, false),
1343            (316.0, true, false),
1344            (180.0, false, true),
1345            (136.0, false, true),
1346            (224.0, false, true),
1347            (90.0, false, false),
1348            (270.0, false, false),
1349        ] {
1350            probe.rotation = rotation;
1351            assert_eq!(probe.is_upright(), upright, "upright at {rotation}");
1352            assert_eq!(
1353                probe.is_upside_down(),
1354                upside_down,
1355                "upside_down at {rotation}"
1356            );
1357        }
1358    }
1359}