Skip to main content

rust_docx/
export.rs

1//! HTML document export with selectable HTML, Markdown, or CSV tables.
2mod styles;
3use crate::{BlockType, DocumentBlock, InlineChange, RevisionInline};
4use serde::{Deserialize, Serialize};
5use serde_json::{json, Value};
6use std::collections::{BTreeMap, HashMap, HashSet};
7
8#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
9#[serde(rename_all = "snake_case")]
10pub enum TableFormat {
11    #[default]
12    Html,
13    Markdown,
14    Csv,
15}
16
17#[derive(Clone, Debug, Default, Serialize, Deserialize)]
18#[serde(default, rename_all = "camelCase")]
19pub struct ContentOverride {
20    /// Trusted HTML supplied by the caller; it is not escaped or sanitized.
21    pub html: Option<String>,
22    pub text: Option<String>,
23    pub replace_content: bool,
24    pub list_label_present: bool,
25    pub suppress_image: bool,
26}
27
28#[derive(Clone, Debug, Serialize, Deserialize)]
29#[serde(default, rename_all = "camelCase")]
30pub struct ExportOptions {
31    pub table_format: TableFormat,
32    /// Group adjacent insertion/deletion runs in a change element.
33    pub group_changes: bool,
34    pub max_depth: usize,
35    pub max_blocks: usize,
36    pub max_table_columns: usize,
37    pub max_table_cells: usize,
38    pub max_output_bytes: usize,
39}
40impl Default for ExportOptions {
41    fn default() -> Self {
42        Self {
43            table_format: TableFormat::Html,
44            group_changes: false,
45            max_depth: 64,
46            max_blocks: 100_000,
47            max_table_columns: 1024,
48            max_table_cells: 1_000_000,
49            max_output_bytes: 128 * 1024 * 1024,
50        }
51    }
52}
53
54#[derive(Clone, Debug, Default, Serialize, Deserialize)]
55#[serde(default, rename_all = "camelCase")]
56pub struct ExportRequest {
57    pub blocks: Vec<DocumentBlock>,
58    pub options: ExportOptions,
59    pub overrides: BTreeMap<String, ContentOverride>,
60    pub revision_inlines: BTreeMap<String, Vec<RevisionInline>>,
61    /// Payloads returned by asset-deduplicated extraction, or caller-provided URLs.
62    pub assets: BTreeMap<String, String>,
63}
64
65#[derive(Clone, Debug, Serialize, Deserialize)]
66#[serde(rename_all = "camelCase")]
67pub struct ExportedDocument {
68    pub content: String,
69    pub block_content: BTreeMap<String, String>,
70}
71
72pub fn escape_html(value: &str) -> String {
73    value
74        .replace('&', "&amp;")
75        .replace('<', "&lt;")
76        .replace('>', "&gt;")
77        .replace('"', "&quot;")
78}
79fn safe_link(v: &str) -> bool {
80    let lower = v.to_ascii_lowercase();
81    !v.chars().any(char::is_control)
82        && (lower.starts_with("http:")
83            || lower.starts_with("https:")
84            || lower.starts_with("mailto:")
85            || v.starts_with('#'))
86}
87fn safe_image(v: &str) -> bool {
88    let lower = v.to_ascii_lowercase();
89    !v.chars().any(char::is_control)
90        && (lower.starts_with("http://")
91            || lower.starts_with("https://")
92            || ["png", "gif", "jpeg", "webp", "svg+xml"]
93                .iter()
94                .any(|kind| {
95                    lower.starts_with(&format!("data:image/{kind};"))
96                        || lower.starts_with(&format!("data:image/{kind},"))
97                }))
98}
99fn number(v: &Value, field: &str, fallback: usize) -> usize {
100    v[field]
101        .as_u64()
102        .and_then(|n| usize::try_from(n).ok())
103        .unwrap_or(fallback)
104}
105fn label(text: &str, d: &Value, already_present: bool) -> String {
106    let Some(label) = d["listLabel"].as_str().filter(|v| !v.is_empty()) else {
107        return text.into();
108    };
109    let trimmed = text.trim_start();
110    if already_present
111        && trimmed.strip_prefix(label).is_some_and(|rest| {
112            rest.is_empty() || rest.chars().next().is_some_and(char::is_whitespace)
113        })
114    {
115        return text.into();
116    }
117    format!("{label} {text}")
118}
119fn cells(block: &DocumentBlock) -> Vec<&DocumentBlock> {
120    block
121        .children
122        .iter()
123        .flat_map(|c| {
124            if c.r#type == BlockType::TableHead {
125                c.children.iter().collect()
126            } else {
127                vec![c]
128            }
129        })
130        .filter(|c| c.r#type == BlockType::TableCell)
131        .collect()
132}
133fn markup(pieces: Vec<(String, InlineChange)>, grouped: bool) -> String {
134    let mut output = String::new();
135    let mut state = InlineChange::Unchanged;
136    for (text, next) in pieces {
137        if text.is_empty() {
138            continue;
139        }
140        if state != next {
141            match state {
142                InlineChange::Deletion => output.push_str("</del>"),
143                InlineChange::Insertion => output.push_str("</ins>"),
144                _ => {}
145            }
146            if grouped && next == InlineChange::Unchanged {
147                output.push_str("</change>");
148            }
149            if grouped && state == InlineChange::Unchanged {
150                output.push_str("<change>");
151            }
152            match next {
153                InlineChange::Deletion => output.push_str("<del>"),
154                InlineChange::Insertion => output.push_str("<ins>"),
155                _ => {}
156            }
157            state = next;
158        }
159        output.push_str(&text);
160    }
161    match state {
162        InlineChange::Deletion => output.push_str("</del>"),
163        InlineChange::Insertion => output.push_str("</ins>"),
164        _ => {}
165    }
166    if grouped && state != InlineChange::Unchanged {
167        output.push_str("</change>");
168    }
169    output
170}
171struct Exporter<'a> {
172    request: &'a ExportRequest,
173    content: BTreeMap<String, String>,
174    children: HashMap<&'a str, HashMap<&'a str, &'a DocumentBlock>>,
175    bytes: usize,
176}
177impl Exporter<'_> {
178    fn inline(
179        &mut self,
180        inline: &Value,
181        block: &DocumentBlock,
182        d: &Value,
183        plain: bool,
184    ) -> Result<String, String> {
185        if inline["type"] == "block" {
186            return match inline["blockId"]
187                .as_str()
188                .and_then(|id| {
189                    self.children
190                        .get(block.id.as_str())
191                        .and_then(|children| children.get(id))
192                })
193                .copied()
194            {
195                Some(child) => {
196                    if plain {
197                        self.text(child)
198                    } else {
199                        self.block(child)
200                    }
201                }
202                None => Ok(String::new()),
203            };
204        }
205        let text = inline["text"].as_str().unwrap_or("");
206        if plain {
207            return Ok(escape_html(text));
208        }
209        let style = inline
210            .get("style")
211            .filter(|v| !v.is_null())
212            .unwrap_or(&d["field"]["style"]);
213        Ok(styles::text_html(
214            text,
215            style,
216            inline["link"]
217                .as_str()
218                .or_else(|| d["field"]["link"].as_str()),
219        ))
220    }
221    fn changed(
222        &mut self,
223        block: &DocumentBlock,
224        d: &Value,
225        plain: bool,
226    ) -> Result<Option<String>, String> {
227        let Some(pieces) = self.request.revision_inlines.get(&block.id) else {
228            return Ok(None);
229        };
230        let mut rendered = Vec::new();
231        for piece in pieces {
232            rendered.push((
233                self.inline(
234                    &serde_json::to_value(&piece.inline).map_err(|e| e.to_string())?,
235                    block,
236                    d,
237                    plain,
238                )?,
239                piece.change,
240            ));
241        }
242        Ok(Some(markup(rendered, self.request.options.group_changes)))
243    }
244    fn text(&mut self, block: &DocumentBlock) -> Result<String, String> {
245        let d = block.details.to_json_value();
246        let replace = self.request.overrides.get(&block.id);
247        if let Some(o) = replace.filter(|o| !o.replace_content) {
248            if let Some(text) = o.text.as_ref().or(o.html.as_ref()) {
249                return Ok(text.clone());
250            }
251        }
252        if let Some(text) = self.changed(block, &d, true)? {
253            return Ok(label(&text, &d, false));
254        }
255        if let Some(o) = replace {
256            if let Some(text) = &o.text {
257                return Ok(label(text, &d, o.list_label_present));
258            }
259        }
260        if d["kind"] == "paragraph" {
261            if block.children.iter().any(|c| {
262                self.request
263                    .overrides
264                    .get(&c.id)
265                    .is_some_and(|o| !o.replace_content)
266            }) {
267                if let Some(inlines) = d["inlines"].as_array() {
268                    let mut result = String::new();
269                    for inline in inlines {
270                        if inline["type"] == "block" {
271                            result.push_str(&self.inline(inline, block, &d, true)?);
272                        } else {
273                            result.push_str(inline["text"].as_str().unwrap_or(""));
274                        }
275                    }
276                    return Ok(label(&result, &d, false));
277                }
278            }
279            return Ok(label(&block.content, &d, false));
280        }
281        if block.children.is_empty() {
282            Ok(block.content.clone())
283        } else {
284            block
285                .children
286                .iter()
287                .map(|c| self.text(c))
288                .collect::<Result<Vec<_>, _>>()
289                .map(|v| v.join("\n"))
290        }
291    }
292    fn table(&mut self, block: &DocumentBlock, d: &Value) -> Result<String, String> {
293        let format = self.request.options.table_format;
294        let mut rows: BTreeMap<usize, Vec<&DocumentBlock>> = BTreeMap::new();
295        for cell in cells(block) {
296            rows.entry(number(&cell.details.to_json_value(), "rowIndex", 0))
297                .or_default()
298                .push(cell);
299        }
300        let width = number(d, "columnCount", 1).max(1);
301        let style = &d["style"];
302        let mut rendered = Vec::new();
303        for (row_index, mut row) in rows {
304            row.sort_by_key(|c| number(&c.details.to_json_value(), "columnIndex", 0));
305            let mut values = if format == TableFormat::Html {
306                Vec::new()
307            } else {
308                vec![String::new(); width]
309            };
310            for cell in row {
311                let cd = cell.details.to_json_value();
312                let column = number(&cd, "columnIndex", 0);
313                if format != TableFormat::Html {
314                    values[column] = self.text(cell)?;
315                    continue;
316                }
317                let tag = if cd["isHeader"] == true { "th" } else { "td" };
318                let colspan = number(&cd, "columnSpan", 1).max(1);
319                let rowspan = number(&cd, "rowSpan", 1).max(1);
320                let content = if cell.children.is_empty() {
321                    escape_html(&cell.content)
322                } else {
323                    cell.children
324                        .iter()
325                        .map(|c| self.block(c))
326                        .collect::<Result<Vec<_>, _>>()?
327                        .join("\n")
328                };
329                let mut borders = json!({"top":style["borders"][if row_index==0{"top"}else{"insideH"}],"bottom":style["borders"][if row_index.saturating_add(rowspan)==number(d,"rowCount",0){"bottom"}else{"insideH"}],"left":style["borders"][if column==0{"left"}else{"insideV"}],"right":style["borders"][if column.saturating_add(colspan)==width{"right"}else{"insideV"}]});
330                if let Some(b) = cd["style"]["borders"].as_object() {
331                    borders.as_object_mut().unwrap().extend(b.clone());
332                }
333                let mut cell_style = cd["style"].as_object().cloned().unwrap_or_default();
334                cell_style.insert("borders".into(), borders);
335                values.push(format!(
336                    "<{tag} colspan=\"{colspan}\" rowspan=\"{rowspan}\"{}>{content}</{tag}>",
337                    styles::cell(&Value::Object(cell_style), style)
338                ));
339            }
340            rendered.push(match format {
341                TableFormat::Html => format!(
342                    "<tr{}>{}</tr>",
343                    styles::row(&d["rowStyles"][row_index]),
344                    values.join("")
345                ),
346                TableFormat::Csv => values
347                    .iter()
348                    .map(|t| format!("\"{}\"", t.replace('"', "\"\"")))
349                    .collect::<Vec<_>>()
350                    .join(","),
351                TableFormat::Markdown => format!(
352                    "| {} |",
353                    values
354                        .iter()
355                        .map(|t| t
356                            .replace('|', "\\|")
357                            .replace("\r\n", "\n")
358                            .replace('\n', "<br>"))
359                        .collect::<Vec<_>>()
360                        .join(" | ")
361                ),
362            });
363        }
364        Ok(match format {
365            TableFormat::Html => format!(
366                "<table{}>{}{}</table>",
367                styles::table(style),
368                styles::columns(style),
369                rendered.join("")
370            ),
371            TableFormat::Markdown => {
372                rendered.insert(
373                    usize::from(!rendered.is_empty()),
374                    format!("| {} |", vec!["---"; width].join(" | ")),
375                );
376                rendered.join("\n")
377            }
378            TableFormat::Csv => rendered.join("\n"),
379        })
380    }
381    fn block(&mut self, block: &DocumentBlock) -> Result<String, String> {
382        if let Some(content) = self.content.get(&block.id) {
383            return Ok(content.clone());
384        }
385        let d = block.details.to_json_value();
386        let o = self.request.overrides.get(&block.id);
387        let content = if let Some(o) = o
388            .filter(|o| o.replace_content && !self.request.revision_inlines.contains_key(&block.id))
389        {
390            o.html.clone().unwrap_or_else(|| {
391                format!(
392                    "<p>{}</p>",
393                    escape_html(&label(
394                        o.text.as_deref().unwrap_or(""),
395                        &d,
396                        o.list_label_present
397                    ))
398                    .replace('\n', "<br>")
399                )
400            })
401        } else if block.r#type == BlockType::Table {
402            self.table(block, &d)?
403        } else if matches!(
404            block.r#type,
405            BlockType::Figure | BlockType::Watermark | BlockType::Barcode | BlockType::Formula
406        ) {
407            let image_url = |field: &str| {
408                d[format!("{field}Url")].as_str().or_else(|| {
409                    d[format!("{field}AssetId")]
410                        .as_str()
411                        .and_then(|id| self.request.assets.get(id).map(String::as_str))
412                })
413            };
414            let primary = image_url("image").unwrap_or("");
415            let url =
416                if primary.to_ascii_lowercase().starts_with("data:image/") && safe_image(primary) {
417                    primary
418                } else {
419                    image_url("previewImage").unwrap_or(primary)
420                };
421            let width = d["width"].as_f64().filter(|n| n.is_finite() && *n > 0.);
422            let height = d["height"].as_f64().filter(|n| n.is_finite() && *n > 0.);
423            let size = width
424                .zip(height)
425                .map(|(w, h)| format!(" width=\"{w}\" height=\"{h}\""))
426                .unwrap_or_default();
427            let image = if !o.is_some_and(|o| o.suppress_image) && safe_image(url) {
428                format!(
429                    "<img src=\"{}\" alt=\"{}\"{size} />",
430                    escape_html(url),
431                    escape_html(d["alt"].as_str().unwrap_or(""))
432                )
433            } else {
434                String::new()
435            };
436            let text = o.and_then(|o| o.text.as_deref()).unwrap_or(&block.content);
437            let caption = o.and_then(|o| o.html.clone()).unwrap_or_else(|| {
438                if text.is_empty() {
439                    String::new()
440                } else {
441                    format!("<p>{}</p>", escape_html(text).replace('\n', "<br>"))
442                }
443            });
444            image + &caption
445        } else if d["kind"] == "story"
446            || matches!(block.r#type, BlockType::TableCell | BlockType::TableHead)
447        {
448            block
449                .children
450                .iter()
451                .map(|c| self.block(c))
452                .collect::<Result<Vec<_>, _>>()?
453                .join("\n")
454        } else {
455            let tag = if matches!(block.r#type, BlockType::Heading | BlockType::SectionHeading) {
456                format!("h{}", number(&d, "headingLevel", 1).clamp(1, 6))
457            } else {
458                "p".into()
459            };
460            let content = if let Some(changed) = self.changed(block, &d, false)? {
461                changed
462            } else if let Some(inlines) = d["inlines"].as_array() {
463                inlines
464                    .iter()
465                    .map(|i| self.inline(i, block, &d, false))
466                    .collect::<Result<Vec<_>, _>>()?
467                    .join("")
468            } else {
469                styles::text_html(&block.content, &d["field"]["style"], None)
470            };
471            format!(
472                "<{tag}{}>{}{content}</{tag}>",
473                styles::paragraph(&d["style"]),
474                escape_html(&label("", &d, false))
475            )
476        };
477        self.bytes = self.bytes.saturating_add(content.len());
478        if self.bytes > self.request.options.max_output_bytes {
479            return Err("LIMIT: exported content".into());
480        }
481        self.content.insert(block.id.clone(), content.clone());
482        Ok(content)
483    }
484}
485
486/// Export parsed blocks or caller-selected projections without depending on an application schema.
487pub fn export_document(request: &ExportRequest) -> Result<ExportedDocument, String> {
488    let mut pending: Vec<_> = request.blocks.iter().map(|b| (b, 0)).collect();
489    let mut ids = HashSet::new();
490    let mut all = Vec::new();
491    while let Some((block, depth)) = pending.pop() {
492        if depth > request.options.max_depth || ids.len() >= request.options.max_blocks {
493            return Err("LIMIT: export structure".into());
494        }
495        if !ids.insert(block.id.as_str()) {
496            return Err("Duplicate export block ID".into());
497        }
498        if block.r#type == BlockType::Table {
499            let d = block.details.to_json_value();
500            let columns = number(&d, "columnCount", 1).max(1);
501            let rows = number(&d, "rowCount", 0);
502            if columns > request.options.max_table_columns
503                || rows.saturating_mul(columns) > request.options.max_table_cells
504                || cells(block).iter().any(|c| {
505                    let details = c.details.to_json_value();
506                    number(&details, "columnIndex", 0) >= columns
507                        || number(&details, "rowIndex", 0) >= rows
508                })
509            {
510                return Err("LIMIT: export table".into());
511            }
512        }
513        all.push(block);
514        pending.extend(block.children.iter().map(|b| (b, depth + 1)));
515    }
516    let children = all
517        .iter()
518        .map(|b| {
519            (
520                b.id.as_str(),
521                b.children.iter().map(|c| (c.id.as_str(), c)).collect(),
522            )
523        })
524        .collect();
525    let mut exporter = Exporter {
526        request,
527        content: BTreeMap::new(),
528        children,
529        bytes: 0,
530    };
531    let content = request
532        .blocks
533        .iter()
534        .map(|b| exporter.block(b))
535        .collect::<Result<Vec<_>, _>>()?
536        .join("\n\n");
537    for block in all {
538        exporter.block(block)?;
539    }
540    Ok(ExportedDocument {
541        content,
542        block_content: exporter.content,
543    })
544}