collective

Browse / github.com/storytold/wordcraft / patch

Copy: numbered paragraphs reach other programs as an HTML list, as Word's copy does

Copy: numbered paragraphs reach other programs as an HTML list, as Word's copy does Refs #2015. ### What Copying numbered or bulleted paragraphs and pasting them into Word (or Mail, Pages, a browser) loses the numbering: the pasted paragraphs come out unnumbered. Copy builds its clipboard HTML from the copy as a document of its own (`Fragment::to_document`), which has none of the source's list definitions, so `model::from_doc` sees no lists and the exporter writes plain `<p>`s. #2022 and #2023 put the labels into the clipboard's **plain text**. Word, Mail and most editors paste the **HTML** when it's there, so the numbering is still lost in them; this PR is the HTML side a

Status
open
Score
0.00
Adopters
0 other operators
Author
jpc7k1 (imported from pull request #4485, open)
Base
6a2b520fa49aa0d0595cea36589aaf16ca371cd3
Size
+137 −3 in 4 files
Review
pending
Shared
2026-10-28
Id
d55652661bae8c640cb2e4be

How it went

No other operator has reported using it yet.

Diff

raw · crates/engine/src/cmd/edit.rs crates/engine/src/tests.rs crates/formats/src/html.rs crates/formats/src/model.rs

diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs--- a/crates/engine/src/cmd/edit.rs+++ b/crates/engine/src/cmd/edit.rs@@ -183,7 +183,7 @@ fn copy(s: &mut Session, _: &Value) -> CmdResult { /// and for a copy of one equation alone, its MathML (#1297). pub(crate) fn copy_result(s: &mut Session) -> Value {     s.clipboard_html = match &s.clipboard {-        Some(f) => wordcraft_formats::html::export_fragment(&f.to_document()),+        Some(f) => wordcraft_formats::html::export_fragment(&clipboard_document(s, f)),         None => String::new(),     };     let mut r = json!({"text": s.clipboard_text});@@ -200,6 +200,53 @@ pub(crate) fn copy_result(s: &mut Session) -> Value {     r } +/// The copy as a document, for the HTML Copy puts on the system clipboard: the fragment with the+/// source's styles and list definitions, so its numbered and bulleted paragraphs stay lists when+/// another program pastes them (Word turns the HTML list back into numbering), and with each+/// copied list starting at the number it has in the source, counted as the page shows it: a copy+/// of items 3 and 4 reads 3 and 4, as Word's does. The first copied item of each list and level+/// gets a list of its own that starts there, so a later list at that level, under the next item,+/// still starts at its own start.+fn clipboard_document(s: &Session, f: &Fragment) -> wordcraft_doc::Document {+    let mut d = f.to_document();+    d.styles = s.doc.styles.clone();+    d.numbering = s.doc.numbering.clone();+    let (a, b) = s.sel.ordered();+    // Copied body blocks map one to one onto the source's from the first: only a copy that starts+    // in a top-level paragraph of the body.+    let Some(first) = (a.story == StoryRef::Body && a.path.depth() == 0).then(|| a.path.last()) else { return d };+    let mut counters = wordcraft_doc::numbering::Counters::default();+    let mut seen = std::collections::HashSet::new();+    for path in s.doc.para_paths(StoryRef::Body) {+        if path > b.path {+            break;+        }+        let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue };+        let Some(n) = s.doc.styles.resolve_para(&p.props).numbering.filter(|n| n.num != 0) else { continue };+        if counters.next_label(&s.doc.numbering, n.num, n.level).is_none() || path < a.path || !seen.insert((n.num, n.level)) {+            continue;+        }+        let Some(value) = counters.values(&s.doc.numbering, n.num).and_then(|v| v.get(n.level as usize).copied()) else { continue };+        let Some(own) = d.numbering.restart(n.num) else { continue };+        // Its level starts at its number, and the levels above it at their current numbers: they+        // stand for the items above it the copy leaves out.+        let current = counters.values(&s.doc.numbering, n.num).unwrap_or_default();+        if let Some(num) = d.numbering.nums.iter_mut().find(|x| x.id == own) {+            num.start_overrides.retain(|(l, _)| *l > n.level);+            num.start_overrides.extend((0..n.level).filter_map(|k| current.get(k as usize).map(|v| (k, (*v).max(1)))));+            num.start_overrides.push((n.level, value));+        }+        let mut at = path.clone();+        if let Some(top) = at.0.first_mut() {+            *top -= first as u32;+        }+        if let Ok(q) = d.para_mut(StoryRef::Body, &at) {+            q.props.numbering = Some(wordcraft_doc::props::NumRef { num: own, level: n.level });+        }+    }+    d+}+ fn cut(s: &mut Session, v: &Value) -> CmdResult {     let r = copy(s, v)?;     // A tracked cut of a paragraph or sentence may become a move when pasted.diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs--- a/crates/engine/src/tests.rs+++ b/crates/engine/src/tests.rs@@ -1309,6 +1309,40 @@ fn copying_bold_text_puts_html_on_the_clipboard() {     assert_eq!(s.clipboard_html, html); } +/// Copied numbered paragraphs reach other programs as an HTML list (Word turns it back into+/// numbering when it pastes; it pasted them unnumbered): with the levels' number formats, and+/// starting at the numbers the copied items have, the levels above a copy that starts inside a+/// sub-list included, as Word's own copy does.+#[test]+fn copying_numbered_paragraphs_puts_an_html_list_on_the_clipboard() {+    let mut s = s();+    run(&mut s, "document.setText", json!({"text": "One\nTwo\nSub A\nSub B\nThree"}));+    run(&mut s, "select.all", json!({}));+    run(&mut s, "para.numbering", json!({}));+    for k in [2, 3] {+        s.sel = crate::Selection::caret(Pos::body(k, 0));+        run(&mut s, "para.listLevel", json!({"level": 1}));+    }+    let copied = |s: &mut Session, from: usize| {+        s.sel = crate::Selection { anchor: Pos::body(from, 0), focus: Pos::body(4, 5) };+        let r = run(s, "edit.copy", json!({}));+        r["html"].as_str().unwrap_or_default().replace('\n', "")+    };+    let num = s.doc.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().props.numbering.unwrap().num;+    let sub = match s.doc.numbering.level(num, 1).map(|l| l.format) {+        Some(wordcraft_doc::section::NumFormat::LowerLetter) => " type=\"a\"",+        Some(wordcraft_doc::section::NumFormat::LowerRoman) => " type=\"i\"",+        _ => "",+    };+    let all = copied(&mut s, 0);+    assert!(all.contains(&format!("<ol><li>One</li><li>Two<ol{sub}><li>Sub A</li><li>Sub B</li></ol></li><li>Three</li></ol>")), "{all}");+    let from_two = copied(&mut s, 1);+    assert!(from_two.contains(&format!("<ol start=\"2\"><li>Two<ol{sub}><li>Sub A</li>")), "{from_two}");+    // From the second sub-item: under item 2, starting at its second number, then item 3.+    let from_sub_b = copied(&mut s, 3);+    assert!(from_sub_b.contains(&format!("<ol start=\"2\"><li><ol{sub} start=\"2\"><li>Sub B</li></ol></li><li>Three</li></ol>")), "{from_sub_b}");+}+ #[test] fn copying_symbol_font_text_puts_the_unicode_symbols_on_the_clipboard() {     let mut s = s();diff --git a/crates/formats/src/html.rs b/crates/formats/src/html.rs--- a/crates/formats/src/html.rs+++ b/crates/formats/src/html.rs@@ -16,6 +16,7 @@ use std::sync::Arc;  use wordcraft_doc::props::VAlign;+use wordcraft_doc::section::NumFormat; use wordcraft_doc::{Align, Document};  use crate::model::{@@ -1858,7 +1859,19 @@ fn blocks_html(blocks: &[FBlock], out: &mut String, depth: usize) {                             out.push_str("<li>");                             top.1 = true;                         }-                        out.push_str(if li.ordered { "<ol>" } else { "<ul>" });+                        // The innermost list opens with the item's number format and start, as+                        // a browser and Word read them (`<ol type="a" start="3">`).+                        let innermost = stack.len() == lv;+                        // A list opening above it (the copy leaves its item out) takes that+                        // level's start and format.+                        out.push_str(&match (li.ordered, innermost) {+                            (true, true) => format!("<ol{}>", ol_attrs(li.start, q.list_format)),+                            (true, false) => match q.list_outer.get(stack.len()) {+                                Some((start, fmt)) => format!("<ol{}>", ol_attrs(*start, *fmt)),+                                None => "<ol>".to_string(),+                            },+                            (false, _) => "<ul>".to_string(),+                        });                         stack.push((li.ordered, false));                     }                     if let Some(top) = stack.last_mut() {@@ -1914,6 +1927,23 @@ fn blocks_html(blocks: &[FBlock], out: &mut String, depth: usize) {     } } +/// An ordered list's `type` (letters and roman numerals; decimal is the default) and `start`+/// (when not 1).+fn ol_attrs(start: u32, format: Option<NumFormat>) -> String {+    let ty = match format {+        Some(NumFormat::LowerLetter) => Some("a"),+        Some(NumFormat::UpperLetter) => Some("A"),+        Some(NumFormat::LowerRoman) => Some("i"),+        Some(NumFormat::UpperRoman) => Some("I"),+        _ => None,+    };+    let mut a = ty.map(|t| format!(" type=\"{t}\"")).unwrap_or_default();+    if start != 1 {+        a.push_str(&format!(" start=\"{start}\""));+    }+    a+}+ fn close_list(stack: &mut Vec<(bool, bool)>, out: &mut String) {     if let Some((ordered, li_open)) = stack.pop() {         if li_open {diff --git a/crates/formats/src/model.rs b/crates/formats/src/model.rs--- a/crates/formats/src/model.rs+++ b/crates/formats/src/model.rs@@ -241,6 +241,12 @@ pub struct Spacing { pub struct Para {     pub kind: Kind,     pub list: Option<ListInfo>,+    /// The number format of an ordered list item that a document's numbering gives it (letters,+    /// roman numerals…): HTML writes it as the list's `type`, so a paste keeps "a." or "i.".+    pub list_format: Option<NumFormat>,+    /// For an ordered list item, the start and number format of each level above it, for a list+    /// that opens straight at a deeper level (a copy that starts under an item it leaves out).+    pub list_outer: Vec<(u32, Option<NumFormat>)>,     pub align: Option<Align>,     pub page_break: bool,     /// Keep the paragraph on the same page as the next one (ODT `fo:keep-with-next`).@@ -1030,7 +1036,9 @@ fn props_kind(doc: &Document, props: &ParaProps, blank: bool) -> (Kind, Option<L     };     let list = rp.numbering.and_then(|n| {         let lv = doc.numbering.level(n.num, n.level)?;-        Some(ListInfo { ordered: !matches!(lv.format, NumFormat::Bullet | NumFormat::None), level: n.level.min(8), start: lv.start })+        // A list's own start override for the level wins over its definition's start.+        let start = doc.numbering.num(n.num).and_then(|x| x.start_overrides.iter().find(|(l, _)| *l == n.level)).map_or(lv.start, |(_, s)| *s);+        Some(ListInfo { ordered: !matches!(lv.format, NumFormat::Bullet | NumFormat::None), level: n.level.min(8), start })     });     (kind, list) }@@ -1058,9 +1066,22 @@ fn flow_paras_in(doc: &Document, p: &Paragraph, tracked: bool) -> Vec<Para> {     let (kind, list) = para_kind(doc, p);     let mut done: Vec<Para> = Vec::new();     let borders = if kind == Kind::Rule { None } else { p.props.borders.filter(|b| b.any_visible()) };+    let numbered = list.filter(|l| l.ordered).and_then(|_| doc.styles.resolve_para(&p.props).numbering);+    let list_format = numbered.and_then(|n| doc.numbering.level(n.num, n.level).map(|lv| lv.format));+    let list_outer = numbered.map_or_else(Vec::new, |n| {+        (0..n.level.min(8))+            .map(|k| {+                let lv = doc.numbering.level(n.num, k);+                let own = doc.numbering.num(n.num).and_then(|x| x.start_overrides.iter().find(|(l, _)| *l == k)).map(|(_, s)| *s);+                (own.or(lv.map(|l| l.start)).unwrap_or(1), lv.map(|l| l.format))+            })+            .collect()+    });     let mut out = Para {         kind,         list,+        list_format,+        list_outer,         align: p.props.align,         page_break: p.props.page_break_before.unwrap_or(false),         keep_next: p.props.keep_next.unwrap_or(false),@@ -1092,6 +1113,8 @@ fn flow_paras_in(doc: &Document, p: &Paragraph, tracked: bool) -> Vec<Para> {                     let next = Para {                         kind,                         list: None,+                        list_format: None,+                        list_outer: Vec::new(),                         align: p.props.align,                         page_break: true,                         keep_next: p.props.keep_next.unwrap_or(false),