Skip to main content

text_mirror/convert/
workbook.rs

1//! The workbook converter over calamine.
2//!
3//! Renders every sheet row by row, cells joined with tabs, one row
4//! per line, with one blank line between sheets. A cell that carries
5//! a formula renders its cached value first and the formula as inert
6//! text in the form `value [=formula]`. Formulas are never evaluated.
7//!
8//! Hidden content renders inline, unmarked. The marks are out of
9//! band: the segments records carry a span for every sheet with its
10//! hidden flag, and hidden-true spans for hidden rows and for cells
11//! in hidden columns. Sheet visibility comes from calamine, row and
12//! column visibility from the sibling visibility module, and a file
13//! whose visibility cannot be resolved fails instead of emitting
14//! unmarked content.
15//!
16//! Legacy `xls` runs the differential check against a second
17//! extraction, comparing cell values only.
18
19use 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
30/// Registry id of the workbook converter.
31pub const WORKBOOK_ID: &str = "workbook-ir";
32
33/// Version of the workbook converter.
34pub const WORKBOOK_VERSION: &str = "1.0.0";
35
36/// Normalizes one cell's text: NFC, and structure characters (tabs
37/// and line breaks) become spaces so a cell can never fake a row or
38/// column boundary.
39fn 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
57/// The workbook converter. See the module documentation.
58pub 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        // Visibility resolves first. A workbook never emits unmarked
82        // content, so an unresolvable file fails before any rendering.
83        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            // A sheet the visibility reader did not resolve must not
113            // render, whatever the reason for the disagreement.
114            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            // The secondary sees values only, so the comparison side
169            // excludes formulas and rendering structure.
170            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        // Trailing empty cells render nothing, so a row never ends in
209        // separator tabs.
210        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            // Hidden coordinates always get their record, zero width
240            // when the cell rendered no bytes.
241            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}