Browse / github.com/storytold/wordcraft / patch
Paste plain text that is Markdown as formatted text
Paste plain text that is Markdown as formatted text ## What Pasting text copied from a Markdown source (GitHub, Obsidian, an AI chat) now produces headings, lists, bold/italic, inline code, fenced code blocks, block quotes and GFM tables instead of literal `#` and `**`. Paste Special… (Ctrl+Alt+V) lists Markdown whenever the clipboard text parses as Markdown. ## Why People copy notes and answers out of Markdown tools all the time. WordCraft pasted the markers verbatim, so every paste had to be reformatted by hand — and "Keep Text Only" was the only alternative. Word has no Markdown paste, but this is the "goes further" the README promises, a
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- lucasmelo-mike01 (imported from pull request #4437, open)
- Base
6a2b520fa49aa0d0595cea36589aaf16ca371cd3- Size
- +360 −15 in 2 files
- Review
- pending
- Shared
- 2026-10-28
- Id
655d433b87289254e168d69b
How it went
No other operator has reported using it yet.
Diff
raw · crates/engine/src/cmd/paste.rs crates/engine/src/tests.rs
@@ -1,12 +1,15 @@-//! Paste Special: paste the clipboard in a chosen format (formatted, RTF, HTML, picture,+//! Paste Special: paste the clipboard in a chosen format (formatted, Markdown, RTF, HTML, picture, //! unformatted), and the formatted content Paste and Merge Formatting take from other programs. //! //! The clipboard can hold several representations of the same content. WordCraft's own copy keeps //! a rich fragment; the system clipboard gives text, and callers (agents, the CLI, a front end-//! with a richer clipboard) may pass `html`, `rtf` or a `picture` too. Text that is itself RTF or-//! HTML source can also be pasted as that format. Paste keeps the source formatting of another-//! program's HTML or RTF; Merge Formatting keeps only its emphasis (#687).-//! Plain text has no formatting to keep, so Paste inserts it as Keep Text Only does.+//! with a richer clipboard) may pass `html`, `rtf` or a `picture` too. Text that is itself Markdown,+//! RTF or HTML source can also be pasted as that format. Paste keeps the source formatting of+//! another program's HTML or RTF; Merge Formatting keeps only its emphasis (#687).+//! Text with Markdown structure (headings, lists, tables, emphasis, code) reads as a document, as+//! opening a `.md` file does, so pasting it gives its headings and lists WordCraft's own+//! formatting instead of the source's punctuation; plain text has no formatting to keep, so Paste+//! inserts it as Keep Text Only does. use std::collections::BTreeMap; use std::sync::Arc;@@ -26,8 +29,9 @@ pub const MAX_PICTURE_BYTES: usize = 96 << 20; const MAX_DEPTH: usize = 64; /// The formats, in the order the dialog lists them: (id, label).-pub const FORMATS: [(&str, &str); 5] = [+pub const FORMATS: [(&str, &str); 6] = [ ("formatted", "Formatted Text"),+ ("markdown", "Markdown"), ("rtf", "Rich Text Format (RTF)"), ("html", "HTML Format"), ("picture", "Picture"),@@ -35,11 +39,9 @@ pub const FORMATS: [(&str, &str); 5] = [ ]; pub fn specs() -> Vec<CommandSpec> {- vec![- CommandSpec::new("edit.pasteSpecial", "Paste Special", "Home › Clipboard › Paste", paste_special)- .key("Mod+Alt+V")- .params(r#"{"as"?: "formatted"|"rtf"|"html"|"picture"|"text", "text"?: string, "html"?: string, "rtf"?: string, "picture"?: base64}"#),- ]+ vec![CommandSpec::new("edit.pasteSpecial", "Paste Special", "Home › Clipboard › Paste", paste_special).key("Mod+Alt+V").params(+ r#"{"as"?: "formatted"|"markdown"|"rtf"|"html"|"picture"|"text", "text"?: string, "html"?: string, "rtf"?: string, "picture"?: base64}"#,+ )] } /// Clipboard text from the params (system clipboard), else WordCraft's own copy.@@ -59,6 +61,201 @@ fn looks_like_html(t: &str) -> bool { t.starts_with('<') && (t.contains("</") || t.contains("/>")) } +/// How much of a pasted text [`looks_like_markdown`] reads, so a hostile clipboard can't make the+/// test long.+const MARKDOWN_SCAN_BYTES: usize = 64 << 10;+/// Most lines [`looks_like_markdown`] reads.+const MARKDOWN_SCAN_LINES: usize = 500;++/// The first `max` bytes of `t` (fewer if that would cut a character).+fn head_bytes(t: &str, max: usize) -> &str {+ if t.len() <= max {+ return t;+ }+ let mut end = max;+ while end > 0 && !t.is_char_boundary(end) {+ end -= 1;+ }+ t.get(..end).unwrap_or("")+}++/// A line without Markdown's indentation: up to three spaces. More than that, or a tab, starts an+/// indented code block, whose lines are code and not structure.+fn unindented(l: &str) -> &str {+ let mut s = l;+ for _ in 0..3 {+ match s.strip_prefix(' ') {+ Some(rest) => s = rest,+ None => break,+ }+ }+ s+}++/// The level of an ATX heading line (`## Tema`), if it is one.+fn atx_heading(l: &str) -> Option<u8> {+ let s = unindented(l);+ let n = s.chars().take_while(|&c| c == '#').count();+ if n == 0 || n > 6 {+ return None;+ }+ let rest = s.get(n..)?;+ (rest.is_empty() || rest.starts_with([' ', '\t'])).then_some(n as u8)+}++/// The fence character of a code fence line (```` ``` ```` or `~~~`), if it is one.+fn fence_marker(l: &str) -> Option<char> {+ let s = unindented(l);+ let c = s.chars().next()?;+ if c != '`' && c != '~' {+ return None;+ }+ (s.chars().take_while(|&x| x == c).count() >= 3).then_some(c)+}++/// Whether the line is a thematic break (`---`, `***`, `___`).+fn thematic_break(l: &str) -> bool {+ let s = unindented(l).trim_end_matches([' ', '\t']);+ let Some(c) = s.chars().next() else {+ return false;+ };+ if !matches!(c, '-' | '*' | '_') {+ return false;+ }+ s.chars().filter(|&x| x == c).count() >= 3 && s.chars().all(|x| x == c || x == ' ')+}++/// Whether the line is a block quote line (`> quoted`).+fn block_quote(l: &str) -> bool {+ let s = unindented(l);+ s == ">" || s.starts_with("> ") || s.starts_with(">\t")+}++/// Whether the line is a GFM table's delimiter row (`| --- | :-: |`).+fn table_delimiter(l: &str) -> bool {+ let s = unindented(l).trim();+ if !s.contains('|') {+ return false;+ }+ let cells: Vec<&str> = s.trim_matches('|').split('|').map(str::trim).collect();+ cells.len() >= 2+ && cells.iter().all(|c| {+ let c = c.strip_prefix(':').unwrap_or(c);+ let c = c.strip_suffix(':').unwrap_or(c);+ !c.is_empty() && c.chars().all(|x| x == '-')+ })+}++/// Whether the line is an unordered list item (`- a`).+fn bullet_item(l: &str) -> bool {+ let s = unindented(l);+ let Some(c) = s.chars().next() else {+ return false;+ };+ if !matches!(c, '-' | '*' | '+') {+ return false;+ }+ let rest = s.get(1..).unwrap_or("");+ matches!(rest.chars().next(), Some(' ') | Some('\t')) && !rest.trim().is_empty()+}++/// Whether the line is an ordered list item (`1. a`).+fn ordered_item(l: &str) -> bool {+ let s = unindented(l);+ let digits = s.chars().take_while(char::is_ascii_digit).count();+ if digits == 0 || digits > 9 {+ return false;+ }+ let rest = s.get(digits..).unwrap_or("");+ let mut it = rest.chars();+ if !matches!(it.next(), Some('.') | Some(')')) {+ return false;+ }+ matches!(it.next(), Some(' ') | Some('\t')) && !it.as_str().trim().is_empty()+}++/// Whether the line is a setext heading's underline (`===`).+fn setext_underline(l: &str) -> bool {+ let s = unindented(l).trim_end_matches([' ', '\t']);+ s.len() >= 2 && s.chars().all(|c| c == '=')+}++/// Emphasis, code and link markers in `t`, as pairs: `**bold**` is one, a link one, a code span+/// one.+fn inline_markers(t: &str) -> usize {+ let count = |m: &str| t.matches(m).count();+ let double = |m: &str| count(m) / 2;+ double("**") + double("__") + double("~~") + count("`") / 2 + count("](")+}++/// Whether clipboard text reads as a Markdown document rather than as prose: the punctuation+/// someone typed as text (`#`, `**`, `- `, `|`), which, pasted, becomes WordCraft's own headings,+/// lists, tables and emphasis, as opening a `.md` file does.+///+/// The test errs towards "no": one heading-looking line (a `#` comment in a shell snippet), one+/// `>` quote or one pair of `**` is text, and Keep Text Only pastes it raw. What it accepts: a+/// code fence, a GFM table, two headings, a heading beside emphasis, three list items, and so on.+/// Paste Special still offers Markdown and formats any text the user picks, whatever this says.+fn looks_like_markdown(t: &str) -> bool {+ if t.trim().is_empty() || t.len() > MAX_PASTE_BYTES {+ return false;+ }+ let head = head_bytes(t, MARKDOWN_SCAN_BYTES);+ let mut headings = 0usize;+ let mut fences = 0usize;+ let mut tables = 0usize;+ let mut quotes = 0usize;+ let mut rules = 0usize;+ let mut bullets = 0usize;+ let mut ordered = 0usize;+ let mut fence: Option<char> = None;+ for line in head.split('\n').take(MARKDOWN_SCAN_LINES) {+ if let Some(c) = fence {+ // Inside a code fence the lines are code: only the closing fence reads.+ if fence_marker(line) == Some(c) {+ fences += 1;+ fence = None;+ }+ continue;+ }+ if let Some(c) = fence_marker(line) {+ fences += 1;+ fence = Some(c);+ } else if atx_heading(line).is_some() {+ headings += 1;+ } else if table_delimiter(line) {+ tables += 1;+ } else if block_quote(line) {+ quotes += 1;+ } else if thematic_break(line) {+ rules += 1;+ } else if bullet_item(line) {+ bullets += 1;+ } else if ordered_item(line) {+ ordered += 1;+ } else if setext_underline(line) {+ headings += 1;+ }+ }+ let structural = headings + fences + tables + quotes + rules;+ let lists = bullets + ordered;+ let inline = inline_markers(head);+ // A code block, a table or a second heading is Markdown whatever else the text holds.+ if fences >= 2 || tables > 0 || headings >= 2 {+ return true;+ }+ // A heading over emphasized text (`## Tema` and `**nota**`) is a document.+ if headings > 0 && inline >= 2 {+ return true;+ }+ // Three list items are a list, not three lines that happen to begin with a dash.+ if bullets >= 3 {+ return true;+ }+ // Anything else that adds up: a quote and two items, a rule and bold and a link, …+ structural > 0 && structural + lists + inline >= 3+}+ /// The source for a format, if the clipboard has it. fn source(s: &Session, v: &Value, fmt: &str) -> Option<String> { let non_empty = |t: String| if t.is_empty() { None } else { Some(t) };@@ -73,6 +270,9 @@ fn source(s: &Session, v: &Value, fmt: &str) -> Option<String> { } "rtf" => p::str(v, "rtf").map(str::to_string).or_else(|| clip_text(s, v).filter(|t| looks_like_rtf(t))).and_then(non_empty), "html" => p::str(v, "html").map(str::to_string).or_else(|| clip_text(s, v).filter(|t| looks_like_html(t))).and_then(non_empty),+ // Picking Markdown formats any text, as picking HTML parses HTML source; only the dialog's+ // list of formats (see `available`) is what the test keeps off text that isn't Markdown.+ "markdown" => clip_text(s, v).filter(|t| !t.trim().is_empty() && t.len() <= MAX_PASTE_BYTES), "picture" => p::str(v, "picture").map(str::to_string).and_then(non_empty), "text" => clip_text(s, v).and_then(non_empty), _ => None,@@ -81,7 +281,16 @@ fn source(s: &Session, v: &Value, fmt: &str) -> Option<String> { /// Formats the clipboard can be pasted as, best first. pub fn available(s: &Session, v: &Value) -> Vec<&'static str> {- FORMATS.iter().map(|(id, _)| *id).filter(|id| source(s, v, id).is_some()).collect()+ FORMATS+ .iter()+ .map(|(id, _)| *id)+ .filter(|id| match *id {+ // Offered for text that reads as Markdown, not for every text: plain prose is what Keep+ // Text Only is for, and the dialog shouldn't suggest turning it into a document.+ "markdown" => clip_text(s, v).is_some_and(|t| looks_like_markdown(&t)),+ _ => source(s, v, id).is_some(),+ })+ .collect() } fn formats_json(ids: &[&str]) -> Value {@@ -113,11 +322,14 @@ fn paste_special(s: &mut Session, v: &Value) -> CmdResult { }; let fmt = match fmt.to_ascii_lowercase().as_str() { "formatted" | "keep" | "wordcraft" => "formatted",+ "markdown" | "md" => "markdown", "rtf" => "rtf", "html" => "html", "picture" | "png" | "image" => "picture", "text" | "unformatted" | "plain" => "text",- other => return Err(CmdError::Params(format!("unknown format `{other}` (formatted, rtf, html, picture or text)"))),+ other => {+ return Err(CmdError::Params(format!("unknown format `{other}` (formatted, markdown, rtf, html, picture or text)")));+ } }; let Some(src) = source(s, v, fmt) else { return Err(CmdError::Failed(if ids.is_empty() {@@ -139,6 +351,7 @@ fn paste_special(s: &mut Session, v: &Value) -> CmdResult { return Ok(r); } "formatted" => s.clipboard.clone().ok_or_else(|| CmdError::Failed("the clipboard is empty".into()))?,+ "markdown" => adopt(s, wordcraft_formats::markdown::import(&src)), "html" => adopt(s, import_html(&src)), _ => adopt(s, wordcraft_formats::rtf::import(src.as_bytes()).map_err(|e| CmdError::Failed(format!("bad RTF: {e}")))?), };@@ -259,14 +472,16 @@ pub fn rich_fragment(s: &mut Session, v: &Value) -> Option<Fragment> { /// What a Paste brings in. pub enum Pasted {- /// WordCraft's own copy, or another program's HTML or RTF: it keeps the formatting it carries.+ /// WordCraft's own copy, or another program's HTML, RTF or Markdown: it keeps the formatting+ /// it carries. Fragment(Fragment), /// The system clipboard's text, lines separated by `\n`. Text(String), } /// What Paste inserts: WordCraft's own copy while the system clipboard still holds it, else-/// another program's formatted copy (HTML, RTF), else the system clipboard's text.+/// another program's formatted copy (HTML, RTF), else Markdown that reads as a document, else the+/// system clipboard's text. pub fn paste_content(s: &mut Session, v: &Value) -> Result<Pasted, CmdError> { let text = p::str(v, "text").map(|t| t.replace("\r\n", "\n")); if let Some(f) = &s.clipboard@@ -278,6 +493,15 @@ pub fn paste_content(s: &mut Session, v: &Value) -> Result<Pasted, CmdError> { if let Some(f) = rich_fragment(s, v) { return Ok(Pasted::Fragment(f)); }+ if let Some(t) = text.as_deref().filter(|t| looks_like_markdown(t)) {+ // Markdown that reads as a document: its headings, lists, tables and emphasis become this+ // document's formatting, as opening a `.md` file does. Keep Text Only pastes it as text,+ // and so does Paste Special › Unformatted Text.+ let doc = wordcraft_formats::markdown::import(t);+ if !doc.body.is_empty() {+ return Ok(Pasted::Fragment(adopt(s, doc)));+ }+ } match text { Some(t) if !t.is_empty() => Ok(Pasted::Text(t)), _ => Err(CmdError::Failed("the clipboard is empty".into())),@@ -1673,6 +1673,127 @@ fn paste_keeps_source_formatting_from_other_programs() { assert_eq!(props_at(&s, 4).italic, Some(true)); } +/// Markdown copied as text reads as a document when pasted: its headings, lists, tables and+/// emphasis become WordCraft's own formatting, as opening a `.md` file does — instead of the+/// source's `##`, `**` and `- ` landing in the text.+#[test]+fn paste_reads_markdown_as_a_formatted_document() {+ let mut s = s();+ let md =+ "# Título\n\n## Tema central da questão\n\n**Direito** e *ênfase*\n\n- A\n- B\n\n> citação\n\n---\n\n| A | B |\n| :-: | --- |\n| 1 | 2 |\n";+ run(&mut s, "edit.paste", json!({"text": md}));+ let plain = text(&s);+ assert!(plain.starts_with("Título\nTema central da questão\nDireito e ênfase"), "{plain:?}");+ for marker in ["#", "**", "*ênfase*", "- A", "| ---"] {+ assert!(!plain.contains(marker), "{marker:?} left in {plain:?}");+ }+ let style = |s: &Session, start: &str| {+ s.doc.body.iter().filter_map(|b| b.as_para()).find(|p| p.text.starts_with(start)).and_then(|p| p.props.style.clone())+ };+ assert_eq!(style(&s, "Título").as_deref(), Some("Heading1"));+ assert_eq!(style(&s, "Tema central").as_deref(), Some("Heading2"));+ assert_eq!(style(&s, "citação").as_deref(), Some("Quote"));+ // Emphasis: bold on "Direito", italic on "ênfase".+ let at = s.doc.body.iter().position(|b| b.as_para().is_some_and(|p| p.text.starts_with("Direito"))).unwrap();+ assert!(bold_at(&s, at, 0));+ assert_eq!(s.doc.para_at(&Pos::body(at, 10)).unwrap().props_of_char(10).italic, Some(true));+ // A bullet list, with its numbering definition adopted.+ let lists: Vec<_> = s.doc.body.iter().filter_map(|b| b.as_para()).filter(|p| p.props.numbering.is_some_and(|n| n.num != 0)).collect();+ assert_eq!(lists.len(), 2, "{plain:?}");+ assert!(lists.iter().all(|p| s.doc.numbering.nums.iter().any(|n| n.id == p.props.numbering.unwrap().num)));+ // The pipe table is a table.+ assert!(s.doc.body.iter().any(|b| matches!(&**b, wordcraft_doc::Block::Table(_))), "{plain:?}");+}++/// Paste Special offers Markdown for text that reads as Markdown, formats that text (and any text+/// the user picks Markdown for), and doesn't offer it for prose.+#[test]+fn paste_special_lists_and_pastes_markdown() {+ let mut s = s();+ let md = "## Tema\n\n**negrito** e lista:\n\n- um\n- dois\n- três\n";+ let r = run(&mut s, "edit.pasteSpecial", json!({"text": md}));+ let ids: Vec<&str> = r["formats"].as_array().unwrap().iter().map(|f| f["id"].as_str().unwrap()).collect();+ assert_eq!(ids, ["markdown", "text"]);+ let r = run(&mut s, "edit.pasteSpecial", json!({"as": "markdown", "text": md}));+ assert_eq!(r["pastedAs"], "markdown");+ assert!(text(&s).starts_with("Tema\nnegrito e lista:\num\ndois\ntrês"), "{}", text(&s));+ assert_eq!(s.doc.body.first().and_then(|b| b.as_para()).and_then(|p| p.props.style.as_deref()), Some("Heading2"));+ // Picking Markdown formats text the detection would leave alone: one pair of `**` is enough+ // once the user says what the text is.+ let mut t = self::s();+ run(&mut t, "edit.pasteSpecial", json!({"as": "markdown", "text": "usa **negrito** aqui"}));+ assert_eq!(text(&t), "usa negrito aqui");+ assert!(bold_at(&t, 0, 4));+ // Prose: Markdown is not offered.+ let r = run(&mut t, "edit.pasteSpecial", json!({"text": "Art. 1º O Município é pessoa jurídica de direito público interno."}));+ let ids: Vec<&str> = r["formats"].as_array().unwrap().iter().map(|f| f["id"].as_str().unwrap()).collect();+ assert_eq!(ids, ["text"]);+}++/// Text that only looks a little like Markdown stays text: one `#` comment line, one `>` quote or+/// one pair of `**` is prose, not a document, and so are numbered lines with no other structure.+#[test]+fn markdown_detection_keeps_prose_as_text() {+ let offered = |sample: &str| {+ let mut s = s();+ let r = run(&mut s, "edit.pasteSpecial", json!({"text": sample}));+ r["formats"].as_array().unwrap().iter().any(|f| f["id"] == "markdown")+ };+ for sample in ["# git status\ngit status --short", "Use **cuidado** ao citar o art. 5º.", "> ver nota anterior", "1. Art. 5º\n2. Art. 6º"] {+ assert!(!offered(sample), "read as Markdown: {sample:?}");+ let mut s = s();+ run(&mut s, "edit.paste", json!({"text": sample}));+ assert_eq!(text(&s), sample, "pasted as text: {sample:?}");+ assert!(s.doc.body.iter().filter_map(|b| b.as_para()).all(|p| p.props.style.is_none() && p.props.numbering.is_none()), "{sample:?}");+ }+ for sample in ["# Um\n\n# Dois", "```\ncode\n```", "| A | B |\n| - | - |\n| 1 | 2 |", "- a\n- b\n- c", "## Tema\n\n**negrito** e `código`"] {+ assert!(offered(sample), "not read as Markdown: {sample:?}");+ }+}++/// Keep Text Only keeps the Markdown source, whatever Paste would make of it.+#[test]+fn paste_text_keeps_the_markdown_source() {+ let mut s = s();+ let md = "## Tema\n\n**negrito**\n\n- um\n- dois\n- três\n";+ run(&mut s, "edit.pasteText", json!({"text": md}));+ assert_eq!(text(&s), md);+ assert!(s.doc.body.iter().filter_map(|b| b.as_para()).all(|p| p.props.style.is_none() && p.props.numbering.is_none()), "{}", text(&s));+ // A statute pasted as text: numbered lines are its text, not a list to renumber.+ let law = "Art. 1º O Município é pessoa jurídica de direito público interno.\n\n1. A autonomia política;\n2. A autonomia financeira;\n\n* Ver a Lei Orgânica.";+ let mut t = self::s();+ run(&mut t, "edit.paste", json!({"text": law}));+ assert_eq!(text(&t), law);+ assert!(t.doc.body.iter().filter_map(|b| b.as_para()).all(|p| p.props.numbering.is_none()));+}++/// Headings pasted from Markdown keep their styles, outline levels and emoji through a save and+/// a reopen: the Navigator derives its tree from those levels, so the outline survives the file.+#[test]+fn pasted_headings_keep_their_outline_and_emoji_after_saving() {+ let mut s = s();+ run(&mut s, "edit.paste", json!({"text": "## 1️⃣ Tema central\n\n### 📘 Conceito Básico\n\n## 2️⃣ Resumo teórico\n"}));+ assert_eq!(text(&s), "1️⃣ Tema central\n📘 Conceito Básico\n2️⃣ Resumo teórico");+ let dir = std::env::temp_dir().join(format!("wc-outline-{}", std::process::id()));+ std::fs::create_dir_all(&dir).unwrap();+ let path = dir.join("outline.docx");+ run(&mut s, "file.saveAs", json!({"path": path.to_str().unwrap()}));+ let mut t = self::s();+ run(&mut t, "file.open", json!({"path": path.to_str().unwrap()}));+ let outline: Vec<Option<u8>> = t+ .doc+ .body+ .iter()+ .filter_map(|b| b.as_para())+ .filter(|p| p.text.trim() != "")+ .map(|p| t.doc.styles.resolve_para(&p.props).outline_level)+ .collect();+ assert_eq!(outline, [Some(1), Some(2), Some(1)], "Heading 2, Heading 3, Heading 2");+ assert!(text(&t).contains("1\u{FE0F}\u{20E3}"), "the keycap code points survive the file: {:?}", text(&t));+ assert!(text(&t).contains("📘"), "the marker emoji survives the file: {:?}", text(&t));+ let _ = std::fs::remove_dir_all(&dir);+}+ /// A CF_HTML ("HTML Format") clipboard payload around `html`, with the byte offsets Windows writes. fn cf_html(html: &str) -> String { let n = "Version:1.0\r\nStartHTML:0000000000\r\nEndHTML:0000000000\r\nStartFragment:0000000000\r\nEndFragment:0000000000\r\n".len();