Merge upstream/main into mcp-insert-table-2242

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76
2026-11-01 13:20:55 +01:00
co-authored by Claude Haiku 5.5
174 changed files with 5781 additions and 603 deletions
+3 -2
View File
@@ -273,8 +273,9 @@ fn set(s: &mut Session, v: &Value) -> CmdResult {
} else if let (Some(pg), Some(x), Some(y)) = (p::u64(v, "page"), p::f32(v, "x"), p::f32(v, "y")) {
let story = super::story_param(s, v);
let l = s.layout();
s.page_hint = pg as usize;
l.hit(pg as usize, x, y, story).ok_or_else(|| CmdError::Params("no text on that page".into()))?
let page = usize::try_from(pg).unwrap_or(usize::MAX);
s.page_hint = page;
l.hit(page, x, y, story).ok_or_else(|| CmdError::Params("no text on that page".into()))?
} else {
return Err(CmdError::Params("`pos` or `page`/`x`/`y` required".into()));
};
@@ -1,5 +1,5 @@
use super::generated::page_of;
use super::{CharProps, CmdError, CmdResult, InlineObject, Paragraph, Pos, Selection, Session, StoryRef, Value, delete_selection, json, p};
use super::{CharProps, CmdError, CmdResult, Document, InlineObject, Paragraph, Pos, Selection, Session, StoryRef, Value, delete_selection, json, p};
pub(super) fn cross_ref(s: &mut Session, v: &Value) -> CmdResult {
let to = p::str(v, "to").unwrap_or("heading");
@@ -77,13 +77,19 @@ pub(super) fn caption_label(p: &Paragraph, text: &str) -> Option<String> {
(!label.is_empty() && label.chars().count() <= 40).then_some(label)
}
/// Whether `p` is a caption: its style is Caption or one based on it, as a caption restyled with a
/// style of its own (a `Table Caption` based on Caption) still is (#4411).
pub(super) fn is_caption(doc: &Document, p: &Paragraph) -> bool {
p.props.style.as_deref().is_some_and(|id| id == "Caption" || doc.styles.chain(id).iter().any(|s| s.id == "Caption"))
}
/// The caption paragraphs of the body: (text, position, label).
fn captions(s: &Session) -> Vec<(String, Pos, Option<String>)> {
s.doc
.para_paths(StoryRef::Body)
.into_iter()
.filter_map(|path| {
let p = s.doc.para(StoryRef::Body, &path).filter(|p| p.props.style.as_deref() == Some("Caption"))?;
let p = s.doc.para(StoryRef::Body, &path).filter(|p| is_caption(&s.doc, p))?;
let text = p.plain_text();
let label = caption_label(p, &text);
Some((text, Pos { story: StoryRef::Body, path, off: 0 }, label))
@@ -125,7 +131,7 @@ pub fn ref_targets(s: &Session, to: &str) -> Vec<(String, Pos)> {
};
for path in s.doc.para_paths(StoryRef::Body) {
if let Some(p) = s.doc.para(StoryRef::Body, &path)
&& p.props.style.as_deref() == Some("Caption")
&& is_caption(&s.doc, p)
&& p.plain_text().starts_with(label)
{
targets.push((p.plain_text(), Pos { story: StoryRef::Body, path: path.clone(), off: 0 }));
+7 -7
View File
@@ -1,10 +1,10 @@
use super::cross_refs::caption_label;
use super::cross_refs::{caption_label, is_caption};
use super::render::entry;
use super::sources::bib_order;
use super::styles::style;
use super::{
Block, CharProps, CmdError, CmdResult, FieldCode, InlineObject, Paragraph, Path, Pos, Session, SortKey, StoryRef, TabAlign, TabLeader, TabStop,
Value, delete_selection, heading_letter, p, sel_result, sort_key,
Block, CharProps, CmdError, CmdResult, Document, FieldCode, InlineObject, Paragraph, Path, Pos, Session, SortKey, StoryRef, TabAlign, TabLeader,
TabStop, Value, delete_selection, heading_letter, p, sel_result, sort_key,
};
/// The TOC field for `references.tableOfFigures` (ECMA-376 §17.16.5.68): `\c "<label>"` lists the
@@ -86,8 +86,8 @@ fn figures_label(instr: &str) -> Option<String> {
/// Whether `p` is a caption numbered by SEQ identifier `ident` (any case, as Word matches it). A
/// caption typed by hand, without a SEQ field, goes by its label.
fn captioned_as(p: &Paragraph, ident: &str) -> bool {
if p.props.style.as_deref() != Some("Caption") {
fn captioned_as(doc: &Document, p: &Paragraph, ident: &str) -> bool {
if !is_caption(doc, p) {
return false;
}
let seq = p.objects.iter().find_map(|o| match o {
@@ -256,7 +256,7 @@ fn entries_for(s: &mut Session, kind: &str) -> Vec<(String, bool)> {
for path in s.doc.para_paths(StoryRef::Body) {
let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue };
if let Some(ident) = &figures {
if captioned_as(p, ident) {
if captioned_as(&s.doc, p, ident) {
let text = if bare { caption_body(p, &ident.replace('_', " ")) } else { p.plain_text() };
marks.push((text, Pos { story: StoryRef::Body, path: path.clone(), off: 0 }));
}
@@ -280,7 +280,7 @@ fn entries_for(s: &mut Session, kind: &str) -> Vec<(String, bool)> {
for path in s.doc.para_paths(StoryRef::Body) {
let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue };
if let Some(ident) = &figures {
if captioned_as(p, ident) {
if captioned_as(&s.doc, p, ident) {
v.push(Pos { story: StoryRef::Body, path: path.clone(), off: 0 });
}
continue;
+14 -7
View File
@@ -1,6 +1,12 @@
use super::Source;
use super::styles::{Cite, Name, Names, style};
/// A source's corporate author, when it has one: written as it is in place of the person names
/// (Word's Corporate Author, #4848).
pub(super) fn corporate(src: &Source) -> Option<&str> {
Some(src.corporate.trim()).filter(|c| !c.is_empty())
}
pub(super) fn authors(a: &str) -> Vec<(String, String)> {
a.split(';')
.map(str::trim)
@@ -110,12 +116,13 @@ pub fn cite(src: &Source, style_name: &str, n: usize, pages: &str) -> String {
let st = style(style_name);
let au = authors(&src.author);
let lasts: Vec<&str> = au.iter().map(|a| a.0.as_str()).collect();
let who = match lasts.as_slice() {
[] => src.title.clone(),
[one] => one.to_string(),
[first, ..] if lasts.len() >= st.et_al_from.max(2) => format!("{first} et al."),
[a, b] => format!("{a} {} {b}", st.and),
[init @ .., z] => format!("{} {} {z}", init.join(", "), st.and),
let who = match (corporate(src), lasts.as_slice()) {
(Some(c), _) => c.to_string(),
(None, []) => src.title.clone(),
(None, [one]) => one.to_string(),
(None, [first, ..]) if lasts.len() >= st.et_al_from.max(2) => format!("{first} et al."),
(None, [a, b]) => format!("{a} {} {b}", st.and),
(None, [init @ .., z]) => format!("{} {} {z}", init.join(", "), st.and),
};
match st.cite {
Cite::AuthorPage if pages.is_empty() => format!("({who})"),
@@ -148,7 +155,7 @@ pub fn entry(src: &Source, style_name: &str, n: usize) -> String {
let field = |key: &str| -> String {
match key {
"n" => n.to_string(),
"names" => name_list(&au, &st.names),
"names" => corporate(src).map_or_else(|| name_list(&au, &st.names), str::to_string),
"year" if src.year.is_empty() => "n.d.".into(),
"year" => src.year.clone(),
"title" => src.title.clone(),
+14 -8
View File
@@ -1,5 +1,5 @@
use super::generated::update_generated;
use super::render::{authors, cite};
use super::render::{authors, cite, corporate};
use super::styles::{CiteStyle, Order, style};
use super::{CmdError, CmdResult, Document, InlineObject, Selection, Session, Source, StoryRef, Value, delete_selection, json, p, sort_key};
@@ -14,18 +14,19 @@ fn auto_tag(doc: &Document, src: &Source) -> String {
(1u32..).map(|n| format!("{base}{n}")).find(|t| !taken(t)).unwrap_or(base)
}
/// The author of a source for matching untagged entries: the editor when there is no author.
/// The author of a source for matching untagged entries: the corporate author (which takes the
/// author's place when a source is saved), else the author, else the editor.
#[cfg(not(target_arch = "wasm32"))]
fn by_author(x: &Source) -> &str {
if x.author.is_empty() { x.editor.as_str() } else { x.author.as_str() }
[x.corporate.trim(), x.author.as_str(), x.editor.as_str()].into_iter().find(|w| !w.is_empty()).unwrap_or_default()
}
/// The stem of a new source's tag, before `1`, `2`… are added while it is taken: the first three
/// alphanumerics of the first author's name (nothing of the author's, `Src`) and the last two
/// digits of the year.
/// alphanumerics of the first author's name or the corporate author (nothing of the author's,
/// `Src`) and the last two digits of the year.
fn auto_tag_base(src: &Source) -> String {
let name: String =
authors(&src.author).first().map(|x| x.0.clone()).unwrap_or_default().chars().filter(|c| c.is_alphanumeric()).take(3).collect();
let first = corporate(src).map(str::to_string).or_else(|| authors(&src.author).first().map(|x| x.0.clone())).unwrap_or_default();
let name: String = first.chars().filter(|c| c.is_alphanumeric()).take(3).collect();
let name = if name.is_empty() { "Src".to_string() } else { name };
let digits: Vec<char> = src.year.chars().filter(char::is_ascii_digit).collect();
let year: String = digits.get(digits.len().saturating_sub(2)..).unwrap_or_default().iter().collect();
@@ -184,8 +185,13 @@ fn cited_tags(doc: &Document) -> Vec<String> {
out
}
/// A source's place in an author-sorted bibliography: the corporate author, else the first
/// author's surname, else the title.
fn author_key(src: &Source) -> String {
authors(&src.author).first().map(|a| a.0.to_lowercase()).unwrap_or_else(|| src.title.to_lowercase())
corporate(src)
.map(str::to_lowercase)
.or_else(|| authors(&src.author).first().map(|a| a.0.to_lowercase()))
.unwrap_or_else(|| src.title.to_lowercase())
}
/// Indices into `doc.sources` in bibliography order: the cited sources (every source while
+4 -2
View File
@@ -103,8 +103,10 @@ pub static STYLES: [CiteStyle; 12] = [
et_al_from: 3,
order: Order::Author,
names: names(Name::LastInitials, Name::LastInitials, ", ", ", & ", ", & "),
book: "⟦{names} ⟧({year}). {title}.⟦ {publisher}.⟧⟦ {url}⟧",
article: "⟦{names} ⟧({year}). {title}. {journal}⟦, {volume}⟧⟦, {pages}⟧.⟦ {url}⟧",
// The names end with a full stop: an initial's, or one added after a group author or a
// name without initials ("World Health Organization. (2021).", #4848).
book: "⟦{names}. ⟧({year}). {title}.⟦ {publisher}.⟧⟦ {url}⟧",
article: "⟦{names}. ⟧({year}). {title}. {journal}⟦, {volume}⟧⟦, {pages}⟧.⟦ {url}⟧",
bib_title: "References",
},
CiteStyle {
+86
View File
@@ -18,6 +18,49 @@ fn citations_and_bibliography() {
assert!(s.doc.plain_text(StoryRef::Body).contains("[1]"));
}
/// #4848: a corporate author is cited and listed as written, in every style: never turned into
/// a surname and initials ("Organization, W. H."), never left out for the title. Its tag and its
/// place in an author-sorted list come from the name too.
#[test]
fn a_corporate_author_is_cited_and_listed_as_written() {
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("text.insert", &json!({"text": "Hearing loss is common "})).unwrap();
let who = "World Health Organization";
s.run("references.citation", &json!({"source": {"kind": "report", "corporate": who, "title": "World report on hearing", "year": "2021", "publisher": "WHO Press", "city": "Geneva"}}))
.unwrap();
s.run("text.insert", &json!({"text": " and rising "})).unwrap();
s.run("references.citation", &json!({"source": {"author": "Adams, Kim", "title": "Quiet Rooms", "year": "2019"}})).unwrap();
s.run("text.newParagraph", &json!({})).unwrap();
s.run("references.bibliography", &json!({})).unwrap();
assert_eq!(s.doc.sources[0].tag, "Wor21", "the tag starts with the corporate name");
let t = s.doc.plain_text(StoryRef::Body);
assert!(t.contains(&format!("({who}, 2021)")), "{t}");
assert!(t.contains(&format!("{who}. (2021). World report on hearing. WHO Press.")), "{t}");
// Sorted by the corporate name: Adams before World.
let adams = t.find("Adams, K. (2019)").unwrap_or(usize::MAX);
let world = t.find(&format!("{who}. (2021)")).unwrap_or(0);
assert!(adams < world, "{t}");
for style in [
"MLA",
"Chicago",
"GB/T 7714",
"GOST (name sort)",
"GOST (title sort)",
"ISO 690 (author-date)",
"ISO 690 (numerical)",
"Harvard (Anglia)",
"Turabian",
"IEEE",
"SIST02",
] {
s.run("references.citationStyle", &json!({"style": style})).unwrap();
let t = s.doc.plain_text(StoryRef::Body);
let entry = t.lines().find(|l| l.contains("World report on hearing")).unwrap_or_default();
assert!(entry.contains(who), "{style}: {entry}");
assert!(!entry.contains("Organization, W") && !entry.contains("ORGANIZATION, W"), "{style}: {entry}");
}
}
/// Updating fields in a document without generated lists leaves its styles alone: a new
/// style would make the next layout start over from nothing.
#[test]
@@ -162,6 +205,49 @@ fn caption_labels_do_not_need_a_digit() {
assert!(s.doc.plain_text(StoryRef::Body).ends_with("See Photo Plate I"), "{}", s.doc.plain_text(StoryRef::Body));
}
/// #4411: captions restyled with a style based on Caption, directly (`Caption Table`) or through
/// another (`Caption Table Wide`), are still captions after a DOCX save and reopen: every `tableX`
/// caption is a cross-reference target and in the table of figures, and a Table caption is a
/// Table target. Captions in a style not based on Caption are not, nor are their labels.
#[test]
fn captions_in_a_style_based_on_caption() {
let mut s = Session::new(wordcraft_doc::Document::from_text("T1\nT2\nT3\nT4\nT5\nNote\nGrid\nStone\nSee "));
let caption = |s: &mut Session, text: &str, label: &str, title: &str| {
s.run("select.text", &json!({"text": text})).unwrap();
s.run("references.caption", &json!({"label": label, "text": title})).unwrap();
};
for k in 1..=5 {
caption(&mut s, &format!("T{k}"), "tableX", &format!("Row {k}"));
}
caption(&mut s, "Note", "tableX", "Aside");
caption(&mut s, "Grid", "Table", "Grid");
caption(&mut s, "Stone", "Plate", "Stone");
s.run("styles.create", &json!({"name": "Caption Table", "basedOn": "Caption", "fromSelection": false})).unwrap();
s.run("styles.create", &json!({"name": "Caption Table Wide", "basedOn": "Caption Table", "fromSelection": false})).unwrap();
s.run("styles.create", &json!({"name": "Aside", "fromSelection": false})).unwrap();
let style = |s: &Session, name: &str| s.doc.styles.find(name).map(|st| st.id.clone());
let (table, wide, aside) = (style(&s, "Caption Table"), style(&s, "Caption Table Wide"), style(&s, "Aside"));
for path in s.doc.para_paths(StoryRef::Body) {
let p = s.doc.para_mut(StoryRef::Body, &path).unwrap();
let text = p.plain_text();
p.props.style = match text.get(..8).unwrap_or_default() {
"tableX 1" | "tableX 2" => table.clone(),
"tableX 3" | "Table 1:" => wide.clone(),
"tableX 6" | "Plate 1:" => aside.clone(),
_ => continue,
};
}
let bytes = wordcraft_docx::write(&s.doc).expect("docx write");
let mut s = Session::new(wordcraft_docx::read(&bytes).expect("docx read"));
let want: Vec<String> = (1..=5).map(|k| format!("tableX {k}: Row {k}")).collect();
assert_eq!(s.run("insert.crossReference", &json!({"to": "label:tableX"})).unwrap(), json!(want));
assert_eq!(s.run("insert.crossReference", &json!({"to": "table"})).unwrap(), json!(["Table 1: Grid"]));
assert_eq!(caption_labels(&s), ["tableX", "Table"]);
s.run("caret.docEnd", &json!({})).unwrap();
s.run("references.tableOfFigures", &json!({"label": "tableX", "pageNumbers": false})).unwrap();
assert_eq!(list_entries(&s).into_iter().map(|(t, _)| t).collect::<Vec<_>>(), want);
}
/// The entries of every generated list, in order, with their last tab stop's leader.
fn list_entries(s: &Session) -> Vec<(String, Option<TabLeader>)> {
s.doc
+2
View File
@@ -180,6 +180,8 @@ pub(crate) fn dispatch(s: &mut Session, id: &str, mutates: bool, run: fn(&mut Se
// Whole cells: table commands take the rectangle between the corners, and Tab moves on
// from it without clearing it, as in Word.
_ if cells && (id.starts_with("table.") || matches!(id, "text.tab" | "text.backTab")) => run(s, v),
// Copy and Cut of whole cells take a table of them (#1744).
"edit.copy" | "edit.cut" if cells => run(s, v),
"edit.copy" => copy(s, &segs),
"edit.cut" => {
let r = copy(s, &segs)?;
+50 -6
View File
@@ -48,10 +48,10 @@ fn scan(s: &Session) -> (HashMap<u32, Pos>, Vec<Pos>, Vec<Pos>) {
let (mut ids, mut conns, mut groups) = (HashMap::new(), Vec::new(), Vec::new());
for path in s.doc.para_paths(StoryRef::Body) {
let Some(para) = s.doc.para(StoryRef::Body, &path) else { continue };
for off in para.object_offsets() {
for (off, obj) in para.objects_with_offsets() {
let pos = Pos { story: StoryRef::Body, path: path.clone(), off };
match para.object_at(off) {
Some(InlineObject::Shape { kind, extra, float, .. }) => {
match obj {
InlineObject::Shape { kind, extra, float, .. } => {
if extra.id != 0 {
ids.entry(extra.id).or_insert(pos.clone());
}
@@ -59,7 +59,7 @@ fn scan(s: &Session) -> (HashMap<u32, Pos>, Vec<Pos>, Vec<Pos>) {
conns.push(pos);
}
}
Some(InlineObject::Group { children, .. })
InlineObject::Group { children, .. }
if children.iter().any(|c| matches!(&c.obj, InlineObject::Shape { kind, extra, .. } if kind.is_connector() && (extra.start.is_some() || extra.end.is_some()))) =>
{
groups.push(pos)
@@ -91,10 +91,11 @@ pub fn reroute(s: &mut Session) -> bool {
s.touch();
}
let layout = s.layout();
let objects = layout.object_index();
let site = |e: Option<ConnEnd>, page: usize| -> Option<(f32, f32)> {
let e = e?;
let target = ids.get(&e.id)?;
let hit = layout.object(target, page)?;
let hit = objects.object(target, page)?;
let kind = match s.doc.para_at(target)?.object_at(target.off)? {
InlineObject::Shape { kind, .. } => *kind,
_ => return None,
@@ -103,7 +104,7 @@ pub fn reroute(s: &mut Session) -> bool {
};
let mut plans = Vec::new();
for pos in conns {
let Some(hit) = layout.object(&pos, s.page_hint) else { continue };
let Some(hit) = objects.object(&pos, s.page_hint) else { continue };
let Some(InlineObject::Shape { extra, float, .. }) = s.doc.para_at(&pos).and_then(|p| p.object_at(pos.off)) else { continue };
let r = hit.rect;
let (s0, e0) = ends(r.x, r.y, r.w, r.h, float.flip_h, float.flip_v);
@@ -313,6 +314,49 @@ mod tests {
}
}
/// Drawing commands must not search every page again for each glued connector.
#[test]
fn rerouting_many_pages_does_not_rescan_all_objects_per_connector() {
use wordcraft_doc::{Document, Paragraph, para_block};
let mut doc = Document::new();
doc.body = (0..8000u32)
.map(|i| {
let mut p = Paragraph::default();
p.props.page_break_before = Some(i > 0);
let id = i * 2 + 1;
for (kind, x, extra) in [
(ShapeKind::Rectangle, 100.0, ShapeExtra { id, ..Default::default() }),
(ShapeKind::Ellipse, 300.0, ShapeExtra { id: id + 1, ..Default::default() }),
(
ShapeKind::ElbowConnector,
0.0,
ShapeExtra { start: Some(ConnEnd { id, site: 3 }), end: Some(ConnEnd { id: id + 1, site: 2 }), ..Default::default() },
),
] {
let float = Float { wrap: Wrap::InFrontOfText, h_rel: Anchor::Page, v_rel: Anchor::Page, x, y: 200.0, ..Default::default() };
p.insert_object(p.len(), shape(kind, extra, float), &Default::default()).unwrap();
}
para_block(p)
})
.collect();
let mut s = Session::new(doc);
s.view.proofing = false;
s.page_hint = 0;
assert_eq!(s.layout().pages.len(), 8000);
let started = std::time::Instant::now();
assert!(reroute(&mut s));
let elapsed = started.elapsed();
eprintln!("8000-page reroute: {elapsed:?}");
assert!(elapsed < std::time::Duration::from_secs(5), "reroute took {elapsed:?}");
for path in s.doc.para_paths(StoryRef::Body) {
let para = s.doc.para(StoryRef::Body, &path).unwrap();
let InlineObject::Shape { w, h, float, .. } = para.object_at(6).unwrap() else { panic!("connector") };
assert_eq!((*w, *h, float.x, float.y), (160.0, 0.0, 140.0, 210.0));
}
s.touch();
assert!(!reroute(&mut s), "rerouting must settle after refreshing the layout");
}
/// The per-case shape/geometry of one scaled-group reroute case.
struct ScaledGroupSpec {
w: f32,
+48 -10
View File
@@ -2,7 +2,7 @@
use serde_json::{Value, json};
use wordcraft_doc::edit::Fragment;
use wordcraft_doc::{Pos, StoryRef};
use wordcraft_doc::{Path, Pos, StoryRef};
use super::{delete_selection, find_pattern, pos_json, sel_result};
use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
@@ -27,10 +27,16 @@ fn undo_to(s: &mut Session, v: &Value) -> CmdResult {
Ok(r)
}
fn has_sel(s: &Session) -> Option<&'static str> {
/// Something the Spike can collect: text (it doesn't take empty cells).
fn has_text_sel(s: &Session) -> Option<&'static str> {
if s.sel.is_collapsed() { Some("nothing is selected") } else { None }
}
/// Something to cut: text, or whole table cells (an empty cell's selection has no length, #1744).
fn has_sel(s: &Session) -> Option<&'static str> {
if s.sel.is_collapsed() && s.cell_range().is_none() { Some("nothing is selected") } else { None }
}
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("edit.undo", "Undo", "Quick Access Toolbar", |s, _| {
@@ -63,7 +69,7 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("edit.copy", "Copy", "Home › Clipboard", copy).key("Mod+C / Mod+Insert").pure(),
// Word's Spike (Ctrl+F3, Ctrl+Shift+F3; ⌘ on the Mac): the AutoText entry "Spike", so Quick
// Parts and AutoText insert it without emptying it, as in Word.
CommandSpec::new("edit.spike", "Spike", "Editing", spike).key("Mod+F3").when(has_sel),
CommandSpec::new("edit.spike", "Spike", "Editing", spike).key("Mod+F3").when(has_text_sel),
CommandSpec::new("edit.insertSpike", "Insert Spike", "Editing", insert_spike).key("Mod+Shift+F3").when(|s| {
if s.math.is_some() {
Some("leave the equation first")
@@ -167,11 +173,25 @@ pub fn specs() -> Vec<CommandSpec> {
}
fn copy(s: &mut Session, _: &Value) -> CmdResult {
let whole = super::table::selected_table(s);
copy_with(s, whole.as_ref())
}
/// Copy, `whole` being the table the selection covers whole, if it does.
fn copy_with(s: &mut Session, whole: Option<&Path>) -> CmdResult {
let (a, b) = s.sel.ordered();
if a == b {
return Ok(json!({"text": ""}));
}
let f = s.doc.copy_range(&a, &b);
// Whole table cells selected copy as a table, even empty ones (their selection has no
// length): the whole table, or a table of just the selected rows or cells (#1744).
let table = match (whole, s.cell_range()) {
(Some(tp), _) => s.doc.copy_block(a.story, tp),
(None, Some(r)) => s.doc.copy_table_cells(r.story, &r.table, r.rows, r.cols),
(None, None) => None,
};
let f = match table {
Some(f) => f,
None if a == b => return Ok(json!({"text": ""})),
None => s.doc.copy_range(&a, &b),
};
s.clipboard_text = f.clipboard_text();
s.clip_history.push(&f, &s.clipboard_text);
s.clipboard = Some(f);
@@ -200,8 +220,26 @@ pub(crate) fn copy_result(s: &mut Session) -> Value {
r
}
fn cut(s: &mut Session, v: &Value) -> CmdResult {
let r = copy(s, v)?;
fn cut(s: &mut Session, _: &Value) -> CmdResult {
let whole = super::table::selected_table(s);
let r = copy_with(s, whole.as_ref())?;
// A whole table selected: Cut removes the table, as Delete Table does (#1744). With Track
// Changes on, the cut below marks its text deleted (a tracked cut may become a move).
if !s.doc.settings.track_changes
&& let Some(tp) = &whole
{
super::table::delete_table_at(s, tp)?;
super::controls::restore_placeholder(s)?;
return Ok(r);
}
// Whole rows or some cells: their text goes, the rows and cells stay, as in Word.
if whole.is_none()
&& let Some(range) = s.cell_range().cloned()
{
super::table::clear_range_cells(s, &range)?;
super::controls::restore_placeholder(s)?;
return Ok(r);
}
// A tracked cut of a paragraph or sentence may become a move when pasted.
if !super::moves::cut_tracked(s)? {
delete_selection(s)?;
@@ -738,7 +776,7 @@ fn goto(s: &mut Session, v: &Value) -> CmdResult {
});
if let Some(pg) = rel.or_else(|| p::u64(v, "page")) {
let l = s.layout();
let idx = (pg.max(1) as usize - 1).min(l.pages.len().saturating_sub(1));
let idx = crate::p::page_index(pg).min(l.pages.len().saturating_sub(1));
let target = l.pages.get(idx).and_then(|page| {
page.items.iter().find_map(|it| match it {
wordcraft_layout::Placed::Lines { story, path, para, l0, .. } if *story == StoryRef::Body => {
+38 -14
View File
@@ -147,7 +147,7 @@ pub fn specs() -> Vec<CommandSpec> {
.params(r#"{"id"?: n (an open document, see view.switchWindows; default the active one), "force"?: bool (discard unsaved changes; without it a document with unsaved changes is an error and stays open)} → {"closed": id, "active": id, "documents": [{"id", "title", "path", "dirty", "active"}]}; closing the last document leaves a new blank one"#)
.pure(),
CommandSpec::new("file.properties", "Properties", "File › Info", properties)
.params(r#"{"title"?, "subject"?, "author"?, "keywords"?, "comments"?, "category"?, "custom"?: {name: string | null (removes)}}"#),
.params(r#"{"title"?, "subject"?, "author"?, "keywords"?, "comments"?, "category"?, "custom"?: {name: string | {"value": string, "linkTarget"?: string | null} | null (removes)}}; text values and bookmark targets are at most 1 MiB; a string keeps an existing target, an object replaces it (omitted/null target unlinks)"#),
CommandSpec::new("file.info", "Info", "File", info).pure(),
CommandSpec::new("file.setAuthor", "User Name", "File › Options › General", set_author)
.params(r#"{"name": string (author of new tracked changes and comments)}"#)
@@ -428,7 +428,7 @@ fn export_png(s: &mut Session, v: &Value) -> CmdResult {
s.push_ui_request(json!({"open": "exportPng"}));
return Ok(json!({"exported": false}));
};
let page = p::u64(v, "page").unwrap_or(1).max(1) as usize - 1;
let page = crate::p::page_index(p::u64(v, "page").unwrap_or(1));
let scale = p::f32(v, "scale").unwrap_or(2.0).clamp(0.1, 8.0);
let l = s.export_layout_with(export_options(s, v).markup);
let pg = l.pages.get(page).ok_or_else(|| CmdError::Params(format!("no page {}", page + 1)))?;
@@ -445,6 +445,15 @@ fn export_png(s: &mut Session, v: &Value) -> CmdResult {
Ok(json!({"path": path, "width": img.width, "height": img.height, "bytes": png.len()}))
}
/// Text and its bookmark association cross the dialog/command boundary together on rename.
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct LinkedCustomText<'a> {
value: &'a str,
#[serde(borrow)]
link_target: Option<&'a str>,
}
fn properties(s: &mut Session, v: &Value) -> CmdResult {
let c = &mut s.doc.core;
let set = |dst: &mut String, k: &str| {
@@ -460,7 +469,7 @@ fn properties(s: &mut Session, v: &Value) -> CmdResult {
set(&mut c.category, "category");
if let Some(custom) = v.get("custom") {
let Some(m) = custom.as_object() else {
return Err(CmdError::Params("`custom` must be an object of name → string or null".into()));
return Err(CmdError::Params("`custom` must be an object of name → text, linked text value, or null".into()));
};
// Removals first: a case-only rename sends the old spelling as null and the new one
// with its value, and `set_custom_prop` matches names case-insensitively, so setting
@@ -471,17 +480,32 @@ fn properties(s: &mut Session, v: &Value) -> CmdResult {
}
}
for (name, val) in m {
match val {
// Removals took the name as stored above: a file opened from an older Word may
// hold one longer than today's limit, and it must still be deletable (#2982).
Value::Null => {}
Value::String(x) if x.len() <= MAX_CUSTOM_VALUE => {
if name.trim().is_empty() || name.chars().count() > MAX_CUSTOM_NAME {
return Err(CmdError::Params(format!("custom property names are 1–{MAX_CUSTOM_NAME} characters")));
}
s.doc.set_custom_prop(name, x);
let bad_value =
|| CmdError::Params(format!("custom property `{name}` must be text (≤ {MAX_CUSTOM_VALUE} bytes), a linked text value, or null"));
let (value, target) = match val {
// Removal also accepts names longer than today's limit (#2982).
Value::Null => continue,
Value::String(value) => (value.as_str(), None),
Value::Object(_) => {
let linked: LinkedCustomText<'_> = serde::Deserialize::deserialize(val).map_err(|_| bad_value())?;
(linked.value, Some(linked.link_target))
}
_ => return Err(CmdError::Params(format!("custom property `{name}` must be a string (≤ {MAX_CUSTOM_VALUE} bytes) or null"))),
_ => return Err(bad_value()),
};
if value.len() > MAX_CUSTOM_VALUE {
return Err(bad_value());
}
if name.trim().is_empty() || name.chars().count() > MAX_CUSTOM_NAME {
return Err(CmdError::Params(format!("custom property names are 1–{MAX_CUSTOM_NAME} characters")));
}
if target.flatten().is_some_and(|t| t.len() > MAX_CUSTOM_VALUE) {
return Err(CmdError::Params(format!("custom property bookmark targets are at most {MAX_CUSTOM_VALUE} bytes")));
}
s.doc.set_custom_prop(name, value);
if let Some(target) = target
&& let Some(prop) = s.doc.custom_props.iter_mut().find(|p| p.name.eq_ignore_ascii_case(name))
{
prop.link_target = target.map(str::to_string);
}
}
}
@@ -495,7 +519,7 @@ fn properties(s: &mut Session, v: &Value) -> CmdResult {
/// Longest custom property name (Word's limit). The Custom Properties dialog validates with it
/// too (#2982).
pub const MAX_CUSTOM_NAME: usize = 255;
/// Largest custom property value we accept from a command. The Custom Properties dialog
/// Largest custom property value or bookmark target we accept from a command. The Custom Properties dialog
/// validates with it too (#2982).
pub const MAX_CUSTOM_VALUE: usize = 1024 * 1024;
+75 -19
View File
@@ -197,6 +197,17 @@ fn column_rows(s: &Session) -> Option<Vec<(Pos, Pos)>> {
/// Apply a change to the selection (or the pending caret formatting).
pub fn apply(s: &mut Session, f: &dyn Fn(&mut CharProps)) -> CmdResult {
apply_to(s, target, f)
}
/// The selection only, not the word at a caret: what highlighting formats (#4086).
fn selection(s: &Session) -> Option<(Pos, Pos)> {
let (a, b) = s.sel.ordered();
(a != b).then_some((a, b))
}
/// [`apply`], with `target` choosing what a non-column selection formats.
fn apply_to(s: &mut Session, target: fn(&Session) -> Option<(Pos, Pos)>, f: &dyn Fn(&mut CharProps)) -> CmdResult {
// A column selection formats each row's piece.
if let Some(rows) = column_rows(s) {
for (a, b) in rows {
@@ -211,6 +222,12 @@ pub fn apply(s: &mut Session, f: &dyn Fn(&mut CharProps)) -> CmdResult {
match selected_text_box(s).or_else(|| target(s)) {
Some((a, b)) => {
s.doc.format_range(&a, &b, f)?;
// The word at a caret took the change: so does formatting waiting for typing (#4086).
if s.sel.is_collapsed()
&& let Some(pend) = s.pending.as_mut()
{
f(pend);
}
}
None => {
let mut pend = s.typing_props();
@@ -354,26 +371,59 @@ fn current_size(s: &Session) -> f32 {
}
fn step_size(s: &mut Session, dir: i32) -> CmdResult {
let cur = current_size(s);
let next = if dir > 0 {
SIZES.iter().copied().find(|x| *x > cur + 0.01).unwrap_or(((cur / 10.0).floor() + 1.0) * 10.0)
} else {
SIZES.iter().rev().copied().find(|x| *x < cur - 0.01).unwrap_or(1.0)
};
let next = next.clamp(1.0, 1638.0);
// Each run steps from its own size when the selection mixes sizes.
apply(s, &|c| {
c.size = Some(next);
c.size_cs = Some(next);
resize(s, &|cur| {
if dir > 0 {
SIZES.iter().copied().find(|x| *x > cur + 0.01).unwrap_or(((cur / 10.0).floor() + 1.0) * 10.0)
} else {
SIZES.iter().rev().copied().find(|x| *x < cur - 0.01).unwrap_or(1.0)
}
})
}
fn nudge_size(s: &mut Session, d: f32) -> CmdResult {
let next = (current_size(s) + d).clamp(1.0, 1638.0);
apply(s, &|c| {
c.size = Some(next);
c.size_cs = Some(next);
})
resize(s, &|cur| cur + d)
}
/// Relative commands transform each run's effective size, including inherited sizes. Keep
/// `apply`'s targets and the existing explicit override of both normal and complex-script size.
fn resize(s: &mut Session, next: &dyn Fn(f32) -> f32) -> CmdResult {
let set = |c: &mut CharProps, size: f32| {
let size = next(size).clamp(1.0, 1638.0);
c.size = Some(size);
c.size_cs = Some(size);
};
let ranges = column_rows(s).or_else(|| selected_text_box(s).or_else(|| target(s)).map(|range| vec![range]));
let Some(ranges) = ranges else {
// Pending typing props and an empty paragraph's mark get the same single change.
let size = current_size(s);
return apply(s, &|c| set(c, size));
};
// Resolve against one immutable snapshot while formatting splits and normalizes runs.
let styles = s.doc.styles.clone();
for (a, b) in ranges {
for path in s.doc.paths_between(&a, &b) {
let p = s.doc.para_mut(a.story, &path)?;
let style = p.props.style.clone();
let from = if path == a.path { a.off } else { 0 };
let to = if path == b.path { b.off } else { p.len() };
let format = |c: &mut CharProps| set(c, styles.resolve_char(style.as_deref(), c).size);
// Avoid format_range here: its path walk would repeat a paragraph-prefix scan.
p.format(from, to, &format)?;
// Keep format_range's paragraph-mark rule, including filtering linked marks.
if to == p.len() && (from == 0 || path != a.path) {
let style = p.mark.style.clone();
format(&mut p.mark);
if p.mark.link.take().is_some() {
p.mark.style = style.filter(|s| s != "Hyperlink");
}
}
}
// An empty selected cell has only its paragraph mark, which needs a new layout too.
if a == b {
s.doc.para_mut(a.story, &a.path)?.touch();
}
}
sel_result(s)
}
fn color(s: &mut Session, v: &Value) -> CmdResult {
@@ -410,7 +460,7 @@ fn target_has_highlight(s: &Session, h: Highlight) -> bool {
return true;
}
}
if let Some((a, b)) = selected_text_box(s).or_else(|| target(s)) {
if let Some((a, b)) = selected_text_box(s).or_else(|| selection(s)) {
for path in s.doc.paths_between(&a, &b) {
let Some(p) = s.doc.para(a.story, &path) else { continue };
let from = if path == a.path { a.off } else { 0 };
@@ -422,7 +472,12 @@ fn target_has_highlight(s: &Session, h: Highlight) -> bool {
}
}
}
// No runs: retain the caret/pending and empty text-box paragraph-mark rules.
// No runs: retain the caret/pending and empty text-box paragraph-mark rules. A caret checks
// what is typed next, not the word it's in (#4086).
if selected_text_box(s).or_else(|| selection(s)).is_none() {
let style = s.doc.para_at(&s.sel.focus).and_then(|p| p.props.style.as_deref());
return seen || matches(style, &s.typing_props());
}
seen || resolved(s).iter().all(|r| r.highlight == h.rgb())
}
@@ -472,7 +527,8 @@ fn highlight(s: &mut Session, v: &Value) -> CmdResult {
};
let remove = color.is_none() && target_has_highlight(s, h);
let value = if remove { Highlight::None } else { h };
let result = apply(s, &|x| x.highlight = Some(value))?;
// A caret highlights what is typed next, not the word it's in, as in Word (#4086).
let result = apply_to(s, selection, &|x| x.highlight = Some(value))?;
s.last_highlight = h;
Ok(result)
}
+2 -2
View File
@@ -2,9 +2,9 @@ use super::filter::refresh_shown;
use super::{CmdError, CmdResult, Session, Value, json, p};
/// Parse CSV (RFC 4180-ish: quotes, doubled quotes, commas/semicolons/tabs).
/// The field separator: whichever of `,` `;` and tab the first line (outside quotes) uses most.
/// The field separator: whichever of `,` `;` and tab the first non-blank line (outside quotes) uses most.
fn csv_separator(text: &str) -> char {
let first = text.lines().next().unwrap_or("");
let first = text.lines().find(|l| !l.trim().is_empty()).unwrap_or("");
let mut quoted = false;
let mut unquoted = String::new();
for c in first.chars() {
+7
View File
@@ -52,6 +52,13 @@ fn csv_delimiter_ignores_quoted_text() {
assert_eq!(r, vec![vec!["Name, first, last", "City"], vec!["Ann", "Paris"]]);
}
#[test]
fn csv_delimiter_skips_leading_blank_lines() {
let want = vec![vec!["Name", "Email"], vec!["Ann", "ann@x.org"]];
assert_eq!(parse_csv("\nName,Email\nAnn,ann@x.org\n"), want);
assert_eq!(parse_csv("\r\n\r\nName;Email\r\nAnn;ann@x.org"), want);
}
/// Field types ignore case (ECMA-376 Part 1, 17.16.1), and `MERGEFIELDX` is another field.
#[test]
fn merge_field_types_ignore_case() {
+15 -3
View File
@@ -508,9 +508,21 @@ fn regenerate_toc(s: &mut Session, leader: Option<TabLeader>, tabs: &[(String, V
let page =
l.caret(&Pos { story: StoryRef::Body, path: target, off: e.lead }).and_then(|c| l.pages.get(c.page)).map(|pg| pg.number).unwrap_or(1);
let show_page = no_pages.is_none_or(|(a, b)| !(a..=b).contains(&(e.level + 1)));
let text = if show_page { format!("{}{sep}{page}", e.text) } else { e.text.clone() };
let props = CharProps { link: links.get(k).cloned().flatten(), ..Default::default() };
let mut para = Paragraph::with_text(&text, props).styled(&format!("TOC{}", e.level + 1));
let link = links.get(k).cloned().flatten();
// With a bookmark to point at, the page number is a `PAGEREF` field, as Word writes
// it, so updating it in Word follows the heading to its new page.
let pageref = link.as_deref().and_then(|l| l.strip_prefix('#')).filter(|_| show_page).map(|b| format!("PAGEREF {b} \\h"));
let text = match (show_page, &pageref) {
(true, None) => format!("{}{sep}{page}", e.text),
(true, Some(_)) => format!("{}{sep}", e.text),
(false, _) => e.text.clone(),
};
let props = CharProps { link, ..Default::default() };
let mut para = Paragraph::with_text(&text, props.clone()).styled(&format!("TOC{}", e.level + 1));
if let Some(instr) = pageref {
let field = InlineObject::Field { instr, result: page.to_string(), locked: false, dirty: false, code: false, form: String::new() };
para.insert_object(para.len(), field, &props)?;
}
para.mark = CharProps::default();
if e.text.contains('\t') {
para.props.tabs = tabs.iter().find(|(style, _)| Some(style) == para.props.style.as_ref()).map(|(_, tabs)| tabs.clone());
+3 -1
View File
@@ -20,6 +20,7 @@ pub(super) use cells::grid_cols;
pub use cells::selected_cells;
use cells::{cell, with_cells, with_table};
pub(crate) use cells::{cell_text, row_ends};
pub(super) use clear::clear_range_cells;
pub(crate) use clear::{backspace_cells, clear_cells};
use clear::{cell_prefix, guard_delete, text_spans};
use convert::{formula, from_text, quick_table, sort_table, to_text};
@@ -31,6 +32,7 @@ use select::{select_cells, select_column};
pub use structure::split_targets;
pub(super) use structure::{col, row};
use structure::{del_col, del_row, del_table, merge, split, split_table};
pub(super) use structure::{delete_table_at, selected_table};
fn in_table(s: &Session) -> Option<&'static str> {
if s.sel.focus.path.cell().is_some() { None } else { Some("the cursor isn't in a table") }
@@ -223,7 +225,7 @@ pub fn specs() -> Vec<CommandSpec> {
})
.params(r#"{"value"?: bool} (true: the first column stands at the right; without a value it toggles)"#)),
t(CommandSpec::new("table.properties", "Properties", "Table Layout › Table", properties).params(
r#"{"align"?: "left|center|right", "rtl"?: bool, "width"?: pt|null, "widthPct"?: percent, "indent"?: pt, "rowHeight"?: pt|null, "rowHeightRule"?: "atLeast|exact", "allowBreak"?: bool, "headerRow"?: bool, "columnWidth"?: pt, "cellWidth"?: pt|null, "valign"?: "top|center|bottom"} (row, column and cell settings apply to the caret's; without settings it returns the current ones)"#,
r#"{"align"?: "left|center|right", "rtl"?: bool, "width"?: pt|null, "widthPct"?: percent, "indent"?: pt, "rowHeight"?: pt|null, "rowHeightRule"?: "atLeast|exact", "allowBreak"?: bool, "headerRow"?: bool, "columnWidth"?: pt, "cellWidth"?: pt|null, "valign"?: "top|center|bottom"} (row, column and cell settings apply to the selected rows, columns and cells, or the caret's; without settings it returns the caret's current ones)"#,
)),
CommandSpec::new("table.fromText", "Convert Text to Table", "Insert › Tables", from_text)
.params(r#"{"separator"?: "tab|comma|paragraph|other", "char"?: string (with other), "columns"?: n, "autofit"?: "initial|fixed|contents|window", "width"?: pt (initial column width, 0 = Auto, at most 1584)}"#),
+24
View File
@@ -30,6 +30,30 @@ pub(crate) fn backspace_cells(s: &mut Session) -> Option<CmdResult> {
Some(r)
}
/// Clear the text of the cells `range` selects, leaving the cells (Cut of rows or cells, #1744);
/// tracked when Track Changes is on. The caret goes to the first cell.
pub(in crate::cmd) fn clear_range_cells(s: &mut Session, range: &crate::CellRange) -> Result<(), CmdError> {
let Some(t) = s.doc.table(range.story, &range.table) else { return Ok(()) };
// The cells Copy takes: columns widened over merged cells, as `Table::slice` does.
let (lo, hi, any) = t.slice_bounds(range.rows, range.cols);
let cells = if any { crate::CellRange { cols: (lo, hi.saturating_sub(1)), ..range.clone() }.cells(t) } else { Vec::new() };
let prefixes: Vec<Vec<u32>> = cells.into_iter().map(|(r, c)| cell_prefix(&range.table, r, c)).collect();
let spans = text_spans(s, range.story, &prefixes);
let first = spans.first().map(|(a, _)| a.clone());
if guard_delete(s, &spans)?.is_none() {
for (a, b) in spans.iter().rev() {
if a != b {
s.doc.delete_range(a, b)?;
}
}
}
s.column = None;
if let Some(a) = first {
s.sel = Selection::caret(s.doc.clamp(&a));
}
Ok(())
}
/// Delete with whole cells selected (#2802): each cell is emptied to one empty paragraph, as in
/// Word, nested tables included. That paragraph keeps the formatting of the cell's last one (its
/// mark is the end-of-cell mark). `None` without whole cells, and with Track Changes on (the text
+19 -21
View File
@@ -1,4 +1,4 @@
use super::cells::{cell, with_cells, with_table};
use super::cells::{cell, selected_cells, with_cells, with_table};
use super::{
Align, Border, BorderStyle, Borders, CmdError, CmdResult, HeightRule, Path, Pos, Rgb, Selection, Session, Table, TextDirection, VAlign, Value,
json, p, sel_result,
@@ -74,18 +74,9 @@ pub(super) fn set_column_widths(t: &mut Table, cells: &[(usize, usize)], w: f32)
}
}
fn set_column_width(t: &mut Table, r: usize, c: usize, w: f32) {
let g = t.grid_col(r, c);
if let Some(x) = t.grid.get_mut(g) {
*x = w;
t.props.width = None;
t.props.width_pct = None;
sync_cell_widths(t);
}
}
/// Table Properties: table, row, column and cell settings in one undoable step. Row, column and
/// cell settings apply to the caret's. Without any setting it returns the current values.
/// cell settings apply to the selected rows, columns and cells, as in Word, and to the caret's
/// when nothing is selected (#4857). Without any setting it returns the caret's current values.
pub(super) fn properties(s: &mut Session, v: &Value) -> CmdResult {
let (tp, r, c) = cell(s)?;
let keys = [
@@ -150,6 +141,11 @@ pub(super) fn properties(s: &mut Session, v: &Value) -> CmdResult {
let column_width = p::f32(v, "columnWidth").map(|x| x.clamp(6.0, 1584.0));
let cell_width = size("cellWidth", 6.0, 1584.0);
let rtl = p::bool(v, "rtl");
// The selected cells (a whole table, rows, a column…), else the caret's.
let cells = Some(selected_cells(s)).filter(|c| !c.is_empty()).unwrap_or_else(|| vec![(r, c)]);
let mut rows: Vec<usize> = cells.iter().map(|&(r, _)| r).collect();
rows.sort_unstable();
rows.dedup();
with_table(s, |t| {
if let Some(a) = align {
t.props.align = Some(a);
@@ -161,7 +157,7 @@ pub(super) fn properties(s: &mut Session, v: &Value) -> CmdResult {
t.props.indent = Some(i);
}
if let Some(cw) = column_width {
set_column_width(t, r, c, cw);
set_column_widths(t, &cells, cw);
}
if let Some(w) = width {
t.props.width = w;
@@ -170,7 +166,8 @@ pub(super) fn properties(s: &mut Session, v: &Value) -> CmdResult {
if let Some(pct) = width_pct {
t.props.width_pct = Some(pct);
}
if let Some(row) = t.rows.get_mut(r) {
for &r in &rows {
let Some(row) = t.rows.get_mut(r) else { continue };
if let Some(h) = row_height {
row.props.height = h;
row.props.height_rule = if h.is_some() { rule.unwrap_or(HeightRule::AtLeast) } else { HeightRule::Auto };
@@ -185,13 +182,14 @@ pub(super) fn properties(s: &mut Session, v: &Value) -> CmdResult {
if let Some(h) = header {
row.props.header = h;
}
if let Some(cl) = row.cells.get_mut(c) {
if let Some(w) = cell_width {
cl.props.width = w;
}
if let Some(va) = valign {
cl.props.valign = Some(va);
}
}
for &(r, c) in &cells {
let Some(cl) = t.rows.get_mut(r).and_then(|row| row.cells.get_mut(c)) else { continue };
if let Some(w) = cell_width {
cl.props.width = w;
}
if let Some(va) = valign {
cl.props.valign = Some(va);
}
}
})
+20
View File
@@ -2,6 +2,7 @@ use super::cells::{cell, selected_cells};
use super::clear::{cell_prefix, guard_delete, text_spans};
use super::layout::{fix_caret, sync_cell_widths};
use super::{Block, CmdError, CmdResult, Paragraph, Pos, Selection, Session, Value, json, p, sel_result};
use wordcraft_doc::Path;
/// Insert Above / Below: rows like the caret's, as many as the selection spans; the caret goes into the first.
pub(in crate::cmd) fn row(s: &mut Session, below: bool) -> CmdResult {
@@ -104,6 +105,25 @@ pub(super) fn del_col(s: &mut Session, _: &Value) -> CmdResult {
pub(super) fn del_table(s: &mut Session, _: &Value) -> CmdResult {
let (tp, _, _) = cell(s)?;
delete_table_at(s, &tp)
}
/// The table the selection covers whole (Select Table, a drag over all of it), if it does (#1744).
pub(in crate::cmd) fn selected_table(s: &Session) -> Option<Path> {
if let Some(r) = s.cell_range()
&& let Some(t) = s.doc.table(r.story, &r.table)
&& r.rows == (0, t.rows.len().saturating_sub(1))
&& r.cols == (0, t.cols().saturating_sub(1))
{
return Some(r.table.clone());
}
let (a, b) = s.sel.ordered();
s.doc.whole_table(&a, &b)
}
/// Delete Table for the table at `tp` (in the selection's story): tracked when Track Changes is on.
pub(in crate::cmd) fn delete_table_at(s: &mut Session, tp: &Path) -> CmdResult {
let tp = tp.clone();
let story = s.sel.focus.story;
let spans = text_spans(s, story, std::slice::from_ref(&tp.0));
if let Some(done) = guard_delete(s, &spans)? {
+17 -2
View File
@@ -1,5 +1,5 @@
use super::enter_tab::tab_indent_on;
use super::{Block, CmdResult, NumRef, Pos, Selection, Session, StoryRef, Value, delete_selection, delete_selection_effect, sel_result};
use super::{Block, CmdResult, NumRef, OBJ, Pos, Selection, Session, StoryRef, Value, delete_selection, delete_selection_effect, sel_result};
pub(super) fn backspace(s: &mut Session, v: &Value) -> CmdResult {
let r = backspace_inner(s, v)?;
@@ -43,8 +43,17 @@ fn backspace_inner(s: &mut Session, _: &Value) -> CmdResult {
});
return sel_result(s);
}
let f = s.sel.focus.clone();
let mut f = s.sel.focus.clone();
let Some(para) = s.doc.para_at(&f) else { return sel_result(s) };
// Only floating objects before the caret: they are anchors, not characters, so this is the
// paragraph start and the shape stays (Word).
let before = para.text.get(..f.off).unwrap_or_default();
if !before.is_empty()
&& before.chars().all(|c| c == OBJ)
&& para.objects_with_offsets().take_while(|(o, _)| *o < f.off).all(|(_, o)| o.is_marker() || o.is_floating())
{
f.off = 0;
}
if f.off > 0 {
let a = Pos { off: para.backspace_from(f.off), ..f.clone() };
if let Some(c) = s.autocap.as_mut() {
@@ -98,6 +107,12 @@ fn backspace_inner(s: &mut Session, _: &Value) -> CmdResult {
delete_selection(s)?;
return sel_result(s);
}
// Joining moves a content control whose start is at the paragraph edge. Check its
// contents explicitly: the paragraph-mark range itself ends before those contents.
for control in s.doc.control_ranges(f.story).into_iter().filter(|r| r.start.path == f.path && r.start.off == f.off) {
crate::cmd::controls::check_edit(s, &control.content_start(), &control.end)?;
}
crate::cmd::controls::check_delete(s, &a, &f)?;
let at = s.doc.delete_range(&a, &f)?;
s.sel = Selection::caret(at);
}
+5
View File
@@ -192,6 +192,11 @@ pub mod p {
pub fn u64(v: &Value, k: &str) -> Option<u64> {
v.get(k).and_then(|x| x.as_u64().or_else(|| x.as_f64().filter(|f| *f >= 0.0 && f.is_finite()).map(|f| f as u64)))
}
/// A 1-based page parameter as a 0-based index. A value that cannot fit in
/// `usize` (32-bit and wasm) saturates instead of wrapping to page 1.
pub fn page_index(page: u64) -> usize {
usize::try_from(page.max(1)).unwrap_or(usize::MAX).saturating_sub(1)
}
pub fn bool(v: &Value, k: &str) -> Option<bool> {
v.get(k).and_then(Value::as_bool)
}
+44 -1
View File
@@ -156,8 +156,20 @@ impl Session {
}
/// The document as the PDF of a print job `v` describes (see [`plan`]), with the number of
/// distinct pages chosen and copies. Does not touch the document.
/// distinct pages chosen and copies, as File › Print › Save these pages as PDF keeps it:
/// links work. Does not touch the document.
pub fn print_pdf(&self, v: &Value) -> Result<(Vec<u8>, PrintPlan), CmdError> {
self.print_pdf_with(v, true)
}
/// The PDF sent to a printer for job `v`: [`Self::print_pdf`] without link annotations,
/// which print nothing, and which a printer driver can't fit to the paper ("flatten page
/// annotations before using shrink-to-fit", #4734).
pub fn print_job_pdf(&self, v: &Value) -> Result<(Vec<u8>, PrintPlan), CmdError> {
self.print_pdf_with(v, false)
}
fn print_pdf_with(&self, v: &Value, links: bool) -> Result<(Vec<u8>, PrintPlan), CmdError> {
// Print Markup (File › Print) decides whether tracked changes and comments print.
let markup = crate::cmd::file::export_options(self, v);
let lay = self.export_layout_with(markup.markup);
@@ -170,6 +182,7 @@ impl Session {
include_markup: markup.markup,
comments: markup.comments,
paper,
links,
..Default::default()
};
let bytes = wordcraft_pdf::export_layout(&self.doc, &lay, &opts).map_err(|e| CmdError::Failed(e.to_string()))?;
@@ -238,6 +251,36 @@ mod tests {
assert!(r, "printPdf needs a path");
}
/// #4734: the PDF sent to a printer has no link annotations, which a printer driver can't fit
/// to the paper ("flatten page annotations before using shrink-to-fit"). An exported PDF and
/// one saved with Save these pages as PDF (`file.printPdf`) keep them.
#[test]
fn only_the_print_job_leaves_out_link_annotations() {
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("insert.link", &json!({"url": "https://example.com", "text": "a link"})).unwrap();
let lay = s.export_layout();
let exported = wordcraft_pdf::export_layout(&s.doc, &lay, &wordcraft_pdf::PdfOptions::default()).unwrap();
let links = |bytes: &[u8]| {
let raw = String::from_utf8_lossy(bytes);
raw.contains("/Annots") && raw.contains("/Link")
};
assert!(links(&exported), "an exported PDF keeps the link");
for params in [json!({}), json!({"paper": "A4"}), json!({"markup": true})] {
let (job, _) = s.print_job_pdf(&params).unwrap();
assert!(!links(&job) && !String::from_utf8_lossy(&job).contains("/Annots"), "{params}");
let (saved, _) = s.print_pdf(&params).unwrap();
assert!(links(&saved), "{params}");
}
#[cfg(not(target_arch = "wasm32"))]
{
let path = std::env::temp_dir().join(format!("wc-print-links-{}.pdf", std::process::id()));
s.run("file.printPdf", &json!({"path": path.to_string_lossy()})).unwrap();
let saved = std::fs::read(&path).unwrap();
let _ = std::fs::remove_file(&path);
assert!(links(&saved), "Save these pages as PDF keeps the link");
}
}
/// Scale to paper size (#2108): the PDF goes on the chosen paper; the document keeps its size.
#[test]
fn print_pdf_scales_to_the_chosen_paper() {
+495 -2
View File
@@ -11,6 +11,7 @@ use crate::{Session, cmd};
mod bidi;
mod documents;
mod font_sizes;
mod fonts;
mod ime_noop;
mod revision_checks;
@@ -1943,6 +1944,291 @@ fn tables_commands() {
assert!(s.run("table.merge", &json!({})).is_err());
}
/// A 2×2 table "A1 B1 / A2 B2" after an "Intro" paragraph, the caret in its first cell.
fn table_doc() -> Session {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "Intro"}));
run(&mut s, "caret.docEnd", json!({}));
run(&mut s, "insert.table", json!({"rows": 2, "cols": 2}));
for (i, t) in ["A1", "B1", "A2", "B2"].iter().enumerate() {
if i > 0 {
run(&mut s, "text.tab", json!({}));
}
run(&mut s, "text.insert", json!({"text": t}));
}
s
}
fn body_table_cells(s: &Session) -> Vec<Vec<String>> {
s.doc
.body
.iter()
.filter_map(|b| b.as_table())
.map(|t| {
t.rows
.iter()
.flat_map(|r| r.cells.iter())
.map(|c| c.blocks.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).collect::<String>())
.collect()
})
.collect()
}
/// #1744: Select Table → Copy → Paste outside the table pastes a copy of the whole table.
#[test]
fn copy_whole_table_pastes_a_table() {
let mut s = table_doc();
run(&mut s, "table.selectTable", json!({}));
let r = run(&mut s, "edit.copy", json!({}));
assert!(r["text"].as_str().unwrap().contains("A1\tB1"), "clipboard text is tab separated: {r}");
assert!(matches!(s.clipboard.as_ref().unwrap().blocks.as_slice(), [wordcraft_doc::Block::Table(_)]), "the copy is the table");
run(&mut s, "caret.docStart", json!({}));
run(&mut s, "edit.paste", json!({}));
let want = vec!["A1".to_string(), "B1".into(), "A2".into(), "B2".into()];
assert_eq!(body_table_cells(&s), vec![want.clone(), want]);
}
/// #1744: Select Table → Cut removes the whole table, and one Undo brings it back.
#[test]
fn cut_whole_table_removes_it_and_undoes() {
let mut s = table_doc();
run(&mut s, "table.selectTable", json!({}));
run(&mut s, "edit.cut", json!({}));
assert!(body_table_cells(&s).is_empty(), "the table is gone");
assert!(s.sel.focus.path.cell().is_none(), "the caret left the table");
run(&mut s, "edit.undo", json!({}));
assert_eq!(body_table_cells(&s), vec![vec!["A1".to_string(), "B1".into(), "A2".into(), "B2".into()]]);
// Paste puts it back as a table.
run(&mut s, "table.selectTable", json!({}));
run(&mut s, "edit.cut", json!({}));
run(&mut s, "edit.paste", json!({}));
assert_eq!(body_table_cells(&s).len(), 1);
}
/// A 3×3 table "A1 B1 C1 / A2 B2 C2 / A3 B3 C3" after an "Intro" paragraph (#1744).
fn table3_doc() -> Session {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "Intro"}));
run(&mut s, "caret.docEnd", json!({}));
run(&mut s, "insert.table", json!({"rows": 3, "cols": 3}));
for (i, t) in ["A1", "B1", "C1", "A2", "B2", "C2", "A3", "B3", "C3"].iter().enumerate() {
if i > 0 {
run(&mut s, "text.tab", json!({}));
}
run(&mut s, "text.insert", json!({"text": t}));
}
s
}
/// Select whole cells of the body's table: rows and grid columns, both inclusive.
fn select_table_cells(s: &mut Session, rows: (usize, usize), cols: (usize, usize)) {
let tp = wordcraft_doc::Path(vec![1]);
crate::cmd::table::select_range(s, StoryRef::Body, &tp, rows, cols).unwrap();
assert_eq!(s.cell_range().map(|r| (r.rows, r.cols)), Some((rows, cols)));
}
fn row_cells(texts: &[&str]) -> Vec<String> {
texts.iter().map(|t| t.to_string()).collect()
}
/// #1744 (Word check A): copying whole rows, two cells of a row, or a block of cells over
/// several rows, then pasting outside the table, pastes a table of just those cells.
#[test]
fn copy_rows_and_cells_pastes_a_table_of_them() {
let full = row_cells(&["A1", "B1", "C1", "A2", "B2", "C2", "A3", "B3", "C3"]);
for (rows, cols, want) in [
((1, 1), (0, 2), row_cells(&["A2", "B2", "C2"])),
((0, 0), (0, 1), row_cells(&["A1", "B1"])),
((1, 2), (1, 2), row_cells(&["B2", "C2", "B3", "C3"])),
] {
let mut s = table3_doc();
select_table_cells(&mut s, rows, cols);
run(&mut s, "edit.copy", json!({}));
let Some([wordcraft_doc::Block::Table(t)]) = s.clipboard.as_ref().map(|f| f.blocks.as_slice()) else {
panic!("{rows:?} {cols:?}: the copy is a table, not {:?}", s.clipboard_text);
};
assert_eq!(t.rows.len(), rows.1 - rows.0 + 1, "{rows:?} {cols:?}");
assert_eq!(t.grid.len(), cols.1 - cols.0 + 1, "{rows:?} {cols:?}: the grid has the copied columns");
run(&mut s, "caret.docStart", json!({}));
run(&mut s, "edit.paste", json!({}));
assert_eq!(body_table_cells(&s), vec![want, full.clone()], "{rows:?} {cols:?}");
}
}
/// #1744 (Word check A): Cut of whole rows, or of some cells, clears their text and leaves
/// the rows and cells; the clipboard still gets a table of them. One Undo brings the text back.
#[test]
fn cut_rows_and_cells_clears_their_text() {
let full = row_cells(&["A1", "B1", "C1", "A2", "B2", "C2", "A3", "B3", "C3"]);
for (rows, cols, left, copied) in [
((1, 1), (0, 2), row_cells(&["A1", "B1", "C1", "", "", "", "A3", "B3", "C3"]), row_cells(&["A2", "B2", "C2"])),
((0, 0), (0, 1), row_cells(&["", "", "C1", "A2", "B2", "C2", "A3", "B3", "C3"]), row_cells(&["A1", "B1"])),
((1, 2), (1, 2), row_cells(&["A1", "B1", "C1", "A2", "", "", "A3", "", ""]), row_cells(&["B2", "C2", "B3", "C3"])),
] {
let mut s = table3_doc();
select_table_cells(&mut s, rows, cols);
run(&mut s, "edit.cut", json!({}));
assert_eq!(body_table_cells(&s), vec![left.clone()], "{rows:?} {cols:?}: rows and cells stay");
assert!(s.sel.is_collapsed(), "{rows:?} {cols:?}");
run(&mut s, "edit.undo", json!({}));
assert_eq!(body_table_cells(&s), vec![full.clone()], "{rows:?} {cols:?}: one undo step");
select_table_cells(&mut s, rows, cols);
run(&mut s, "edit.cut", json!({}));
run(&mut s, "caret.docStart", json!({}));
run(&mut s, "edit.paste", json!({}));
assert_eq!(body_table_cells(&s), vec![copied, left], "{rows:?} {cols:?}: the cut pastes as a table");
}
}
/// #1744 review: Cut clears every cell Copy takes: a cell merged across the selected columns
/// widens them, so the cell below the merge's second column is copied and cleared too.
#[test]
fn cut_clears_the_cells_a_merged_cell_widens_the_copy_to() {
let mut s = table3_doc();
select_table_cells(&mut s, (0, 0), (0, 1));
run(&mut s, "table.merge", json!({}));
select_table_cells(&mut s, (0, 1), (0, 0));
run(&mut s, "edit.copy", json!({}));
run(&mut s, "edit.cut", json!({}));
let cells = body_table_cells(&s).remove(0);
assert_eq!(cells.len(), 8, "one merged cell and seven others: {cells:?}");
assert_eq!(cells[1], "C1");
assert!(cells[2..4].iter().all(String::is_empty), "A2 and B2 are cleared: {cells:?}");
assert_eq!(cells[4..], ["C2", "A3", "B3", "C3"], "the rest stays");
}
/// #1744 review: an empty cell's selection enables Cut but not the Spike, which takes text only.
#[test]
fn spike_stays_disabled_on_an_empty_cell_selection() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "Intro"}));
run(&mut s, "caret.docEnd", json!({}));
run(&mut s, "insert.table", json!({"rows": 2, "cols": 2}));
select_table_cells(&mut s, (0, 0), (0, 0));
let r = s.run("edit.spike", &json!({}));
assert!(matches!(r, Err(crate::CmdError::Disabled(_))), "{r:?}");
}
fn clip_is_table(s: &Session) -> bool {
matches!(s.clipboard.as_ref().map(|f| f.blocks.as_slice()), Some([wordcraft_doc::Block::Table(_)]))
}
/// The body table's cells as they read with every change accepted.
fn body_table_final(s: &Session) -> Vec<String> {
let t = s.doc.body.iter().find_map(|b| b.as_table()).expect("a table");
t.rows.iter().flat_map(|r| r.cells.iter()).map(|c| c.blocks.iter().filter_map(|b| b.as_para()).map(|p| p.final_text()).collect()).collect()
}
/// #1744 review: an empty table or cell selected whole still copies, and Cut of the empty
/// table removes it, as in Word.
#[test]
fn copy_and_cut_of_an_empty_table_or_cell() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "Intro"}));
run(&mut s, "caret.docEnd", json!({}));
run(&mut s, "insert.table", json!({"rows": 1, "cols": 1}));
run(&mut s, "table.selectTable", json!({}));
run(&mut s, "edit.copy", json!({}));
assert!(clip_is_table(&s), "an empty 1×1 table copies: {:?}", s.clipboard);
run(&mut s, "edit.cut", json!({}));
assert!(body_table_cells(&s).is_empty(), "Cut removes the empty table");
run(&mut s, "edit.undo", json!({}));
assert_eq!(body_table_cells(&s), vec![vec![String::new()]]);
let mut s = Session::new(wordcraft_doc::Document::new());
run(&mut s, "document.setText", json!({"text": "Intro"}));
run(&mut s, "caret.docEnd", json!({}));
run(&mut s, "insert.table", json!({"rows": 2, "cols": 2}));
select_table_cells(&mut s, (0, 0), (0, 0));
run(&mut s, "edit.copy", json!({}));
assert!(clip_is_table(&s), "an empty cell copies as a one-cell table");
run(&mut s, "edit.cut", json!({}));
assert_eq!(body_table_cells(&s).len(), 1, "Cut of an empty cell leaves the table");
}
/// #1744 review: a text selection over exactly a whole table (no whole-cell record) copies the
/// table; so does one over exactly a table nested in a cell. Other users of the copied range
/// read it as a table too: the selected text is tab separated, a Quick Part saved from it
/// inserts a table.
#[test]
fn text_selection_of_a_whole_table_copies_the_table() {
let mut s = table_doc();
let at = |path: Vec<u32>, off: usize| json!({"story": "body", "path": path, "off": off});
run(&mut s, "select.range", json!({"anchor": at(vec![1, 0, 0, 0], 0), "focus": at(vec![1, 1, 1, 0], 2)}));
assert!(s.cell_range().is_none(), "a text selection");
assert_eq!(s.selected_text(), "A1\tB1\nA2\tB2");
run(&mut s, "edit.copy", json!({}));
assert!(clip_is_table(&s));
run(&mut s, "insert.quickParts", json!({"save": "grid"}));
run(&mut s, "caret.docStart", json!({}));
run(&mut s, "insert.quickParts", json!({"insert": "grid"}));
assert_eq!(body_table_cells(&s).len(), 2, "the Quick Part is a table");
// A table nested in cell A1: selecting its text whole copies just it.
let mut s = table_doc();
run(&mut s, "select.range", json!({"anchor": at(vec![1, 0, 0, 0], 2), "focus": at(vec![1, 0, 0, 0], 2)}));
run(&mut s, "insert.table", json!({"rows": 2, "cols": 1}));
run(&mut s, "text.insert", json!({"text": "N1"}));
run(&mut s, "text.tab", json!({}));
run(&mut s, "text.insert", json!({"text": "N2"}));
let (inner, _, _) = s.sel.focus.path.cell().unwrap();
assert_eq!(inner.0.len(), 4, "nested in a cell");
let first = [inner.0.clone(), vec![0, 0, 0]].concat();
let last = [inner.0.clone(), vec![1, 0, 0]].concat();
run(&mut s, "select.range", json!({"anchor": at(first, 0), "focus": at(last, 2)}));
assert!(s.cell_range().is_none(), "a text selection");
run(&mut s, "edit.copy", json!({}));
let Some([wordcraft_doc::Block::Table(t)]) = s.clipboard.as_ref().map(|f| f.blocks.as_slice()) else { panic!("{:?}", s.clipboard_text) };
assert_eq!(t.rows.len(), 2, "the nested table, not the outer one");
}
/// #1744 review: with Track Changes on, Cut of the whole table or of a row marks the text
/// deleted and keeps the table; one Undo takes the marks back.
#[test]
fn tracked_cut_of_a_table_or_row_marks_text_deleted() {
let full = row_cells(&["A1", "B1", "C1", "A2", "B2", "C2", "A3", "B3", "C3"]);
let mut s = table3_doc();
s.doc.settings.track_changes = true;
run(&mut s, "table.selectTable", json!({}));
run(&mut s, "edit.cut", json!({}));
assert!(clip_is_table(&s));
assert_eq!(body_table_cells(&s), vec![full.clone()], "the table and its text stay, marked");
assert!(body_table_final(&s).iter().all(String::is_empty), "{:?}", body_table_final(&s));
run(&mut s, "edit.undo", json!({}));
assert_eq!(body_table_final(&s), full);
select_table_cells(&mut s, (1, 1), (0, 2));
run(&mut s, "edit.cut", json!({}));
assert!(clip_is_table(&s));
assert_eq!(body_table_cells(&s), vec![full.clone()]);
assert_eq!(body_table_final(&s), row_cells(&["A1", "B1", "C1", "", "", "", "A3", "B3", "C3"]));
run(&mut s, "edit.undo", json!({}));
assert_eq!(body_table_final(&s), full);
}
/// #1744 review: Cut of a table, or of cells, holding a content control locked against deletion
/// is refused and changes nothing in the document; the copy is made first, so the clipboard
/// holds the table, as for any refused Cut.
#[test]
fn cut_of_cells_with_a_locked_control_is_refused() {
for whole in [true, false] {
let mut s = table3_doc();
run(&mut s, "edit.find", json!({"text": "B2"}));
run(&mut s, "developer.richText", json!({"tag": "keep"}));
run(&mut s, "developer.properties", json!({"control": "keep", "lockDelete": true}));
if whole {
run(&mut s, "table.selectTable", json!({}));
} else {
select_table_cells(&mut s, (1, 1), (0, 2));
}
let before = s.doc.clone();
assert!(s.run("edit.cut", &json!({})).is_err(), "whole table: {whole}");
assert_eq!(s.doc, before, "whole table: {whole}");
assert!(s.status.contains("can't be deleted"), "{}", s.status);
assert!(clip_is_table(&s), "whole table: {whole}");
}
}
#[test]
fn inserting_a_column_keeps_cell_widths_in_step_with_the_grid() {
let mut s = s();
@@ -2432,6 +2718,79 @@ fn table_properties_apply_in_one_step() {
assert!(s.run("table.properties", &json!({"valign": "middle"})).is_err());
}
/// #4857: Table Properties' row settings apply to every selected row (the whole table, say), the
/// column width to every selected column and the cell settings to every selected cell, as in
/// Word, in one undo step; with nothing selected, to the caret's alone.
#[test]
fn table_properties_apply_to_the_selected_rows_columns_and_cells() {
use wordcraft_doc::props::VAlign;
let mut s = s();
run(&mut s, "insert.table", json!({"rows": 3, "cols": 2}));
let table = |s: &Session| s.doc.body.iter().find_map(|b| b.as_table()).unwrap().clone();
let cant_split = |s: &Session| table(s).rows.iter().map(|r| r.props.cant_split).collect::<Vec<_>>();
run(&mut s, "table.properties", json!({"allowBreak": false}));
assert_eq!(cant_split(&s), [true, false, false], "the caret's row alone");
run(&mut s, "edit.undo", json!({}));
let before = table(&s);
run(&mut s, "table.selectTable", json!({}));
run(&mut s, "table.properties", json!({"allowBreak": false, "rowHeight": 30.0, "columnWidth": 100.0, "cellWidth": 90.0, "valign": "center"}));
let t = table(&s);
assert_eq!(cant_split(&s), [true, true, true], "every selected row");
assert!(t.rows.iter().all(|r| r.props.height == Some(30.0)));
assert_eq!(t.grid, [100.0, 100.0], "every selected column");
assert!(t.rows.iter().flat_map(|r| &r.cells).all(|c| c.props.width == Some(90.0) && c.props.valign == Some(VAlign::Center)));
run(&mut s, "edit.undo", json!({}));
assert_eq!(table(&s), before, "one undo step");
// A selected row: that row alone; a selected column: that column alone.
run(&mut s, "caret.docStart", json!({}));
run(&mut s, "text.tab", json!({}));
run(&mut s, "text.tab", json!({}));
run(&mut s, "table.selectRow", json!({}));
run(&mut s, "table.properties", json!({"allowBreak": false}));
assert_eq!(cant_split(&s), [false, true, false]);
run(&mut s, "caret.docStart", json!({}));
run(&mut s, "text.tab", json!({}));
run(&mut s, "table.selectColumn", json!({}));
run(&mut s, "table.properties", json!({"columnWidth": 150.0}));
assert_eq!(table(&s).grid.get(1), Some(&150.0));
assert_ne!(table(&s).grid.first(), Some(&150.0));
}
/// #4857: a tall row across a page break moves to the next page whole once the whole table is
/// selected and "Allow row to break across pages" is turned off.
#[test]
fn a_selected_table_kept_from_breaking_moves_its_row_whole() {
// Lines above the table until its 12-line second row starts across the page break.
let found = (0..80u32).find_map(|above| {
let mut s = s();
for k in 0..above {
run(&mut s, "text.insert", json!({"text": format!("Line {k}")}));
run(&mut s, "text.newParagraph", json!({}));
}
run(&mut s, "insert.table", json!({"rows": 3, "cols": 1}));
run(&mut s, "text.insert", json!({"text": "Kiln"}));
run(&mut s, "text.tab", json!({}));
for i in 0..12 {
run(&mut s, "text.insert", json!({"text": format!("Press note {i}")}));
run(&mut s, "text.newParagraph", json!({}));
}
(row_pages(&mut s, above).len() == 2).then_some((s, above))
});
let (mut s, table) = found.expect("the row starts across the page break");
run(&mut s, "table.selectTable", json!({}));
run(&mut s, "table.properties", json!({"allowBreak": false}));
assert_eq!(row_pages(&mut s, table), [1], "the row moves to the next page whole");
}
/// The pages the second row of the table at body block `table` has lines on.
fn row_pages(s: &mut Session, table: u32) -> Vec<usize> {
let row = [table, 1];
let on_page = |p: &wordcraft_layout::Page| {
p.items.iter().any(|it| matches!(it, wordcraft_layout::Placed::Lines { path, .. } if path.0.get(..2) == Some(&row[..])))
};
s.layout().pages.iter().enumerate().filter(|(_, p)| on_page(p)).map(|(i, _)| i).collect()
}
/// Table Layout › Text Direction (#226) cycles the selected cells' text through horizontal,
/// top-to-bottom and bottom-to-top, and the layout turns the text.
#[test]
@@ -3754,7 +4113,8 @@ fn update_toc_clears_stale_field_result_for_docx_span() {
s
};
let b = xml.find(r#"w:fldCharType="begin""#).expect("TOC begin marker present");
let e = xml.find(r#"w:fldCharType="end""#).expect("TOC end marker present");
// The TOC's end is the last one: the entries' page numbers are `PAGEREF` fields inside it.
let e = xml.rfind(r#"w:fldCharType="end""#).expect("TOC end marker present");
assert!(b < e, "begin marker precedes end marker");
let inside_field = &xml[b..e];
let after_field = &xml[e..];
@@ -3939,6 +4299,50 @@ fn toc_page_numbers_follow_headings() {
}
}
/// A hyperlinked TOC's page numbers are `PAGEREF` fields to the headings' bookmarks, as Word
/// writes them, so updating them in Word follows the headings; they survive a save and reopen.
#[test]
fn toc_page_numbers_are_pageref_fields() {
let mut s = s();
run(&mut s, "references.toc", json!({}));
run(&mut s, "caret.docEnd", json!({}));
for name in ["Alpha", "Bravo"] {
run(&mut s, "text.pageBreak", json!({}));
run(&mut s, "text.insert", json!({"text": name}));
run(&mut s, "para.heading1", json!({}));
run(&mut s, "text.newParagraph", json!({}));
}
run(&mut s, "references.updateToc", json!({}));
let fields: Vec<(String, String)> = s
.doc
.body
.iter()
.filter_map(|b| b.as_para())
.filter(|p| p.props.style.as_deref() == Some("TOC1"))
.flat_map(|p| p.objects.iter())
.filter_map(|o| match o {
wordcraft_doc::para::InlineObject::Field { instr, result, .. } => Some((instr.clone(), result.clone())),
_ => None,
})
.collect();
assert_eq!(fields.len(), 2, "{fields:?}");
for ((instr, result), page) in fields.iter().zip(["2", "3"]) {
let mark = instr.strip_prefix("PAGEREF ").and_then(|r| r.strip_suffix(" \\h")).unwrap_or_else(|| panic!("{instr}"));
assert!(mark.starts_with("_Toc") && s.doc.bookmarks().iter().any(|(n, _)| n == mark), "{instr}");
assert_eq!(result, page);
}
let expected = text(&s);
assert!(expected.contains("Alpha\t2") && expected.contains("Bravo\t3"), "{expected}");
let bytes = crate::io::save_bytes("toc.docx", &s.doc).unwrap();
let mut z = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
let mut xml = String::new();
std::io::Read::read_to_string(&mut z.by_name("word/document.xml").unwrap(), &mut xml).unwrap();
assert_eq!(xml.matches("PAGEREF _Toc").count(), 2, "{xml}");
let mut s = Session::new(crate::io::open_bytes("toc.docx", &bytes).unwrap());
run(&mut s, "references.updateFields", json!({}));
assert_eq!(text(&s), expected);
}
/// Headings numbered through a list carry their numbers, and the level's separator, into the
/// TOC entries.
#[test]
@@ -4594,7 +4998,7 @@ fn word_count_with_an_object_selected_counts_the_document() {
fn custom_properties_delete_legacy_names_but_new_names_keep_the_limit() {
let mut s = s();
let long = "x".repeat(crate::cmd::file::MAX_CUSTOM_NAME + 1);
s.doc.custom_props.push(wordcraft_doc::CustomProp { name: long.clone(), kind: "raw".into(), value: "<data/>".into() });
s.doc.custom_props.push(wordcraft_doc::CustomProp { name: long.clone(), kind: "raw".into(), value: "<data/>".into(), ..Default::default() });
let mut custom = serde_json::Map::new();
custom.insert(long.clone(), serde_json::Value::Null);
run(&mut s, "file.properties", json!({"custom": custom}));
@@ -4631,6 +5035,36 @@ fn custom_properties_case_only_rename_keeps_the_new_spelling() {
assert_eq!(props, [("Status", "draft")], "the property keeps the new spelling and its value");
}
/// Linked text objects carry rename metadata explicitly and retain the existing input bounds.
#[test]
fn custom_properties_linked_values_are_typed_bounded_and_atomic() {
let mut s = s();
s.doc.set_custom_prop("Status", "draft");
s.doc.custom_props[0].link_target = Some("OriginalBookmark".into());
let before = s.doc.clone();
let depth = s.undo_depth();
let max = crate::cmd::file::MAX_CUSTOM_VALUE;
for value in [
json!({"value": "changed", "linkTarget": 7}),
json!({"value": 7, "linkTarget": "OriginalBookmark"}),
json!({"value": "changed", "linkTarget": "OriginalBookmark", "kind": "raw"}),
json!({"value": "x".repeat(max + 1), "linkTarget": "OriginalBookmark"}),
json!({"value": "changed", "linkTarget": "x".repeat(max + 1)}),
] {
assert!(s.run("file.properties", &json!({"title": "must roll back", "custom": {"Status": null, "Renamed": value}})).is_err());
assert_eq!(s.doc, before);
assert_eq!(s.undo_depth(), depth);
}
run(&mut s, "file.properties", json!({"custom": {"Status": {"value": "final", "linkTarget": "OriginalBookmark"}}}));
run(&mut s, "file.properties", json!({"custom": {"Status": "string edit"}}));
assert_eq!(s.doc.custom_props[0].link_target.as_deref(), Some("OriginalBookmark"));
for value in [json!({"value": "unlinked", "linkTarget": null}), json!({"value": "unlinked"})] {
s.doc.custom_props[0].link_target = Some("OriginalBookmark".into());
run(&mut s, "file.properties", json!({"custom": {"Status": value}}));
assert_eq!(s.doc.custom_props[0].link_target, None);
}
}
/// #2982: what the Custom Properties dialog saves (through `file.properties`) survives a .docx
/// save and reopen.
#[test]
@@ -11086,3 +11520,62 @@ fn scale_font_sizes_reaches_char_table_and_cs_sizes_and_unset_default() {
assert_eq!(grid.header_chr.size, Some(16.0));
assert_eq!(grid.regions.get(&TablePart::Band2Horz).unwrap().chr.size, Some(10.0));
}
/// #3676: a 1-based page parameter becomes a 0-based index without wrapping.
/// On 32-bit and wasm the old cast truncated 2^32 to 0 and `0 - 1` panicked.
#[test]
fn page_index_saturates_instead_of_wrapping_to_page_one() {
assert_eq!(crate::p::page_index(0), 0);
assert_eq!(crate::p::page_index(1), 0);
assert_eq!(crate::p::page_index(2), 1);
assert_ne!(crate::p::page_index(1u64 << 32), 0, "2^32 must not wrap to page 1");
assert_eq!(crate::p::page_index(u64::MAX), usize::MAX - 1);
}
/// #3676: absolute page numbers that cannot be an index are rejected, not silently
/// turned into page 1. `edit.goto` clamps to the last page, as it does past the end.
#[test]
fn hostile_absolute_pages_never_land_on_page_one() {
let mut s = s();
for i in 0..5 {
if i > 0 {
run(&mut s, "insert.pageBreak", json!({}));
}
run(&mut s, "text.insert", json!({"text": format!("Page {}", i + 1)}));
}
let page = |s: &mut Session| s.layout().caret_on(&s.sel.focus, s.page_hint).unwrap().page + 1;
run(&mut s, "edit.goto", json!({"page": 1u64 << 32}));
assert_eq!(page(&mut s), 5, "an out-of-range page lands on the last page");
run(&mut s, "edit.goto", json!({"page": u64::MAX}));
assert_eq!(page(&mut s), 5, "u64::MAX lands on the last page");
run(&mut s, "edit.goto", json!({"page": 0}));
assert_eq!(page(&mut s), 1, "page 0 is the first page");
}
/// #3676: `caret.set` takes a 0-based page; an oversized page is rejected instead of
/// truncating to page 0 on 32-bit and wasm.
#[test]
fn caret_set_rejects_a_page_number_that_does_not_fit() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Hello"}));
let r = s.run("caret.set", &json!({"page": 1u64 << 32, "x": 100.0, "y": 100.0}));
assert!(matches!(r, Err(crate::CmdError::Params(_))), "page 2^32: {r:?}");
}
/// #3676: `file.exportPng` rejects a page number that cannot be an index instead of
/// exporting page 1 (the 32-bit truncation) and writing it to the requested path.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn export_png_rejects_a_page_number_that_does_not_fit() {
let dir = scratch_dir("export-png-hostile-page");
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Hello"}));
let target = dir.join("page.png");
let r = s.run("file.exportPng", &json!({"path": target.to_string_lossy(), "page": 1u64 << 32}));
assert!(matches!(r, Err(crate::CmdError::Params(_))), "page 2^32: {r:?}");
assert!(!target.exists(), "no file is written for an invalid page");
let r = s.run("file.exportPng", &json!({"path": target.to_string_lossy(), "page": u64::MAX}));
assert!(matches!(r, Err(crate::CmdError::Params(_))), "page u64::MAX: {r:?}");
assert!(!target.exists(), "no file is written for an invalid page");
let _ = std::fs::remove_dir_all(&dir);
}
+229
View File
@@ -0,0 +1,229 @@
//! Relative font sizes keep each selected run's size (#4903).
use serde_json::json;
use wordcraft_doc::{Document, Pos};
use crate::Session;
fn mixed() -> Session {
let mut doc = Document::from_text("Large heading\nSmall detail");
for (block, size) in [(0, 20.0), (1, 10.0)] {
let end = doc.para_at(&Pos::body(block, 0)).unwrap().len();
doc.format_range(&Pos::body(block, 0), &Pos::body(block, end), &|c| {
c.size = Some(size);
c.size_cs = Some(size);
})
.unwrap();
}
let mut s = Session::new(doc);
s.run("select.all", &json!({})).unwrap();
s
}
fn sizes(s: &Session) -> Vec<f32> {
(0..2)
.map(|block| {
let p = s.doc.para_at(&Pos::body(block, 0)).unwrap();
s.doc.styles.resolve_char(p.props.style.as_deref(), p.props_of_char(0)).size
})
.collect()
}
#[test]
fn relative_font_sizes_preserve_mixed_runs_and_one_undo() {
for (command, expected) in [
("format.growFont", [22.0, 10.5]),
("format.shrinkFont", [18.0, 9.0]),
("format.growFont1", [21.0, 11.0]),
("format.shrinkFont1", [19.0, 9.0]),
] {
let mut s = mixed();
let before = s.doc.clone();
let selection = s.sel.clone();
let depth = s.undo_depth();
s.run(command, &json!({})).unwrap();
assert_eq!(sizes(&s), expected, "{command}");
assert_eq!(s.sel, selection);
assert_eq!(s.undo_depth(), depth + 1);
let after = s.doc.clone();
s.run("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc, before);
s.run("edit.redo", &json!({})).unwrap();
assert_eq!(s.doc, after);
}
}
#[test]
fn relative_font_sizes_resolve_styles_and_preserve_other_properties() {
use wordcraft_doc::props::CharProps;
use wordcraft_doc::styles::{Style, StyleKind};
let mut s = mixed();
s.doc.styles.default_chr.size_cs = Some(30.0);
s.doc.styles.upsert(Style {
id: "Large".into(),
name: "Large".into(),
based_on: Some("Normal".into()),
chr: CharProps { size: Some(20.0), bold: Some(true), ..Default::default() },
..Default::default()
});
s.doc.styles.upsert(Style {
id: "Small".into(),
name: "Small".into(),
kind: StyleKind::Character,
chr: CharProps { size: Some(10.0), italic: Some(true), ..Default::default() },
..Default::default()
});
for block in 0..2 {
let p = s.doc.para_mut(wordcraft_doc::StoryRef::Body, &wordcraft_doc::Path::top(block)).unwrap();
p.props.style = Some(if block == 0 { "Large" } else { "Normal" }.into());
for c in std::iter::once(&mut p.mark).chain(p.runs.iter_mut().map(|r| &mut r.props)) {
c.size = None;
c.size_cs = None;
if block == 1 {
c.style = Some("Small".into());
}
}
}
s.run("format.growFont", &json!({})).unwrap();
assert_eq!(sizes(&s), [22.0, 10.5]);
for (block, size) in [(0, 22.0), (1, 10.5)] {
let p = s.doc.para_at(&Pos::body(block, 0)).unwrap();
assert_eq!(p.mark.size, Some(size));
for (_, c) in p.run_ranges() {
assert_eq!((c.size, c.size_cs), (Some(size), Some(size)), "both scripts still get the explicit override");
let resolved = s.doc.styles.resolve_char(p.props.style.as_deref(), c);
assert!(if block == 0 { resolved.bold } else { resolved.italic });
}
}
}
#[test]
fn relative_font_sizes_change_only_the_selected_unicode_pieces() {
let mut s = Session::new(Document::from_text("éA中Bz"));
s.doc.format_range(&Pos::body(0, 2), &Pos::body(0, 3), &|c| c.size = Some(20.0)).unwrap();
s.doc.format_range(&Pos::body(0, 3), &Pos::body(0, 6), &|c| c.size = Some(10.0)).unwrap();
let before = s.doc.clone();
s.run("select.range", &json!({"anchor": Pos::body(0, 2), "focus": Pos::body(0, 6)})).unwrap();
s.run("format.growFont1", &json!({})).unwrap();
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
let original = before.para_at(&Pos::body(0, 0)).unwrap();
assert_eq!(p.text, "éA中Bz");
assert_eq!(p.props_of_char(2).size, Some(21.0));
assert_eq!(p.props_of_char(3).size, Some(11.0));
assert_eq!(p.props_of_char(0), original.props_of_char(0));
assert_eq!(p.props_of_char(6), original.props_of_char(6));
assert_eq!(p.mark, original.mark, "a partial selection leaves the paragraph mark alone");
}
#[test]
fn relative_font_sizes_keep_the_caret_and_word_targets() {
let mut s = Session::new(Document::new());
s.run("format.size", &json!({"size": 10})).unwrap();
for size in [11.0, 12.0] {
s.run("format.growFont1", &json!({})).unwrap();
assert_eq!(s.pending.as_ref().unwrap().size, Some(size));
assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().mark.size, Some(size));
}
s.run("text.insert", &json!({"text": "abcd ef"})).unwrap();
s.doc.format_range(&Pos::body(0, 0), &Pos::body(0, 2), &|c| c.size = Some(20.0)).unwrap();
s.doc.format_range(&Pos::body(0, 2), &Pos::body(0, 4), &|c| c.size = Some(10.0)).unwrap();
s.run("caret.set", &json!({"pos": Pos::body(0, 2)})).unwrap();
let selection = s.sel.clone();
s.run("format.growFont1", &json!({})).unwrap();
assert_eq!(s.sel, selection);
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
assert_eq!((p.props_of_char(0).size, p.props_of_char(2).size, p.props_of_char(5).size), (Some(21.0), Some(11.0), Some(12.0)));
}
#[test]
fn relative_font_sizes_keep_column_cell_and_text_box_targets() {
use wordcraft_doc::{Path, StoryRef};
let mut s = super::column_doc();
super::block(&mut s);
for (block, size) in [(0, 20.0), (1, 10.0), (2, 14.0)] {
s.doc.format_range(&Pos::body(block, 0), &Pos::body(block, 6), &|c| c.size = Some(size)).unwrap();
}
s.run("format.growFont1", &json!({})).unwrap();
assert!(s.column_segments().is_some());
for (block, size) in [(0, 20.0), (1, 10.0), (2, 14.0)] {
let p = s.doc.para_at(&Pos::body(block, 0)).unwrap();
assert_eq!((p.props_of_char(1).size, p.props_of_char(2).size, p.props_of_char(4).size), (Some(size), Some(size + 1.0), Some(size)));
assert_eq!(p.mark.size, Some(size));
}
let mut s = Session::new(Document::new());
s.run("insert.table", &json!({"rows": 1, "cols": 2})).unwrap();
let paths = [Path(vec![0, 0, 0, 0]), Path(vec![0, 0, 1, 0])];
for (path, size) in paths.iter().zip([20.0, 10.0]) {
s.doc.para_mut(StoryRef::Body, path).unwrap().mark.size = Some(size);
}
s.run("table.selectTable", &json!({})).unwrap();
let _ = s.layout();
s.run("format.growFont1", &json!({})).unwrap();
for (path, size) in paths.iter().zip([21.0, 11.0]) {
assert_eq!(s.doc.para(StoryRef::Body, path).unwrap().mark.size, Some(size), "empty cells retain distinct sizes");
}
let mut s = Session::new(Document::from_text("Body"));
let id = s.run("insert.textBox", &json!({"text": "AB"})).unwrap()["story"].as_u64().unwrap() as u32;
let story = StoryRef::Part(id);
let at = |off| Pos { story, path: Path::top(0), off };
for (a, b, size) in [(0, 1, 20.0), (1, 2, 10.0)] {
s.doc.format_range(&at(a), &at(b), &|c| c.size = Some(size)).unwrap();
}
s.run("select.range", &json!({"anchor": Pos::body(0, 0), "focus": Pos::body(0, "\u{fffc}".len())})).unwrap();
let before = s.doc.para_at(&Pos::body(0, 0)).unwrap().clone();
s.run("format.growFont1", &json!({})).unwrap();
assert_eq!(*s.doc.para_at(&Pos::body(0, 0)).unwrap(), before);
let p = s.doc.para(story, &Path::top(0)).unwrap();
assert_eq!((p.props_of_char(0).size, p.props_of_char(1).size), (Some(21.0), Some(11.0)));
}
#[test]
fn relative_font_sizes_keep_bounds_protection_and_saved_sizes() {
for (command, values, expected) in [
("format.growFont", [72.0, 1638.0], [80.0, 1638.0]),
("format.shrinkFont", [1.0, 8.0], [1.0, 1.0]),
("format.growFont1", [1.0, 1638.0], [2.0, 1638.0]),
("format.shrinkFont1", [1.0, 1638.0], [1.0, 1637.0]),
] {
let mut s = mixed();
for (block, size) in values.into_iter().enumerate() {
let end = s.doc.para_at(&Pos::body(block, 0)).unwrap().len();
s.doc.format_range(&Pos::body(block, 0), &Pos::body(block, end), &|c| c.size = Some(size)).unwrap();
}
s.run(command, &json!({})).unwrap();
assert_eq!(sizes(&s), expected, "{command}");
let bytes = crate::io::save_bytes("sizes.docx", &s.doc).unwrap();
let reopened = Session::new(crate::io::open_bytes("sizes.docx", &bytes).unwrap());
assert_eq!(sizes(&reopened), expected, "saved {command}");
s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap();
let before = s.doc.clone();
let depth = s.undo_depth();
assert!(s.run(command, &json!({})).is_err());
assert_eq!(s.doc, before);
assert_eq!(s.undo_depth(), depth);
}
}
#[test]
fn relative_font_sizes_keep_paragraph_mark_link_filtering() {
let mut s = mixed();
for (block, style) in [(0, "Hyperlink"), (1, "CustomLink")] {
let p = s.doc.para_mut(wordcraft_doc::StoryRef::Body, &wordcraft_doc::Path::top(block)).unwrap();
p.mark.link = Some("https://example.test/mark".into());
p.mark.style = Some(style.into());
p.runs[0].props.link = Some("https://example.test/text".into());
p.runs[0].props.style = Some("Hyperlink".into());
}
s.run("format.growFont", &json!({})).unwrap();
assert_eq!(sizes(&s), [22.0, 10.5]);
for block in 0..2 {
let p = s.doc.para_at(&Pos::body(block, 0)).unwrap();
assert_eq!(p.mark.link, None);
assert_eq!(p.mark.style.as_deref(), if block == 0 { None } else { Some("CustomLink") });
assert!(p.run_ranges().all(|(_, c)| c.link.as_deref() == Some("https://example.test/text") && c.style.as_deref() == Some("Hyperlink")));
}
}
+151
View File
@@ -1414,6 +1414,78 @@ fn indic_and_khmer_backspace_removes_one_character() {
assert_eq!(paras(&s)[0].0, "a");
}
/// Arabic vowel and Quranic marks are individually editable without retyping their base (#4757).
#[test]
fn arabic_backspace_removes_one_mark_and_keeps_the_base() {
for mark in [
'\u{0610}',
'\u{064B}',
'\u{064C}',
'\u{064D}',
'\u{064E}',
'\u{064F}',
'\u{0650}',
'\u{0651}',
'\u{0652}',
'\u{065F}',
'\u{0670}',
'\u{06D6}',
'\u{06DF}',
'\u{06E7}',
'\u{06EA}',
'\u{0897}',
'\u{089F}',
'\u{08CA}',
'\u{08E1}',
'\u{08E3}',
'\u{08FF}',
'\u{10EFA}',
'\u{10EFF}',
] {
let original = format!("Λ ب{mark}");
let mut s = typed(&original);
typ(&mut s, "\u{8}");
assert_eq!(paras(&s)[0].0, "Λ ب", "mark U+{:04X}", u32::from(mark));
assert_eq!(s.sel.focus.off, "Λ ب".len());
run(&mut s, "edit.undo", json!({}));
assert_eq!(paras(&s)[0].0, original);
run(&mut s, "edit.redo", json!({}));
typ(&mut s, "\u{0650}");
assert_eq!(paras(&s)[0].0, "Λ ب\u{0650}");
}
}
#[test]
fn arabic_backspace_uses_stored_mark_order_but_delete_keeps_the_cluster() {
for marks in ["\u{0651}\u{064E}", "\u{064E}\u{0651}"] {
let mut s = typed(&format!("ب{marks}"));
typ(&mut s, "\u{8}");
assert_eq!(paras(&s)[0].0, format!("ب{}", marks.chars().next().unwrap()));
typ(&mut s, "\u{8}");
assert_eq!(paras(&s)[0].0, "ب");
typ(&mut s, "\u{8}");
assert!(paras(&s)[0].0.is_empty());
let original = format!("ب{marks}ت");
let mut s = typed(&original);
// Arabic arrows move visually. They still visit only whole-cluster boundaries.
let boundaries = [0, original.len() - "ت".len(), original.len()];
let mut visited = vec![s.sel.focus.off];
for command in ["caret.right", "caret.right", "caret.left", "caret.left"] {
run(&mut s, command, json!({}));
assert!(boundaries.contains(&s.sel.focus.off));
visited.push(s.sel.focus.off);
}
assert!(boundaries.iter().all(|off| visited.contains(off)));
s.sel = crate::Selection::caret(Pos::body(0, 0));
run(&mut s, "text.delete", json!({}));
assert_eq!(paras(&s)[0].0, "ت");
}
for cluster in ["e\u{0301}", "👩‍💻", "👍🏽", "🇵🇱", "ב\u{05B7}"] {
let s = typed(&format!("a{cluster}\u{8}"));
assert_eq!(paras(&s)[0].0, "a", "unchanged grapheme {cluster}");
}
}
/// Press a shortcut the way the keyboard does: run the command bound to it.
fn press(s: &mut Session, combo: &str) -> Result<serde_json::Value, crate::CmdError> {
let id = s.registry.by_shortcut(combo).map(|c| c.id).unwrap_or_else(|| panic!("nothing bound to {combo}"));
@@ -3124,6 +3196,37 @@ fn highlight_toggle_handles_mixed_inherited_and_pending_formatting() {
assert_eq!(serde_json::to_value(&s.doc).unwrap(), before);
}
/// #4086: a caret inside a word highlights what is typed next, not the word; Bold still
/// formats the word.
#[test]
fn highlight_at_a_caret_leaves_the_word_alone() {
let mut s = typed("Alpha Beta");
run(&mut s, "caret.docStart", json!({}));
run(&mut s, "caret.right", json!({}));
run(&mut s, "format.highlight", json!({"color":"yellow"}));
assert!(s.doc.body[0].as_para().unwrap().runs.iter().all(|r| r.props.highlight.is_none()));
assert_eq!(s.pending.as_ref().unwrap().highlight, Some(wordcraft_doc::props::Highlight::Yellow));
run(&mut s, "text.insert", json!({"text":"X"}));
run(&mut s, "select.text", json!({"text":"X"}));
assert_eq!(s.run("format.state", &json!({})).unwrap()["highlight"], json!("FFFF00"));
run(&mut s, "select.text", json!({"text":"Beta"}));
assert_eq!(s.run("format.state", &json!({})).unwrap()["highlight"], serde_json::Value::Null);
// The toggle reads the pending colour, not the word's: a second press turns it off.
run(&mut s, "caret.docStart", json!({}));
run(&mut s, "caret.right", json!({}));
run(&mut s, "format.highlight", json!({}));
assert_eq!(s.pending.as_ref().unwrap().highlight, Some(wordcraft_doc::props::Highlight::Yellow));
run(&mut s, "format.highlight", json!({}));
assert_eq!(s.pending.as_ref().unwrap().highlight, Some(wordcraft_doc::props::Highlight::None));
// Bold after Highlight formats the word and keeps both for what is typed next.
run(&mut s, "format.highlight", json!({}));
run(&mut s, "format.bold", json!({}));
run(&mut s, "text.insert", json!({"text":"Y"}));
run(&mut s, "select.text", json!({"text":"Y"}));
let state = s.run("format.state", &json!({})).unwrap();
assert_eq!((state["highlight"].clone(), state["bold"].clone()), (json!("FFFF00"), json!(true)));
}
/// A differently coloured final paragraph must remain visible to the shortcut beyond
/// the formatting-state query limit, including a style-inherited tail and Undo/Redo.
#[cfg(not(target_os = "macos"))]
@@ -3386,3 +3489,51 @@ fn double_space_adds_a_period_when_asked() {
typ(&mut t, "Hello world");
assert_eq!(text(&t), "Hello world");
}
#[test]
fn backspace_after_a_floating_shape_anchor_joins_paragraphs_and_keeps_the_shape() {
let mut s = typed("Line one\nLine two");
run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1], "off": 0}}));
run(&mut s, "insert.textBox", json!({"text": "Box"}));
let (a, f) = (Pos::body(1, 0), Pos::body(1, 3));
run(&mut s, "select.range", json!({"anchor": a, "focus": f}));
run(&mut s, "arrange.wrap", json!({"wrap": "inFrontOfText"}));
let objects = |s: &Session| s.doc.para_at(&Pos::body(1, 0)).map(|p| p.object_offsets().len());
assert_eq!(objects(&s), Some(1));
run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1], "off": 3}}));
run(&mut s, "text.backspace", json!({}));
assert_eq!(s.doc.body.iter().filter(|b| b.as_para().is_some()).count(), 1);
assert_eq!(objects(&s), None);
assert_eq!(
s.doc.para_at(&Pos::body(0, 0)).map(|p| (p.text.replace('\u{FFFC}', ""), p.object_offsets().len())),
Some(("Line oneLine two".to_string(), 1))
);
}
#[test]
fn backspace_after_marker_and_floating_anchors_still_joins_paragraphs() {
let mut s = typed("Line one\nLine two");
run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1], "off": 0}}));
run(&mut s, "insert.textBox", json!({"text": "Box"}));
let (a, f) = (Pos::body(1, 0), Pos::body(1, 3));
run(&mut s, "select.range", json!({"anchor": a, "focus": f}));
run(&mut s, "arrange.wrap", json!({"wrap": "inFrontOfText"}));
s.doc.insert_object(&Pos::body(1, 0), wordcraft_doc::InlineObject::BookmarkStart { name: "anchor".into() }, &Default::default()).unwrap();
s.doc.insert_object(&Pos::body(1, 0), wordcraft_doc::InlineObject::CommentStart { id: 1 }, &Default::default()).unwrap();
run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1], "off": 9}}));
run(&mut s, "text.backspace", json!({}));
assert_eq!(s.doc.body.iter().filter(|b| b.as_para().is_some()).count(), 1);
assert_eq!(s.doc.para_at(&Pos::body(0, 0)).map(|p| p.text.replace('\u{FFFC}', "")), Some("Line oneLine two".to_string()));
}
#[test]
fn backspace_cannot_join_into_locked_content_control() {
let mut s = typed("Above\nInside");
run(&mut s, "select.range", json!({"anchor": {"story": "body", "path": [1], "off": 0}, "focus": {"story": "body", "path": [1], "off": 6}}));
run(&mut s, "developer.richText", json!({"tag": "locked"}));
run(&mut s, "developer.properties", json!({"control": "locked", "lockEdit": true}));
run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1], "off": 0}}));
let before = s.doc.clone();
assert!(s.run("text.backspace", &json!({})).is_err());
assert_eq!(s.doc, before);
}
+21
View File
@@ -76,6 +76,27 @@ fn moving_a_joined_shape_reroutes_its_connector() {
assert_eq!(connector_ends(&mut s), ((172.0, 248.0), (300.0, 218.0)));
}
/// A target on another page, or a missing target, leaves the connector's end free.
#[test]
fn rerouting_keeps_off_page_and_missing_targets_at_their_free_ends() {
let mut s = joined();
let mut connector = s.doc.para_at(&Pos::body(0, 11)).unwrap().object_at(11).unwrap().clone();
let InlineObject::Shape { extra, .. } = &mut connector else { panic!("connector") };
extra.end = Some(ConnEnd { id: 404, site: 2 });
let mut p = Paragraph::new();
p.props.page_break_before = Some(true);
p.insert_object(0, connector.clone(), &Default::default()).unwrap();
s.doc.body.push(para_block(p));
s.touch();
select(&mut s, 5);
run(&mut s, "arrange.nudge", json!({"dx": 10, "dy": 30}));
assert_eq!(s.layout().pages.len(), 2);
assert_eq!(s.doc.para_at(&Pos::body(1, 0)).unwrap().object_at(0), Some(&connector));
assert_eq!(connector_ends(&mut s), ((182.0, 248.0), (300.0, 218.0)));
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.para_at(&Pos::body(1, 0)).unwrap().object_at(0), Some(&connector));
}
/// A command that changes nothing but reroutes a stale connector is still an edit: the
/// reroute must not be left without an undo step (#23).
#[test]