Skip to main content

text_document/
fragment.rs

1//! DocumentFragment — format-agnostic rich text interchange type.
2
3use crate::{InlineContent, ListStyle};
4use frontend::common::parser_tools::content_parser::{ParsedElement, ParsedSpan};
5use frontend::common::parser_tools::fragment_schema::{
6    FragmentBlock, FragmentData, FragmentElement, FragmentTable, FragmentTableCell,
7};
8
9/// A piece of rich text that can be inserted into a [`TextDocument`](crate::TextDocument).
10///
11/// `DocumentFragment` is the clipboard/interchange type. It carries
12/// blocks, per-character format runs, image anchors, and structural
13/// metadata in a format-agnostic internal representation.
14#[derive(Debug, Clone)]
15pub struct DocumentFragment {
16    data: String,
17    plain_text: String,
18}
19
20impl DocumentFragment {
21    /// Create an empty fragment.
22    pub fn new() -> Self {
23        Self {
24            data: String::new(),
25            plain_text: String::new(),
26        }
27    }
28
29    /// Create a fragment from plain text.
30    ///
31    /// Builds valid fragment data so the fragment can be inserted via
32    /// [`TextCursor::insert_fragment`](crate::TextCursor::insert_fragment).
33    pub fn from_plain_text(text: &str) -> Self {
34        let blocks: Vec<FragmentBlock> = text
35            .split('\n')
36            .map(|line| FragmentBlock {
37                plain_text: line.to_string(),
38                elements: vec![FragmentElement {
39                    content: InlineContent::Text(line.to_string()),
40                    fmt_font_family: None,
41                    fmt_font_point_size: None,
42                    fmt_font_weight: None,
43                    fmt_font_bold: None,
44                    fmt_font_italic: None,
45                    fmt_font_underline: None,
46                    fmt_font_overline: None,
47                    fmt_font_strikeout: None,
48                    fmt_letter_spacing: None,
49                    fmt_word_spacing: None,
50                    fmt_anchor_href: None,
51                    fmt_anchor_names: vec![],
52                    fmt_is_anchor: None,
53                    fmt_tooltip: None,
54                    fmt_underline_style: None,
55                    fmt_vertical_alignment: None,
56                }],
57                heading_level: None,
58                list: None,
59                alignment: None,
60                indent: None,
61                text_indent: None,
62                marker: None,
63                top_margin: None,
64                bottom_margin: None,
65                left_margin: None,
66                right_margin: None,
67                tab_positions: vec![],
68                line_height: None,
69                non_breakable_lines: None,
70                direction: None,
71                background_color: None,
72                is_code_block: None,
73                code_language: None,
74                hyphenate: None,
75                language: None,
76            })
77            .collect();
78
79        let data = serde_json::to_string(&FragmentData {
80            blocks,
81            tables: vec![],
82        })
83        .expect("fragment serialization should not fail");
84
85        Self {
86            data,
87            plain_text: text.to_string(),
88        }
89    }
90
91    /// Create a fragment from HTML.
92    pub fn from_html(html: &str) -> Self {
93        let parsed = frontend::common::parser_tools::content_parser::parse_html_elements(html);
94        parsed_elements_to_fragment(parsed)
95    }
96
97    /// Create a fragment from Markdown.
98    pub fn from_markdown(markdown: &str) -> Self {
99        let parsed = frontend::common::parser_tools::content_parser::parse_markdown(markdown);
100        parsed_elements_to_fragment(parsed)
101    }
102
103    /// Create a fragment from djot markup. Paste always uses the lossless
104    /// default [`crate::DjotImportOptions`]; per-feature selection is exposed on
105    /// the document-level import path (`TextDocument::set_djot_with_options`).
106    pub fn from_djot(djot: &str) -> Self {
107        let parsed = frontend::common::parser_tools::content_parser::parse_djot(
108            djot,
109            &frontend::common::parser_tools::DjotImportOptions::default(),
110        );
111        parsed_elements_to_fragment(parsed)
112    }
113
114    /// Create a fragment from an entire document.
115    pub fn from_document(doc: &crate::TextDocument) -> crate::Result<Self> {
116        let inner = doc.inner.lock();
117        // Use i64::MAX as anchor to ensure the full document is captured.
118        // Document positions include inter-block gaps, so character_count
119        // alone would truncate the last block.
120        let dto = frontend::document_inspection::ExtractFragmentDto {
121            position: 0,
122            anchor: i64::MAX,
123        };
124        let result =
125            frontend::commands::document_inspection_commands::extract_fragment(&inner.ctx, &dto)?;
126        Ok(Self::from_raw(result.fragment_data, result.plain_text))
127    }
128
129    /// Create a fragment from the serialized internal format.
130    pub(crate) fn from_raw(data: String, plain_text: String) -> Self {
131        Self { data, plain_text }
132    }
133
134    /// Export the fragment as plain text.
135    pub fn to_plain_text(&self) -> &str {
136        &self.plain_text
137    }
138
139    /// Export the fragment as HTML.
140    pub fn to_html(&self) -> String {
141        if self.data.is_empty() {
142            return String::from("<html><head><meta charset=\"utf-8\"></head><body></body></html>");
143        }
144
145        let fragment_data: FragmentData = match serde_json::from_str(&self.data) {
146            Ok(d) => d,
147            Err(_) => {
148                return String::from(
149                    "<html><head><meta charset=\"utf-8\"></head><body></body></html>",
150                );
151            }
152        };
153
154        let mut body = String::new();
155        let blocks = &fragment_data.blocks;
156
157        // Single inline-only block with no tables: emit inline HTML without block wrapper
158        if blocks.len() == 1 && blocks[0].is_inline_only() && fragment_data.tables.is_empty() {
159            push_inline_html(&mut body, &blocks[0].elements);
160            return format!(
161                "<html><head><meta charset=\"utf-8\"></head><body>{}</body></html>",
162                body
163            );
164        }
165
166        // Sort tables by block_insert_index so we can interleave them
167        let mut sorted_tables: Vec<&FragmentTable> = fragment_data.tables.iter().collect();
168        sorted_tables.sort_by_key(|t| t.block_insert_index);
169        let mut table_cursor = 0;
170
171        let mut i = 0;
172
173        while i < blocks.len() {
174            // Insert any tables whose block_insert_index == i
175            while table_cursor < sorted_tables.len()
176                && sorted_tables[table_cursor].block_insert_index <= i
177            {
178                push_table_html(&mut body, sorted_tables[table_cursor]);
179                table_cursor += 1;
180            }
181
182            let block = &blocks[i];
183
184            if let Some(ref list) = block.list {
185                let is_ordered = is_ordered_list_style(&list.style);
186                let list_tag = if is_ordered { "ol" } else { "ul" };
187                body.push('<');
188                body.push_str(list_tag);
189                body.push('>');
190
191                while i < blocks.len() {
192                    let b = &blocks[i];
193                    match &b.list {
194                        Some(l) if is_ordered_list_style(&l.style) == is_ordered => {
195                            body.push_str("<li>");
196                            push_inline_html(&mut body, &b.elements);
197                            body.push_str("</li>");
198                            i += 1;
199                        }
200                        _ => break,
201                    }
202                }
203
204                body.push_str("</");
205                body.push_str(list_tag);
206                body.push('>');
207            } else if let Some(level) = block.heading_level {
208                let n = level.clamp(1, 6);
209                body.push_str(&format!("<h{}>", n));
210                push_inline_html(&mut body, &block.elements);
211                body.push_str(&format!("</h{}>", n));
212                i += 1;
213            } else {
214                // Emit block-level formatting as inline styles (ISSUE-19)
215                let style = block_style_attr(block);
216                if style.is_empty() {
217                    body.push_str("<p>");
218                } else {
219                    body.push_str(&format!("<p style=\"{}\">", style));
220                }
221                push_inline_html(&mut body, &block.elements);
222                body.push_str("</p>");
223                i += 1;
224            }
225        }
226
227        // Emit any remaining tables after all blocks
228        while table_cursor < sorted_tables.len() {
229            push_table_html(&mut body, sorted_tables[table_cursor]);
230            table_cursor += 1;
231        }
232
233        format!(
234            "<html><head><meta charset=\"utf-8\"></head><body>{}</body></html>",
235            body
236        )
237    }
238
239    /// Export the fragment as Markdown.
240    pub fn to_markdown(&self) -> String {
241        if self.data.is_empty() {
242            return String::new();
243        }
244
245        let fragment_data: FragmentData = match serde_json::from_str(&self.data) {
246            Ok(d) => d,
247            Err(_) => return String::new(),
248        };
249
250        // (rendered_text, is_list_item) — used for join logic
251        let mut parts: Vec<(String, bool)> = Vec::new();
252        let mut prev_was_list = false;
253        let mut list_counter: u32 = 0;
254
255        // Sort tables by block_insert_index for interleaving
256        let mut sorted_tables: Vec<&FragmentTable> = fragment_data.tables.iter().collect();
257        sorted_tables.sort_by_key(|t| t.block_insert_index);
258        let mut table_cursor = 0;
259
260        for (blk_idx, block) in fragment_data.blocks.iter().enumerate() {
261            // Insert tables before this block index
262            while table_cursor < sorted_tables.len()
263                && sorted_tables[table_cursor].block_insert_index <= blk_idx
264            {
265                parts.push((render_table_markdown(sorted_tables[table_cursor]), false));
266                prev_was_list = false;
267                list_counter = 0;
268                table_cursor += 1;
269            }
270
271            let inline_text = render_inline_markdown(&block.elements);
272            let is_list = block.list.is_some();
273
274            let indent_prefix = match block.indent {
275                Some(n) if n > 0 => "  ".repeat(n as usize),
276                _ => String::new(),
277            };
278
279            if let Some(level) = block.heading_level {
280                let n = level.clamp(1, 6) as usize;
281                let prefix = "#".repeat(n);
282                parts.push((format!("{} {}", prefix, inline_text), false));
283                prev_was_list = false;
284                list_counter = 0;
285            } else if let Some(ref list) = block.list {
286                let is_ordered = is_ordered_list_style(&list.style);
287                if !prev_was_list {
288                    list_counter = 0;
289                }
290                if is_ordered {
291                    list_counter += 1;
292                    parts.push((
293                        format!("{}{}. {}", indent_prefix, list_counter, inline_text),
294                        true,
295                    ));
296                } else {
297                    parts.push((format!("{}- {}", indent_prefix, inline_text), true));
298                }
299                prev_was_list = true;
300            } else {
301                if indent_prefix.is_empty() {
302                    parts.push((inline_text, false));
303                } else {
304                    parts.push((format!("{}{}", indent_prefix, inline_text), false));
305                }
306                prev_was_list = false;
307                list_counter = 0;
308            }
309
310            if !is_list {
311                prev_was_list = false;
312            }
313        }
314
315        // Emit remaining tables after all blocks
316        while table_cursor < sorted_tables.len() {
317            parts.push((render_table_markdown(sorted_tables[table_cursor]), false));
318            table_cursor += 1;
319        }
320
321        // Join: list items with \n, others with \n\n
322        let mut result = String::new();
323        for (idx, (text, is_list)) in parts.iter().enumerate() {
324            if idx > 0 {
325                let (_, prev_is_list) = &parts[idx - 1];
326                if *prev_is_list && *is_list {
327                    result.push('\n');
328                } else {
329                    result.push_str("\n\n");
330                }
331            }
332            result.push_str(text);
333        }
334
335        result
336    }
337
338    /// Returns true if the fragment contains no text or elements.
339    pub fn is_empty(&self) -> bool {
340        self.plain_text.is_empty()
341    }
342
343    /// Returns the serialized internal representation.
344    pub(crate) fn raw_data(&self) -> &str {
345        &self.data
346    }
347}
348
349impl Default for DocumentFragment {
350    fn default() -> Self {
351        Self::new()
352    }
353}
354
355// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
356// Shared helpers (used by both to_html and to_markdown)
357// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
358
359fn is_ordered_list_style(style: &ListStyle) -> bool {
360    matches!(
361        style,
362        ListStyle::Decimal
363            | ListStyle::LowerAlpha
364            | ListStyle::UpperAlpha
365            | ListStyle::LowerRoman
366            | ListStyle::UpperRoman
367    )
368}
369
370// ── HTML helpers ────────────────────────────────────────────────
371
372fn escape_html(s: &str) -> String {
373    let mut out = String::with_capacity(s.len());
374    for c in s.chars() {
375        match c {
376            '&' => out.push_str("&amp;"),
377            '<' => out.push_str("&lt;"),
378            '>' => out.push_str("&gt;"),
379            '"' => out.push_str("&quot;"),
380            '\'' => out.push_str("&#x27;"),
381            // A raw CR in text content is normalised to LF by the HTML5 input
382            // preprocessor on re-import (CR-from-`&#xD;` survives, literal CR
383            // does not), which breaks serialiser idempotency. Emit it as a
384            // numeric reference so it round-trips losslessly.
385            '\r' => out.push_str("&#13;"),
386            _ => out.push(c),
387        }
388    }
389    out
390}
391
392/// Build a CSS `style` attribute value from block-level formatting (ISSUE-19).
393fn block_style_attr(block: &FragmentBlock) -> String {
394    use crate::Alignment;
395
396    let mut parts = Vec::new();
397    if let Some(ref alignment) = block.alignment {
398        let value = match alignment {
399            Alignment::Left => "left",
400            Alignment::Right => "right",
401            Alignment::Center => "center",
402            Alignment::Justify => "justify",
403        };
404        parts.push(format!("text-align: {}", value));
405    }
406    if let Some(n) = block.indent
407        && n > 0
408    {
409        parts.push(format!("margin-left: {}em", n));
410    }
411    if let Some(px) = block.text_indent
412        && px != 0
413    {
414        parts.push(format!("text-indent: {}px", px));
415    }
416    if let Some(px) = block.top_margin {
417        parts.push(format!("margin-top: {}px", px));
418    }
419    if let Some(px) = block.bottom_margin {
420        parts.push(format!("margin-bottom: {}px", px));
421    }
422    if let Some(px) = block.left_margin {
423        parts.push(format!("margin-left: {}px", px));
424    }
425    if let Some(px) = block.right_margin {
426        parts.push(format!("margin-right: {}px", px));
427    }
428    parts.join("; ")
429}
430
431fn push_inline_html(out: &mut String, elements: &[FragmentElement]) {
432    for elem in elements {
433        let text = match &elem.content {
434            InlineContent::Text(t) => escape_html(t),
435            InlineContent::Image {
436                name,
437                width,
438                height,
439                ..
440            } => {
441                format!(
442                    "<img src=\"{}\" width=\"{}\" height=\"{}\">",
443                    escape_html(name),
444                    width,
445                    height
446                )
447            }
448            InlineContent::Empty => String::new(),
449        };
450
451        let is_monospace = elem
452            .fmt_font_family
453            .as_deref()
454            .is_some_and(|f| f == "monospace");
455        let is_bold = elem.fmt_font_bold.unwrap_or(false);
456        let is_italic = elem.fmt_font_italic.unwrap_or(false);
457        let is_underline = elem.fmt_font_underline.unwrap_or(false);
458        let is_strikeout = elem.fmt_font_strikeout.unwrap_or(false);
459        let is_anchor = elem.fmt_is_anchor.unwrap_or(false);
460
461        let mut result = text;
462
463        if is_monospace {
464            result = format!("<code>{}</code>", result);
465        }
466        if is_bold {
467            result = format!("<strong>{}</strong>", result);
468        }
469        if is_italic {
470            result = format!("<em>{}</em>", result);
471        }
472        if is_underline {
473            result = format!("<u>{}</u>", result);
474        }
475        if is_strikeout {
476            result = format!("<s>{}</s>", result);
477        }
478        if is_anchor && let Some(ref href) = elem.fmt_anchor_href {
479            result = format!("<a href=\"{}\">{}</a>", escape_html(href), result);
480        }
481
482        out.push_str(&result);
483    }
484}
485
486/// Emit an HTML `<table>` for a `FragmentTable`.
487fn push_table_html(out: &mut String, table: &FragmentTable) {
488    out.push_str("<table>");
489    for row in 0..table.rows {
490        out.push_str("<tr>");
491        for col in 0..table.columns {
492            if let Some(cell) = table.cells.iter().find(|c| c.row == row && c.column == col) {
493                out.push_str("<td");
494                if cell.row_span > 1 {
495                    out.push_str(&format!(" rowspan=\"{}\"", cell.row_span));
496                }
497                if cell.column_span > 1 {
498                    out.push_str(&format!(" colspan=\"{}\"", cell.column_span));
499                }
500                out.push('>');
501                for (i, block) in cell.blocks.iter().enumerate() {
502                    if i > 0 {
503                        out.push_str("<br>");
504                    }
505                    push_inline_html(out, &block.elements);
506                }
507                out.push_str("</td>");
508            }
509            // Skip positions covered by spans — the HTML renderer handles them.
510        }
511        out.push_str("</tr>");
512    }
513    out.push_str("</table>");
514}
515
516// ── Markdown helpers ────────────────────────────────────────────
517
518fn escape_markdown(s: &str) -> String {
519    let mut out = String::with_capacity(s.len());
520    for c in s.chars() {
521        if matches!(
522            c,
523            '\\' | '`'
524                | '*'
525                | '_'
526                | '{'
527                | '}'
528                | '['
529                | ']'
530                | '('
531                | ')'
532                | '#'
533                | '+'
534                | '-'
535                | '.'
536                | '!'
537                | '|'
538                | '~'
539                | '<'
540                | '>'
541        ) {
542            out.push('\\');
543        }
544        out.push(c);
545    }
546    out
547}
548
549fn render_inline_markdown(elements: &[FragmentElement]) -> String {
550    let mut out = String::new();
551    for elem in elements {
552        let raw_text = match &elem.content {
553            InlineContent::Text(t) => t.clone(),
554            InlineContent::Image { name, .. } => format!("![{}]({})", name, name),
555            InlineContent::Empty => String::new(),
556        };
557
558        let is_monospace = elem
559            .fmt_font_family
560            .as_deref()
561            .is_some_and(|f| f == "monospace");
562        let is_bold = elem.fmt_font_bold.unwrap_or(false);
563        let is_italic = elem.fmt_font_italic.unwrap_or(false);
564        let is_strikeout = elem.fmt_font_strikeout.unwrap_or(false);
565        let is_anchor = elem.fmt_is_anchor.unwrap_or(false);
566
567        if is_monospace {
568            out.push('`');
569            out.push_str(&raw_text);
570            out.push('`');
571        } else {
572            let mut text = escape_markdown(&raw_text);
573            if is_bold && is_italic {
574                text = format!("***{}***", text);
575            } else if is_bold {
576                text = format!("**{}**", text);
577            } else if is_italic {
578                text = format!("*{}*", text);
579            }
580            if is_strikeout {
581                text = format!("~~{}~~", text);
582            }
583            if is_anchor {
584                let href = elem.fmt_anchor_href.as_deref().unwrap_or("");
585                out.push_str(&format!("[{}]({})", text, href));
586            } else {
587                out.push_str(&text);
588            }
589        }
590    }
591    out
592}
593
594/// Render a `FragmentTable` as a pipe-delimited Markdown table.
595fn render_table_markdown(table: &FragmentTable) -> String {
596    let mut rows: Vec<Vec<String>> = vec![vec![String::new(); table.columns]; table.rows];
597
598    for cell in &table.cells {
599        let text: String = cell
600            .blocks
601            .iter()
602            .map(|b| render_inline_markdown(&b.elements))
603            .collect::<Vec<_>>()
604            .join(" ");
605        if cell.row < table.rows && cell.column < table.columns {
606            rows[cell.row][cell.column] = text;
607        }
608    }
609
610    let mut out = String::new();
611    for (i, row) in rows.iter().enumerate() {
612        out.push_str("| ");
613        out.push_str(&row.join(" | "));
614        out.push_str(" |");
615        if i == 0 {
616            // Header separator
617            out.push('\n');
618            out.push('|');
619            for _ in 0..table.columns {
620                out.push_str(" --- |");
621            }
622        }
623        if i + 1 < rows.len() {
624            out.push('\n');
625        }
626    }
627    out
628}
629
630// ── Fragment construction from parsed content ───────────────────
631
632/// Convert parsed blocks (from HTML or Markdown parser) into a `DocumentFragment`.
633/// Convert a `ParsedSpan` to a `FragmentElement`.
634fn span_to_fragment_element(span: &ParsedSpan) -> FragmentElement {
635    let content = InlineContent::Text(span.text.clone());
636    let fmt_font_family = if span.code {
637        Some("monospace".into())
638    } else {
639        None
640    };
641    let fmt_font_bold = if span.bold { Some(true) } else { None };
642    let fmt_font_italic = if span.italic { Some(true) } else { None };
643    let fmt_font_underline = if span.underline { Some(true) } else { None };
644    let fmt_font_strikeout = if span.strikeout { Some(true) } else { None };
645    let (fmt_anchor_href, fmt_is_anchor) = if let Some(ref href) = span.link_href {
646        (Some(href.clone()), Some(true))
647    } else {
648        (None, None)
649    };
650
651    FragmentElement {
652        content,
653        fmt_font_family,
654        fmt_font_point_size: None,
655        fmt_font_weight: None,
656        fmt_font_bold,
657        fmt_font_italic,
658        fmt_font_underline,
659        fmt_font_overline: None,
660        fmt_font_strikeout,
661        fmt_letter_spacing: None,
662        fmt_word_spacing: None,
663        fmt_anchor_href,
664        fmt_anchor_names: vec![],
665        fmt_is_anchor,
666        fmt_tooltip: None,
667        fmt_underline_style: None,
668        fmt_vertical_alignment: None,
669    }
670}
671
672/// Convert parsed elements (blocks + tables) into a `DocumentFragment`,
673/// preserving table structure as `FragmentTable` entries.
674fn parsed_elements_to_fragment(parsed: Vec<ParsedElement>) -> DocumentFragment {
675    use frontend::common::parser_tools::fragment_schema::FragmentList;
676
677    let mut blocks: Vec<FragmentBlock> = Vec::new();
678    let mut tables: Vec<FragmentTable> = Vec::new();
679
680    for elem in parsed {
681        match elem {
682            ParsedElement::Block(pb) => {
683                let elements: Vec<FragmentElement> =
684                    pb.spans.iter().map(span_to_fragment_element).collect();
685                let plain_text: String = pb.spans.iter().map(|s| s.text.as_str()).collect();
686                let list = pb.list_style.map(|style| FragmentList {
687                    style,
688                    indent: pb.list_indent as i64,
689                    prefix: String::new(),
690                    suffix: String::new(),
691                });
692
693                blocks.push(FragmentBlock {
694                    plain_text,
695                    elements,
696                    heading_level: pb.heading_level,
697                    list,
698                    alignment: None,
699                    indent: None,
700                    text_indent: None,
701                    marker: None,
702                    top_margin: None,
703                    bottom_margin: None,
704                    left_margin: None,
705                    right_margin: None,
706                    tab_positions: vec![],
707                    line_height: pb.line_height,
708                    non_breakable_lines: pb.non_breakable_lines,
709                    direction: pb.direction,
710                    background_color: pb.background_color,
711                    is_code_block: None,
712                    code_language: None,
713                    hyphenate: None,
714                    language: None,
715                });
716            }
717            ParsedElement::Table(pt) => {
718                let block_insert_index = blocks.len();
719                let num_columns = pt.rows.iter().map(|r| r.len()).max().unwrap_or(0);
720                let num_rows = pt.rows.len();
721
722                let mut frag_cells: Vec<FragmentTableCell> = Vec::new();
723                for (row_idx, row) in pt.rows.iter().enumerate() {
724                    for (col_idx, cell) in row.iter().enumerate() {
725                        let cell_elements: Vec<FragmentElement> =
726                            cell.spans.iter().map(span_to_fragment_element).collect();
727                        let cell_text: String =
728                            cell.spans.iter().map(|s| s.text.as_str()).collect();
729
730                        frag_cells.push(FragmentTableCell {
731                            row: row_idx,
732                            column: col_idx,
733                            row_span: 1,
734                            column_span: 1,
735                            blocks: vec![FragmentBlock {
736                                plain_text: cell_text,
737                                elements: cell_elements,
738                                heading_level: None,
739                                list: None,
740                                alignment: None,
741                                indent: None,
742                                text_indent: None,
743                                marker: None,
744                                top_margin: None,
745                                bottom_margin: None,
746                                left_margin: None,
747                                right_margin: None,
748                                tab_positions: vec![],
749                                line_height: None,
750                                non_breakable_lines: None,
751                                direction: None,
752                                background_color: None,
753                                is_code_block: None,
754                                code_language: None,
755                                hyphenate: None,
756                                language: None,
757                            }],
758                            fmt_padding: None,
759                            fmt_border: None,
760                            fmt_vertical_alignment: None,
761                            fmt_background_color: None,
762                        });
763                    }
764                }
765
766                tables.push(FragmentTable {
767                    rows: num_rows,
768                    columns: num_columns,
769                    cells: frag_cells,
770                    block_insert_index,
771                    fmt_border: None,
772                    fmt_cell_spacing: None,
773                    fmt_cell_padding: None,
774                    fmt_width: None,
775                    fmt_alignment: None,
776                    column_widths: vec![],
777                });
778            }
779        }
780    }
781
782    let data = serde_json::to_string(&FragmentData { blocks, tables })
783        .expect("fragment serialization should not fail");
784
785    let plain_text = parsed_plain_text_from_data(&data);
786
787    DocumentFragment { data, plain_text }
788}
789
790/// Extract plain text from serialized fragment data.
791fn parsed_plain_text_from_data(data: &str) -> String {
792    let fragment_data: FragmentData = match serde_json::from_str(data) {
793        Ok(d) => d,
794        Err(_) => return String::new(),
795    };
796
797    fragment_data
798        .blocks
799        .iter()
800        .map(|b| b.plain_text.as_str())
801        .collect::<Vec<_>>()
802        .join("\n")
803}