use djvu_rs::{DjVuDocument, DjVuPage, Rotation, TextLayer, TextZone, TextZoneKind};
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)))
}
}
}
struct Block {
text: String,
words: usize,
lines: usize,
}
fn blocks(page: &str) -> Vec<Block> {
page.split("\n\n")
.filter_map(|block| {
let words: Vec<&str> = block.split_whitespace().collect();
if words.is_empty() {
return None;
}
let lines = block.split('\n').filter(|l| !l.trim().is_empty()).count();
Some(Block {
text: words.join(" "),
words: words.len(),
lines,
})
})
.collect()
}
#[cfg(test)]
fn paragraphs(page: &str) -> Vec<String> {
blocks(page).into_iter().map(|b| b.text).collect()
}
fn zone_boxes(zones: &[TextZone], kind: TextZoneKind, out: &mut Vec<[u32; 4]>) {
for z in zones {
if z.kind == kind {
let r = &z.rect;
out.push([r.x, r.y, r.x + r.width, r.y + r.height]);
}
zone_boxes(&z.children, kind, out);
}
}
fn block_boxes(layer: &TextLayer, blocks: &[Block]) -> Option<Vec<[u32; 4]>> {
type Count = fn(&Block) -> usize;
let levels: [(TextZoneKind, Count); 3] = [
(TextZoneKind::Word, |b| b.words),
(TextZoneKind::Line, |b| b.lines),
(TextZoneKind::Para, |_| 1),
];
for (kind, count) in levels {
let mut boxes = Vec::new();
zone_boxes(&layer.zones, kind, &mut boxes);
if boxes.is_empty() || boxes.len() != blocks.iter().map(count).sum::<usize>() {
continue;
}
let mut boxes = boxes.into_iter();
return Some(
blocks
.iter()
.map(|b| {
(&mut boxes)
.take(count(b))
.fold([u32::MAX, u32::MAX, 0, 0], |a, z| {
[
a[0].min(z[0]),
a[1].min(z[1]),
a[2].max(z[2]),
a[3].max(z[3]),
]
})
})
.collect(),
);
}
None
}
fn display_dims(page: &DjVuPage) -> (u32, u32) {
let (w, h) = (page.width() as u32, page.height() as u32);
match page.rotation() {
Rotation::Cw90 | Rotation::Ccw90 => (h, w),
_ => (w, h),
}
}
fn dpi(page: &DjVuPage) -> f32 {
match page.dpi() {
0 => 300.0,
d => f32::from(d),
}
}
fn grid(v: u32, dim: u32) -> u16 {
if dim == 0 {
return 0;
}
let g = (512.0 * f64::from(v) / f64::from(dim)).round() as i64;
g.clamp(0, 511) as u16
}
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 (dw, dh) = display_dims(page);
let scale = 72.0 / dpi(page);
doc.push(Node::PageInfo {
page_no,
width: dw as f32 * scale,
height: dh as f32 * scale,
});
let layer = page
.text_layer()
.map_err(|e| ConversionError::Parse(format!("djvu: page {page_no} text layer: {e}")))?;
let Some(layer) = layer else {
continue;
};
let layer = match page.rotation() {
Rotation::None => layer,
rot => layer.transform(page.width() as u32, page.height() as u32, rot, dw, dh),
};
let blocks = blocks(&layer.text);
let boxes = block_boxes(&layer, &blocks);
for (i, block) in blocks.into_iter().enumerate() {
let para = Node::Paragraph { text: block.text };
match boxes.as_ref().map(|b| b[i]) {
Some([l, t, r, b]) => doc.push(Node::Located {
location: [grid(l, dw), grid(t, dh), grid(r, dw), grid(b, dh)],
inner: Box::new(para),
}),
None => doc.push(para),
}
}
}
Ok(doc)
}
pub fn is_text_layer_empty(doc: &DoclingDocument) -> bool {
fn is_paragraph(n: &Node) -> bool {
match n {
Node::Paragraph { .. } => true,
Node::Located { inner, .. } => is_paragraph(inner),
_ => false,
}
}
!doc.nodes.iter().any(is_paragraph)
}
#[cfg(feature = "pdf")]
pub fn rasterize_pages(
bytes: &[u8],
range: Option<(usize, usize)>,
) -> Result<Vec<(usize, 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((page_no, 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 markers: Vec<(usize, f32, f32)> = doc
.nodes
.iter()
.filter_map(|n| match n {
Node::PageInfo {
page_no,
width,
height,
} => Some((*page_no, *width, *height)),
_ => None,
})
.collect();
assert_eq!(
markers,
vec![(1, 612.0, 792.0), (2, 612.0, 792.0), (3, 612.0, 792.0)]
);
assert!(matches!(
doc.nodes.first(),
Some(Node::PageInfo { page_no: 1, .. })
));
let mut located = 0;
for n in &doc.nodes {
match n {
Node::Located { location, inner } => {
assert!(matches!(inner.as_ref(), Node::Paragraph { .. }));
let [l, t, r, b] = *location;
assert!(l < r && t < b && r <= 511 && b <= 511, "{location:?}");
located += 1;
}
Node::Paragraph { .. } => panic!("a bare paragraph escaped its zone box"),
_ => {}
}
}
assert_eq!(located, 3, "one reflowed paragraph per page, each located");
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!(matches!(
p2.nodes.first(),
Some(Node::PageInfo { page_no: 2, .. })
));
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());
}
fn zone(
kind: TextZoneKind,
x: u32,
y: u32,
w: u32,
h: u32,
children: Vec<TextZone>,
) -> TextZone {
TextZone {
kind,
rect: djvu_rs::text::Rect {
x,
y,
width: w,
height: h,
},
text: String::new(),
children,
}
}
#[test]
fn block_boxes_align_words_first() {
let text = "Hello big world\n\nBye now\n";
let layer = TextLayer {
text: text.into(),
zones: vec![zone(
TextZoneKind::Page,
0,
0,
1000,
1000,
vec![
zone(
TextZoneKind::Line,
0,
100,
1000,
50,
vec![
zone(TextZoneKind::Word, 100, 100, 100, 50, vec![]),
zone(TextZoneKind::Word, 210, 100, 100, 50, vec![]),
zone(TextZoneKind::Word, 320, 105, 100, 40, vec![]),
],
),
zone(
TextZoneKind::Line,
0,
300,
1000,
50,
vec![
zone(TextZoneKind::Word, 100, 300, 80, 50, vec![]),
zone(TextZoneKind::Word, 190, 300, 80, 50, vec![]),
],
),
],
)],
};
let blocks = blocks(text);
assert_eq!((blocks[0].words, blocks[0].lines), (3, 1));
assert_eq!((blocks[1].words, blocks[1].lines), (2, 1));
assert_eq!(
block_boxes(&layer, &blocks),
Some(vec![[100, 100, 420, 150], [100, 300, 270, 350]])
);
}
#[test]
fn block_boxes_fall_back_by_level() {
let text = "Hello big\nworld\n\nBye\n";
let lines = |words: Vec<TextZone>| {
vec![zone(
TextZoneKind::Page,
0,
0,
1000,
1000,
vec![
zone(TextZoneKind::Line, 10, 100, 500, 50, words),
zone(TextZoneKind::Line, 10, 160, 300, 50, vec![]),
zone(TextZoneKind::Line, 10, 400, 200, 50, vec![]),
],
)]
};
let blocks = blocks(text);
let layer = TextLayer {
text: text.into(),
zones: lines(vec![
zone(TextZoneKind::Word, 10, 100, 200, 50, vec![]),
zone(TextZoneKind::Word, 220, 100, 200, 50, vec![]),
]),
};
assert_eq!(
block_boxes(&layer, &blocks),
Some(vec![[10, 100, 510, 210], [10, 400, 210, 450]])
);
let layer = TextLayer {
text: text.into(),
zones: vec![zone(
TextZoneKind::Page,
0,
0,
1000,
1000,
vec![zone(TextZoneKind::Line, 0, 0, 10, 10, vec![])],
)],
};
assert_eq!(block_boxes(&layer, &blocks), None);
let layer = TextLayer {
text: text.into(),
zones: vec![],
};
assert_eq!(block_boxes(&layer, &blocks), None);
}
#[test]
fn grid_matches_the_pdf_assembler() {
assert_eq!(grid(0, 1000), 0);
assert_eq!(grid(500, 1000), 256);
assert_eq!(grid(1000, 1000), 511, "the far edge clamps onto the grid");
assert_eq!(grid(7, 0), 0, "a zero-sized page cannot divide");
}
}