1mod 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 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 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 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('&', "&")
75 .replace('<', "<")
76 .replace('>', ">")
77 .replace('"', """)
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
486pub 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}