Skip to main content

kobo_core/html_text/
extract.rs

1// SPDX-License-Identifier: MIT
2// Copyright (c) 2026 Nayeem Bin Ahsan
3//! DOM extraction: chapter XHTML -> plain text + per-block-element segments.
4
5use super::style::{self, BookStyle, IndentMap, MAX_INDENT_EM};
6use scraper::{Html, Selector};
7use std::sync::LazyLock;
8
9/// A run of chapter text belonging to one block element, with its char range.
10/// For `<img>`/`<figure>` segments the text range is empty (zero-width marker at
11/// the image's flow position) and `src`/`caption` carry the image data.
12/// List marker kind for `li` segments.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
14pub enum ListKind {
15    Unordered,
16    Ordered(u32),
17    /// The list sets `list-style: none`. The item still indents to its nesting
18    /// depth, but draws no bullet or number -- the shape a table of contents
19    /// or a nav list uses.
20    Unmarked,
21}
22
23/// One `em` of left inset per list nesting level.
24pub const LIST_INDENT_EM: f32 = 1.5;
25
26/// Blockquote depth for segments inside `<blockquote>` elements.
27/// Serde-defaulted so caches written before this field existed still deserialize.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
29pub enum BlockquoteKind {
30    #[default]
31    None,
32    Leaf,
33    Children,
34}
35
36#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
37pub struct TextSegment {
38    /// Inclusive start byte/char offset into the chapter text.
39    pub start: usize,
40    /// Exclusive end offset.
41    pub end: usize,
42    /// Element tag (e.g. `p`, `h1`, `li`, `img`, `figure`).
43    pub tag: String,
44    /// The element's `id` attribute if any (useful for anchoring).
45    pub id: Option<String>,
46    /// `src` for `img`/`figure` (None for text blocks).
47    pub src: Option<String>,
48    /// `figcaption` text for a `figure` (None for non-figures).
49    pub caption: Option<String>,
50    /// Left indent of the block, in `em`, resolved from the book's stylesheet
51    /// (see [`crate::html_text::style`]) plus any leading spaces the markup
52    /// kept. 0 for ordinary prose. Serde-defaulted so offset caches written
53    /// before indents existed still deserialize.
54    #[serde(default)]
55    pub indent: f32,
56    /// Bold/italic spans within this segment, in chapter-text offsets.
57    #[serde(default)]
58    pub styles: Vec<StyleRun>,
59    /// For `li` segments: the parent list type and 1-based index.
60    #[serde(default)]
61    pub list: Option<ListKind>,
62    /// Nesting depth of this list item (0-based).
63    #[serde(default)]
64    pub list_depth: u32,
65    /// Whether this segment is inside a `<blockquote>`.
66    #[serde(default)]
67    pub blockquote: BlockquoteKind,
68    /// Hyperlink destinations within this segment, in chapter-text offsets.
69    #[serde(default)]
70    pub links: Vec<LinkRun>,
71    /// Markup for a figure the book *draws* inline rather than referencing as a
72    /// file. `src` names no archive entry in that case, so the bytes have to
73    /// travel with the segment. Serialised with the offset cache rather than
74    /// skipped: a skipped field would make every inline diagram render on the
75    /// first open of a book and vanish on the second.
76    #[serde(default)]
77    pub svg: Option<String>,
78    /// True only for an ordinary paragraph whose indent came from a Calibre
79    /// code-listing margin (resolved via the `IndentMap`), i.e. the one case
80    /// that should render as code. Table cards and nested lists also set
81    /// [`TextSegment::indent`] but through other fields, so they default to
82    /// `false` and render as prose. Serde-defaulted so offset caches written
83    /// before this field existed decode as `false`.
84    #[serde(default)]
85    pub code_indent: bool,
86}
87
88/// A hyperlink's text range and its raw `href`, in chapter-text byte offsets.
89///
90/// Kept out of [`StyleRun`] deliberately: `StyleRun` is cloned per row on every
91/// repaint, and an owned `String` per run would make that allocation-heavy for
92/// a field only the tap handler reads.
93#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
94pub struct LinkRun {
95    pub start: usize,
96    pub end: usize,
97    /// The `href` exactly as the markup wrote it, relative to the chapter file.
98    pub href: String,
99}
100
101/// A run of emphasised text inside a chapter, in chapter-text byte offsets.
102///
103/// Held alongside the text rather than inside it. `Row` is a Slint struct
104/// carrying flat text, and threading styled spans through it would mean
105/// changing the `.slint` type, every construction site, and the justification
106/// and TTS-highlight code that keys off row ranges. Rows already carry byte
107/// ranges, so emphasis can simply be looked up by offset at draw time.
108#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
109pub struct StyleRun {
110    pub start: usize,
111    pub end: usize,
112    pub bold: bool,
113    pub italic: bool,
114    #[serde(default)]
115    pub link: bool,
116}
117
118impl TextSegment {
119    pub fn contains(&self, i: usize) -> bool {
120        i >= self.start && i < self.end
121    }
122
123    /// The bullet or number this item draws, if any.
124    ///
125    /// Nesting depth is **not** baked in here as leading spaces -- it is
126    /// carried by [`TextSegment::indent`] so the renderer insets the whole
127    /// block in real pixels. Padding with spaces would only line up in a
128    /// monospace face.
129    pub fn list_marker(&self) -> Option<String> {
130        match self.list.as_ref()? {
131            ListKind::Unordered => Some("\u{2022} ".to_string()),
132            ListKind::Ordered(n) => Some(format!("{n}. ")),
133            ListKind::Unmarked => None,
134        }
135    }
136}
137
138/// Selector for the block elements we extract as flow items (text + images).
139/// `image` matches SVG `<image>` (covers, inline SVG art).
140const BLOCK_SELECTOR: &str =
141    "p, h1, h2, h3, h4, h5, h6, li, blockquote, pre, div, figure, img, image, svg, table";
142const CONTAINER_TAGS: &[&str] = &["div", "blockquote"];
143
144static BLOCK_SELECTOR_OBJ: LazyLock<Selector> =
145    LazyLock::new(|| Selector::parse(BLOCK_SELECTOR).expect("valid selector"));
146static IMG_SELECTOR_OBJ: LazyLock<Selector> =
147    LazyLock::new(|| Selector::parse("img").expect("selector"));
148static CAP_SELECTOR_OBJ: LazyLock<Selector> =
149    LazyLock::new(|| Selector::parse("figcaption").expect("selector"));
150static SVG_IMAGE_SELECTOR_OBJ: LazyLock<Selector> =
151    LazyLock::new(|| Selector::parse("image").expect("selector"));
152static SVG_SELECTOR_OBJ: LazyLock<Selector> =
153    LazyLock::new(|| Selector::parse("svg").expect("selector"));
154
155/// Extract plain text + per-block-element segments from chapter XHTML.
156/// Images (`<img>`/`<figure>`) appear in document order as zero-width segments
157/// carrying `src`/`caption` (they contribute no text to the returned string).
158pub fn extract(xhtml: &str) -> (String, Vec<TextSegment>) {
159    extract_with_style(xhtml, &BookStyle::new())
160}
161
162/// As [`extract`], but resolves each text block's left indent against the
163/// book's stylesheet (`class -> em`, built by [`style::parse_indents`]).
164///
165/// Calibre encodes code nesting as `margin-left` on a per-level class rather
166/// than as `<pre>`, so without the map every indent level collapses to zero and
167/// Python listings lose the structure that carries their meaning.
168pub fn extract_with_indents(xhtml: &str, indents: &IndentMap) -> (String, Vec<TextSegment>) {
169    extract_with_style(xhtml, &BookStyle::from_indents(indents.clone()))
170}
171
172/// As [`extract_with_indents`], but reads every style the scan understands --
173/// currently block indents plus `list-style: none`.
174pub fn extract_with_style(xhtml: &str, style: &BookStyle) -> (String, Vec<TextSegment>) {
175    let html = Html::parse_fragment(xhtml);
176    let sel = BLOCK_SELECTOR_OBJ.clone();
177    let child_sel = BLOCK_SELECTOR_OBJ.clone();
178    let img_sel = IMG_SELECTOR_OBJ.clone();
179    let cap_sel = CAP_SELECTOR_OBJ.clone();
180
181    let mut text = String::new();
182    let mut segs: Vec<TextSegment> = Vec::new();
183    let mut figure_srcs: std::collections::HashSet<String> = std::collections::HashSet::new();
184    // Numbers the synthetic keys inline drawings are looked up by.
185    let mut inline_svg_seq = 0usize;
186
187    for elem in html.select(&sel) {
188        let tag = elem.value().name().to_string();
189        let id = elem.value().attr("id").map(|s| s.to_string());
190
191        // A caption belongs to its figure, and a cell belongs to its table.
192        // Both are emitted by the owning element, so the block elements the
193        // markup wrapped them in must not be walked a second time.
194        if tag != "figure" && has_ancestor(elem, &["figcaption"]) {
195            continue;
196        }
197        if tag != "table" && has_ancestor(elem, &["table"]) {
198            continue;
199        }
200
201        let pos = text.len();
202        let produced: Vec<TextSegment> = match tag.as_str() {
203            "figure" => extract_figure_segment(
204                elem,
205                &img_sel,
206                &cap_sel,
207                pos,
208                id,
209                &mut figure_srcs,
210                &mut inline_svg_seq,
211            )
212            .into_iter()
213            .collect(),
214            "img" => {
215                if figure_srcs.contains(elem.value().attr("src").unwrap_or("")) {
216                    Vec::new()
217                } else {
218                    vec![TextSegment {
219                        start: pos,
220                        end: pos,
221                        tag,
222                        id,
223                        src: elem.value().attr("src").map(|s| s.to_string()),
224                        caption: None,
225                        indent: 0.0,
226                        styles: Vec::new(),
227                        list: None,
228                        list_depth: 0,
229                        blockquote: BlockquoteKind::None,
230                        svg: None,
231                        code_indent: false,
232                        links: Vec::new(),
233                    }]
234                }
235            }
236            "image" => extract_svg_image_segment(elem, pos, id)
237                .into_iter()
238                .collect(),
239            "svg" => extract_inline_svg_segment(elem, pos, id, &mut inline_svg_seq)
240                .into_iter()
241                .collect(),
242            "table" => extract_table_segments(elem, &mut text, id),
243            _ => extract_text_segment(elem, &child_sel, &mut text, pos, tag, id, &segs, style)
244                .into_iter()
245                .collect(),
246        };
247        segs.extend(produced);
248    }
249
250    resolve_blockquote_context(&mut segs, &html);
251
252    let extra = scan_raw_svg_images(xhtml, &segs, &mut text);
253    segs.extend(extra);
254
255    collect_orphan_text(&html, &mut text, &mut segs);
256
257    (text, segs)
258}
259
260static BODY_SELECTOR_OBJ: LazyLock<Selector> =
261    LazyLock::new(|| Selector::parse("body, html").expect("selector"));
262
263/// Tags directly matched by BLOCK_SELECTOR. Every other element at the root
264/// level is a candidate for orphan text collection.
265static BLOCK_SELECTOR_TAGS: std::sync::LazyLock<std::collections::HashSet<&'static str>> =
266    std::sync::LazyLock::new(|| {
267        [
268            "p", "h1", "h2", "h3", "h4", "h5", "h6", "li", "blockquote", "pre", "div",
269            "figure", "img", "image", "svg", "table",
270        ]
271        .into_iter()
272        .collect()
273    });
274
275/// Elements whose text content is never visible page prose: `<script>` carries
276/// JavaScript, `<style>` carries CSS, and the rest are document metadata. The
277/// orphan collector must skip these the way the main BLOCK_SELECTOR pass does
278/// implicitly (they are not in the selector), or the raw code at a chapter's
279/// end leaks into the rendered text.
280static NON_CONTENT_TAGS: std::sync::LazyLock<std::collections::HashSet<&'static str>> =
281    std::sync::LazyLock::new(|| {
282        [
283            "script", "style", "link", "meta", "head", "title", "noscript", "template",
284            "base",
285        ]
286        .into_iter()
287        .collect()
288    });
289
290/// Walk root children for text nodes or elements not in BLOCK_SELECTOR whose
291/// text content was silently dropped.
292///
293/// Custom tags (`<examples>`, `<input>`), bare text directly in `<body>`, and
294/// any unknown container are otherwise discarded with no warning during the
295/// main pass. This function collects only the gaps -- no threshold, no
296/// duplicate dump -- and injects each gap as a `div` segment at its natural
297/// position.
298fn collect_orphan_text(html: &Html, text: &mut String, segs: &mut Vec<TextSegment>) {
299    let body_sel = BODY_SELECTOR_OBJ.clone();
300    let Some(root) = html.select(&body_sel).next() else {
301        return;
302    };
303
304    let root_ref = scraper::ElementRef::wrap(*root).unwrap_or(root);
305
306    fn walk_orphans(node: ego_tree::NodeRef<scraper::Node>) -> Vec<String> {
307        let mut parts: Vec<String> = Vec::new();
308        for child in node.children() {
309            match child.value() {
310                scraper::Node::Text(t) => {
311                    let trimmed = t.trim();
312                    if !trimmed.is_empty() {
313                        parts.push(trimmed.to_string());
314                    }
315                }
316                scraper::Node::Element(e) => {
317                    if BLOCK_SELECTOR_TAGS.contains(e.name())
318                        || NON_CONTENT_TAGS.contains(e.name())
319                    {
320                        continue;
321                    }
322                    let owned = own_text_inner(child);
323                    let trimmed = owned.trim();
324                    if !trimmed.is_empty() {
325                        parts.push(trimmed.to_string());
326                    }
327                }
328                _ => {}
329            }
330        }
331        parts
332    }
333
334    let gap_texts = walk_orphans(*root_ref);
335    if gap_texts.is_empty() {
336        return;
337    }
338
339    let total_gap: usize = gap_texts.iter().map(|s| s.len()).sum();
340    log::warn!(
341        "extract: orphan text collected (captured {} chars, injected {} chars in {} gaps)",
342        text.len(),
343        total_gap,
344        gap_texts.len(),
345    );
346
347    for gap in gap_texts {
348        let pos = text.len();
349        text.push_str(&gap);
350        segs.push(TextSegment {
351            start: pos,
352            end: pos + gap.len(),
353            tag: "div".to_string(),
354            id: None,
355            src: None,
356            caption: None,
357            indent: 0.0,
358            styles: Vec::new(),
359            list: None,
360            list_depth: 0,
361            blockquote: BlockquoteKind::None,
362            svg: None,
363            code_indent: false,
364            links: Vec::new(),
365        });
366    }
367}
368
369fn own_text_inner(node: ego_tree::NodeRef<scraper::Node>) -> String {
370    let mut out = String::new();
371    for child in node.children() {
372        match child.value() {
373            scraper::Node::Text(t) => out.push_str(t),
374            scraper::Node::Element(e)
375                if !is_block_tag(e.name()) && !NON_CONTENT_TAGS.contains(e.name()) =>
376            {
377                out.push_str(&own_text_inner(child));
378            }
379            _ => {}
380        }
381    }
382    out
383}
384
385/// Does `elem` sit inside an element with one of these tag names?
386fn has_ancestor(elem: scraper::ElementRef, names: &[&str]) -> bool {
387    elem.ancestors()
388        .any(|a| matches!(a.value(), scraper::Node::Element(e) if names.contains(&e.name())))
389}
390
391fn extract_figure_segment(
392    elem: scraper::ElementRef,
393    img_sel: &Selector,
394    cap_sel: &Selector,
395    pos: usize,
396    id: Option<String>,
397    figure_srcs: &mut std::collections::HashSet<String>,
398    seq: &mut usize,
399) -> Option<TextSegment> {
400    // A figure may wrap a plain `<img>` or an SVG `<image>`; the SVG form is
401    // what most diagram exporters emit. Reading only `<img src>` dropped the
402    // whole figure -- caption included -- for the SVG shape.
403    let mut src = elem
404        .select(img_sel)
405        .next()
406        .and_then(|i| i.value().attr("src"))
407        .or_else(|| {
408            elem.select(&SVG_IMAGE_SELECTOR_OBJ)
409                .next()
410                .and_then(image_href)
411        })
412        .map(|s| {
413            figure_srcs.insert(s.to_string());
414            s.to_string()
415        });
416    // Neither shape referenced a file, so the drawing is defined in the chapter
417    // itself. It travels with the segment under a synthetic key.
418    let mut svg = None;
419    if src.is_none() {
420        if let Some(markup) = elem
421            .select(&SVG_SELECTOR_OBJ)
422            .next()
423            .and_then(inline_svg_markup)
424        {
425            src = Some(inline_svg_key(seq));
426            svg = Some(markup);
427        }
428    }
429    let cap = elem
430        .select(cap_sel)
431        .next()
432        .map(|c| {
433            c.text()
434                .collect::<String>()
435                .split_whitespace()
436                .collect::<Vec<_>>()
437                .join(" ")
438        })
439        .filter(|c| !c.is_empty());
440    // A figure with neither a picture nor a caption has nothing to show.
441    if src.is_none() && cap.is_none() {
442        return None;
443    }
444    Some(TextSegment {
445        start: pos,
446        end: pos,
447        tag: "figure".to_string(),
448        id,
449        src,
450        caption: cap,
451        indent: 0.0,
452        styles: Vec::new(),
453        list: None,
454        list_depth: 0,
455        blockquote: BlockquoteKind::None,
456        svg,
457        code_indent: false,
458        links: Vec::new(),
459    })
460}
461
462/// The `href` of an SVG `<image>`, whichever spelling the markup used.
463///
464/// `xlink:href` is a namespaced attribute, so a qualified-name lookup for
465/// either `"xlink:href"` or `"href"` misses it -- the parser keeps the prefix
466/// separately from the local name. Matching on the local name finds both the
467/// namespaced form and the plain SVG2 `href`.
468fn image_href<'a>(elem: scraper::ElementRef<'a>) -> Option<&'a str> {
469    elem.value()
470        .attrs()
471        .find(|(name, value)| *name == "href" && !value.trim().is_empty())
472        .map(|(_, value)| value)
473}
474
475/// A drawing the book defines in the chapter itself, as `<svg>` shape elements
476/// rather than a reference to a file.
477///
478/// This is what every diagramming tool that "embeds" its output produces, and
479/// until now it extracted to nothing: the element holds no text, names no
480/// `src`, and so contributed neither a picture nor a caption.
481///
482/// The markup is re-serialised from the parsed tree. SVG is case-sensitive
483/// where HTML is not (`viewBox`, `preserveAspectRatio`, `clipPath`), but the
484/// parser puts these elements in the SVG namespace and restores their casing on
485/// the way in, so the round-trip preserves them -- see the `viewBox` test.
486fn inline_svg_markup(elem: scraper::ElementRef) -> Option<String> {
487    // An `<svg>` that only wraps `<image href=...>` is a pointer to an archive
488    // entry, which the `figure`/`image` paths already resolve. Rasterising the
489    // wrapper here would draw an empty box over the real picture.
490    if elem.select(&SVG_IMAGE_SELECTOR_OBJ).next().is_some() {
491        return None;
492    }
493    // Nothing to draw: no shapes, no text.
494    if !elem.children().any(|c| c.value().is_element()) {
495        return None;
496    }
497    Some(with_svg_namespace(&elem.html()))
498}
499
500/// usvg parses XML, and rejects a document whose root is not in the SVG
501/// namespace. An inline `<svg>` in XHTML frequently declares no `xmlns` of its
502/// own -- it inherits the document's -- and the serialiser does not write one
503/// back, so it has to be restored here or every inline diagram fails to parse.
504fn with_svg_namespace(block: &str) -> String {
505    if block.contains("xmlns") {
506        return block.to_string();
507    }
508    match block.find(|c: char| c.is_ascii_whitespace() || c == '>') {
509        Some(i) => format!(
510            "{} xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\"{}",
511            &block[..i],
512            &block[i..]
513        ),
514        None => block.to_string(),
515    }
516}
517
518/// Key an inline drawing is looked up by. Consumers resolve pictures through
519/// `src`, and this one names no archive entry, so it gets a synthetic name that
520/// cannot collide with a real path (no book resource starts with `#`).
521fn inline_svg_key(seq: &mut usize) -> String {
522    let key = format!("#inline-svg-{seq}");
523    *seq += 1;
524    key
525}
526
527fn extract_inline_svg_segment(
528    elem: scraper::ElementRef,
529    pos: usize,
530    id: Option<String>,
531    seq: &mut usize,
532) -> Option<TextSegment> {
533    // A figure owns its own drawing, caption included; emitting a second
534    // segment here would render the diagram twice.
535    if has_ancestor(elem, &["figure"]) {
536        return None;
537    }
538    let markup = inline_svg_markup(elem)?;
539    Some(TextSegment {
540        start: pos,
541        end: pos,
542        tag: "image".to_string(),
543        id,
544        src: Some(inline_svg_key(seq)),
545        caption: None,
546        indent: 0.0,
547        styles: Vec::new(),
548        list: None,
549        list_depth: 0,
550        blockquote: BlockquoteKind::None,
551        svg: Some(markup),
552        code_indent: false,
553        links: Vec::new(),
554    })
555}
556
557fn extract_svg_image_segment(
558    elem: scraper::ElementRef,
559    pos: usize,
560    id: Option<String>,
561) -> Option<TextSegment> {
562    let src = image_href(elem).unwrap_or("");
563    if src.is_empty() {
564        return None;
565    }
566    Some(TextSegment {
567        start: pos,
568        end: pos,
569        tag: "image".to_string(),
570        id,
571        src: Some(src.to_string()),
572        caption: None,
573        indent: 0.0,
574        styles: Vec::new(),
575        list: None,
576        list_depth: 0,
577        blockquote: BlockquoteKind::None,
578        svg: None,
579        code_indent: false,
580        links: Vec::new(),
581    })
582}
583
584fn extract_text_segment(
585    elem: scraper::ElementRef,
586    child_sel: &Selector,
587    text: &mut String,
588    pos: usize,
589    tag: String,
590    id: Option<String>,
591    segs: &[TextSegment],
592    style_sheet: &BookStyle,
593) -> Option<TextSegment> {
594    if CONTAINER_TAGS.contains(&tag.as_str()) && elem.select(child_sel).next().is_some() {
595        return None;
596    }
597    let indents = &style_sheet.indents;
598    // A list item that contains a nested list must contribute only its own
599    // label. `elem.text()` would swallow every descendant item's text, which
600    // both duplicates it and starves the nested items of their own segments.
601    let raw: String = if tag == "li" {
602        own_text(elem)
603    } else {
604        elem.text().collect()
605    };
606    // `pre` keeps its indentation inside the text itself, so adding a block
607    // indent on top would double it.
608    let indent_from_block = if tag == "pre" {
609        0.0
610    } else {
611        block_indent_em(elem, &raw, indents)
612    };
613    // Calibre encodes code-listing nesting as a per-class `margin-left` rather
614    // than a real `<pre>`; that resolved margin is the only static signal an
615    // ordinary paragraph is code. List items and table cards also indent
616    // (nesting, card layout) but through other fields, so they stay `false`
617    // here and render as prose -- not faint monospace.
618    let code_indent = tag != "pre" && tag != "li" && indent_from_block > 0.0;
619    let indent = indent_from_block;
620    // `pre` is verbatim: keep its own line breaks and leading indentation, only
621    // shedding the blank lines the markup wraps around the block. Every other
622    // block collapses to a trimmed run that the layout re-wraps.
623    let t: &str = if tag == "pre" {
624        raw.trim_matches('\n').trim_end()
625    } else {
626        raw.trim()
627    };
628    if t.is_empty() {
629        return None;
630    }
631    if !text.is_empty() {
632        text.push('\n');
633    }
634    let content_start = text.len();
635    text.push_str(t);
636    let end = text.len();
637    let dup = segs
638        .last()
639        .map(|s| text[s.start..s.end] == text[content_start..end])
640        .unwrap_or(false);
641    if dup {
642        text.truncate(pos);
643        return None;
644    }
645    // Emphasis offsets are measured in `raw`; `t` dropped the leading
646    // whitespace, so shift by however much went and clamp to the stored range.
647    let lead = raw.len() - raw.trim_start().len();
648    let rebase = |a: usize, b: usize| -> Option<(usize, usize)> {
649        let a = content_start + a.saturating_sub(lead);
650        let b = content_start + b.saturating_sub(lead);
651        let (a, b) = (a.max(content_start).min(end), b.max(content_start).min(end));
652        (a < b).then_some((a, b))
653    };
654    let (raw_styles, raw_links) = if tag == "pre" {
655        (Vec::new(), Vec::new())
656    } else {
657        collect_style_runs(elem)
658    };
659    let styles: Vec<StyleRun> = raw_styles
660        .into_iter()
661        .filter_map(|(a, b, bold, italic, link)| {
662            rebase(a, b).map(|(start, end)| StyleRun {
663                start,
664                end,
665                bold,
666                italic,
667                link,
668            })
669        })
670        .collect();
671    let links: Vec<LinkRun> = raw_links
672        .into_iter()
673        .filter_map(|(a, b, href)| rebase(a, b).map(|(start, end)| LinkRun { start, end, href }))
674        .collect();
675    let (list, list_depth) = list_context(elem, &tag, style_sheet);
676    // Nesting is drawn as a real pixel inset, so it stacks with whatever the
677    // stylesheet already asked for rather than replacing it.
678    let indent = if list.is_some() {
679        (indent + list_depth as f32 * LIST_INDENT_EM).min(MAX_INDENT_EM)
680    } else {
681        indent
682    };
683    Some(TextSegment {
684        start: content_start,
685        end,
686        tag,
687        id,
688        src: None,
689        caption: None,
690        indent,
691        styles,
692        list,
693        list_depth,
694        blockquote: BlockquoteKind::None,
695        svg: None,
696        code_indent,
697        links,
698    })
699}
700
701/// Text belonging to this element itself: descendant text nodes, but not those
702/// inside a nested block element (which gets its own segment).
703fn own_text(elem: scraper::ElementRef) -> String {
704    fn walk_own(node: ego_tree::NodeRef<scraper::Node>, out: &mut String) {
705        for child in node.children() {
706            match child.value() {
707                scraper::Node::Text(t) => out.push_str(t),
708                scraper::Node::Element(e) if !is_block_tag(e.name()) => walk_own(child, out),
709                _ => {}
710            }
711        }
712    }
713    let mut out = String::new();
714    walk_own(*elem, &mut out);
715    out
716}
717
718/// Tags that own a segment of their own, so an ancestor must not absorb them.
719fn is_block_tag(name: &str) -> bool {
720    matches!(
721        name,
722        "p" | "h1"
723            | "h2"
724            | "h3"
725            | "h4"
726            | "h5"
727            | "h6"
728            | "li"
729            | "ul"
730            | "ol"
731            | "blockquote"
732            | "pre"
733            | "div"
734            | "figure"
735            | "img"
736            | "image"
737            | "table"
738    )
739}
740
741/// The list marker kind and nesting depth for an `li`, read from its ancestors.
742///
743/// Resolved here rather than in a post-pass over the segment list: a post-pass
744/// has to guess which `li` element produced which segment, and any item the
745/// walk skipped (empty, or de-duplicated) silently shifts every marker after
746/// it. Walking up from the element itself cannot drift.
747fn list_context(
748    elem: scraper::ElementRef,
749    tag: &str,
750    style_sheet: &BookStyle,
751) -> (Option<ListKind>, u32) {
752    if tag != "li" {
753        return (None, 0);
754    }
755    let mut depth = 0u32;
756    let mut owner: Option<scraper::ElementRef> = None;
757    for anc in elem.ancestors() {
758        let Some(e) = scraper::ElementRef::wrap(anc) else {
759            continue;
760        };
761        if matches!(e.value().name(), "ul" | "ol") {
762            if owner.is_none() {
763                owner = Some(e);
764            } else {
765                depth += 1;
766            }
767        }
768    }
769    let Some(list_elem) = owner else {
770        return (None, 0);
771    };
772    if list_marker_suppressed(list_elem, style_sheet)
773        && has_ancestor(list_elem, &["nav"])
774    {
775        return (Some(ListKind::Unmarked), depth);
776    }
777    if list_elem.value().name() == "ol" {
778        let start = list_elem
779            .value()
780            .attr("start")
781            .and_then(|s| s.parse::<u32>().ok())
782            .unwrap_or(1);
783        // Position among *sibling* items only -- a nested list's items restart
784        // their own numbering rather than continuing the parent's.
785        let preceding = elem
786            .prev_siblings()
787            .filter(|n| matches!(n.value(), scraper::Node::Element(e) if e.name() == "li"))
788            .count() as u32;
789        (Some(ListKind::Ordered(start + preceding)), depth)
790    } else {
791        (Some(ListKind::Unordered), depth)
792    }
793}
794
795/// Does this `ul`/`ol` (or an ancestor list) set `list-style: none`?
796///
797/// Only asked of lists inside a `<nav>` -- a real EPUB table of contents, page
798/// list or landmarks section, which is nearly always styled this way and whose
799/// entries usually carry their own numbering already. `list-style: none` shows
800/// up just as often on an ordinary numbered or bulleted list in the body text,
801/// there purely to override the browser's default indent; suppressing those
802/// markers too was the wrong read -- an in-chapter "eight things an agent does"
803/// list lost its numbers entirely, which is a worse loss than a moot bullet on
804/// a nav link. `list_context` gates the class/inline-style check on
805/// `has_ancestor(list_elem, &["nav"])` for exactly this reason.
806fn list_marker_suppressed(list_elem: scraper::ElementRef, style_sheet: &BookStyle) -> bool {
807    let by_class = list_elem
808        .value()
809        .attr("class")
810        .map(|classes| {
811            classes
812                .split_whitespace()
813                .any(|c| style_sheet.no_marker.contains(c))
814        })
815        .unwrap_or(false);
816    let by_inline = list_elem
817        .value()
818        .attr("style")
819        .map(style::inline_list_style_none)
820        .unwrap_or(false);
821    by_class || by_inline
822}
823
824fn is_bold_tag(name: &str) -> bool {
825    matches!(name, "b" | "strong")
826}
827
828fn is_italic_tag(name: &str) -> bool {
829    matches!(name, "i" | "em" | "cite" | "var")
830}
831
832fn is_link_tag(name: &str) -> bool {
833    name == "a"
834}
835
836struct StyleWalk {
837    runs: Vec<(usize, usize, bool, bool, bool)>,
838    links: Vec<(usize, usize, String)>,
839}
840
841fn walk(
842    node: ego_tree::NodeRef<scraper::Node>,
843    bold: bool,
844    italic: bool,
845    href: Option<&str>,
846    pos: &mut usize,
847    out: &mut StyleWalk,
848) {
849    for child in node.children() {
850        match child.value() {
851            scraper::Node::Text(t) => {
852                let start = *pos;
853                *pos += t.len();
854                let link = href.is_some();
855                if (bold || italic || link) && *pos > start {
856                    out.runs.push((start, *pos, bold, italic, link));
857                }
858                if let Some(h) = href {
859                    if *pos > start {
860                        out.links.push((start, *pos, h.to_string()));
861                    }
862                }
863            }
864            scraper::Node::Element(e) => {
865                let name = e.name();
866                // An `<a>` without an href is an anchor target, not a link --
867                // underlining it would promise a tap that goes nowhere.
868                let child_href = if is_link_tag(name) {
869                    e.attr("href").filter(|h| !h.trim().is_empty()).or(href)
870                } else {
871                    href
872                };
873                walk(
874                    child,
875                    bold || is_bold_tag(name),
876                    italic || is_italic_tag(name),
877                    child_href,
878                    pos,
879                    out,
880                );
881            }
882            _ => {}
883        }
884    }
885}
886
887/// Walk a block's descendants in document order, recording which byte ranges of
888/// its concatenated text sit inside a bold, italic, or link element.
889///
890/// Offsets are into the raw (untrimmed) text of the block, which is the same
891/// string `elem.text()` produces -- both walk text nodes in the same order.
892#[allow(clippy::type_complexity)]
893fn collect_style_runs(
894    elem: scraper::ElementRef,
895) -> (
896    Vec<(usize, usize, bool, bool, bool)>,
897    Vec<(usize, usize, String)>,
898) {
899    let mut out = StyleWalk {
900        runs: Vec::new(),
901        links: Vec::new(),
902    };
903    let mut pos = 0usize;
904    walk(*elem, false, false, None, &mut pos, &mut out);
905    out.runs.dedup_by(|b, a| {
906        if a.1 == b.0 && a.2 == b.2 && a.3 == b.3 && a.4 == b.4 {
907            a.1 = b.1;
908            true
909        } else {
910            false
911        }
912    });
913    out.links.dedup_by(|b, a| {
914        if a.1 == b.0 && a.2 == b.2 {
915            a.1 = b.1;
916            true
917        } else {
918            false
919        }
920    });
921    (out.runs, out.links)
922}
923
924/// Extra indent contributed by each leading space the markup preserved.
925///
926/// Calibre puts the coarse nesting level on the class (1em per level) but
927/// leaves finer, within-level alignment -- a continued call's arguments, a JSON
928/// key one step inside its brace -- as leading `&nbsp;`, which `trim` would
929/// otherwise discard. Half an em keeps that visible without competing with a
930/// real nesting step.
931const EM_PER_LEADING_SPACE: f32 = 0.5;
932
933/// Left indent for a text block: its class's stylesheet `margin-left` (or an
934/// inline one), plus whatever leading spaces the markup kept.
935///
936/// The two are additive because they encode different things -- see
937/// [`EM_PER_LEADING_SPACE`].
938fn block_indent_em(elem: scraper::ElementRef, raw: &str, indents: &IndentMap) -> f32 {
939    let from_class = elem
940        .value()
941        .attr("class")
942        .map(|classes| {
943            classes
944                .split_whitespace()
945                .filter_map(|c| indents.get(c).copied())
946                .fold(0.0f32, f32::max)
947        })
948        .unwrap_or(0.0);
949    let from_style = elem
950        .value()
951        .attr("style")
952        .and_then(style::inline_indent_em)
953        .unwrap_or(0.0);
954    let leading = raw
955        .chars()
956        .take_while(|c| *c == ' ' || *c == '\u{00A0}' || *c == '\t')
957        .count() as f32;
958    (from_class.max(from_style) + leading * EM_PER_LEADING_SPACE).min(MAX_INDENT_EM)
959}
960
961static BQ_SELECTOR_OBJ: LazyLock<Selector> =
962    LazyLock::new(|| Selector::parse("blockquote").expect("selector"));
963static TR_SELECTOR_OBJ: LazyLock<Selector> =
964    LazyLock::new(|| Selector::parse("tr").expect("selector"));
965static TD_SELECTOR_OBJ: LazyLock<Selector> =
966    LazyLock::new(|| Selector::parse("td, th").expect("selector"));
967static TH_SELECTOR_OBJ: LazyLock<Selector> =
968    LazyLock::new(|| Selector::parse("th").expect("selector"));
969
970fn resolve_blockquote_context(segs: &mut [TextSegment], html: &Html) {
971    let bq_sel = BQ_SELECTOR_OBJ.clone();
972    let block_sel = BLOCK_SELECTOR_OBJ.clone();
973    for bq_elem in html.select(&bq_sel) {
974        let bq_children: Vec<_> = bq_elem.select(&block_sel).collect();
975        if bq_children.is_empty() {
976            continue;
977        }
978        let bq_text: String = bq_elem.text().collect();
979        let bq_trimmed = bq_text.trim();
980        if bq_trimmed.is_empty() {
981            continue;
982        }
983        let is_leaf = bq_children.len() == 1
984            && bq_children[0].value().name() != "div"
985            && bq_children[0].value().name() != "blockquote";
986        for seg in segs.iter_mut() {
987            if seg.tag == "blockquote" || seg.tag == "div" || seg.src.is_some() {
988                continue;
989            }
990            if seg.start >= bq_trimmed.len() {
991                continue;
992            }
993            let seg_text = if seg.end <= bq_trimmed.len() {
994                &bq_trimmed[seg.start..seg.end]
995            } else {
996                continue;
997            };
998            if seg_text.is_empty() {
999                continue;
1000            }
1001            if !bq_trimmed.contains(seg_text) {
1002                continue;
1003            }
1004            seg.blockquote = if is_leaf {
1005                BlockquoteKind::Leaf
1006            } else {
1007                BlockquoteKind::Children
1008            };
1009        }
1010    }
1011}
1012
1013/// Turn a `<table>` into a stacked card list, in document order.
1014///
1015/// A fixed-width grid cannot work here. The text column is a few hundred
1016/// points wide and the body face is proportional, so even three columns of
1017/// ordinary prose overflow it, and padding cells to a character count is
1018/// meaningless for CJK, whose glyphs are twice as wide as the count implies.
1019///
1020/// So each `<tr>` becomes a card, the way responsive web tables collapse on a
1021/// phone: the row's first cell is the card's title, and every other cell is
1022/// emitted as its own `header: value` line. Nothing is truncated, everything
1023/// wraps, and -- unlike the grid, which was never added to the chapter text --
1024/// all of it reaches read-aloud.
1025fn extract_table_segments(
1026    table: scraper::ElementRef,
1027    text: &mut String,
1028    id: Option<String>,
1029) -> Vec<TextSegment> {
1030    let tr_sel = TR_SELECTOR_OBJ.clone();
1031    let td_sel = TD_SELECTOR_OBJ.clone();
1032    let rows: Vec<Vec<String>> = table
1033        .select(&tr_sel)
1034        .map(|tr| {
1035            tr.select(&td_sel)
1036                .map(|td| {
1037                    td.text()
1038                        .collect::<String>()
1039                        .split_whitespace()
1040                        .collect::<Vec<_>>()
1041                        .join(" ")
1042                })
1043                .collect()
1044        })
1045        .filter(|cells: &Vec<String>| !cells.is_empty())
1046        .collect();
1047    if rows.is_empty() {
1048        return Vec::new();
1049    }
1050
1051    // A header row is what turns a bare cell into `label: value`. Prefer a real
1052    // `<th>` row; fall back to the first row when every later row has the same
1053    // shape, which is how most converters emit a header they forgot to mark up.
1054    let has_th = table
1055        .select(&tr_sel)
1056        .next()
1057        .map(|tr| tr.select(&TH_SELECTOR_OBJ).next().is_some())
1058        .unwrap_or(false);
1059    let uniform = rows.len() > 1 && rows[1..].iter().all(|r| r.len() == rows[0].len());
1060    let (headers, body_rows) = if (has_th || uniform) && rows.len() > 1 {
1061        (Some(&rows[0]), &rows[1..])
1062    } else {
1063        (None, &rows[..])
1064    };
1065
1066    let mut out = Vec::new();
1067    let mut push =
1068        |content: &str, indent: f32, bold: bool, text: &mut String, id: &mut Option<String>| {
1069            if content.is_empty() {
1070                return;
1071            }
1072            if !text.is_empty() {
1073                text.push('\n');
1074            }
1075            let start = text.len();
1076            text.push_str(content);
1077            let end = text.len();
1078            let styles = if bold {
1079                vec![StyleRun {
1080                    start,
1081                    end,
1082                    bold: true,
1083                    italic: false,
1084                    link: false,
1085                }]
1086            } else {
1087                Vec::new()
1088            };
1089            out.push(TextSegment {
1090                start,
1091                end,
1092                tag: "p".to_string(),
1093                // The table's own id belongs to its first line, so an internal
1094                // link that targets the table lands at its top.
1095                id: id.take(),
1096                src: None,
1097                caption: None,
1098                indent,
1099                styles,
1100                list: None,
1101                list_depth: 0,
1102                blockquote: BlockquoteKind::None,
1103                svg: None,
1104                code_indent: false,
1105                links: Vec::new(),
1106            });
1107        };
1108
1109    let mut pending_id = id;
1110    for row in body_rows {
1111        let mut cells = row.iter().enumerate();
1112        // Card title: the row's first cell, which is what identifies the row.
1113        if let Some((_, title)) = cells.next() {
1114            let title = if title.is_empty() { "\u{2014}" } else { title };
1115            push(title, 0.0, true, text, &mut pending_id);
1116        }
1117        for (ci, cell) in cells {
1118            if cell.is_empty() {
1119                continue;
1120            }
1121            let line = match headers.and_then(|h| h.get(ci)) {
1122                Some(label) if !label.is_empty() => format!("{label}: {cell}"),
1123                _ => cell.clone(),
1124            };
1125            push(&line, TABLE_CELL_INDENT_EM, false, text, &mut pending_id);
1126        }
1127    }
1128    out
1129}
1130
1131/// Left inset for a table card's `label: value` lines, so they read as
1132/// belonging to the title above them.
1133const TABLE_CELL_INDENT_EM: f32 = 1.5;
1134
1135fn scan_raw_svg_images(xhtml: &str, segs: &[TextSegment], text: &mut String) -> Vec<TextSegment> {
1136    let captured_srcs: std::collections::HashSet<String> =
1137        segs.iter().filter_map(|s| s.src.clone()).collect();
1138    let mut extra: Vec<TextSegment> = Vec::new();
1139    let mut search = 0;
1140    while let Some(pos) = xhtml[search..].find("<image") {
1141        let abs = search + pos;
1142        let tag_end = xhtml[abs..]
1143            .find('>')
1144            .map(|p| abs + p)
1145            .unwrap_or(xhtml.len());
1146        let tag = &xhtml[abs..tag_end];
1147        for attr in &["xlink:href", "href"] {
1148            if let Some(eq) = tag.find(attr) {
1149                let rest = &tag[eq + attr.len()..];
1150                let rest = rest.trim_start();
1151                if let Some(after_eq) = rest.strip_prefix('=') {
1152                    let v = after_eq.trim_start();
1153                    let q = v.chars().next().unwrap_or('"');
1154                    if (q == '"' || q == '\'') && v.len() > 1 {
1155                        if let Some(end) = v[1..].find(q) {
1156                            let src = &v[1..1 + end];
1157                            if !captured_srcs.contains(src) && !src.is_empty() {
1158                                extra.push(TextSegment {
1159                                    start: text.len(),
1160                                    end: text.len(),
1161                                    tag: "image".to_string(),
1162                                    id: None,
1163                                    src: Some(src.to_string()),
1164                                    caption: None,
1165                                    indent: 0.0,
1166                                    styles: Vec::new(),
1167                                    list: None,
1168                                    list_depth: 0,
1169                                    blockquote: BlockquoteKind::None,
1170                                    svg: None,
1171                                    code_indent: false,
1172                                    links: Vec::new(),
1173                                });
1174                            }
1175                            break;
1176                        }
1177                    }
1178                }
1179            }
1180        }
1181        search = tag_end;
1182    }
1183    extra
1184}
1185
1186/// Find the [`TextSegment`] containing char offset `i`, if any.
1187pub fn segment_at(segs: &[TextSegment], i: usize) -> Option<&TextSegment> {
1188    segs.iter().find(|s| s.contains(i))
1189}
1190#[cfg(test)]
1191mod tests;