Skip to main content

document_svg/document/
odt.rs

1//! OpenDocument Text (ODT/OTT/FODT) paragraph, list, table, and text-style preview.
2//!
3//! The parser follows `office:text` content, keeps heading levels and table cell
4//! text, embeds bounded package-linked PNG/JPEG images as centered flow blocks,
5//! and uses the shared bounded HTML-like page composer for SVG pagination. A
6//! bounded named/automatic style subset resolves font family, size, weight,
7//! italic, and color; paragraph layout styles, objects, and external resources
8//! are not fully reproduced.
9
10use std::collections::HashMap;
11use std::fs::File;
12use std::io::Read;
13use std::path::Path;
14
15use base64::Engine;
16use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
17use quick_xml::Reader;
18use quick_xml::events::{BytesStart, Event};
19
20use crate::convert::{ConvertOptions, PageConsumer};
21use crate::document::html::{
22    HtmlBlock, HtmlTextBlockKind, HtmlTextRun, HtmlTextStyle, render_blocks_to_pages,
23};
24use crate::error::{Error, Result};
25use crate::ooxml::{
26    ZipPackage, attribute, color_from_hex, decode_xml_reference, local_name, resolve_part_target,
27    sniff_image_mime,
28};
29use crate::table::{TableAlign, TableData};
30
31const ODT_MIMETYPE_LIMIT: u64 = 256;
32const MAX_ODT_REPEAT: usize = 10_000;
33const MAX_ODT_TABLE_CELLS: usize = 200_000;
34const MAX_ODT_XML_DEPTH: usize = 256;
35const MAX_ODT_STYLES: usize = 100_000;
36const MAX_ODT_STYLE_NAME_BYTES: usize = 1_024;
37const MAX_ODT_TEXT_BYTES: usize = 32 * 1024 * 1024;
38const MAX_ODT_TEXT_RUNS: usize = 200_000;
39const MAX_ODT_EXPANDED_TABLE_TEXT_BYTES: usize = 32 * 1024 * 1024;
40const MAX_ODT_IMAGES: usize = 10_000;
41const MAX_ODT_IMAGE_BYTES: u64 = 8 * 1024 * 1024;
42const MAX_ODT_TOTAL_IMAGE_BYTES: usize = 32 * 1024 * 1024;
43const MAX_ODT_TOTAL_DATA_URI_BYTES: usize = 48 * 1024 * 1024;
44const MAX_ODT_IMAGE_PIXELS: u64 = 40_000_000;
45const MAX_ODT_TOTAL_IMAGE_PIXELS: u64 = 100_000_000;
46
47#[derive(Clone, Debug)]
48enum ParagraphKind {
49    Heading(u8),
50    Paragraph,
51    ListItem,
52}
53
54#[derive(Clone, Debug)]
55struct Paragraph {
56    kind: ParagraphKind,
57    base_style: HtmlTextStyle,
58    current_style: HtmlTextStyle,
59    runs: Vec<HtmlTextRun>,
60}
61
62#[derive(Clone, Default)]
63struct OdtNamedStyle {
64    properties: HtmlTextStyle,
65    parent: Option<String>,
66}
67
68#[derive(Default)]
69struct OdtStyles {
70    paragraph: HashMap<String, OdtNamedStyle>,
71    text: HashMap<String, OdtNamedStyle>,
72    default_paragraph: HtmlTextStyle,
73    default_text: HtmlTextStyle,
74    font_faces: HashMap<String, String>,
75    count: usize,
76    unsupported_text_properties: bool,
77    unsupported_paragraph_properties: bool,
78}
79
80struct OdtStyleBuilder {
81    family: String,
82    name: Option<String>,
83    parent: Option<String>,
84    properties: HtmlTextStyle,
85    is_default: bool,
86    depth: usize,
87}
88
89impl OdtStyles {
90    fn resolve_paragraph(&self, name: Option<&str>) -> (HtmlTextStyle, bool) {
91        let mut style = self.default_paragraph.clone();
92        merge_text_style(&mut style, self.default_text.clone());
93        let missing = name.is_some_and(|name| {
94            resolve_odt_named_style(name, &self.paragraph, &self.font_faces, &mut style)
95        });
96        if let Some(font_name) = style.font_family.as_deref()
97            && let Some(font_family) = self.font_faces.get(font_name)
98        {
99            style.font_family = Some(font_family.clone());
100        }
101        (style, missing)
102    }
103
104    fn resolve_text(&self, name: Option<&str>) -> (HtmlTextStyle, bool) {
105        let mut style = HtmlTextStyle::default();
106        let missing = name.is_some_and(|name| {
107            resolve_odt_named_style(name, &self.text, &self.font_faces, &mut style)
108        });
109        if let Some(font_name) = style.font_family.as_deref()
110            && let Some(font_family) = self.font_faces.get(font_name)
111        {
112            style.font_family = Some(font_family.clone());
113        }
114        (style, missing)
115    }
116}
117
118fn merge_text_style(target: &mut HtmlTextStyle, source: HtmlTextStyle) {
119    if source.font_family.is_some() {
120        target.font_family = source.font_family;
121    }
122    if source.font_size.is_some() {
123        target.font_size = source.font_size;
124    }
125    if source.bold.is_some() {
126        target.bold = source.bold;
127    }
128    if source.italic.is_some() {
129        target.italic = source.italic;
130    }
131    if source.color.is_some() {
132        target.color = source.color;
133    }
134}
135
136fn resolve_odt_named_style(
137    name: &str,
138    styles: &HashMap<String, OdtNamedStyle>,
139    font_faces: &HashMap<String, String>,
140    target: &mut HtmlTextStyle,
141) -> bool {
142    let mut chain = Vec::new();
143    let mut seen = HashMap::new();
144    let mut current = Some(name.to_owned());
145    let mut missing = false;
146    while let Some(style_name) = current {
147        if chain.len() >= 32 || seen.insert(style_name.clone(), ()).is_some() {
148            missing = true;
149            break;
150        }
151        let Some(style) = styles.get(&style_name) else {
152            missing = true;
153            break;
154        };
155        chain.push(style.properties.clone());
156        current = style.parent.clone();
157    }
158    for properties in chain.into_iter().rev() {
159        merge_text_style(target, properties);
160    }
161    if let Some(font_name) = target.font_family.as_deref()
162        && let Some(font_family) = font_faces.get(font_name)
163    {
164        target.font_family = Some(font_family.clone());
165    }
166    missing
167}
168
169fn parse_style_definitions(xml: &str, max_events: usize, styles: &mut OdtStyles) -> Result<()> {
170    let mut reader = Reader::from_str(xml);
171    reader.config_mut().trim_text(false);
172    let mut buffer = Vec::new();
173    let mut stack = Vec::<String>::new();
174    let mut current = None::<OdtStyleBuilder>;
175    let mut events = 0usize;
176    loop {
177        events = events.saturating_add(1);
178        if events > max_events {
179            return Err(Error::LimitExceeded(format!(
180                "ODT style XML exceeds {max_events} parser events"
181            )));
182        }
183        match reader.read_event_into(&mut buffer)? {
184            Event::Start(element) => {
185                let name =
186                    String::from_utf8_lossy(local_name(element.name().as_ref())).into_owned();
187                if matches!(name.as_str(), "style" | "default-style") && current.is_none() {
188                    if let Some(family) = attribute(&element, b"family")
189                        && matches!(family.as_str(), "paragraph" | "text")
190                    {
191                        current = Some(OdtStyleBuilder {
192                            family,
193                            name: bounded_odt_style_name(&element, b"name")?,
194                            parent: bounded_odt_style_name(&element, b"parent-style-name")?,
195                            properties: HtmlTextStyle::default(),
196                            is_default: name == "default-style",
197                            depth: stack.len() + 1,
198                        });
199                    }
200                } else if let Some(builder) = current.as_mut() {
201                    if name == "text-properties" {
202                        merge_text_style(
203                            &mut builder.properties,
204                            parse_text_properties(&element, styles),
205                        );
206                    } else if name == "paragraph-properties" && has_xml_attributes(&element) {
207                        styles.unsupported_paragraph_properties = true;
208                    }
209                }
210                if name == "font-face"
211                    && let (Some(name), Some(family)) = (
212                        attribute(&element, b"name"),
213                        attribute(&element, b"font-family"),
214                    )
215                    && name.len() <= 256
216                    && family.len() <= 256
217                    && !family.chars().any(char::is_control)
218                {
219                    if styles.font_faces.len() >= MAX_ODT_STYLES
220                        && !styles.font_faces.contains_key(&name)
221                    {
222                        return Err(Error::LimitExceeded(format!(
223                            "ODT font face count exceeds {MAX_ODT_STYLES}"
224                        )));
225                    }
226                    styles.font_faces.insert(name, unquote_font_family(&family));
227                }
228                if stack.len() >= MAX_ODT_XML_DEPTH {
229                    return Err(Error::LimitExceeded(format!(
230                        "ODT style XML nesting exceeds {MAX_ODT_XML_DEPTH} elements"
231                    )));
232                }
233                stack.push(name);
234            }
235            Event::Empty(element) => {
236                let name =
237                    String::from_utf8_lossy(local_name(element.name().as_ref())).into_owned();
238                if matches!(name.as_str(), "style" | "default-style") && current.is_none() {
239                    if let Some(family) = attribute(&element, b"family")
240                        && matches!(family.as_str(), "paragraph" | "text")
241                    {
242                        finish_odt_style(
243                            OdtStyleBuilder {
244                                family,
245                                name: bounded_odt_style_name(&element, b"name")?,
246                                parent: bounded_odt_style_name(&element, b"parent-style-name")?,
247                                properties: HtmlTextStyle::default(),
248                                is_default: name == "default-style",
249                                depth: stack.len(),
250                            },
251                            styles,
252                        )?;
253                    }
254                } else if let Some(builder) = current.as_mut() {
255                    if name == "text-properties" {
256                        merge_text_style(
257                            &mut builder.properties,
258                            parse_text_properties(&element, styles),
259                        );
260                    } else if name == "paragraph-properties" && has_xml_attributes(&element) {
261                        styles.unsupported_paragraph_properties = true;
262                    }
263                }
264                if name == "font-face"
265                    && let (Some(name), Some(family)) = (
266                        attribute(&element, b"name"),
267                        attribute(&element, b"font-family"),
268                    )
269                    && name.len() <= 256
270                    && family.len() <= 256
271                    && !family.chars().any(char::is_control)
272                {
273                    if styles.font_faces.len() >= MAX_ODT_STYLES
274                        && !styles.font_faces.contains_key(&name)
275                    {
276                        return Err(Error::LimitExceeded(format!(
277                            "ODT font face count exceeds {MAX_ODT_STYLES}"
278                        )));
279                    }
280                    styles.font_faces.insert(name, unquote_font_family(&family));
281                }
282            }
283            Event::End(element) => {
284                let name =
285                    String::from_utf8_lossy(local_name(element.name().as_ref())).into_owned();
286                if matches!(name.as_str(), "style" | "default-style")
287                    && current
288                        .as_ref()
289                        .is_some_and(|builder| builder.depth == stack.len())
290                    && let Some(builder) = current.take()
291                {
292                    finish_odt_style(builder, styles)?;
293                }
294                if stack.pop().as_deref() != Some(name.as_str()) {
295                    return Err(Error::InvalidInput(format!(
296                        "mismatched ODT style XML end tag '{name}'"
297                    )));
298                }
299            }
300            Event::DocType(_) => {
301                return Err(Error::InvalidInput(
302                    "ODT style XML must not contain a document type declaration".into(),
303                ));
304            }
305            Event::Eof => break,
306            _ => {}
307        }
308        buffer.clear();
309    }
310    if current.is_some() || !stack.is_empty() {
311        return Err(Error::InvalidInput(
312            "ODT style XML ended with an incomplete element".into(),
313        ));
314    }
315    Ok(())
316}
317
318fn finish_odt_style(builder: OdtStyleBuilder, styles: &mut OdtStyles) -> Result<()> {
319    if builder.is_default {
320        let target = if builder.family == "paragraph" {
321            &mut styles.default_paragraph
322        } else {
323            &mut styles.default_text
324        };
325        merge_text_style(target, builder.properties);
326        return Ok(());
327    }
328    let Some(name) = builder.name else {
329        return Ok(());
330    };
331    let map = if builder.family == "paragraph" {
332        &mut styles.paragraph
333    } else {
334        &mut styles.text
335    };
336    if !map.contains_key(&name) {
337        styles.count = styles.count.saturating_add(1);
338        if styles.count > MAX_ODT_STYLES {
339            return Err(Error::LimitExceeded(format!(
340                "ODT style count exceeds {MAX_ODT_STYLES}"
341            )));
342        }
343    }
344    map.insert(
345        name,
346        OdtNamedStyle {
347            properties: builder.properties,
348            parent: builder.parent,
349        },
350    );
351    Ok(())
352}
353
354fn parse_text_properties(element: &BytesStart<'_>, styles: &mut OdtStyles) -> HtmlTextStyle {
355    let mut result = HtmlTextStyle::default();
356    let supported = [
357        "font-name",
358        "font-family",
359        "font-size",
360        "font-weight",
361        "font-style",
362        "color",
363    ];
364    for attribute_value in element.attributes().with_checks(false).flatten() {
365        let name = local_name(attribute_value.key.as_ref());
366        if !supported
367            .iter()
368            .any(|supported| name == supported.as_bytes())
369        {
370            styles.unsupported_text_properties = true;
371        }
372    }
373    let font_family =
374        attribute(element, b"font-name").or_else(|| attribute(element, b"font-family"));
375    if let Some(value) = font_family {
376        if value.len() <= 256 && !value.chars().any(char::is_control) {
377            let value = unquote_font_family(&value);
378            if !value.is_empty() {
379                result.font_family = Some(value);
380            }
381        } else {
382            styles.unsupported_text_properties = true;
383        }
384    }
385    if let Some(value) = attribute(element, b"font-size") {
386        result.font_size = parse_odt_font_size(&value).filter(|size| (1.0..=512.0).contains(size));
387        if result.font_size.is_none() {
388            styles.unsupported_text_properties = true;
389        }
390    }
391    if let Some(value) = attribute(element, b"font-weight") {
392        result.bold = match value.to_ascii_lowercase().as_str() {
393            "bold" | "bolder" => Some(true),
394            "normal" | "lighter" => Some(false),
395            _ => value.parse::<u16>().ok().map(|weight| weight >= 600),
396        };
397        if result.bold.is_none() {
398            styles.unsupported_text_properties = true;
399        }
400    }
401    if let Some(value) = attribute(element, b"font-style") {
402        result.italic = match value.to_ascii_lowercase().as_str() {
403            "normal" => Some(false),
404            "italic" | "oblique" => Some(true),
405            _ => None,
406        };
407        if result.italic.is_none() {
408            styles.unsupported_text_properties = true;
409        }
410    }
411    if let Some(value) = attribute(element, b"color") {
412        if is_odt_hex_color(&value) {
413            result.color = Some(color_from_hex(&value, "#334155"));
414        } else {
415            styles.unsupported_text_properties = true;
416        }
417    }
418    result
419}
420
421fn has_xml_attributes(element: &BytesStart<'_>) -> bool {
422    element.attributes().with_checks(false).next().is_some()
423}
424
425fn bounded_odt_style_name(element: &BytesStart<'_>, name: &[u8]) -> Result<Option<String>> {
426    let value = attribute(element, name);
427    if value
428        .as_ref()
429        .is_some_and(|value| value.len() > MAX_ODT_STYLE_NAME_BYTES)
430    {
431        return Err(Error::LimitExceeded(format!(
432            "ODT style identifier exceeds {MAX_ODT_STYLE_NAME_BYTES} bytes"
433        )));
434    }
435    Ok(value)
436}
437
438fn parse_odt_font_size(value: &str) -> Option<f64> {
439    let value = value.trim().to_ascii_lowercase();
440    for (unit, scale) in [
441        ("pt", 1.0),
442        ("pc", 12.0),
443        ("in", 72.0),
444        ("cm", 72.0 / 2.54),
445        ("mm", 72.0 / 25.4),
446        ("px", 0.75),
447    ] {
448        if let Some(number) = value.strip_suffix(unit) {
449            return number
450                .trim()
451                .parse::<f64>()
452                .ok()
453                .filter(|number| number.is_finite())
454                .map(|number| number * scale);
455        }
456    }
457    None
458}
459
460fn unquote_font_family(value: &str) -> String {
461    value.trim().trim_matches(['"', '\'']).trim().to_owned()
462}
463
464fn is_odt_hex_color(value: &str) -> bool {
465    value.starts_with('#')
466        && matches!(value.len(), 4 | 7)
467        && value[1..].bytes().all(|byte| byte.is_ascii_hexdigit())
468}
469
470#[derive(Clone, Debug, Default)]
471struct TableBuilder {
472    headers: Vec<String>,
473    rows: Vec<Vec<String>>,
474    total_cells: usize,
475    total_text_bytes: usize,
476}
477
478#[derive(Default)]
479struct OdtImageBudget {
480    count: usize,
481    decoded_bytes: usize,
482    data_uri_bytes: usize,
483    pixels: u64,
484}
485
486struct PendingImage {
487    depth: usize,
488    href: Option<String>,
489    alt: String,
490    has_inline_binary: bool,
491    inline_data: Option<String>,
492    was_in_table: bool,
493    over_limit: bool,
494}
495
496struct OdtImageContext<'a> {
497    package: Option<&'a mut ZipPackage<File>>,
498    budget: &'a mut OdtImageBudget,
499    warnings: &'a mut Vec<String>,
500    blocks: &'a mut Vec<HtmlBlock>,
501    cell: &'a mut Option<String>,
502    warned_table_images: &'a mut bool,
503    text_bytes: &'a mut usize,
504    text_run_count: &'a mut usize,
505}
506
507pub(crate) fn convert(
508    path: &Path,
509    options: &ConvertOptions,
510    sink: &mut dyn PageConsumer,
511) -> Result<Vec<String>> {
512    let (bytes, styles_xml, mut package) = if path
513        .extension()
514        .and_then(|extension| extension.to_str())
515        .is_some_and(|extension| extension.eq_ignore_ascii_case("fodt"))
516    {
517        let mut file = File::open(path)?;
518        let mut bytes = Vec::new();
519        Read::take(&mut file, options.max_input_bytes.saturating_add(1)).read_to_end(&mut bytes)?;
520        if bytes.len() as u64 > options.max_input_bytes {
521            return Err(Error::LimitExceeded(format!(
522                "FODT input exceeds maximum bytes ({})",
523                options.max_input_bytes
524            )));
525        }
526        (bytes, None, None)
527    } else {
528        let mut package = ZipPackage::open(path, options.max_zip_entry_bytes)?;
529        let mimetype = package.read_limited("mimetype", ODT_MIMETYPE_LIMIT)?;
530        let mimetype = std::str::from_utf8(&mimetype)
531            .map_err(|error| Error::InvalidInput(format!("ODT mimetype is not UTF-8: {error}")))?
532            .trim();
533        if !matches!(
534            mimetype,
535            "application/vnd.oasis.opendocument.text"
536                | "application/vnd.oasis.opendocument.text-template"
537                | "application/vnd.sun.xml.writer"
538                | "application/vnd.sun.xml.writer.template"
539        ) {
540            return Err(Error::InvalidInput(format!(
541                "unsupported OpenDocument text mimetype '{mimetype}'"
542            )));
543        }
544        let content = package.read("content.xml")?;
545        let styles = package.read_optional("styles.xml")?;
546        (content, styles, Some(package))
547    };
548
549    let xml = String::from_utf8(bytes)
550        .map_err(|error| Error::InvalidInput(format!("ODT content is not UTF-8: {error}")))?;
551    let mut styles = OdtStyles::default();
552    if let Some(styles_xml) = styles_xml {
553        let styles_xml = String::from_utf8(styles_xml)
554            .map_err(|error| Error::InvalidInput(format!("ODT styles are not UTF-8: {error}")))?;
555        parse_style_definitions(&styles_xml, options.max_xml_events, &mut styles)?;
556    }
557    parse_style_definitions(&xml, options.max_xml_events, &mut styles)?;
558    let (blocks, mut warnings) =
559        parse_content_with_styles(&xml, options.max_xml_events, package.as_mut(), &styles)?;
560    if blocks.is_empty() {
561        return Err(Error::InvalidInput(
562            "OpenDocument Text contains no renderable text or tables".into(),
563        ));
564    }
565    warnings.insert(
566        0,
567        "ODT bold, italic, text color, font size, and font family text styles are applied where supported; paragraph spacing/alignment, table styles, exact page geometry, and pagination are approximated".into(),
568    );
569    render_blocks_to_pages(&blocks, sink, options)?;
570    Ok(warnings)
571}
572
573#[cfg(test)]
574fn parse_content(
575    xml: &str,
576    max_events: usize,
577    package: Option<&mut ZipPackage<File>>,
578) -> Result<(Vec<HtmlBlock>, Vec<String>)> {
579    parse_content_with_styles(xml, max_events, package, &OdtStyles::default())
580}
581
582fn parse_content_with_styles(
583    xml: &str,
584    max_events: usize,
585    mut package: Option<&mut ZipPackage<File>>,
586    styles: &OdtStyles,
587) -> Result<(Vec<HtmlBlock>, Vec<String>)> {
588    let mut reader = Reader::from_str(xml);
589    reader.config_mut().trim_text(false);
590    let mut buffer = Vec::new();
591    let mut stack = Vec::new();
592    let mut blocks = Vec::new();
593    let mut warnings = Vec::new();
594    if styles.unsupported_text_properties {
595        warnings.push("some ODT character style properties are not rendered".into());
596    }
597    if styles.unsupported_paragraph_properties {
598        warnings.push("ODT paragraph spacing/alignment style properties are approximated".into());
599    }
600    let mut in_text_body = false;
601    let mut list_depth = 0usize;
602    let mut header_rows_depth = 0usize;
603    let mut paragraph: Option<Paragraph> = None;
604    let mut text_style_stack: Vec<HtmlTextStyle> = Vec::new();
605    let mut table: Option<TableBuilder> = None;
606    let mut row: Option<Vec<String>> = None;
607    let mut row_text_bytes = 0usize;
608    let mut row_is_header = false;
609    let mut row_repeats = 1usize;
610    let mut cell: Option<String> = None;
611    let mut cell_repeats = 1usize;
612    let mut event_count = 0usize;
613    let mut image_budget = OdtImageBudget::default();
614    let mut pending_image: Option<PendingImage> = None;
615    let mut frame_alt: Vec<String> = Vec::new();
616    let mut warned_image_flow = false;
617    let mut warned_table_images = false;
618    let mut warned_spans = false;
619    let mut warned_table_styles = false;
620    let mut warned_missing_style = false;
621    let mut text_bytes = 0usize;
622    let mut text_run_count = 0usize;
623
624    loop {
625        event_count = event_count.saturating_add(1);
626        if event_count > max_events {
627            return Err(Error::LimitExceeded(format!(
628                "ODT content.xml exceeds {max_events} parser events"
629            )));
630        }
631
632        match reader.read_event_into(&mut buffer)? {
633            Event::Start(ref element) => {
634                let name =
635                    String::from_utf8_lossy(local_name(element.name().as_ref())).into_owned();
636                if name == "text" && stack.last().is_some_and(|parent| parent == "body") {
637                    in_text_body = true;
638                }
639                if in_text_body {
640                    match name.as_str() {
641                        "frame" => frame_alt.push(attribute(element, b"name").unwrap_or_default()),
642                        "list" => list_depth = list_depth.saturating_add(1),
643                        "list-item" => {}
644                        "table-header-rows" => {
645                            header_rows_depth = header_rows_depth.saturating_add(1)
646                        }
647                        "table" => {
648                            if table.is_some() {
649                                return Err(Error::Unsupported(
650                                    "nested OpenDocument tables are not supported".into(),
651                                ));
652                            }
653                            flush_paragraph(
654                                &mut paragraph,
655                                &mut cell,
656                                &mut blocks,
657                                &mut warnings,
658                                &mut warned_table_styles,
659                            );
660                            table = Some(TableBuilder::default());
661                        }
662                        "table-row" => {
663                            if row.is_some() {
664                                return Err(Error::InvalidInput(
665                                    "nested OpenDocument table rows are invalid".into(),
666                                ));
667                            }
668                            row = Some(Vec::new());
669                            row_text_bytes = 0;
670                            row_is_header = header_rows_depth > 0;
671                            row_repeats = parse_repeat(element, b"number-rows-repeated")?;
672                        }
673                        "table-cell" => {
674                            if cell.is_some() {
675                                return Err(Error::InvalidInput(
676                                    "nested OpenDocument table cells are invalid".into(),
677                                ));
678                            }
679                            let has_spanned_cells = [
680                                attribute(element, b"number-columns-spanned"),
681                                attribute(element, b"number-rows-spanned"),
682                            ]
683                            .into_iter()
684                            .flatten()
685                            .filter_map(|value| value.parse::<usize>().ok())
686                            .any(|span| span > 1);
687                            if has_spanned_cells && !warned_spans {
688                                warnings.push(
689                                    "ODT merged table cells are not expanded in the preview".into(),
690                                );
691                                warned_spans = true;
692                            }
693                            cell = Some(String::new());
694                            cell_repeats = parse_repeat(element, b"number-columns-repeated")?;
695                        }
696                        "covered-table-cell" => {
697                            cell = Some(String::new());
698                            cell_repeats = 1;
699                        }
700                        "p" => {
701                            let kind = if list_depth > 0 {
702                                ParagraphKind::ListItem
703                            } else {
704                                ParagraphKind::Paragraph
705                            };
706                            let style_name = bounded_odt_style_name(element, b"style-name")?;
707                            let (style, missing) = styles.resolve_paragraph(style_name.as_deref());
708                            if missing && !warned_missing_style {
709                                warnings.push("some ODT paragraph styles were missing or had cyclic/deep inheritance; default text properties were used".into());
710                                warned_missing_style = true;
711                            }
712                            paragraph = Some(new_paragraph(kind, style));
713                            text_style_stack.clear();
714                        }
715                        "h" => {
716                            let level = attribute(element, b"outline-level")
717                                .and_then(|value| value.parse::<u8>().ok())
718                                .unwrap_or(1)
719                                .clamp(1, 6);
720                            let style_name = bounded_odt_style_name(element, b"style-name")?;
721                            let (style, missing) = styles.resolve_paragraph(style_name.as_deref());
722                            if missing && !warned_missing_style {
723                                warnings.push("some ODT paragraph styles were missing or had cyclic/deep inheritance; default text properties were used".into());
724                                warned_missing_style = true;
725                            }
726                            paragraph = Some(new_paragraph(ParagraphKind::Heading(level), style));
727                            text_style_stack.clear();
728                        }
729                        "span" if paragraph.is_some() => {
730                            let style_name = bounded_odt_style_name(element, b"style-name")?;
731                            let (span_style, missing) = styles.resolve_text(style_name.as_deref());
732                            if missing && !warned_missing_style {
733                                warnings.push("some ODT character styles were missing or had cyclic/deep inheritance; inherited text properties were used".into());
734                                warned_missing_style = true;
735                            }
736                            if let Some(paragraph) = paragraph.as_mut() {
737                                let mut merged = paragraph.current_style.clone();
738                                merge_text_style(&mut merged, span_style);
739                                paragraph.current_style = merged.clone();
740                                text_style_stack.push(merged);
741                            }
742                        }
743                        "image" => {
744                            if pending_image.is_some() {
745                                return Err(Error::InvalidInput(
746                                    "nested OpenDocument images are invalid".into(),
747                                ));
748                            }
749                            let (kind, base_style, current_style) = paragraph
750                                .as_ref()
751                                .map(|paragraph| {
752                                    (
753                                        paragraph.kind.clone(),
754                                        paragraph.base_style.clone(),
755                                        paragraph.current_style.clone(),
756                                    )
757                                })
758                                .unwrap_or_else(|| {
759                                    let (style, missing) = styles.resolve_paragraph(None);
760                                    if missing && !warned_missing_style {
761                                        warnings.push("some ODT paragraph styles were missing or had cyclic/deep inheritance; default text properties were used".into());
762                                        warned_missing_style = true;
763                                    }
764                                    (
765                                        if list_depth > 0 {
766                                            ParagraphKind::ListItem
767                                        } else {
768                                            ParagraphKind::Paragraph
769                                        },
770                                        style.clone(),
771                                        style,
772                                    )
773                                });
774                            flush_paragraph(
775                                &mut paragraph,
776                                &mut cell,
777                                &mut blocks,
778                                &mut warnings,
779                                &mut warned_table_styles,
780                            );
781                            let over_limit = !reserve_odt_image(&mut image_budget, &mut warnings);
782                            pending_image = Some(PendingImage {
783                                depth: stack.len() + 1,
784                                href: attribute(element, b"href"),
785                                alt: attribute(element, b"name")
786                                    .filter(|value| !value.trim().is_empty())
787                                    .or_else(|| {
788                                        frame_alt
789                                            .last()
790                                            .filter(|value| !value.trim().is_empty())
791                                            .cloned()
792                                    })
793                                    .unwrap_or_else(|| "Embedded image".into()),
794                                has_inline_binary: false,
795                                inline_data: None,
796                                was_in_table: table.is_some(),
797                                over_limit,
798                            });
799                            if !warned_image_flow && cell.is_none() {
800                                warnings.push("ODT inline image anchors, wrapping, and frame placement are approximated as centered flow blocks".into());
801                                warned_image_flow = true;
802                            }
803                            if cell.is_none() {
804                                // Preserve the paragraph kind for text following an inline frame.
805                                paragraph = Some(Paragraph {
806                                    kind,
807                                    base_style,
808                                    current_style,
809                                    runs: Vec::new(),
810                                });
811                            }
812                        }
813                        "binary-data" if pending_image.is_some() => {
814                            if let Some(image) = pending_image.as_mut() {
815                                image.has_inline_binary = true;
816                                image.inline_data = Some(String::new());
817                            }
818                        }
819                        _ => {}
820                    }
821                }
822                if stack.len() >= MAX_ODT_XML_DEPTH {
823                    return Err(Error::LimitExceeded(format!(
824                        "ODT XML nesting exceeds {MAX_ODT_XML_DEPTH} elements"
825                    )));
826                }
827                stack.push(name);
828            }
829            Event::Empty(ref element) => {
830                let qualified_name = element.name();
831                let name = local_name(qualified_name.as_ref());
832                if in_text_body {
833                    match name {
834                        b"s" => append_spaces(
835                            &mut paragraph,
836                            &mut cell,
837                            element,
838                            &mut text_bytes,
839                            &mut text_run_count,
840                        )?,
841                        b"tab" => append_text(
842                            &mut paragraph,
843                            &mut cell,
844                            "\t",
845                            &mut text_bytes,
846                            &mut text_run_count,
847                        )?,
848                        b"line-break" => append_text(
849                            &mut paragraph,
850                            &mut cell,
851                            "\n",
852                            &mut text_bytes,
853                            &mut text_run_count,
854                        )?,
855                        b"covered-table-cell" => {
856                            append_cell("".into(), 1, &mut row, &mut row_text_bytes)?
857                        }
858                        b"table-cell" => {
859                            let repeats = parse_repeat(element, b"number-columns-repeated")?;
860                            append_cell(String::new(), repeats, &mut row, &mut row_text_bytes)?;
861                        }
862                        b"image" => {
863                            let (kind, base_style, current_style) = paragraph
864                                .as_ref()
865                                .map(|paragraph| {
866                                    (
867                                        paragraph.kind.clone(),
868                                        paragraph.base_style.clone(),
869                                        paragraph.current_style.clone(),
870                                    )
871                                })
872                                .unwrap_or_else(|| {
873                                    let (style, _) = styles.resolve_paragraph(None);
874                                    (
875                                        if list_depth > 0 {
876                                            ParagraphKind::ListItem
877                                        } else {
878                                            ParagraphKind::Paragraph
879                                        },
880                                        style.clone(),
881                                        style,
882                                    )
883                                });
884                            let alt = attribute(element, b"name")
885                                .filter(|value| !value.trim().is_empty())
886                                .or_else(|| {
887                                    frame_alt
888                                        .last()
889                                        .filter(|value| !value.trim().is_empty())
890                                        .cloned()
891                                })
892                                .unwrap_or_else(|| "Embedded image".into());
893                            let was_in_table = table.is_some();
894                            flush_paragraph(
895                                &mut paragraph,
896                                &mut cell,
897                                &mut blocks,
898                                &mut warnings,
899                                &mut warned_table_styles,
900                            );
901                            if !warned_image_flow && cell.is_none() {
902                                warnings.push("ODT inline image anchors, wrapping, and frame placement are approximated as centered flow blocks".into());
903                                warned_image_flow = true;
904                            }
905                            if reserve_odt_image(&mut image_budget, &mut warnings) {
906                                attach_odt_image(
907                                    attribute(element, b"href").as_deref(),
908                                    None,
909                                    &alt,
910                                    was_in_table,
911                                    OdtImageContext {
912                                        package: package.as_deref_mut(),
913                                        budget: &mut image_budget,
914                                        warnings: &mut warnings,
915                                        blocks: &mut blocks,
916                                        cell: &mut cell,
917                                        warned_table_images: &mut warned_table_images,
918                                        text_bytes: &mut text_bytes,
919                                        text_run_count: &mut text_run_count,
920                                    },
921                                )?;
922                            }
923                            if cell.is_none() {
924                                paragraph = Some(Paragraph {
925                                    kind,
926                                    base_style,
927                                    current_style,
928                                    runs: Vec::new(),
929                                });
930                            }
931                        }
932                        b"binary-data" if pending_image.is_some() => {
933                            if let Some(image) = pending_image.as_mut() {
934                                image.has_inline_binary = true;
935                                image.inline_data = Some(String::new());
936                            }
937                        }
938                        _ => {}
939                    }
940                }
941            }
942            Event::Text(ref value) => {
943                if in_text_body && stack.iter().any(|element| element == "binary-data") {
944                    let decoded = value.decode().map_err(|error| {
945                        Error::InvalidInput(format!("invalid ODT inline image encoding: {error}"))
946                    })?;
947                    if let Some(image) = pending_image.as_mut()
948                        && let Some(data) = image.inline_data.as_mut()
949                    {
950                        if data.len().saturating_add(decoded.len())
951                            > (MAX_ODT_IMAGE_BYTES as usize).saturating_mul(2)
952                        {
953                            return Err(Error::LimitExceeded(format!(
954                                "ODT inline image base64 exceeds the {}-byte limit",
955                                MAX_ODT_IMAGE_BYTES.saturating_mul(2)
956                            )));
957                        }
958                        data.push_str(&decoded);
959                    }
960                } else if in_text_body {
961                    let decoded = value.decode().map_err(|error| {
962                        Error::InvalidInput(format!("invalid ODT text encoding: {error}"))
963                    })?;
964                    let text = quick_xml::escape::unescape(&decoded).map_err(|error| {
965                        Error::InvalidInput(format!("invalid ODT XML text: {error}"))
966                    })?;
967                    append_text(
968                        &mut paragraph,
969                        &mut cell,
970                        &text,
971                        &mut text_bytes,
972                        &mut text_run_count,
973                    )?;
974                }
975            }
976            Event::GeneralRef(ref reference) => {
977                if in_text_body && !stack.iter().any(|element| element == "binary-data") {
978                    let text = decode_xml_reference(reference, "ODT text")?;
979                    append_text(
980                        &mut paragraph,
981                        &mut cell,
982                        &text,
983                        &mut text_bytes,
984                        &mut text_run_count,
985                    )?;
986                }
987            }
988            Event::CData(ref value) => {
989                if in_text_body && stack.iter().any(|element| element == "binary-data") {
990                    let decoded = value.decode().map_err(|error| {
991                        Error::InvalidInput(format!("invalid ODT inline image encoding: {error}"))
992                    })?;
993                    if let Some(image) = pending_image.as_mut()
994                        && let Some(data) = image.inline_data.as_mut()
995                    {
996                        if data.len().saturating_add(decoded.len())
997                            > (MAX_ODT_IMAGE_BYTES as usize).saturating_mul(2)
998                        {
999                            return Err(Error::LimitExceeded(format!(
1000                                "ODT inline image base64 exceeds the {}-byte limit",
1001                                MAX_ODT_IMAGE_BYTES.saturating_mul(2)
1002                            )));
1003                        }
1004                        data.push_str(&decoded);
1005                    }
1006                } else if in_text_body {
1007                    let decoded = value.decode().map_err(|error| {
1008                        Error::InvalidInput(format!("invalid ODT CDATA encoding: {error}"))
1009                    })?;
1010                    append_text(
1011                        &mut paragraph,
1012                        &mut cell,
1013                        &decoded,
1014                        &mut text_bytes,
1015                        &mut text_run_count,
1016                    )?;
1017                }
1018            }
1019            Event::DocType(_) => {
1020                return Err(Error::InvalidInput(
1021                    "OpenDocument content must not contain a document type declaration".into(),
1022                ));
1023            }
1024            Event::End(ref element) => {
1025                let qualified_name = element.name();
1026                let name = local_name(qualified_name.as_ref());
1027                match name {
1028                    b"image" => {
1029                        let pending = pending_image.take().ok_or_else(|| {
1030                            Error::InvalidInput("OpenDocument image ended without opening".into())
1031                        })?;
1032                        if pending.depth != stack.len() {
1033                            return Err(Error::InvalidInput(
1034                                "mismatched OpenDocument image nesting".into(),
1035                            ));
1036                        }
1037                        if pending.over_limit {
1038                            // The common warning was added when the reference was counted.
1039                        } else if pending.has_inline_binary {
1040                            attach_odt_image(
1041                                pending.href.as_deref(),
1042                                pending.inline_data.as_deref(),
1043                                &pending.alt,
1044                                pending.was_in_table,
1045                                OdtImageContext {
1046                                    package: package.as_deref_mut(),
1047                                    budget: &mut image_budget,
1048                                    warnings: &mut warnings,
1049                                    blocks: &mut blocks,
1050                                    cell: &mut cell,
1051                                    warned_table_images: &mut warned_table_images,
1052                                    text_bytes: &mut text_bytes,
1053                                    text_run_count: &mut text_run_count,
1054                                },
1055                            )?;
1056                        } else {
1057                            attach_odt_image(
1058                                pending.href.as_deref(),
1059                                None,
1060                                &pending.alt,
1061                                pending.was_in_table,
1062                                OdtImageContext {
1063                                    package: package.as_deref_mut(),
1064                                    budget: &mut image_budget,
1065                                    warnings: &mut warnings,
1066                                    blocks: &mut blocks,
1067                                    cell: &mut cell,
1068                                    warned_table_images: &mut warned_table_images,
1069                                    text_bytes: &mut text_bytes,
1070                                    text_run_count: &mut text_run_count,
1071                                },
1072                            )?;
1073                        }
1074                    }
1075                    b"frame" => {
1076                        frame_alt.pop();
1077                    }
1078                    b"span" => {
1079                        if let Some(paragraph) = paragraph.as_mut() {
1080                            text_style_stack.pop();
1081                            paragraph.current_style = text_style_stack
1082                                .last()
1083                                .cloned()
1084                                .unwrap_or_else(|| paragraph.base_style.clone());
1085                        }
1086                    }
1087                    b"p" | b"h" => {
1088                        flush_paragraph(
1089                            &mut paragraph,
1090                            &mut cell,
1091                            &mut blocks,
1092                            &mut warnings,
1093                            &mut warned_table_styles,
1094                        );
1095                        text_style_stack.clear();
1096                    }
1097                    b"table-cell" => {
1098                        let value = cell.take().ok_or_else(|| {
1099                            Error::InvalidInput("ODT table cell ended without opening".into())
1100                        })?;
1101                        append_cell(value, cell_repeats, &mut row, &mut row_text_bytes)?;
1102                        cell_repeats = 1;
1103                    }
1104                    b"covered-table-cell" => {
1105                        let value = cell.take().ok_or_else(|| {
1106                            Error::InvalidInput("ODT covered cell ended without opening".into())
1107                        })?;
1108                        append_cell(value, cell_repeats, &mut row, &mut row_text_bytes)?;
1109                        cell_repeats = 1;
1110                    }
1111                    b"table-row" => {
1112                        let row_values = row.take().ok_or_else(|| {
1113                            Error::InvalidInput("ODT table row ended without opening".into())
1114                        })?;
1115                        append_row(
1116                            row_values,
1117                            row_text_bytes,
1118                            row_repeats,
1119                            row_is_header,
1120                            &mut table,
1121                        )?;
1122                        row_repeats = 1;
1123                        row_is_header = false;
1124                    }
1125                    b"table-header-rows" => {
1126                        header_rows_depth = header_rows_depth.saturating_sub(1);
1127                    }
1128                    b"table" => {
1129                        let builder = table.take().ok_or_else(|| {
1130                            Error::InvalidInput("ODT table ended without opening".into())
1131                        })?;
1132                        if !builder.headers.is_empty() || !builder.rows.is_empty() {
1133                            let mut headers = builder.headers;
1134                            let mut rows = builder.rows;
1135                            if headers.is_empty() && !rows.is_empty() {
1136                                headers = rows.remove(0);
1137                            }
1138                            let columns = headers
1139                                .len()
1140                                .max(rows.iter().map(Vec::len).max().unwrap_or(0));
1141                            blocks.push(HtmlBlock::Table(TableData {
1142                                headers,
1143                                rows,
1144                                alignments: vec![TableAlign::Left; columns],
1145                                raw_source: String::new(),
1146                            }));
1147                        }
1148                    }
1149                    b"list" => list_depth = list_depth.saturating_sub(1),
1150                    b"text" if stack.len() > 1 => in_text_body = false,
1151                    _ => {}
1152                }
1153                let closing = String::from_utf8_lossy(name).into_owned();
1154                if stack.pop().as_deref() != Some(closing.as_str()) {
1155                    return Err(Error::InvalidInput(format!(
1156                        "mismatched OpenDocument XML end tag '{closing}'"
1157                    )));
1158                }
1159            }
1160            Event::Eof => break,
1161            _ => {}
1162        }
1163        buffer.clear();
1164    }
1165
1166    if paragraph.is_some() || cell.is_some() || row.is_some() || table.is_some() {
1167        return Err(Error::InvalidInput(
1168            "incomplete OpenDocument content XML structure".into(),
1169        ));
1170    }
1171    if pending_image.is_some() {
1172        return Err(Error::InvalidInput(
1173            "incomplete OpenDocument image element".into(),
1174        ));
1175    }
1176    Ok((blocks, warnings))
1177}
1178
1179fn attach_odt_image(
1180    href: Option<&str>,
1181    inline_data: Option<&str>,
1182    alt: &str,
1183    was_in_table: bool,
1184    context: OdtImageContext<'_>,
1185) -> Result<()> {
1186    let OdtImageContext {
1187        package,
1188        budget,
1189        warnings,
1190        blocks,
1191        cell,
1192        warned_table_images,
1193        text_bytes,
1194        text_run_count,
1195    } = context;
1196    if was_in_table {
1197        if !*warned_table_images {
1198            push_odt_warning_once(
1199                warnings,
1200                "ODT images inside tables are represented by alt text because table-cell image layout is unavailable",
1201            );
1202            *warned_table_images = true;
1203        }
1204        append_text(
1205            &mut None,
1206            cell,
1207            &format!("[{}]", alt),
1208            text_bytes,
1209            text_run_count,
1210        )?;
1211        return Ok(());
1212    }
1213    if let Some(inline_data) = inline_data {
1214        return attach_odt_inline_image(inline_data, alt, warnings, budget, blocks);
1215    }
1216    let Some(package) = package else {
1217        push_odt_warning_once(
1218            warnings,
1219            "flat OpenDocument images and external resources are omitted; package-linked PNG/JPEG images are supported",
1220        );
1221        return Ok(());
1222    };
1223    let Some(href) = href.filter(|href| !href.trim().is_empty()) else {
1224        push_odt_warning_once(warnings, "ODT image without a package href was omitted");
1225        return Ok(());
1226    };
1227    if href.contains(':') || href.starts_with("//") || href.starts_with('\\') {
1228        push_odt_warning_once(warnings, "external ODT image resources are not fetched");
1229        return Ok(());
1230    }
1231    // A single leading slash is package-root-relative. Strip it before the
1232    // common resolver so its normalization still rejects `..` traversal.
1233    let package_href = href.strip_prefix('/').unwrap_or(href);
1234    let target = match resolve_part_target("content.xml", package_href) {
1235        Ok(target) => target,
1236        Err(_) => {
1237            push_odt_warning_once(
1238                warnings,
1239                "ODT image path escaped the package or was invalid and was omitted",
1240            );
1241            return Ok(());
1242        }
1243    };
1244    let bytes = match package.read_optional_limited(&target, MAX_ODT_IMAGE_BYTES) {
1245        Ok(Some(bytes)) => bytes,
1246        Ok(None) => {
1247            push_odt_warning_once(warnings, "missing ODT image parts were omitted");
1248            return Ok(());
1249        }
1250        Err(Error::LimitExceeded(_)) => {
1251            push_odt_warning_once(
1252                warnings,
1253                "ODT image parts exceeding the per-image byte limit were omitted",
1254            );
1255            return Ok(());
1256        }
1257        Err(error) => return Err(error),
1258    };
1259    let Some(mime) =
1260        sniff_image_mime(&bytes).filter(|mime| matches!(*mime, "image/png" | "image/jpeg"))
1261    else {
1262        push_odt_warning_once(
1263            warnings,
1264            "unsupported ODT image types were omitted; only PNG and JPEG are embedded",
1265        );
1266        return Ok(());
1267    };
1268    let Some((width, height)) = crate::document::mhtml::image_dimensions(&bytes, mime) else {
1269        push_odt_warning_once(warnings, "invalid ODT PNG/JPEG images were omitted");
1270        return Ok(());
1271    };
1272    let pixels = u64::from(width) * u64::from(height);
1273    let next_pixels = budget.pixels.saturating_add(pixels);
1274    if width == 0
1275        || height == 0
1276        || pixels > MAX_ODT_IMAGE_PIXELS
1277        || next_pixels > MAX_ODT_TOTAL_IMAGE_PIXELS
1278    {
1279        push_odt_warning_once(
1280            warnings,
1281            "ODT images exceeding the per-image or total pixel limit were omitted",
1282        );
1283        return Ok(());
1284    }
1285    let next_bytes = budget.decoded_bytes.saturating_add(bytes.len());
1286    if next_bytes > MAX_ODT_TOTAL_IMAGE_BYTES {
1287        push_odt_warning_once(
1288            warnings,
1289            "ODT images exceeding the total decoded image byte limit were omitted",
1290        );
1291        return Ok(());
1292    }
1293    let prefix = format!("data:{mime};base64,");
1294    let uri_bytes = prefix
1295        .len()
1296        .saturating_add(bytes.len().div_ceil(3).saturating_mul(4));
1297    let next_uri_bytes = budget.data_uri_bytes.saturating_add(uri_bytes);
1298    if next_uri_bytes > MAX_ODT_TOTAL_DATA_URI_BYTES {
1299        push_odt_warning_once(
1300            warnings,
1301            "ODT images exceeding the total data URI byte limit were omitted",
1302        );
1303        return Ok(());
1304    }
1305    let href = format!("{prefix}{}", BASE64_STANDARD.encode(&bytes));
1306    blocks.push(HtmlBlock::Image {
1307        href,
1308        pixel_width: width,
1309        pixel_height: height,
1310        alt: alt.to_owned(),
1311    });
1312    budget.decoded_bytes = next_bytes;
1313    budget.data_uri_bytes = next_uri_bytes;
1314    budget.pixels = next_pixels;
1315    Ok(())
1316}
1317
1318fn attach_odt_inline_image(
1319    data: &str,
1320    alt: &str,
1321    warnings: &mut Vec<String>,
1322    budget: &mut OdtImageBudget,
1323    blocks: &mut Vec<HtmlBlock>,
1324) -> Result<()> {
1325    let compact: String = data
1326        .chars()
1327        .filter(|character| !character.is_ascii_whitespace())
1328        .collect();
1329    let bytes = match BASE64_STANDARD.decode(compact.as_bytes()) {
1330        Ok(bytes) => bytes,
1331        Err(_) => {
1332            push_odt_warning_once(
1333                warnings,
1334                "malformed ODT inline office:binary-data image was omitted",
1335            );
1336            return Ok(());
1337        }
1338    };
1339    if bytes.len() as u64 > MAX_ODT_IMAGE_BYTES {
1340        push_odt_warning_once(
1341            warnings,
1342            "ODT inline image exceeded the per-image byte limit",
1343        );
1344        return Ok(());
1345    }
1346    let Some(mime) =
1347        sniff_image_mime(&bytes).filter(|mime| matches!(*mime, "image/png" | "image/jpeg"))
1348    else {
1349        push_odt_warning_once(
1350            warnings,
1351            "unsupported ODT inline image type was omitted; only PNG and JPEG are embedded",
1352        );
1353        return Ok(());
1354    };
1355    let Some((width, height)) = crate::document::mhtml::image_dimensions(&bytes, mime) else {
1356        push_odt_warning_once(warnings, "invalid ODT inline PNG/JPEG image was omitted");
1357        return Ok(());
1358    };
1359    let pixels = u64::from(width).saturating_mul(u64::from(height));
1360    let next_pixels = budget.pixels.saturating_add(pixels);
1361    if width == 0
1362        || height == 0
1363        || pixels > MAX_ODT_IMAGE_PIXELS
1364        || next_pixels > MAX_ODT_TOTAL_IMAGE_PIXELS
1365    {
1366        push_odt_warning_once(warnings, "ODT inline images exceeded the pixel limit");
1367        return Ok(());
1368    }
1369    let next_bytes = budget.decoded_bytes.saturating_add(bytes.len());
1370    if next_bytes > MAX_ODT_TOTAL_IMAGE_BYTES {
1371        push_odt_warning_once(warnings, "ODT inline images exceeded the total byte limit");
1372        return Ok(());
1373    }
1374    let prefix = format!("data:{mime};base64,");
1375    let uri_bytes = prefix
1376        .len()
1377        .saturating_add(bytes.len().div_ceil(3).saturating_mul(4));
1378    let next_uri = budget.data_uri_bytes.saturating_add(uri_bytes);
1379    if next_uri > MAX_ODT_TOTAL_DATA_URI_BYTES {
1380        push_odt_warning_once(
1381            warnings,
1382            "ODT inline images exceeded the total data URI limit",
1383        );
1384        return Ok(());
1385    }
1386    blocks.push(HtmlBlock::Image {
1387        href: format!("{prefix}{}", BASE64_STANDARD.encode(&bytes)),
1388        pixel_width: width,
1389        pixel_height: height,
1390        alt: alt.to_owned(),
1391    });
1392    budget.decoded_bytes = next_bytes;
1393    budget.data_uri_bytes = next_uri;
1394    budget.pixels = next_pixels;
1395    Ok(())
1396}
1397
1398fn reserve_odt_image(budget: &mut OdtImageBudget, warnings: &mut Vec<String>) -> bool {
1399    if budget.count >= MAX_ODT_IMAGES {
1400        push_odt_warning_once(
1401            warnings,
1402            "ODT image count exceeded the supported limit; remaining images were omitted",
1403        );
1404        false
1405    } else {
1406        budget.count += 1;
1407        true
1408    }
1409}
1410
1411fn push_odt_warning_once(warnings: &mut Vec<String>, warning: &str) {
1412    if !warnings.iter().any(|existing| existing == warning) {
1413        warnings.push(warning.to_owned());
1414    }
1415}
1416
1417fn parse_repeat(element: &BytesStart<'_>, name: &[u8]) -> Result<usize> {
1418    let count = attribute(element, name)
1419        .map(|value| {
1420            value.parse::<usize>().map_err(|_| {
1421                Error::InvalidInput(format!(
1422                    "invalid OpenDocument repeat count for '{}'",
1423                    String::from_utf8_lossy(name)
1424                ))
1425            })
1426        })
1427        .transpose()?
1428        .unwrap_or(1);
1429    if count == 0 || count > MAX_ODT_REPEAT {
1430        return Err(Error::LimitExceeded(format!(
1431            "OpenDocument repeat count must be between 1 and {MAX_ODT_REPEAT}"
1432        )));
1433    }
1434    Ok(count)
1435}
1436
1437fn append_spaces(
1438    paragraph: &mut Option<Paragraph>,
1439    cell: &mut Option<String>,
1440    element: &BytesStart<'_>,
1441    text_bytes: &mut usize,
1442    text_run_count: &mut usize,
1443) -> Result<()> {
1444    let count = parse_repeat(element, b"c")?;
1445    let spaces = " ".repeat(count);
1446    append_text(paragraph, cell, &spaces, text_bytes, text_run_count)?;
1447    Ok(())
1448}
1449
1450fn new_paragraph(kind: ParagraphKind, style: HtmlTextStyle) -> Paragraph {
1451    Paragraph {
1452        kind,
1453        base_style: style.clone(),
1454        current_style: style,
1455        runs: Vec::new(),
1456    }
1457}
1458
1459fn append_text(
1460    paragraph: &mut Option<Paragraph>,
1461    cell: &mut Option<String>,
1462    text: &str,
1463    text_bytes: &mut usize,
1464    text_run_count: &mut usize,
1465) -> Result<()> {
1466    if text.is_empty() {
1467        return Ok(());
1468    }
1469    *text_bytes = text_bytes.saturating_add(text.len());
1470    if *text_bytes > MAX_ODT_TEXT_BYTES {
1471        return Err(Error::LimitExceeded(format!(
1472            "ODT rendered text exceeds {MAX_ODT_TEXT_BYTES} bytes"
1473        )));
1474    }
1475    if let Some(paragraph) = paragraph.as_mut() {
1476        if let Some(last) = paragraph.runs.last_mut()
1477            && last.style == paragraph.current_style
1478        {
1479            last.text.push_str(text);
1480        } else {
1481            *text_run_count = text_run_count.saturating_add(1);
1482            if *text_run_count > MAX_ODT_TEXT_RUNS {
1483                return Err(Error::LimitExceeded(format!(
1484                    "ODT text exceeds {MAX_ODT_TEXT_RUNS} style runs"
1485                )));
1486            }
1487            paragraph.runs.push(HtmlTextRun {
1488                text: text.to_owned(),
1489                style: paragraph.current_style.clone(),
1490            });
1491        }
1492    } else if let Some(cell) = cell.as_mut() {
1493        cell.push_str(text);
1494    }
1495    Ok(())
1496}
1497
1498fn flush_paragraph(
1499    paragraph: &mut Option<Paragraph>,
1500    cell: &mut Option<String>,
1501    blocks: &mut Vec<HtmlBlock>,
1502    warnings: &mut Vec<String>,
1503    warned_table_styles: &mut bool,
1504) {
1505    let Some(mut paragraph) = paragraph.take() else {
1506        return;
1507    };
1508    trim_odt_runs(&mut paragraph.runs);
1509    let text = paragraph
1510        .runs
1511        .iter()
1512        .map(|run| run.text.as_str())
1513        .collect::<String>();
1514    if text.is_empty() {
1515        return;
1516    }
1517    if let Some(cell) = cell.as_mut() {
1518        if (!odt_style_is_empty(&paragraph.base_style)
1519            || paragraph
1520                .runs
1521                .iter()
1522                .any(|run| !odt_style_is_empty(&run.style)))
1523            && !*warned_table_styles
1524        {
1525            warnings
1526                .push("ODT rich text styles inside table cells are flattened to plain text".into());
1527            *warned_table_styles = true;
1528        }
1529        if !cell.is_empty() {
1530            cell.push('\n');
1531        }
1532        cell.push_str(&text);
1533        return;
1534    }
1535    let styled = !odt_style_is_empty(&paragraph.base_style)
1536        || paragraph
1537            .runs
1538            .iter()
1539            .any(|run| !odt_style_is_empty(&run.style));
1540    if styled {
1541        let kind = match paragraph.kind {
1542            ParagraphKind::Heading(level) => HtmlTextBlockKind::Heading(level),
1543            ParagraphKind::Paragraph => HtmlTextBlockKind::Paragraph,
1544            ParagraphKind::ListItem => HtmlTextBlockKind::ListItem {
1545                bullet: "•".into()
1546            },
1547        };
1548        blocks.push(HtmlBlock::StyledText {
1549            kind,
1550            runs: paragraph.runs,
1551        });
1552        return;
1553    }
1554    blocks.push(match paragraph.kind {
1555        ParagraphKind::Heading(level) => HtmlBlock::Heading { level, text },
1556        ParagraphKind::Paragraph => HtmlBlock::Paragraph { text },
1557        ParagraphKind::ListItem => HtmlBlock::ListItem {
1558            bullet: "•".into(),
1559            text,
1560        },
1561    });
1562}
1563
1564fn trim_odt_runs(runs: &mut Vec<HtmlTextRun>) {
1565    let mut first_nonempty = 0usize;
1566    while first_nonempty < runs.len() {
1567        runs[first_nonempty].text = runs[first_nonempty].text.trim_start().to_owned();
1568        if runs[first_nonempty].text.is_empty() {
1569            first_nonempty += 1;
1570        } else {
1571            break;
1572        }
1573    }
1574    if first_nonempty > 0 {
1575        runs.drain(..first_nonempty);
1576    }
1577    let mut end = runs.len();
1578    while end > 0 {
1579        runs[end - 1].text = runs[end - 1].text.trim_end().to_owned();
1580        if runs[end - 1].text.is_empty() {
1581            end -= 1;
1582        } else {
1583            break;
1584        }
1585    }
1586    runs.truncate(end);
1587}
1588
1589fn odt_style_is_empty(style: &HtmlTextStyle) -> bool {
1590    style.font_family.is_none()
1591        && style.font_size.is_none()
1592        && style.bold.is_none()
1593        && style.italic.is_none()
1594        && style.color.is_none()
1595}
1596
1597fn append_cell(
1598    value: String,
1599    repeats: usize,
1600    row: &mut Option<Vec<String>>,
1601    row_text_bytes: &mut usize,
1602) -> Result<()> {
1603    let row = row
1604        .as_mut()
1605        .ok_or_else(|| Error::InvalidInput("ODT table cell appears outside a table row".into()))?;
1606    if row.len().saturating_add(repeats) > MAX_ODT_TABLE_CELLS {
1607        return Err(Error::LimitExceeded(format!(
1608            "ODT table row exceeds {MAX_ODT_TABLE_CELLS} cells"
1609        )));
1610    }
1611    let added_text_bytes = value
1612        .len()
1613        .checked_mul(repeats)
1614        .ok_or_else(|| Error::LimitExceeded("ODT table text size overflowed".into()))?;
1615    *row_text_bytes = row_text_bytes
1616        .checked_add(added_text_bytes)
1617        .ok_or_else(|| Error::LimitExceeded("ODT table text size overflowed".into()))?;
1618    if *row_text_bytes > MAX_ODT_EXPANDED_TABLE_TEXT_BYTES {
1619        return Err(Error::LimitExceeded(format!(
1620            "ODT expanded table text exceeds {MAX_ODT_EXPANDED_TABLE_TEXT_BYTES} bytes"
1621        )));
1622    }
1623    row.extend(std::iter::repeat_n(value, repeats));
1624    Ok(())
1625}
1626
1627fn append_row(
1628    values: Vec<String>,
1629    row_text_bytes: usize,
1630    repeats: usize,
1631    is_header: bool,
1632    table: &mut Option<TableBuilder>,
1633) -> Result<()> {
1634    let table = table
1635        .as_mut()
1636        .ok_or_else(|| Error::InvalidInput("ODT table row appears outside a table".into()))?;
1637    let total_rows = table.rows.len() + usize::from(!table.headers.is_empty());
1638    if total_rows.saturating_add(repeats) > MAX_ODT_TABLE_CELLS {
1639        return Err(Error::LimitExceeded(format!(
1640            "ODT table exceeds {MAX_ODT_TABLE_CELLS} rows"
1641        )));
1642    }
1643    let added_cells = values
1644        .len()
1645        .checked_mul(repeats)
1646        .ok_or_else(|| Error::LimitExceeded("ODT table cell count overflowed".into()))?;
1647    table.total_cells = table
1648        .total_cells
1649        .checked_add(added_cells)
1650        .ok_or_else(|| Error::LimitExceeded("ODT table cell count overflowed".into()))?;
1651    if table.total_cells > MAX_ODT_TABLE_CELLS {
1652        return Err(Error::LimitExceeded(format!(
1653            "ODT table exceeds {MAX_ODT_TABLE_CELLS} cells"
1654        )));
1655    }
1656    let added_text_bytes = row_text_bytes
1657        .checked_mul(repeats)
1658        .ok_or_else(|| Error::LimitExceeded("ODT expanded table text size overflowed".into()))?;
1659    table.total_text_bytes = table
1660        .total_text_bytes
1661        .checked_add(added_text_bytes)
1662        .ok_or_else(|| Error::LimitExceeded("ODT expanded table text size overflowed".into()))?;
1663    if table.total_text_bytes > MAX_ODT_EXPANDED_TABLE_TEXT_BYTES {
1664        return Err(Error::LimitExceeded(format!(
1665            "ODT expanded table text exceeds {MAX_ODT_EXPANDED_TABLE_TEXT_BYTES} bytes"
1666        )));
1667    }
1668    for _ in 0..repeats {
1669        if is_header && table.headers.is_empty() {
1670            table.headers = values.clone();
1671        } else {
1672            table.rows.push(values.clone());
1673        }
1674    }
1675    Ok(())
1676}
1677
1678#[cfg(test)]
1679mod tests {
1680    use super::*;
1681
1682    #[test]
1683    fn rejects_document_type_declarations_and_limits_xml_depth() {
1684        let doctype = r#"<!DOCTYPE office:document-content [<!ENTITY x "expanded">]><office:document-content/>"#;
1685        assert!(matches!(
1686            parse_content(doctype, 100, None),
1687            Err(Error::InvalidInput(_))
1688        ));
1689
1690        let nested = format!("{}<office:text/>{}", "<x>".repeat(300), "</x>".repeat(300));
1691        assert!(matches!(
1692            parse_content(&nested, 2_000, None),
1693            Err(Error::LimitExceeded(_))
1694        ));
1695    }
1696
1697    #[test]
1698    fn repeated_cells_cannot_expand_past_the_table_budget() {
1699        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"><office:body><office:text><table:table><table:table-row><table:table-cell table:number-columns-repeated="10001"/></table:table-row></table:table></office:text></office:body></office:document-content>"#;
1700        assert!(matches!(
1701            parse_content(xml, 1_000, None),
1702            Err(Error::LimitExceeded(_))
1703        ));
1704    }
1705
1706    #[test]
1707    fn repeated_rows_cannot_amplify_large_text_into_unbounded_memory() {
1708        let content = "T".repeat(40_000);
1709        let xml = format!(
1710            "<office:document-content xmlns:office=\"urn:oasis:names:tc:opendocument:xmlns:office:1.0\" xmlns:text=\"urn:oasis:names:tc:opendocument:xmlns:text:1.0\" xmlns:table=\"urn:oasis:names:tc:opendocument:xmlns:table:1.0\"><office:body><office:text><table:table><table:table-row table:number-rows-repeated=\"1000\"><table:table-cell><text:p>{content}</text:p></table:table-cell></table:table-row></table:table></office:text></office:body></office:document-content>"
1711        );
1712        let error = parse_content(&xml, 10_000, None).unwrap_err();
1713        assert!(error.to_string().contains("expanded table text"));
1714    }
1715
1716    #[test]
1717    fn inline_binary_image_payload_is_embedded_without_becoming_text() {
1718        let xml = r#"<office:document-content xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office: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:text><text:p>before<draw:frame draw:name="inline"><draw:image xlink:href="Pictures/fallback.png"><office:binary-data>iVBORw0KGgoAAAANSUhEUgAAACAAAAAQCAIAAAD4YuoOAAAAIklEQVR4nGP4z8BAEiJR+X9SlY9aMGrBqAWjFoxaMCAWAABQpv4QX+h4RQAAAABJRU5ErkJggg==</office:binary-data></draw:image></draw:frame>after</text:p></office:text></office:body></office:document-content>"#;
1719        let (blocks, warnings) = parse_content(xml, 1_000, None).unwrap();
1720        let rendered_text = format!("{blocks:?}");
1721
1722        assert!(rendered_text.contains("before"));
1723        assert!(rendered_text.contains("after"));
1724        assert!(rendered_text.contains("data:image/png;base64,"));
1725        assert!(
1726            blocks
1727                .iter()
1728                .any(|block| matches!(block, HtmlBlock::Image { .. }))
1729        );
1730        assert!(
1731            !warnings
1732                .iter()
1733                .any(|warning| warning.contains("binary-data"))
1734        );
1735    }
1736}