1use std::fmt::Write as FmtWrite;
28
29use crate::djvu_document::DjVuDocument;
30use crate::text::{TextLayer, TextZone, TextZoneKind};
31
32#[derive(Debug, thiserror::Error)]
36#[non_exhaustive]
37pub enum TextSerializeError {
38 #[error("document error: {0}")]
40 Doc(#[from] crate::djvu_document::DocError),
41
42 #[error("text layer error: {0}")]
44 Text(#[from] crate::text::TextError),
45
46 #[error("format error: {0}")]
48 Fmt(#[from] std::fmt::Error),
49}
50
51#[deprecated(since = "0.21.0", note = "renamed to `TextSerializeError`")]
54pub type OcrExportError = TextSerializeError;
55
56#[derive(Debug, Clone, Default)]
60pub struct HocrOptions {
61 pub page_index: Option<usize>,
63 pub dpi: Option<u32>,
69}
70
71#[derive(Debug, Clone, Default)]
73pub struct AltoOptions {
74 pub page_index: Option<usize>,
76 pub dpi: Option<u32>,
82}
83
84pub fn to_hocr(doc: &DjVuDocument, opts: &HocrOptions) -> Result<String, TextSerializeError> {
97 let mut out = String::with_capacity(4096);
98
99 writeln!(out, "<!DOCTYPE html>")?;
100 writeln!(out, r#"<html xmlns="http://www.w3.org/1999/xhtml">"#)?;
101 writeln!(out, "<head>")?;
102 writeln!(out, r#" <meta charset="utf-8"/>"#)?;
103 writeln!(out, r#" <meta name="ocr-system" content="djvu-rs"/>"#)?;
104 writeln!(
105 out,
106 r#" <meta name="ocr-capabilities" content="ocr_page ocr_block ocr_par ocr_line ocrx_word"/>"#
107 )?;
108 writeln!(out, "</head>")?;
109 writeln!(out, "<body>")?;
110
111 for page_idx in crate::export_common::page_indices(doc, opts.page_index) {
112 let page = doc.page(page_idx)?;
113 let (out_w, out_h) = match opts.dpi {
114 Some(target_dpi) => crate::export_common::size_at_dpi(page, target_dpi as f32),
115 None => (page.width() as u32, page.height() as u32),
116 };
117
118 write!(
120 out,
121 r#" <div class="ocr_page" id="page_{idx}" title="image page_{idx}.djvu; bbox 0 0 {w} {h}; ppageno {idx}">"#,
122 idx = page_idx,
123 w = out_w,
124 h = out_h,
125 )?;
126 writeln!(out)?;
127
128 let layer_opt = if opts.dpi.is_some() {
129 page.text_layer_at_size(out_w, out_h)?
130 } else {
131 page.text_layer()?
132 };
133 if let Some(layer) = layer_opt {
134 write_hocr_zones(&mut out, &layer, page_idx)?;
135 }
136
137 writeln!(out, " </div>")?;
138 }
139
140 writeln!(out, "</body>")?;
141 writeln!(out, "</html>")?;
142
143 Ok(out)
144}
145
146pub fn to_alto(doc: &DjVuDocument, opts: &AltoOptions) -> Result<String, TextSerializeError> {
155 let mut out = String::with_capacity(4096);
156
157 writeln!(out, r#"<?xml version="1.0" encoding="UTF-8"?>"#)?;
158 writeln!(
159 out,
160 r#"<alto xmlns="http://www.loc.gov/standards/alto/ns-v4#""#
161 )?;
162 writeln!(
163 out,
164 r#" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance""#
165 )?;
166 writeln!(
167 out,
168 r#" xsi:schemaLocation="http://www.loc.gov/standards/alto/ns-v4# https://www.loc.gov/standards/alto/v4/alto.xsd">"#
169 )?;
170 writeln!(out, " <Description>")?;
171 writeln!(out, " <MeasurementUnit>pixel</MeasurementUnit>")?;
172 writeln!(out, " <sourceImageInformation>")?;
173 writeln!(out, " <fileName>document.djvu</fileName>")?;
174 writeln!(out, " </sourceImageInformation>")?;
175 writeln!(out, " </Description>")?;
176 writeln!(out, " <Layout>")?;
177
178 for page_idx in crate::export_common::page_indices(doc, opts.page_index) {
179 let page = doc.page(page_idx)?;
180 let (out_w, out_h) = match opts.dpi {
181 Some(target_dpi) => crate::export_common::size_at_dpi(page, target_dpi as f32),
182 None => (page.width() as u32, page.height() as u32),
183 };
184
185 writeln!(
186 out,
187 r#" <Page ID="page_{idx}" WIDTH="{w}" HEIGHT="{h}" PHYSICAL_IMG_NR="{idx}">"#,
188 idx = page_idx,
189 w = out_w,
190 h = out_h,
191 )?;
192 writeln!(
193 out,
194 " <PrintSpace WIDTH=\"{w}\" HEIGHT=\"{h}\" HPOS=\"0\" VPOS=\"0\">",
195 w = out_w,
196 h = out_h
197 )?;
198
199 let layer_opt = if opts.dpi.is_some() {
200 page.text_layer_at_size(out_w, out_h)?
201 } else {
202 page.text_layer()?
203 };
204 if let Some(layer) = layer_opt {
205 write_alto_zones(&mut out, &layer, page_idx)?;
206 }
207
208 writeln!(out, " </PrintSpace>")?;
209 writeln!(out, " </Page>")?;
210 }
211
212 writeln!(out, " </Layout>")?;
213 writeln!(out, "</alto>")?;
214
215 Ok(out)
216}
217
218fn write_hocr_zones(
221 out: &mut String,
222 layer: &TextLayer,
223 page_idx: usize,
224) -> Result<(), TextSerializeError> {
225 let mut block_id = 0usize;
226 let mut line_id = 0usize;
227 let mut word_id = 0usize;
228
229 for zone in &layer.zones {
230 write_hocr_zone(
231 out,
232 zone,
233 page_idx,
234 &mut block_id,
235 &mut line_id,
236 &mut word_id,
237 3,
238 )?;
239 }
240 Ok(())
241}
242
243fn write_hocr_zone(
244 out: &mut String,
245 zone: &TextZone,
246 page_idx: usize,
247 block_id: &mut usize,
248 line_id: &mut usize,
249 word_id: &mut usize,
250 indent: usize,
251) -> Result<(), TextSerializeError> {
252 let pad = " ".repeat(indent);
253 let r = &zone.rect;
254 let bbox = format!("bbox {} {} {} {}", r.x, r.y, r.x + r.width, r.y + r.height);
255
256 match zone.kind {
257 TextZoneKind::Page => {
258 for child in &zone.children {
260 write_hocr_zone(out, child, page_idx, block_id, line_id, word_id, indent)?;
261 }
262 }
263 TextZoneKind::Column | TextZoneKind::Region => {
264 let id = *block_id;
265 *block_id += 1;
266 writeln!(
267 out,
268 r#"{pad}<div class="ocr_block" id="block_{page}_{id}" title="{bbox}">"#,
269 page = page_idx
270 )?;
271 for child in &zone.children {
272 write_hocr_zone(out, child, page_idx, block_id, line_id, word_id, indent + 2)?;
273 }
274 writeln!(out, "{pad}</div>")?;
275 }
276 TextZoneKind::Para => {
277 let id = *block_id;
278 *block_id += 1;
279 writeln!(
280 out,
281 r#"{pad}<p class="ocr_par" id="par_{page}_{id}" title="{bbox}">"#,
282 page = page_idx
283 )?;
284 for child in &zone.children {
285 write_hocr_zone(out, child, page_idx, block_id, line_id, word_id, indent + 2)?;
286 }
287 writeln!(out, "{pad}</p>")?;
288 }
289 TextZoneKind::Line => {
290 let id = *line_id;
291 *line_id += 1;
292 writeln!(
293 out,
294 r#"{pad}<span class="ocr_line" id="line_{page}_{id}" title="{bbox}">"#,
295 page = page_idx
296 )?;
297 for child in &zone.children {
298 write_hocr_zone(out, child, page_idx, block_id, line_id, word_id, indent + 2)?;
299 }
300 writeln!(out, "{pad}</span>")?;
301 }
302 TextZoneKind::Word => {
303 let id = *word_id;
304 *word_id += 1;
305 let text = escape_markup(&zone.text);
306 writeln!(
307 out,
308 r#"{pad}<span class="ocrx_word" id="word_{page}_{id}" title="{bbox}">{text}</span>"#,
309 page = page_idx
310 )?;
311 }
313 TextZoneKind::Character => {
314 }
316 }
317 Ok(())
318}
319
320fn escape_markup(s: &str) -> String {
326 s.chars()
327 .flat_map(|c| match c {
328 '&' => "&".chars().collect::<Vec<_>>(),
329 '<' => "<".chars().collect(),
330 '>' => ">".chars().collect(),
331 '"' => """.chars().collect(),
332 '\'' => "'".chars().collect(),
333 c => vec![c],
334 })
335 .collect()
336}
337
338fn write_alto_zones(
341 out: &mut String,
342 layer: &TextLayer,
343 page_idx: usize,
344) -> Result<(), TextSerializeError> {
345 let mut block_id = 0usize;
346 let mut line_id = 0usize;
347 let mut word_id = 0usize;
348
349 for zone in &layer.zones {
350 write_alto_zone(
351 out,
352 zone,
353 page_idx,
354 &mut block_id,
355 &mut line_id,
356 &mut word_id,
357 4,
358 )?;
359 }
360 Ok(())
361}
362
363fn write_alto_zone(
364 out: &mut String,
365 zone: &TextZone,
366 page_idx: usize,
367 block_id: &mut usize,
368 line_id: &mut usize,
369 word_id: &mut usize,
370 indent: usize,
371) -> Result<(), TextSerializeError> {
372 let pad = " ".repeat(indent);
373 let r = &zone.rect;
374
375 match zone.kind {
376 TextZoneKind::Page => {
377 for child in &zone.children {
378 write_alto_zone(out, child, page_idx, block_id, line_id, word_id, indent)?;
379 }
380 }
381 TextZoneKind::Column | TextZoneKind::Region | TextZoneKind::Para => {
382 let id = *block_id;
383 *block_id += 1;
384 writeln!(
385 out,
386 r#"{pad}<TextBlock ID="block_{page}_{id}" HPOS="{hpos}" VPOS="{vpos}" WIDTH="{w}" HEIGHT="{h}">"#,
387 page = page_idx,
388 hpos = r.x,
389 vpos = r.y,
390 w = r.width,
391 h = r.height,
392 )?;
393 for child in &zone.children {
394 write_alto_zone(out, child, page_idx, block_id, line_id, word_id, indent + 2)?;
395 }
396 writeln!(out, "{pad}</TextBlock>")?;
397 }
398 TextZoneKind::Line => {
399 let id = *line_id;
400 *line_id += 1;
401 writeln!(
402 out,
403 r#"{pad}<TextLine ID="line_{page}_{id}" HPOS="{hpos}" VPOS="{vpos}" WIDTH="{w}" HEIGHT="{h}">"#,
404 page = page_idx,
405 hpos = r.x,
406 vpos = r.y,
407 w = r.width,
408 h = r.height,
409 )?;
410 for child in &zone.children {
411 write_alto_zone(out, child, page_idx, block_id, line_id, word_id, indent + 2)?;
412 }
413 writeln!(out, "{pad}</TextLine>")?;
414 }
415 TextZoneKind::Word => {
416 let id = *word_id;
417 *word_id += 1;
418 let text = escape_markup(&zone.text);
419 writeln!(
420 out,
421 r#"{pad}<String ID="word_{page}_{id}" HPOS="{hpos}" VPOS="{vpos}" WIDTH="{w}" HEIGHT="{h}" CONTENT="{text}"/>"#,
422 page = page_idx,
423 hpos = r.x,
424 vpos = r.y,
425 w = r.width,
426 h = r.height,
427 )?;
428 }
429 TextZoneKind::Character => {
430 }
432 }
433 Ok(())
434}
435
436#[cfg(test)]
437mod tests {
438 use super::*;
439 use crate::text::{Rect, TextLayer, TextZone, TextZoneKind};
440
441 fn word_zone(text: &str, x: u32, y: u32, w: u32, h: u32) -> TextZone {
442 TextZone {
443 kind: TextZoneKind::Word,
444 rect: Rect {
445 x,
446 y,
447 width: w,
448 height: h,
449 },
450 text: text.to_string(),
451 children: vec![],
452 }
453 }
454
455 fn line_zone(words: Vec<TextZone>, x: u32, y: u32, w: u32, h: u32) -> TextZone {
456 TextZone {
457 kind: TextZoneKind::Line,
458 rect: Rect {
459 x,
460 y,
461 width: w,
462 height: h,
463 },
464 text: words
465 .iter()
466 .map(|z| z.text.as_str())
467 .collect::<Vec<_>>()
468 .join(" "),
469 children: words,
470 }
471 }
472
473 fn page_zone(children: Vec<TextZone>) -> TextZone {
474 TextZone {
475 kind: TextZoneKind::Page,
476 rect: Rect {
477 x: 0,
478 y: 0,
479 width: 800,
480 height: 600,
481 },
482 text: String::new(),
483 children,
484 }
485 }
486
487 fn simple_layer(words: &[(&str, u32, u32, u32, u32)]) -> TextLayer {
488 let word_zones: Vec<_> = words
489 .iter()
490 .map(|&(t, x, y, w, h)| word_zone(t, x, y, w, h))
491 .collect();
492 let line = line_zone(word_zones, 0, 0, 800, 40);
493 TextLayer {
494 text: words.iter().map(|w| w.0).collect::<Vec<_>>().join(" "),
495 zones: vec![page_zone(vec![line])],
496 }
497 }
498
499 #[test]
502 fn escape_ampersand() {
503 assert_eq!(escape_markup("a & b"), "a & b");
504 }
505
506 #[test]
507 fn escape_less_than() {
508 assert_eq!(escape_markup("<tag>"), "<tag>");
509 }
510
511 #[test]
512 fn escape_quote() {
513 assert_eq!(escape_markup(r#"say "hi""#), "say "hi"");
514 }
515
516 #[test]
517 fn escape_apostrophe_uses_numeric_ref() {
518 assert_eq!(escape_markup("it's"), "it's");
519 }
520
521 #[test]
522 fn escape_plain_text_unchanged() {
523 assert_eq!(escape_markup("hello world"), "hello world");
524 }
525
526 #[test]
527 fn escape_empty() {
528 assert_eq!(escape_markup(""), "");
529 }
530
531 #[test]
534 fn hocr_word_contains_class_and_text() {
535 let layer = simple_layer(&[("hello", 10, 20, 50, 15)]);
536 let mut out = String::new();
537 write_hocr_zones(&mut out, &layer, 0).unwrap();
538 assert!(out.contains("ocrx_word"), "expected ocrx_word class");
539 assert!(out.contains("hello"), "expected word text");
540 }
541
542 #[test]
543 fn hocr_word_bbox_format() {
544 let layer = simple_layer(&[("foo", 10, 20, 30, 10)]);
546 let mut out = String::new();
547 write_hocr_zones(&mut out, &layer, 0).unwrap();
548 assert!(
550 out.contains("bbox 10 20 40 30"),
551 "expected bbox 10 20 40 30, got: {out}"
552 );
553 }
554
555 #[test]
556 fn hocr_word_ids_increment() {
557 let layer = simple_layer(&[("a", 0, 0, 10, 10), ("b", 20, 0, 10, 10)]);
558 let mut out = String::new();
559 write_hocr_zones(&mut out, &layer, 0).unwrap();
560 assert!(out.contains("word_0_0"));
561 assert!(out.contains("word_0_1"));
562 }
563
564 #[test]
565 fn hocr_page_index_in_ids() {
566 let layer = simple_layer(&[("x", 0, 0, 10, 10)]);
567 let mut out = String::new();
568 write_hocr_zones(&mut out, &layer, 3).unwrap();
569 assert!(
570 out.contains("word_3_0"),
571 "page index should appear in id: {out}"
572 );
573 }
574
575 #[test]
576 fn hocr_escapes_special_chars_in_text() {
577 let layer = simple_layer(&[("a&b", 0, 0, 10, 10)]);
578 let mut out = String::new();
579 write_hocr_zones(&mut out, &layer, 0).unwrap();
580 assert!(out.contains("a&b"), "expected escaped ampersand: {out}");
581 assert!(!out.contains(" a&b "), "unescaped text must not appear");
582 }
583
584 #[test]
585 fn hocr_line_zone_has_ocr_line_class() {
586 let layer = simple_layer(&[("w", 0, 0, 50, 20)]);
587 let mut out = String::new();
588 write_hocr_zones(&mut out, &layer, 0).unwrap();
589 assert!(out.contains("ocr_line"), "expected ocr_line class: {out}");
590 }
591
592 #[test]
595 fn alto_word_has_string_element() {
596 let layer = simple_layer(&[("hello", 5, 10, 40, 12)]);
597 let mut out = String::new();
598 write_alto_zones(&mut out, &layer, 0).unwrap();
599 assert!(out.contains("<String"), "expected String element: {out}");
600 assert!(
601 out.contains(r#"CONTENT="hello""#),
602 "expected CONTENT attr: {out}"
603 );
604 }
605
606 #[test]
607 fn alto_word_hpos_vpos_width_height() {
608 let layer = simple_layer(&[("w", 5, 10, 40, 12)]);
609 let mut out = String::new();
610 write_alto_zones(&mut out, &layer, 0).unwrap();
611 assert!(out.contains(r#"HPOS="5""#));
612 assert!(out.contains(r#"VPOS="10""#));
613 assert!(out.contains(r#"WIDTH="40""#));
614 assert!(out.contains(r#"HEIGHT="12""#));
615 }
616
617 #[test]
618 fn alto_word_ids_increment() {
619 let layer = simple_layer(&[("a", 0, 0, 10, 10), ("b", 20, 0, 10, 10)]);
620 let mut out = String::new();
621 write_alto_zones(&mut out, &layer, 0).unwrap();
622 assert!(out.contains(r#"ID="word_0_0""#));
623 assert!(out.contains(r#"ID="word_0_1""#));
624 }
625
626 #[test]
627 fn alto_line_has_textline_element() {
628 let layer = simple_layer(&[("w", 0, 0, 50, 20)]);
629 let mut out = String::new();
630 write_alto_zones(&mut out, &layer, 0).unwrap();
631 assert!(
632 out.contains("<TextLine"),
633 "expected TextLine element: {out}"
634 );
635 assert!(
636 out.contains("</TextLine>"),
637 "expected closing TextLine: {out}"
638 );
639 }
640
641 #[test]
642 fn alto_escapes_special_chars_in_content() {
643 let layer = simple_layer(&[("it's", 0, 0, 30, 10)]);
644 let mut out = String::new();
645 write_alto_zones(&mut out, &layer, 0).unwrap();
646 assert!(out.contains("'"), "expected escaped apostrophe: {out}");
647 }
648
649 #[test]
650 fn alto_page_index_in_word_id() {
651 let layer = simple_layer(&[("x", 0, 0, 10, 10)]);
652 let mut out = String::new();
653 write_alto_zones(&mut out, &layer, 5).unwrap();
654 assert!(
655 out.contains(r#"ID="word_5_0""#),
656 "page index in word id: {out}"
657 );
658 }
659
660 fn zone(kind: TextZoneKind, children: Vec<TextZone>) -> TextZone {
663 TextZone {
664 kind,
665 rect: Rect {
666 x: 0,
667 y: 0,
668 width: 100,
669 height: 50,
670 },
671 text: String::new(),
672 children,
673 }
674 }
675
676 fn layer_with_zone(z: TextZone) -> TextLayer {
677 TextLayer {
678 text: String::new(),
679 zones: vec![zone(TextZoneKind::Page, vec![z])],
680 }
681 }
682
683 #[test]
684 fn hocr_column_zone_emits_ocr_block() {
685 let inner = zone(TextZoneKind::Word, vec![]);
686 let col = zone(TextZoneKind::Column, vec![inner]);
687 let layer = layer_with_zone(col);
688 let mut out = String::new();
689 write_hocr_zones(&mut out, &layer, 0).unwrap();
690 assert!(
691 out.contains("ocr_block"),
692 "Column must produce ocr_block: {out}"
693 );
694 assert!(out.contains("</div>"), "must close div: {out}");
695 }
696
697 #[test]
698 fn hocr_region_zone_emits_ocr_block() {
699 let inner = zone(TextZoneKind::Word, vec![]);
700 let reg = zone(TextZoneKind::Region, vec![inner]);
701 let layer = layer_with_zone(reg);
702 let mut out = String::new();
703 write_hocr_zones(&mut out, &layer, 0).unwrap();
704 assert!(
705 out.contains("ocr_block"),
706 "Region must produce ocr_block: {out}"
707 );
708 }
709
710 #[test]
711 fn hocr_para_zone_emits_ocr_par() {
712 let inner = zone(TextZoneKind::Word, vec![]);
713 let para = zone(TextZoneKind::Para, vec![inner]);
714 let layer = layer_with_zone(para);
715 let mut out = String::new();
716 write_hocr_zones(&mut out, &layer, 0).unwrap();
717 assert!(out.contains("ocr_par"), "Para must produce ocr_par: {out}");
718 assert!(out.contains("</p>"), "must close p: {out}");
719 }
720
721 #[test]
722 fn hocr_character_zone_is_skipped() {
723 let ch = zone(TextZoneKind::Character, vec![]);
724 let layer = layer_with_zone(ch);
725 let mut out = String::new();
726 write_hocr_zones(&mut out, &layer, 0).unwrap();
727 assert!(!out.contains("span"), "Character must not emit span: {out}");
729 assert!(!out.contains("div"), "Character must not emit div: {out}");
730 }
731
732 #[test]
733 fn hocr_block_ids_increment_across_column_and_para() {
734 let w1 = word_zone("a", 0, 0, 10, 10);
735 let w2 = word_zone("b", 20, 0, 10, 10);
736 let col = zone(TextZoneKind::Column, vec![w1]);
737 let para = zone(TextZoneKind::Para, vec![w2]);
738 let layer = TextLayer {
739 text: String::new(),
740 zones: vec![zone(TextZoneKind::Page, vec![col, para])],
741 };
742 let mut out = String::new();
743 write_hocr_zones(&mut out, &layer, 0).unwrap();
744 assert!(out.contains("block_0_0"), "first block id: {out}");
745 assert!(out.contains("par_0_1"), "second block id (par): {out}");
746 }
747
748 #[test]
751 fn alto_column_zone_emits_textblock() {
752 let inner = zone(TextZoneKind::Word, vec![]);
753 let col = zone(TextZoneKind::Column, vec![inner]);
754 let layer = layer_with_zone(col);
755 let mut out = String::new();
756 write_alto_zones(&mut out, &layer, 0).unwrap();
757 assert!(
758 out.contains("<TextBlock"),
759 "Column must produce TextBlock: {out}"
760 );
761 assert!(out.contains("</TextBlock>"), "must close TextBlock: {out}");
762 }
763
764 #[test]
765 fn alto_para_zone_emits_textblock() {
766 let inner = zone(TextZoneKind::Word, vec![]);
767 let para = zone(TextZoneKind::Para, vec![inner]);
768 let layer = layer_with_zone(para);
769 let mut out = String::new();
770 write_alto_zones(&mut out, &layer, 0).unwrap();
771 assert!(
772 out.contains("<TextBlock"),
773 "Para must produce TextBlock: {out}"
774 );
775 }
776
777 #[test]
778 fn alto_region_zone_emits_textblock() {
779 let inner = zone(TextZoneKind::Word, vec![]);
780 let reg = zone(TextZoneKind::Region, vec![inner]);
781 let layer = layer_with_zone(reg);
782 let mut out = String::new();
783 write_alto_zones(&mut out, &layer, 0).unwrap();
784 assert!(
785 out.contains("<TextBlock"),
786 "Region must produce TextBlock: {out}"
787 );
788 }
789
790 #[test]
791 fn alto_character_zone_is_skipped() {
792 let ch = zone(TextZoneKind::Character, vec![]);
793 let layer = layer_with_zone(ch);
794 let mut out = String::new();
795 write_alto_zones(&mut out, &layer, 0).unwrap();
796 assert!(
797 !out.contains("String"),
798 "Character must not emit String: {out}"
799 );
800 assert!(
801 !out.contains("TextBlock"),
802 "Character must not emit TextBlock: {out}"
803 );
804 }
805
806 #[test]
807 fn alto_nested_column_para_line_word() {
808 let word = word_zone("hello", 5, 5, 30, 10);
809 let line = line_zone(vec![word], 0, 0, 100, 20);
810 let para = TextZone {
811 kind: TextZoneKind::Para,
812 rect: Rect {
813 x: 0,
814 y: 0,
815 width: 100,
816 height: 20,
817 },
818 text: String::new(),
819 children: vec![line],
820 };
821 let col = TextZone {
822 kind: TextZoneKind::Column,
823 rect: Rect {
824 x: 0,
825 y: 0,
826 width: 100,
827 height: 50,
828 },
829 text: String::new(),
830 children: vec![para],
831 };
832 let layer = layer_with_zone(col);
833 let mut out = String::new();
834 write_alto_zones(&mut out, &layer, 0).unwrap();
835 assert!(out.contains("<TextBlock"), "must have TextBlock for column");
836 assert!(out.contains("<TextBlock"), "must have TextBlock for para");
837 assert!(out.contains("<TextLine"), "must have TextLine");
838 assert!(out.contains(r#"CONTENT="hello""#), "must have word content");
839 }
840
841 fn assets_path() -> std::path::PathBuf {
844 std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
845 .join("references/djvujs/library/assets")
846 }
847
848 fn load_doc(name: &str) -> crate::djvu_document::DjVuDocument {
849 let data =
850 std::fs::read(assets_path().join(name)).unwrap_or_else(|_| panic!("{name} must exist"));
851 crate::djvu_document::DjVuDocument::parse(&data).unwrap_or_else(|e| panic!("parse: {e}"))
852 }
853
854 #[test]
855 fn to_hocr_output_is_valid_html_structure() {
856 let doc = load_doc("chicken.djvu");
857 let out = to_hocr(&doc, &HocrOptions::default()).unwrap();
858 assert!(
859 out.starts_with("<!DOCTYPE html>"),
860 "must start with DOCTYPE"
861 );
862 assert!(out.contains("<html"), "must have html element");
863 assert!(out.contains("</html>"), "must close html");
864 assert!(out.contains("ocr_page"), "must have ocr_page");
865 }
866
867 #[test]
868 fn to_hocr_page_index_option_limits_to_one_page() {
869 let doc = load_doc("chicken.djvu");
870 let all = to_hocr(&doc, &HocrOptions::default()).unwrap();
871 let one = to_hocr(
872 &doc,
873 &HocrOptions {
874 page_index: Some(0),
875 dpi: None,
876 },
877 )
878 .unwrap();
879 assert!(
880 one.len() <= all.len(),
881 "single-page output must not exceed all-pages"
882 );
883 assert!(one.contains("page_0"), "must include page_0");
884 }
885
886 #[test]
887 fn to_alto_output_is_valid_xml_structure() {
888 let doc = load_doc("chicken.djvu");
889 let out = to_alto(&doc, &AltoOptions::default()).unwrap();
890 assert!(
891 out.starts_with(r#"<?xml version="1.0""#),
892 "must start with XML declaration"
893 );
894 assert!(out.contains("<alto"), "must have alto element");
895 assert!(out.contains("</alto>"), "must close alto");
896 assert!(out.contains("<Page"), "must have Page element");
897 }
898
899 #[test]
900 fn to_alto_page_index_option_limits_to_one_page() {
901 let doc = load_doc("chicken.djvu");
902 let all = to_alto(&doc, &AltoOptions::default()).unwrap();
903 let one = to_alto(
904 &doc,
905 &AltoOptions {
906 page_index: Some(0),
907 dpi: None,
908 },
909 )
910 .unwrap();
911 assert!(one.len() <= all.len());
912 assert!(one.contains(r#"ID="page_0""#), "must include page_0");
913 }
914
915 #[test]
918 fn to_hocr_with_dpi_produces_valid_output() {
919 let doc = load_doc("chicken.djvu");
920 let opts = HocrOptions {
921 page_index: None,
922 dpi: Some(150),
923 };
924 let out = to_hocr(&doc, &opts).unwrap();
925 assert!(
926 out.contains("ocr_page"),
927 "DPI-scaled hOCR must have ocr_page"
928 );
929 assert!(out.contains("</html>"), "DPI-scaled hOCR must close html");
930 }
931
932 #[test]
933 fn to_alto_with_dpi_produces_valid_output() {
934 let doc = load_doc("chicken.djvu");
935 let opts = AltoOptions {
936 page_index: None,
937 dpi: Some(150),
938 };
939 let out = to_alto(&doc, &opts).unwrap();
940 assert!(
941 out.contains("<Page"),
942 "DPI-scaled ALTO must have Page element"
943 );
944 assert!(out.contains("</alto>"), "DPI-scaled ALTO must close alto");
945 }
946
947 #[test]
951 fn to_hocr_dpi_with_text_layer_emits_zones() {
952 let doc = load_doc("colorbook.djvu");
953 let opts = HocrOptions {
954 page_index: None,
955 dpi: Some(150),
956 };
957 let out = to_hocr(&doc, &opts).unwrap();
958 assert!(out.contains("ocr_page"), "hOCR must contain ocr_page");
959 }
960
961 #[test]
962 fn to_alto_dpi_with_text_layer_emits_page() {
963 let doc = load_doc("colorbook.djvu");
964 let opts = AltoOptions {
965 page_index: None,
966 dpi: Some(150),
967 };
968 let out = to_alto(&doc, &opts).unwrap();
969 assert!(out.contains("<Page"), "ALTO must contain Page");
970 }
971}