Keep inline content and formatting in Convert Text to Table (#3937)

Fixes #3751

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
NetterSilver
2026-10-23 12:37:16 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 76988a9902
commit dbee608dca
2 changed files with 97 additions and 5 deletions
+68 -5
View File
@@ -1285,6 +1285,67 @@ pub fn text_table_cells(lines: &[String], sep: Option<char>, columns: Option<usi
(rows, cols)
}
/// The cells Convert Text to Table makes of `paras`, row by row, and the column count, as
/// [`text_table_cells`] makes them of their text. Each cell is its field of the source paragraph
/// cut out of it, trimmed of surrounding white space, so its formatting, objects (pictures,
/// fields, notes…) and tracked changes go with it.
fn text_table_paras(paras: Vec<Paragraph>, sep: Option<char>, columns: Option<usize>) -> Result<(Vec<Vec<Paragraph>>, 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<Vec<std::ops::Range<usize>>> = 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<Paragraph>> = Vec::new();
let mut row: Vec<Paragraph> = 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<Paragraph> = (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)];
}
}
}
+29
View File
@@ -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.