1use 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 } 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}