1302 lines
55 KiB
Rust
1302 lines
55 KiB
Rust
use anyhow::{Context, Result};
|
|
use html5ever::serialize;
|
|
use html5ever::{
|
|
QualName,
|
|
serialize::{Serialize, Serializer, TraversalScope},
|
|
};
|
|
use itertools::Itertools;
|
|
use markdown_parser::{
|
|
CodeBlockText, FormattedIndentTextInline, FormattedTableAlignment, FormattedTaskList,
|
|
FormattedText, FormattedTextFragment, FormattedTextHeader, FormattedTextInline,
|
|
FormattedTextLine, OrderedFormattedIndentTextInline,
|
|
};
|
|
use markup5ever::ns;
|
|
use std::collections::VecDeque;
|
|
use std::fmt::Write;
|
|
use std::iter;
|
|
use std::{io, ops::Range};
|
|
use galaxyui::text::point::Point;
|
|
use galaxyui::{AppContext, ModelContext, ModelHandle};
|
|
|
|
use string_offset::CharOffset;
|
|
use galaxyui::elements::{ListIndentLevel, ListNumbering};
|
|
|
|
use crate::content::anchor::AnchorSide;
|
|
use crate::content::selection_model::BufferSelectionModel;
|
|
use crate::content::version::BufferVersion;
|
|
|
|
use super::buffer::{
|
|
ActionWithSelectionDelta, EditOrigin, EditResult, StyledBufferBlocks, StyledBufferRun,
|
|
};
|
|
use super::core::{CoreEditorAction, CoreEditorActionType};
|
|
use super::text::{
|
|
BlockHeaderSize, BlockType, BufferBlockItem, BufferBlockStyle, FormattedTable,
|
|
TABLE_BLOCK_MARKDOWN_LANG,
|
|
};
|
|
use super::{
|
|
buffer::{Buffer, StyledBufferBlock},
|
|
text::TextStylesWithMetadata,
|
|
};
|
|
|
|
/// A Markdown format to serialize a [`Buffer`] into.
|
|
#[derive(Clone, Copy)]
|
|
pub enum MarkdownStyle<'a> {
|
|
/// The internal Markdown format used in Warp Drive. References are normalized, so the Markdown
|
|
/// only refers to other objects by their IDs, with no other data.
|
|
Internal,
|
|
/// A Markdown format suitable for external use. If an [`AppContext`] is set, it may be used to
|
|
/// enrich the exported Markdown (e.g. by expanding out embedded objects).
|
|
Export {
|
|
app_context: Option<&'a AppContext>,
|
|
should_not_escape_markdown_punctuation: bool,
|
|
},
|
|
}
|
|
|
|
/// Parser that takes a slice of content model with StyledBufferBlocks and returns
|
|
/// the formatted markdown in string.
|
|
pub struct BufferMarkdownParser<'a> {
|
|
blocks: StyledBufferBlocks<'a>,
|
|
style: MarkdownStyle<'a>,
|
|
}
|
|
|
|
impl<'a> BufferMarkdownParser<'a> {
|
|
pub fn new(style: MarkdownStyle<'a>, blocks: StyledBufferBlocks<'a>) -> Self {
|
|
Self { blocks, style }
|
|
}
|
|
|
|
// Consumes the iterator and returns the corresponding markdown string.
|
|
pub fn to_markdown(&mut self) -> String {
|
|
let mut res = String::new();
|
|
let mut ordered_list_numbering = ListNumbering::new();
|
|
|
|
for block in self.blocks.by_ref() {
|
|
match block {
|
|
StyledBufferBlock::Item(item) => {
|
|
res.push_str(&item.as_markdown(self.style));
|
|
res.push('\n');
|
|
}
|
|
StyledBufferBlock::Text(text_block) => {
|
|
if let BufferBlockStyle::Table { alignments, .. } = &text_block.style
|
|
&& matches!(self.style, MarkdownStyle::Export { .. })
|
|
{
|
|
let markdown_text = Self::styled_runs_to_markdown_text(&text_block.block);
|
|
let table = super::text::table_from_internal_format_with_inline_markdown(
|
|
&markdown_text,
|
|
alignments.clone(),
|
|
);
|
|
Self::serialize_table_to_gfm_markdown(&table, &mut res);
|
|
continue;
|
|
}
|
|
|
|
// Push the prefix for the active block.
|
|
match text_block.style.clone() {
|
|
BufferBlockStyle::Header { header_size } => {
|
|
res.push_str(&"#".repeat(header_size.into()));
|
|
res.push(' ');
|
|
}
|
|
BufferBlockStyle::UnorderedList { indent_level } => {
|
|
res.push_str(" ".repeat(indent_level.as_usize()).as_str());
|
|
res.push_str("* ")
|
|
}
|
|
BufferBlockStyle::OrderedList {
|
|
indent_level,
|
|
number,
|
|
} => {
|
|
let indent = indent_level.as_usize();
|
|
res.push_str(" ".repeat(indent).as_str());
|
|
|
|
let _ = write!(
|
|
&mut res,
|
|
"{}. ",
|
|
ordered_list_numbering.advance(indent, number).label_index
|
|
);
|
|
}
|
|
BufferBlockStyle::CodeBlock { code_block_type } => res.push_str(&format!(
|
|
"```{}\n",
|
|
&code_block_type.to_markdown_representation(self.style)
|
|
)),
|
|
BufferBlockStyle::TaskList {
|
|
indent_level,
|
|
complete,
|
|
} => {
|
|
res.push_str(" ".repeat(indent_level.as_usize()).as_str());
|
|
if complete {
|
|
res.push_str("- [x] ");
|
|
} else {
|
|
res.push_str("- [ ] ");
|
|
}
|
|
}
|
|
BufferBlockStyle::Table { .. } => {
|
|
res.push_str("```");
|
|
res.push_str(TABLE_BLOCK_MARKDOWN_LANG);
|
|
res.push('\n');
|
|
}
|
|
BufferBlockStyle::PlainText => (),
|
|
};
|
|
|
|
if !matches!(text_block.style, BufferBlockStyle::OrderedList { .. }) {
|
|
ordered_list_numbering.reset();
|
|
}
|
|
|
|
let block_escapes = !matches!(
|
|
self.style,
|
|
MarkdownStyle::Export {
|
|
should_not_escape_markdown_punctuation: true,
|
|
..
|
|
}
|
|
) && text_block.style.escape_markdown_punctuation();
|
|
Self::append_block_content(&text_block.block, block_escapes, &mut res);
|
|
|
|
// Push the suffix for the active block.
|
|
match text_block.style {
|
|
BufferBlockStyle::CodeBlock { .. } | BufferBlockStyle::Table { .. } => {
|
|
res.push_str("```\n")
|
|
}
|
|
BufferBlockStyle::Header { .. }
|
|
| BufferBlockStyle::PlainText
|
|
| BufferBlockStyle::UnorderedList { .. }
|
|
| BufferBlockStyle::OrderedList { .. }
|
|
| BufferBlockStyle::TaskList { .. } => (),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
res
|
|
}
|
|
|
|
fn serialize_table_to_gfm_markdown(table: &FormattedTable, buf: &mut String) {
|
|
let mut column_count = table.headers.len();
|
|
for row in &table.rows {
|
|
column_count = column_count.max(row.len());
|
|
}
|
|
|
|
if column_count == 0 {
|
|
return;
|
|
}
|
|
|
|
let header_cells = (0..column_count)
|
|
.map(|index| {
|
|
table
|
|
.headers
|
|
.get(index)
|
|
.map(inline_to_markdown)
|
|
.unwrap_or_default()
|
|
})
|
|
.collect::<Vec<_>>();
|
|
append_gfm_table_row(&header_cells, buf);
|
|
|
|
let separator_cells: Vec<String> = (0..column_count)
|
|
.map(|index| {
|
|
alignment_to_gfm_separator(
|
|
table
|
|
.alignments
|
|
.get(index)
|
|
.copied()
|
|
.unwrap_or(FormattedTableAlignment::Left),
|
|
)
|
|
})
|
|
.collect();
|
|
append_gfm_table_row(&separator_cells, buf);
|
|
|
|
for row in &table.rows {
|
|
let row_cells = (0..column_count)
|
|
.map(|index| row.get(index).map(inline_to_markdown).unwrap_or_default())
|
|
.collect::<Vec<_>>();
|
|
append_gfm_table_row(&row_cells, buf);
|
|
}
|
|
}
|
|
|
|
/// Emits Markdown formatting markers for changing from `prev_styles` to `next_styles`.
|
|
///
|
|
/// This will omit formatting for unchanged styles, which is required to produce valid Markdown
|
|
/// (for example, according to [this](https://spec.commonmark.org/0.30/#example-411),
|
|
/// `*abc**def*`, parses to `<i_s>abc**def<i_e>`, not `<i_s>abcdef<i_e>`).
|
|
///
|
|
/// If the text that `next_styles` applies to (`next_content`) starts with whitespace,
|
|
/// the formatting markers might be reordered after the first character to comply
|
|
/// with [these rules](https://spec.commonmark.org/0.30/#left-flanking-delimiter-run).
|
|
/// Likewise, if the last character of `buf` is whitespace, the formatting markers may be
|
|
/// reordered before it.
|
|
///
|
|
/// This alters the user content slightly, but will parse back to what they expect, rather than
|
|
/// something un-representable. In this case, the returned `&str` is the shifted
|
|
/// `next_content`.
|
|
fn append_formatting<'b>(
|
|
prev_styles: &TextStylesWithMetadata,
|
|
next_styles: &TextStylesWithMetadata,
|
|
mut next_content: &'b str,
|
|
buf: &mut String,
|
|
) -> &'b str {
|
|
// Because of how links start a new formatting scope, we have to change styles in a particular
|
|
// order, from the inside out:
|
|
// 1. End inline code (since other style markers aren't interpreted within code spans)
|
|
// 2. End other inline styles
|
|
// 3. End and then start links
|
|
// 4. Start inline styles
|
|
// 5. Start inline code
|
|
|
|
if prev_styles.is_inline_code() && !next_styles.is_inline_code() {
|
|
buf.push('`')
|
|
}
|
|
|
|
let end_bold = prev_styles.is_at_least_bold() && !next_styles.is_at_least_bold();
|
|
let end_italic = prev_styles.is_italic() && !next_styles.is_italic();
|
|
let end_strikethrough = prev_styles.is_strikethrough() && !next_styles.is_strikethrough();
|
|
let end_underline = prev_styles.is_underlined() && !next_styles.is_underlined();
|
|
|
|
// This, and the parallel logic below, is an unfortunate workaround for the CommonMark
|
|
// rules about space before/after bold and italic formatting markers
|
|
// (https://spec.commonmark.org/0.30/#left-flanking-delimiter-run). Like Notion, we
|
|
// rearrange the user content slightly to match the spec, in order to be compatible with
|
|
// GitHub and other parsers.
|
|
// In this case, if the current buffer ends in whitespace, we remove it, write the
|
|
// end formatting markers, and then re-add it.
|
|
let swapped_content = buf
|
|
.char_indices()
|
|
.rev()
|
|
.take_while(|(_, c)| c.is_whitespace())
|
|
.last()
|
|
// Panic safety: iterating over `char_indices` means that `idx` must be in-bounds and a
|
|
// code point boundary.
|
|
.map(|(idx, _)| buf.split_off(idx));
|
|
|
|
if end_underline {
|
|
buf.push_str("</u>")
|
|
}
|
|
|
|
if end_strikethrough {
|
|
buf.push_str("~~");
|
|
}
|
|
|
|
if end_bold {
|
|
buf.push_str("**");
|
|
}
|
|
|
|
if end_italic {
|
|
buf.push('*');
|
|
}
|
|
|
|
buf.extend(swapped_content);
|
|
|
|
let prev_link = prev_styles.link_content();
|
|
let next_link = next_styles.link_content();
|
|
|
|
// Open/close links if:
|
|
// * A link is beginning or ending.
|
|
// * There are two adjacent, different, links.
|
|
if prev_link != next_link {
|
|
if let Some(closing_url) = prev_link {
|
|
// The `String` implementation of `fmt::Write` never returns `Err`.
|
|
let _ = write!(buf, "]({closing_url})");
|
|
}
|
|
|
|
if next_link.is_some() {
|
|
buf.push('[');
|
|
}
|
|
}
|
|
|
|
let start_bold = !prev_styles.is_at_least_bold() && next_styles.is_at_least_bold();
|
|
let start_italic = !prev_styles.is_italic() && next_styles.is_italic();
|
|
let start_strikethrough = !prev_styles.is_strikethrough() && next_styles.is_strikethrough();
|
|
let start_underline = !prev_styles.is_underlined() && next_styles.is_underlined();
|
|
|
|
// In order to parse correctly, Markdown emphasis markers must be after whitespace. If the
|
|
// next run of content starts with whitespace, we write that and then the markers, and then
|
|
// let the outer loop write the rest of the content.
|
|
if (start_bold || start_italic || start_strikethrough || start_underline)
|
|
&& let Some(idx) = next_content.find(|c: char| !c.is_whitespace())
|
|
{
|
|
// Panic safety: if the pattern matches, `find` returns the byte index of the first
|
|
// matching character, so it will be a code point boundary.
|
|
let (whitespace, rest) = next_content.split_at(idx);
|
|
buf.push_str(whitespace);
|
|
next_content = rest;
|
|
}
|
|
|
|
if start_bold {
|
|
buf.push_str("**");
|
|
}
|
|
|
|
if start_italic {
|
|
buf.push('*');
|
|
}
|
|
|
|
if start_strikethrough {
|
|
buf.push_str("~~");
|
|
}
|
|
|
|
if start_underline {
|
|
buf.push_str("<u>");
|
|
}
|
|
|
|
if !prev_styles.is_inline_code() && next_styles.is_inline_code() {
|
|
buf.push('`');
|
|
}
|
|
|
|
next_content
|
|
}
|
|
|
|
/// Appends text content to a Markdown buffer, escaping any special characters as needed.
|
|
fn append_content(text: &str, should_escape: bool, buf: &mut String) {
|
|
if should_escape {
|
|
buf.reserve(text.len());
|
|
for ch in text.chars() {
|
|
// Only escape punctuation characters that have special meaning in Markdown
|
|
if Self::is_markdown_special_char(ch) {
|
|
buf.push('\\');
|
|
}
|
|
buf.push(ch);
|
|
}
|
|
} else {
|
|
buf.push_str(text);
|
|
}
|
|
}
|
|
|
|
fn styled_runs_to_markdown_text(runs: &[StyledBufferRun]) -> String {
|
|
let mut res = String::new();
|
|
Self::append_block_content(runs, false, &mut res);
|
|
res
|
|
}
|
|
|
|
fn append_block_content(runs: &[StyledBufferRun], block_escapes: bool, buf: &mut String) {
|
|
let mut prev_styles = TextStylesWithMetadata::default();
|
|
for run in runs {
|
|
if run.run.is_empty() || run.text_styles.is_placeholder() {
|
|
continue;
|
|
}
|
|
let (content, has_trailing_newline) = match run.run.strip_suffix('\n') {
|
|
Some(without_newline) => (without_newline, true),
|
|
_ => (run.run.as_str(), false),
|
|
};
|
|
let content = Self::append_formatting(&prev_styles, &run.text_styles, content, buf);
|
|
prev_styles.clone_from(&run.text_styles);
|
|
let should_escape = block_escapes && run.text_styles.escape_markdown_punctuation();
|
|
Self::append_content(content, should_escape, buf);
|
|
if has_trailing_newline {
|
|
Self::append_formatting(
|
|
&run.text_styles,
|
|
&TextStylesWithMetadata::default(),
|
|
"",
|
|
buf,
|
|
);
|
|
prev_styles = TextStylesWithMetadata::default();
|
|
buf.push('\n');
|
|
}
|
|
}
|
|
Self::append_formatting(&prev_styles, &TextStylesWithMetadata::default(), "", buf);
|
|
}
|
|
|
|
/// Returns true if the character has special meaning in Markdown and should be escaped.
|
|
fn is_markdown_special_char(ch: char) -> bool {
|
|
matches!(
|
|
ch,
|
|
'\\' | // Backslash - used for escaping other characters
|
|
'`' | // Backtick - used for inline code
|
|
'*' | // Asterisk - used for emphasis and unordered lists
|
|
'_' | // Underscore - used for emphasis
|
|
'{' | // Left curly brace - used in some Markdown extensions
|
|
'}' | // Right curly brace - used in some Markdown extensions
|
|
'[' | // Left square bracket - used for links and images
|
|
']' | // Right square bracket - used for links and images
|
|
'(' | // Left parenthesis - used for links and images
|
|
')' | // Right parenthesis - used for links and images
|
|
'#' | // Hash - used for headings
|
|
'+' | // Plus - used for unordered lists
|
|
'-' | // Minus - used for unordered lists and horizontal rules
|
|
'.' | // Period - used for ordered lists
|
|
'!' // Exclamation mark - used for images
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Convert a slice of buffer model in StyledBufferBlocks into FormattedText.
|
|
pub struct BufferToFormattedText<'a> {
|
|
blocks: StyledBufferBlocks<'a>,
|
|
}
|
|
|
|
impl<'a> BufferToFormattedText<'a> {
|
|
pub fn new(blocks: StyledBufferBlocks<'a>) -> Self {
|
|
Self { blocks }
|
|
}
|
|
|
|
pub fn to_formatted_text(self) -> FormattedText {
|
|
let mut lines = VecDeque::new();
|
|
let mut trailing_new_line = false;
|
|
for item in self.blocks {
|
|
match item {
|
|
StyledBufferBlock::Item(item) => {
|
|
trailing_new_line = false;
|
|
lines.push_back(item.to_formatted_text_line());
|
|
}
|
|
StyledBufferBlock::Text(text_block) => {
|
|
lines.push_back(match text_block.style.clone() {
|
|
BufferBlockStyle::Header { header_size } => {
|
|
trailing_new_line = false;
|
|
FormattedTextLine::Heading(FormattedTextHeader {
|
|
heading_size: header_size.into(),
|
|
text: text_block
|
|
.block
|
|
.into_iter()
|
|
.map(|run| run.to_formatted_text_fragment())
|
|
.collect(),
|
|
})
|
|
}
|
|
BufferBlockStyle::TaskList {
|
|
indent_level,
|
|
complete,
|
|
} => {
|
|
trailing_new_line = false;
|
|
FormattedTextLine::TaskList(FormattedTaskList {
|
|
complete,
|
|
indent_level: indent_level.as_usize(),
|
|
text: text_block
|
|
.block
|
|
.into_iter()
|
|
.map(|run| run.to_formatted_text_fragment())
|
|
.collect(),
|
|
})
|
|
}
|
|
BufferBlockStyle::UnorderedList { indent_level } => {
|
|
trailing_new_line = false;
|
|
FormattedTextLine::UnorderedList(FormattedIndentTextInline {
|
|
indent_level: indent_level.as_usize(),
|
|
text: text_block
|
|
.block
|
|
.into_iter()
|
|
.map(|run| run.to_formatted_text_fragment())
|
|
.collect(),
|
|
})
|
|
}
|
|
BufferBlockStyle::OrderedList {
|
|
indent_level,
|
|
number,
|
|
} => {
|
|
trailing_new_line = false;
|
|
FormattedTextLine::OrderedList(OrderedFormattedIndentTextInline {
|
|
number,
|
|
indented_text: FormattedIndentTextInline {
|
|
indent_level: indent_level.as_usize(),
|
|
text: text_block
|
|
.block
|
|
.into_iter()
|
|
.map(|run| run.to_formatted_text_fragment())
|
|
.collect(),
|
|
},
|
|
})
|
|
}
|
|
BufferBlockStyle::CodeBlock { code_block_type } => {
|
|
trailing_new_line = false;
|
|
let text = text_block.block.into_iter().map(|run| run.run).collect();
|
|
FormattedTextLine::CodeBlock(CodeBlockText {
|
|
lang: code_block_type.to_string(),
|
|
code: text,
|
|
})
|
|
}
|
|
BufferBlockStyle::Table { alignments, .. } => {
|
|
trailing_new_line = false;
|
|
let text: String =
|
|
text_block.block.into_iter().map(|run| run.run).collect();
|
|
FormattedTextLine::Table(
|
|
markdown_parser::FormattedTable::from_internal_format_with_alignments(&text, alignments),
|
|
)
|
|
}
|
|
BufferBlockStyle::PlainText => {
|
|
trailing_new_line = false;
|
|
FormattedTextLine::Line(
|
|
text_block
|
|
.block
|
|
.into_iter()
|
|
.map(|run| {
|
|
trailing_new_line = run.run.ends_with('\n');
|
|
run.to_formatted_text_fragment()
|
|
})
|
|
.collect(),
|
|
)
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Make sure trailing newline gets its own linebreak.
|
|
if trailing_new_line {
|
|
lines.push_back(FormattedTextLine::LineBreak);
|
|
}
|
|
|
|
FormattedText { lines }
|
|
}
|
|
}
|
|
|
|
impl StyledBufferRun {
|
|
fn to_formatted_text_fragment(&self) -> FormattedTextFragment {
|
|
let text = match self.run.strip_suffix('\n') {
|
|
Some(without_newline) => without_newline.to_string(),
|
|
_ => self.run.clone(),
|
|
};
|
|
|
|
FormattedTextFragment {
|
|
text,
|
|
styles: self.text_styles.clone().into(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl BufferBlockStyle {
|
|
/// Whether or not punctuation that may be misinterpreted as Markdown formatting should be
|
|
/// escaped. This is true for _most_ block types, but some do not allow escapes.
|
|
fn escape_markdown_punctuation(&self) -> bool {
|
|
!matches!(
|
|
self,
|
|
BufferBlockStyle::CodeBlock { .. } | BufferBlockStyle::Table { .. }
|
|
)
|
|
}
|
|
}
|
|
|
|
impl TextStylesWithMetadata {
|
|
fn escape_markdown_punctuation(&self) -> bool {
|
|
!self.is_inline_code()
|
|
}
|
|
}
|
|
|
|
use crate::content::buffer::{BufferEvent, ShouldAutoscroll, ToBufferCharOffset};
|
|
use markdown_parser::FormattedTextDelta;
|
|
|
|
impl Buffer {
|
|
pub(super) fn replace_with_formatted_text(
|
|
&mut self,
|
|
range: Range<CharOffset>,
|
|
text: FormattedText,
|
|
origin: EditOrigin,
|
|
selection_model: ModelHandle<BufferSelectionModel>,
|
|
ctx: &mut ModelContext<Self>,
|
|
) -> EditResult {
|
|
selection_model.update(ctx, |selection_model, _| {
|
|
selection_model.truncate();
|
|
});
|
|
let edit = self.edits_for_formatted_text(range, text, origin);
|
|
self.modify_first_selection(selection_model, edit, ctx)
|
|
}
|
|
|
|
pub(super) fn insert_formatted_text_at_selections(
|
|
&mut self,
|
|
text: FormattedText,
|
|
origin: EditOrigin,
|
|
selection_model: ModelHandle<BufferSelectionModel>,
|
|
ctx: &mut ModelContext<Self>,
|
|
) -> EditResult {
|
|
self.modify_each_selection(
|
|
selection_model,
|
|
|buffer, selection, selection_model, _index| {
|
|
let range = selection_model.selection_to_offset_range(selection);
|
|
let text = text.clone();
|
|
buffer.edits_for_formatted_text(range, text, origin)
|
|
},
|
|
ctx,
|
|
)
|
|
}
|
|
|
|
pub(super) fn edits_for_formatted_text(
|
|
&mut self,
|
|
range: Range<CharOffset>,
|
|
text: FormattedText,
|
|
origin: EditOrigin,
|
|
) -> ActionWithSelectionDelta {
|
|
let mut editor_action_set = vec![];
|
|
// When replacing text in-place for a subset of the document, we may
|
|
// need to adjust the block style based on the first line of the
|
|
// replacement. However, for full-buffer replacements (the entire
|
|
// document body), we rely on the inserted formatted text alone and do
|
|
// not emit a separate StyleBlock action, to avoid introducing extra
|
|
// empty headings or markers.
|
|
let is_full_buffer_replacement =
|
|
range.start == CharOffset::from(1) && range.end == self.max_charoffset();
|
|
|
|
if range.start != CharOffset::zero()
|
|
&& !is_full_buffer_replacement
|
|
&& range.start == self.containing_line_start(range.start)
|
|
&& range.end == self.containing_line_end(range.end) - 1
|
|
{
|
|
let new_block_style = text
|
|
.lines
|
|
.front()
|
|
.and_then(formatted_text_line_to_block_style);
|
|
|
|
let active_block_type = self.block_type_at_point(range.start);
|
|
|
|
if let BlockType::Text(active_block_style) = active_block_type {
|
|
match new_block_style {
|
|
Some(block_style)
|
|
if block_style != active_block_style
|
|
&& active_block_style == BufferBlockStyle::PlainText =>
|
|
{
|
|
editor_action_set.push(CoreEditorAction::new(
|
|
range.clone(),
|
|
CoreEditorActionType::StyleBlock(block_style),
|
|
));
|
|
}
|
|
_ => (),
|
|
}
|
|
}
|
|
}
|
|
|
|
editor_action_set.push(CoreEditorAction::new(
|
|
range.clone(),
|
|
CoreEditorActionType::Insert {
|
|
text,
|
|
source: origin,
|
|
override_next_style: false,
|
|
insert_on_selection: true,
|
|
},
|
|
));
|
|
editor_action_set.push(self.update_buffer_end(range.end));
|
|
ActionWithSelectionDelta::new_with_offsets(
|
|
editor_action_set,
|
|
&mut self.internal_anchors,
|
|
range.end,
|
|
range.end,
|
|
0,
|
|
AnchorSide::Right,
|
|
)
|
|
}
|
|
|
|
/// Compute the character range corresponding to the suffix of the
|
|
/// document that should be replaced based on the given formatted-text
|
|
/// delta.
|
|
///
|
|
/// The Point is at the start of the line after the common prefix lines.
|
|
fn formatted_text_suffix_range(&self, delta: &FormattedTextDelta) -> Range<CharOffset> {
|
|
let row = delta.common_prefix_lines + 1;
|
|
let mut suffix_start = Point::new(row as u32, 0).to_buffer_char_offset(self);
|
|
|
|
// By default the buffer has an empty plain text. If we don't replace the initial marker and we're inserting
|
|
// a different formatted text line at the beginning, we will end up with an incorrect initial line.
|
|
// If we're replacing the whole content, include the zeroth offset so we set the initial block styling correctly.
|
|
if suffix_start == CharOffset::from(1) {
|
|
suffix_start = CharOffset::zero();
|
|
}
|
|
suffix_start = suffix_start.clamp(CharOffset::zero(), self.max_charoffset());
|
|
|
|
suffix_start..self.max_charoffset()
|
|
}
|
|
|
|
pub fn apply_formatted_text_delta(
|
|
&mut self,
|
|
delta: &FormattedTextDelta,
|
|
selection_model: ModelHandle<BufferSelectionModel>,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
if delta.is_noop() {
|
|
return;
|
|
}
|
|
|
|
let suffix_range = self.formatted_text_suffix_range(delta);
|
|
let selection_model_id = selection_model.id();
|
|
|
|
// Build the new suffix as formatted text. This will replace the old
|
|
// suffix range and effectively delete the old suffix while inserting
|
|
// the new suffix, leaving the prefix untouched.
|
|
let new_suffix_text = FormattedText::new(delta.new_suffix.clone());
|
|
let edit_result = self.replace_with_formatted_text(
|
|
suffix_range,
|
|
new_suffix_text,
|
|
// We need user initiated here as it impacts the editing behavior. Specifically,
|
|
// we want to insert inline and not trigger the block specific linebreak suffix.
|
|
EditOrigin::UserInitiated,
|
|
selection_model,
|
|
ctx,
|
|
);
|
|
|
|
self.version = BufferVersion::new();
|
|
|
|
let Some(delta) = edit_result.delta else {
|
|
log::debug!("Editor action was no-op");
|
|
return;
|
|
};
|
|
ctx.emit(BufferEvent::ContentChanged {
|
|
delta,
|
|
origin: EditOrigin::SystemEdit,
|
|
should_autoscroll: ShouldAutoscroll::No,
|
|
buffer_version: self.version,
|
|
selection_model_id: Some(selection_model_id),
|
|
});
|
|
}
|
|
}
|
|
|
|
pub struct ExportedBufferBlocks<'a> {
|
|
pub blocks: Vec<StyledBufferBlock>,
|
|
pub context: &'a AppContext,
|
|
}
|
|
|
|
impl ExportedBufferBlocks<'_> {
|
|
// Serialize a styled block into HTML string.
|
|
pub fn serialize_styled_blocks(self) -> Result<String> {
|
|
let mut result = vec![];
|
|
serialize(&mut result, &self, Default::default())?;
|
|
String::from_utf8(result).context("Failed to parse HTML bytes")
|
|
}
|
|
}
|
|
|
|
impl Serialize for ExportedBufferBlocks<'_> {
|
|
fn serialize<S>(&self, serializer: &mut S, _: TraversalScope) -> io::Result<()>
|
|
where
|
|
S: Serializer,
|
|
{
|
|
let mut previous_block_type = None;
|
|
let mut active_indent_level = 0;
|
|
for block in self.blocks.iter() {
|
|
match block {
|
|
StyledBufferBlock::Item(item) => {
|
|
if let Some(end_container) = previous_block_type
|
|
.take()
|
|
.and_then(|style| container_element(&style))
|
|
{
|
|
end_list_with_indent(serializer, end_container, active_indent_level)?
|
|
}
|
|
|
|
match item {
|
|
BufferBlockItem::HorizontalRule => {
|
|
let element_name = "hr";
|
|
|
|
let tag_name = QualName::new(None, ns!(html), element_name.into());
|
|
serializer.start_elem(tag_name.clone(), iter::empty())?;
|
|
serializer.end_elem(tag_name)?;
|
|
}
|
|
BufferBlockItem::Embedded { item } => {
|
|
let rich_copy_format = item.to_rich_format(self.context);
|
|
|
|
let html_format = rich_copy_format.html;
|
|
let tag_name =
|
|
QualName::new(None, ns!(html), html_format.element_name.into());
|
|
|
|
let attributes = html_format
|
|
.attributes
|
|
.iter()
|
|
.map(|(key, value)| {
|
|
(QualName::new(None, ns!(), (*key).into()), *value)
|
|
})
|
|
.collect_vec();
|
|
serializer.start_elem(
|
|
tag_name.clone(),
|
|
attributes.iter().map(|(name, value)| (name, *value)),
|
|
)?;
|
|
|
|
serializer.write_text(&html_format.content)?;
|
|
serializer.end_elem(tag_name)?;
|
|
}
|
|
BufferBlockItem::Image {
|
|
alt_text,
|
|
source,
|
|
title,
|
|
} => {
|
|
let tag_name = QualName::new(None, ns!(html), "img".into());
|
|
let src_attr = QualName::new(None, ns!(), "src".into());
|
|
let alt_attr = QualName::new(None, ns!(), "alt".into());
|
|
let title_attr = QualName::new(None, ns!(), "title".into());
|
|
let mut attrs =
|
|
vec![(&src_attr, source.as_str()), (&alt_attr, alt_text.as_str())];
|
|
// Only emit `title="..."` when the parsed title is non-empty so
|
|
// the default form matches the pre-title behavior.
|
|
if let Some(title) = title.as_deref().filter(|t| !t.is_empty()) {
|
|
attrs.push((&title_attr, title));
|
|
}
|
|
serializer.start_elem(tag_name.clone(), attrs.into_iter())?;
|
|
serializer.end_elem(tag_name)?;
|
|
}
|
|
};
|
|
|
|
active_indent_level = 0;
|
|
}
|
|
StyledBufferBlock::Text(text_block) => {
|
|
let (new_indent_level, number) = match &text_block.style {
|
|
BufferBlockStyle::OrderedList {
|
|
indent_level,
|
|
number,
|
|
} => (indent_level.as_usize() + 1, *number),
|
|
BufferBlockStyle::UnorderedList { indent_level }
|
|
| BufferBlockStyle::TaskList { indent_level, .. } => {
|
|
(indent_level.as_usize() + 1, None)
|
|
}
|
|
_ => (0, None),
|
|
};
|
|
|
|
// Start or end the active list element.
|
|
let current_container = container_element(&text_block.style);
|
|
let previous_container =
|
|
previous_block_type.as_ref().and_then(container_element);
|
|
|
|
// Right now the only container element is lists. In the future when we support more container types we will
|
|
// need to update the logic here.
|
|
//
|
|
// For lists, note that we don't start and close <li> tag within the same iteration. This is because the syntax
|
|
// of nested list is we would need to start the new <ul> tag within the open <li> tag. For example, <ul0>first line<ul1>second line
|
|
// will look like <ul><li>first line<ul><li>second line</li></ul></li></ul> in HTML.
|
|
match (previous_container, current_container) {
|
|
// If we are starting a new list from a non-list block, just start the <ul> or <ol> tag with the new indent level.
|
|
(None, Some(current_tag)) => start_list_with_indent(
|
|
serializer,
|
|
current_tag,
|
|
new_indent_level,
|
|
number,
|
|
)?,
|
|
// If we are ending a list into a non-list block, end the <ul> or <ol> tag with the active indent level.
|
|
(Some(prev_tag), None) => {
|
|
end_list_with_indent(serializer, prev_tag, active_indent_level)?
|
|
}
|
|
(Some(prev_tag), Some(current_tag)) => {
|
|
// If we are converting from one type of list to another, end the active list and start the new list.
|
|
if prev_tag != current_tag {
|
|
end_list_with_indent(serializer, prev_tag, active_indent_level)?;
|
|
start_list_with_indent(
|
|
serializer,
|
|
current_tag,
|
|
new_indent_level,
|
|
number,
|
|
)?
|
|
// If the list type is the same and we have less indent level on the new list, we need to end the number of tags
|
|
// matching the difference in indent level and start a new <li> element.
|
|
} else if active_indent_level > new_indent_level {
|
|
end_list_with_indent(
|
|
serializer,
|
|
prev_tag,
|
|
active_indent_level - new_indent_level,
|
|
)?;
|
|
|
|
serializer.end_elem(QualName::new(None, ns!(html), "li".into()))?;
|
|
serializer.start_elem(
|
|
QualName::new(None, ns!(html), "li".into()),
|
|
iter::empty(),
|
|
)?;
|
|
// If the list type is the same and we have more indent level on the new list, we need to start the number of tags
|
|
// matching the difference in indent level.
|
|
} else if new_indent_level > active_indent_level {
|
|
start_list_with_indent(
|
|
serializer,
|
|
current_tag,
|
|
new_indent_level - active_indent_level,
|
|
number,
|
|
)?
|
|
// If the list type is the same and the indent level is the same, we just need to start a new <li> element.
|
|
} else if new_indent_level == active_indent_level {
|
|
serializer.end_elem(QualName::new(None, ns!(html), "li".into()))?;
|
|
serializer.start_elem(
|
|
QualName::new(None, ns!(html), "li".into()),
|
|
iter::empty(),
|
|
)?;
|
|
}
|
|
}
|
|
(None, None) => (),
|
|
}
|
|
|
|
active_indent_level = new_indent_level;
|
|
if let BufferBlockStyle::Table { alignments, .. } = &text_block.style {
|
|
let markdown_text =
|
|
BufferMarkdownParser::styled_runs_to_markdown_text(&text_block.block);
|
|
let table = super::text::table_from_internal_format_with_inline_markdown(
|
|
&markdown_text,
|
|
alignments.clone(),
|
|
);
|
|
serialize_table_to_html(serializer, &table)?;
|
|
previous_block_type = Some(text_block.style.clone());
|
|
continue;
|
|
}
|
|
let name = match text_block.style {
|
|
BufferBlockStyle::CodeBlock { .. } => Some("pre".to_string()),
|
|
BufferBlockStyle::UnorderedList { .. }
|
|
| BufferBlockStyle::OrderedList { .. }
|
|
| BufferBlockStyle::TaskList { .. } => None,
|
|
BufferBlockStyle::Header { header_size } => {
|
|
Some(format!("h{}", Into::<usize>::into(header_size)))
|
|
}
|
|
BufferBlockStyle::PlainText => Some("p".to_string()),
|
|
BufferBlockStyle::Table { .. } => None,
|
|
};
|
|
let tag_name = name.map(|name| QualName::new(None, ns!(html), name.into()));
|
|
|
|
previous_block_type = Some(text_block.style.clone());
|
|
|
|
if let Some(tag_name) = tag_name.clone() {
|
|
serializer.start_elem(tag_name, iter::empty())?;
|
|
|
|
// Push an additional <code> element here so other apps could recognize the included language info.
|
|
if let BufferBlockStyle::CodeBlock { code_block_type } = &text_block.style {
|
|
let language_name = QualName::new(None, ns!(), "class".into());
|
|
let language_value = format!(
|
|
"language-{}",
|
|
code_block_type
|
|
.to_markdown_representation(MarkdownStyle::Export {
|
|
app_context: Some(self.context),
|
|
should_not_escape_markdown_punctuation: false,
|
|
})
|
|
.to_lowercase()
|
|
);
|
|
let attrs = [(&language_name, language_value.as_str())];
|
|
|
|
let code_block_name = QualName::new(None, ns!(html), "code".into());
|
|
serializer.start_elem(code_block_name.clone(), attrs.into_iter())?;
|
|
}
|
|
} else if let BufferBlockStyle::TaskList { complete, .. } = text_block.style {
|
|
// For tasklists, the formatting is similar to unordered lists but with an additional
|
|
// <input type="checkbox"></input> before the text fragments.
|
|
let type_name = QualName::new(None, ns!(), "type".into());
|
|
let checked_name = QualName::new(None, ns!(), "checked".into());
|
|
|
|
let mut checkbox_attrs = vec![(&type_name, "checkbox")];
|
|
|
|
if complete {
|
|
checkbox_attrs.push((&checked_name, ""));
|
|
}
|
|
|
|
let tag_name = QualName::new(None, ns!(html), "input".into());
|
|
serializer.start_elem(tag_name.clone(), checkbox_attrs.into_iter())?;
|
|
serializer.end_elem(tag_name)?;
|
|
}
|
|
|
|
for (idx, runs) in text_block.block.iter().enumerate() {
|
|
let mut text = runs.run.as_str();
|
|
// Strip trailing newline since <p> or <code> already includes a linebreak.
|
|
if idx == text_block.block.len() - 1
|
|
&& let Some(removed_trailing_newline) = runs.run.strip_suffix('\n')
|
|
{
|
|
text = removed_trailing_newline;
|
|
}
|
|
|
|
if let Some(link) = runs.text_styles.link_content() {
|
|
let tag_name = QualName::new(None, ns!(html), "a".into());
|
|
let attribute_name = QualName::new(None, ns!(), "href".into());
|
|
serializer.start_elem(
|
|
tag_name,
|
|
vec![(&attribute_name, link.as_str())].into_iter(),
|
|
)?;
|
|
}
|
|
|
|
if runs.text_styles.is_at_least_bold() {
|
|
let tag_name = QualName::new(None, ns!(html), "strong".into());
|
|
serializer.start_elem(tag_name, iter::empty())?;
|
|
}
|
|
|
|
if runs.text_styles.is_italic() {
|
|
let tag_name = QualName::new(None, ns!(html), "em".into());
|
|
serializer.start_elem(tag_name, iter::empty())?;
|
|
}
|
|
|
|
if runs.text_styles.is_strikethrough() {
|
|
let tag_name = QualName::new(None, ns!(html), "s".into());
|
|
serializer.start_elem(tag_name, iter::empty())?;
|
|
}
|
|
|
|
if runs.text_styles.is_underlined() {
|
|
let tag_name = QualName::new(None, ns!(html), "u".into());
|
|
serializer.start_elem(tag_name, iter::empty())?;
|
|
}
|
|
|
|
if runs.text_styles.is_inline_code() {
|
|
let tag_name = QualName::new(None, ns!(html), "code".into());
|
|
serializer.start_elem(tag_name, iter::empty())?;
|
|
}
|
|
|
|
serializer.write_text(text)?;
|
|
|
|
if runs.text_styles.is_inline_code() {
|
|
serializer.end_elem(QualName::new(None, ns!(html), "code".into()))?;
|
|
}
|
|
|
|
if runs.text_styles.is_underlined() {
|
|
serializer.end_elem(QualName::new(None, ns!(html), "u".into()))?;
|
|
}
|
|
|
|
if runs.text_styles.is_strikethrough() {
|
|
serializer.end_elem(QualName::new(None, ns!(html), "s".into()))?;
|
|
}
|
|
|
|
if runs.text_styles.is_italic() {
|
|
serializer.end_elem(QualName::new(None, ns!(html), "em".into()))?;
|
|
}
|
|
|
|
if runs.text_styles.is_at_least_bold() {
|
|
serializer.end_elem(QualName::new(None, ns!(html), "strong".into()))?;
|
|
}
|
|
|
|
if runs.text_styles.is_link() {
|
|
serializer.end_elem(QualName::new(None, ns!(html), "a".into()))?;
|
|
}
|
|
}
|
|
|
|
if let Some(tag_name) = tag_name {
|
|
if matches!(text_block.style, BufferBlockStyle::CodeBlock { .. }) {
|
|
let code_block_name = QualName::new(None, ns!(html), "code".into());
|
|
serializer.end_elem(code_block_name)?;
|
|
}
|
|
serializer.end_elem(tag_name)?;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// If the copied region ended on a list element, close the parent list.
|
|
if let Some(end_container) = previous_block_type.and_then(|style| container_element(&style))
|
|
{
|
|
end_list_with_indent(serializer, end_container, active_indent_level)?
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
fn start_list_with_indent<S>(
|
|
serializer: &mut S,
|
|
tag: &str,
|
|
indent_level: usize,
|
|
start: Option<usize>,
|
|
) -> io::Result<()>
|
|
where
|
|
S: Serializer,
|
|
{
|
|
let start_attr = start.map(|start| {
|
|
(
|
|
QualName::new(None, ns![], "start".into()),
|
|
start.to_string(),
|
|
)
|
|
});
|
|
|
|
for i in 0..indent_level {
|
|
// If an ordered list item had a custom number, we make it the start of the immediate parent
|
|
// list. As a result, custom numbers on items within a list are ignored, matching the
|
|
// behavior for Markdown.
|
|
let list_attrs = start_attr.as_ref().and_then(|(attr, value)| {
|
|
if i == indent_level - 1 {
|
|
Some((attr, value.as_str()))
|
|
} else {
|
|
None
|
|
}
|
|
});
|
|
serializer.start_elem(
|
|
QualName::new(None, ns!(html), tag.into()),
|
|
list_attrs.into_iter(),
|
|
)?;
|
|
serializer.start_elem(QualName::new(None, ns!(html), "li".into()), iter::empty())?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn end_list_with_indent<S>(serializer: &mut S, tag: &str, indent_level: usize) -> io::Result<()>
|
|
where
|
|
S: Serializer,
|
|
{
|
|
for _ in 0..indent_level {
|
|
serializer.end_elem(QualName::new(None, ns!(html), "li".into()))?;
|
|
serializer.end_elem(QualName::new(None, ns!(html), tag.into()))?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// The parent element wrapping consecutive blocks of a given style.
|
|
///
|
|
/// This only applies to list blocks.
|
|
fn container_element(style: &BufferBlockStyle) -> Option<&'static str> {
|
|
match style {
|
|
BufferBlockStyle::OrderedList { .. } => Some("ol"),
|
|
BufferBlockStyle::UnorderedList { .. } | BufferBlockStyle::TaskList { .. } => Some("ul"),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn formatted_text_line_to_block_style(line: &FormattedTextLine) -> Option<BufferBlockStyle> {
|
|
match line {
|
|
FormattedTextLine::Heading(header) => Some(BufferBlockStyle::Header {
|
|
header_size: header
|
|
.heading_size
|
|
.try_into()
|
|
.unwrap_or(BlockHeaderSize::Header1),
|
|
}),
|
|
FormattedTextLine::CodeBlock(text) => Some(BufferBlockStyle::CodeBlock {
|
|
code_block_type: text.into(),
|
|
}),
|
|
FormattedTextLine::Table(table) => Some(BufferBlockStyle::table(table.alignments.clone())),
|
|
FormattedTextLine::UnorderedList(list) => Some(BufferBlockStyle::UnorderedList {
|
|
indent_level: ListIndentLevel::from_usize(list.indent_level),
|
|
}),
|
|
FormattedTextLine::TaskList(list) => Some(BufferBlockStyle::TaskList {
|
|
indent_level: ListIndentLevel::from_usize(list.indent_level),
|
|
complete: list.complete,
|
|
}),
|
|
FormattedTextLine::OrderedList(list) => Some(BufferBlockStyle::OrderedList {
|
|
indent_level: ListIndentLevel::from_usize(list.indented_text.indent_level),
|
|
number: list.number,
|
|
}),
|
|
FormattedTextLine::Line(_) => Some(BufferBlockStyle::PlainText),
|
|
FormattedTextLine::LineBreak => None,
|
|
FormattedTextLine::HorizontalRule => Some(BufferBlockStyle::PlainText),
|
|
// TODO(kevin): handle embedded objects in buffer.
|
|
FormattedTextLine::Embedded(_) => None,
|
|
// Images are block items, not block styles
|
|
FormattedTextLine::Image(_) => None,
|
|
}
|
|
}
|
|
|
|
fn alignment_to_html_value(alignment: &FormattedTableAlignment) -> &'static str {
|
|
match alignment {
|
|
FormattedTableAlignment::Left => "left",
|
|
FormattedTableAlignment::Center => "center",
|
|
FormattedTableAlignment::Right => "right",
|
|
}
|
|
}
|
|
|
|
fn serialize_table_cell_inline<S>(
|
|
serializer: &mut S,
|
|
inline: &[FormattedTextFragment],
|
|
) -> io::Result<()>
|
|
where
|
|
S: Serializer,
|
|
{
|
|
for fragment in inline {
|
|
let styles = TextStylesWithMetadata::from(fragment.styles.clone());
|
|
|
|
if let Some(link) = styles.link_content() {
|
|
let tag = QualName::new(None, ns!(html), "a".into());
|
|
let href = QualName::new(None, ns!(), "href".into());
|
|
serializer.start_elem(tag, vec![(&href, link.as_str())].into_iter())?;
|
|
}
|
|
if styles.is_at_least_bold() {
|
|
serializer.start_elem(
|
|
QualName::new(None, ns!(html), "strong".into()),
|
|
iter::empty(),
|
|
)?;
|
|
}
|
|
if styles.is_italic() {
|
|
serializer.start_elem(QualName::new(None, ns!(html), "em".into()), iter::empty())?;
|
|
}
|
|
if styles.is_strikethrough() {
|
|
serializer.start_elem(QualName::new(None, ns!(html), "s".into()), iter::empty())?;
|
|
}
|
|
if styles.is_underlined() {
|
|
serializer.start_elem(QualName::new(None, ns!(html), "u".into()), iter::empty())?;
|
|
}
|
|
if styles.is_inline_code() {
|
|
serializer.start_elem(QualName::new(None, ns!(html), "code".into()), iter::empty())?;
|
|
}
|
|
|
|
serializer.write_text(&fragment.text)?;
|
|
|
|
if styles.is_inline_code() {
|
|
serializer.end_elem(QualName::new(None, ns!(html), "code".into()))?;
|
|
}
|
|
if styles.is_underlined() {
|
|
serializer.end_elem(QualName::new(None, ns!(html), "u".into()))?;
|
|
}
|
|
if styles.is_strikethrough() {
|
|
serializer.end_elem(QualName::new(None, ns!(html), "s".into()))?;
|
|
}
|
|
if styles.is_italic() {
|
|
serializer.end_elem(QualName::new(None, ns!(html), "em".into()))?;
|
|
}
|
|
if styles.is_at_least_bold() {
|
|
serializer.end_elem(QualName::new(None, ns!(html), "strong".into()))?;
|
|
}
|
|
if styles.link_content().is_some() {
|
|
serializer.end_elem(QualName::new(None, ns!(html), "a".into()))?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn serialize_table_to_html<S>(serializer: &mut S, table: &FormattedTable) -> io::Result<()>
|
|
where
|
|
S: Serializer,
|
|
{
|
|
let table_tag = QualName::new(None, ns!(html), "table".into());
|
|
serializer.start_elem(table_tag.clone(), iter::empty())?;
|
|
let thead_tag = QualName::new(None, ns!(html), "thead".into());
|
|
serializer.start_elem(thead_tag.clone(), iter::empty())?;
|
|
let tr_tag = QualName::new(None, ns!(html), "tr".into());
|
|
serializer.start_elem(tr_tag.clone(), iter::empty())?;
|
|
let align_attr = QualName::new(None, ns!(), "align".into());
|
|
for (i, header) in table.headers.iter().enumerate() {
|
|
let alignment = table
|
|
.alignments
|
|
.get(i)
|
|
.unwrap_or(&FormattedTableAlignment::Left);
|
|
let align_value = alignment_to_html_value(alignment);
|
|
let th_tag = QualName::new(None, ns!(html), "th".into());
|
|
serializer.start_elem(th_tag.clone(), vec![(&align_attr, align_value)].into_iter())?;
|
|
serialize_table_cell_inline(serializer, header)?;
|
|
serializer.end_elem(th_tag)?;
|
|
}
|
|
serializer.end_elem(tr_tag)?;
|
|
serializer.end_elem(thead_tag)?;
|
|
let tbody_tag = QualName::new(None, ns!(html), "tbody".into());
|
|
serializer.start_elem(tbody_tag.clone(), iter::empty())?;
|
|
for row in &table.rows {
|
|
let tr_tag = QualName::new(None, ns!(html), "tr".into());
|
|
serializer.start_elem(tr_tag.clone(), iter::empty())?;
|
|
for (i, cell) in row.iter().enumerate() {
|
|
let alignment = table
|
|
.alignments
|
|
.get(i)
|
|
.unwrap_or(&FormattedTableAlignment::Left);
|
|
let align_value = alignment_to_html_value(alignment);
|
|
let td_tag = QualName::new(None, ns!(html), "td".into());
|
|
serializer.start_elem(td_tag.clone(), vec![(&align_attr, align_value)].into_iter())?;
|
|
serialize_table_cell_inline(serializer, cell)?;
|
|
serializer.end_elem(td_tag)?;
|
|
}
|
|
serializer.end_elem(tr_tag)?;
|
|
}
|
|
serializer.end_elem(tbody_tag)?;
|
|
serializer.end_elem(table_tag)?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn inline_to_markdown(inline: &FormattedTextInline) -> String {
|
|
let mut markdown = String::new();
|
|
let mut previous_styles = TextStylesWithMetadata::default();
|
|
for fragment in inline {
|
|
let next_styles = TextStylesWithMetadata::from(fragment.styles.clone());
|
|
let content = BufferMarkdownParser::append_formatting(
|
|
&previous_styles,
|
|
&next_styles,
|
|
fragment.text.as_str(),
|
|
&mut markdown,
|
|
);
|
|
previous_styles = next_styles;
|
|
BufferMarkdownParser::append_content(content, true, &mut markdown);
|
|
}
|
|
BufferMarkdownParser::append_formatting(
|
|
&previous_styles,
|
|
&TextStylesWithMetadata::default(),
|
|
"",
|
|
&mut markdown,
|
|
);
|
|
markdown
|
|
}
|
|
|
|
fn append_gfm_table_row(cells: &[String], buf: &mut String) {
|
|
buf.push('|');
|
|
for cell in cells {
|
|
buf.push(' ');
|
|
buf.push_str(&escape_gfm_table_cell(cell));
|
|
buf.push(' ');
|
|
buf.push('|');
|
|
}
|
|
buf.push('\n');
|
|
}
|
|
|
|
fn alignment_to_gfm_separator(alignment: FormattedTableAlignment) -> String {
|
|
match alignment {
|
|
FormattedTableAlignment::Left => "---".to_string(),
|
|
FormattedTableAlignment::Center => ":---:".to_string(),
|
|
FormattedTableAlignment::Right => "---:".to_string(),
|
|
}
|
|
}
|
|
|
|
fn escape_gfm_table_cell(cell: &str) -> String {
|
|
cell.replace('|', "\\|")
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "markdown_tests.rs"]
|
|
mod tests;
|