Skip to main content

text_document/
fragment.rs

1//! DocumentFragment — format-agnostic rich text interchange type.
2
3use crate::{CharVerticalAlignment, 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                page_break_before: None,
71                direction: None,
72                background_color: None,
73                is_code_block: None,
74                code_language: None,
75                hyphenate: None,
76                language: None,
77            })
78            .collect();
79
80        let data = serde_json::to_string(&FragmentData {
81            blocks,
82            tables: vec![],
83        })
84        .expect("fragment serialization should not fail");
85
86        Self {
87            data,
88            plain_text: text.to_string(),
89        }
90    }
91
92    /// Create a fragment from HTML.
93    pub fn from_html(html: &str) -> Self {
94        let parsed = frontend::common::parser_tools::content_parser::parse_html_elements(html);
95        parsed_elements_to_fragment(parsed)
96    }
97
98    /// Create a fragment from Markdown.
99    pub fn from_markdown(markdown: &str) -> Self {
100        let parsed = frontend::common::parser_tools::content_parser::parse_markdown(markdown);
101        parsed_elements_to_fragment(parsed)
102    }
103
104    /// Create a fragment from djot markup. Paste always uses the lossless
105    /// default [`crate::DjotImportOptions`]; per-feature selection is exposed on
106    /// the document-level import path (`TextDocument::set_djot_with_options`).
107    pub fn from_djot(djot: &str) -> Self {
108        let parsed = frontend::common::parser_tools::content_parser::parse_djot(
109            djot,
110            &frontend::common::parser_tools::DjotImportOptions::default(),
111        );
112        parsed_elements_to_fragment(parsed)
113    }
114
115    /// Create a fragment from an entire document.
116    pub fn from_document(doc: &crate::TextDocument) -> crate::Result<Self> {
117        let inner = doc.inner.lock();
118        // Use i64::MAX as anchor to ensure the full document is captured.
119        // Document positions include inter-block gaps, so character_count
120        // alone would truncate the last block.
121        let dto = frontend::document_inspection::ExtractFragmentDto {
122            position: 0,
123            anchor: i64::MAX,
124        };
125        let result =
126            frontend::commands::document_inspection_commands::extract_fragment(&inner.ctx, &dto)?;
127        Ok(Self::from_raw(result.fragment_data, result.plain_text))
128    }
129
130    /// Create a fragment from the serialized internal format.
131    pub(crate) fn from_raw(data: String, plain_text: String) -> Self {
132        Self { data, plain_text }
133    }
134
135    /// Export the fragment as plain text.
136    pub fn to_plain_text(&self) -> &str {
137        &self.plain_text
138    }
139
140    /// Export the fragment as HTML.
141    pub fn to_html(&self) -> String {
142        if self.data.is_empty() {
143            return String::from("<html><head><meta charset=\"utf-8\"></head><body></body></html>");
144        }
145
146        let fragment_data: FragmentData = match serde_json::from_str(&self.data) {
147            Ok(d) => d,
148            Err(_) => {
149                return String::from(
150                    "<html><head><meta charset=\"utf-8\"></head><body></body></html>",
151                );
152            }
153        };
154
155        let mut body = String::new();
156        let blocks = &fragment_data.blocks;
157
158        // Single inline-only block with no tables: emit inline HTML without block wrapper
159        if blocks.len() == 1 && blocks[0].is_inline_only() && fragment_data.tables.is_empty() {
160            push_inline_html(&mut body, &blocks[0].elements);
161            return format!(
162                "<html><head><meta charset=\"utf-8\"></head><body>{}</body></html>",
163                body
164            );
165        }
166
167        // Sort tables by block_insert_index so we can interleave them
168        let mut sorted_tables: Vec<&FragmentTable> = fragment_data.tables.iter().collect();
169        sorted_tables.sort_by_key(|t| t.block_insert_index);
170        let mut table_cursor = 0;
171
172        let mut i = 0;
173
174        while i < blocks.len() {
175            // Insert any tables whose block_insert_index == i
176            while table_cursor < sorted_tables.len()
177                && sorted_tables[table_cursor].block_insert_index <= i
178            {
179                push_table_html(&mut body, sorted_tables[table_cursor]);
180                table_cursor += 1;
181            }
182
183            let block = &blocks[i];
184
185            if let Some(ref list) = block.list {
186                let is_ordered = is_ordered_list_style(&list.style);
187                let list_tag = if is_ordered { "ol" } else { "ul" };
188                body.push('<');
189                body.push_str(list_tag);
190                body.push('>');
191
192                while i < blocks.len() {
193                    let b = &blocks[i];
194                    match &b.list {
195                        Some(l) if is_ordered_list_style(&l.style) == is_ordered => {
196                            body.push_str("<li>");
197                            push_inline_html(&mut body, &b.elements);
198                            body.push_str("</li>");
199                            i += 1;
200                        }
201                        _ => break,
202                    }
203                }
204
205                body.push_str("</");
206                body.push_str(list_tag);
207                body.push('>');
208            } else if let Some(level) = block.heading_level {
209                let n = level.clamp(1, 6);
210                body.push_str(&format!("<h{}>", n));
211                push_inline_html(&mut body, &block.elements);
212                body.push_str(&format!("</h{}>", n));
213                i += 1;
214            } else {
215                // Emit block-level formatting as inline styles (ISSUE-19)
216                let style = block_style_attr(block);
217                if style.is_empty() {
218                    body.push_str("<p>");
219                } else {
220                    body.push_str(&format!("<p style=\"{}\">", style));
221                }
222                push_inline_html(&mut body, &block.elements);
223                body.push_str("</p>");
224                i += 1;
225            }
226        }
227
228        // Emit any remaining tables after all blocks
229        while table_cursor < sorted_tables.len() {
230            push_table_html(&mut body, sorted_tables[table_cursor]);
231            table_cursor += 1;
232        }
233
234        format!(
235            "<html><head><meta charset=\"utf-8\"></head><body>{}</body></html>",
236            body
237        )
238    }
239
240    /// Export the fragment as Markdown.
241    pub fn to_markdown(&self) -> String {
242        if self.data.is_empty() {
243            return String::new();
244        }
245
246        let fragment_data: FragmentData = match serde_json::from_str(&self.data) {
247            Ok(d) => d,
248            Err(_) => return String::new(),
249        };
250
251        // (rendered_text, is_list_item) — used for join logic
252        let mut parts: Vec<(String, bool)> = Vec::new();
253        let mut prev_was_list = false;
254        let mut list_counter: u32 = 0;
255
256        // Sort tables by block_insert_index for interleaving
257        let mut sorted_tables: Vec<&FragmentTable> = fragment_data.tables.iter().collect();
258        sorted_tables.sort_by_key(|t| t.block_insert_index);
259        let mut table_cursor = 0;
260
261        for (blk_idx, block) in fragment_data.blocks.iter().enumerate() {
262            // Insert tables before this block index
263            while table_cursor < sorted_tables.len()
264                && sorted_tables[table_cursor].block_insert_index <= blk_idx
265            {
266                parts.push((render_table_markdown(sorted_tables[table_cursor]), false));
267                prev_was_list = false;
268                list_counter = 0;
269                table_cursor += 1;
270            }
271
272            let inline_text = render_inline_markdown(&block.elements);
273            let is_list = block.list.is_some();
274
275            let indent_prefix = match block.indent {
276                Some(n) if n > 0 => "  ".repeat(n as usize),
277                _ => String::new(),
278            };
279
280            if let Some(level) = block.heading_level {
281                let n = level.clamp(1, 6) as usize;
282                let prefix = "#".repeat(n);
283                parts.push((format!("{} {}", prefix, inline_text), false));
284                prev_was_list = false;
285                list_counter = 0;
286            } else if let Some(ref list) = block.list {
287                let is_ordered = is_ordered_list_style(&list.style);
288                if !prev_was_list {
289                    list_counter = 0;
290                }
291                if is_ordered {
292                    list_counter += 1;
293                    parts.push((
294                        format!("{}{}. {}", indent_prefix, list_counter, inline_text),
295                        true,
296                    ));
297                } else {
298                    parts.push((format!("{}- {}", indent_prefix, inline_text), true));
299                }
300                prev_was_list = true;
301            } else {
302                if indent_prefix.is_empty() {
303                    parts.push((inline_text, false));
304                } else {
305                    parts.push((format!("{}{}", indent_prefix, inline_text), false));
306                }
307                prev_was_list = false;
308                list_counter = 0;
309            }
310
311            if !is_list {
312                prev_was_list = false;
313            }
314        }
315
316        // Emit remaining tables after all blocks
317        while table_cursor < sorted_tables.len() {
318            parts.push((render_table_markdown(sorted_tables[table_cursor]), false));
319            table_cursor += 1;
320        }
321
322        // Join: list items with \n, others with \n\n
323        let mut result = String::new();
324        for (idx, (text, is_list)) in parts.iter().enumerate() {
325            if idx > 0 {
326                let (_, prev_is_list) = &parts[idx - 1];
327                if *prev_is_list && *is_list {
328                    result.push('\n');
329                } else {
330                    result.push_str("\n\n");
331                }
332            }
333            result.push_str(text);
334        }
335
336        result
337    }
338
339    /// Returns true if the fragment contains no text or elements.
340    pub fn is_empty(&self) -> bool {
341        self.plain_text.is_empty()
342    }
343
344    /// Returns the serialized internal representation.
345    pub(crate) fn raw_data(&self) -> &str {
346        &self.data
347    }
348}
349
350impl Default for DocumentFragment {
351    fn default() -> Self {
352        Self::new()
353    }
354}
355
356// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
357// Shared helpers (used by both to_html and to_markdown)
358// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
359
360fn is_ordered_list_style(style: &ListStyle) -> bool {
361    matches!(
362        style,
363        ListStyle::Decimal
364            | ListStyle::LowerAlpha
365            | ListStyle::UpperAlpha
366            | ListStyle::LowerRoman
367            | ListStyle::UpperRoman
368    )
369}
370
371// ── HTML helpers ────────────────────────────────────────────────
372
373fn escape_html(s: &str) -> String {
374    let mut out = String::with_capacity(s.len());
375    for c in s.chars() {
376        match c {
377            '&' => out.push_str("&amp;"),
378            '<' => out.push_str("&lt;"),
379            '>' => out.push_str("&gt;"),
380            '"' => out.push_str("&quot;"),
381            '\'' => out.push_str("&#x27;"),
382            // A raw CR in text content is normalised to LF by the HTML5 input
383            // preprocessor on re-import (CR-from-`&#xD;` survives, literal CR
384            // does not), which breaks serialiser idempotency. Emit it as a
385            // numeric reference so it round-trips losslessly.
386            '\r' => out.push_str("&#13;"),
387            _ => out.push(c),
388        }
389    }
390    out
391}
392
393/// Build a CSS `style` attribute value from block-level formatting (ISSUE-19).
394fn block_style_attr(block: &FragmentBlock) -> String {
395    use crate::Alignment;
396
397    let mut parts = Vec::new();
398    if let Some(ref alignment) = block.alignment {
399        let value = match alignment {
400            Alignment::Left => "left",
401            Alignment::Right => "right",
402            Alignment::Center => "center",
403            Alignment::Justify => "justify",
404        };
405        parts.push(format!("text-align: {}", value));
406    }
407    if let Some(n) = block.indent
408        && n > 0
409    {
410        parts.push(format!("margin-left: {}em", n));
411    }
412    if let Some(px) = block.text_indent
413        && px != 0
414    {
415        parts.push(format!("text-indent: {}px", px));
416    }
417    if let Some(px) = block.top_margin {
418        parts.push(format!("margin-top: {}px", px));
419    }
420    if let Some(px) = block.bottom_margin {
421        parts.push(format!("margin-bottom: {}px", px));
422    }
423    if let Some(px) = block.left_margin {
424        parts.push(format!("margin-left: {}px", px));
425    }
426    if let Some(px) = block.right_margin {
427        parts.push(format!("margin-right: {}px", px));
428    }
429    parts.join("; ")
430}
431
432fn push_inline_html(out: &mut String, elements: &[FragmentElement]) {
433    for elem in elements {
434        let text = match &elem.content {
435            InlineContent::Text(t) => escape_html(t),
436            // A reference carried onto the clipboard keeps its marker; the note
437            // body is not part of the fragment.
438            InlineContent::FootnoteRef { label } => {
439                let id = escape_html(label);
440                out.push_str(&format!(
441                    "<a epub:type=\"noteref\" role=\"doc-noteref\" href=\"#fn-{id}\"><sup>{id}</sup></a>"
442                ));
443                continue;
444            }
445            InlineContent::Image {
446                name,
447                alt,
448                width,
449                height,
450                ..
451            } => {
452                // This is the HTML written to the OS clipboard, so it is what
453                // another application receives on paste. It carried no `alt` at
454                // all, which made every copied image inaccessible in the target
455                // document.
456                let mut tag = format!(
457                    "<img src=\"{}\" alt=\"{}\"",
458                    escape_html(name),
459                    escape_html(alt)
460                );
461                if *width > 0 {
462                    tag.push_str(&format!(" width=\"{width}\""));
463                }
464                if *height > 0 {
465                    tag.push_str(&format!(" height=\"{height}\""));
466                }
467                tag.push('>');
468                tag
469            }
470            InlineContent::Empty => String::new(),
471        };
472
473        let is_monospace = elem
474            .fmt_font_family
475            .as_deref()
476            .is_some_and(|f| f == "monospace");
477        let is_bold = elem.fmt_font_bold.unwrap_or(false);
478        let is_italic = elem.fmt_font_italic.unwrap_or(false);
479        let is_underline = elem.fmt_font_underline.unwrap_or(false);
480        let is_strikeout = elem.fmt_font_strikeout.unwrap_or(false);
481        let is_anchor = elem.fmt_is_anchor.unwrap_or(false);
482        // Superscript and subscript are character formatting like the rest, and
483        // an exponent or a chemical index that lands on the baseline is simply
484        // the wrong text — so they belong in the clipboard payload alongside
485        // bold and italic rather than being dropped on the way out.
486        let vertical = elem.fmt_vertical_alignment.as_ref();
487
488        let mut result = text;
489
490        if is_monospace {
491            result = format!("<code>{}</code>", result);
492        }
493        if is_bold {
494            result = format!("<strong>{}</strong>", result);
495        }
496        if is_italic {
497            result = format!("<em>{}</em>", result);
498        }
499        if is_underline {
500            result = format!("<u>{}</u>", result);
501        }
502        if is_strikeout {
503            result = format!("<s>{}</s>", result);
504        }
505        match vertical {
506            Some(CharVerticalAlignment::SuperScript) => result = format!("<sup>{result}</sup>"),
507            Some(CharVerticalAlignment::SubScript) => result = format!("<sub>{result}</sub>"),
508            _ => {}
509        }
510        if is_anchor && let Some(ref href) = elem.fmt_anchor_href {
511            result = format!("<a href=\"{}\">{}</a>", escape_html(href), result);
512        }
513
514        out.push_str(&result);
515    }
516}
517
518/// Emit an HTML `<table>` for a `FragmentTable`.
519fn push_table_html(out: &mut String, table: &FragmentTable) {
520    out.push_str("<table>");
521    for row in 0..table.rows {
522        out.push_str("<tr>");
523        for col in 0..table.columns {
524            if let Some(cell) = table.cells.iter().find(|c| c.row == row && c.column == col) {
525                out.push_str("<td");
526                if cell.row_span > 1 {
527                    out.push_str(&format!(" rowspan=\"{}\"", cell.row_span));
528                }
529                if cell.column_span > 1 {
530                    out.push_str(&format!(" colspan=\"{}\"", cell.column_span));
531                }
532                out.push('>');
533                for (i, block) in cell.blocks.iter().enumerate() {
534                    if i > 0 {
535                        out.push_str("<br>");
536                    }
537                    push_inline_html(out, &block.elements);
538                }
539                out.push_str("</td>");
540            }
541            // Skip positions covered by spans — the HTML renderer handles them.
542        }
543        out.push_str("</tr>");
544    }
545    out.push_str("</table>");
546}
547
548// ── Markdown helpers ────────────────────────────────────────────
549
550fn escape_markdown(s: &str) -> String {
551    let mut out = String::with_capacity(s.len());
552    for c in s.chars() {
553        if matches!(
554            c,
555            '\\' | '`'
556                | '*'
557                | '_'
558                | '{'
559                | '}'
560                | '['
561                | ']'
562                | '('
563                | ')'
564                | '#'
565                | '+'
566                | '-'
567                | '.'
568                | '!'
569                | '|'
570                | '~'
571                | '<'
572                | '>'
573        ) {
574            out.push('\\');
575        }
576        out.push(c);
577    }
578    out
579}
580
581fn render_inline_markdown(elements: &[FragmentElement]) -> String {
582    let mut out = String::new();
583    for elem in elements {
584        let raw_text = match &elem.content {
585            InlineContent::Text(t) => t.clone(),
586            // `name` was used as both alt and source, so a pasted image
587            // described itself with its filename.
588            InlineContent::Image { name, alt, .. } => format!("![{alt}]({name})"),
589            InlineContent::FootnoteRef { label } => format!("[^{label}]"),
590            InlineContent::Empty => String::new(),
591        };
592
593        let is_monospace = elem
594            .fmt_font_family
595            .as_deref()
596            .is_some_and(|f| f == "monospace");
597        let is_bold = elem.fmt_font_bold.unwrap_or(false);
598        let is_italic = elem.fmt_font_italic.unwrap_or(false);
599        let is_strikeout = elem.fmt_font_strikeout.unwrap_or(false);
600        let is_anchor = elem.fmt_is_anchor.unwrap_or(false);
601
602        if is_monospace {
603            out.push('`');
604            out.push_str(&raw_text);
605            out.push('`');
606        } else {
607            let mut text = escape_markdown(&raw_text);
608            if is_bold && is_italic {
609                text = format!("***{}***", text);
610            } else if is_bold {
611                text = format!("**{}**", text);
612            } else if is_italic {
613                text = format!("*{}*", text);
614            }
615            if is_strikeout {
616                text = format!("~~{}~~", text);
617            }
618            if is_anchor {
619                let href = elem.fmt_anchor_href.as_deref().unwrap_or("");
620                out.push_str(&format!("[{}]({})", text, href));
621            } else {
622                out.push_str(&text);
623            }
624        }
625    }
626    out
627}
628
629/// Render a `FragmentTable` as a pipe-delimited Markdown table.
630fn render_table_markdown(table: &FragmentTable) -> String {
631    let mut rows: Vec<Vec<String>> = vec![vec![String::new(); table.columns]; table.rows];
632
633    for cell in &table.cells {
634        let text: String = cell
635            .blocks
636            .iter()
637            .map(|b| render_inline_markdown(&b.elements))
638            .collect::<Vec<_>>()
639            .join(" ");
640        if cell.row < table.rows && cell.column < table.columns {
641            rows[cell.row][cell.column] = text;
642        }
643    }
644
645    let mut out = String::new();
646    for (i, row) in rows.iter().enumerate() {
647        out.push_str("| ");
648        out.push_str(&row.join(" | "));
649        out.push_str(" |");
650        if i == 0 {
651            // Header separator
652            out.push('\n');
653            out.push('|');
654            for _ in 0..table.columns {
655                out.push_str(" --- |");
656            }
657        }
658        if i + 1 < rows.len() {
659            out.push('\n');
660        }
661    }
662    out
663}
664
665// ── Fragment construction from parsed content ───────────────────
666
667/// Convert parsed blocks (from HTML or Markdown parser) into a `DocumentFragment`.
668/// Convert a `ParsedSpan` to a `FragmentElement`.
669/// The plain text of a run of parsed spans, with an image standing as its
670/// `U+FFFC` sentinel.
671///
672/// That sentinel is the convention the *extract* side already writes (see
673/// `extract_fragment_uc`), and it is what `insert_fragment_uc` positions each
674/// `ImageAnchor` against — an image with no character in the text has nothing
675/// to anchor to, and every format run after it lands three bytes early.
676fn spans_plain_text(spans: &[ParsedSpan]) -> String {
677    spans
678        .iter()
679        .map(|s| {
680            // Every inline object contributes its sentinel: the anchors this
681            // fragment carries are positioned against *this* string, so an
682            // object missing from it puts every anchor after it out by three
683            // bytes.
684            if s.image.is_some() || s.footnote_ref.is_some() {
685                "\u{FFFC}"
686            } else {
687                s.text.as_str()
688            }
689        })
690        .collect()
691}
692
693fn span_to_fragment_element(span: &ParsedSpan) -> FragmentElement {
694    // An image span carries no text — the picture *is* the content. Emitting
695    // `Text("")` for it, which is what this did, produced a fragment with an
696    // empty element and no image anywhere: `insert_djot` of an image markup
697    // returned `Ok` and inserted nothing at all.
698    //
699    // A footnote reference is the same shape and the same trap: its span carries
700    // no text either, so the identical `Text("")` would have made
701    // `insert_footnote_reference` a no-op that reported success.
702    let content = match (&span.footnote_ref, &span.image) {
703        (Some(label), _) => InlineContent::FootnoteRef {
704            label: label.clone(),
705        },
706        (None, Some(img)) => InlineContent::Image {
707            name: img.src.clone(),
708            alt: img.alt.clone(),
709            width: img.width,
710            height: img.height,
711            // The source states a display size or it does not; nothing here
712            // re-encodes the image, so there is no quality to carry.
713            quality: 100,
714        },
715        (None, None) => InlineContent::Text(span.text.clone()),
716    };
717    let fmt_font_family = if span.code {
718        Some("monospace".into())
719    } else {
720        None
721    };
722    let fmt_font_bold = if span.bold { Some(true) } else { None };
723    let fmt_font_italic = if span.italic { Some(true) } else { None };
724    let fmt_font_underline = if span.underline { Some(true) } else { None };
725    let fmt_font_strikeout = if span.strikeout { Some(true) } else { None };
726    let (fmt_anchor_href, fmt_is_anchor) = if let Some(ref href) = span.link_href {
727        (Some(href.clone()), Some(true))
728    } else {
729        (None, None)
730    };
731    // Raised and lowered text is character formatting like bold and italic, and
732    // dropping it here is what silently flattened an exponent or a chemical
733    // index on the way in: the parsers set the flags, `character_format_from_span`
734    // maps them, and only this conversion — the one every `insert_html` /
735    // `insert_markdown` / `insert_djot` goes through — threw them away.
736    let fmt_vertical_alignment = if span.superscript {
737        Some(CharVerticalAlignment::SuperScript)
738    } else if span.subscript {
739        Some(CharVerticalAlignment::SubScript)
740    } else {
741        None
742    };
743
744    FragmentElement {
745        content,
746        fmt_font_family,
747        fmt_font_point_size: None,
748        fmt_font_weight: None,
749        fmt_font_bold,
750        fmt_font_italic,
751        fmt_font_underline,
752        fmt_font_overline: None,
753        fmt_font_strikeout,
754        fmt_letter_spacing: None,
755        fmt_word_spacing: None,
756        fmt_anchor_href,
757        fmt_anchor_names: vec![],
758        fmt_is_anchor,
759        fmt_tooltip: None,
760        fmt_underline_style: None,
761        fmt_vertical_alignment,
762    }
763}
764
765/// Convert parsed elements (blocks + tables) into a `DocumentFragment`,
766/// preserving table structure as `FragmentTable` entries.
767fn parsed_elements_to_fragment(parsed: Vec<ParsedElement>) -> DocumentFragment {
768    use frontend::common::parser_tools::fragment_schema::FragmentList;
769
770    let mut blocks: Vec<FragmentBlock> = Vec::new();
771    let mut tables: Vec<FragmentTable> = Vec::new();
772
773    for elem in parsed {
774        match elem {
775            // A clipboard fragment carries prose, not note bodies: a definition
776            // has no position in the flow being copied, and pasting one would
777            // splice a note's text into the middle of a sentence. The reference
778            // travels; the body stays where it is defined.
779            ParsedElement::FootnoteDefinition { .. } => {}
780            ParsedElement::Block(pb) => {
781                let elements: Vec<FragmentElement> =
782                    pb.spans.iter().map(span_to_fragment_element).collect();
783                let plain_text: String = spans_plain_text(&pb.spans);
784                let list = pb.list_style.map(|style| FragmentList {
785                    style,
786                    indent: pb.list_indent as i64,
787                    prefix: String::new(),
788                    suffix: String::new(),
789                });
790
791                blocks.push(FragmentBlock {
792                    plain_text,
793                    elements,
794                    heading_level: pb.heading_level,
795                    list,
796                    alignment: None,
797                    indent: None,
798                    text_indent: None,
799                    marker: None,
800                    top_margin: None,
801                    bottom_margin: None,
802                    left_margin: None,
803                    right_margin: None,
804                    tab_positions: vec![],
805                    line_height: pb.line_height,
806                    non_breakable_lines: pb.non_breakable_lines,
807                    page_break_before: pb.page_break_before,
808                    direction: pb.direction,
809                    background_color: pb.background_color,
810                    is_code_block: None,
811                    code_language: None,
812                    hyphenate: None,
813                    language: None,
814                });
815            }
816            ParsedElement::Table(pt) => {
817                let block_insert_index = blocks.len();
818                let num_columns = pt.rows.iter().map(|r| r.len()).max().unwrap_or(0);
819                let num_rows = pt.rows.len();
820
821                let mut frag_cells: Vec<FragmentTableCell> = Vec::new();
822                for (row_idx, row) in pt.rows.iter().enumerate() {
823                    for (col_idx, cell) in row.iter().enumerate() {
824                        let cell_elements: Vec<FragmentElement> =
825                            cell.spans.iter().map(span_to_fragment_element).collect();
826                        let cell_text: String = spans_plain_text(&cell.spans);
827
828                        frag_cells.push(FragmentTableCell {
829                            row: row_idx,
830                            column: col_idx,
831                            row_span: 1,
832                            column_span: 1,
833                            blocks: vec![FragmentBlock {
834                                plain_text: cell_text,
835                                elements: cell_elements,
836                                heading_level: None,
837                                list: None,
838                                alignment: None,
839                                indent: None,
840                                text_indent: None,
841                                marker: None,
842                                top_margin: None,
843                                bottom_margin: None,
844                                left_margin: None,
845                                right_margin: None,
846                                tab_positions: vec![],
847                                line_height: None,
848                                non_breakable_lines: None,
849                                page_break_before: None,
850                                direction: None,
851                                background_color: None,
852                                is_code_block: None,
853                                code_language: None,
854                                hyphenate: None,
855                                language: None,
856                            }],
857                            fmt_padding: None,
858                            fmt_border: None,
859                            fmt_vertical_alignment: None,
860                            fmt_background_color: None,
861                        });
862                    }
863                }
864
865                tables.push(FragmentTable {
866                    rows: num_rows,
867                    columns: num_columns,
868                    cells: frag_cells,
869                    block_insert_index,
870                    fmt_border: None,
871                    fmt_cell_spacing: None,
872                    fmt_cell_padding: None,
873                    fmt_width: None,
874                    fmt_alignment: None,
875                    column_widths: vec![],
876                });
877            }
878        }
879    }
880
881    let data = serde_json::to_string(&FragmentData { blocks, tables })
882        .expect("fragment serialization should not fail");
883
884    let plain_text = parsed_plain_text_from_data(&data);
885
886    DocumentFragment { data, plain_text }
887}
888
889/// Extract plain text from serialized fragment data.
890fn parsed_plain_text_from_data(data: &str) -> String {
891    let fragment_data: FragmentData = match serde_json::from_str(data) {
892        Ok(d) => d,
893        Err(_) => return String::new(),
894    };
895
896    fragment_data
897        .blocks
898        .iter()
899        .map(|b| b.plain_text.as_str())
900        .collect::<Vec<_>>()
901        .join("\n")
902}