Skip to main content

djvu_rs/
text_serialize.rs

1//! hOCR and ALTO XML serialization for the DjVu text layer.
2//!
3//! This module does **not** run OCR. It serializes the structured
4//! [`TextLayer`] / [`TextZone`] hierarchy already present in a document's
5//! TXTz/TXTa chunk into two widely-used interchange formats:
6//!
7//! - **hOCR** — HTML micro-format used by Tesseract, Google Books, Internet Archive.
8//! - **ALTO XML** — ISO 25577:2013 standard used by national libraries (LoC, Europeana, BnF).
9//!
10//! Recognition (producing a text layer from page images) lives in the
11//! [`crate::ocr`] cluster; this module is purely the text-layer → markup half.
12//!
13//! ## Key public types
14//!
15//! - [`HocrOptions`] — options for hOCR output (page selection, DPI scale)
16//! - [`AltoOptions`] — options for ALTO output (page selection, DPI scale)
17//! - [`to_hocr`] — generate hOCR HTML string for a document
18//! - [`to_alto`] — generate ALTO XML string for a document
19//! - [`TextSerializeError`] — typed errors from this module
20//!
21//! [`HocrOptions`]: crate::text_serialize::HocrOptions
22//! [`AltoOptions`]: crate::text_serialize::AltoOptions
23//! [`to_hocr`]: crate::text_serialize::to_hocr
24//! [`to_alto`]: crate::text_serialize::to_alto
25//! [`TextSerializeError`]: crate::text_serialize::TextSerializeError
26
27use std::fmt::Write as FmtWrite;
28
29use crate::djvu_document::DjVuDocument;
30use crate::text::{TextLayer, TextZone, TextZoneKind};
31
32// ---- Error ------------------------------------------------------------------
33
34/// Errors from text-layer serialization.
35#[derive(Debug, thiserror::Error)]
36#[non_exhaustive]
37pub enum TextSerializeError {
38    /// Accessing a page failed.
39    #[error("document error: {0}")]
40    Doc(#[from] crate::djvu_document::DocError),
41
42    /// Text layer extraction failed.
43    #[error("text layer error: {0}")]
44    Text(#[from] crate::text::TextError),
45
46    /// String formatting error (infallible in practice).
47    #[error("format error: {0}")]
48    Fmt(#[from] std::fmt::Error),
49}
50
51/// Deprecated alias for [`TextSerializeError`], kept so code written against the
52/// former `ocr_export` module name still compiles.
53#[deprecated(since = "0.21.0", note = "renamed to `TextSerializeError`")]
54pub type OcrExportError = TextSerializeError;
55
56// ---- Options ----------------------------------------------------------------
57
58/// Options for hOCR output.
59#[derive(Debug, Clone, Default)]
60pub struct HocrOptions {
61    /// If `Some(n)`, only include page `n` (0-based). Default: all pages.
62    pub page_index: Option<usize>,
63    /// Target DPI for coordinate scaling. When set, page dimensions and all
64    /// text zone coordinates are scaled so that 1 pixel equals 1/dpi inches
65    /// at the specified resolution. Useful when the hOCR output must align
66    /// with a rendered image at a specific DPI. When `None`, coordinates are
67    /// emitted in native page pixels.
68    pub dpi: Option<u32>,
69}
70
71/// Options for ALTO XML output.
72#[derive(Debug, Clone, Default)]
73pub struct AltoOptions {
74    /// If `Some(n)`, only include page `n` (0-based). Default: all pages.
75    pub page_index: Option<usize>,
76    /// Target DPI for coordinate scaling. When set, page dimensions and all
77    /// text zone coordinates are scaled so that 1 pixel equals 1/dpi inches
78    /// at the specified resolution. Useful when the ALTO output must align
79    /// with a rendered image at a specific DPI. When `None`, coordinates are
80    /// emitted in native page pixels.
81    pub dpi: Option<u32>,
82}
83
84// ---- Public API -------------------------------------------------------------
85
86/// Generate hOCR HTML for the text layer of a [`DjVuDocument`].
87///
88/// Returns the complete HTML document as a `String`. Pages without a text
89/// layer produce an empty `ocr_page` div (with correct dimensions) so that
90/// the page count in the output always matches the document.
91///
92/// # Errors
93///
94/// Returns [`TextSerializeError`] if a page cannot be accessed or its text layer
95/// cannot be decoded.
96pub 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        // bbox for the full page
119        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
146/// Generate ALTO XML for the text layer of a [`DjVuDocument`].
147///
148/// Returns a complete ALTO 4.x XML document as a `String`.
149///
150/// # Errors
151///
152/// Returns [`TextSerializeError`] if a page cannot be accessed or its text layer
153/// cannot be decoded.
154pub 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
218// ---- hOCR helpers -----------------------------------------------------------
219
220fn 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            // Page zone is handled by the caller
259            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            // Words may have character children — skip sub-word nesting in hOCR
312        }
313        TextZoneKind::Character => {
314            // Characters are not a standard hOCR class; skip.
315        }
316    }
317    Ok(())
318}
319
320/// Escape text for embedding in hOCR (XHTML) or ALTO (XML) markup.
321///
322/// Uses the numeric character reference `&#39;` for the apostrophe rather than
323/// the named `&apos;`: `&#39;` is valid in both XHTML and XML, so one escaper
324/// serves both writers (the two previously disagreed on this single character).
325fn escape_markup(s: &str) -> String {
326    s.chars()
327        .flat_map(|c| match c {
328            '&' => "&amp;".chars().collect::<Vec<_>>(),
329            '<' => "&lt;".chars().collect(),
330            '>' => "&gt;".chars().collect(),
331            '"' => "&quot;".chars().collect(),
332            '\'' => "&#39;".chars().collect(),
333            c => vec![c],
334        })
335        .collect()
336}
337
338// ---- ALTO helpers -----------------------------------------------------------
339
340fn 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            // Glyph-level elements not included in the basic ALTO export.
431        }
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    // ---- escape_markup -------------------------------------------------------
500
501    #[test]
502    fn escape_ampersand() {
503        assert_eq!(escape_markup("a & b"), "a &amp; b");
504    }
505
506    #[test]
507    fn escape_less_than() {
508        assert_eq!(escape_markup("<tag>"), "&lt;tag&gt;");
509    }
510
511    #[test]
512    fn escape_quote() {
513        assert_eq!(escape_markup(r#"say "hi""#), "say &quot;hi&quot;");
514    }
515
516    #[test]
517    fn escape_apostrophe_uses_numeric_ref() {
518        assert_eq!(escape_markup("it's"), "it&#39;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    // ---- hOCR ---------------------------------------------------------------
532
533    #[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        // bbox in hOCR is "x1 y1 x2 y2"
545        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        // x2 = x + width = 10 + 30 = 40, y2 = 20 + 10 = 30
549        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&amp;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    // ---- ALTO ---------------------------------------------------------------
593
594    #[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("&#39;"), "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    // ---- hOCR: non-word zone kinds ------------------------------------------
661
662    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        // Character zones produce no output at all
728        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    // ---- ALTO: non-word zone kinds ------------------------------------------
749
750    #[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    // ---- to_hocr / to_alto with real document -------------------------------
842
843    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    // ---- DPI scaling path (text_layer_at_size) --------------------------------
916
917    #[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    // Tests below use colorbook.djvu (has TXTz) so write_hocr_zones /
948    // write_alto_page are actually called when DPI scaling is active.
949
950    #[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}