Skip to main content

pdf_engine/
words.rs

1//! Word-level boxes for citations, highlighting and grounding.
2//!
3//! [`PdfDocument::extract_words`] is the `extract_words=True` of pymupdf4llm:
4//! every word of the document with the box it was drawn in. It is built from
5//! the blocks [`PdfDocument::extract_blocks`] returns -- same options, same
6//! window -- splitting each span's text on whitespace and boxing each word
7//! from the glyphs that drew it. One function, no second text pass.
8//!
9//! A word's box is the union of its glyphs' boxes turned into the page frame:
10//! an upright word's boxes pass through untouched, a turned word's become the
11//! axis-aligned envelope of its turned glyphs. Boxes are one per glyph while
12//! characters count the spaces the text pass inserted too, so each span first
13//! aligns its two: a space sitting in a gap between glyphs owns no box, a
14//! drawn one owns its glyph's, and a glyph one character stands for keeps its
15//! whole box however many letters it decodes to. A word the page split across
16//! spans joins by the rule text joins by
17//! ([`crate::words_join::pieces_join`]), so joining a line's words with single
18//! spaces is the line's own text.
19//!
20//! [`PdfDocument::extract_words`]: crate::PdfDocument::extract_words
21//! [`PdfDocument::extract_blocks`]: crate::PdfDocument::extract_blocks
22
23// Copyright (c) 2026 Innovation Trigger B.V.
24//
25// PDFluent is available under two licences, at your option: the GNU AGPLv3, or
26// the PDFluent Commercial Licence. See the LICENSE file in this repository --
27// that file travels with the copy you received, which a URL does not.
28
29use crate::blocks::{Block, ExtractOptions};
30use crate::text::TextSpan;
31
32/// One word of a document's text, with the box it was drawn in.
33///
34/// Built from the blocks [`PdfDocument::extract_blocks`] returns, so a word
35/// carries where its block sits as well as where it sits itself. Words come
36/// in reading order, block by block and line by line.
37///
38/// [`PdfDocument::extract_blocks`]: crate::PdfDocument::extract_blocks
39#[derive(Debug, Clone, PartialEq)]
40pub struct Word {
41    /// 0-based page index the word was drawn on.
42    pub page: usize,
43    /// The word, without surrounding whitespace.
44    pub text: String,
45    /// Bounding box in PDF user space, `[x0, y0, x1, y1]` with y up and
46    /// `y0 < y1`: the union of the word's glyph boxes, in the frame block
47    /// boxes use.
48    pub bbox: [f64; 4],
49    /// Position of the word's block in the `Vec` `extract_blocks` returned
50    /// beside these words -- the window's numbering, not the document's.
51    ///
52    /// [`Block::index`] keeps the document's own numbering when a window is
53    /// asked for; this is the index into the answer the caller is holding,
54    /// so `blocks[word.block]` is the block the word came from.
55    pub block: usize,
56    /// 0-based line within the block, in the order the block's lines read.
57    pub line: usize,
58    /// 0-based word within the line, in the order the line reads.
59    pub word: usize,
60    /// Baseline angle in degrees, counter-clockwise from +x; 0 upright.
61    ///
62    /// A word joined across spans takes the angle of its longer piece, the
63    /// left one on a tie -- the same longest-piece rule as the style fields.
64    pub dir: f64,
65    /// Whether the word's span is set bold.
66    pub bold: bool,
67    /// Whether the word's span is set italic.
68    pub italic: bool,
69    /// Font size of the word's span, in points.
70    pub font_size: f64,
71}
72
73fn union(a: [f64; 4], b: [f64; 4]) -> [f64; 4] {
74    [
75        a[0].min(b[0]),
76        a[1].min(b[1]),
77        a[2].max(b[2]),
78        a[3].max(b[3]),
79    ]
80}
81
82/// The box a span claims, from its origin and extent.
83///
84/// Glyph spans measure both; a span that was placed by a rectangle rather
85/// than drawn carries the placement in the same fields. Where either extent
86/// is missing the wire contract's fallback answers instead -- half an em per
87/// character across, the type size up -- so the box is never degenerate.
88fn span_box(span: &TextSpan, chars: usize) -> [f64; 4] {
89    let width = if span.width > 0.0 {
90        span.width
91    } else {
92        span.font_size.max(1.0) * 0.5 * chars as f64
93    };
94    let height = if span.height > 0.0 {
95        span.height
96    } else {
97        span.font_size.max(1.0)
98    };
99    [span.x, span.y, span.x + width, span.y + height]
100}
101
102/// A span's baseline angle in degrees counter-clockwise from +x, with the unit
103/// direction its baseline runs in.
104///
105/// The first column of the span's composed transform, normalised: `(1, 0)`
106/// for upright text, `(0, 1)` for a 90-degree run. A span not built from a
107/// glyph carries no transform and reads as upright, as does a degenerate one
108/// with no direction to speak of.
109fn span_angle(span: &TextSpan) -> (f64, (f64, f64)) {
110    let Some(coeffs) = span.transform.as_ref() else {
111        return (0.0, (1.0, 0.0));
112    };
113    let (a, b) = (coeffs[0], coeffs[1]);
114    let length = (a * a + b * b).sqrt();
115    if length <= f64::EPSILON {
116        return (0.0, (1.0, 0.0));
117    }
118    let dir = b.atan2(a).to_degrees();
119    (if dir == 0.0 { 0.0 } else { dir }, (a / length, b / length))
120}
121
122/// The axis-aligned envelope of one glyph box turned into the page frame.
123///
124/// A `char_bound` is an advance and a size hung off a page-space origin, not
125/// the rectangle a turned glyph covers, so each corner is turned about the
126/// origin by the span's baseline direction and the envelope is taken. Upright
127/// text passes through untouched -- to the bit, by the early return rather
128/// than by arithmetic that rounds to the same.
129fn turned_box(bound: [f64; 4], direction: (f64, f64)) -> [f64; 4] {
130    let (dx, dy) = direction;
131    if dx == 1.0 && dy == 0.0 {
132        return bound;
133    }
134    let (x0, y0) = (bound[0], bound[1]);
135    let (advance, size) = (bound[2] - bound[0], bound[3] - bound[1]);
136    let mut envelope: Option<[f64; 4]> = None;
137    for (u, v) in [(0.0, 0.0), (advance, 0.0), (advance, size), (0.0, size)] {
138        let corner = [x0 + u * dx - v * dy, y0 + u * dy + v * dx];
139        envelope = Some(match envelope {
140            Some(b) => [
141                b[0].min(corner[0]),
142                b[1].min(corner[1]),
143                b[2].max(corner[0]),
144                b[3].max(corner[1]),
145            ],
146            None => [corner[0], corner[1], corner[0], corner[1]],
147        });
148    }
149    envelope.unwrap_or(bound)
150}
151
152/// How far along the span's baseline a point sits: page `x` for upright text,
153/// the distance up the page for a 90-degree run.
154fn along(x: f64, y: f64, direction: (f64, f64)) -> f64 {
155    x * direction.0 + y * direction.1
156}
157
158/// The white between two glyphs along the baseline they share.
159///
160/// Each bound carries its glyph's advance (`bound[2] - bound[0]`), so where
161/// the left glyph stops is known whatever angle it was drawn at. Negative
162/// where the glyphs overlap.
163fn gap_between(left: [f64; 4], right: [f64; 4], direction: (f64, f64)) -> f64 {
164    along(right[0], right[1], direction)
165        - (along(left[0], left[1], direction) + (left[2] - left[0]))
166}
167
168/// The word a whitespace-owned glyph joins, if any.
169///
170/// The proportional mapping leaves a glyph to whitespace both where the page
171/// drew a space and where the text pass inserted one, and only the geometry
172/// tells them apart: an inserted space sits in a gap between glyphs, a drawn
173/// one sits on its own glyph with ink on both sides. So with more characters
174/// than glyphs, a glyph whose owning space stands alone joins the word on its
175/// left where the gap on its right breaks the line and the one on its left
176/// does not -- the shared join rule, read both ways. Anywhere else the glyph
177/// stays out: a drawn space must never widen the word beside it.
178fn skipped_glyph_word(
179    chars: &[char],
180    word_of_char: &[Option<usize>],
181    glyphs: &[[f64; 4]],
182    index: usize,
183    owner: usize,
184    direction: (f64, f64),
185    em: f64,
186) -> Option<usize> {
187    // NaN reaches no further: every comparison below is false for it, so the
188    // glyph stays out -- the same answer as for a degenerate size.
189    if em <= 0.0 || em.is_nan() || chars.len() <= glyphs.len() {
190        return None;
191    }
192    if owner > 0 && chars[owner - 1].is_whitespace() {
193        return None;
194    }
195    if owner + 1 < chars.len() && chars[owner + 1].is_whitespace() {
196        return None;
197    }
198    if index == 0 || index + 1 >= glyphs.len() {
199        return None;
200    }
201    let joins_left = crate::words_join::pieces_join(
202        gap_between(glyphs[index - 1], glyphs[index], direction),
203        0.0,
204        em,
205    );
206    let breaks_right = !crate::words_join::pieces_join(
207        gap_between(glyphs[index], glyphs[index + 1], direction),
208        0.0,
209        em,
210    );
211    if joins_left && breaks_right {
212        (0..owner).rev().find_map(|k| word_of_char[k])
213    } else {
214        None
215    }
216}
217
218/// The words of one span: each run of non-whitespace with its box.
219///
220/// Each glyph lands in exactly one word -- or in none, when it only carries
221/// whitespace -- and a word's box is the union of its glyphs' boxes turned
222/// into the page frame. A word the glyphs left out takes its proportional
223/// slice of the span's own box, by character count, spaces included: the
224/// slices tile the span left to right, so they lie inside it and never
225/// overlap.
226///
227/// The alignment is positional. Boxes are one per glyph while characters
228/// count the spaces the text pass inserted too, so character `c` of `n` maps
229/// to glyph `c * g / n`, and a glyph one character stands for is that word's
230/// however many letters it decodes to -- a ligature's letters keep its whole
231/// box, and so does an `/ActualText` replacement. A glyph the mapping leaves
232/// to whitespace is still a letter's where the page says so: with more
233/// characters than glyphs, a lone space beside a gap between glyphs is the
234/// inserted one, and the glyph on its other side belongs to the word on its
235/// left ([`skipped_glyph_word`]).
236fn words_of_span(span: &TextSpan) -> Vec<(String, [f64; 4])> {
237    let chars: Vec<char> = span.text.chars().collect();
238    let n = chars.len();
239    if n == 0 {
240        return Vec::new();
241    }
242    let glyphs = &span.char_bounds;
243    let g = glyphs.len();
244    let box_of_span = span_box(span, n);
245
246    // Which word each character belongs to, whitespace being in none.
247    let mut word_of_char: Vec<Option<usize>> = Vec::with_capacity(n);
248    let mut texts: Vec<String> = Vec::new();
249    let mut ranges: Vec<(usize, usize)> = Vec::new();
250    let mut in_word = false;
251    for (i, ch) in chars.iter().enumerate() {
252        if ch.is_whitespace() {
253            in_word = false;
254            word_of_char.push(None);
255        } else {
256            if !in_word {
257                texts.push(String::new());
258                ranges.push((i, i));
259                in_word = true;
260            }
261            let w = texts.len() - 1;
262            texts[w].push(*ch);
263            ranges[w].1 = i + 1;
264            word_of_char.push(Some(w));
265        }
266    }
267    if texts.is_empty() {
268        return Vec::new();
269    }
270
271    let (_, direction) = span_angle(span);
272    let mut boxes: Vec<Option<[f64; 4]>> = vec![None; texts.len()];
273    // The character a glyph comes from: the first character mapping to it, or
274    // the nearest one before it where the mapping skips it, which is what a
275    // span with more glyphs than characters does.
276    for (index, glyph_box) in glyphs.iter().enumerate() {
277        let owner = (0..n)
278            .find(|&c| c * g / n == index)
279            .or_else(|| (0..n).rev().find(|&c| c * g / n <= index));
280        let Some(c) = owner else { continue };
281        // A glyph shared by a whitespace character and a word character
282        // belongs to the word: look forward inside the glyph's characters
283        // for one.
284        let word = (c..n)
285            .take_while(|&k| k * g / n == index || k == c)
286            .find_map(|k| word_of_char[k])
287            .or_else(|| {
288                skipped_glyph_word(
289                    &chars,
290                    &word_of_char,
291                    glyphs,
292                    index,
293                    c,
294                    direction,
295                    span.font_size,
296                )
297            });
298        if let Some(w) = word {
299            let envelope = turned_box(*glyph_box, direction);
300            boxes[w] = Some(match boxes[w] {
301                Some(b) => union(b, envelope),
302                None => envelope,
303            });
304        }
305    }
306
307    let total = n as f64;
308    texts
309        .into_iter()
310        .zip(ranges)
311        .zip(boxes)
312        .map(|((text, (start, end)), bbox)| {
313            let bbox = bbox.unwrap_or_else(|| {
314                let left =
315                    box_of_span[0] + (box_of_span[2] - box_of_span[0]) * start as f64 / total;
316                let right = if end == n {
317                    box_of_span[2]
318                } else {
319                    box_of_span[0] + (box_of_span[2] - box_of_span[0]) * end as f64 / total
320                };
321                [left, box_of_span[1], right, box_of_span[3]]
322            });
323            (text, bbox)
324        })
325        .collect()
326}
327
328/// Whether `text` opens with whitespace.
329fn starts_with_whitespace(text: &str) -> bool {
330    text.chars().next().is_some_and(|c| c.is_whitespace())
331}
332
333/// Whether `text` closes with whitespace.
334fn ends_with_whitespace(text: &str) -> bool {
335    text.chars().last().is_some_and(|c| c.is_whitespace())
336}
337
338/// The rightmost piece so far: what the next span's first piece may join.
339struct Tail {
340    /// Position in the line of the span that drew the last piece.
341    span_pos: usize,
342    /// Baseline of that span.
343    y: f64,
344    /// Font size of that span.
345    font_size: f64,
346    /// Characters of the piece that lent the last word its style and angle.
347    style_len: usize,
348}
349
350/// Every word of one line, joining a word the page split across spans.
351///
352/// Each span is cut into whitespace-separated pieces first; then the last
353/// piece of a span and the first piece of the next join when the spans
354/// themselves carry no space between them and the shared rule
355/// ([`crate::words_join::pieces_join`]) joins them. The joined word's text is
356/// the concatenation and its box the union; `bold`, `italic`, `font_size`
357/// and `dir` come from the piece with more characters, the left one on a tie.
358fn words_of_line(
359    block: &Block,
360    block_index: usize,
361    line_index: usize,
362    line: &[usize],
363) -> Vec<Word> {
364    let mut out: Vec<Word> = Vec::new();
365    let mut tail: Option<Tail> = None;
366    let mut prev_ends_with_space = false;
367    for (pos, span_index) in line.iter().enumerate() {
368        let span = &block.spans[*span_index];
369        let (span_dir, _) = span_angle(span);
370        let starts_with_space = starts_with_whitespace(&span.text);
371        for (piece_index, (text, bbox)) in words_of_span(span).iter().enumerate() {
372            let join = piece_index == 0
373                && tail.as_ref().is_some_and(|t| {
374                    let Some(last) = out.last() else {
375                        return false;
376                    };
377                    if t.span_pos + 1 != pos || prev_ends_with_space || starts_with_space {
378                        return false;
379                    }
380                    let em = t.font_size.max(span.font_size);
381                    let gap = bbox[0] - last.bbox[2];
382                    crate::words_join::pieces_join(gap, (t.y - span.y).abs(), em)
383                });
384            let len = text.chars().count();
385            if join {
386                let last = out.last_mut().expect("join implies a word");
387                last.text.push_str(text);
388                last.bbox = union(last.bbox, *bbox);
389                if let Some(t) = tail.as_mut() {
390                    if len > t.style_len {
391                        last.bold = span.is_bold;
392                        last.italic = span.is_italic;
393                        last.font_size = span.font_size;
394                        last.dir = span_dir;
395                        t.style_len = len;
396                    }
397                    t.span_pos = pos;
398                    t.y = span.y;
399                    t.font_size = span.font_size;
400                }
401            } else {
402                out.push(Word {
403                    page: block.page,
404                    text: text.clone(),
405                    bbox: *bbox,
406                    block: block_index,
407                    line: line_index,
408                    word: 0,
409                    dir: span_dir,
410                    bold: span.is_bold,
411                    italic: span.is_italic,
412                    font_size: span.font_size,
413                });
414                tail = Some(Tail {
415                    span_pos: pos,
416                    y: span.y,
417                    font_size: span.font_size,
418                    style_len: len,
419                });
420            }
421        }
422        prev_ends_with_space = ends_with_whitespace(&span.text);
423    }
424    for (index, word) in out.iter_mut().enumerate() {
425        word.word = index;
426    }
427    out
428}
429
430/// Every word of `blocks`, block by block and line by line.
431///
432/// `block` on each word is the position in `blocks` -- the window's
433/// numbering -- and `line` the line within its block. The lines are the same
434/// grouping the block's own readers use, top first, each line left to right.
435pub(crate) fn words_from_blocks(blocks: &[Block]) -> Vec<Word> {
436    let mut out = Vec::new();
437    for (block_index, block) in blocks.iter().enumerate() {
438        for (line_index, line) in crate::text::lines_of_block(block).iter().enumerate() {
439            out.extend(words_of_line(block, block_index, line_index, line));
440        }
441    }
442    out
443}
444
445impl ExtractOptions {
446    /// Carry the word boxes beside the blocks. Default off: [`crate::to_json`]
447    /// writes exactly what it wrote before this option existed.
448    #[must_use]
449    pub fn with_word_boxes(mut self, word_boxes: bool) -> Self {
450        self.word_boxes = word_boxes;
451        self
452    }
453}
454
455/// The blocks as JSON, each block carrying the lines it groups.
456///
457/// Beside every field [`crate::to_json`] writes, each block carries
458/// `"lines": [{"spans": [...], "words": [{"text", "bbox", "word", "dir"}]}]`.
459/// Strip the added key and the document is [`crate::to_json`]'s, byte for byte.
460pub fn to_json_with_words(blocks: &[Block]) -> String {
461    use crate::json::INDENT;
462    if blocks.is_empty() {
463        return format!("{{\n{INDENT}\"blocks\": []\n}}\n");
464    }
465    let pad = INDENT.repeat(2);
466    let inner = INDENT.repeat(3);
467    let written: Vec<String> = blocks
468        .iter()
469        .enumerate()
470        .map(|(index, block)| {
471            let plain = crate::json::block(block, 2);
472            let suffix = format!("\n{pad}}}");
473            let stem = plain.strip_suffix(&suffix).expect(
474                "to_json_with_words: the block object does not close as json::block writes it",
475            );
476            format!(
477                "{stem},\n{inner}\"lines\": {}{suffix}",
478                lines_json(block, index)
479            )
480        })
481        .collect();
482    format!(
483        "{{\n{INDENT}\"blocks\": [\n{}\n{INDENT}]\n}}\n",
484        written.join(",\n")
485    )
486}
487
488/// One block's lines with their words, as the `"lines"` value of
489/// [`to_json_with_words`]: `[{"spans": [...], "words": [...]}]`.
490///
491/// `spans` names the block spans the line groups, by position; each word
492/// carries `text`, `bbox`, `word` and `dir`. One line object per line, like
493/// the one-line span records of [`crate::to_json`].
494fn lines_json(block: &Block, block_index: usize) -> String {
495    use crate::json::{number, string, INDENT};
496    let inner = INDENT.repeat(3);
497    let pad = INDENT.repeat(4);
498    let mut lines = Vec::new();
499    for (line_index, line) in crate::text::lines_of_block(block).iter().enumerate() {
500        let words = words_of_line(block, block_index, line_index, line);
501        let spans = line
502            .iter()
503            .map(|i| i.to_string())
504            .collect::<Vec<_>>()
505            .join(", ");
506        let mut entries = Vec::with_capacity(words.len());
507        for word in &words {
508            let bbox = word
509                .bbox
510                .iter()
511                .map(|v| number(*v))
512                .collect::<Vec<_>>()
513                .join(", ");
514            entries.push(format!(
515                "{{\"text\": {}, \"bbox\": [{bbox}], \"word\": {}, \"dir\": {}}}",
516                string(&word.text),
517                word.word,
518                number(word.dir)
519            ));
520        }
521        lines.push(format!(
522            "{pad}{{\"spans\": [{spans}], \"words\": [{}]}}",
523            entries.join(", ")
524        ));
525    }
526    if lines.is_empty() {
527        "[]".to_string()
528    } else {
529        format!("[\n{}\n{inner}]", lines.join(",\n"))
530    }
531}
532
533/// The words as JSON lines, one object per word.
534///
535/// Each line is `{"page", "block", "line", "word", "text", "bbox", "dir"}` in
536/// the order [`crate::PdfDocument::extract_words`] hands the words back.
537pub fn to_words_json_lines(words: &[Word]) -> String {
538    use crate::json::{number, string};
539    let mut out = String::new();
540    for word in words {
541        let bbox = word
542            .bbox
543            .iter()
544            .map(|v| number(*v))
545            .collect::<Vec<_>>()
546            .join(", ");
547        out.push_str(&format!(
548            "{{\"page\": {}, \"block\": {}, \"line\": {}, \"word\": {}, \"text\": {}, \"bbox\": [{bbox}], \"dir\": {}}}\n",
549            word.page,
550            word.block,
551            word.line,
552            word.word,
553            string(&word.text),
554            number(word.dir)
555        ));
556    }
557    out
558}
559
560#[cfg(test)]
561mod tests {
562    use super::*;
563    use crate::blocks::BlockKind;
564
565    fn block_of(spans: Vec<TextSpan>) -> Block {
566        let mut block = Block::new(
567            BlockKind::Paragraph,
568            0,
569            [0.0, 0.0, 100.0, 100.0],
570            String::new(),
571            0,
572        );
573        block.spans = spans;
574        block
575    }
576
577    fn span_with(text: &str, x: f64, y: f64, glyph_width: f64, size: f64) -> TextSpan {
578        let mut span = TextSpan {
579            text: text.to_string(),
580            x,
581            y,
582            width: glyph_width * text.chars().count() as f64,
583            height: size,
584            font_size: size,
585            ..Default::default()
586        };
587        let mut cursor = x;
588        for _ in text.chars() {
589            span.char_bounds
590                .push([cursor, y, cursor + glyph_width, y + size]);
591            cursor += glyph_width;
592        }
593        span
594    }
595
596    /// A span drawn at an angle, baseline `degrees` counter-clockwise from +x.
597    ///
598    /// The boxes stay in the device's own form -- a page-space origin with an
599    /// advance and a size hung off it -- so the word pass turns them exactly
600    /// as it turns a span the interpreter drew.
601    fn turned_span(
602        text: &str,
603        x: f64,
604        y: f64,
605        glyph_width: f64,
606        size: f64,
607        degrees: f64,
608    ) -> TextSpan {
609        let mut span = span_with(text, x, y, glyph_width, size);
610        let (sin, cos) = degrees.to_radians().sin_cos();
611        span.transform = Some([cos, sin, -sin, cos, x, y]);
612        span
613    }
614
615    #[test]
616    fn a_word_takes_the_union_of_its_glyphs() {
617        let span = span_with("hello world", 10.0, 20.0, 6.0, 12.0);
618        let words = words_of_span(&span);
619        assert_eq!(words.len(), 2);
620        assert_eq!(words[0].0, "hello");
621        assert_eq!(words[1].0, "world");
622        assert_eq!(words[0].1, [10.0, 20.0, 40.0, 32.0]);
623        assert_eq!(words[1].1, [46.0, 20.0, 76.0, 32.0]);
624    }
625
626    #[test]
627    fn a_span_without_boxes_splits_proportionally() {
628        let span = TextSpan {
629            text: "ab cd".to_string(),
630            x: 0.0,
631            y: 0.0,
632            width: 100.0,
633            height: 10.0,
634            font_size: 10.0,
635            ..Default::default()
636        };
637        assert!(span.char_bounds.is_empty());
638        let words = words_of_span(&span);
639        assert_eq!(words.len(), 2);
640        assert_eq!(words[0].1, [0.0, 0.0, 40.0, 10.0]);
641        assert_eq!(words[1].1, [60.0, 0.0, 100.0, 10.0]);
642    }
643
644    #[test]
645    fn an_inserted_space_owns_no_glyph() {
646        // Two glyphs, three characters: the space was inserted by the
647        // consensus and the letters keep their own boxes.
648        let mut span = span_with("ab", 0.0, 0.0, 10.0, 10.0);
649        span.text = "a b".to_string();
650        span.width = 30.0;
651        let words = words_of_span(&span);
652        assert_eq!(words.len(), 2);
653        assert_eq!(words[0].1, [0.0, 0.0, 10.0, 10.0]);
654        assert_eq!(words[1].1, [10.0, 0.0, 20.0, 10.0]);
655    }
656
657    #[test]
658    fn two_touching_spans_join_into_one_word() {
659        // "Hel" in bold 12pt, "lo" in roman 10pt, boxes touching: one word
660        // "Hello" with the union box and the longer piece's style. Take the
661        // join out and this comes back as two words.
662        let mut hel = span_with("Hel", 0.0, 20.0, 6.0, 12.0);
663        hel.is_bold = true;
664        let lo = span_with("lo", 18.0, 20.0, 6.0, 10.0);
665        let words = words_from_blocks(&[block_of(vec![hel, lo])]);
666        assert_eq!(words.len(), 1, "the split word did not join: {words:?}");
667        assert_eq!(words[0].text, "Hello");
668        assert_eq!(words[0].bbox, [0.0, 20.0, 30.0, 32.0]);
669        assert!(words[0].bold, "the longer piece lends the style");
670        assert_eq!(words[0].font_size, 12.0);
671    }
672
673    #[test]
674    fn two_spans_a_word_space_apart_stay_two_words() {
675        // 0.4 em at 12pt: a real inter-word space, past the 0.15 em a join
676        // allows. Both pieces sit on one line, so only the gap refuses.
677        let hel = span_with("Hel", 0.0, 20.0, 6.0, 12.0);
678        let lo = span_with("lo", 18.0 + 0.4 * 12.0, 20.0, 6.0, 12.0);
679        let words = words_from_blocks(&[block_of(vec![hel, lo])]);
680        assert_eq!(words.len(), 2, "the space did not hold: {words:?}");
681        assert_eq!(words[0].text, "Hel");
682        assert_eq!(words[1].text, "lo");
683        assert_eq!(words[0].line, words[1].line, "not on one line");
684    }
685
686    #[test]
687    fn a_superscript_right_after_its_base_stays_its_own_word() {
688        // The "2" touches "x" but rides 0.4 em higher: past the 0.3 em a
689        // join allows, yet inside the half em that keeps one line one line.
690        let x = span_with("x", 0.0, 20.0, 6.0, 10.0);
691        let two = span_with("2", 6.0, 24.0, 6.0, 10.0);
692        let words = words_from_blocks(&[block_of(vec![x, two])]);
693        assert_eq!(words.len(), 2, "the superscript joined: {words:?}");
694        assert_eq!(words[0].text, "x");
695        assert_eq!(words[1].text, "2");
696        assert_eq!(words[0].line, words[1].line, "not on one line");
697    }
698
699    #[test]
700    fn a_tied_join_keeps_the_left_pieces_style() {
701        // Two characters each, boxes touching: one word with the left
702        // piece's bold, size and angle. Take the angle with the style and
703        // this comes back 0.
704        let mut ab = turned_span("ab", 0.0, 20.0, 6.0, 10.0, 10.0);
705        ab.is_bold = true;
706        let cd = span_with("cd", 12.0, 20.0, 6.0, 14.0);
707        let words = words_from_blocks(&[block_of(vec![ab, cd])]);
708        assert_eq!(words.len(), 1, "the split word did not join: {words:?}");
709        assert_eq!(words[0].text, "abcd");
710        assert!(words[0].bold, "the tie went right");
711        assert_eq!(words[0].font_size, 10.0);
712        assert!(
713            (words[0].dir - 10.0).abs() < 1e-9,
714            "the tie went right: {}",
715            words[0].dir
716        );
717    }
718
719    #[test]
720    fn a_join_takes_the_longer_pieces_angle() {
721        // Three characters at ten degrees, two upright: the joined word reads
722        // at the longer piece's angle. Take the angle with the style and this
723        // comes back 0.
724        let hel = turned_span("Hel", 0.0, 20.0, 6.0, 12.0, 10.0);
725        let lo = span_with("lo", 18.0, 20.0, 6.0, 12.0);
726        let words = words_from_blocks(&[block_of(vec![hel, lo])]);
727        assert_eq!(words.len(), 1, "the split word did not join: {words:?}");
728        assert_eq!(words[0].text, "Hello");
729        assert!(
730            (words[0].dir - 10.0).abs() < 1e-9,
731            "the angle went right: {}",
732            words[0].dir
733        );
734    }
735
736    #[test]
737    fn a_join_takes_the_longer_pieces_angle_from_the_right() {
738        // Two characters at ten degrees, four upright: length beats position
739        // and the joined word reads upright. Take the left piece's angle and
740        // this comes back 10.
741        let ab = turned_span("ab", 0.0, 20.0, 6.0, 12.0, 10.0);
742        let cdef = span_with("cdef", 12.0, 20.0, 6.0, 12.0);
743        let words = words_from_blocks(&[block_of(vec![ab, cdef])]);
744        assert_eq!(words.len(), 1, "the split word did not join: {words:?}");
745        assert_eq!(words[0].text, "abcdef");
746        assert_eq!(words[0].dir, 0.0, "length lost to position");
747    }
748
749    #[test]
750    fn a_space_at_the_span_boundary_blocks_the_join() {
751        // The pieces touch, but the left span ends in a space: two words.
752        let hel = span_with("Hel ", 0.0, 20.0, 6.0, 12.0);
753        let lo = span_with("lo", 18.0, 20.0, 6.0, 12.0);
754        let words = words_from_blocks(&[block_of(vec![hel, lo])]);
755        assert_eq!(words.len(), 2, "the boundary space did not hold: {words:?}");
756        assert_eq!(words[0].text, "Hel");
757        assert_eq!(words[1].text, "lo");
758    }
759}