mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-31 05:47:00 -06:00
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:
co-authored by
Claude Opus 5.5
parent
76988a9902
commit
dbee608dca
@@ -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)];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user