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)]
37pub enum TextError {
38    /// The binary data is too short to be a valid text layer.
39    #[error("text layer data too short")]
40    TooShort,
41
42    /// A text length field points past the end of the data.
43    #[error("text length overflows data")]
44    TextOverflow,
45
46    /// The text bytes are not valid UTF-8.
47    #[error("invalid UTF-8 in text layer")]
48    InvalidUtf8,
49
50    /// A zone record is truncated (not enough bytes for a field).
51    #[error("zone record truncated at offset {0}")]
52    ZoneTruncated(usize),
53
54    /// An unknown zone type byte was encountered.
55    #[error("unknown zone type {0}")]
56    UnknownZoneType(u8),
57}
58
59// ---- Public types -----------------------------------------------------------
60
61/// Zone type discriminant in the DjVu text layer hierarchy.
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
64pub enum TextZoneKind {
65    Page,
66    Column,
67    Region,
68    Para,
69    Line,
70    Word,
71    Character,
72}
73
74/// Bounding rectangle in top-left-origin coordinates (pixels).
75#[derive(Debug, Clone, PartialEq, Eq)]
76#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
77pub struct Rect {
78    pub x: u32,
79    pub y: u32,
80    pub width: u32,
81    pub height: u32,
82}
83
84/// A single node in the text zone hierarchy.
85#[derive(Debug, Clone)]
86#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
87pub struct TextZone {
88    /// Zone type.
89    pub kind: TextZoneKind,
90    /// Bounding box (top-left origin, after coordinate remap).
91    pub rect: Rect,
92    /// Text covered by this zone (substring of [`TextLayer::text`]).
93    pub text: String,
94    /// Child zones (columns inside page, words inside line, etc.).
95    pub children: Vec<TextZone>,
96}
97
98/// The complete text layer of a DjVu page.
99#[derive(Debug, Clone)]
100#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
101pub struct TextLayer {
102    /// Full plain-text content of the page, UTF-8.
103    pub text: String,
104    /// Top-level zone nodes (usually a single `Page` zone).
105    pub zones: Vec<TextZone>,
106}
107
108/// A reflowable paragraph: the original lines as they appear on the page,
109/// plus a single joined `text` string with line-break and hyphenation rules
110/// applied (see [`TextLayer::reflowable_text`]).
111#[derive(Debug, Clone, PartialEq, Eq)]
112#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
113pub struct Paragraph {
114    /// Each physical line as it appeared on the page (trimmed of trailing
115    /// whitespace, ordered top-to-bottom).
116    pub lines: Vec<String>,
117    /// Lines joined into a single string. Hyphenated line breaks (line ends
118    /// with `-` and next line starts with a lowercase letter) are joined with
119    /// no separator and the hyphen is dropped. Other line breaks become a
120    /// single ASCII space.
121    pub text: String,
122}
123
124// ---- TextLayer methods ------------------------------------------------------
125
126impl TextLayer {
127    /// Return a copy of this text layer with all zone rectangles transformed to
128    /// match a rendered page of size `render_w × render_h`.
129    ///
130    /// - `page_w`, `page_h` — native page dimensions from the INFO chunk.
131    /// - `rotation` — page rotation from the INFO chunk.
132    /// - `render_w`, `render_h` — the pixel size of the rendered output.
133    ///
134    /// Applies rotation first (in native pixel space), then scales the result
135    /// proportionally to the requested render size.  The text content is
136    /// preserved unchanged.
137    pub fn transform(
138        &self,
139        page_w: u32,
140        page_h: u32,
141        rotation: Rotation,
142        render_w: u32,
143        render_h: u32,
144    ) -> Self {
145        let (disp_w, disp_h) = match rotation {
146            Rotation::Cw90 | Rotation::Ccw90 => (page_h, page_w),
147            _ => (page_w, page_h),
148        };
149        let t = ZoneTransform {
150            page_w,
151            page_h,
152            rotation,
153            disp_w,
154            disp_h,
155            render_w,
156            render_h,
157        };
158        let zones = self.zones.iter().map(|z| transform_zone(z, &t)).collect();
159        TextLayer {
160            text: self.text.clone(),
161            zones,
162        }
163    }
164
165    /// Group the page text into reading-order paragraphs (#228).
166    ///
167    /// Uses the DjVu zone separator characters carried in `self.text`:
168    ///
169    /// - `\x00` (NUL), `\x0b` (VT), `\x1d` (GS), `\x1f` (US) — paragraph /
170    ///   region / column / page boundary. Each starts a new [`Paragraph`].
171    /// - `\x0a` (LF) — line break within a paragraph.
172    ///
173    /// Line joining: trailing whitespace is dropped from each line. If a line
174    /// ends with `-` and the next line starts with an ASCII lowercase letter,
175    /// the hyphen is dropped and the lines are joined with no separator
176    /// (soft-hyphen at line end). Otherwise lines are joined with a single
177    /// ASCII space.
178    ///
179    /// Empty paragraphs (only whitespace) are skipped. The returned vector
180    /// preserves zone-stream order — for the typical OCR'd single-column page
181    /// this is reading order.
182    pub fn reflowable_text(&self) -> Vec<Paragraph> {
183        let mut out = Vec::new();
184        for chunk in self
185            .text
186            .split(['\u{0000}', '\u{000b}', '\u{001d}', '\u{001f}'])
187        {
188            let lines: Vec<String> = chunk
189                .split('\n')
190                .map(|l| l.trim().to_string())
191                .filter(|l| !l.is_empty())
192                .collect();
193            if lines.is_empty() {
194                continue;
195            }
196            let text = join_paragraph_lines(&lines);
197            out.push(Paragraph { lines, text });
198        }
199        out
200    }
201}
202
203fn join_paragraph_lines(lines: &[String]) -> String {
204    let mut out = String::new();
205    for (i, line) in lines.iter().enumerate() {
206        if i == 0 {
207            out.push_str(line);
208            continue;
209        }
210        let prev_hyphen =
211            out.ends_with('-') && line.chars().next().is_some_and(|c| c.is_ascii_lowercase());
212        if prev_hyphen {
213            out.pop();
214            out.push_str(line);
215        } else {
216            out.push(' ');
217            out.push_str(line);
218        }
219    }
220    out
221}
222
223// ---- Rect methods -----------------------------------------------------------
224
225impl Rect {
226    /// Rotate this rectangle within a `page_w × page_h` native coordinate space.
227    ///
228    /// Coordinates are in top-left origin.  Returns the transformed rect in the
229    /// rotated display space (which has dimensions `page_h × page_w` for 90°
230    /// rotations and `page_w × page_h` for 0°/180°).
231    pub fn rotate(&self, page_w: u32, page_h: u32, rotation: Rotation) -> Self {
232        match rotation {
233            Rotation::None => self.clone(),
234            Rotation::Rot180 => Rect {
235                x: page_w.saturating_sub(self.x.saturating_add(self.width)),
236                y: page_h.saturating_sub(self.y.saturating_add(self.height)),
237                width: self.width,
238                height: self.height,
239            },
240            // Clockwise 90°: displayed page is page_h wide × page_w tall.
241            // (x, y, w, h) → (page_h - y - h,  x,  h,  w)
242            Rotation::Cw90 => Rect {
243                x: page_h.saturating_sub(self.y.saturating_add(self.height)),
244                y: self.x,
245                width: self.height,
246                height: self.width,
247            },
248            // Counter-clockwise 90°: displayed page is page_h wide × page_w tall.
249            // (x, y, w, h) → (y,  page_w - x - w,  h,  w)
250            Rotation::Ccw90 => Rect {
251                x: self.y,
252                y: page_w.saturating_sub(self.x.saturating_add(self.width)),
253                width: self.height,
254                height: self.width,
255            },
256        }
257    }
258
259    /// Scale this rectangle from a `from_w × from_h` space to `to_w × to_h`.
260    pub fn scale(&self, from_w: u32, from_h: u32, to_w: u32, to_h: u32) -> Self {
261        if from_w == 0 || from_h == 0 {
262            return self.clone();
263        }
264        Rect {
265            x: (self.x as u64 * to_w as u64 / from_w as u64) as u32,
266            y: (self.y as u64 * to_h as u64 / from_h as u64) as u32,
267            width: (self.width as u64 * to_w as u64 / from_w as u64) as u32,
268            height: (self.height as u64 * to_h as u64 / from_h as u64) as u32,
269        }
270    }
271}
272
273// ---- Private zone transform helpers -----------------------------------------
274
275/// Parameters for `transform_zone` — groups the 7 invariants so we stay
276/// under clippy's `too_many_arguments` limit.
277struct ZoneTransform {
278    page_w: u32,
279    page_h: u32,
280    rotation: Rotation,
281    disp_w: u32,
282    disp_h: u32,
283    render_w: u32,
284    render_h: u32,
285}
286
287fn transform_zone(zone: &TextZone, t: &ZoneTransform) -> TextZone {
288    let rotated = zone.rect.rotate(t.page_w, t.page_h, t.rotation);
289    let scaled = rotated.scale(t.disp_w, t.disp_h, t.render_w, t.render_h);
290    let children = zone.children.iter().map(|c| transform_zone(c, t)).collect();
291    TextZone {
292        kind: zone.kind,
293        rect: scaled,
294        text: zone.text.clone(),
295        children,
296    }
297}
298
299// ---- Entry point ------------------------------------------------------------
300
301/// Parse a decoded text-layer payload (the bytes of a `TXTa` chunk, or a
302/// BZZ-decompressed `TXTz` chunk).
303///
304/// This is a pure parser: BZZ decompression for `TXTz` is handled upstream by
305/// [`DjVuPage::chunk_payload`](crate::DjVuPage::chunk_payload).  `page_height`
306/// is used to remap DjVu bottom-left coordinates to top-left.
307pub fn parse_text_layer(data: &[u8], page_height: u32) -> Result<TextLayer, TextError> {
308    parse_text_layer_inner(data, page_height)
309}
310
311// ---- Internal parsing -------------------------------------------------------
312
313fn parse_text_layer_inner(data: &[u8], page_height: u32) -> Result<TextLayer, TextError> {
314    if data.len() < 3 {
315        return Err(TextError::TooShort);
316    }
317
318    let mut pos = 0usize;
319
320    // Read text length (u24be)
321    let text_len = read_u24(data, &mut pos).ok_or(TextError::TooShort)?;
322
323    // Read UTF-8 text
324    let text_end = pos.checked_add(text_len).ok_or(TextError::TextOverflow)?;
325    if text_end > data.len() {
326        return Err(TextError::TextOverflow);
327    }
328    let text = core::str::from_utf8(data.get(pos..text_end).ok_or(TextError::TextOverflow)?)
329        .map_err(|_| TextError::InvalidUtf8)?
330        .to_string();
331    pos = text_end;
332
333    // Consume version byte (if present)
334    if pos < data.len() {
335        pos += 1; // version byte — currently unused
336    }
337
338    // Parse zone tree
339    let mut zones = Vec::new();
340    if pos < data.len() {
341        let zone = parse_zone(data, &mut pos, None, None, &text, page_height)?;
342        zones.push(zone);
343    }
344
345    Ok(TextLayer { text, zones })
346}
347
348// ---- Zone parsing -----------------------------------------------------------
349
350/// Delta-encoding context carried from one zone parse to the next.
351#[derive(Clone)]
352struct ZoneCtx {
353    x: i32,
354    y: i32, // bottom-left y (DjVu native)
355    width: i32,
356    height: i32,
357    text_start: i32,
358    text_len: i32,
359}
360
361fn parse_zone(
362    data: &[u8],
363    pos: &mut usize,
364    parent: Option<&ZoneCtx>,
365    prev: Option<&ZoneCtx>,
366    full_text: &str,
367    page_height: u32,
368) -> Result<TextZone, TextError> {
369    if *pos >= data.len() {
370        return Err(TextError::ZoneTruncated(*pos));
371    }
372
373    let type_byte = *data.get(*pos).ok_or(TextError::ZoneTruncated(*pos))?;
374    *pos += 1;
375
376    let kind = match type_byte {
377        1 => TextZoneKind::Page,
378        2 => TextZoneKind::Column,
379        3 => TextZoneKind::Region,
380        4 => TextZoneKind::Para,
381        5 => TextZoneKind::Line,
382        6 => TextZoneKind::Word,
383        7 => TextZoneKind::Character,
384        other => return Err(TextError::UnknownZoneType(other)),
385    };
386
387    let mut x = read_i16_biased(data, pos).ok_or(TextError::ZoneTruncated(*pos))?;
388    let mut y = read_i16_biased(data, pos).ok_or(TextError::ZoneTruncated(*pos))?;
389    let width = read_i16_biased(data, pos).ok_or(TextError::ZoneTruncated(*pos))?;
390    let height = read_i16_biased(data, pos).ok_or(TextError::ZoneTruncated(*pos))?;
391    let mut text_start = read_i16_biased(data, pos).ok_or(TextError::ZoneTruncated(*pos))?;
392    let text_len = read_i24(data, pos).ok_or(TextError::ZoneTruncated(*pos))?;
393
394    // Apply delta encoding (matches djvujs DjVuText.js decodeZone logic)
395    if let Some(prev) = prev {
396        match type_byte {
397            1 | 4 | 5 => {
398                // PAGE, PARAGRAPH, LINE
399                x += prev.x;
400                y = prev.y - (y + height);
401            }
402            _ => {
403                // COLUMN, REGION, WORD, CHARACTER
404                x += prev.x + prev.width;
405                y += prev.y;
406            }
407        }
408        text_start += prev.text_start + prev.text_len;
409    } else if let Some(parent) = parent {
410        x += parent.x;
411        y = parent.y + parent.height - (y + height);
412        text_start += parent.text_start;
413    }
414
415    // Remap y from DjVu bottom-left to top-left
416    // top_left_y = page_height - (bl_y + height)
417    let tl_y = (page_height as i32)
418        .saturating_sub(y.saturating_add(height))
419        .max(0) as u32;
420    let tl_x = x.max(0) as u32;
421    let tl_w = width.max(0) as u32;
422    let tl_h = height.max(0) as u32;
423
424    let rect = Rect {
425        x: tl_x,
426        y: tl_y,
427        width: tl_w,
428        height: tl_h,
429    };
430
431    // Extract zone text
432    let ts = text_start.max(0) as usize;
433    let tl = text_len.max(0) as usize;
434    let zone_text = extract_text_slice(full_text, ts, tl);
435
436    let children_count = read_i24(data, pos)
437        .ok_or(TextError::ZoneTruncated(*pos))?
438        .max(0) as usize;
439
440    let ctx = ZoneCtx {
441        x,
442        y,
443        width,
444        height,
445        text_start,
446        text_len,
447    };
448
449    let mut children = Vec::with_capacity(children_count);
450    let mut prev_child: Option<ZoneCtx> = None;
451
452    for _ in 0..children_count {
453        let child = parse_zone(
454            data,
455            pos,
456            Some(&ctx),
457            prev_child.as_ref(),
458            full_text,
459            page_height,
460        )?;
461        prev_child = Some(ZoneCtx {
462            x: child.rect.x as i32,
463            y: {
464                // We need to store the original bottom-left y for delta calc.
465                // Inverse remap: bl_y = page_height - (tl_y + height)
466                (page_height as i32).saturating_sub(child.rect.y as i32 + child.rect.height as i32)
467            },
468            width: child.rect.width as i32,
469            height: child.rect.height as i32,
470            text_start: ts as i32,
471            text_len: tl as i32,
472        });
473        children.push(child);
474    }
475
476    Ok(TextZone {
477        kind,
478        rect,
479        text: zone_text,
480        children,
481    })
482}
483
484/// Extract a substring from `full_text` starting at byte offset `start` with byte length `len`.
485///
486/// Clamps to valid char boundaries to avoid panics on multi-byte UTF-8.
487fn extract_text_slice(full_text: &str, start: usize, len: usize) -> String {
488    let end = start.saturating_add(len).min(full_text.len());
489    let start = start.min(end);
490    // Walk back to a valid char boundary
491    let safe_start = (0..=start)
492        .rev()
493        .find(|&i| full_text.is_char_boundary(i))
494        .unwrap_or(0);
495    let safe_end = (end..=full_text.len())
496        .find(|&i| full_text.is_char_boundary(i))
497        .unwrap_or(full_text.len());
498    full_text[safe_start..safe_end].to_string()
499}
500
501// ---- Low-level readers (no indexing, no unwrap) -----------------------------
502
503/// Read 3 bytes as a u24 big-endian value; advance `pos` by 3. Returns None if truncated.
504fn read_u24(data: &[u8], pos: &mut usize) -> Option<usize> {
505    let b0 = *data.get(*pos)?;
506    let b1 = *data.get(*pos + 1)?;
507    let b2 = *data.get(*pos + 2)?;
508    *pos += 3;
509    Some(((b0 as usize) << 16) | ((b1 as usize) << 8) | (b2 as usize))
510}
511
512/// Read 2 bytes as a biased i16 (raw u16 − 0x8000). Returns None if truncated.
513fn read_i16_biased(data: &[u8], pos: &mut usize) -> Option<i32> {
514    let b0 = *data.get(*pos)?;
515    let b1 = *data.get(*pos + 1)?;
516    *pos += 2;
517    let raw = u16::from_be_bytes([b0, b1]);
518    Some(raw as i32 - 0x8000)
519}
520
521/// Read 3 bytes as a signed i24 big-endian. Returns None if truncated.
522fn read_i24(data: &[u8], pos: &mut usize) -> Option<i32> {
523    let b0 = *data.get(*pos)? as i32;
524    let b1 = *data.get(*pos + 1)? as i32;
525    let b2 = *data.get(*pos + 2)? as i32;
526    *pos += 3;
527    Some((b0 << 16) | (b1 << 8) | b2)
528}
529
530#[cfg(test)]
531mod tests {
532    use super::*;
533
534    // ── Low-level reader tests ──────────────────────────────────────────────
535
536    #[test]
537    fn test_read_u24() {
538        let data = [0x01, 0x02, 0x03];
539        let mut pos = 0;
540        assert_eq!(read_u24(&data, &mut pos), Some(0x010203));
541        assert_eq!(pos, 3);
542    }
543
544    #[test]
545    fn test_read_u24_truncated() {
546        let data = [0x01, 0x02];
547        let mut pos = 0;
548        assert_eq!(read_u24(&data, &mut pos), None);
549    }
550
551    #[test]
552    fn test_read_i16_biased() {
553        let data = [0x80, 0x00]; // 0x8000 - 0x8000 = 0
554        let mut pos = 0;
555        assert_eq!(read_i16_biased(&data, &mut pos), Some(0));
556        assert_eq!(pos, 2);
557    }
558
559    #[test]
560    fn test_read_i16_biased_negative() {
561        let data = [0x00, 0x00]; // 0x0000 - 0x8000 = -32768
562        let mut pos = 0;
563        assert_eq!(read_i16_biased(&data, &mut pos), Some(-0x8000));
564    }
565
566    #[test]
567    fn test_read_i16_biased_truncated() {
568        let data = [0x80];
569        let mut pos = 0;
570        assert_eq!(read_i16_biased(&data, &mut pos), None);
571    }
572
573    #[test]
574    fn test_read_i24() {
575        let data = [0x00, 0x01, 0x00];
576        let mut pos = 0;
577        assert_eq!(read_i24(&data, &mut pos), Some(256));
578    }
579
580    // ── extract_text_slice ──────────────────────────────────────────────────
581
582    #[test]
583    fn test_extract_text_slice_basic() {
584        assert_eq!(extract_text_slice("hello world", 0, 5), "hello");
585        assert_eq!(extract_text_slice("hello world", 6, 5), "world");
586    }
587
588    #[test]
589    fn test_extract_text_slice_out_of_bounds() {
590        assert_eq!(extract_text_slice("hello", 10, 5), "");
591        assert_eq!(extract_text_slice("hello", 0, 100), "hello");
592    }
593
594    #[test]
595    fn test_extract_text_slice_utf8_boundary() {
596        // Multi-byte char: each char is 2 bytes
597        let s = "\u{00e9}\u{00e8}"; // é è — 2 bytes each
598        // Slicing at byte 1 (mid-char) should snap to boundary
599        let result = extract_text_slice(s, 1, 2);
600        assert!(result.is_char_boundary(0));
601    }
602
603    #[test]
604    fn test_extract_text_slice_empty() {
605        assert_eq!(extract_text_slice("", 0, 0), "");
606        assert_eq!(extract_text_slice("abc", 1, 0), "");
607    }
608
609    // ── Error paths ─────────────────────────────────────────────────────────
610
611    #[test]
612    fn test_too_short_data() {
613        assert!(matches!(
614            parse_text_layer(&[0x00], 100),
615            Err(TextError::TooShort)
616        ));
617        assert!(matches!(
618            parse_text_layer(&[], 100),
619            Err(TextError::TooShort)
620        ));
621    }
622
623    #[test]
624    fn test_text_overflow() {
625        // text_len = 0x00_00_FF (255) but only 3+1 bytes available
626        let data = [0x00, 0x00, 0xFF, 0x41];
627        assert!(matches!(
628            parse_text_layer(&data, 100),
629            Err(TextError::TextOverflow)
630        ));
631    }
632
633    #[test]
634    fn test_invalid_utf8() {
635        // text_len = 2, then 2 invalid bytes
636        let data = [0x00, 0x00, 0x02, 0xFF, 0xFE];
637        assert!(matches!(
638            parse_text_layer(&data, 100),
639            Err(TextError::InvalidUtf8)
640        ));
641    }
642
643    #[test]
644    fn test_unknown_zone_type() {
645        // text_len=1, text="A", version=0, then zone type=99 (invalid)
646        let data = [
647            0x00, 0x00, 0x01, // text_len = 1
648            b'A', // text
649            0x00, // version
650            99,   // invalid zone type
651        ];
652        assert!(matches!(
653            parse_text_layer(&data, 100),
654            Err(TextError::UnknownZoneType(99))
655        ));
656    }
657
658    #[test]
659    fn test_zone_truncated() {
660        // text_len=1, text="A", version=0, zone type=1 (Page), then truncated
661        let data = [
662            0x00, 0x00, 0x01, // text_len = 1
663            b'A', // text
664            0x00, // version
665            0x01, // zone type = Page
666            0x80, 0x00, // x (only partial fields)
667        ];
668        assert!(matches!(
669            parse_text_layer(&data, 100),
670            Err(TextError::ZoneTruncated(_))
671        ));
672    }
673
674    // ── Successful parse ────────────────────────────────────────────────────
675
676    #[test]
677    fn test_empty_text_no_zones() {
678        // text_len=0, no zones after that
679        let data = [0x00, 0x00, 0x00];
680        let result = parse_text_layer(&data, 100).unwrap();
681        assert_eq!(result.text, "");
682        assert!(result.zones.is_empty());
683    }
684
685    #[test]
686    fn test_text_only_no_zones() {
687        // text_len=5, text="Hello", version byte, then no zone data
688        let data = [
689            0x00, 0x00, 0x05, // text_len = 5
690            b'H', b'e', b'l', b'l', b'o', // text
691            0x00, // version
692        ];
693        let result = parse_text_layer(&data, 100).unwrap();
694        assert_eq!(result.text, "Hello");
695        assert!(result.zones.is_empty());
696    }
697
698    // ── TextLayer::transform ─────────────────────────────────────────────────
699
700    fn make_layer(x: u32, y: u32, w: u32, h: u32) -> TextLayer {
701        TextLayer {
702            text: "test".to_string(),
703            zones: vec![TextZone {
704                kind: TextZoneKind::Page,
705                rect: Rect {
706                    x,
707                    y,
708                    width: w,
709                    height: h,
710                },
711                text: "test".to_string(),
712                children: vec![],
713            }],
714        }
715    }
716
717    fn rect0(layer: &TextLayer) -> &Rect {
718        &layer.zones[0].rect
719    }
720
721    #[test]
722    fn transform_none_identity() {
723        use crate::info::Rotation;
724        // No rotation, 1:1 scale — rects unchanged
725        let layer = make_layer(10, 20, 30, 40);
726        let out = layer.transform(100, 200, Rotation::None, 100, 200);
727        assert_eq!(
728            *rect0(&out),
729            Rect {
730                x: 10,
731                y: 20,
732                width: 30,
733                height: 40
734            }
735        );
736    }
737
738    #[test]
739    fn transform_none_scale_2x() {
740        use crate::info::Rotation;
741        let layer = make_layer(10, 20, 30, 40);
742        let out = layer.transform(100, 200, Rotation::None, 200, 400);
743        assert_eq!(
744            *rect0(&out),
745            Rect {
746                x: 20,
747                y: 40,
748                width: 60,
749                height: 80
750            }
751        );
752    }
753
754    #[test]
755    fn transform_rot180() {
756        use crate::info::Rotation;
757        // page 100×200, rect (10, 20, 30, 40)
758        // new_x = 100 - 10 - 30 = 60
759        // new_y = 200 - 20 - 40 = 140
760        let layer = make_layer(10, 20, 30, 40);
761        let out = layer.transform(100, 200, Rotation::Rot180, 100, 200);
762        assert_eq!(
763            *rect0(&out),
764            Rect {
765                x: 60,
766                y: 140,
767                width: 30,
768                height: 40
769            }
770        );
771    }
772
773    #[test]
774    fn transform_cw90() {
775        use crate::info::Rotation;
776        // page 100×200, rect (x=10, y=20, w=30, h=40)
777        // displayed: 200 wide × 100 tall
778        // new_x = page_h - y - h = 200 - 20 - 40 = 140
779        // new_y = x = 10
780        // new_w = h = 40,  new_h = w = 30
781        let layer = make_layer(10, 20, 30, 40);
782        let out = layer.transform(100, 200, Rotation::Cw90, 200, 100);
783        assert_eq!(
784            *rect0(&out),
785            Rect {
786                x: 140,
787                y: 10,
788                width: 40,
789                height: 30
790            }
791        );
792    }
793
794    #[test]
795    fn transform_ccw90() {
796        use crate::info::Rotation;
797        // page 100×200, rect (x=10, y=20, w=30, h=40)
798        // displayed: 200 wide × 100 tall
799        // new_x = y = 20
800        // new_y = page_w - x - w = 100 - 10 - 30 = 60
801        // new_w = h = 40,  new_h = w = 30
802        let layer = make_layer(10, 20, 30, 40);
803        let out = layer.transform(100, 200, Rotation::Ccw90, 200, 100);
804        assert_eq!(
805            *rect0(&out),
806            Rect {
807                x: 20,
808                y: 60,
809                width: 40,
810                height: 30
811            }
812        );
813    }
814
815    #[test]
816    fn transform_cw90_then_scale() {
817        use crate::info::Rotation;
818        // page 100×200, rect (10, 20, 30, 40), render at 2× (400×200)
819        // After Cw90: (140, 10, 40, 30) in 200×100 space
820        // Scale ×2: (280, 20, 80, 60)
821        let layer = make_layer(10, 20, 30, 40);
822        let out = layer.transform(100, 200, Rotation::Cw90, 400, 200);
823        assert_eq!(
824            *rect0(&out),
825            Rect {
826                x: 280,
827                y: 20,
828                width: 80,
829                height: 60
830            }
831        );
832    }
833
834    #[test]
835    fn transform_text_preserved() {
836        use crate::info::Rotation;
837        let layer = make_layer(0, 0, 10, 10);
838        let out = layer.transform(100, 100, Rotation::Cw90, 100, 100);
839        assert_eq!(out.text, "test");
840        assert_eq!(out.zones[0].text, "test");
841    }
842
843    #[test]
844    fn test_single_word_zone() {
845        // Build a minimal text layer with one Page zone containing "Hi"
846        let text = b"Hi";
847        let mut data = Vec::new();
848        // text_len = 2 (u24be)
849        data.extend_from_slice(&[0x00, 0x00, 0x02]);
850        data.extend_from_slice(text);
851        data.push(0x00); // version
852
853        // Page zone (type=1)
854        data.push(0x01);
855        // x=0, y=0, w=100, h=50 (biased i16: value + 0x8000)
856        data.extend_from_slice(&0x8000u16.to_be_bytes()); // x=0
857        data.extend_from_slice(&0x8000u16.to_be_bytes()); // y=0
858        data.extend_from_slice(&(100u16 + 0x8000u16).wrapping_add(0).to_be_bytes()); // w=100
859        let h_val = 50i32 + 0x8000;
860        data.extend_from_slice(&(h_val as u16).to_be_bytes()); // h=50
861        data.extend_from_slice(&0x8000u16.to_be_bytes()); // text_start=0
862        // text_len = 2 (i24)
863        data.extend_from_slice(&[0x00, 0x00, 0x02]);
864        // children_count = 0 (i24)
865        data.extend_from_slice(&[0x00, 0x00, 0x00]);
866
867        let result = parse_text_layer(&data, 100).unwrap();
868        assert_eq!(result.text, "Hi");
869        assert_eq!(result.zones.len(), 1);
870        assert_eq!(result.zones[0].kind, TextZoneKind::Page);
871        assert_eq!(result.zones[0].text, "Hi");
872        assert_eq!(result.zones[0].rect.width, 100);
873        assert_eq!(result.zones[0].rect.height, 50);
874    }
875
876    // ── Paragraph reflow tests (#228) ───────────────────────────────────────
877
878    fn layer_with(text: &str) -> TextLayer {
879        TextLayer {
880            text: text.to_string(),
881            zones: Vec::new(),
882        }
883    }
884
885    #[test]
886    fn reflowable_text_splits_on_paragraph_separator() {
887        // Two paragraphs separated by US (\x1f), each with two lines.
888        let layer = layer_with("first line\nsecond line\u{001f}third line\nfourth line");
889        let paras = layer.reflowable_text();
890        assert_eq!(paras.len(), 2);
891        assert_eq!(paras[0].lines, vec!["first line", "second line"]);
892        assert_eq!(paras[0].text, "first line second line");
893        assert_eq!(paras[1].lines, vec!["third line", "fourth line"]);
894        assert_eq!(paras[1].text, "third line fourth line");
895    }
896
897    #[test]
898    fn reflowable_text_joins_soft_hyphen() {
899        // "compre-" + "hensive" → "comprehensive" (lowercase next, hyphen drop).
900        let layer = layer_with("a compre-\nhensive guide");
901        let paras = layer.reflowable_text();
902        assert_eq!(paras.len(), 1);
903        assert_eq!(paras[0].text, "a comprehensive guide");
904    }
905
906    #[test]
907    fn reflowable_text_keeps_hyphen_before_uppercase() {
908        // "Anglo-" + "Saxon" — the hyphen is part of the word, not a soft
909        // line-break. Uppercase next ⇒ keep the hyphen, replace newline with
910        // a space.
911        let layer = layer_with("Anglo-\nSaxon roots");
912        let paras = layer.reflowable_text();
913        assert_eq!(paras.len(), 1);
914        assert_eq!(paras[0].text, "Anglo- Saxon roots");
915    }
916
917    #[test]
918    fn reflowable_text_treats_all_separator_codes_as_break() {
919        // NUL, VT, GS, US should each break paragraphs.
920        let layer = layer_with("a\u{0000}b\u{000b}c\u{001d}d\u{001f}e");
921        let paras = layer.reflowable_text();
922        assert_eq!(paras.len(), 5);
923        assert_eq!(paras[0].text, "a");
924        assert_eq!(paras[4].text, "e");
925    }
926
927    #[test]
928    fn reflowable_text_skips_empty_paragraphs() {
929        let layer = layer_with("\u{001f}\u{001f}only one\u{001f}");
930        let paras = layer.reflowable_text();
931        assert_eq!(paras.len(), 1);
932        assert_eq!(paras[0].text, "only one");
933    }
934
935    #[test]
936    fn reflowable_text_trims_per_line_whitespace() {
937        let layer = layer_with("  leading\n  trailing  \n   middle   ");
938        let paras = layer.reflowable_text();
939        assert_eq!(paras.len(), 1);
940        assert_eq!(paras[0].lines, vec!["leading", "trailing", "middle"]);
941        assert_eq!(paras[0].text, "leading trailing middle");
942    }
943
944    // ── Character zone (type_byte=7) ─────────────────────────────────────────
945
946    #[test]
947    fn parse_character_zone_type_7() {
948        // text "X", then a Character zone (type=7), no children
949        let mut data = Vec::new();
950        data.extend_from_slice(&[0x00, 0x00, 0x01]); // text_len = 1
951        data.push(b'X'); // text
952        data.push(0x00); // version
953        data.push(0x07); // zone type = Character (7)
954        data.extend_from_slice(&[0x80, 0x00]); // x = 0 (biased)
955        data.extend_from_slice(&[0x80, 0x00]); // y = 0
956        data.extend_from_slice(&[0x80, 0x0A]); // w = 10
957        data.extend_from_slice(&[0x80, 0x14]); // h = 20
958        data.extend_from_slice(&[0x80, 0x00]); // text_start = 0
959        data.extend_from_slice(&[0x00, 0x00, 0x01]); // text_len (i24) = 1
960        data.extend_from_slice(&[0x00, 0x00, 0x00]); // children_count = 0
961        let result = parse_text_layer(&data, 100).unwrap();
962        assert_eq!(result.zones.len(), 1);
963        assert_eq!(result.zones[0].kind, TextZoneKind::Character);
964    }
965
966    // ── ZoneTruncated from recursive child call ──────────────────────────────
967
968    #[test]
969    fn zone_truncated_when_child_data_missing() {
970        // A Page zone with children_count=1 but no child bytes follow
971        let mut data = Vec::new();
972        data.extend_from_slice(&[0x00, 0x00, 0x01]); // text_len = 1
973        data.push(b'A'); // text
974        data.push(0x00); // version
975        data.push(0x01); // zone type = Page (1)
976        data.extend_from_slice(&[0x80, 0x00]); // x
977        data.extend_from_slice(&[0x80, 0x00]); // y
978        data.extend_from_slice(&[0x80, 0x64]); // w = 100
979        data.extend_from_slice(&[0x80, 0x32]); // h = 50
980        data.extend_from_slice(&[0x80, 0x00]); // text_start
981        data.extend_from_slice(&[0x00, 0x00, 0x01]); // text_len (i24) = 1
982        data.extend_from_slice(&[0x00, 0x00, 0x01]); // children_count = 1 → triggers child
983        // NO child data → ZoneTruncated
984        assert!(matches!(
985            parse_text_layer(&data, 100),
986            Err(TextError::ZoneTruncated(_))
987        ));
988    }
989
990    // ── Rect::scale zero-dimension guard ────────────────────────────────────
991
992    #[test]
993    fn rect_scale_zero_from_w_returns_clone() {
994        let r = Rect {
995            x: 5,
996            y: 10,
997            width: 20,
998            height: 30,
999        };
1000        assert_eq!(r.scale(0, 100, 200, 200), r);
1001    }
1002
1003    #[test]
1004    fn rect_scale_zero_from_h_returns_clone() {
1005        let r = Rect {
1006            x: 5,
1007            y: 10,
1008            width: 20,
1009            height: 30,
1010        };
1011        assert_eq!(r.scale(100, 0, 200, 200), r);
1012    }
1013}