Skip to main content

document_svg/document/
ods.rs

1//! OpenDocument Spreadsheet (ODS/OTS/FODS) table preview.
2//!
3//! Reads cached cell display text, row/column repetitions, sheet order, and
4//! bounded package-linked PNG/JPEG images from `content.xml`. It does not
5//! recalculate formulas or reproduce spreadsheet styles and drawing geometry.
6
7use std::fs::File;
8use std::io::Read;
9use std::path::Path;
10
11use base64::Engine;
12use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
13use quick_xml::Reader;
14use quick_xml::events::{BytesStart, Event};
15
16use crate::convert::{ConvertOptions, PageConsumer};
17use crate::document::html::{HtmlBlock, render_blocks_to_pages};
18use crate::error::{Error, Result};
19use crate::ooxml::{
20    ZipPackage, attribute, decode_xml_reference, local_name, resolve_part_target, sniff_image_mime,
21};
22use crate::table::{TableAlign, TableData};
23
24const ODS_MIMETYPE_LIMIT: u64 = 256;
25
26pub(crate) fn looks_like_legacy_calc_archive(path: &Path) -> bool {
27    let Ok(mut package) = ZipPackage::open(path, ODS_MIMETYPE_LIMIT) else {
28        return false;
29    };
30    let Ok(mimetype) = package.read_limited("mimetype", ODS_MIMETYPE_LIMIT) else {
31        return false;
32    };
33    std::str::from_utf8(&mimetype).ok().is_some_and(|value| {
34        matches!(
35            value.trim(),
36            "application/vnd.sun.xml.calc" | "application/vnd.sun.xml.calc.template"
37        )
38    })
39}
40const MAX_ODS_REPEAT: usize = 10_000;
41const MAX_ODS_TABLE_CELLS: usize = 200_000;
42const MAX_ODS_TABLE_ROWS: usize = 100_000;
43const MAX_ODS_XML_DEPTH: usize = 256;
44const MAX_ODS_IMAGES: usize = 10_000;
45const MAX_ODS_IMAGE_BYTES: u64 = 8 * 1024 * 1024;
46const MAX_ODS_TOTAL_IMAGE_BYTES: usize = 32 * 1024 * 1024;
47const MAX_ODS_TOTAL_DATA_URI_BYTES: usize = 48 * 1024 * 1024;
48const MAX_ODS_IMAGE_PIXELS: u64 = 40_000_000;
49const MAX_ODS_TOTAL_IMAGE_PIXELS: u64 = 100_000_000;
50
51#[derive(Default)]
52struct SheetBuilder {
53    name: String,
54    headers: Vec<String>,
55    rows: Vec<Vec<String>>,
56    total_cells: usize,
57    total_rows: usize,
58    images: Vec<HtmlBlock>,
59}
60
61#[derive(Default)]
62struct OdsImageBudget {
63    references: usize,
64    image_bytes: usize,
65    data_uri_bytes: usize,
66    pixels: u64,
67}
68
69struct PendingImage {
70    depth: usize,
71    href: Option<String>,
72    alt: String,
73    has_inline_binary: bool,
74    inline_data: Option<String>,
75    over_limit: bool,
76}
77
78struct OdsImageContext<'a> {
79    package: Option<&'a mut ZipPackage<File>>,
80    budget: &'a mut OdsImageBudget,
81    warnings: &'a mut Vec<String>,
82    sheet: &'a mut Option<SheetBuilder>,
83}
84
85pub(crate) fn convert(
86    path: &Path,
87    options: &ConvertOptions,
88    sink: &mut dyn PageConsumer,
89) -> Result<Vec<String>> {
90    let (bytes, mut package) = if path
91        .extension()
92        .and_then(|extension| extension.to_str())
93        .is_some_and(|extension| extension.eq_ignore_ascii_case("fods"))
94    {
95        let mut file = File::open(path)?;
96        let mut bytes = Vec::new();
97        Read::take(&mut file, options.max_input_bytes.saturating_add(1)).read_to_end(&mut bytes)?;
98        if bytes.len() as u64 > options.max_input_bytes {
99            return Err(Error::LimitExceeded(format!(
100                "FODS input exceeds maximum bytes ({})",
101                options.max_input_bytes
102            )));
103        }
104        (bytes, None)
105    } else {
106        let mut package = ZipPackage::open(path, options.max_zip_entry_bytes)?;
107        let mimetype = package.read_limited("mimetype", ODS_MIMETYPE_LIMIT)?;
108        let mimetype = std::str::from_utf8(&mimetype)
109            .map_err(|error| Error::InvalidInput(format!("ODS mimetype is not UTF-8: {error}")))?
110            .trim();
111        if !matches!(
112            mimetype,
113            "application/vnd.oasis.opendocument.spreadsheet"
114                | "application/vnd.oasis.opendocument.spreadsheet-template"
115                | "application/vnd.sun.xml.calc"
116                | "application/vnd.sun.xml.calc.template"
117        ) {
118            return Err(Error::InvalidInput(format!(
119                "unsupported OpenDocument spreadsheet mimetype '{mimetype}'"
120            )));
121        }
122        let content = package.read("content.xml")?;
123        (content, Some(package))
124    };
125
126    let xml = String::from_utf8(bytes)
127        .map_err(|error| Error::InvalidInput(format!("ODS content is not UTF-8: {error}")))?;
128    let (blocks, mut warnings) = parse_content(&xml, options.max_xml_events, package.as_mut())?;
129    if blocks.is_empty() {
130        return Err(Error::InvalidInput(
131            "OpenDocument Spreadsheet contains no renderable sheets".into(),
132        ));
133    }
134    warnings.insert(
135        0,
136        "ODS cell styles, number formats, conditional formatting, and print layout are not reproduced".into(),
137    );
138    render_blocks_to_pages(&blocks, sink, options)?;
139    Ok(warnings)
140}
141
142fn parse_content(
143    xml: &str,
144    max_events: usize,
145    mut package: Option<&mut ZipPackage<File>>,
146) -> Result<(Vec<HtmlBlock>, Vec<String>)> {
147    let mut reader = Reader::from_str(xml);
148    reader.config_mut().trim_text(false);
149    let mut buffer = Vec::new();
150    let mut stack = Vec::new();
151    let mut blocks = Vec::new();
152    let mut warnings = Vec::new();
153    let mut in_spreadsheet = false;
154    let mut sheet: Option<SheetBuilder> = None;
155    let mut row: Option<Vec<String>> = None;
156    let mut row_is_header = false;
157    let mut row_repeats = 1usize;
158    let mut header_depth = 0usize;
159    let mut cell: Option<String> = None;
160    let mut cell_fallback = String::new();
161    let mut cell_has_text = false;
162    let mut cell_repeats = 1usize;
163    let mut paragraph: Option<String> = None;
164    let mut event_count = 0usize;
165    let mut warned_graphics = false;
166    let mut warned_formulas = false;
167    let mut warned_image_flow = false;
168    let mut image_budget = OdsImageBudget::default();
169    let mut pending_image: Option<PendingImage> = None;
170    let mut frame_alt: Vec<String> = Vec::new();
171
172    loop {
173        event_count = event_count.saturating_add(1);
174        if event_count > max_events {
175            return Err(Error::LimitExceeded(format!(
176                "ODS content.xml exceeds {max_events} parser events"
177            )));
178        }
179        match reader.read_event_into(&mut buffer)? {
180            Event::Start(ref element) => {
181                let name =
182                    String::from_utf8_lossy(local_name(element.name().as_ref())).into_owned();
183                if name == "spreadsheet" && stack.last().is_some_and(|parent| parent == "body") {
184                    in_spreadsheet = true;
185                }
186                if in_spreadsheet {
187                    match name.as_str() {
188                        "table" => {
189                            if sheet.is_some() {
190                                return Err(Error::Unsupported(
191                                    "nested OpenDocument spreadsheet tables are unsupported".into(),
192                                ));
193                            }
194                            sheet = Some(SheetBuilder {
195                                name: attribute(element, b"name")
196                                    .unwrap_or_else(|| format!("Sheet {}", blocks.len() + 1)),
197                                ..SheetBuilder::default()
198                            });
199                        }
200                        "table-header-rows" => header_depth = header_depth.saturating_add(1),
201                        "table-row" => {
202                            if row.is_some() {
203                                return Err(Error::InvalidInput(
204                                    "nested ODS table rows are invalid".into(),
205                                ));
206                            }
207                            row = Some(Vec::new());
208                            row_is_header = header_depth > 0;
209                            row_repeats = parse_repeat(element, b"number-rows-repeated")?;
210                        }
211                        "table-cell" => {
212                            if cell.is_some() {
213                                return Err(Error::InvalidInput(
214                                    "nested ODS table cells are invalid".into(),
215                                ));
216                            }
217                            cell = Some(String::new());
218                            cell_has_text = false;
219                            cell_repeats = parse_repeat(element, b"number-columns-repeated")?;
220                            cell_fallback = cached_cell_value(element);
221                            if attribute(element, b"formula").is_some() && !warned_formulas {
222                                warnings.push(
223                                    "ODS formulas are not recalculated; cached cell values are shown".into(),
224                                );
225                                warned_formulas = true;
226                            }
227                        }
228                        "p" => {
229                            if cell.is_some() {
230                                paragraph = Some(String::new());
231                            }
232                        }
233                        "frame" => {
234                            frame_alt.push(attribute(element, b"name").unwrap_or_default());
235                        }
236                        "image" => {
237                            if pending_image.is_some() {
238                                return Err(Error::InvalidInput(
239                                    "nested ODS images are invalid".into(),
240                                ));
241                            }
242                            pending_image = Some(PendingImage {
243                                depth: stack.len() + 1,
244                                href: attribute(element, b"href"),
245                                alt: attribute(element, b"name")
246                                    .filter(|value| !value.trim().is_empty())
247                                    .or_else(|| {
248                                        frame_alt
249                                            .last()
250                                            .filter(|value| !value.trim().is_empty())
251                                            .cloned()
252                                    })
253                                    .unwrap_or_else(|| "Embedded image".into()),
254                                has_inline_binary: false,
255                                inline_data: None,
256                                over_limit: !reserve_ods_image(&mut image_budget, &mut warnings),
257                            });
258                            if !warned_image_flow {
259                                push_ods_warning_once(
260                                    &mut warnings,
261                                    "ODS package-linked PNG/JPEG images are rendered after the sheet table; cell anchors, frame dimensions, and z-order are approximated",
262                                );
263                                warned_image_flow = true;
264                            }
265                        }
266                        "binary-data" if pending_image.is_some() => {
267                            if let Some(image) = pending_image.as_mut() {
268                                image.has_inline_binary = true;
269                                image.inline_data = Some(String::new());
270                            }
271                        }
272                        "object" | "chart" | "text-box" | "custom-shape" | "g" | "rect"
273                        | "circle" | "ellipse" | "line" | "measure" | "regular-polygon"
274                        | "polygon" | "polyline" | "path" | "caption" | "page-thumbnail"
275                        | "connector" | "control" | "plugin" | "applet" | "floating-frame" => {
276                            if !warned_graphics {
277                                warnings.push(
278                                    "ODS embedded charts, vector drawings, text boxes, and OLE objects are omitted; package-linked PNG/JPEG images are supported"
279                                        .into(),
280                                );
281                                warned_graphics = true;
282                            }
283                        }
284                        _ => {}
285                    }
286                }
287                if stack.len() >= MAX_ODS_XML_DEPTH {
288                    return Err(Error::LimitExceeded(format!(
289                        "ODS XML nesting exceeds {MAX_ODS_XML_DEPTH} elements"
290                    )));
291                }
292                stack.push(name);
293            }
294            Event::Empty(ref element) => {
295                if in_spreadsheet {
296                    match local_name(element.name().as_ref()) {
297                        b"table-cell" => {
298                            let value = cached_cell_value(element);
299                            if attribute(element, b"formula").is_some() && !warned_formulas {
300                                warnings.push(
301                                    "ODS formulas are not recalculated; cached cell values are shown".into(),
302                                );
303                                warned_formulas = true;
304                            }
305                            append_cell(
306                                value,
307                                parse_repeat(element, b"number-columns-repeated")?,
308                                &mut row,
309                            )?;
310                        }
311                        b"covered-table-cell" => append_cell(String::new(), 1, &mut row)?,
312                        b"image" => {
313                            let alt = attribute(element, b"name")
314                                .filter(|value| !value.trim().is_empty())
315                                .or_else(|| {
316                                    frame_alt
317                                        .last()
318                                        .filter(|value| !value.trim().is_empty())
319                                        .cloned()
320                                })
321                                .unwrap_or_else(|| "Embedded image".into());
322                            if !warned_image_flow {
323                                push_ods_warning_once(
324                                    &mut warnings,
325                                    "ODS package-linked PNG/JPEG images are rendered after the sheet table; cell anchors, frame dimensions, and z-order are approximated",
326                                );
327                                warned_image_flow = true;
328                            }
329                            if reserve_ods_image(&mut image_budget, &mut warnings) {
330                                attach_ods_image(
331                                    attribute(element, b"href").as_deref(),
332                                    None,
333                                    &alt,
334                                    OdsImageContext {
335                                        package: package.as_deref_mut(),
336                                        budget: &mut image_budget,
337                                        warnings: &mut warnings,
338                                        sheet: &mut sheet,
339                                    },
340                                )?;
341                            }
342                        }
343                        b"object" | b"chart" | b"text-box" | b"custom-shape" | b"g" | b"rect"
344                        | b"circle" | b"ellipse" | b"line" | b"measure" | b"regular-polygon"
345                        | b"polygon" | b"polyline" | b"path" | b"caption" | b"page-thumbnail"
346                        | b"connector" | b"control" | b"plugin" | b"applet" | b"floating-frame" => {
347                            if !warned_graphics {
348                                push_ods_warning_once(
349                                    &mut warnings,
350                                    "ODS embedded charts, vector drawings, text boxes, and OLE objects are omitted; package-linked PNG/JPEG images are supported",
351                                );
352                                warned_graphics = true;
353                            }
354                        }
355                        _ => {}
356                    }
357                }
358            }
359            Event::Text(ref value) => {
360                if in_spreadsheet && stack.iter().any(|element| element == "binary-data") {
361                    let decoded = value.decode().map_err(|error| {
362                        Error::InvalidInput(format!("invalid ODS inline image encoding: {error}"))
363                    })?;
364                    if let Some(image) = pending_image.as_mut()
365                        && let Some(data) = image.inline_data.as_mut()
366                    {
367                        if data.len().saturating_add(decoded.len())
368                            > (MAX_ODS_IMAGE_BYTES as usize).saturating_mul(2)
369                        {
370                            return Err(Error::LimitExceeded(
371                                "ODS inline image base64 exceeds the per-image limit".into(),
372                            ));
373                        }
374                        data.push_str(&decoded);
375                    }
376                } else if in_spreadsheet && cell.is_some() {
377                    let decoded = value.decode().map_err(|error| {
378                        Error::InvalidInput(format!("invalid ODS text encoding: {error}"))
379                    })?;
380                    let text = quick_xml::escape::unescape(&decoded).map_err(|error| {
381                        Error::InvalidInput(format!("invalid ODS XML text: {error}"))
382                    })?;
383                    if let Some(paragraph) = paragraph.as_mut() {
384                        paragraph.push_str(&text);
385                    } else if let Some(cell) = cell.as_mut() {
386                        cell.push_str(&text);
387                    }
388                    cell_has_text = true;
389                }
390            }
391            Event::GeneralRef(ref reference) => {
392                if in_spreadsheet
393                    && cell.is_some()
394                    && !stack.iter().any(|element| element == "binary-data")
395                {
396                    let text = decode_xml_reference(reference, "ODS cell text")?;
397                    if let Some(paragraph) = paragraph.as_mut() {
398                        paragraph.push_str(&text);
399                    } else if let Some(cell) = cell.as_mut() {
400                        cell.push_str(&text);
401                    }
402                    cell_has_text = true;
403                }
404            }
405            Event::CData(ref value) => {
406                if in_spreadsheet && stack.iter().any(|element| element == "binary-data") {
407                    let text = value.decode().map_err(|error| {
408                        Error::InvalidInput(format!("invalid ODS inline image encoding: {error}"))
409                    })?;
410                    if let Some(image) = pending_image.as_mut()
411                        && let Some(data) = image.inline_data.as_mut()
412                    {
413                        if data.len().saturating_add(text.len())
414                            > (MAX_ODS_IMAGE_BYTES as usize).saturating_mul(2)
415                        {
416                            return Err(Error::LimitExceeded(
417                                "ODS inline image base64 exceeds the per-image limit".into(),
418                            ));
419                        }
420                        data.push_str(&text);
421                    }
422                } else if in_spreadsheet && cell.is_some() {
423                    let text = value.decode().map_err(|error| {
424                        Error::InvalidInput(format!("invalid ODS CDATA encoding: {error}"))
425                    })?;
426                    if let Some(paragraph) = paragraph.as_mut() {
427                        paragraph.push_str(&text);
428                    } else if let Some(cell) = cell.as_mut() {
429                        cell.push_str(&text);
430                    }
431                    cell_has_text = true;
432                }
433            }
434            Event::DocType(_) => {
435                return Err(Error::InvalidInput(
436                    "OpenDocument spreadsheet must not contain a document type declaration".into(),
437                ));
438            }
439            Event::End(ref element) => {
440                let qualified_name = element.name();
441                let name = local_name(qualified_name.as_ref());
442                match name {
443                    b"image" if pending_image.is_some() => {
444                        let pending = pending_image.take().ok_or_else(|| {
445                            Error::InvalidInput("ODS image ended without opening".into())
446                        })?;
447                        if pending.depth != stack.len() {
448                            return Err(Error::InvalidInput("mismatched ODS image nesting".into()));
449                        }
450                        if pending.over_limit {
451                            // The common count-limit warning was added when the reference was seen.
452                        } else if pending.has_inline_binary {
453                            attach_ods_image(
454                                pending.href.as_deref(),
455                                pending.inline_data.as_deref(),
456                                &pending.alt,
457                                OdsImageContext {
458                                    package: package.as_deref_mut(),
459                                    budget: &mut image_budget,
460                                    warnings: &mut warnings,
461                                    sheet: &mut sheet,
462                                },
463                            )?;
464                        } else {
465                            attach_ods_image(
466                                pending.href.as_deref(),
467                                None,
468                                &pending.alt,
469                                OdsImageContext {
470                                    package: package.as_deref_mut(),
471                                    budget: &mut image_budget,
472                                    warnings: &mut warnings,
473                                    sheet: &mut sheet,
474                                },
475                            )?;
476                        }
477                    }
478                    b"frame" => {
479                        frame_alt.pop();
480                    }
481                    b"p" => {
482                        if let Some(text) = paragraph.take()
483                            && let Some(cell) = cell.as_mut()
484                        {
485                            if !cell.is_empty() {
486                                cell.push('\n');
487                            }
488                            cell.push_str(text.trim());
489                        }
490                    }
491                    b"table-cell" => {
492                        let text = cell.take().ok_or_else(|| {
493                            Error::InvalidInput("ODS table cell ended without opening".into())
494                        })?;
495                        let text_value = text.trim();
496                        let value = if cell_has_text && !text_value.is_empty() {
497                            text_value.to_owned()
498                        } else {
499                            cell_fallback.clone()
500                        };
501                        append_cell(value, cell_repeats, &mut row)?;
502                        cell_fallback.clear();
503                        cell_has_text = false;
504                        cell_repeats = 1;
505                    }
506                    b"covered-table-cell" => append_cell(String::new(), 1, &mut row)?,
507                    b"table-row" => {
508                        let values = row.take().ok_or_else(|| {
509                            Error::InvalidInput("ODS table row ended without opening".into())
510                        })?;
511                        append_row(values, row_repeats, row_is_header, &mut sheet)?;
512                        row_repeats = 1;
513                        row_is_header = false;
514                    }
515                    b"table-header-rows" => header_depth = header_depth.saturating_sub(1),
516                    b"table" => {
517                        let builder = sheet.take().ok_or_else(|| {
518                            Error::InvalidInput("ODS table ended without opening".into())
519                        })?;
520                        let mut headers = builder.headers;
521                        let mut rows = builder.rows;
522                        if headers.is_empty() && !rows.is_empty() {
523                            headers = rows.remove(0);
524                        }
525                        let column_count = headers
526                            .len()
527                            .max(rows.iter().map(Vec::len).max().unwrap_or(0));
528                        let has_table = column_count > 0;
529                        if has_table {
530                            blocks.push(HtmlBlock::Heading {
531                                level: 2,
532                                text: builder.name.clone(),
533                            });
534                            blocks.push(HtmlBlock::Table(TableData {
535                                headers,
536                                rows,
537                                alignments: vec![TableAlign::Left; column_count],
538                                raw_source: String::new(),
539                            }));
540                        }
541                        if !builder.images.is_empty() {
542                            if !has_table {
543                                blocks.push(HtmlBlock::Heading {
544                                    level: 2,
545                                    text: builder.name,
546                                });
547                            }
548                            blocks.extend(builder.images);
549                        }
550                    }
551                    b"spreadsheet" => in_spreadsheet = false,
552                    _ => {}
553                }
554                let closing = String::from_utf8_lossy(name).into_owned();
555                if stack.pop().as_deref() != Some(closing.as_str()) {
556                    return Err(Error::InvalidInput(format!(
557                        "mismatched ODS XML end tag '{closing}'"
558                    )));
559                }
560            }
561            Event::Eof => break,
562            _ => {}
563        }
564        buffer.clear();
565    }
566    if sheet.is_some() || row.is_some() || cell.is_some() || paragraph.is_some() {
567        return Err(Error::InvalidInput(
568            "incomplete ODS spreadsheet XML structure".into(),
569        ));
570    }
571    Ok((blocks, warnings))
572}
573
574fn attach_ods_image(
575    href: Option<&str>,
576    inline_data: Option<&str>,
577    alt: &str,
578    context: OdsImageContext<'_>,
579) -> Result<()> {
580    let OdsImageContext {
581        package,
582        budget,
583        warnings,
584        sheet,
585    } = context;
586    if sheet.is_none() {
587        push_ods_warning_once(warnings, "ODS image outside a supported sheet was omitted");
588        return Ok(());
589    }
590    if let Some(inline_data) = inline_data {
591        return attach_ods_inline_image(inline_data, alt, warnings, budget, sheet);
592    }
593    let Some(package) = package else {
594        push_ods_warning_once(
595            warnings,
596            "flat OpenDocument images and external resources are omitted; package-linked PNG/JPEG images are supported",
597        );
598        return Ok(());
599    };
600    let Some(href) = href.filter(|href| !href.trim().is_empty()) else {
601        push_ods_warning_once(warnings, "ODS image without a package href was omitted");
602        return Ok(());
603    };
604    if href.contains(':') || href.starts_with("//") || href.starts_with('\\') {
605        push_ods_warning_once(warnings, "external ODS image resources are not fetched");
606        return Ok(());
607    }
608    let package_href = href.strip_prefix('/').unwrap_or(href);
609    let target = match resolve_part_target("content.xml", package_href) {
610        Ok(target) => target,
611        Err(_) => {
612            push_ods_warning_once(
613                warnings,
614                "ODS image path escaped the package or was invalid and was omitted",
615            );
616            return Ok(());
617        }
618    };
619    let bytes = match package.read_optional_limited(&target, MAX_ODS_IMAGE_BYTES) {
620        Ok(Some(bytes)) => bytes,
621        Ok(None) => {
622            push_ods_warning_once(warnings, "missing ODS image parts were omitted");
623            return Ok(());
624        }
625        Err(Error::LimitExceeded(_)) => {
626            push_ods_warning_once(
627                warnings,
628                "ODS image parts exceeding the per-image byte limit were omitted",
629            );
630            return Ok(());
631        }
632        Err(error) => return Err(error),
633    };
634    let Some(mime) =
635        sniff_image_mime(&bytes).filter(|mime| matches!(*mime, "image/png" | "image/jpeg"))
636    else {
637        push_ods_warning_once(
638            warnings,
639            "unsupported ODS image types were omitted; only PNG and JPEG are embedded",
640        );
641        return Ok(());
642    };
643    let Some((width, height)) = crate::document::mhtml::image_dimensions(&bytes, mime) else {
644        push_ods_warning_once(warnings, "invalid ODS PNG/JPEG images were omitted");
645        return Ok(());
646    };
647    let pixels = u64::from(width) * u64::from(height);
648    let next_pixels = budget.pixels.saturating_add(pixels);
649    if width == 0
650        || height == 0
651        || pixels > MAX_ODS_IMAGE_PIXELS
652        || next_pixels > MAX_ODS_TOTAL_IMAGE_PIXELS
653    {
654        push_ods_warning_once(
655            warnings,
656            "ODS images exceeding the per-image or total pixel limit were omitted",
657        );
658        return Ok(());
659    }
660    let next_image_bytes = budget.image_bytes.saturating_add(bytes.len());
661    if next_image_bytes > MAX_ODS_TOTAL_IMAGE_BYTES {
662        push_ods_warning_once(
663            warnings,
664            "ODS images exceeding the total image byte limit were omitted",
665        );
666        return Ok(());
667    }
668    let prefix = format!("data:{mime};base64,");
669    let uri_bytes = prefix
670        .len()
671        .saturating_add(bytes.len().div_ceil(3).saturating_mul(4));
672    let next_uri_bytes = budget.data_uri_bytes.saturating_add(uri_bytes);
673    if next_uri_bytes > MAX_ODS_TOTAL_DATA_URI_BYTES {
674        push_ods_warning_once(
675            warnings,
676            "ODS images exceeding the total data URI byte limit were omitted",
677        );
678        return Ok(());
679    }
680    let image = HtmlBlock::Image {
681        href: format!("{prefix}{}", BASE64_STANDARD.encode(&bytes)),
682        pixel_width: width,
683        pixel_height: height,
684        alt: alt.to_owned(),
685    };
686    if let Some(sheet) = sheet.as_mut() {
687        sheet.images.push(image);
688    } else {
689        push_ods_warning_once(warnings, "ODS image outside a supported sheet was omitted");
690        return Ok(());
691    }
692    budget.image_bytes = next_image_bytes;
693    budget.data_uri_bytes = next_uri_bytes;
694    budget.pixels = next_pixels;
695    Ok(())
696}
697
698fn attach_ods_inline_image(
699    data: &str,
700    alt: &str,
701    warnings: &mut Vec<String>,
702    budget: &mut OdsImageBudget,
703    sheet: &mut Option<SheetBuilder>,
704) -> Result<()> {
705    let compact: String = data
706        .chars()
707        .filter(|character| !character.is_ascii_whitespace())
708        .collect();
709    let bytes = match BASE64_STANDARD.decode(compact.as_bytes()) {
710        Ok(bytes) => bytes,
711        Err(_) => {
712            push_ods_warning_once(
713                warnings,
714                "malformed ODS inline office:binary-data image was omitted",
715            );
716            return Ok(());
717        }
718    };
719    if bytes.len() as u64 > MAX_ODS_IMAGE_BYTES {
720        push_ods_warning_once(
721            warnings,
722            "ODS inline image exceeded the per-image byte limit",
723        );
724        return Ok(());
725    }
726    let Some(mime) =
727        sniff_image_mime(&bytes).filter(|mime| matches!(*mime, "image/png" | "image/jpeg"))
728    else {
729        push_ods_warning_once(
730            warnings,
731            "unsupported ODS inline image type was omitted; only PNG and JPEG are embedded",
732        );
733        return Ok(());
734    };
735    let Some((width, height)) = crate::document::mhtml::image_dimensions(&bytes, mime) else {
736        push_ods_warning_once(warnings, "invalid ODS inline PNG/JPEG image was omitted");
737        return Ok(());
738    };
739    let pixels = u64::from(width).saturating_mul(u64::from(height));
740    let next_pixels = budget.pixels.saturating_add(pixels);
741    if width == 0
742        || height == 0
743        || pixels > MAX_ODS_IMAGE_PIXELS
744        || next_pixels > MAX_ODS_TOTAL_IMAGE_PIXELS
745    {
746        push_ods_warning_once(warnings, "ODS inline images exceeded the pixel limit");
747        return Ok(());
748    }
749    let next_bytes = budget.image_bytes.saturating_add(bytes.len());
750    if next_bytes > MAX_ODS_TOTAL_IMAGE_BYTES {
751        push_ods_warning_once(warnings, "ODS inline images exceeded the total byte limit");
752        return Ok(());
753    }
754    let prefix = format!("data:{mime};base64,");
755    let next_uri = budget
756        .data_uri_bytes
757        .saturating_add(prefix.len() + bytes.len().div_ceil(3) * 4);
758    if next_uri > MAX_ODS_TOTAL_DATA_URI_BYTES {
759        push_ods_warning_once(
760            warnings,
761            "ODS inline images exceeded the total data URI limit",
762        );
763        return Ok(());
764    }
765    if let Some(sheet) = sheet.as_mut() {
766        sheet.images.push(HtmlBlock::Image {
767            href: format!("{prefix}{}", BASE64_STANDARD.encode(&bytes)),
768            pixel_width: width,
769            pixel_height: height,
770            alt: alt.to_owned(),
771        });
772    } else {
773        push_ods_warning_once(
774            warnings,
775            "ODS inline image outside a supported sheet was omitted",
776        );
777        return Ok(());
778    }
779    budget.image_bytes = next_bytes;
780    budget.data_uri_bytes = next_uri;
781    budget.pixels = next_pixels;
782    Ok(())
783}
784
785fn reserve_ods_image(budget: &mut OdsImageBudget, warnings: &mut Vec<String>) -> bool {
786    if budget.references >= MAX_ODS_IMAGES {
787        push_ods_warning_once(
788            warnings,
789            "ODS image count exceeded the supported limit; remaining images were omitted",
790        );
791        false
792    } else {
793        budget.references += 1;
794        true
795    }
796}
797
798fn push_ods_warning_once(warnings: &mut Vec<String>, warning: &str) {
799    if !warnings.iter().any(|existing| existing == warning) {
800        warnings.push(warning.to_owned());
801    }
802}
803
804fn cached_cell_value(element: &BytesStart<'_>) -> String {
805    let value_type = attribute(element, b"value-type").unwrap_or_default();
806    let key = match value_type.as_str() {
807        "string" => b"string-value" as &[u8],
808        "date" => b"date-value",
809        "time" => b"time-value",
810        "boolean" => b"boolean-value",
811        _ => b"value",
812    };
813    attribute(element, key).unwrap_or_default()
814}
815
816fn parse_repeat(element: &BytesStart<'_>, name: &[u8]) -> Result<usize> {
817    let count = attribute(element, name)
818        .map(|value| {
819            value.parse::<usize>().map_err(|_| {
820                Error::InvalidInput(format!(
821                    "invalid ODS repeat count for '{}'",
822                    String::from_utf8_lossy(name)
823                ))
824            })
825        })
826        .transpose()?
827        .unwrap_or(1);
828    if count == 0 || count > MAX_ODS_REPEAT {
829        return Err(Error::LimitExceeded(format!(
830            "ODS repeat count must be between 1 and {MAX_ODS_REPEAT}"
831        )));
832    }
833    Ok(count)
834}
835
836fn append_cell(value: String, repeats: usize, row: &mut Option<Vec<String>>) -> Result<()> {
837    let row = row
838        .as_mut()
839        .ok_or_else(|| Error::InvalidInput("ODS cell appears outside a table row".into()))?;
840    if row.len().saturating_add(repeats) > MAX_ODS_TABLE_CELLS {
841        return Err(Error::LimitExceeded(format!(
842            "ODS table row exceeds {MAX_ODS_TABLE_CELLS} cells"
843        )));
844    }
845    row.extend(std::iter::repeat_n(value, repeats));
846    Ok(())
847}
848
849fn append_row(
850    values: Vec<String>,
851    repeats: usize,
852    is_header: bool,
853    sheet: &mut Option<SheetBuilder>,
854) -> Result<()> {
855    let sheet = sheet
856        .as_mut()
857        .ok_or_else(|| Error::InvalidInput("ODS row appears outside a table".into()))?;
858    let added_cells = values
859        .len()
860        .checked_mul(repeats)
861        .ok_or_else(|| Error::LimitExceeded("ODS table cell count overflowed".into()))?;
862    sheet.total_cells = sheet
863        .total_cells
864        .checked_add(added_cells)
865        .ok_or_else(|| Error::LimitExceeded("ODS table cell count overflowed".into()))?;
866    if sheet.total_cells > MAX_ODS_TABLE_CELLS {
867        return Err(Error::LimitExceeded(format!(
868            "ODS sheet exceeds {MAX_ODS_TABLE_CELLS} cells"
869        )));
870    }
871    sheet.total_rows = sheet
872        .total_rows
873        .checked_add(repeats)
874        .ok_or_else(|| Error::LimitExceeded("ODS table row count overflowed".into()))?;
875    if sheet.total_rows > MAX_ODS_TABLE_ROWS {
876        return Err(Error::LimitExceeded(format!(
877            "ODS sheet exceeds {MAX_ODS_TABLE_ROWS} rows"
878        )));
879    }
880    for _ in 0..repeats {
881        if is_header && sheet.headers.is_empty() {
882            sheet.headers = values.clone();
883        } else {
884            sheet.rows.push(values.clone());
885        }
886    }
887    Ok(())
888}
889
890#[cfg(test)]
891mod tests {
892    use super::*;
893
894    #[test]
895    fn rejects_dtd_and_unbounded_spreadsheet_repetition() {
896        let doctype = r#"<!DOCTYPE office:document-content [<!ENTITY x "expanded">]><office:document-content/>"#;
897        assert!(matches!(
898            parse_content(doctype, 100, None),
899            Err(Error::InvalidInput(_))
900        ));
901
902        let repeated = r#"<office:document-content xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0"><office:body><office:spreadsheet><table:table><table:table-row><table:table-cell table:number-columns-repeated="10001"/></table:table-row></table:table></office:spreadsheet></office:body></office:document-content>"#;
903        assert!(matches!(
904            parse_content(repeated, 1_000, None),
905            Err(Error::LimitExceeded(_))
906        ));
907    }
908
909    #[test]
910    fn inline_binary_image_payload_is_omitted_without_becoming_a_cell_value() {
911        let xml = r#"<office:document-content xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" xmlns:xlink="http://www.w3.org/1999/xlink"><office:body><office:spreadsheet><table:table table:name="Sheet"><table:table-row><table:table-cell><text:p>Cell value</text:p><draw:frame draw:name="inline"><draw:image xlink:href="Pictures/fallback.png"><office:binary-data>BASE64_SECRET</office:binary-data></draw:image></draw:frame></table:table-cell></table:table-row></table:table></office:spreadsheet></office:body></office:document-content>"#;
912        let (blocks, warnings) = parse_content(xml, 1_000, None).unwrap();
913        let rendered = format!("{blocks:?}");
914
915        assert!(rendered.contains("Cell value"));
916        assert!(!rendered.contains("BASE64_SECRET"));
917        assert!(
918            warnings
919                .iter()
920                .any(|warning| warning.contains("office:binary-data"))
921        );
922    }
923
924    #[test]
925    fn valid_inline_binary_png_is_embedded_in_the_sheet_preview() {
926        let xml = r#"<office:document-content xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" xmlns:xlink="http://www.w3.org/1999/xlink"><office:body><office:spreadsheet><table:table table:name="Sheet"><table:table-row><table:table-cell><text:p>Cell</text:p><draw:frame draw:name="inline"><draw:image xlink:href="fallback.png"><office:binary-data>iVBORw0KGgoAAAANSUhEUgAAACAAAAAQCAIAAAD4YuoOAAAAIklEQVR4nGP4z8BAEiJR+X9SlY9aMGrBqAWjFoxaMCAWAABQpv4QX+h4RQAAAABJRU5ErkJggg==</office:binary-data></draw:image></draw:frame></table:table-cell></table:table-row></table:table></office:spreadsheet></office:body></office:document-content>"#;
927        let (blocks, warnings) = parse_content(xml, 2_000, None).unwrap();
928        let rendered = format!("{blocks:?}");
929        assert!(rendered.contains("data:image/png;base64,"));
930        assert!(
931            blocks
932                .iter()
933                .any(|block| matches!(block, HtmlBlock::Image { .. }))
934        );
935        assert!(
936            !warnings
937                .iter()
938                .any(|warning| warning.contains("binary-data images are omitted"))
939        );
940    }
941
942    #[test]
943    fn ods_image_reference_count_is_bounded() {
944        let mut budget = OdsImageBudget::default();
945        let mut warnings = Vec::new();
946        for _ in 0..MAX_ODS_IMAGES {
947            assert!(reserve_ods_image(&mut budget, &mut warnings));
948        }
949        assert!(!reserve_ods_image(&mut budget, &mut warnings));
950        assert_eq!(budget.references, MAX_ODS_IMAGES);
951        assert!(
952            warnings
953                .iter()
954                .any(|warning| warning.contains("image count exceeded"))
955        );
956    }
957}