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}