#[cfg(not(feature = "std"))]
use alloc::{
string::{String, ToString},
vec::Vec,
};
use crate::info::Rotation;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum TextError {
#[error("text layer data too short")]
TooShort,
#[error("text length overflows data")]
TextOverflow,
#[error("invalid UTF-8 in text layer")]
InvalidUtf8,
#[error("zone record truncated at offset {0}")]
ZoneTruncated(usize),
#[error("unknown zone type {0}")]
UnknownZoneType(u8),
#[error("zone tree too deep (> {MAX_ZONE_DEPTH})")]
ZoneTooDeep,
}
pub const MAX_ZONE_DEPTH: usize = 64;
const MIN_ZONE_RECORD_BYTES: usize = 17;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum TextZoneKind {
Page,
Column,
Region,
Para,
Line,
Word,
Character,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Rect {
pub x: u32,
pub y: u32,
pub width: u32,
pub height: u32,
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct TextZone {
pub kind: TextZoneKind,
pub rect: Rect,
pub text: String,
pub children: Vec<TextZone>,
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct TextLayer {
pub text: String,
pub zones: Vec<TextZone>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Paragraph {
pub lines: Vec<String>,
pub text: String,
}
impl TextLayer {
pub fn transform(
&self,
page_w: u32,
page_h: u32,
rotation: Rotation,
render_w: u32,
render_h: u32,
) -> Self {
let (disp_w, disp_h) = match rotation {
Rotation::Cw90 | Rotation::Ccw90 => (page_h, page_w),
_ => (page_w, page_h),
};
let t = ZoneTransform {
page_w,
page_h,
rotation,
disp_w,
disp_h,
render_w,
render_h,
};
let zones = self.zones.iter().map(|z| transform_zone(z, &t)).collect();
TextLayer {
text: self.text.clone(),
zones,
}
}
pub fn reflowable_text(&self) -> Vec<Paragraph> {
let mut out = Vec::new();
for chunk in self
.text
.split(['\u{0000}', '\u{000b}', '\u{001d}', '\u{001f}'])
{
let lines: Vec<String> = chunk
.split('\n')
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty())
.collect();
if lines.is_empty() {
continue;
}
let text = join_paragraph_lines(&lines);
out.push(Paragraph { lines, text });
}
out
}
}
fn join_paragraph_lines(lines: &[String]) -> String {
let mut out = String::new();
for (i, line) in lines.iter().enumerate() {
if i == 0 {
out.push_str(line);
continue;
}
let prev_hyphen =
out.ends_with('-') && line.chars().next().is_some_and(|c| c.is_ascii_lowercase());
if prev_hyphen {
out.pop();
out.push_str(line);
} else {
out.push(' ');
out.push_str(line);
}
}
out
}
impl Rect {
pub fn rotate(&self, page_w: u32, page_h: u32, rotation: Rotation) -> Self {
match rotation {
Rotation::None => self.clone(),
Rotation::Rot180 => Rect {
x: page_w.saturating_sub(self.x.saturating_add(self.width)),
y: page_h.saturating_sub(self.y.saturating_add(self.height)),
width: self.width,
height: self.height,
},
Rotation::Cw90 => Rect {
x: page_h.saturating_sub(self.y.saturating_add(self.height)),
y: self.x,
width: self.height,
height: self.width,
},
Rotation::Ccw90 => Rect {
x: self.y,
y: page_w.saturating_sub(self.x.saturating_add(self.width)),
width: self.height,
height: self.width,
},
}
}
pub fn scale(&self, from_w: u32, from_h: u32, to_w: u32, to_h: u32) -> Self {
if from_w == 0 || from_h == 0 {
return self.clone();
}
Rect {
x: (self.x as u64 * to_w as u64 / from_w as u64) as u32,
y: (self.y as u64 * to_h as u64 / from_h as u64) as u32,
width: (self.width as u64 * to_w as u64 / from_w as u64) as u32,
height: (self.height as u64 * to_h as u64 / from_h as u64) as u32,
}
}
}
struct ZoneTransform {
page_w: u32,
page_h: u32,
rotation: Rotation,
disp_w: u32,
disp_h: u32,
render_w: u32,
render_h: u32,
}
fn transform_zone(zone: &TextZone, t: &ZoneTransform) -> TextZone {
let rotated = zone.rect.rotate(t.page_w, t.page_h, t.rotation);
let scaled = rotated.scale(t.disp_w, t.disp_h, t.render_w, t.render_h);
let children = zone.children.iter().map(|c| transform_zone(c, t)).collect();
TextZone {
kind: zone.kind,
rect: scaled,
text: zone.text.clone(),
children,
}
}
pub fn parse_text_layer(data: &[u8], page_height: u32) -> Result<TextLayer, TextError> {
parse_text_layer_inner(data, page_height)
}
fn parse_text_layer_inner(data: &[u8], page_height: u32) -> Result<TextLayer, TextError> {
if data.len() < 3 {
return Err(TextError::TooShort);
}
let mut pos = 0usize;
let text_len = read_u24(data, &mut pos).ok_or(TextError::TooShort)?;
let text_end = pos.checked_add(text_len).ok_or(TextError::TextOverflow)?;
if text_end > data.len() {
return Err(TextError::TextOverflow);
}
let text_bytes = data.get(pos..text_end).ok_or(TextError::TextOverflow)?;
let (full_text, text) = match core::str::from_utf8(text_bytes) {
Ok(s) => (FullText::Utf8(s), s.to_string()),
Err(_) => (
FullText::Legacy(text_bytes),
crate::lenient_text::decode_lossy(text_bytes).into_owned(),
),
};
pos = text_end;
if pos < data.len() {
pos += 1; }
let mut zones = Vec::new();
if pos < data.len() {
let (zone, _) = parse_zone(data, &mut pos, None, None, &full_text, page_height, 0)?;
zones.push(zone);
}
Ok(TextLayer { text, zones })
}
enum FullText<'a> {
Utf8(&'a str),
Legacy(&'a [u8]),
}
#[derive(Clone)]
struct ZoneCtx {
x: i32,
y: i32, width: i32,
height: i32,
text_start: i32,
text_len: i32,
}
fn parse_zone(
data: &[u8],
pos: &mut usize,
parent: Option<&ZoneCtx>,
prev: Option<&ZoneCtx>,
full_text: &FullText<'_>,
page_height: u32,
depth: usize,
) -> Result<(TextZone, ZoneCtx), TextError> {
if depth > MAX_ZONE_DEPTH {
return Err(TextError::ZoneTooDeep);
}
if *pos >= data.len() {
return Err(TextError::ZoneTruncated(*pos));
}
let type_byte = *data.get(*pos).ok_or(TextError::ZoneTruncated(*pos))?;
*pos += 1;
let kind = match type_byte {
1 => TextZoneKind::Page,
2 => TextZoneKind::Column,
3 => TextZoneKind::Region,
4 => TextZoneKind::Para,
5 => TextZoneKind::Line,
6 => TextZoneKind::Word,
7 => TextZoneKind::Character,
other => return Err(TextError::UnknownZoneType(other)),
};
let mut x = read_i16_biased(data, pos).ok_or(TextError::ZoneTruncated(*pos))?;
let mut y = read_i16_biased(data, pos).ok_or(TextError::ZoneTruncated(*pos))?;
let width = read_i16_biased(data, pos).ok_or(TextError::ZoneTruncated(*pos))?;
let height = read_i16_biased(data, pos).ok_or(TextError::ZoneTruncated(*pos))?;
let mut text_start = read_i16_biased(data, pos).ok_or(TextError::ZoneTruncated(*pos))?;
let text_len = read_i24(data, pos).ok_or(TextError::ZoneTruncated(*pos))?;
if let Some(prev) = prev {
match type_byte {
1 | 4 | 5 => {
x += prev.x;
y = prev.y - (y + height);
}
_ => {
x += prev.x + prev.width;
y += prev.y;
}
}
text_start += prev.text_start + prev.text_len;
} else if let Some(parent) = parent {
x += parent.x;
y = parent.y + parent.height - (y + height);
text_start += parent.text_start;
}
let tl_y = (page_height as i32)
.saturating_sub(y.saturating_add(height))
.max(0) as u32;
let tl_x = x.max(0) as u32;
let tl_w = width.max(0) as u32;
let tl_h = height.max(0) as u32;
let rect = Rect {
x: tl_x,
y: tl_y,
width: tl_w,
height: tl_h,
};
let ts = text_start.max(0) as usize;
let tl = text_len.max(0) as usize;
let zone_text = extract_text_slice(full_text, ts, tl);
let children_count = read_i24(data, pos)
.ok_or(TextError::ZoneTruncated(*pos))?
.max(0) as usize;
let remaining = data.len().saturating_sub(*pos);
let reserve = children_count.min(remaining / MIN_ZONE_RECORD_BYTES);
let ctx = ZoneCtx {
x,
y,
width,
height,
text_start,
text_len,
};
let mut children = Vec::with_capacity(reserve);
let mut prev_child: Option<ZoneCtx> = None;
for _ in 0..children_count {
let (child, child_ctx) = parse_zone(
data,
pos,
Some(&ctx),
prev_child.as_ref(),
full_text,
page_height,
depth + 1,
)?;
prev_child = Some(child_ctx);
children.push(child);
}
Ok((
TextZone {
kind,
rect,
text: zone_text,
children,
},
ctx,
))
}
fn extract_text_slice(full_text: &FullText<'_>, start: usize, len: usize) -> String {
match *full_text {
FullText::Utf8(text) => {
let end = start.saturating_add(len).min(text.len());
let start = start.min(end);
let safe_start = (0..=start)
.rev()
.find(|&i| text.is_char_boundary(i))
.unwrap_or(0);
let safe_end = (end..=text.len())
.find(|&i| text.is_char_boundary(i))
.unwrap_or(text.len());
text[safe_start..safe_end].to_string()
}
FullText::Legacy(bytes) => {
let end = start.saturating_add(len).min(bytes.len());
let start = start.min(end);
crate::lenient_text::decode_lossy(&bytes[start..end]).into_owned()
}
}
}
fn read_u24(data: &[u8], pos: &mut usize) -> Option<usize> {
let b0 = *data.get(*pos)?;
let b1 = *data.get(*pos + 1)?;
let b2 = *data.get(*pos + 2)?;
*pos += 3;
Some(((b0 as usize) << 16) | ((b1 as usize) << 8) | (b2 as usize))
}
fn read_i16_biased(data: &[u8], pos: &mut usize) -> Option<i32> {
let b0 = *data.get(*pos)?;
let b1 = *data.get(*pos + 1)?;
*pos += 2;
let raw = u16::from_be_bytes([b0, b1]);
Some(raw as i32 - 0x8000)
}
fn read_i24(data: &[u8], pos: &mut usize) -> Option<i32> {
let b0 = *data.get(*pos)? as i32;
let b1 = *data.get(*pos + 1)? as i32;
let b2 = *data.get(*pos + 2)? as i32;
*pos += 3;
Some((b0 << 16) | (b1 << 8) | b2)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_read_u24() {
let data = [0x01, 0x02, 0x03];
let mut pos = 0;
assert_eq!(read_u24(&data, &mut pos), Some(0x010203));
assert_eq!(pos, 3);
}
#[test]
fn test_read_u24_truncated() {
let data = [0x01, 0x02];
let mut pos = 0;
assert_eq!(read_u24(&data, &mut pos), None);
}
#[test]
fn test_read_i16_biased() {
let data = [0x80, 0x00]; let mut pos = 0;
assert_eq!(read_i16_biased(&data, &mut pos), Some(0));
assert_eq!(pos, 2);
}
#[test]
fn test_read_i16_biased_negative() {
let data = [0x00, 0x00]; let mut pos = 0;
assert_eq!(read_i16_biased(&data, &mut pos), Some(-0x8000));
}
#[test]
fn test_read_i16_biased_truncated() {
let data = [0x80];
let mut pos = 0;
assert_eq!(read_i16_biased(&data, &mut pos), None);
}
#[test]
fn test_read_i24() {
let data = [0x00, 0x01, 0x00];
let mut pos = 0;
assert_eq!(read_i24(&data, &mut pos), Some(256));
}
#[test]
fn test_extract_text_slice_basic() {
let t = FullText::Utf8("hello world");
assert_eq!(extract_text_slice(&t, 0, 5), "hello");
assert_eq!(extract_text_slice(&t, 6, 5), "world");
}
#[test]
fn test_extract_text_slice_out_of_bounds() {
let t = FullText::Utf8("hello");
assert_eq!(extract_text_slice(&t, 10, 5), "");
assert_eq!(extract_text_slice(&t, 0, 100), "hello");
}
#[test]
fn test_extract_text_slice_utf8_boundary() {
let s = FullText::Utf8("\u{00e9}\u{00e8}"); let result = extract_text_slice(&s, 1, 2);
assert!(result.is_char_boundary(0));
}
#[test]
fn test_extract_text_slice_empty() {
assert_eq!(extract_text_slice(&FullText::Utf8(""), 0, 0), "");
assert_eq!(extract_text_slice(&FullText::Utf8("abc"), 1, 0), "");
}
#[test]
fn test_legacy_zone_split_of_utf8_run_diverges() {
let bytes = [0xC3, 0xA9, b'A', 0x96];
let t = FullText::Legacy(&bytes);
assert_eq!(
crate::lenient_text::decode_lossy(&bytes).as_ref(),
"\u{E9}A\u{2013}"
);
assert_eq!(extract_text_slice(&t, 1, 1), "\u{A9}");
assert_eq!(extract_text_slice(&t, 0, 2), "\u{E9}");
assert_eq!(extract_text_slice(&t, 3, 1), "\u{2013}");
}
#[test]
fn test_extract_text_slice_legacy_exact_offsets() {
let bytes = b"a\x96b\x97c";
let t = FullText::Legacy(bytes);
assert_eq!(extract_text_slice(&t, 1, 1), "\u{2013}");
assert_eq!(extract_text_slice(&t, 3, 1), "\u{2014}");
assert_eq!(extract_text_slice(&t, 0, 5), "a\u{2013}b\u{2014}c");
assert_eq!(extract_text_slice(&t, 10, 3), "");
}
#[test]
fn test_too_short_data() {
assert!(matches!(
parse_text_layer(&[0x00], 100),
Err(TextError::TooShort)
));
assert!(matches!(
parse_text_layer(&[], 100),
Err(TextError::TooShort)
));
}
#[test]
fn test_text_overflow() {
let data = [0x00, 0x00, 0xFF, 0x41];
assert!(matches!(
parse_text_layer(&data, 100),
Err(TextError::TextOverflow)
));
}
#[test]
fn test_invalid_utf8_decodes_leniently() {
let data = [0x00, 0x00, 0x02, 0xFF, 0xFE];
let result = parse_text_layer(&data, 100).unwrap();
assert_eq!(result.text, "ÿþ");
assert!(result.zones.is_empty());
}
#[test]
fn test_cp1252_text_zone_offsets_stay_exact() {
let data = [
0x00, 0x00, 0x03, b'a', 0x96, b'b', 0x00, 0x01, 0x80, 0x00, 0x80, 0x00, 0x80, 0x03, 0x80, 0x0A, 0x80, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, ];
let result = parse_text_layer(&data, 100).unwrap();
assert_eq!(result.text, "a\u{2013}b");
assert_eq!(result.zones.len(), 1);
assert_eq!(result.zones[0].text, "a\u{2013}b");
}
#[test]
fn test_unknown_zone_type() {
let data = [
0x00, 0x00, 0x01, b'A', 0x00, 99, ];
assert!(matches!(
parse_text_layer(&data, 100),
Err(TextError::UnknownZoneType(99))
));
}
#[test]
fn test_zone_truncated() {
let data = [
0x00, 0x00, 0x01, b'A', 0x00, 0x01, 0x80, 0x00, ];
assert!(matches!(
parse_text_layer(&data, 100),
Err(TextError::ZoneTruncated(_))
));
}
#[test]
fn test_empty_text_no_zones() {
let data = [0x00, 0x00, 0x00];
let result = parse_text_layer(&data, 100).unwrap();
assert_eq!(result.text, "");
assert!(result.zones.is_empty());
}
#[test]
fn test_text_only_no_zones() {
let data = [
0x00, 0x00, 0x05, b'H', b'e', b'l', b'l', b'o', 0x00, ];
let result = parse_text_layer(&data, 100).unwrap();
assert_eq!(result.text, "Hello");
assert!(result.zones.is_empty());
}
fn make_layer(x: u32, y: u32, w: u32, h: u32) -> TextLayer {
TextLayer {
text: "test".to_string(),
zones: vec![TextZone {
kind: TextZoneKind::Page,
rect: Rect {
x,
y,
width: w,
height: h,
},
text: "test".to_string(),
children: vec![],
}],
}
}
fn rect0(layer: &TextLayer) -> &Rect {
&layer.zones[0].rect
}
#[test]
fn transform_none_identity() {
use crate::info::Rotation;
let layer = make_layer(10, 20, 30, 40);
let out = layer.transform(100, 200, Rotation::None, 100, 200);
assert_eq!(
*rect0(&out),
Rect {
x: 10,
y: 20,
width: 30,
height: 40
}
);
}
#[test]
fn transform_none_scale_2x() {
use crate::info::Rotation;
let layer = make_layer(10, 20, 30, 40);
let out = layer.transform(100, 200, Rotation::None, 200, 400);
assert_eq!(
*rect0(&out),
Rect {
x: 20,
y: 40,
width: 60,
height: 80
}
);
}
#[test]
fn transform_rot180() {
use crate::info::Rotation;
let layer = make_layer(10, 20, 30, 40);
let out = layer.transform(100, 200, Rotation::Rot180, 100, 200);
assert_eq!(
*rect0(&out),
Rect {
x: 60,
y: 140,
width: 30,
height: 40
}
);
}
#[test]
fn transform_cw90() {
use crate::info::Rotation;
let layer = make_layer(10, 20, 30, 40);
let out = layer.transform(100, 200, Rotation::Cw90, 200, 100);
assert_eq!(
*rect0(&out),
Rect {
x: 140,
y: 10,
width: 40,
height: 30
}
);
}
#[test]
fn transform_ccw90() {
use crate::info::Rotation;
let layer = make_layer(10, 20, 30, 40);
let out = layer.transform(100, 200, Rotation::Ccw90, 200, 100);
assert_eq!(
*rect0(&out),
Rect {
x: 20,
y: 60,
width: 40,
height: 30
}
);
}
#[test]
fn transform_cw90_then_scale() {
use crate::info::Rotation;
let layer = make_layer(10, 20, 30, 40);
let out = layer.transform(100, 200, Rotation::Cw90, 400, 200);
assert_eq!(
*rect0(&out),
Rect {
x: 280,
y: 20,
width: 80,
height: 60
}
);
}
#[test]
fn transform_text_preserved() {
use crate::info::Rotation;
let layer = make_layer(0, 0, 10, 10);
let out = layer.transform(100, 100, Rotation::Cw90, 100, 100);
assert_eq!(out.text, "test");
assert_eq!(out.zones[0].text, "test");
}
#[test]
fn test_single_word_zone() {
let text = b"Hi";
let mut data = Vec::new();
data.extend_from_slice(&[0x00, 0x00, 0x02]);
data.extend_from_slice(text);
data.push(0x00);
data.push(0x01);
data.extend_from_slice(&0x8000u16.to_be_bytes()); data.extend_from_slice(&0x8000u16.to_be_bytes()); data.extend_from_slice(&(100u16 + 0x8000u16).wrapping_add(0).to_be_bytes()); let h_val = 50i32 + 0x8000;
data.extend_from_slice(&(h_val as u16).to_be_bytes()); data.extend_from_slice(&0x8000u16.to_be_bytes()); data.extend_from_slice(&[0x00, 0x00, 0x02]);
data.extend_from_slice(&[0x00, 0x00, 0x00]);
let result = parse_text_layer(&data, 100).unwrap();
assert_eq!(result.text, "Hi");
assert_eq!(result.zones.len(), 1);
assert_eq!(result.zones[0].kind, TextZoneKind::Page);
assert_eq!(result.zones[0].text, "Hi");
assert_eq!(result.zones[0].rect.width, 100);
assert_eq!(result.zones[0].rect.height, 50);
}
#[test]
fn sibling_words_decode_against_previous_sibling() {
let path =
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/carte.djvu");
let doc = crate::DjVuDocument::parse(&std::fs::read(path).unwrap()).unwrap();
let layer = doc.page(0).unwrap().text_layer().unwrap().unwrap();
let first_line = &layer.zones[0].children[0];
assert_eq!(first_line.kind, TextZoneKind::Line);
let words: Vec<&str> = first_line
.children
.iter()
.map(|w| w.text.trim_end())
.collect();
assert_eq!(
words,
[
"0",
"PERATI",
"0",
"bl$",
"o/'",
"G\u{7f}ArE\u{7f}L",
"\u{7f}t\u{7f}rA",
"SItINGTON"
]
);
let second_line = &layer.zones[0].children[1];
assert_eq!(second_line.children[0].text.trim_end(), "a\u{7f}ain\u{7f}t");
}
fn layer_with(text: &str) -> TextLayer {
TextLayer {
text: text.to_string(),
zones: Vec::new(),
}
}
#[test]
fn reflowable_text_splits_on_paragraph_separator() {
let layer = layer_with("first line\nsecond line\u{001f}third line\nfourth line");
let paras = layer.reflowable_text();
assert_eq!(paras.len(), 2);
assert_eq!(paras[0].lines, vec!["first line", "second line"]);
assert_eq!(paras[0].text, "first line second line");
assert_eq!(paras[1].lines, vec!["third line", "fourth line"]);
assert_eq!(paras[1].text, "third line fourth line");
}
#[test]
fn reflowable_text_joins_soft_hyphen() {
let layer = layer_with("a compre-\nhensive guide");
let paras = layer.reflowable_text();
assert_eq!(paras.len(), 1);
assert_eq!(paras[0].text, "a comprehensive guide");
}
#[test]
fn reflowable_text_keeps_hyphen_before_uppercase() {
let layer = layer_with("Anglo-\nSaxon roots");
let paras = layer.reflowable_text();
assert_eq!(paras.len(), 1);
assert_eq!(paras[0].text, "Anglo- Saxon roots");
}
#[test]
fn reflowable_text_treats_all_separator_codes_as_break() {
let layer = layer_with("a\u{0000}b\u{000b}c\u{001d}d\u{001f}e");
let paras = layer.reflowable_text();
assert_eq!(paras.len(), 5);
assert_eq!(paras[0].text, "a");
assert_eq!(paras[4].text, "e");
}
#[test]
fn reflowable_text_skips_empty_paragraphs() {
let layer = layer_with("\u{001f}\u{001f}only one\u{001f}");
let paras = layer.reflowable_text();
assert_eq!(paras.len(), 1);
assert_eq!(paras[0].text, "only one");
}
#[test]
fn reflowable_text_trims_per_line_whitespace() {
let layer = layer_with(" leading\n trailing \n middle ");
let paras = layer.reflowable_text();
assert_eq!(paras.len(), 1);
assert_eq!(paras[0].lines, vec!["leading", "trailing", "middle"]);
assert_eq!(paras[0].text, "leading trailing middle");
}
#[test]
fn parse_character_zone_type_7() {
let mut data = Vec::new();
data.extend_from_slice(&[0x00, 0x00, 0x01]); data.push(b'X'); data.push(0x00); data.push(0x07); data.extend_from_slice(&[0x80, 0x00]); data.extend_from_slice(&[0x80, 0x00]); data.extend_from_slice(&[0x80, 0x0A]); data.extend_from_slice(&[0x80, 0x14]); data.extend_from_slice(&[0x80, 0x00]); data.extend_from_slice(&[0x00, 0x00, 0x01]); data.extend_from_slice(&[0x00, 0x00, 0x00]); let result = parse_text_layer(&data, 100).unwrap();
assert_eq!(result.zones.len(), 1);
assert_eq!(result.zones[0].kind, TextZoneKind::Character);
}
#[test]
fn zone_truncated_when_child_data_missing() {
let mut data = Vec::new();
data.extend_from_slice(&[0x00, 0x00, 0x01]); data.push(b'A'); data.push(0x00); data.push(0x01); data.extend_from_slice(&[0x80, 0x00]); data.extend_from_slice(&[0x80, 0x00]); data.extend_from_slice(&[0x80, 0x64]); data.extend_from_slice(&[0x80, 0x32]); data.extend_from_slice(&[0x80, 0x00]); data.extend_from_slice(&[0x00, 0x00, 0x01]); data.extend_from_slice(&[0x00, 0x00, 0x01]); assert!(matches!(
parse_text_layer(&data, 100),
Err(TextError::ZoneTruncated(_))
));
}
#[test]
fn rect_scale_zero_from_w_returns_clone() {
let r = Rect {
x: 5,
y: 10,
width: 20,
height: 30,
};
assert_eq!(r.scale(0, 100, 200, 200), r);
}
#[test]
fn rect_scale_zero_from_h_returns_clone() {
let r = Rect {
x: 5,
y: 10,
width: 20,
height: 30,
};
assert_eq!(r.scale(100, 0, 200, 200), r);
}
#[test]
fn zone_child_count_amplification_is_rejected() {
let mut d = vec![0u8, 0, 0, 0];
d.push(0x01);
for _ in 0..5 {
d.extend_from_slice(&[0x80, 0x00]);
}
d.extend_from_slice(&[0, 0, 0]); d.extend_from_slice(&[0xFF, 0xFF, 0xFF]); let err = parse_text_layer(&d, 1000).unwrap_err();
assert!(
matches!(err, TextError::ZoneTruncated(_)),
"huge child count must fail fast without over-reserving, got {err:?}"
);
}
#[test]
fn zone_depth_is_bounded() {
fn zone_with_one_child(children: u32) -> Vec<u8> {
let mut z = Vec::new();
z.push(0x01); for _ in 0..5 {
z.extend_from_slice(&[0x80, 0x00]); }
z.extend_from_slice(&[0, 0, 0]); z.extend_from_slice(&(children).to_be_bytes()[1..4]); z
}
let mut d = vec![0, 0, 0, 0]; let n = MAX_ZONE_DEPTH + 5;
for i in 0..n {
d.extend_from_slice(&zone_with_one_child(if i + 1 < n { 1 } else { 0 }));
}
let err = parse_text_layer(&d, 1000).unwrap_err();
assert!(
matches!(err, TextError::ZoneTooDeep),
"over-deep chain must error, got {err:?}"
);
}
#[test]
fn zone_normal_tree_still_parses() {
let mut d = vec![0, 0, 0, 0]; d.push(0x01); for _ in 0..5 {
d.extend_from_slice(&[0x80, 0x00]);
}
d.extend_from_slice(&[0, 0, 0]); d.extend_from_slice(&[0, 0, 2]); for _ in 0..2 {
d.push(0x06); for _ in 0..5 {
d.extend_from_slice(&[0x80, 0x00]);
}
d.extend_from_slice(&[0, 0, 0]); d.extend_from_slice(&[0, 0, 0]); }
let tl = parse_text_layer(&d, 1000).unwrap();
assert_eq!(tl.zones.len(), 1);
assert_eq!(tl.zones[0].children.len(), 2);
}
}