1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
//! XLS (Excel 97–2004, BIFF8) backend — issue #127.
//!
//! Native parsing of the legacy binary workbook: `calamine`'s `Xls` reader
//! decodes the CFB container and BIFF records, and the sheet content then goes
//! through the *same* region detection as the XLSX backend ([`find_tables`]),
//! so a workbook produces identical tables whether it arrives as `.xls` or
//! `.xlsx`. docling proper reaches these files by shelling out to LibreOffice
//! (`docling` PR #3804); here the format is a first-class input instead.
//!
//! Scope: cell data (tables), merged regions, sheet order/visibility and page
//! breaks. Drawings, charts and comments are OOXML-part features in the XLSX
//! backend and have no BIFF equivalent here (calamine does not expose them).
use std::io::Cursor;
use calamine::{Reader, Xls};
use docling_core::{DoclingDocument, Node};
use crate::backend::xlsx::{find_tables, location_value, sheet_frame, Merges};
use crate::backend::DeclarativeBackend;
use crate::error::ConversionError;
use crate::source::SourceDocument;
#[derive(Default)]
pub struct XlsBackend {
/// Omit empty cells from sparse-sheet table grids (#271, opt-in) — see
/// [`find_tables`].
pub skip_empty: bool,
}
impl DeclarativeBackend for XlsBackend {
fn convert(&self, source: &SourceDocument) -> Result<DoclingDocument, ConversionError> {
let cursor = Cursor::new(source.bytes.clone());
let mut workbook: Xls<_> =
Xls::new(cursor).map_err(|e| ConversionError::with_source("xls", e))?;
let metas: Vec<(String, calamine::SheetType, calamine::SheetVisible)> = workbook
.sheets_metadata()
.iter()
.map(|s| (s.name.clone(), s.typ, s.visible))
.collect();
let mut doc = DoclingDocument::new(&source.name);
let mut prev_item_page = false;
for (name, typ, visible) in &metas {
if !matches!(typ, calamine::SheetType::WorkSheet) {
continue;
}
let Ok(range) = workbook.worksheet_range(name) else {
continue;
};
let merges: Merges = workbook
.merge_cells_by_sheet_name(name)
.unwrap_or_default()
.iter()
.map(|d| (d.start, d.end))
.collect();
// Same shaping as the XLSX backend: absolute cell coordinates, a
// merge-covered cell counts as content and renders its top-left.
let frame = sheet_frame(&range, &merges);
let (or, oc) = frame.origin;
let mut items: Vec<((usize, usize, usize, usize), Node)> = Vec::new();
for t in find_tables(&range, &frame, self.skip_empty) {
if let Some(label) = t.label {
items.push((
(
oc + t.min_c,
or + t.min_r.saturating_sub(1),
oc + t.max_c + 1,
or + t.min_r,
),
Node::Paragraph { text: label },
));
}
items.push((
(
oc + t.min_c,
or + t.min_r,
oc + t.max_c + 1,
or + t.max_r + 1,
),
Node::Table(t.table),
));
}
if items.is_empty() {
continue;
}
items.sort_by_key(|((_, t, _, _), _)| *t);
let page_w = items.iter().map(|((_, _, r, _), _)| *r).max().unwrap_or(1);
let page_h = items.iter().map(|((_, _, _, b), _)| *b).max().unwrap_or(1);
let hidden = !matches!(visible, calamine::SheetVisible::Visible);
for ((l, t, r, b), mut node) in items {
if let Node::Table(table) = &mut node {
table.location = Some([
location_value(l, page_w),
location_value(t, page_h),
location_value(r, page_w),
location_value(b, page_h),
]);
}
let node = if hidden {
Node::Furniture {
layer: docling_core::ContentLayer::Invisible,
inner: Box::new(node),
}
} else {
node
};
doc.push(node);
}
if prev_item_page {
doc.push(Node::PageBreak);
}
prev_item_page = true;
}
Ok(doc)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::InputFormat;
#[test]
fn garbage_is_an_error_not_a_panic() {
let src = SourceDocument::from_bytes("x.xls", InputFormat::Xls, vec![0u8; 64]);
assert!(XlsBackend::default().convert(&src).is_err());
}
}