Skip to main content

djvu_rs/
text.rs

1//! DjVu text layer — data model and parser.
2//!
3//! Defines the types shared by the text encoder, serialisers, and OCR
4//! backends, and provides the pure [`parse_text_layer`] parser for TXTa
5//! (plain) and TXTz (BZZ-compressed) chunks.
6//!
7//! ## Key types
8//!
9//! - [`TextLayer`] — full text content and zone hierarchy of a page
10//! - [`TextZone`] — single zone node (page/column/para/line/word/char)
11//! - [`TextZoneKind`] — enum discriminating zone types
12//! - [`Rect`] — bounding rectangle in top-left-origin coordinates
13//! - [`Paragraph`] — reflowable paragraph extracted from a [`TextLayer`]
14//! - [`TextError`] — typed errors from text layer parsing
15//!
16//! ## Format notes
17//!
18//! The TXTa/TXTz binary format stores:
19//!   `[u24be text_len][utf8 text][u8 version][zone tree]`
20//!
21//! Zone coordinates use DjVu's bottom-left origin. The parser remaps all
22//! coordinates to a top-left origin using the provided page height.
23//! Zone fields are delta-encoded relative to a parent or previous sibling.
24
25#[cfg(not(feature = "std"))]
26use alloc::{
27    string::{String, ToString},
28    vec::Vec,
29};
30
31use crate::info::Rotation;
32
33// ---- Error ------------------------------------------------------------------
34
35/// Errors from text layer parsing.
36#[derive(Debug, thiserror::Error)]
37#[non_exhaustive]
38pub enum TextError {
39    /// The binary data is too short to be a valid text layer.
40    #[error("text layer data too short")]
41    TooShort,
42
43    /// A text length field points past the end of the data.
44    #[error("text length overflows data")]
45    TextOverflow,
46
47    /// The text bytes are not valid UTF-8.
48    ///
49    /// No longer produced since #524: invalid bytes are decoded leniently
50    /// (CP1252 fallback). Kept so matching code keeps compiling.
51    #[error("invalid UTF-8 in text layer")]
52    InvalidUtf8,
53
54    /// A zone record is truncated (not enough bytes for a field).
55    #[error("zone record truncated at offset {0}")]
56    ZoneTruncated(usize),
57
58    /// An unknown zone type byte was encountered.
59    #[error("unknown zone type {0}")]
60    UnknownZoneType(u8),
61
62    /// The zone hierarchy nests deeper than [`MAX_ZONE_DEPTH`] (#589).
63    #[error("zone tree too deep (> {MAX_ZONE_DEPTH})")]
64    ZoneTooDeep,
65}
66
67/// Maximum text-zone nesting depth (#589). The DjVu hierarchy is
68/// page→column→region→para→line→word→character = 7 real levels; the generous
69/// cap tolerates degenerate-but-legitimate nesting while stopping a crafted
70/// single-child chain from overflowing the stack. Mirrors `MAX_NAVM_DEPTH`.
71pub const MAX_ZONE_DEPTH: usize = 64;
72
73/// Smallest possible child zone record in bytes: 1 type byte + five
74/// 2-byte biased coordinates + a 3-byte `text_len` + a 3-byte
75/// `children_count` = 17. Used to reject a `children_count` that cannot fit in
76/// the remaining input before reserving for it (#589).
77const MIN_ZONE_RECORD_BYTES: usize = 17;
78
79// ---- Public types -----------------------------------------------------------
80
81/// Zone type discriminant in the DjVu text layer hierarchy.
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
84pub enum TextZoneKind {
85    Page,
86    Column,
87    Region,
88    Para,
89    Line,
90    Word,
91    Character,
92}
93
94/// Bounding rectangle in top-left-origin coordinates (pixels).
95#[derive(Debug, Clone, PartialEq, Eq)]
96#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
97pub struct Rect {
98    pub x: u32,
99    pub y: u32,
100    pub width: u32,
101    pub height: u32,
102}
103
104/// A single node in the text zone hierarchy.
105#[derive(Debug, Clone)]
106#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
107pub struct TextZone {
108    /// Zone type.
109    pub kind: TextZoneKind,
110    /// Bounding box (top-left origin, after coordinate remap).
111    pub rect: Rect,
112    /// Text covered by this zone (substring of [`TextLayer::text`]).
113    pub text: String,
114    /// Child zones (columns inside page, words inside line, etc.).
115    pub children: Vec<TextZone>,
116}
117
118/// The complete text layer of a DjVu page.
119#[derive(Debug, Clone)]
120#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
121pub struct TextLayer {
122    /// Full plain-text content of the page, UTF-8.
123    pub text: String,
124    /// Top-level zone nodes (usually a single `Page` zone).
125    pub zones: Vec<TextZone>,
126}
127
128/// A reflowable paragraph: the original lines as they appear on the page,
129/// plus a single joined `text` string with line-break and hyphenation rules
130/// applied (see [`TextLayer::reflowable_text`]).
131#[derive(Debug, Clone, PartialEq, Eq)]
132#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
133pub struct Paragraph {
134    /// Each physical line as it appeared on the page (trimmed of trailing
135    /// whitespace, ordered top-to-bottom).
136    pub lines: Vec<String>,
137    /// Lines joined into a single string. Hyphenated line breaks (line ends
138    /// with `-` and next line starts with a lowercase letter) are joined with
139    /// no separator and the hyphen is dropped. Other line breaks become a
140    /// single ASCII space.
141    pub text: String,
142}
143
144// ---- TextLayer methods ------------------------------------------------------
145
146impl TextLayer {
147    /// Return a copy of this text layer with all zone rectangles transformed to
148    /// match a rendered page of size `render_w × render_h`.
149    ///
150    /// - `page_w`, `page_h` — native page dimensions from the INFO chunk.
151    /// - `rotation` — page rotation from the INFO chunk.
152    /// - `render_w`, `render_h` — the pixel size of the rendered output.
153    ///
154    /// Applies rotation first (in native pixel space), then scales the result
155    /// proportionally to the requested render size.  The text content is
156    /// preserved unchanged.
157    pub fn transform(
158        &self,
159        page_w: u32,
160        page_h: u32,
161        rotation: Rotation,
162        render_w: u32,
163        render_h: u32,
164    ) -> Self {
165        let (disp_w, disp_h) = match rotation {
166            Rotation::Cw90 | Rotation::Ccw90 => (page_h, page_w),
167            _ => (page_w, page_h),
168        };
169        let t = ZoneTransform {
170            page_w,
171            page_h,
172            rotation,
173            disp_w,
174            disp_h,
175            render_w,
176            render_h,
177        };
178        let zones = self.zones.iter().map(|z| transform_zone(z, &t)).collect();
179        TextLayer {
180            text: self.text.clone(),
181            zones,
182        }
183    }
184
185    /// Group the page text into reading-order paragraphs (#228).
186    ///
187    /// Uses the DjVu zone separator characters carried in `self.text`:
188    ///
189    /// - `\x00` (NUL), `\x0b` (VT), `\x1d` (GS), `\x1f` (US) — paragraph /
190    ///   region / column / page boundary. Each starts a new [`Paragraph`].
191    /// - `\x0a` (LF) — line break within a paragraph.
192    ///
193    /// Line joining: trailing whitespace is dropped from each line. If a line
194    /// ends with `-` and the next line starts with an ASCII lowercase letter,
195    /// the hyphen is dropped and the lines are joined with no separator
196    /// (soft-hyphen at line end). Otherwise lines are joined with a single
197    /// ASCII space.
198    ///
199    /// Empty paragraphs (only whitespace) are skipped. The returned vector
200    /// preserves zone-stream order — for the typical OCR'd single-column page
201    /// this is reading order.
202    pub fn reflowable_text(&self) -> Vec<Paragraph> {
203        let mut out = Vec::new();
204        for chunk in self
205            .text
206            .split(['\u{0000}', '\u{000b}', '\u{001d}', '\u{001f}'])
207        {
208            let lines: Vec<String> = chunk
209                .split('\n')
210                .map(|l| l.trim().to_string())
211                .filter(|l| !l.is_empty())
212                .collect();
213            if lines.is_empty() {
214                continue;
215            }
216            let text = join_paragraph_lines(&lines);
217            out.push(Paragraph { lines, text });
218        }
219        out
220    }
221}
222
223fn join_paragraph_lines(lines: &[String]) -> String {
224    let mut out = String::new();
225    for (i, line) in lines.iter().enumerate() {
226        if i == 0 {
227            out.push_str(line);
228            continue;
229        }
230        let prev_hyphen =
231            out.ends_with('-') && line.chars().next().is_some_and(|c| c.is_ascii_lowercase());
232        if prev_hyphen {
233            out.pop();
234            out.push_str(line);
235        } else {
236            out.push(' ');
237            out.push_str(line);
238        }
239    }
240    out
241}
242
243// ---- Rect methods -----------------------------------------------------------
244
245impl Rect {
246    /// Rotate this rectangle within a `page_w × page_h` native coordinate space.
247    ///
248    /// Coordinates are in top-left origin.  Returns the transformed rect in the
249    /// rotated display space (which has dimensions `page_h × page_w` for 90°
250    /// rotations and `page_w × page_h` for 0°/180°).
251    pub fn rotate(&self, page_w: u32, page_h: u32, rotation: Rotation) -> Self {
252        match rotation {
253            Rotation::None => self.clone(),
254            Rotation::Rot180 => Rect {
255                x: page_w.saturating_sub(self.x.saturating_add(self.width)),
256                y: page_h.saturating_sub(self.y.saturating_add(self.height)),
257                width: self.width,
258                height: self.height,
259            },
260            // Clockwise 90°: displayed page is page_h wide × page_w tall.
261            // (x, y, w, h) → (page_h - y - h,  x,  h,  w)
262            Rotation::Cw90 => Rect {
263                x: page_h.saturating_sub(self.y.saturating_add(self.height)),
264                y: self.x,
265                width: self.height,
266                height: self.width,
267            },
268            // Counter-clockwise 90°: displayed page is page_h wide × page_w tall.
269            // (x, y, w, h) → (y,  page_w - x - w,  h,  w)
270            Rotation::Ccw90 => Rect {
271                x: self.y,
272                y: page_w.saturating_sub(self.x.saturating_add(self.width)),
273                width: self.height,
274                height: self.width,
275            },
276        }
277    }
278
279    /// Scale this rectangle from a `from_w × from_h` space to `to_w × to_h`.
280    pub fn scale(&self, from_w: u32, from_h: u32, to_w: u32, to_h: u32) -> Self {
281        if from_w == 0 || from_h == 0 {
282            return self.clone();
283        }
284        Rect {
285            x: (self.x as u64 * to_w as u64 / from_w as u64) as u32,
286            y: (self.y as u64 * to_h as u64 / from_h as u64) as u32,
287            width: (self.width as u64 * to_w as u64 / from_w as u64) as u32,
288            height: (self.height as u64 * to_h as u64 / from_h as u64) as u32,
289        }
290    }
291}
292
293// ---- Private zone transform helpers -----------------------------------------
294
295/// Parameters for `transform_zone` — groups the 7 invariants so we stay
296/// under clippy's `too_many_arguments` limit.
297struct ZoneTransform {
298    page_w: u32,
299    page_h: u32,
300    rotation: Rotation,
301    disp_w: u32,
302    disp_h: u32,
303    render_w: u32,
304    render_h: u32,
305}
306
307fn transform_zone(zone: &TextZone, t: &ZoneTransform) -> TextZone {
308    let rotated = zone.rect.rotate(t.page_w, t.page_h, t.rotation);
309    let scaled = rotated.scale(t.disp_w, t.disp_h, t.render_w, t.render_h);
310    let children = zone.children.iter().map(|c| transform_zone(c, t)).collect();
311    TextZone {
312        kind: zone.kind,
313        rect: scaled,
314        text: zone.text.clone(),
315        children,
316    }
317}
318
319// ---- Entry point ------------------------------------------------------------
320
321/// Parse a decoded text-layer payload (the bytes of a `TXTa` chunk, or a
322/// BZZ-decompressed `TXTz` chunk).
323///
324/// This is a pure parser: BZZ decompression for `TXTz` is handled upstream by
325/// [`DjVuPage::chunk_payload`](crate::DjVuPage::chunk_payload).  `page_height`
326/// is used to remap DjVu bottom-left coordinates to top-left.
327pub fn parse_text_layer(data: &[u8], page_height: u32) -> Result<TextLayer, TextError> {
328    parse_text_layer_inner(data, page_height)
329}
330
331// ---- Internal parsing -------------------------------------------------------
332
333fn parse_text_layer_inner(data: &[u8], page_height: u32) -> Result<TextLayer, TextError> {
334    if data.len() < 3 {
335        return Err(TextError::TooShort);
336    }
337
338    let mut pos = 0usize;
339
340    // Read text length (u24be)
341    let text_len = read_u24(data, &mut pos).ok_or(TextError::TooShort)?;
342
343    // Read the text blob. Nominally UTF-8; legacy files carry CP1252 bytes
344    // (#524), so decode leniently instead of aborting the whole layer. Zone
345    // records index this blob by ON-DISK BYTE offset, so when the bytes are
346    // not valid UTF-8 the zones must slice the original bytes (and decode
347    // each slice), never a re-encoded String whose offsets no longer line up.
348    let text_end = pos.checked_add(text_len).ok_or(TextError::TextOverflow)?;
349    if text_end > data.len() {
350        return Err(TextError::TextOverflow);
351    }
352    let text_bytes = data.get(pos..text_end).ok_or(TextError::TextOverflow)?;
353    // One validation pass decides both views (review of #524: don't scan a
354    // multi-megabyte blob twice).
355    let (full_text, text) = match core::str::from_utf8(text_bytes) {
356        Ok(s) => (FullText::Utf8(s), s.to_string()),
357        Err(_) => (
358            FullText::Legacy(text_bytes),
359            crate::lenient_text::decode_lossy(text_bytes).into_owned(),
360        ),
361    };
362    pos = text_end;
363
364    // Consume version byte (if present)
365    if pos < data.len() {
366        pos += 1; // version byte — currently unused
367    }
368
369    // Parse zone tree
370    let mut zones = Vec::new();
371    if pos < data.len() {
372        let zone = parse_zone(data, &mut pos, None, None, &full_text, page_height, 0)?;
373        zones.push(zone);
374    }
375
376    Ok(TextLayer { text, zones })
377}
378
379/// The page's text blob as zone parsing sees it (#524).
380///
381/// Zone records address the text by on-disk byte offset, so the two variants
382/// preserve exact offsets in both worlds: valid UTF-8 slices the `&str`
383/// directly (with char-boundary clamping), legacy bytes are sliced raw and
384/// each slice is decoded leniently on extraction.
385enum FullText<'a> {
386    Utf8(&'a str),
387    Legacy(&'a [u8]),
388}
389
390// ---- Zone parsing -----------------------------------------------------------
391
392/// Delta-encoding context carried from one zone parse to the next.
393#[derive(Clone)]
394struct ZoneCtx {
395    x: i32,
396    y: i32, // bottom-left y (DjVu native)
397    width: i32,
398    height: i32,
399    text_start: i32,
400    text_len: i32,
401}
402
403fn parse_zone(
404    data: &[u8],
405    pos: &mut usize,
406    parent: Option<&ZoneCtx>,
407    prev: Option<&ZoneCtx>,
408    full_text: &FullText<'_>,
409    page_height: u32,
410    depth: usize,
411) -> Result<TextZone, TextError> {
412    if depth > MAX_ZONE_DEPTH {
413        return Err(TextError::ZoneTooDeep);
414    }
415    if *pos >= data.len() {
416        return Err(TextError::ZoneTruncated(*pos));
417    }
418
419    let type_byte = *data.get(*pos).ok_or(TextError::ZoneTruncated(*pos))?;
420    *pos += 1;
421
422    let kind = match type_byte {
423        1 => TextZoneKind::Page,
424        2 => TextZoneKind::Column,
425        3 => TextZoneKind::Region,
426        4 => TextZoneKind::Para,
427        5 => TextZoneKind::Line,
428        6 => TextZoneKind::Word,
429        7 => TextZoneKind::Character,
430        other => return Err(TextError::UnknownZoneType(other)),
431    };
432
433    let mut x = read_i16_biased(data, pos).ok_or(TextError::ZoneTruncated(*pos))?;
434    let mut y = read_i16_biased(data, pos).ok_or(TextError::ZoneTruncated(*pos))?;
435    let width = read_i16_biased(data, pos).ok_or(TextError::ZoneTruncated(*pos))?;
436    let height = read_i16_biased(data, pos).ok_or(TextError::ZoneTruncated(*pos))?;
437    let mut text_start = read_i16_biased(data, pos).ok_or(TextError::ZoneTruncated(*pos))?;
438    let text_len = read_i24(data, pos).ok_or(TextError::ZoneTruncated(*pos))?;
439
440    // Apply delta encoding (matches djvujs DjVuText.js decodeZone logic)
441    if let Some(prev) = prev {
442        match type_byte {
443            1 | 4 | 5 => {
444                // PAGE, PARAGRAPH, LINE
445                x += prev.x;
446                y = prev.y - (y + height);
447            }
448            _ => {
449                // COLUMN, REGION, WORD, CHARACTER
450                x += prev.x + prev.width;
451                y += prev.y;
452            }
453        }
454        text_start += prev.text_start + prev.text_len;
455    } else if let Some(parent) = parent {
456        x += parent.x;
457        y = parent.y + parent.height - (y + height);
458        text_start += parent.text_start;
459    }
460
461    // Remap y from DjVu bottom-left to top-left
462    // top_left_y = page_height - (bl_y + height)
463    let tl_y = (page_height as i32)
464        .saturating_sub(y.saturating_add(height))
465        .max(0) as u32;
466    let tl_x = x.max(0) as u32;
467    let tl_w = width.max(0) as u32;
468    let tl_h = height.max(0) as u32;
469
470    let rect = Rect {
471        x: tl_x,
472        y: tl_y,
473        width: tl_w,
474        height: tl_h,
475    };
476
477    // Extract zone text
478    let ts = text_start.max(0) as usize;
479    let tl = text_len.max(0) as usize;
480    let zone_text = extract_text_slice(full_text, ts, tl);
481
482    let children_count = read_i24(data, pos)
483        .ok_or(TextError::ZoneTruncated(*pos))?
484        .max(0) as usize;
485    // Cap the up-front reservation to what the remaining bytes could actually
486    // encode (#589): each child needs >= MIN_ZONE_RECORD_BYTES, so a crafted
487    // `children_count` (i24, up to ~16.7M) can no longer reserve ~1.5 GB
488    // before any child is read. The loop below still iterates the full
489    // `children_count` and fails with `ZoneTruncated` on the first missing
490    // child, so genuinely-truncated files error exactly as before — only the
491    // allocation is bounded to O(remaining input).
492    let remaining = data.len().saturating_sub(*pos);
493    let reserve = children_count.min(remaining / MIN_ZONE_RECORD_BYTES);
494
495    let ctx = ZoneCtx {
496        x,
497        y,
498        width,
499        height,
500        text_start,
501        text_len,
502    };
503
504    let mut children = Vec::with_capacity(reserve);
505    let mut prev_child: Option<ZoneCtx> = None;
506
507    for _ in 0..children_count {
508        let child = parse_zone(
509            data,
510            pos,
511            Some(&ctx),
512            prev_child.as_ref(),
513            full_text,
514            page_height,
515            depth + 1,
516        )?;
517        prev_child = Some(ZoneCtx {
518            x: child.rect.x as i32,
519            y: {
520                // We need to store the original bottom-left y for delta calc.
521                // Inverse remap: bl_y = page_height - (tl_y + height)
522                (page_height as i32).saturating_sub(child.rect.y as i32 + child.rect.height as i32)
523            },
524            width: child.rect.width as i32,
525            height: child.rect.height as i32,
526            text_start: ts as i32,
527            text_len: tl as i32,
528        });
529        children.push(child);
530    }
531
532    Ok(TextZone {
533        kind,
534        rect,
535        text: zone_text,
536        children,
537    })
538}
539
540/// Extract a substring from `full_text` starting at byte offset `start` with byte length `len`.
541///
542/// UTF-8 text clamps to valid char boundaries to avoid panics on multi-byte
543/// chars. Legacy (non-UTF-8) text slices the on-disk bytes exactly and
544/// decodes the slice leniently (#524). Known tradeoff: when a legacy blob
545/// contains a valid multi-byte UTF-8 run and a zone edge lands inside it,
546/// that zone's text decodes the split bytes as CP1252 and can genuinely
547/// differ from the same region of [`TextLayer::text`] (e.g. a zone covering
548/// the second byte of "é" reads "©"). Byte-exact offsets for the dominant
549/// pure-CP1252 case are worth that edge; no panic, no out-of-bounds either
550/// way. Pinned by `test_legacy_zone_split_of_utf8_run_diverges`.
551fn extract_text_slice(full_text: &FullText<'_>, start: usize, len: usize) -> String {
552    match *full_text {
553        FullText::Utf8(text) => {
554            let end = start.saturating_add(len).min(text.len());
555            let start = start.min(end);
556            // Walk back to a valid char boundary
557            let safe_start = (0..=start)
558                .rev()
559                .find(|&i| text.is_char_boundary(i))
560                .unwrap_or(0);
561            let safe_end = (end..=text.len())
562                .find(|&i| text.is_char_boundary(i))
563                .unwrap_or(text.len());
564            text[safe_start..safe_end].to_string()
565        }
566        FullText::Legacy(bytes) => {
567            let end = start.saturating_add(len).min(bytes.len());
568            let start = start.min(end);
569            crate::lenient_text::decode_lossy(&bytes[start..end]).into_owned()
570        }
571    }
572}
573
574// ---- Low-level readers (no indexing, no unwrap) -----------------------------
575
576/// Read 3 bytes as a u24 big-endian value; advance `pos` by 3. Returns None if truncated.
577fn read_u24(data: &[u8], pos: &mut usize) -> Option<usize> {
578    let b0 = *data.get(*pos)?;
579    let b1 = *data.get(*pos + 1)?;
580    let b2 = *data.get(*pos + 2)?;
581    *pos += 3;
582    Some(((b0 as usize) << 16) | ((b1 as usize) << 8) | (b2 as usize))
583}
584
585/// Read 2 bytes as a biased i16 (raw u16 − 0x8000). Returns None if truncated.
586fn read_i16_biased(data: &[u8], pos: &mut usize) -> Option<i32> {
587    let b0 = *data.get(*pos)?;
588    let b1 = *data.get(*pos + 1)?;
589    *pos += 2;
590    let raw = u16::from_be_bytes([b0, b1]);
591    Some(raw as i32 - 0x8000)
592}
593
594/// Read 3 bytes as a signed i24 big-endian. Returns None if truncated.
595fn read_i24(data: &[u8], pos: &mut usize) -> Option<i32> {
596    let b0 = *data.get(*pos)? as i32;
597    let b1 = *data.get(*pos + 1)? as i32;
598    let b2 = *data.get(*pos + 2)? as i32;
599    *pos += 3;
600    Some((b0 << 16) | (b1 << 8) | b2)
601}
602
603#[cfg(test)]
604mod tests {
605    use super::*;
606
607    // ── Low-level reader tests ──────────────────────────────────────────────
608
609    #[test]
610    fn test_read_u24() {
611        let data = [0x01, 0x02, 0x03];
612        let mut pos = 0;
613        assert_eq!(read_u24(&data, &mut pos), Some(0x010203));
614        assert_eq!(pos, 3);
615    }
616
617    #[test]
618    fn test_read_u24_truncated() {
619        let data = [0x01, 0x02];
620        let mut pos = 0;
621        assert_eq!(read_u24(&data, &mut pos), None);
622    }
623
624    #[test]
625    fn test_read_i16_biased() {
626        let data = [0x80, 0x00]; // 0x8000 - 0x8000 = 0
627        let mut pos = 0;
628        assert_eq!(read_i16_biased(&data, &mut pos), Some(0));
629        assert_eq!(pos, 2);
630    }
631
632    #[test]
633    fn test_read_i16_biased_negative() {
634        let data = [0x00, 0x00]; // 0x0000 - 0x8000 = -32768
635        let mut pos = 0;
636        assert_eq!(read_i16_biased(&data, &mut pos), Some(-0x8000));
637    }
638
639    #[test]
640    fn test_read_i16_biased_truncated() {
641        let data = [0x80];
642        let mut pos = 0;
643        assert_eq!(read_i16_biased(&data, &mut pos), None);
644    }
645
646    #[test]
647    fn test_read_i24() {
648        let data = [0x00, 0x01, 0x00];
649        let mut pos = 0;
650        assert_eq!(read_i24(&data, &mut pos), Some(256));
651    }
652
653    // ── extract_text_slice ──────────────────────────────────────────────────
654
655    #[test]
656    fn test_extract_text_slice_basic() {
657        let t = FullText::Utf8("hello world");
658        assert_eq!(extract_text_slice(&t, 0, 5), "hello");
659        assert_eq!(extract_text_slice(&t, 6, 5), "world");
660    }
661
662    #[test]
663    fn test_extract_text_slice_out_of_bounds() {
664        let t = FullText::Utf8("hello");
665        assert_eq!(extract_text_slice(&t, 10, 5), "");
666        assert_eq!(extract_text_slice(&t, 0, 100), "hello");
667    }
668
669    #[test]
670    fn test_extract_text_slice_utf8_boundary() {
671        // Multi-byte char: each char is 2 bytes
672        let s = FullText::Utf8("\u{00e9}\u{00e8}"); // é è — 2 bytes each
673        // Slicing at byte 1 (mid-char) should snap to boundary
674        let result = extract_text_slice(&s, 1, 2);
675        assert!(result.is_char_boundary(0));
676    }
677
678    #[test]
679    fn test_extract_text_slice_empty() {
680        assert_eq!(extract_text_slice(&FullText::Utf8(""), 0, 0), "");
681        assert_eq!(extract_text_slice(&FullText::Utf8("abc"), 1, 0), "");
682    }
683
684    #[test]
685    fn test_legacy_zone_split_of_utf8_run_diverges() {
686        // Documented tradeoff (#524 review): blob = valid UTF-8 "é" + "A" +
687        // stray 0x96 → whole blob is invalid, Legacy mode. A zone edge inside
688        // the "é" run decodes the split byte as CP1252 ("©") and differs from
689        // the whole-text view ("éA–"). This pins the tradeoff so a future
690        // change that alters it does so deliberately.
691        let bytes = [0xC3, 0xA9, b'A', 0x96];
692        let t = FullText::Legacy(&bytes);
693        assert_eq!(
694            crate::lenient_text::decode_lossy(&bytes).as_ref(),
695            "\u{E9}A\u{2013}"
696        );
697        assert_eq!(extract_text_slice(&t, 1, 1), "\u{A9}");
698        // Aligned slices still decode cleanly.
699        assert_eq!(extract_text_slice(&t, 0, 2), "\u{E9}");
700        assert_eq!(extract_text_slice(&t, 3, 1), "\u{2013}");
701    }
702
703    #[test]
704    fn test_extract_text_slice_legacy_exact_offsets() {
705        // CP1252 bytes: zone offsets address the raw bytes, one byte per char.
706        let bytes = b"a\x96b\x97c";
707        let t = FullText::Legacy(bytes);
708        assert_eq!(extract_text_slice(&t, 1, 1), "\u{2013}");
709        assert_eq!(extract_text_slice(&t, 3, 1), "\u{2014}");
710        assert_eq!(extract_text_slice(&t, 0, 5), "a\u{2013}b\u{2014}c");
711        assert_eq!(extract_text_slice(&t, 10, 3), "");
712    }
713
714    // ── Error paths ─────────────────────────────────────────────────────────
715
716    #[test]
717    fn test_too_short_data() {
718        assert!(matches!(
719            parse_text_layer(&[0x00], 100),
720            Err(TextError::TooShort)
721        ));
722        assert!(matches!(
723            parse_text_layer(&[], 100),
724            Err(TextError::TooShort)
725        ));
726    }
727
728    #[test]
729    fn test_text_overflow() {
730        // text_len = 0x00_00_FF (255) but only 3+1 bytes available
731        let data = [0x00, 0x00, 0xFF, 0x41];
732        assert!(matches!(
733            parse_text_layer(&data, 100),
734            Err(TextError::TextOverflow)
735        ));
736    }
737
738    #[test]
739    fn test_invalid_utf8_decodes_leniently() {
740        // text_len = 2, then 2 bytes that are not valid UTF-8. Legacy CP1252
741        // text must not abort the layer (#524): 0xFF = ÿ, 0xFE = þ.
742        let data = [0x00, 0x00, 0x02, 0xFF, 0xFE];
743        let result = parse_text_layer(&data, 100).unwrap();
744        assert_eq!(result.text, "ÿþ");
745        assert!(result.zones.is_empty());
746    }
747
748    #[test]
749    fn test_cp1252_text_zone_offsets_stay_exact() {
750        // CP1252 text "a\x96b" with a Page zone covering all 3 on-disk bytes.
751        // Offsets address the original bytes, so the zone text must decode to
752        // the full "a–b" even though the decoded String is 5 bytes (#524).
753        let data = [
754            0x00, 0x00, 0x03, // text_len = 3
755            b'a', 0x96, b'b', // CP1252 text (0x96 = en dash)
756            0x00, // version
757            0x01, // zone type = Page
758            0x80, 0x00, // x = 0 (biased)
759            0x80, 0x00, // y = 0
760            0x80, 0x03, // width = 3
761            0x80, 0x0A, // height = 10
762            0x80, 0x00, // text_start = 0 (biased)
763            0x00, 0x00, 0x03, // text_len = 3 (i24)
764            0x00, 0x00, 0x00, // children = 0 (i24)
765        ];
766        let result = parse_text_layer(&data, 100).unwrap();
767        assert_eq!(result.text, "a\u{2013}b");
768        assert_eq!(result.zones.len(), 1);
769        assert_eq!(result.zones[0].text, "a\u{2013}b");
770    }
771
772    #[test]
773    fn test_unknown_zone_type() {
774        // text_len=1, text="A", version=0, then zone type=99 (invalid)
775        let data = [
776            0x00, 0x00, 0x01, // text_len = 1
777            b'A', // text
778            0x00, // version
779            99,   // invalid zone type
780        ];
781        assert!(matches!(
782            parse_text_layer(&data, 100),
783            Err(TextError::UnknownZoneType(99))
784        ));
785    }
786
787    #[test]
788    fn test_zone_truncated() {
789        // text_len=1, text="A", version=0, zone type=1 (Page), then truncated
790        let data = [
791            0x00, 0x00, 0x01, // text_len = 1
792            b'A', // text
793            0x00, // version
794            0x01, // zone type = Page
795            0x80, 0x00, // x (only partial fields)
796        ];
797        assert!(matches!(
798            parse_text_layer(&data, 100),
799            Err(TextError::ZoneTruncated(_))
800        ));
801    }
802
803    // ── Successful parse ────────────────────────────────────────────────────
804
805    #[test]
806    fn test_empty_text_no_zones() {
807        // text_len=0, no zones after that
808        let data = [0x00, 0x00, 0x00];
809        let result = parse_text_layer(&data, 100).unwrap();
810        assert_eq!(result.text, "");
811        assert!(result.zones.is_empty());
812    }
813
814    #[test]
815    fn test_text_only_no_zones() {
816        // text_len=5, text="Hello", version byte, then no zone data
817        let data = [
818            0x00, 0x00, 0x05, // text_len = 5
819            b'H', b'e', b'l', b'l', b'o', // text
820            0x00, // version
821        ];
822        let result = parse_text_layer(&data, 100).unwrap();
823        assert_eq!(result.text, "Hello");
824        assert!(result.zones.is_empty());
825    }
826
827    // ── TextLayer::transform ─────────────────────────────────────────────────
828
829    fn make_layer(x: u32, y: u32, w: u32, h: u32) -> TextLayer {
830        TextLayer {
831            text: "test".to_string(),
832            zones: vec![TextZone {
833                kind: TextZoneKind::Page,
834                rect: Rect {
835                    x,
836                    y,
837                    width: w,
838                    height: h,
839                },
840                text: "test".to_string(),
841                children: vec![],
842            }],
843        }
844    }
845
846    fn rect0(layer: &TextLayer) -> &Rect {
847        &layer.zones[0].rect
848    }
849
850    #[test]
851    fn transform_none_identity() {
852        use crate::info::Rotation;
853        // No rotation, 1:1 scale — rects unchanged
854        let layer = make_layer(10, 20, 30, 40);
855        let out = layer.transform(100, 200, Rotation::None, 100, 200);
856        assert_eq!(
857            *rect0(&out),
858            Rect {
859                x: 10,
860                y: 20,
861                width: 30,
862                height: 40
863            }
864        );
865    }
866
867    #[test]
868    fn transform_none_scale_2x() {
869        use crate::info::Rotation;
870        let layer = make_layer(10, 20, 30, 40);
871        let out = layer.transform(100, 200, Rotation::None, 200, 400);
872        assert_eq!(
873            *rect0(&out),
874            Rect {
875                x: 20,
876                y: 40,
877                width: 60,
878                height: 80
879            }
880        );
881    }
882
883    #[test]
884    fn transform_rot180() {
885        use crate::info::Rotation;
886        // page 100×200, rect (10, 20, 30, 40)
887        // new_x = 100 - 10 - 30 = 60
888        // new_y = 200 - 20 - 40 = 140
889        let layer = make_layer(10, 20, 30, 40);
890        let out = layer.transform(100, 200, Rotation::Rot180, 100, 200);
891        assert_eq!(
892            *rect0(&out),
893            Rect {
894                x: 60,
895                y: 140,
896                width: 30,
897                height: 40
898            }
899        );
900    }
901
902    #[test]
903    fn transform_cw90() {
904        use crate::info::Rotation;
905        // page 100×200, rect (x=10, y=20, w=30, h=40)
906        // displayed: 200 wide × 100 tall
907        // new_x = page_h - y - h = 200 - 20 - 40 = 140
908        // new_y = x = 10
909        // new_w = h = 40,  new_h = w = 30
910        let layer = make_layer(10, 20, 30, 40);
911        let out = layer.transform(100, 200, Rotation::Cw90, 200, 100);
912        assert_eq!(
913            *rect0(&out),
914            Rect {
915                x: 140,
916                y: 10,
917                width: 40,
918                height: 30
919            }
920        );
921    }
922
923    #[test]
924    fn transform_ccw90() {
925        use crate::info::Rotation;
926        // page 100×200, rect (x=10, y=20, w=30, h=40)
927        // displayed: 200 wide × 100 tall
928        // new_x = y = 20
929        // new_y = page_w - x - w = 100 - 10 - 30 = 60
930        // new_w = h = 40,  new_h = w = 30
931        let layer = make_layer(10, 20, 30, 40);
932        let out = layer.transform(100, 200, Rotation::Ccw90, 200, 100);
933        assert_eq!(
934            *rect0(&out),
935            Rect {
936                x: 20,
937                y: 60,
938                width: 40,
939                height: 30
940            }
941        );
942    }
943
944    #[test]
945    fn transform_cw90_then_scale() {
946        use crate::info::Rotation;
947        // page 100×200, rect (10, 20, 30, 40), render at 2× (400×200)
948        // After Cw90: (140, 10, 40, 30) in 200×100 space
949        // Scale ×2: (280, 20, 80, 60)
950        let layer = make_layer(10, 20, 30, 40);
951        let out = layer.transform(100, 200, Rotation::Cw90, 400, 200);
952        assert_eq!(
953            *rect0(&out),
954            Rect {
955                x: 280,
956                y: 20,
957                width: 80,
958                height: 60
959            }
960        );
961    }
962
963    #[test]
964    fn transform_text_preserved() {
965        use crate::info::Rotation;
966        let layer = make_layer(0, 0, 10, 10);
967        let out = layer.transform(100, 100, Rotation::Cw90, 100, 100);
968        assert_eq!(out.text, "test");
969        assert_eq!(out.zones[0].text, "test");
970    }
971
972    #[test]
973    fn test_single_word_zone() {
974        // Build a minimal text layer with one Page zone containing "Hi"
975        let text = b"Hi";
976        let mut data = Vec::new();
977        // text_len = 2 (u24be)
978        data.extend_from_slice(&[0x00, 0x00, 0x02]);
979        data.extend_from_slice(text);
980        data.push(0x00); // version
981
982        // Page zone (type=1)
983        data.push(0x01);
984        // x=0, y=0, w=100, h=50 (biased i16: value + 0x8000)
985        data.extend_from_slice(&0x8000u16.to_be_bytes()); // x=0
986        data.extend_from_slice(&0x8000u16.to_be_bytes()); // y=0
987        data.extend_from_slice(&(100u16 + 0x8000u16).wrapping_add(0).to_be_bytes()); // w=100
988        let h_val = 50i32 + 0x8000;
989        data.extend_from_slice(&(h_val as u16).to_be_bytes()); // h=50
990        data.extend_from_slice(&0x8000u16.to_be_bytes()); // text_start=0
991        // text_len = 2 (i24)
992        data.extend_from_slice(&[0x00, 0x00, 0x02]);
993        // children_count = 0 (i24)
994        data.extend_from_slice(&[0x00, 0x00, 0x00]);
995
996        let result = parse_text_layer(&data, 100).unwrap();
997        assert_eq!(result.text, "Hi");
998        assert_eq!(result.zones.len(), 1);
999        assert_eq!(result.zones[0].kind, TextZoneKind::Page);
1000        assert_eq!(result.zones[0].text, "Hi");
1001        assert_eq!(result.zones[0].rect.width, 100);
1002        assert_eq!(result.zones[0].rect.height, 50);
1003    }
1004
1005    // ── Paragraph reflow tests (#228) ───────────────────────────────────────
1006
1007    fn layer_with(text: &str) -> TextLayer {
1008        TextLayer {
1009            text: text.to_string(),
1010            zones: Vec::new(),
1011        }
1012    }
1013
1014    #[test]
1015    fn reflowable_text_splits_on_paragraph_separator() {
1016        // Two paragraphs separated by US (\x1f), each with two lines.
1017        let layer = layer_with("first line\nsecond line\u{001f}third line\nfourth line");
1018        let paras = layer.reflowable_text();
1019        assert_eq!(paras.len(), 2);
1020        assert_eq!(paras[0].lines, vec!["first line", "second line"]);
1021        assert_eq!(paras[0].text, "first line second line");
1022        assert_eq!(paras[1].lines, vec!["third line", "fourth line"]);
1023        assert_eq!(paras[1].text, "third line fourth line");
1024    }
1025
1026    #[test]
1027    fn reflowable_text_joins_soft_hyphen() {
1028        // "compre-" + "hensive" → "comprehensive" (lowercase next, hyphen drop).
1029        let layer = layer_with("a compre-\nhensive guide");
1030        let paras = layer.reflowable_text();
1031        assert_eq!(paras.len(), 1);
1032        assert_eq!(paras[0].text, "a comprehensive guide");
1033    }
1034
1035    #[test]
1036    fn reflowable_text_keeps_hyphen_before_uppercase() {
1037        // "Anglo-" + "Saxon" — the hyphen is part of the word, not a soft
1038        // line-break. Uppercase next ⇒ keep the hyphen, replace newline with
1039        // a space.
1040        let layer = layer_with("Anglo-\nSaxon roots");
1041        let paras = layer.reflowable_text();
1042        assert_eq!(paras.len(), 1);
1043        assert_eq!(paras[0].text, "Anglo- Saxon roots");
1044    }
1045
1046    #[test]
1047    fn reflowable_text_treats_all_separator_codes_as_break() {
1048        // NUL, VT, GS, US should each break paragraphs.
1049        let layer = layer_with("a\u{0000}b\u{000b}c\u{001d}d\u{001f}e");
1050        let paras = layer.reflowable_text();
1051        assert_eq!(paras.len(), 5);
1052        assert_eq!(paras[0].text, "a");
1053        assert_eq!(paras[4].text, "e");
1054    }
1055
1056    #[test]
1057    fn reflowable_text_skips_empty_paragraphs() {
1058        let layer = layer_with("\u{001f}\u{001f}only one\u{001f}");
1059        let paras = layer.reflowable_text();
1060        assert_eq!(paras.len(), 1);
1061        assert_eq!(paras[0].text, "only one");
1062    }
1063
1064    #[test]
1065    fn reflowable_text_trims_per_line_whitespace() {
1066        let layer = layer_with("  leading\n  trailing  \n   middle   ");
1067        let paras = layer.reflowable_text();
1068        assert_eq!(paras.len(), 1);
1069        assert_eq!(paras[0].lines, vec!["leading", "trailing", "middle"]);
1070        assert_eq!(paras[0].text, "leading trailing middle");
1071    }
1072
1073    // ── Character zone (type_byte=7) ─────────────────────────────────────────
1074
1075    #[test]
1076    fn parse_character_zone_type_7() {
1077        // text "X", then a Character zone (type=7), no children
1078        let mut data = Vec::new();
1079        data.extend_from_slice(&[0x00, 0x00, 0x01]); // text_len = 1
1080        data.push(b'X'); // text
1081        data.push(0x00); // version
1082        data.push(0x07); // zone type = Character (7)
1083        data.extend_from_slice(&[0x80, 0x00]); // x = 0 (biased)
1084        data.extend_from_slice(&[0x80, 0x00]); // y = 0
1085        data.extend_from_slice(&[0x80, 0x0A]); // w = 10
1086        data.extend_from_slice(&[0x80, 0x14]); // h = 20
1087        data.extend_from_slice(&[0x80, 0x00]); // text_start = 0
1088        data.extend_from_slice(&[0x00, 0x00, 0x01]); // text_len (i24) = 1
1089        data.extend_from_slice(&[0x00, 0x00, 0x00]); // children_count = 0
1090        let result = parse_text_layer(&data, 100).unwrap();
1091        assert_eq!(result.zones.len(), 1);
1092        assert_eq!(result.zones[0].kind, TextZoneKind::Character);
1093    }
1094
1095    // ── ZoneTruncated from recursive child call ──────────────────────────────
1096
1097    #[test]
1098    fn zone_truncated_when_child_data_missing() {
1099        // A Page zone with children_count=1 but no child bytes follow
1100        let mut data = Vec::new();
1101        data.extend_from_slice(&[0x00, 0x00, 0x01]); // text_len = 1
1102        data.push(b'A'); // text
1103        data.push(0x00); // version
1104        data.push(0x01); // zone type = Page (1)
1105        data.extend_from_slice(&[0x80, 0x00]); // x
1106        data.extend_from_slice(&[0x80, 0x00]); // y
1107        data.extend_from_slice(&[0x80, 0x64]); // w = 100
1108        data.extend_from_slice(&[0x80, 0x32]); // h = 50
1109        data.extend_from_slice(&[0x80, 0x00]); // text_start
1110        data.extend_from_slice(&[0x00, 0x00, 0x01]); // text_len (i24) = 1
1111        data.extend_from_slice(&[0x00, 0x00, 0x01]); // children_count = 1 → triggers child
1112        // NO child data → ZoneTruncated
1113        assert!(matches!(
1114            parse_text_layer(&data, 100),
1115            Err(TextError::ZoneTruncated(_))
1116        ));
1117    }
1118
1119    // ── Rect::scale zero-dimension guard ────────────────────────────────────
1120
1121    #[test]
1122    fn rect_scale_zero_from_w_returns_clone() {
1123        let r = Rect {
1124            x: 5,
1125            y: 10,
1126            width: 20,
1127            height: 30,
1128        };
1129        assert_eq!(r.scale(0, 100, 200, 200), r);
1130    }
1131
1132    #[test]
1133    fn rect_scale_zero_from_h_returns_clone() {
1134        let r = Rect {
1135            x: 5,
1136            y: 10,
1137            width: 20,
1138            height: 30,
1139        };
1140        assert_eq!(r.scale(100, 0, 200, 200), r);
1141    }
1142
1143    // ── #589 resource-ceiling regression seeds ────────────────────────────
1144
1145    /// A zone declaring a huge `children_count` (i24) that cannot fit in the
1146    /// remaining bytes is rejected up front — no ~1.5 GB `Vec::with_capacity`.
1147    #[test]
1148    fn zone_child_count_amplification_is_rejected() {
1149        // One Page zone: type=0x00, then 5×2-byte biased coords, 3-byte
1150        // text_len=0, 3-byte children_count = 0xFFFFFF. Then nothing.
1151        // text-layer header: 3-byte text_len = 0, then a version byte
1152        let mut d = vec![0u8, 0, 0, 0];
1153        // zone: type + 5 biased-i16 coords (0x8000 = bias 0) + text_len(0) + children
1154        d.push(0x01);
1155        for _ in 0..5 {
1156            d.extend_from_slice(&[0x80, 0x00]);
1157        }
1158        d.extend_from_slice(&[0, 0, 0]); // text_len = 0
1159        d.extend_from_slice(&[0xFF, 0xFF, 0xFF]); // children_count = 16,777,215
1160        // The huge count is not backed by data, so the parse fails fast with
1161        // ZoneTruncated — and crucially the `Vec::with_capacity` reservation is
1162        // capped to `remaining / MIN_ZONE_RECORD_BYTES` (≈0 here), so no ~1.5 GB
1163        // allocation happens on the way to that error.
1164        let err = parse_text_layer(&d, 1000).unwrap_err();
1165        assert!(
1166            matches!(err, TextError::ZoneTruncated(_)),
1167            "huge child count must fail fast without over-reserving, got {err:?}"
1168        );
1169    }
1170
1171    /// A single-child chain deeper than `MAX_ZONE_DEPTH` errors instead of
1172    /// recursing to a stack overflow.
1173    #[test]
1174    fn zone_depth_is_bounded() {
1175        fn zone_with_one_child(children: u32) -> Vec<u8> {
1176            let mut z = Vec::new();
1177            z.push(0x01); // type
1178            for _ in 0..5 {
1179                z.extend_from_slice(&[0x80, 0x00]); // biased coords = 0
1180            }
1181            z.extend_from_slice(&[0, 0, 0]); // text_len = 0
1182            z.extend_from_slice(&(children).to_be_bytes()[1..4]); // 3-byte children_count
1183            z
1184        }
1185        // Header (text_len=0) + a chain of MAX_ZONE_DEPTH+5 single-child zones,
1186        // deepest one having 0 children.
1187        let mut d = vec![0, 0, 0, 0]; // text_len(0) + version byte
1188        let n = MAX_ZONE_DEPTH + 5;
1189        for i in 0..n {
1190            d.extend_from_slice(&zone_with_one_child(if i + 1 < n { 1 } else { 0 }));
1191        }
1192        let err = parse_text_layer(&d, 1000).unwrap_err();
1193        assert!(
1194            matches!(err, TextError::ZoneTooDeep),
1195            "over-deep chain must error, got {err:?}"
1196        );
1197    }
1198
1199    /// A legitimate shallow tree with a realistic child count still parses.
1200    #[test]
1201    fn zone_normal_tree_still_parses() {
1202        // Page with 2 word children, each 0 grandchildren.
1203        let mut d = vec![0, 0, 0, 0]; // text_len(0) + version byte
1204        d.push(0x01); // Page
1205        for _ in 0..5 {
1206            d.extend_from_slice(&[0x80, 0x00]);
1207        }
1208        d.extend_from_slice(&[0, 0, 0]); // text_len
1209        d.extend_from_slice(&[0, 0, 2]); // 2 children
1210        for _ in 0..2 {
1211            d.push(0x06); // Word
1212            for _ in 0..5 {
1213                d.extend_from_slice(&[0x80, 0x00]);
1214            }
1215            d.extend_from_slice(&[0, 0, 0]); // text_len
1216            d.extend_from_slice(&[0, 0, 0]); // 0 children
1217        }
1218        let tl = parse_text_layer(&d, 1000).unwrap();
1219        assert_eq!(tl.zones.len(), 1);
1220        assert_eq!(tl.zones[0].children.len(), 2);
1221    }
1222}