1#[cfg(not(feature = "std"))]
26use alloc::{
27 string::{String, ToString},
28 vec::Vec,
29};
30
31use crate::info::Rotation;
32
33#[derive(Debug, thiserror::Error)]
37#[non_exhaustive]
38pub enum TextError {
39 #[error("text layer data too short")]
41 TooShort,
42
43 #[error("text length overflows data")]
45 TextOverflow,
46
47 #[error("invalid UTF-8 in text layer")]
52 InvalidUtf8,
53
54 #[error("zone record truncated at offset {0}")]
56 ZoneTruncated(usize),
57
58 #[error("unknown zone type {0}")]
60 UnknownZoneType(u8),
61
62 #[error("zone tree too deep (> {MAX_ZONE_DEPTH})")]
64 ZoneTooDeep,
65}
66
67pub const MAX_ZONE_DEPTH: usize = 64;
72
73const MIN_ZONE_RECORD_BYTES: usize = 17;
78
79#[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#[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#[derive(Debug, Clone)]
106#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
107pub struct TextZone {
108 pub kind: TextZoneKind,
110 pub rect: Rect,
112 pub text: String,
114 pub children: Vec<TextZone>,
116}
117
118#[derive(Debug, Clone)]
120#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
121pub struct TextLayer {
122 pub text: String,
124 pub zones: Vec<TextZone>,
126}
127
128#[derive(Debug, Clone, PartialEq, Eq)]
132#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
133pub struct Paragraph {
134 pub lines: Vec<String>,
137 pub text: String,
142}
143
144impl TextLayer {
147 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 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
243impl Rect {
246 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 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 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 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
293struct 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
319pub fn parse_text_layer(data: &[u8], page_height: u32) -> Result<TextLayer, TextError> {
328 parse_text_layer_inner(data, page_height)
329}
330
331fn 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 let text_len = read_u24(data, &mut pos).ok_or(TextError::TooShort)?;
342
343 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 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 if pos < data.len() {
366 pos += 1; }
368
369 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
379enum FullText<'a> {
386 Utf8(&'a str),
387 Legacy(&'a [u8]),
388}
389
390#[derive(Clone)]
394struct ZoneCtx {
395 x: i32,
396 y: i32, 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 if let Some(prev) = prev {
442 match type_byte {
443 1 | 4 | 5 => {
444 x += prev.x;
446 y = prev.y - (y + height);
447 }
448 _ => {
449 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 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 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 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 (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
540fn 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 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
574fn 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
585fn 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
594fn 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 #[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]; 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]; 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 #[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 let s = FullText::Utf8("\u{00e9}\u{00e8}"); 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 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 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 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 #[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 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 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 let data = [
754 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, ];
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 let data = [
776 0x00, 0x00, 0x01, b'A', 0x00, 99, ];
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 let data = [
791 0x00, 0x00, 0x01, b'A', 0x00, 0x01, 0x80, 0x00, ];
797 assert!(matches!(
798 parse_text_layer(&data, 100),
799 Err(TextError::ZoneTruncated(_))
800 ));
801 }
802
803 #[test]
806 fn test_empty_text_no_zones() {
807 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 let data = [
818 0x00, 0x00, 0x05, b'H', b'e', b'l', b'l', b'o', 0x00, ];
822 let result = parse_text_layer(&data, 100).unwrap();
823 assert_eq!(result.text, "Hello");
824 assert!(result.zones.is_empty());
825 }
826
827 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 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 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 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 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 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 let text = b"Hi";
976 let mut data = Vec::new();
977 data.extend_from_slice(&[0x00, 0x00, 0x02]);
979 data.extend_from_slice(text);
980 data.push(0x00); data.push(0x01);
984 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;
989 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]);
993 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 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 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 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 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 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 #[test]
1076 fn parse_character_zone_type_7() {
1077 let mut data = Vec::new();
1079 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();
1091 assert_eq!(result.zones.len(), 1);
1092 assert_eq!(result.zones[0].kind, TextZoneKind::Character);
1093 }
1094
1095 #[test]
1098 fn zone_truncated_when_child_data_missing() {
1099 let mut data = Vec::new();
1101 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!(
1114 parse_text_layer(&data, 100),
1115 Err(TextError::ZoneTruncated(_))
1116 ));
1117 }
1118
1119 #[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 #[test]
1148 fn zone_child_count_amplification_is_rejected() {
1149 let mut d = vec![0u8, 0, 0, 0];
1153 d.push(0x01);
1155 for _ in 0..5 {
1156 d.extend_from_slice(&[0x80, 0x00]);
1157 }
1158 d.extend_from_slice(&[0, 0, 0]); d.extend_from_slice(&[0xFF, 0xFF, 0xFF]); 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 #[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); for _ in 0..5 {
1179 z.extend_from_slice(&[0x80, 0x00]); }
1181 z.extend_from_slice(&[0, 0, 0]); z.extend_from_slice(&(children).to_be_bytes()[1..4]); z
1184 }
1185 let mut d = vec![0, 0, 0, 0]; 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 #[test]
1201 fn zone_normal_tree_still_parses() {
1202 let mut d = vec![0, 0, 0, 0]; d.push(0x01); for _ in 0..5 {
1206 d.extend_from_slice(&[0x80, 0x00]);
1207 }
1208 d.extend_from_slice(&[0, 0, 0]); d.extend_from_slice(&[0, 0, 2]); for _ in 0..2 {
1211 d.push(0x06); for _ in 0..5 {
1213 d.extend_from_slice(&[0x80, 0x00]);
1214 }
1215 d.extend_from_slice(&[0, 0, 0]); d.extend_from_slice(&[0, 0, 0]); }
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}