diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 9fc82e39f..3087b991e 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -1285,6 +1285,67 @@ pub fn text_table_cells(lines: &[String], sep: Option, columns: Option, sep: Option, columns: Option) -> Result<(Vec>, usize), CmdError> { + use wordcraft_doc::para::OBJ; + use wordcraft_doc::table::MAX_COLS; + // Each paragraph's fields as byte ranges; an object is never a separator. + let fields: Vec>> = paras + .iter() + .map(|p| { + let mut out = Vec::new(); + let mut from = 0; + if let Some(sep) = sep.filter(|c| *c != OBJ) { + for (i, c) in p.text.char_indices().filter(|(_, c)| *c == sep) { + out.push(from..i); + from = i + c.len_utf8(); + } + } + out.push(from..p.text.len()); + out + }) + .collect(); + let cols = match sep { + None => columns.unwrap_or(1).clamp(1, MAX_COLS), + Some(_) => columns.unwrap_or_else(|| fields.iter().map(Vec::len).max().unwrap_or(1)).max(1), + }; + let per_row = cols.min(MAX_COLS); + let mut rows: Vec> = Vec::new(); + let mut row: Vec = Vec::new(); + for (mut work, ranges) in paras.into_iter().zip(fields) { + // The section break stays out of the cells. + work.section = None; + let mut pieces = Vec::with_capacity(ranges.len()); + // Last field first, so the earlier offsets stay valid. + for r in ranges.into_iter().rev() { + let field = work.text.get(r.clone()).unwrap_or(""); + let start = r.start + (field.len() - field.trim_start().len()); + let end = (r.start + field.trim_end().len()).max(start); + let mut piece = work.split_off(start)?; + piece.split_off(end - start)?; + pieces.push(piece); + } + pieces.reverse(); + for piece in pieces { + row.push(piece); + if row.len() == per_row { + rows.push(std::mem::take(&mut row)); + } + } + // With a separator, each paragraph starts a row. + if sep.is_some() && !row.is_empty() { + rows.push(std::mem::take(&mut row)); + } + } + if !row.is_empty() { + rows.push(row); + } + Ok((rows, cols)) +} + /// Word's largest Initial column width, 22 in, in points. const MAX_INITIAL_WIDTH: f64 = 22.0 * 72.0; @@ -1316,8 +1377,10 @@ fn from_text(s: &mut Session, v: &Value) -> CmdResult { }; let columns = p::u64(v, "columns").map(|n| n.clamp(1, wordcraft_doc::table::MAX_COLS as u64) as usize); let (a, b) = s.sel.ordered(); - let lines = text_table_lines(s)?; - let (cells, cols) = text_table_cells(&lines, sep, columns); + // Checks the selection: only paragraphs, a table between them is refused. + text_table_lines(s)?; + let paras: Vec = (a.path.last()..=b.path.last()).filter_map(|i| s.doc.para(a.story, &a.path.with_last(i)).cloned()).collect(); + let (cells, cols) = text_table_paras(paras, sep, columns)?; // A table has at most 63 columns; a longer row would lose its last fields. if cols > wordcraft_doc::table::MAX_COLS { return Err(CmdError::Failed("a table can have at most 63 columns".into())); @@ -1333,10 +1396,10 @@ fn from_text(s: &mut Session, v: &Value) -> CmdResult { t.grid.iter_mut().for_each(|w| *w = col_width); t.rows.iter_mut().flat_map(|row| row.cells.iter_mut()).for_each(|c| c.props.width = Some(col_width)); } - for (r, l) in cells.iter().enumerate() { - for (c, txt) in l.iter().enumerate() { + for (r, l) in cells.into_iter().enumerate() { + for (c, para) in l.into_iter().enumerate() { if let Some(cl) = t.rows.get_mut(r).and_then(|row| row.cells.get_mut(c)) { - cl.blocks = vec![para_block(Paragraph::with_text(txt.trim(), Default::default()))]; + cl.blocks = vec![para_block(para)]; } } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index a91e71efe..1b5406c2b 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -6350,6 +6350,35 @@ fn convert_text_to_table_refuses_to_drop_content() { assert_eq!(s.undo_depth(), depth); } +#[test] +fn convert_text_to_table_keeps_inline_content() { + // Issue #3751: the cells were rebuilt from plain text, losing formatting, pictures, links + // and tracked deletions. + use wordcraft_doc::para::InlineObject; + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "Name\t\tLink\tOld"})); + { + let p = s.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap(); + p.format(0, 4, &|c| c.bold = Some(true)).unwrap(); + p.format(6, 10, &|c| c.link = Some("https://example.com".into())).unwrap(); + p.format(11, 14, &|c| c.del = Some(1)).unwrap(); + let img = + InlineObject::Image { media: "pic".into(), w: 10.0, h: 10.0, alt: String::new(), float: Default::default(), crop: [0.0; 4], ole: None }; + p.insert_object(5, img, &Default::default()).unwrap(); + } + run(&mut s, "select.all", json!({})); + run(&mut s, "table.fromText", json!({})); + let t = s.doc.body.iter().find_map(|b| b.as_table()).expect("a table"); + let cell = |c: usize| t.rows[0].cells[c].blocks.iter().find_map(|b| b.as_para()).expect("a paragraph"); + assert_eq!(cell(0).text, "Name"); + assert_eq!(cell(0).props_of_char(0).bold, Some(true), "cell 1 lost bold"); + assert!(matches!(cell(1).objects.as_slice(), [InlineObject::Image { media, .. }] if media == "pic"), "cell 2 lost its picture"); + assert_eq!(cell(2).text, "Link"); + assert_eq!(cell(2).props_of_char(0).link.as_deref(), Some("https://example.com"), "cell 3 lost its link"); + assert_eq!(cell(3).text, "Old"); + assert_eq!(cell(3).props_of_char(0).del, Some(1), "cell 4 lost its tracked deletion"); +} + #[test] fn delete_columns_deletes_every_selected_column() { // Issue #431: only the selection's focus column went.