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
@@ -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.@@ -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();@@ -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 {@@ -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),