use djvu_rs::DjVuDocument;
use docling_core::{DoclingDocument, Node};
use crate::error::ConversionError;
use crate::source::SourceDocument;
pub fn looks_like_djvu(bytes: &[u8]) -> bool {
bytes.len() >= 16
&& &bytes[0..8] == b"AT&TFORM"
&& matches!(&bytes[12..16], b"DJVM" | b"DJVU" | b"DJVI" | b"THUM")
}
fn parse(bytes: &[u8]) -> Result<DjVuDocument, ConversionError> {
DjVuDocument::parse(bytes).map_err(|e| ConversionError::Parse(format!("djvu: {e}")))
}
fn resolve_range(
range: Option<(usize, usize)>,
total: usize,
) -> Result<(usize, usize), ConversionError> {
match range {
None => Ok((1, total)),
Some((first, last)) => {
if first == 0 || last < first {
return Err(ConversionError::Parse(format!(
"djvu: invalid page range {first}-{last} (pages are 1-based, first <= last)"
)));
}
if first > total {
return Err(ConversionError::Parse(format!(
"djvu: page range {first}-{last} is outside the document ({total} page(s))"
)));
}
Ok((first, last.min(total)))
}
}
}
fn paragraphs(page: &str) -> Vec<String> {
page.split("\n\n")
.filter_map(|block| {
let joined = block.split_whitespace().collect::<Vec<_>>().join(" ");
(!joined.is_empty()).then_some(joined)
})
.collect()
}
pub fn convert_text_layer(
source: &SourceDocument,
range: Option<(usize, usize)>,
) -> Result<DoclingDocument, ConversionError> {
let djvu = parse(&source.bytes)?;
let (first, last) = resolve_range(range, djvu.page_count())?;
let mut doc = DoclingDocument::new(&source.name);
for page_no in first..=last {
if page_no > first {
doc.push(Node::PageBreak);
}
let page = djvu
.page(page_no - 1)
.map_err(|e| ConversionError::Parse(format!("djvu: page {page_no}: {e}")))?;
let text = page
.text()
.map_err(|e| ConversionError::Parse(format!("djvu: page {page_no} text layer: {e}")))?;
for para in paragraphs(text.as_deref().unwrap_or("")) {
doc.push(Node::Paragraph { text: para });
}
}
Ok(doc)
}
pub fn is_text_layer_empty(doc: &DoclingDocument) -> bool {
!doc.nodes
.iter()
.any(|n| matches!(n, Node::Paragraph { .. }))
}
#[cfg(feature = "pdf")]
pub fn rasterize_pages(
bytes: &[u8],
range: Option<(usize, usize)>,
) -> Result<Vec<Vec<u8>>, ConversionError> {
use djvu_rs::djvu_render::{render_pixmap, RenderOptions};
use std::io::Cursor;
let max_side: u32 = docling_core::env::parse("DOCLING_RS_DJVU_RENDER_PX").unwrap_or(2500);
let djvu = parse(bytes)?;
let (first, last) = resolve_range(range, djvu.page_count())?;
let mut pngs = Vec::with_capacity(last - first + 1);
for page_no in first..=last {
let page = djvu
.page(page_no - 1)
.map_err(|e| ConversionError::Parse(format!("djvu: page {page_no}: {e}")))?;
let opts = RenderOptions::fit_to_box(page, max_side, max_side);
let pm = render_pixmap(page, &opts)
.map_err(|e| ConversionError::Parse(format!("djvu: rendering page {page_no}: {e}")))?;
let rgba = image::RgbaImage::from_raw(pm.width, pm.height, pm.data).ok_or_else(|| {
ConversionError::Parse(format!("djvu: page {page_no}: pixmap size mismatch"))
})?;
let rgb = image::DynamicImage::ImageRgba8(rgba).to_rgb8();
let mut png = Vec::new();
rgb.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png)
.map_err(|e| ConversionError::Parse(format!("djvu: encoding page {page_no}: {e}")))?;
pngs.push(png);
}
Ok(pngs)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::format::InputFormat;
#[test]
fn magic_recognizes_the_djvu_form_types() {
let mut multi = b"AT&TFORM\x00\x00\x03\x3cDJVM".to_vec();
multi.extend_from_slice(b"rest");
assert!(looks_like_djvu(&multi));
assert!(looks_like_djvu(b"AT&TFORM\x00\x00\x00\x10DJVUxxxx"));
assert!(looks_like_djvu(b"AT&TFORM\x00\x00\x00\x10DJVIxxxx"));
assert!(!looks_like_djvu(b"%PDF-1.7"));
assert!(!looks_like_djvu(b"AT&TFORM\x00\x00\x00\x10PORTxxxx"));
assert!(!looks_like_djvu(b"AT&TFORM"));
}
#[test]
fn paragraphs_reflow_blank_line_blocks() {
let page = "First line \nsecond line \n\nNext paragraph \n\n\n \n";
assert_eq!(
paragraphs(page),
vec![
"First line second line".to_string(),
"Next paragraph".to_string()
]
);
assert!(paragraphs(" \n\n \n").is_empty());
}
#[test]
fn range_resolution_matches_pdf_semantics() {
assert_eq!(resolve_range(None, 5).unwrap(), (1, 5));
assert_eq!(resolve_range(Some((2, 3)), 5).unwrap(), (2, 3));
assert_eq!(resolve_range(Some((2, 99)), 5).unwrap(), (2, 5)); assert!(resolve_range(Some((0, 3)), 5).is_err()); assert!(resolve_range(Some((3, 2)), 5).is_err()); assert!(resolve_range(Some((9, 9)), 5).is_err()); }
#[test]
fn text_layer_of_the_fixture_extracts_in_process() {
let path = concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/data/djvu/sources/example.djvu"
);
let bytes = std::fs::read(path).expect("fixture exists");
let src = SourceDocument::from_bytes("example", InputFormat::Djvu, bytes);
let doc = convert_text_layer(&src, None).expect("converts");
assert!(!is_text_layer_empty(&doc));
let md = doc.export_to_markdown();
assert!(md.contains("RGB Image CMYK Image CMYK Graphic"), "{md:?}");
assert!(md.contains("Black RGB Gray RGB Neutral CIELAB"), "{md:?}");
assert_eq!(
doc.nodes
.iter()
.filter(|n| matches!(n, Node::PageBreak))
.count(),
2,
"3 pages -> 2 page breaks"
);
let p2 = convert_text_layer(&src, Some((2, 2))).expect("converts");
assert!(!p2.nodes.iter().any(|n| matches!(n, Node::PageBreak)));
assert!(!p2.export_to_markdown().contains("RGB Image CMYK Image"));
assert!(convert_text_layer(&src, Some((9, 9))).is_err());
let junk = SourceDocument::from_bytes("junk", InputFormat::Djvu, b"AT&TFORM junk".to_vec());
assert!(convert_text_layer(&junk, None).is_err());
}
}