1use std::collections::HashMap;
20use std::io::Cursor;
21
22use calamine::{Data, Reader, SheetType, SheetVisible};
23
24use crate::segments::{Segment, SegmentKind};
25
26use super::differential::{self, DifferentialOutcome};
27use super::visibility::{self, SheetVisibility, VisibilityError};
28use super::{ConvertError, Converter, Outcome, normalize_text};
29
30pub const WORKBOOK_ID: &str = "workbook-ir";
32
33pub const WORKBOOK_VERSION: &str = "1.0.0";
35
36fn cell_piece(raw: &str) -> String {
40 normalize_text(raw).replace(['\n', '\t'], " ")
41}
42
43fn value_text(value: &Data) -> String {
44 match value {
45 Data::Empty => String::new(),
46 other => cell_piece(&other.to_string()),
47 }
48}
49
50fn parse_error(detail: impl std::fmt::Display) -> ConvertError {
51 ConvertError {
52 code: "workbook_parse_error",
53 message: detail.to_string(),
54 }
55}
56
57pub struct WorkbookIr;
59
60impl Converter for WorkbookIr {
61 fn id(&self) -> &'static str {
62 WORKBOOK_ID
63 }
64
65 fn version(&self) -> &'static str {
66 WORKBOOK_VERSION
67 }
68
69 fn convert(
70 &self,
71 source: &[u8],
72 detected_format: &str,
73 ) -> std::result::Result<Outcome, ConvertError> {
74 if !matches!(detected_format, "xls" | "xlsx" | "xlsm" | "ods") {
75 return Err(ConvertError {
76 code: "unclaimed_format",
77 message: format!("the workbook converter does not handle {detected_format}"),
78 });
79 }
80
81 let visibility =
84 visibility::read_visibility(source, detected_format).map_err(|error| match error {
85 VisibilityError::Limit(message) => ConvertError {
86 code: "resource_limit",
87 message,
88 },
89 VisibilityError::Unresolved(message) => ConvertError {
90 code: "visibility_read_error",
91 message,
92 },
93 })?;
94
95 let mut workbook = calamine::open_workbook_auto_from_rs(Cursor::new(source.to_vec()))
96 .map_err(parse_error)?;
97 let sheets = workbook.sheets_metadata().to_vec();
98
99 let mut text = String::new();
100 let mut segments = Vec::new();
101 let mut value_stream: Vec<String> = Vec::new();
102
103 for sheet in &sheets {
104 if sheet.typ != SheetType::WorkSheet {
105 continue;
106 }
107 if !text.is_empty() {
108 text.push('\n');
109 }
110 let sheet_start = text.len();
111 let sheet_hidden = !matches!(sheet.visible, SheetVisible::Visible);
112 let Some(sheet_visibility) = visibility.sheet(&sheet.name) else {
115 return Err(ConvertError {
116 code: "visibility_read_error",
117 message: format!("no resolved visibility for sheet {:?}", sheet.name),
118 });
119 };
120
121 let range = workbook.worksheet_range(&sheet.name).map_err(parse_error)?;
122 let formulas = workbook
123 .worksheet_formula(&sheet.name)
124 .map_err(parse_error)?;
125 let mut formula_map: HashMap<(u32, u32), String> = HashMap::new();
126 if let Some((formula_row, formula_col)) = formulas.start() {
127 for (row, col, formula) in formulas.used_cells() {
128 if !formula.is_empty() {
129 formula_map.insert(
130 (formula_row + row as u32, formula_col + col as u32),
131 cell_piece(formula),
132 );
133 }
134 }
135 }
136
137 render_sheet(
138 &range,
139 &formula_map,
140 &sheet_visibility,
141 &mut text,
142 &mut segments,
143 &mut value_stream,
144 );
145
146 segments.push(Segment::boundary(SegmentKind::Sheet, sheet_start).named(&sheet.name));
147 let mut sheet_span = Segment::span(sheet_start, text.len(), "sheet").named(&sheet.name);
148 if sheet_hidden {
149 sheet_span = sheet_span.hidden();
150 }
151 segments.push(sheet_span);
152 }
153
154 segments.sort_by_key(|s| (s.start, s.end));
155
156 if !source.is_empty() && text.is_empty() {
157 return Err(ConvertError {
158 code: "empty_output",
159 message: format!(
160 "source is {} bytes but conversion produced no text",
161 source.len()
162 ),
163 });
164 }
165
166 let mut warnings = Vec::new();
167 if detected_format == "xls" {
168 let values_only = value_stream.join(" ");
171 match differential::check(&values_only, source, "xls") {
172 DifferentialOutcome::Agreement => {}
173 DifferentialOutcome::Warning(warning) => warnings.push(warning),
174 DifferentialOutcome::Failure(detail) => {
175 return Err(ConvertError {
176 code: "differential-divergence",
177 message: detail,
178 });
179 }
180 }
181 }
182
183 Ok(Outcome {
184 converter_id: WORKBOOK_ID.to_string(),
185 converter_version: WORKBOOK_VERSION.to_string(),
186 detected_format: detected_format.to_string(),
187 text,
188 warnings,
189 segments,
190 })
191 }
192}
193
194fn render_sheet(
195 range: &calamine::Range<Data>,
196 formula_map: &HashMap<(u32, u32), String>,
197 sheet_visibility: &SheetVisibility,
198 text: &mut String,
199 segments: &mut Vec<Segment>,
200 value_stream: &mut Vec<String>,
201) {
202 let Some((start_row, start_col)) = range.start() else {
203 return;
204 };
205 for (relative_row, row) in range.rows().enumerate() {
206 let absolute_row = start_row + relative_row as u32;
207 let line_start = text.len();
208 let rendered_width = row
211 .iter()
212 .enumerate()
213 .rev()
214 .find(|(relative_col, value)| {
215 **value != Data::Empty
216 || formula_map.contains_key(&(absolute_row, start_col + *relative_col as u32))
217 })
218 .map(|(relative_col, _)| relative_col + 1)
219 .unwrap_or(0);
220 for (relative_col, value) in row.iter().take(rendered_width).enumerate() {
221 if relative_col > 0 {
222 text.push('\t');
223 }
224 let absolute_col = start_col + relative_col as u32;
225 let cell_start = text.len();
226 let value_piece = value_text(value);
227 if !value_piece.is_empty() {
228 value_stream.push(value_piece.clone());
229 }
230 match formula_map.get(&(absolute_row, absolute_col)) {
231 Some(formula) if value_piece.is_empty() => {
232 text.push_str(&format!("[={formula}]"));
233 }
234 Some(formula) => {
235 text.push_str(&format!("{value_piece} [={formula}]"));
236 }
237 None => text.push_str(&value_piece),
238 }
239 if sheet_visibility.column_hidden(absolute_col) {
242 segments.push(Segment::span(cell_start, text.len(), "column").hidden());
243 }
244 }
245 text.push('\n');
246 let line_end = text.len() - 1;
247 if sheet_visibility.row_hidden(absolute_row) {
248 segments.push(Segment::span(line_start, line_end, "row").hidden());
249 }
250 }
251}