M-egui037: reconcile upstream and document migration review fixes

This commit is contained in:
skylartaylor
2026-10-25 06:42:20 +01:00
24 changed files with 1762 additions and 83 deletions
+13 -1
View File
File diff suppressed because one or more lines are too long
+48
View File
@@ -165,6 +165,54 @@ impl Table {
self.rows.remove(r);
}
/// Split the table before row `at`: the rows from `at` on move to the returned table, which
/// has this table's properties and grid. A vertical merge that the split cuts ends in this
/// table's last row and starts again in the new table's first row. A row content control
/// around rows on both sides closes after this table's last row and opens again (without its
/// `w:id`) before the new table's first row. Content controls that close after this table
/// close after the new one.
pub fn split_off(&mut self, at: usize) -> Table {
let at = at.min(self.rows.len());
let mut open = Vec::new();
for row in self.rows.iter().take(at) {
open.extend(row.controls.open.iter().cloned());
let close = usize::try_from(row.controls.close).unwrap_or(usize::MAX).min(open.len());
open.truncate(open.len() - close);
}
let mut tail = Table {
props: self.props.clone(),
grid: self.grid.clone(),
rows: self.rows.split_off(at),
controls: ControlWrap { open: Vec::new(), close: std::mem::take(&mut self.controls.close) },
};
if let Some(last) = self.rows.last_mut() {
last.controls.close = last.controls.close.saturating_add(u32::try_from(open.len()).unwrap_or(u32::MAX));
for c in &mut last.cells {
if c.props.vmerge == VMerge::Restart {
c.props.vmerge = VMerge::None;
}
}
}
for c in &mut open {
c.id = None;
}
if let Some(first) = tail.rows.first_mut() {
let own = std::mem::replace(&mut first.controls.open, open);
first.controls.open.extend(own);
}
let starts: Vec<(usize, usize)> = (0..tail.rows.first().map_or(0, |row| row.cells.len()))
.filter(|&c| tail.rows.first().and_then(|row| row.cells.get(c)).is_some_and(|x| x.props.vmerge == VMerge::Continue))
.map(|c| (c, tail.grid_col(0, c)))
.collect();
for (c, g) in starts {
let more = tail.cell_at_grid(1, g).and_then(|i| tail.rows.get(1)?.cells.get(i)).is_some_and(|x| x.props.vmerge == VMerge::Continue);
if let Some(x) = tail.rows.first_mut().and_then(|r| r.cells.get_mut(c)) {
x.props.vmerge = if more { VMerge::Restart } else { VMerge::None };
}
}
tail
}
/// Insert a grid column at grid index `g` (cells spanning it widen instead).
pub fn insert_col(&mut self, g: usize) {
if self.grid.len() >= MAX_COLS {
+16
View File
@@ -346,6 +346,9 @@ View|Window|Switch Windows|view.switchWindows
View|Macros|Macros|tools.macros
View|Macros|Record Macro|tools.recordMacro
View|SharePoint|Properties|file.properties
Read Mode|View|Column Width|view.readColumnWidth
Read Mode|View|Page Color|view.readPageColor
Read Mode|View|Layout|view.readLayout
Developer|Controls|Rich Text Content Control|developer.richText
Developer|Controls|Plain Text Content Control|developer.plainText
Developer|Controls|Picture Content Control|developer.picture
@@ -591,4 +594,17 @@ mod tests {
// The floor only ever rises.
assert!(pct >= 90.4, "parity {pct}%");
}
#[test]
fn read_mode_view_menu_is_catalogued() {
let e = entries();
let read_mode: Vec<_> = e.iter().filter(|x| x.0 == "Read Mode").map(|x| (x.1, x.2, x.3)).collect();
assert_eq!(
read_mode,
[("View", "Column Width", "view.readColumnWidth"), ("View", "Page Color", "view.readPageColor"), ("View", "Layout", "view.readLayout")]
);
let reg = crate::cmd::registry();
assert!(reg.get("view.readColumnWidth").is_some(), "Column Width counts as live");
assert!(reg.get("view.readPageColor").is_some(), "Page Color counts as live");
}
}
+1 -1
View File
@@ -286,7 +286,7 @@ fn content_base(s: &Session, r: &ControlRange) -> CharProps {
/// and stops showing it. Returns the formatting the text should get, when it changes.
pub fn prepare_edit(s: &mut Session) -> Result<Option<CharProps>, CmdError> {
if s.sel.is_collapsed() {
let at = s.doc.typing_pos(&s.sel.focus);
let at = super::protect::typing_pos(s, &s.doc.typing_pos(&s.sel.focus));
if at != s.sel.focus {
s.sel = Selection::caret(at);
}
+72
View File
@@ -637,6 +637,35 @@ pub(crate) fn protection_lifted(before: &Document, after: &Document) -> bool {
b.protection.is_some() && (a.protection != b.protection || a.protect_formatting != b.protect_formatting || a.protect_hash != b.protect_hash)
}
/// Where typing at the caret `p` goes in a document that is read only or open to comments only: a
/// caret right before the start of an exception this author may edit goes inside it, and one right
/// after its end goes back inside it. The markers take no room, so the start of a line that an
/// exception starts, and the end of a line that one ends, are those carets.
pub(crate) fn typing_pos(s: &Session, p: &Pos) -> Pos {
if !matches!(s.doc.settings.protection.as_deref(), Some("readOnly" | "comments")) {
return p.clone();
}
let Some(para) = s.doc.para_at(p) else { return p.clone() };
if para.objects.is_empty() {
return p.clone();
}
let editable: Vec<u32> = s.doc.perm_ranges().iter().filter(|r| editable_by(r, &s.author)).map(|r| r.id).collect();
let is_start = |off: usize| matches!(para.object_at(off), Some(InlineObject::PermStart { id, .. }) if editable.contains(id));
let is_end = |off: usize| matches!(para.object_at(off), Some(InlineObject::PermEnd { id }) if editable.contains(id));
let w = OBJ.len_utf8();
let mut off = p.off;
if is_start(off) {
while is_start(off) {
off += w;
}
return Pos { off, ..p.clone() };
}
while off >= w && is_end(off - w) {
off -= w;
}
Pos { off, ..p.clone() }
}
/// The first position in an editable range after `from` (wrapping round), for Find Next Region
/// I Can Edit; with it, the range's end. Filling in forms: the next fillable control's content.
pub fn next_region(s: &Session, from: &Pos) -> Option<(Pos, Pos)> {
@@ -689,6 +718,49 @@ mod tests {
assert_eq!(s.doc.settings.protection.as_deref(), Some("readOnly"));
}
/// An exception that starts a line: Home, Left and Down put the caret before its start
/// marker, and Enter and typing there go inside the exception. A caret after the end marker at
/// the end of the line goes back inside it too.
#[test]
fn typing_at_the_start_or_end_of_an_exception_line_goes_inside_it() {
let visible = |s: &Session| s.doc.plain_text(StoryRef::Body).replace(OBJ, "");
let mut s = Session::new(Document::from_text("Title\nBody text\nEnd."));
select(&mut s, "Body text");
s.run("review.restrict", &json!({"exception": "add"})).unwrap();
s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap();
s.run("caret.home", &json!({})).unwrap();
assert_eq!(s.sel.focus, Pos::body(1, 0), "the caret is before the start marker");
s.run("text.newParagraph", &json!({})).unwrap();
s.run("text.insert", &json!({"text": "New "})).unwrap();
assert_eq!(visible(&s), "Title\n\nNew Body text\nEnd.");
s.run("caret.end", &json!({})).unwrap();
s.run("text.insert", &json!({"text": "!"})).unwrap();
s.run("text.newParagraph", &json!({})).unwrap();
assert_eq!(visible(&s), "Title\n\nNew Body text!\n\nEnd.");
// Outside the exception stays refused: the start of "Title" and the end of "End.".
for at in [Pos::body(0, 0), s.doc.end_of(StoryRef::Body)] {
s.sel = Selection::caret(at);
assert!(s.run("text.newParagraph", &json!({})).is_err());
assert!(s.run("text.insert", &json!({"text": "x"})).is_err());
}
// An empty editable line right after a protected short line, with Built-in Heading styles
// on: two Enters break the paragraph, and the protected line stays Normal text.
let mut s = Session::new(Document::from_text("Title\n\nEnd."));
s.run("tools.autocorrect", &json!({"headingStyles": true})).unwrap();
s.sel = Selection { anchor: Pos::body(1, 0), focus: Pos::body(2, 0) };
s.run("review.restrict", &json!({"exception": "add"})).unwrap();
s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap();
s.sel = Selection::caret(Pos::body(0, 2));
s.run("caret.down", &json!({})).unwrap();
assert_eq!(s.sel.focus, Pos::body(1, 0), "the caret is before the start marker");
s.run("text.newParagraph", &json!({})).unwrap();
s.run("text.newParagraph", &json!({})).unwrap();
s.run("text.insert", &json!({"text": "Notes"})).unwrap();
assert_eq!(visible(&s), "Title\n\n\nNotes\nEnd.");
assert!(s.doc.body.iter().filter_map(|b| b.as_para()).all(|p| p.props.style.is_none()));
}
/// Table, row and cell formatting reaches protected text, so an exception inside one cell
/// doesn't let table commands run there change the table, the row or the cell (#1327).
#[test]
+235 -19
View File
@@ -940,12 +940,12 @@ pub fn update_seq(s: &mut Session) -> Result<(), CmdError> {
n.0 = n.0.saturating_add(1);
}
let fmt = wordcraft_layout::fields::format_switch(instr).unwrap_or_default();
updates.push((k, if f.has('h') { String::new() } else { fmt.format(n.0) }));
updates.push((k, if f.has('h') { String::new() } else { wordcraft_layout::fields::field_number(instr, fmt, n.0) }));
}
"STYLEREF" => {
let ix = styles.get_or_insert_with(|| wordcraft_layout::styleref::StyleIndex::build(&s.doc));
if let Some(i) = ix.index_of(&path) {
updates.push((k, ix.eval(instr, wordcraft_layout::styleref::At::Body(i))));
updates.push((k, text_result(instr, ix.eval(instr, wordcraft_layout::styleref::At::Body(i)))));
}
}
_ => {}
@@ -975,10 +975,11 @@ fn has_switch(instr: &str, sw: char) -> bool {
})
}
/// Update fields of the story being edited, as Word's F9 does: dates, REF/PAGEREF, TITLE, AUTHOR
/// and NUMWORDS in the body, or in the header, footer, note or text box the caret is in (the body's
/// fields stay as they are then). SEQ numbering, citations and TOC are body-only, so they run only
/// when the caret is in the body.
/// Update fields of the story being edited, as Word's F9 does: dates, REF/PAGEREF, the document
/// information fields (TITLE, SUBJECT, AUTHOR, KEYWORDS, COMMENTS, LASTSAVEDBY, REVNUM, NUMWORDS,
/// NUMCHARS, FILENAME, DOCPROPERTY) in the body, or in the header, footer, note or text box the
/// caret is in (the body's fields stay as they are then). SEQ numbering, citations and TOC are
/// body-only, so they run only when the caret is in the body.
pub fn update_fields(s: &mut Session) -> Result<(), CmdError> {
update_fields_in(s, false)
}
@@ -1008,6 +1009,9 @@ pub fn update_fields_in(s: &mut Session, all: bool) -> Result<(), CmdError> {
}
let stories: Vec<StoryRef> =
if all { std::iter::once(StoryRef::Body).chain(s.doc.parts.keys().map(|k| StoryRef::Part(*k))).collect() } else { vec![caret_story] };
// The document properties as the fields read and set them (`SUBJECT "New subject"`): a field
// after one that sets a property shows the new value.
let mut core = s.doc.core.clone();
for (story, path) in stories.into_iter().flat_map(|st| s.doc.para_paths(st).into_iter().map(move |p| (st, p))).collect::<Vec<_>>() {
let Some(p) = s.doc.para(story, &path) else { continue };
if !p.objects.iter().any(|o| matches!(o, InlineObject::Field { .. })) {
@@ -1034,7 +1038,7 @@ pub fn update_fields_in(s: &mut Session, all: bool) -> Result<(), CmdError> {
match name.as_str() {
"DATE" | "TIME" | "CREATEDATE" | "SAVEDATE" | "PRINTDATE" => {
let pic = date_picture(instr).unwrap_or_else(|| if name == "TIME" { "h:mm am/pm".into() } else { "M/d/yyyy".into() });
updates.push((k, super::insert::format_date(&pic)));
updates.push((k, text_result(instr, super::insert::format_date(&pic))));
}
"REF" | "PAGEREF" => {
let f = FieldCode::parse(instr);
@@ -1044,34 +1048,48 @@ pub fn update_fields_in(s: &mut Session, all: bool) -> Result<(), CmdError> {
} else if !has_switch(instr, 'p') {
pages.get(target).map(|(num, page_fmt)| {
let fmt = wordcraft_layout::fields::format_switch(instr).unwrap_or(*page_fmt);
fmt.format(*num)
wordcraft_layout::fields::field_number(instr, fmt, *num)
})
} else {
None
};
if let Some(r) = r {
updates.push((k, r));
updates.push((k, if name == "REF" { text_result(instr, r) } else { r }));
}
}
"TITLE" => updates.push((k, s.doc.core.title.clone())),
"AUTHOR" => updates.push((k, s.doc.core.creator.clone())),
"NUMWORDS" => updates.push((k, s.doc.word_count().to_string())),
// The summary properties; a quoted text after the name replaces the property.
"TITLE" | "SUBJECT" | "AUTHOR" | "KEYWORDS" | "COMMENTS" => {
if let Some(v) = new_property_value(instr) {
set_summary_property(&mut core, &name, v);
}
updates.push((k, text_result(instr, summary_property(&core, &name).to_string())));
}
"LASTSAVEDBY" => updates.push((k, text_result(instr, core.last_modified_by.clone()))),
"NUMWORDS" => updates.push((k, count_text(instr, s.doc.word_count()))),
"NUMCHARS" => {
let chars = s.doc.plain_text(StoryRef::Body).chars().filter(|c| !c.is_whitespace()).count();
updates.push((k, count_text(instr, chars)));
}
"REVNUM" => updates.push((k, count_text(instr, core.revision as usize))),
// DOCPROPERTY <name>: a custom property first, then a built-in one (#1798).
"DOCPROPERTY" => {
if let Some(name) = doc_property_arg(instr)
&& let Some(v) = doc_property(&s.doc, &name)
&& let Some(v) = doc_property(&s.doc, &core, &name)
{
updates.push((k, v));
updates.push((k, text_result(instr, v)));
}
}
"FILENAME" => {
let (fn_name, fn_path) = s.resolved_filenames();
let text = if has_switch(instr, 'p') { fn_path } else { fn_name };
updates.push((k, text));
updates.push((k, text_result(instr, text)));
}
_ => {}
}
}
if core != s.doc.core {
s.doc.core.clone_from(&core);
}
if updates.is_empty() {
continue;
}
@@ -1114,7 +1132,7 @@ pub fn refresh_date_fields(doc: &mut wordcraft_doc::Document) {
}
// Word's default picture when the code carries no `\@` switch.
let pic = date_picture(instr).unwrap_or_else(|| if name == "TIME" { "h:mm am/pm".into() } else { "M/d/yyyy".into() });
updates.push((k, super::insert::format_date(&pic)));
updates.push((k, text_result(instr, super::insert::format_date(&pic))));
}
if updates.is_empty() {
continue;
@@ -1130,6 +1148,52 @@ pub fn refresh_date_fields(doc: &mut wordcraft_doc::Document) {
}
}
/// The summary property a TITLE, SUBJECT, AUTHOR, KEYWORDS or COMMENTS field shows.
fn summary_property<'a>(core: &'a wordcraft_doc::CoreProps, field: &str) -> &'a str {
match field {
"TITLE" => &core.title,
"SUBJECT" => &core.subject,
"AUTHOR" => &core.creator,
"KEYWORDS" => &core.keywords,
"COMMENTS" => &core.description,
_ => "",
}
}
fn set_summary_property(core: &mut wordcraft_doc::CoreProps, field: &str, value: String) {
match field {
"TITLE" => core.title = value,
"SUBJECT" => core.subject = value,
"AUTHOR" => core.creator = value,
"KEYWORDS" => core.keywords = value,
"COMMENTS" => core.description = value,
_ => {}
}
}
/// The new value a summary-property field gives its property: the argument after the field's
/// name (`SUBJECT "Quarterly report"`, or one word without quotes: `SUBJECT Draft`), at most 255
/// characters as Word allows.
fn new_property_value(instr: &str) -> Option<String> {
FieldCode::parse(instr).args.first().map(|a| a.chars().take(255).collect())
}
/// A count (NUMWORDS, NUMCHARS, REVNUM) in the field's `\*` number format, digits without one or
/// when the format does not cover the count (see [`super::table_formula::number_format_covers`]),
/// with the field's capitalization switches.
fn count_text(instr: &str, n: usize) -> String {
let fmt = wordcraft_layout::fields::format_switch(instr)
.filter(|f| super::table_formula::number_format_covers(*f, n as f64))
.unwrap_or(NumFormat::Decimal);
wordcraft_layout::fields::field_number(instr, fmt, u32::try_from(n).unwrap_or(u32::MAX))
}
/// A field's computed text with its capitalization switches (`\* Upper`, `\* Lower`, `\* Caps`,
/// `\* FirstCap`) applied in order.
fn text_result(instr: &str, text: String) -> String {
wordcraft_layout::fields::apply_case_switch(instr, text)
}
/// The property name of a `DOCPROPERTY` field: the word (or quoted text) after the field name,
/// before any switch (`DOCPROPERTY "Project Name" \* MERGEFORMAT`).
fn doc_property_arg(instr: &str) -> Option<String> {
@@ -1143,12 +1207,12 @@ fn doc_property_arg(instr: &str) -> Option<String> {
/// The value of a document property under the name a `DOCPROPERTY` field uses: a custom property
/// when one has this name, else the built-in property (English names, case-insensitively, as
/// Word's field accepts). `None` for a name this application doesn't know.
fn doc_property(doc: &wordcraft_doc::Document, name: &str) -> Option<String> {
/// Word's field accepts) from `core`. `None` for a name this application doesn't know.
fn doc_property(doc: &wordcraft_doc::Document, core: &wordcraft_doc::CoreProps, name: &str) -> Option<String> {
if let Some(v) = doc.custom_prop(name) {
return Some(v.to_string());
}
let c = &doc.core;
let c = core;
Some(match name.trim().to_ascii_lowercase().as_str() {
"title" => c.title.clone(),
"subject" => c.subject.clone(),
@@ -1482,6 +1546,158 @@ mod tests {
assert!(story_fields(&s, StoryRef::Body).iter().all(|(_, r)| r == "stale"), "the body is not touched");
}
/// A session whose body reads "one two three" and whose header holds stale fields with
/// these codes, the caret in the header.
fn header_fields_session(doc: wordcraft_doc::Document, instrs: &[&str]) -> (Session, u32) {
use wordcraft_doc::{CharProps, Paragraph, PartKind};
let mut p = Paragraph::new();
for instr in instrs {
let off = p.len();
let field =
InlineObject::Field { instr: (*instr).into(), result: "stale".into(), locked: false, dirty: false, code: false, form: String::new() };
p.insert_object(off, field, &CharProps::default()).unwrap();
}
let mut doc = doc;
let hdr = doc.add_part(PartKind::Header, vec![std::sync::Arc::new(wordcraft_doc::Block::Para(p))]);
let mut s = Session::new(doc);
let path = s.doc.para_paths(StoryRef::Part(hdr)).remove(0);
s.sel = crate::session::Selection::caret(Pos::new(StoryRef::Part(hdr), path, 0));
(s, hdr)
}
/// SUBJECT, KEYWORDS, COMMENTS, LASTSAVEDBY and REVNUM show the document properties, and
/// NUMCHARS counts the body's characters without spaces; the counts take a `\*` format.
#[test]
fn document_information_fields_show_the_properties() {
let mut doc = wordcraft_doc::Document::from_text("one two three");
doc.core.subject = "Budget".into();
doc.core.keywords = "plan; costs".into();
doc.core.description = "Draft for review".into();
doc.core.last_modified_by = "Grace".into();
doc.core.revision = 7;
let codes = ["SUBJECT", "KEYWORDS", "COMMENTS", "LASTSAVEDBY", "REVNUM", r"REVNUM \* ROMAN", "NUMCHARS", r"NUMWORDS \* roman"];
let (mut s, hdr) = header_fields_session(doc, &codes);
s.run("references.updateFields", &json!({})).unwrap();
let got: Vec<String> = story_fields(&s, StoryRef::Part(hdr)).into_iter().map(|(_, r)| r).collect();
assert_eq!(got, ["Budget", "plan; costs", "Draft for review", "Grace", "7", "VII", "11", "iii"]);
}
/// A quoted text after TITLE, SUBJECT, AUTHOR, KEYWORDS or COMMENTS replaces the property
/// (at most 255 characters); the field and later ones, DOCPROPERTY too, show the new value.
#[test]
fn summary_fields_with_a_new_value_replace_the_property() {
let mut doc = wordcraft_doc::Document::from_text("one two three");
doc.core.subject = "Old".into();
let long = "x".repeat(300);
let long_code = format!("COMMENTS \"{long}\"");
let codes = [
r#"SUBJECT "Quarterly report""#,
"SUBJECT",
r#"TITLE "Plan" \* MERGEFORMAT"#,
r#"AUTHOR "Ada""#,
r#"KEYWORDS "a, b""#,
&long_code,
"DOCPROPERTY Subject",
];
let (mut s, hdr) = header_fields_session(doc, &codes);
s.run("references.updateFields", &json!({})).unwrap();
let got: Vec<String> = story_fields(&s, StoryRef::Part(hdr)).into_iter().map(|(_, r)| r).collect();
assert_eq!(got[..5], ["Quarterly report", "Quarterly report", "Plan", "Ada", "a, b"]);
assert_eq!(got[5].chars().count(), 255);
assert_eq!(got[6], "Quarterly report", "DOCPROPERTY reads the subject the paragraph set");
let c = &s.doc.core;
assert_eq!((c.subject.as_str(), c.title.as_str(), c.creator.as_str(), c.keywords.as_str()), ("Quarterly report", "Plan", "Ada", "a, b"));
assert_eq!(c.description, got[5]);
}
/// One word after the name sets the property without quotes; a switch alone sets nothing.
#[test]
fn summary_fields_take_one_word_without_quotes() {
let mut doc = wordcraft_doc::Document::from_text("one two three");
doc.core.subject = "Old".into();
doc.core.creator = "Grace".into();
doc.core.title = "Plan".into();
let (mut s, hdr) = header_fields_session(doc, &["SUBJECT Draft", "AUTHOR Ada", r"TITLE \* Upper"]);
s.run("references.updateFields", &json!({})).unwrap();
let got: Vec<String> = story_fields(&s, StoryRef::Part(hdr)).into_iter().map(|(_, r)| r).collect();
assert_eq!(got, ["Draft", "Ada", "PLAN"]);
let c = &s.doc.core;
assert_eq!((c.subject.as_str(), c.creator.as_str(), c.title.as_str()), ("Draft", "Ada", "Plan"));
}
/// `\* Upper`, `\* Lower`, `\* Caps` and `\* FirstCap` apply to the text Update Field computes,
/// and `\* CardText` and `\* OrdText` are lowercase unless one of them follows.
#[test]
fn update_fields_apply_the_capitalization_switches() {
let mut doc = wordcraft_doc::Document::from_text("one two three");
doc.core.subject = "budget".into();
doc.core.creator = "Ada LOVELACE".into();
doc.core.title = "weekly report on sales".into();
doc.core.description = "weekly report on sales".into();
doc.core.last_modified_by = "grace".into();
doc.core.revision = 7;
let codes = [
r"SUBJECT \* Upper",
r"AUTHOR \* Lower",
r"TITLE \* Caps \* MERGEFORMAT",
r"COMMENTS \*FirstCap",
r"LASTSAVEDBY \* upper",
r"NUMWORDS \* CardText",
r"NUMWORDS \* CardText \* Caps",
r"REVNUM \* OrdText",
r"REVNUM \* OrdText \* FirstCap",
r"DOCPROPERTY Subject \* Upper",
r#"SUBJECT "new \* Upper""#,
];
let (mut s, hdr) = header_fields_session(doc, &codes);
s.run("references.updateFields", &json!({})).unwrap();
let got: Vec<String> = story_fields(&s, StoryRef::Part(hdr)).into_iter().map(|(_, r)| r).collect();
assert_eq!(
got,
[
"BUDGET",
"ada lovelace",
"Weekly Report On Sales",
"Weekly report on sales",
"GRACE",
"three",
"Three",
"seventh",
"Seventh",
"BUDGET",
"new \\* Upper"
]
);
}
/// The capitalization switches also apply to REF, PAGEREF, SEQ and FILENAME results in the body.
#[test]
fn body_fields_apply_the_capitalization_switches() {
let mut s = Session::new(wordcraft_doc::Document::from_text("Bookmark target text here.\nSecond paragraph."));
s.run("select.text", &json!({"text": "Bookmark target text"})).unwrap();
s.run("insert.bookmark", &json!({"name": "TargetMark"})).unwrap();
s.run("caret.docEnd", &json!({})).unwrap();
for instr in [r"REF TargetMark \* Upper", r"PAGEREF TargetMark \* CardText", r"SEQ Figure \* OrdText \* Caps", r"FILENAME \* Lower"] {
s.run("insert.field", &json!({"instr": instr, "result": "old"})).unwrap();
}
update_fields(&mut s).unwrap();
let got: Vec<String> = fields(&s).into_iter().map(|(_, r)| r).collect();
let name = s.resolved_filenames().0.to_lowercase();
assert_eq!(got, ["BOOKMARK TARGET TEXT".to_string(), "one".into(), "First".into(), name]);
}
/// A count the field's number format does not cover keeps its digits.
#[test]
fn counts_past_the_number_format_keep_their_digits() {
let mut doc = wordcraft_doc::Document::from_text("one two three");
doc.core.revision = 1_000_000;
let codes = [r"REVNUM \* ROMAN", r"REVNUM \* CardText", r"REVNUM \* alphabetic", r"NUMWORDS \* ROMAN"];
let (mut s, hdr) = header_fields_session(doc, &codes);
s.run("references.updateFields", &json!({})).unwrap();
let got: Vec<String> = story_fields(&s, StoryRef::Part(hdr)).into_iter().map(|(_, r)| r).collect();
assert_eq!(got, ["1000000", "1000000", "1000000", "III"]);
}
/// #1706: `all: true` (printing) updates the whole document even with the caret in a header.
#[test]
fn update_fields_all_covers_every_story() {
+150 -7
View File
@@ -36,10 +36,14 @@ pub fn specs() -> Vec<CommandSpec> {
})
.params(r#"{"value"?: bool (omit to toggle)}"#)
.key("Mod+Shift+E"),
CommandSpec::new("review.acceptAll", "Accept All Changes", "Review › Changes", |s, _| resolve_all(s, true)),
CommandSpec::new("review.rejectAll", "Reject All Changes", "Review › Changes", |s, _| resolve_all(s, false)),
CommandSpec::new("review.accept", "Accept", "Review › Changes", |s, _| resolve_sel(s, true)),
CommandSpec::new("review.reject", "Reject", "Review › Changes", |s, _| resolve_sel(s, false)),
CommandSpec::new("review.acceptAll", "Accept All Changes", "Review › Changes", |s, v| resolve_all_then(s, v, true))
.params(r#"{"stopTracking"?: bool (also turn Track Changes off)}"#),
CommandSpec::new("review.rejectAll", "Reject All Changes", "Review › Changes", |s, v| resolve_all_then(s, v, false))
.params(r#"{"stopTracking"?: bool (also turn Track Changes off)}"#),
CommandSpec::new("review.accept", "Accept", "Review › Changes", |s, v| resolve_sel_then(s, v, true))
.params(r#"{"next"?: bool (then select the next change)}"#),
CommandSpec::new("review.reject", "Reject", "Review › Changes", |s, v| resolve_sel_then(s, v, false))
.params(r#"{"next"?: bool (then select the next change)}"#),
CommandSpec::new("review.nextChange", "Next Change", "Review › Changes", |s, _| nav_change(s, 1)).pure(),
CommandSpec::new("review.previousChange", "Previous Change", "Review › Changes", |s, _| nav_change(s, -1)).pure(),
CommandSpec::new("review.markup", "Display for Review", "Review › Tracking", |s, v| {
@@ -588,7 +592,21 @@ fn resolve_all(s: &mut Session, accept: bool) -> CmdResult {
sel_result(s)
}
fn resolve_sel(s: &mut Session, accept: bool) -> CmdResult {
/// Accept or Reject All; with `stopTracking`, Track Changes is turned off as well.
fn resolve_all_then(s: &mut Session, v: &Value, accept: bool) -> CmdResult {
let out = resolve_all(s, accept)?;
if p::bool(v, "stopTracking") == Some(true) {
s.doc.settings.track_changes = false;
}
Ok(out)
}
/// Accept or Reject at the selection; with `next`, the next change is selected afterwards.
fn resolve_sel_then(s: &mut Session, v: &Value, accept: bool) -> CmdResult {
resolve_sel(s, accept, p::bool(v, "next") == Some(true))
}
fn resolve_sel(s: &mut Session, accept: bool, next: bool) -> CmdResult {
let (a, b) = s.sel.ordered();
// A caret right before a tracked paragraph mark (and no tracked text around it): that mark.
let mut caret_mark = None;
@@ -650,7 +668,7 @@ fn resolve_sel(s: &mut Session, accept: bool) -> CmdResult {
}
None => {
if resolve_cell(s, a.story, &a.path, accept)? {
return sel_result(s);
return if next { select_next_unresolved(s) } else { sel_result(s) };
}
return nav_change(s, 1);
}
@@ -675,7 +693,7 @@ fn resolve_sel(s: &mut Session, accept: bool) -> CmdResult {
super::moves::resolve(s, &moves, accept)?;
s.sel = Selection::caret(a);
s.clamp_selection();
sel_result(s)
if next { select_next_unresolved(s) } else { sel_result(s) }
}
fn changes(s: &Session) -> Vec<Change> {
@@ -864,6 +882,18 @@ fn nav_change(s: &mut Session, dir: i32) -> CmdResult {
sel_result(s)
}
/// After a change at the caret is resolved: select the first change from the caret on, including
/// one that starts and ends there (an empty paragraph's, a paragraph mark's or a table's
/// formatting), else the first change in the document.
fn select_next_unresolved(s: &mut Session) -> CmdResult {
let list = changes(s);
let caret = s.sel.ordered().0;
if let Some(c) = list.iter().find(|c| c.a >= caret).or(list.first()) {
s.sel = Selection { anchor: c.a.clone(), focus: c.b.clone() };
}
sel_result(s)
}
fn list_changes(s: &mut Session, _: &Value) -> CmdResult {
let list = changes(s);
Ok(Value::Array(
@@ -1675,6 +1705,119 @@ mod tests {
}
}
/// "one two three" with "new " typed at the start, "two " deleted and " end" typed at the end,
/// under Track Changes: three changes.
fn three_changes() -> Session {
let mut s = Session::new(Document::new());
s.view.proofing = false;
s.author = "Ana".into();
s.run("document.setText", &json!({"text": "one two three"})).unwrap();
s.run("review.trackChanges", &json!({"value": true})).unwrap();
s.run("caret.docStart", &json!({})).unwrap();
s.run("text.insert", &json!({"text": "new "})).unwrap();
s.run("select.range", &json!({"anchor": {"block": 0, "off": 8}, "focus": {"block": 0, "off": 12}})).unwrap();
s.run("text.delete", &json!({})).unwrap();
s.run("caret.docEnd", &json!({})).unwrap();
s.run("text.insert", &json!({"text": " end"})).unwrap();
s
}
fn change_spans(s: &mut Session) -> Vec<(String, u64, u64)> {
let list = s.run("review.changes", &json!({})).unwrap();
list.as_array()
.unwrap()
.iter()
.map(|c| (c["kind"].as_str().unwrap().to_string(), c["start"]["off"].as_u64().unwrap(), c["end"]["off"].as_u64().unwrap()))
.collect()
}
fn selected(s: &Session) -> (usize, usize) {
let (a, b) = s.sel.ordered();
(a.off, b.off)
}
/// Accept and Move to Next, Reject and Move to Next: the change at the caret is resolved and
/// the next one is selected; without `next` the caret stays where the change was.
#[test]
fn accept_and_reject_can_move_to_the_next_change() {
for (cmd, accept) in [("review.accept", true), ("review.reject", false)] {
let mut s = three_changes();
let before = change_spans(&mut s);
assert_eq!(before.iter().map(|c| c.0.as_str()).collect::<Vec<_>>(), ["insert", "delete", "insert"], "{cmd}");
s.run("caret.docStart", &json!({})).unwrap();
s.run(cmd, &json!({"next": true})).unwrap();
let after = change_spans(&mut s);
assert_eq!(after.len(), 2, "{cmd}: the first insertion is resolved");
assert_eq!(after[0].0, "delete", "{cmd}");
let deletion = (after[0].1 as usize, after[0].2 as usize);
assert_eq!(selected(&s), deletion, "{cmd}: the deletion is selected next");
assert_eq!(s.doc.plain_text(wordcraft_doc::StoryRef::Body).starts_with("new "), accept, "{cmd}");
// Without `next`, the caret stays at the resolved change.
let mut s = three_changes();
s.run("caret.docStart", &json!({})).unwrap();
s.run(cmd, &json!({})).unwrap();
assert_eq!(selected(&s), (0, 0), "{cmd}");
}
}
/// With the caret on no change, Accept and Move to Next selects the next change without
/// resolving it, as Accept and Next Change do.
#[test]
fn accept_and_move_to_next_off_a_change_selects_it_unresolved() {
let mut s = three_changes();
s.run("select.range", &json!({"anchor": {"block": 0, "off": 5}, "focus": {"block": 0, "off": 5}})).unwrap();
s.run("review.accept", &json!({"next": true})).unwrap();
let after = change_spans(&mut s);
assert_eq!(after.len(), 3, "nothing is resolved");
assert_eq!(selected(&s), (after[1].1 as usize, after[1].2 as usize), "the deletion after the caret is selected");
}
/// Reject and Move to Next on the only text of a centered paragraph: the paragraph's formatting
/// change, now empty at the caret, is selected next, not the insertion after it.
#[test]
fn move_to_next_selects_a_change_left_at_the_caret() {
let mut s = Session::new(Document::new());
s.view.proofing = false;
s.author = "Ana".into();
s.run("document.setText", &json!({"text": "one\n\nthree"})).unwrap();
s.run("review.trackChanges", &json!({"value": true})).unwrap();
s.run("select.range", &json!({"anchor": {"block": 1, "off": 0}, "focus": {"block": 1, "off": 0}})).unwrap();
s.run("para.align", &json!({"value": "center"})).unwrap();
s.run("text.insert", &json!({"text": "x"})).unwrap();
s.run("caret.docEnd", &json!({})).unwrap();
s.run("text.insert", &json!({"text": "!"})).unwrap();
let before = change_spans(&mut s);
assert_eq!(before.iter().map(|c| c.0.as_str()).collect::<Vec<_>>(), ["format", "insert", "insert"], "{before:?}");
s.run("select.range", &json!({"anchor": {"block": 1, "off": 1}, "focus": {"block": 1, "off": 1}})).unwrap();
s.run("review.reject", &json!({"next": true})).unwrap();
let after = change_spans(&mut s);
assert_eq!(after.iter().map(|c| c.0.as_str()).collect::<Vec<_>>(), ["format", "insert"], "{after:?}");
let (a, b) = s.sel.ordered();
let at = (wordcraft_doc::Path::top(1), 0);
assert_eq!(((a.path.clone(), a.off), (b.path.clone(), b.off)), (at.clone(), at), "the centering is selected");
}
/// Accept All Changes and Stop Tracking, Reject All Changes and Stop Tracking: every change is
/// resolved and Track Changes is off; plain Accept All and Reject All leave it on.
#[test]
fn accept_and_reject_all_can_stop_tracking() {
for cmd in ["review.acceptAll", "review.rejectAll"] {
let mut s = three_changes();
s.run(cmd, &json!({"stopTracking": true})).unwrap();
assert!(change_spans(&mut s).is_empty(), "{cmd}");
assert!(!s.doc.settings.track_changes, "{cmd}: Track Changes is off");
s.run("edit.undo", &json!({})).unwrap();
assert!(s.doc.settings.track_changes, "{cmd}: one undo step brings tracking and the changes back");
assert_eq!(change_spans(&mut s).len(), 3, "{cmd}");
let mut s = three_changes();
s.run(cmd, &json!({})).unwrap();
assert!(change_spans(&mut s).is_empty(), "{cmd}");
assert!(s.doc.settings.track_changes, "{cmd}: Track Changes stays on");
}
}
/// "plain words here" with "words" made bold, then 14 pt, and the paragraph centred, under
/// Track Changes by Ana.
fn formatted_while_tracking() -> Session {
+146 -22
View File
@@ -4,7 +4,7 @@ use super::table_formula::parse_number;
use serde_json::{Value, json};
use wordcraft_doc::collate::{SortKey, sort_key};
use wordcraft_doc::props::{Align, Border, BorderStyle, Borders, HeightRule, Rgb, TextDirection, VAlign};
use wordcraft_doc::{Block, Document, Paragraph, Path, Pos, RevisionKind, StoryRef, Table, para_block};
use wordcraft_doc::{Block, Document, InlineObject, Paragraph, Path, Pos, RevisionKind, StoryRef, Table, para_block};
use super::sel_result;
use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
@@ -171,7 +171,8 @@ pub fn specs() -> Vec<CommandSpec> {
.params(r#"{"text"?: string, "title"?: string} (empty clears)"#)),
t(CommandSpec::new("table.sort", "Sort", "Table Layout › Data", sort_table)
.params(r#"{"column"?: n, "descending"?: bool, "header"?: bool}"#)),
t(CommandSpec::new("table.toText", "Convert to Text", "Table Layout › Data", to_text).params(r#"{"separator"?: "tab|comma|paragraph"}"#)),
t(CommandSpec::new("table.toText", "Convert to Text", "Table Layout › Data", to_text)
.params(r#"{"separator"?: "tab|comma|paragraph|other", "char"?: string (with other)} (the selected rows, or the whole table)"#)),
t(CommandSpec::new("table.formula", "Formula", "Table Layout › Data", formula).params(r#"{"formula"?: "=SUM(ABOVE)"}"#)),
t(CommandSpec::new("table.selectTable", "Select Table", "Table Layout › Table › Select", |s, _| {
let (tp, _, _) = cell(s)?;
@@ -844,11 +845,7 @@ fn split_table(s: &mut Session, _: &Value) -> CmdResult {
}
let story = s.sel.focus.story;
let t = s.doc.table_mut(story, &tp)?;
let tail_rows = t.rows.split_off(r);
// Content controls closing after the table now close after its second part.
let close = std::mem::take(&mut t.controls.close);
let controls = wordcraft_doc::ControlWrap { open: Vec::new(), close };
let tail = Table { props: t.props.clone(), grid: t.grid.clone(), rows: tail_rows, controls };
let tail = t.split_off(r);
let after = tp.with_last(tp.last() + 1);
s.doc.insert_block(story, &after, Block::Para(Paragraph::new()))?;
s.doc.insert_block(story, &after.with_last(after.last() + 1), Block::Table(tail))?;
@@ -948,26 +945,153 @@ fn sort_table(s: &mut Session, v: &Value) -> CmdResult {
})
}
/// Table Layout › Data › Convert to Text: the selected rows, or the whole table with the caret
/// in one cell, become paragraphs. Each row is one paragraph with its cells set apart by the
/// separator (a tab, a comma or another character); with `paragraph` each cell paragraph
/// stays a separate paragraph. The text keeps its formatting and objects, a table nested in a cell stays
/// a table, and rows above and below the selected ones stay a table each.
fn to_text(s: &mut Session, v: &Value) -> CmdResult {
let (tp, _, _) = cell(s)?;
let sep = match p::str(v, "separator").unwrap_or("tab") {
"comma" => ",",
"paragraph" => "\n",
_ => "\t",
let sep = match p::str(v, "separator") {
None | Some("tab") => Some('\t'),
Some("comma") => Some(','),
Some("paragraph") => None,
Some("other") => match p::str(v, "char").and_then(|c| c.chars().next()) {
// U+FFFC marks an object in the text, so it cannot set the cells apart.
Some(c) if c != '\n' && c != '\r' && c != wordcraft_doc::para::OBJ => Some(c),
_ => return Err(CmdError::Params("`char` (the separating character) is required with `other`".into())),
},
Some(x) => return Err(CmdError::Params(format!("unknown separator `{x}`"))),
};
let story = s.sel.focus.story;
let t = s.doc.table(story, &tp).cloned().ok_or_else(|| CmdError::Failed("no table".into()))?;
let mut t = s.doc.table(story, &tp).cloned().ok_or_else(|| CmdError::Failed("no table".into()))?;
// Several cells selected: their rows. A caret (or a selection inside one cell): every row.
let cells = selected_cells(s);
let (r0, r1) = match (cells.iter().map(|x| x.0).min(), cells.iter().map(|x| x.0).max()) {
(Some(a), Some(b)) if cells.len() > 1 || s.cell_range().is_some() => (a, b.min(t.rows.len().saturating_sub(1))),
_ => (0, t.rows.len().saturating_sub(1)),
};
// `split_off` ends the vertical merges and row content controls that a cut crosses, and opens
// them again (without their ids) below it.
let mut tail = t.split_off(r1 + 1);
let rows = t.split_off(r0).rows;
t.controls.close = std::mem::take(&mut tail.controls.close);
let mut blocks = Vec::new();
if !t.rows.is_empty() {
let mut head = Table { props: t.props.clone(), grid: t.grid.clone(), rows: std::mem::take(&mut t.rows), controls: Default::default() };
head.controls.open = std::mem::take(&mut t.controls.open);
blocks.push(Block::Table(head));
}
let text_from = blocks.len();
for row in rows {
row_to_text(row, sep, &mut blocks);
}
// Rows without cells (only from a damaged file) still leave a paragraph in their place.
if blocks.len() == text_from {
blocks.push(Block::Para(Paragraph::new()));
}
// The caret goes to the first converted paragraph (into a nested table when there is none).
let caret = match blocks.iter().enumerate().skip(text_from).find(|(_, b)| matches!(b, Block::Para(_))) {
Some((k, _)) => tp.with_last(tp.last() + k),
None => Path(tp.with_last(tp.last() + text_from).0.into_iter().chain([0, 0, 0]).collect()),
};
if !tail.rows.is_empty() {
tail.controls.close = std::mem::take(&mut t.controls.close);
// A floating table keeps its position in the part above; the part below goes in the text
// flow.
if text_from > 0 {
tail.props.float = None;
}
blocks.push(Block::Table(tail));
}
// A content control around the table keeps both ends: on the text where no table part is
// left to carry them.
let (open, close) = (std::mem::take(&mut t.controls.open), std::mem::take(&mut t.controls.close));
match blocks.get_mut(text_from) {
Some(Block::Para(p)) => {
for (k, mut control) in open.into_iter().enumerate() {
control.block = true;
let start = InlineObject::ControlStart { control: Box::new(control) };
p.insert_object(k * wordcraft_doc::para::OBJ.len_utf8(), start, &Default::default())?;
}
}
Some(Block::Table(x)) => {
let inner = std::mem::replace(&mut x.controls.open, open);
x.controls.open.extend(inner);
}
None => {}
}
match blocks.last_mut() {
Some(Block::Para(p)) => {
// The count is read from the file; at most 1,024 ends go onto the text.
for _ in 0..close.min(1024) {
let end = p.len();
p.insert_object(end, InlineObject::ControlEnd, &Default::default())?;
}
}
Some(Block::Table(x)) => x.controls.close = x.controls.close.saturating_add(close),
None => {}
}
s.doc.remove_block(story, &tp)?;
let mut i = tp.last();
for row in &t.rows {
let text: Vec<String> = row.cells.iter().map(cell_text).collect();
for line in text.join(sep).split('\n') {
s.doc.insert_block(story, &tp.with_last(i), Block::Para(Paragraph::with_text(line, Default::default())))?;
i += 1;
for (k, b) in blocks.into_iter().enumerate() {
s.doc.insert_block(story, &tp.with_last(tp.last() + k), b)?;
}
s.sel = Selection::caret(s.doc.clamp(&Pos { story, path: caret, off: 0 }));
sel_result(s)
}
/// One row of Convert to Text as blocks: a paragraph per row (and per further paragraph in a
/// cell) with the cells set apart by `sep`, or each cell's blocks as they are without one.
fn row_to_text(row: wordcraft_doc::Row, sep: Option<char>, out: &mut Vec<Block>) {
let Some(sep) = sep else {
for b in row.cells.into_iter().flat_map(|c| c.blocks) {
out.push(match std::sync::Arc::unwrap_or_clone(b) {
Block::Para(mut para) => {
para.section = None;
para.touch();
Block::Para(para)
}
other => other,
});
}
return;
};
let mut line: Option<Paragraph> = None;
for (ci, cell) in row.cells.into_iter().enumerate() {
if ci > 0 {
// The separator takes no formatting from the text before it.
line.get_or_insert_with(Paragraph::new).append(Paragraph::with_text(sep.encode_utf8(&mut [0; 4]), Default::default()));
}
let mut first = true;
for b in cell.blocks {
match std::sync::Arc::unwrap_or_clone(b) {
Block::Para(mut para) => {
para.section = None;
match line.as_mut() {
Some(p) if first => p.append(para),
_ => {
if let Some(done) = line.take() {
out.push(Block::Para(done));
}
para.touch();
line = Some(para);
}
}
}
other => {
// A nested table stays a table between the row's paragraphs.
if let Some(done) = line.take() {
out.push(Block::Para(done));
}
out.push(other);
}
}
first = false;
}
}
s.sel = Selection::caret(Pos { story, path: tp, off: 0 });
sel_result(s)
if let Some(done) = line {
out.push(Block::Para(done));
}
}
/// Table Layout › Data › Formula: inserts a formula field at the caret, evaluated now and again
@@ -998,8 +1122,8 @@ fn formula(s: &mut Session, v: &Value) -> CmdResult {
Ok(json!({"result": text, "formula": instr}))
}
/// A formula as a field stores it: upper case, with one leading `=`. A `\#` numeric picture is
/// kept as typed.
/// A formula as a field stores it: upper case, with one leading `=`. The `\#` numeric picture and
/// the `\*` switches are kept as typed.
pub(crate) fn formula_instr(raw: &str) -> String {
let body = raw.trim();
let body = body.strip_prefix('=').unwrap_or(body);
+90 -7
View File
@@ -59,7 +59,8 @@ pub fn format_result(v: f64) -> String {
}
/// The text a formula field shows: its result under its `\#` numeric picture when it has one (see
/// [`format_picture`]), otherwise as [`format_result`] shows it.
/// [`format_picture`]), otherwise as [`format_result`] shows it, and then under its `\*` switches:
/// a number format such as `\* CardText`, then a capitalization switch such as `\* Upper`.
pub fn evaluate_field(t: &Table, r: usize, c: usize, instr: &str) -> Result<String, CmdError> {
evaluate_field_with(t, None, None, r, c, instr)
}
@@ -74,7 +75,7 @@ pub fn evaluate_field_with(
) -> Result<String, CmdError> {
let (formula, switch) = split_switch(instr);
let value = evaluate_with(t, doc, default_lang, r, c, formula)?;
Ok(match switch.and_then(picture_of) {
let text = match switch.and_then(picture_of) {
Some(picture) => format_picture(value, &picture),
None => {
let out_lang = doc
@@ -97,21 +98,63 @@ pub fn evaluate_field_with(
.or_else(|| default_lang.map(str::to_string));
wordcraft_doc::locale_number::format_number(value, out_lang.as_deref())
}
};
Ok(match switch {
Some(switch) => format_switches(switch, value, text),
None => text,
})
}
/// Splits a field's `\#` switch off its formula: `=SUM(ABOVE) \# "0.00"` gives the formula and the
/// switch text (from the `\#` on).
/// The text of a formula result under the field's `\*` switches, where `text` is `value` formatted
/// with the `\#` picture, or in the cell's language when there is no picture. A number format such
/// as `\* CardText`, `\* OrdText` or `\* roman` writes a whole number the format covers (up to
/// 999,999 for CardText and OrdText, 1 to 50,000 for roman, 1 to 780 for alphabetic), in lowercase
/// for CardText and OrdText. `\* Hex` writes a whole number from 0 to 4,294,967,295 in uppercase
/// hexadecimal digits. `\* DollarText` writes a result from 0 to 999,999.99 in lowercase cardinal
/// text with its first two decimals (rounded) over 100 (see
/// [`wordcraft_layout::fields::dollar_text`]). Any other result keeps its digits. The capitalization
/// switches (`\* Upper`, `\* Lower`, `\* Caps`, `\* FirstCap`) then apply to the text in order. Other
/// `\*` switches leave the text as it is. Quoted text, such as a `\#` picture, is not a switch.
fn format_switches(switches: &str, value: f64, text: String) -> String {
use wordcraft_doc::section::NumFormat;
let unquoted = switches.split('"').step_by(2).collect::<Vec<_>>().join(" ");
let fmt = wordcraft_layout::fields::format_switch(&unquoted).filter(|f| *f != NumFormat::Decimal);
match fmt {
Some(NumFormat::DollarText) => {
let text = wordcraft_layout::fields::dollar_text(value).unwrap_or(text);
wordcraft_layout::fields::apply_case_switch(switches, text)
}
Some(fmt) if number_format_covers(fmt, value) => wordcraft_layout::fields::field_number(switches, fmt, value as u32),
_ => wordcraft_layout::fields::apply_case_switch(switches, text),
}
}
/// Whether the `\*` number format `fmt` writes `value`: a whole number up to 999,999 for CardText
/// and OrdText, 1 to 50,000 for roman, 1 to 780 for alphabetic, and 0 to `u32::MAX` otherwise.
pub(crate) fn number_format_covers(fmt: wordcraft_doc::section::NumFormat, value: f64) -> bool {
use wordcraft_doc::section::NumFormat;
let range = match fmt {
NumFormat::CardinalText | NumFormat::OrdinalText => 0.0..=999_999.0,
NumFormat::LowerRoman | NumFormat::UpperRoman => 1.0..=50_000.0,
NumFormat::LowerLetter | NumFormat::UpperLetter => 1.0..=780.0,
_ => 0.0..=f64::from(u32::MAX),
};
value.fract() == 0.0 && range.contains(&value)
}
/// Splits a field's switches (`\#` and `\*`) off its formula: `=SUM(ABOVE) \# "0.00"` gives the
/// formula and the switch text (from the first switch on).
pub fn split_switch(instr: &str) -> (&str, Option<&str>) {
match instr.find("\\#") {
match [instr.find("\\#"), instr.find("\\*")].into_iter().flatten().min() {
Some(at) => (instr[..at].trim_end(), Some(&instr[at..])),
None => (instr, None),
}
}
/// The picture a `\#` switch names: a quoted one (`"#,##0.00"`) or a single word.
/// The picture of the `\#` switch in a field's switch text: a quoted one (`"#,##0.00"`) or a
/// single word.
fn picture_of(switch: &str) -> Option<String> {
let rest = switch.strip_prefix("\\#")?.trim_start();
let rest = switch[switch.find("\\#")? + 2..].trim_start();
let picture = match rest.strip_prefix('"') {
Some(quoted) => quoted.split('"').next().unwrap_or_default(),
None => rest.split_whitespace().next().unwrap_or_default(),
@@ -954,6 +997,46 @@ mod tests {
assert_eq!(shown("=A1 \\# \"\""), "1234.50", "an empty picture is none");
}
#[test]
fn a_format_switch_writes_the_result_as_text_and_sets_its_capitals() {
let t = table(&[&["790"], &["1.5"], &[""]]);
assert_eq!(split_switch("=A1 \\* Caps \\# 0"), ("=A1", Some("\\* Caps \\# 0")));
let shown = |f: &str| evaluate_field(&t, 2, 0, f).unwrap_or_else(|e| panic!("{f}: {e}"));
assert_eq!(shown("=A1 \\* CardText"), "seven hundred ninety");
assert_eq!(shown("=A1 \\* CardText \\* Caps"), "Seven Hundred Ninety");
assert_eq!(shown("=A1 \\* OrdText \\* FirstCap"), "Seven hundred ninetieth");
assert_eq!(shown("=A1 \\* roman"), "dccxc");
assert_eq!(shown("=A1 \\* MERGEFORMAT"), "790", "a switch that formats nothing");
assert_eq!(shown("=A1 \\# \"#,##0.00\" \\* MERGEFORMAT"), "790.00");
assert_eq!(shown("=A1 \\* MERGEFORMAT \\# 0.0"), "790.0", "a picture after a format switch");
assert_eq!(shown("=A1 \\# \"0 \\* CardText items\""), "790 \\* CardText items", "a switch inside the picture is text");
assert_eq!(shown("=A2 \\* CardText"), "1.50", "a fraction keeps its digits");
assert_eq!(shown("=A1-800 \\* CardText"), "-10", "a negative number keeps its digits");
assert_eq!(shown("=A1*1266 \\* CardText"), "1000140", "a number past CardText keeps its digits");
assert_eq!(shown("=A1*1266 \\* OrdText \\* Upper"), "1000140");
assert_eq!(shown("=A1*64 \\* roman"), "50560", "a number past roman keeps its digits");
assert_eq!(shown("=A1-790 \\* roman"), "0", "roman has no zero");
assert_eq!(shown("=A1 \\* alphabetic"), "790", "a number past alphabetic keeps its digits");
}
#[test]
fn dollar_text_and_hex_switches_write_the_result() {
let t = table(&[&["9.20", "5.35"], &["458", "-1"], &["", ""]]);
let shown = |f: &str| evaluate_field(&t, 2, 0, f).unwrap_or_else(|e| panic!("{f}: {e}"));
assert_eq!(shown("= 9.20 + 5.35 \\* DollarText \\* Upper"), "FOURTEEN AND 55/100");
assert_eq!(shown("=A1+B1 \\* DollarText"), "fourteen and 55/100");
assert_eq!(shown("=A2 \\* DollarText \\* FirstCap"), "Four hundred fifty-eight and 00/100");
assert_eq!(shown("=0.999 \\* DollarText"), "one and 00/100");
assert_eq!(shown("=A2 \\* Hex"), "1CA");
assert_eq!(shown("=A2 \\* Hex \\* Lower"), "1ca");
assert_eq!(shown("=A2*0 \\* Hex"), "0");
assert_eq!(shown("=B2 \\* DollarText"), shown("=B2"), "a negative number keeps its digits");
assert_eq!(shown("=B2 \\* Hex"), shown("=B2"));
assert_eq!(shown("=A2/4 \\* Hex"), shown("=A2/4"), "a fraction keeps its digits");
assert_eq!(shown("=A2*2200 \\* DollarText"), shown("=A2*2200"), "a number past DollarText keeps its digits");
assert_eq!(shown("=A2 \\* ArabicDash"), "- 458 -");
}
#[test]
fn a_huge_range_over_a_small_table_reads_only_the_table() {
let t = table(&[&["1", "2", "3"], &["4", "5", "6"], &["", "", ""]]);
+27 -2
View File
@@ -269,7 +269,7 @@ fn border_line(s: &Session, at: &Pos) -> Option<Border> {
|| at.off != text.len()
|| text.len() < 3
|| !text.chars().all(|x| x == c)
|| para.props.numbering.is_some_and(|n| n.num != 0)
|| s.doc.styles.resolve_para(&para.props).numbering.is_some_and(|n| n.num != 0)
|| para.run_ranges().any(|(_, r)| r.del.is_some())
{
return None;
@@ -296,7 +296,7 @@ fn table_line(s: &Session, at: &Pos) -> Option<Vec<usize>> {
|| at.story != StoryRef::Body
|| at.path.0.len() != 1
|| at.off != text.len()
|| para.props.numbering.is_some_and(|n| n.num != 0)
|| s.doc.styles.resolve_para(&para.props).numbering.is_some_and(|n| n.num != 0)
|| para.run_ranges().any(|(_, c)| c.del.is_some())
{
return None;
@@ -722,6 +722,31 @@ fn new_paragraph(s: &mut Session, _: &Value) -> CmdResult {
}
// Enter finishes a word like a space does ("teh" → "the", sentence capitals, links).
if s.sel.is_collapsed() && at.off > 0 {
// A `*bold*` or `_italic_` word: like the border lines above, Undo right after brings
// the marks back and the Enter stays.
let plain = super::tools::has_emphasis(s, true).then(|| (s.doc.clone(), s.sel.clone()));
if let Some((doc, sel)) = plain {
// Removes the marks, then AutoCorrects the word as without them.
super::tools::autocorrect_word(s, true)?;
s.autocap.take_if(|c| c.depth == u64::MAX);
let (done_doc, done_sel) = (std::mem::replace(&mut s.doc, doc), std::mem::replace(&mut s.sel, sel));
let new = split_para(s, &at)?;
let c = s.doc.para_at(&new).map(|p| p.mark.clone()).unwrap_or_default();
s.sel = Selection::caret(new);
let plain_doc = std::mem::replace(&mut s.doc, done_doc);
let plain_sel = std::mem::replace(&mut s.sel, done_sel);
let new = split_para(s, &s.sel.focus.clone())?;
let p = s.doc.para_mut(new.story, &new.path)?;
// The new paragraph is formatted as after a plain Enter (the closing mark's
// formatting, or none when the next style starts afresh); its mark keeps its revisions.
let (ins, del, fmt_change) = (p.mark.ins, p.mark.del, p.mark.fmt_change.take());
p.mark = wordcraft_doc::props::CharProps { ins, del, fmt_change, ..c };
p.touch();
s.sel = Selection::caret(new);
s.goal_x = None;
s.push_undo("AutoFormat", plain_doc, plain_sel);
return sel_result(s);
}
super::tools::autocorrect_word(s, true)?;
s.autocap.take_if(|c| c.depth == u64::MAX);
at = s.sel.focus.clone();
+105 -5
View File
@@ -93,6 +93,9 @@ pub struct AutoCorrectPrefs {
pub fractions: bool,
/// Ordinals (1st) with superscript.
pub ordinals: bool,
/// *Bold* and _italic_ with real formatting: the marks are removed and the text between them
/// is made bold or italic. Off unless the user turns it on.
pub bold_italic: bool,
/// Hyphens (--) with dashes.
pub dashes: bool,
/// Internet addresses with hyperlinks.
@@ -141,6 +144,7 @@ impl Default for AutoCorrectPrefs {
smart_quotes: true,
fractions: true,
ordinals: true,
bold_italic: false,
dashes: true,
links: true,
bullets: true,
@@ -332,6 +336,7 @@ fn autocorrect_options(s: &mut Session, v: &Value) -> CmdResult {
("smartQuotes", &mut ac.smart_quotes),
("fractions", &mut ac.fractions),
("ordinals", &mut ac.ordinals),
("boldItalic", &mut ac.bold_italic),
("dashes", &mut ac.dashes),
("links", &mut ac.links),
("bullets", &mut ac.bullets),
@@ -407,6 +412,7 @@ fn autocorrect_options(s: &mut Session, v: &Value) -> CmdResult {
"smartQuotes": ac.smart_quotes,
"fractions": ac.fractions,
"ordinals": ac.ordinals,
"boldItalic": ac.bold_italic,
"dashes": ac.dashes,
"links": ac.links,
"bullets": ac.bullets,
@@ -451,7 +457,7 @@ pub fn specs() -> Vec<CommandSpec> {
.pure(),
CommandSpec::new("tools.autocorrect", "AutoCorrect Options", "File › Options › Proofing", autocorrect_options)
.params(
r#"{"enabled"?: bool (all of AutoCorrect), "replaceText"|"twoInitialCaps"|"capSentences"|"capCells"|"capDays"|"capsLock"|"smartQuotes"|"fractions"|"ordinals"|"dashes"|"links"|"bullets"|"numbering"|"borderLines"?: bool, "tabIndent"?: bool (Set left- and first-indent with tabs and backspaces; default false), "headingStyles"?: bool (Built-in Heading styles; default false), "autoAddFirstLetter"|"autoAddInitialCaps"?: bool (Automatically add words to the exception lists when an automatic capital is undone; default true), "add"?: {"from": string, "to": string} (adds or replaces an entry), "delete"?: string (an entry's "from", built in or added), "addException"?: string, "deleteException"?: string, "addInitialCapsException"?: string (keeps TWo INitial CApitals, like "IDs"), "deleteInitialCapsException"?: string} → options, entries and exceptions"#,
r#"{"enabled"?: bool (all of AutoCorrect), "replaceText"|"twoInitialCaps"|"capSentences"|"capCells"|"capDays"|"capsLock"|"smartQuotes"|"fractions"|"ordinals"|"dashes"|"links"|"bullets"|"numbering"|"borderLines"?: bool, "boldItalic"?: bool (*bold* and _italic_ with real formatting; default false), "tabIndent"?: bool (Set left- and first-indent with tabs and backspaces; default false), "headingStyles"?: bool (Built-in Heading styles; default false), "autoAddFirstLetter"|"autoAddInitialCaps"?: bool (Automatically add words to the exception lists when an automatic capital is undone; default true), "add"?: {"from": string, "to": string} (adds or replaces an entry), "delete"?: string (an entry's "from", built in or added), "addException"?: string, "deleteException"?: string, "addInitialCapsException"?: string (keeps TWo INitial CApitals, like "IDs"), "deleteInitialCapsException"?: string} → options, entries and exceptions"#,
)
.pure(),
CommandSpec::new("tools.customizeKeyboard", "Customize Keyboard", "File › Options", customize_keyboard)
@@ -891,6 +897,8 @@ pub fn autocorrect_word(s: &mut Session, on_enter: bool) -> Result<(), CmdError>
if !s.prefs.autocorrect.enabled {
return Ok(());
}
// The word without its marks goes on through the steps below ("*teh* " → "The ").
let outside = emphasis_autoformat(s, on_enter)?;
let ac = s.prefs.autocorrect.clone();
let mut f = s.sel.focus.clone();
let Some(text) = s.doc.para_at(&f).map(|p| p.text.clone()) else { return Ok(()) };
@@ -903,11 +911,20 @@ pub fn autocorrect_word(s: &mut Session, on_enter: bool) -> Result<(), CmdError>
let Some(body) = before.get(..before.len() - trigger) else { return Ok(()) };
// The word starts after the last white-space character, whatever its width (a no-break
// space is two bytes and an ideographic space is three).
let start = body.char_indices().rev().find(|(_, c)| c.is_whitespace()).map(|(i, c)| i + c.len_utf8()).unwrap_or(0);
let mut start = body.char_indices().rev().find(|(_, c)| c.is_whitespace()).map(|(i, c)| i + c.len_utf8()).unwrap_or(0);
let Some(mut word) = body.get(start..).map(str::to_string) else { return Ok(()) };
if word.is_empty() {
return Ok(());
}
// The opening mark of a `*bold*` or `_italic_` span is not part of the word, so the first
// word of a span of several words is corrected as it is typed ("*teh end*" → "*the end*").
let mark = word.chars().next().filter(|c| ac.bold_italic && (*c == '*' || *c == '_'));
let mut opened = false;
if let Some(rest) = mark.and_then(|m| word.get(1..).filter(|r| !r.is_empty() && !r.contains(m))) {
start += 1;
word = rest.to_string();
opened = true;
}
// The word's language (run, paragraph style, document default) picks the replacement list
// (#835) and the case mapping (#1923). Text with no language at all is in the editing
// language (a Polish interface writes Polish), else English.
@@ -992,14 +1009,16 @@ pub fn autocorrect_word(s: &mut Session, on_enter: bool) -> Result<(), CmdError>
c.link = Some(href.clone());
c.style = Some("Hyperlink".into());
})?;
// Typing after the link isn't part of it (but keeps the text's other formatting).
let mut after = s.doc.para_at(&f).map(|p| p.props_of_char(start).clone()).unwrap_or_default();
// Typing after the link isn't part of it (but keeps the text's other formatting, or
// the formatting outside a `*bold*` or `_italic_` span).
let mut after = outside.unwrap_or_else(|| s.doc.para_at(&f).map(|p| p.props_of_char(start).clone()).unwrap_or_default());
after.link = None;
after.style = None;
s.pending = Some(after);
return Ok(());
}
let before_word = text.get(..start).unwrap_or("");
// A sentence starts before the opening mark, as for the word without it.
let before_word = text.get(..start - usize::from(opened)).unwrap_or("");
// The first word in a table cell follows its own option; other sentences the sentence one.
let sentence_on = if before_word.trim().is_empty() && f.path.cell().is_some() { ac.cap_cells } else { ac.cap_sentences };
let exceptions = ac.exception_list();
@@ -1129,6 +1148,87 @@ fn case_fix(caps: &AutoCorrectPrefs, word: &str, turkic: bool) -> Option<(usize,
None
}
/// *Bold* and _italic_ with real formatting, for the word ending at the caret (`on_enter` as for
/// [`autocorrect_word`]): the two marks are removed and the text between them is made bold or
/// italic, for plain and complex-script text. Returns the closing mark's formatting, which is
/// the formatting outside the span, when anything changed.
pub fn emphasis_autoformat(s: &mut Session, on_enter: bool) -> Result<Option<wordcraft_doc::props::CharProps>, CmdError> {
let Some((open, close, bold)) = emphasis_at_caret(s, on_enter) else { return Ok(None) };
let f = s.sel.focus.clone();
let para = s.doc.para_mut(f.story, &f.path)?;
let outside = para.props_of_char(close).clone();
para.delete(close, close + 1)?;
para.delete(open, open + 1)?;
para.format(open, close - 1, &|c| {
if bold {
c.bold = Some(true);
c.bold_cs = Some(true);
} else {
c.italic = Some(true);
c.italic_cs = Some(true);
}
})?;
s.sel = Selection::caret(Pos { off: f.off.saturating_sub(2), ..f });
Ok(Some(outside))
}
/// Would [`emphasis_autoformat`] change the word ending at the caret?
pub fn has_emphasis(s: &Session, on_enter: bool) -> bool {
emphasis_at_caret(s, on_enter).is_some()
}
/// The `*bold*` or `_italic_` span ending at the caret (see [`emphasis_span`]) when the option is
/// on. With Track Changes on, only the current author's tracked insertions are converted, since
/// removing the marks from other text would not be tracked. Nothing is converted while the
/// document limits formatting to its styles.
fn emphasis_at_caret(s: &Session, on_enter: bool) -> Option<(usize, usize, bool)> {
// Formatting limited to the document's styles allows no direct bold or italic.
let styles_only = super::protect::enforced(&s.doc) && s.doc.settings.protect_formatting;
if !(s.prefs.autocorrect.enabled && s.prefs.autocorrect.bold_italic) || styles_only {
return None;
}
let f = &s.sel.focus;
let para = s.doc.para_at(f)?;
let before = para.text.get(..f.off)?;
let trigger = match before.chars().next_back() {
_ if on_enter => 0,
Some(c) if c == ' ' || ",.;:!?".contains(c) => c.len_utf8(),
_ => return None,
};
let span = before.get(..before.len() - trigger).and_then(emphasis_span)?;
let tracked = |(open, close, _): &(usize, usize, bool)| {
!s.doc.settings.track_changes
|| para
.run_ranges()
.filter(|(r, _)| r.start <= *close && r.end > *open)
.all(|(_, p)| p.del.is_none() && p.ins.and_then(|i| s.doc.revisions.get(i as usize)).is_some_and(|rv| rv.author == s.author))
};
Some(span).filter(tracked)
}
/// The marks of a `*bold*` or `_italic_` span that `text` ends with: the byte offsets of the
/// opening and closing mark, and whether it is bold. The opening mark starts a word (it follows
/// the paragraph start, white space or an opening bracket or quote), the text between the marks
/// holds a letter or digit and neither starts nor ends with white space, so `2*3*`, `file_name_`
/// and `*?!*` stay as typed. Text holding an object (a picture, field or content control) is left
/// alone.
fn emphasis_span(text: &str) -> Option<(usize, usize, bool)> {
let mark = text.chars().next_back().filter(|c| *c == '*' || *c == '_')?;
let close = text.len() - 1;
let open = text.get(..close)?.rfind(mark)?;
let inner = text.get(open + 1..close)?;
let edge_ok = |c: Option<char>| c.is_some_and(|c| !c.is_whitespace());
if !edge_ok(inner.chars().next())
|| !edge_ok(inner.chars().next_back())
|| !inner.chars().any(char::is_alphanumeric)
|| inner.contains(wordcraft_doc::para::OBJ)
{
return None;
}
let starts_word = text.get(..open)?.chars().next_back().is_none_or(|c| c.is_whitespace() || "([{\"'“‘".contains(c));
starts_word.then_some((open, close, mark == '*'))
}
/// Byte length of an ordinal suffix to superscript ("1st" → 2), if `word` is a correct ordinal.
fn ordinal_suffix(word: &str) -> Option<usize> {
let digits = word.find(|c: char| !c.is_ascii_digit())?;
+308
View File
@@ -1969,6 +1969,65 @@ fn delete_row_on_a_merge_start_promotes_the_cell_below() {
assert_eq!(vm(&s, 1), VMerge::Continue);
}
/// Split Table under a vertically merged cell: the merge ends in the first table and starts
/// again in the second.
#[test]
fn split_table_ends_and_restarts_a_merge_it_cuts() {
use wordcraft_doc::props::VMerge::{self, Continue, None as No, Restart};
let cell = |r: u32, c: u32| json!({"story": "body", "path": [0, r, c, 0], "off": 0});
let tables = |s: &Session| -> Vec<Vec<Vec<VMerge>>> {
let rows = |t: &wordcraft_doc::Table| t.rows.iter().map(|r| r.cells.iter().map(|c| c.props.vmerge).collect()).collect();
s.doc.body.iter().filter_map(|b| b.as_table()).map(rows).collect()
};
let merged = |last: u32| {
let mut s = table_from("A\tB\nC\tD\nE\tF\nG\tH");
run(&mut s, "select.range", json!({"anchor": cell(0, 0), "focus": cell(last, 0)}));
run(&mut s, "table.merge", json!({}));
s
};
// Rows 0 to 2 merged, split at row 1: the second table's merge covers rows 1 and 2.
let mut s = merged(2);
let before = tables(&s);
run(&mut s, "caret.set", json!({"pos": cell(1, 1)}));
run(&mut s, "table.splitTable", json!({}));
assert_eq!(tables(&s), [vec![vec![No, No]], vec![vec![Restart, No], vec![Continue, No], vec![No, No]]]);
run(&mut s, "edit.undo", json!({}));
assert_eq!(tables(&s), before);
// Rows 0 and 1 merged, split at row 1: neither part keeps a merge.
let mut s = merged(1);
run(&mut s, "caret.set", json!({"pos": cell(1, 1)}));
run(&mut s, "table.splitTable", json!({}));
assert_eq!(tables(&s), [vec![vec![No, No]], vec![vec![No, No], vec![No, No], vec![No, No]]]);
}
/// Split Table inside a row content control (a repeating section) around several rows: the
/// control closes after the first table and opens again before the second.
#[test]
fn split_table_keeps_a_row_control_on_both_parts() {
let cell = |r: u32, c: u32| json!({"story": "body", "path": [0, r, c, 0], "off": 0});
let control = |tag: &str| wordcraft_doc::ContentControl { id: Some(7), tag: tag.into(), ..Default::default() };
let mut s = table_from("A\tB\nC\tD\nE\tF\nG\tH");
if let Some(b) = s.doc.body.iter_mut().find(|b| b.as_table().is_some())
&& let wordcraft_doc::Block::Table(t) = std::sync::Arc::make_mut(b)
{
// "outer" goes around every row, and "inner" goes around row 1 alone.
t.rows[0].controls.open = vec![control("outer")];
t.rows[1].controls.open = vec![control("inner")];
t.rows[1].controls.close = 1;
t.rows[3].controls.close = 1;
}
run(&mut s, "caret.set", json!({"pos": cell(2, 0)}));
run(&mut s, "table.splitTable", json!({}));
let wraps = |t: &wordcraft_doc::Table| -> Vec<(Vec<String>, u32)> {
t.rows.iter().map(|r| (r.controls.open.iter().map(|c| c.tag.clone()).collect(), r.controls.close)).collect()
};
let parts: Vec<&wordcraft_doc::Table> = s.doc.body.iter().filter_map(|b| b.as_table()).collect();
assert_eq!(parts.len(), 2);
assert_eq!(wraps(parts[0]), [(vec!["outer".to_string()], 0), (vec!["inner".to_string()], 2)], "the first table closes the control");
assert_eq!(wraps(parts[1]), [(vec!["outer".to_string()], 0), (vec![], 1)], "the second table opens it again");
assert_eq!(parts[1].rows[0].controls.open[0].id, None, "the copy doesn't repeat the control's id");
}
fn cells(s: &Session) -> Option<((usize, usize), (usize, usize))> {
s.cell_range().map(|x| (x.rows, x.cols))
}
@@ -6971,6 +7030,210 @@ fn convert_text_to_table_keeps_inline_content() {
assert_eq!(cell(3).props_of_char(0).del, Some(1), "cell 4 lost its tracked deletion");
}
/// A table `cols` wide holding `cells` row by row, typed in with Tab; the caret ends in the
/// last cell.
fn typed_table(cols: usize, cells: &[&str]) -> Session {
let mut s = s();
run(&mut s, "insert.table", json!({"rows": cells.len() / cols, "cols": cols}));
for (k, c) in cells.iter().enumerate() {
if k > 0 {
run(&mut s, "text.tab", json!({}));
}
if !c.is_empty() {
run(&mut s, "text.insert", json!({"text": c}));
}
}
s
}
/// The body as `T` for a table and the text of each paragraph.
fn body_outline(s: &Session) -> Vec<String> {
s.doc.body.iter().map(|b| b.as_para().map_or_else(|| "T".to_string(), |p| p.text.clone())).collect()
}
fn cell_at(table: u32, r: u32, c: u32) -> serde_json::Value {
json!({"story": "body", "path": [table, r, c, 0], "off": 0})
}
/// Convert to Text sets a row's cells apart with a tab, a comma, another character or
/// paragraph marks; rows are paragraphs.
#[test]
fn convert_to_text_takes_each_separator() {
let cases = [
(json!({}), vec!["a\tb", "c\td"]),
(json!({"separator": "tab"}), vec!["a\tb", "c\td"]),
(json!({"separator": "comma"}), vec!["a,b", "c,d"]),
(json!({"separator": "other", "char": ";"}), vec!["a;b", "c;d"]),
(json!({"separator": "paragraph"}), vec!["a", "b", "c", "d"]),
];
for (params, want) in cases {
let mut s = typed_table(2, &["a", "b", "c", "d"]);
run(&mut s, "table.toText", params.clone());
let got = body_outline(&s);
assert_eq!(got.get(..want.len()), Some(&want.iter().map(|x| x.to_string()).collect::<Vec<_>>()[..]), "{params}: {got:?}");
assert!(s.doc.body.iter().all(|b| b.as_table().is_none()), "{params}");
}
// U+FFFC marks an object in a paragraph's text, so it cannot be the separator.
let obj = wordcraft_doc::para::OBJ.to_string();
for bad in [
json!({"separator": "other"}),
json!({"separator": "other", "char": "\n"}),
json!({"separator": "other", "char": obj}),
json!({"separator": "pipe"}),
] {
let mut s = typed_table(2, &["a", "b", "c", "d"]);
let depth = s.undo_depth();
assert!(s.run("table.toText", &bad).is_err(), "{bad}");
assert!(s.doc.body.iter().any(|b| b.as_table().is_some()), "{bad}: the table stays");
assert_eq!(s.undo_depth(), depth);
}
}
/// With rows selected, only those become text; the rows above and below stay tables, and a
/// vertical merge cut by the selection starts again below it.
#[test]
fn convert_to_text_converts_the_selected_rows() {
let mut s = typed_table(2, &["a", "b", "c", "d", "e", "f", "g", "h"]);
let t = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32;
// Column 1 merged down rows 1 to 3.
run(&mut s, "select.range", json!({"anchor": cell_at(t, 1, 1), "focus": cell_at(t, 3, 1)}));
run(&mut s, "table.merge", json!({}));
run(&mut s, "select.range", json!({"anchor": cell_at(t, 1, 0), "focus": cell_at(t, 1, 1)}));
run(&mut s, "table.toText", json!({}));
let at = t as usize;
// The merged cell holds d, f and h, one paragraph each.
assert_eq!(body_outline(&s).get(at..at + 5), Some(&["T", "c\td", "f", "h", "T"].map(String::from)[..]), "{:?}", body_outline(&s));
let head = s.doc.body[at].as_table().unwrap();
let tail = s.doc.body[at + 4].as_table().unwrap();
assert_eq!((head.rows.len(), tail.rows.len()), (1, 2));
use wordcraft_doc::props::VMerge;
let merges: Vec<VMerge> = tail.rows.iter().map(|r| r.cells.last().unwrap().props.vmerge).collect();
assert_eq!(merges, [VMerge::Restart, VMerge::Continue], "the merge starts again in the first row left");
assert_eq!(s.sel.focus.path.0, [t + 1], "the caret goes to the converted text");
// A merge that went on into the converted rows ends in the last row above them.
let mut s = typed_table(2, &["a", "b", "c", "d", "e", "f"]);
let t = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32;
run(&mut s, "select.range", json!({"anchor": cell_at(t, 0, 0), "focus": cell_at(t, 1, 0)}));
run(&mut s, "table.merge", json!({}));
run(&mut s, "select.range", json!({"anchor": cell_at(t, 1, 1), "focus": cell_at(t, 2, 1)}));
run(&mut s, "table.toText", json!({}));
let head = s.doc.body[t as usize].as_table().unwrap();
assert_eq!(head.rows.len(), 1);
assert_eq!(head.rows[0].cells[0].props.vmerge, VMerge::None, "no merge goes on below the table");
// A floating table keeps its place in the part above; the part below goes in the text flow.
let mut s = typed_table(2, &["a", "b", "c", "d", "e", "f"]);
let t = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32;
let float = wordcraft_doc::props::TableFloat { x: 100.0, y: 200.0, overlap: true, ..Default::default() };
s.doc.table_mut(StoryRef::Body, &wordcraft_doc::Path(vec![t])).unwrap().props.float = Some(float);
run(&mut s, "select.range", json!({"anchor": cell_at(t, 1, 0), "focus": cell_at(t, 1, 1)}));
run(&mut s, "table.toText", json!({}));
let at = t as usize;
assert_eq!(s.doc.body[at].as_table().unwrap().props.float, Some(float));
assert_eq!(s.doc.body[at + 2].as_table().unwrap().props.float, None, "the part below doesn't sit on the part above");
// The caret alone in a cell converts every row, as before.
let mut s = typed_table(2, &["a", "b", "c", "d", "e", "f"]);
run(&mut s, "table.toText", json!({}));
assert!(s.doc.body.iter().all(|b| b.as_table().is_none()));
}
/// A content control around the table keeps both its ends when the first rows, the last rows
/// or every row become text.
#[test]
fn convert_to_text_keeps_a_content_control_around_the_table() {
use wordcraft_doc::{ContentControl, ControlWrap, InlineObject};
let has = |p: &wordcraft_doc::Paragraph, start: bool| {
p.objects.iter().any(|o| if start { matches!(o, InlineObject::ControlStart { .. }) } else { matches!(o, InlineObject::ControlEnd) })
};
for rows in [Some((0, 0)), Some((2, 2)), None] {
let mut s = typed_table(2, &["a", "b", "c", "d", "e", "f"]);
let t = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32;
s.doc.table_mut(StoryRef::Body, &wordcraft_doc::Path(vec![t])).unwrap().controls =
ControlWrap { open: vec![ContentControl { tag: "wrap".into(), ..Default::default() }], close: 1 };
assert!(!s.doc.has_unbalanced_controls());
match rows {
Some((r0, r1)) => run(&mut s, "select.range", json!({"anchor": cell_at(t, r0, 0), "focus": cell_at(t, r1, 1)})),
None => run(&mut s, "select.range", json!({"anchor": cell_at(t, 1, 0), "focus": cell_at(t, 1, 0)})),
};
run(&mut s, "table.toText", json!({}));
assert!(!s.doc.has_unbalanced_controls(), "{rows:?}: {:?}", body_outline(&s));
let at = t as usize;
if rows != Some((2, 2)) {
assert!(s.doc.body[at].as_para().is_some_and(|p| has(p, true)), "{rows:?}: the control starts on the text");
}
if rows != Some((0, 0)) {
let last = s.doc.body.iter().skip(at).rfind(|b| b.as_para().is_some_and(|p| !p.text.is_empty()) || b.as_table().is_some()).unwrap();
assert!(last.as_para().is_some_and(|p| has(p, false)), "{rows:?}: the control ends on the text");
}
}
// A control around rows 0 to 3, row 1 converted: the table above closes it, the table below
// opens it again.
let mut s = typed_table(2, &["a", "b", "c", "d", "e", "f", "g", "h"]);
let t = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32;
{
let table = s.doc.table_mut(StoryRef::Body, &wordcraft_doc::Path(vec![t])).unwrap();
table.rows[0].controls.open = vec![ContentControl { id: Some(7), tag: "rows".into(), ..Default::default() }];
table.rows[3].controls.close = 1;
}
run(&mut s, "select.range", json!({"anchor": cell_at(t, 1, 0), "focus": cell_at(t, 1, 1)}));
run(&mut s, "table.toText", json!({}));
let at = t as usize;
let head = s.doc.body[at].as_table().unwrap();
let tail = s.doc.body[at + 2].as_table().unwrap();
assert_eq!((head.rows.len(), head.rows[0].controls.close), (1, 1), "the table above closes the control");
assert_eq!(tail.rows[0].controls.open.iter().map(|c| c.tag.as_str()).collect::<Vec<_>>(), ["rows"], "the table below opens it");
assert_eq!((head.rows[0].controls.open[0].id, tail.rows[0].controls.open[0].id), (Some(7), None), "one w:id in the file");
assert_eq!(tail.rows[1].controls.close, 1);
assert!(!s.doc.has_unbalanced_controls());
}
/// The text keeps its formatting, and a table nested in a cell stays a table.
#[test]
fn convert_to_text_keeps_formatting_and_nested_tables() {
let mut s = typed_table(2, &["Name", "X"]);
let t = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32;
let first = json!({"story": "body", "path": [t, 0, 0, 0], "off": 0});
let end = json!({"story": "body", "path": [t, 0, 0, 0], "off": 4});
run(&mut s, "select.range", json!({"anchor": first, "focus": end}));
run(&mut s, "format.bold", json!({}));
// A table nested in the second cell, after its text.
s.sel = crate::Selection::caret(wordcraft_doc::Pos { story: StoryRef::Body, path: wordcraft_doc::Path(vec![t, 0, 1, 0]), off: 1 });
run(&mut s, "text.newParagraph", json!({}));
run(&mut s, "insert.table", json!({"rows": 1, "cols": 1}));
run(&mut s, "text.insert", json!({"text": "Inner"}));
s.sel = crate::Selection::caret(wordcraft_doc::Pos { story: StoryRef::Body, path: wordcraft_doc::Path(vec![t, 0, 0, 0]), off: 0 });
run(&mut s, "table.toText", json!({}));
let at = t as usize;
let line = s.doc.body[at].as_para().unwrap();
assert_eq!(line.text, "Name\tX");
assert_eq!(line.props_of_char(0).bold, Some(true), "the text lost its bold");
assert_ne!(line.props_of_char(4).bold, Some(true), "the tab takes no formatting");
let inner = s.doc.body.iter().skip(at + 1).find_map(|b| b.as_table()).expect("the nested table stays a table");
assert_eq!(table_cell_text(inner), "Inner");
// A selected row whose first cell starts with a nested table: the caret goes to the row's
// text, not back into the table above.
let mut s = typed_table(2, &["A", "B", "", "D"]);
let t = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32;
s.sel = crate::Selection::caret(wordcraft_doc::Pos { story: StoryRef::Body, path: wordcraft_doc::Path(vec![t, 1, 0, 0]), off: 0 });
run(&mut s, "insert.table", json!({"rows": 1, "cols": 1}));
let row = &s.doc.body[t as usize].as_table().unwrap().rows[1];
assert!(row.cells[0].blocks.first().is_some_and(|b| b.as_table().is_some()), "the cell starts with the nested table");
run(&mut s, "select.range", json!({"anchor": cell_at(t, 1, 1), "focus": cell_at(t, 1, 1)}));
s.sel.anchor = wordcraft_doc::Pos { story: StoryRef::Body, path: wordcraft_doc::Path(vec![t, 1, 0, 1]), off: 0 };
run(&mut s, "table.toText", json!({}));
let at = t as usize;
assert_eq!(body_outline(&s).get(at..at + 3), Some(&["T", "T", "\tD"].map(String::from)[..]), "{:?}", body_outline(&s));
assert_eq!(s.sel.focus.path.0, [t + 2], "the caret is on the row's text");
}
fn table_cell_text(t: &wordcraft_doc::Table) -> String {
t.rows.iter().flat_map(|r| &r.cells).flat_map(|c| &c.blocks).filter_map(|b| b.as_para()).map(|p| p.text.clone()).collect()
}
#[test]
fn delete_columns_deletes_every_selected_column() {
// Issue #431: only the selection's focus column went.
@@ -8512,6 +8775,51 @@ fn update_fields_recalculates_a_table_formula() {
assert_eq!(formula_result(&s, 4, 1).as_deref(), Some("21"));
}
/// A formula's `\*` switches format its result when the formula is inserted and when Update Field
/// works it out again.
#[test]
fn table_formula_results_take_the_format_switches() {
for (formula, inserted, updated) in [
(r"=SUM(ABOVE) \* CardText", "twelve", "twenty-one"),
(r"=SUM(ABOVE) \* CardText \* Caps", "Twelve", "Twenty-one"),
(r"=SUM(ABOVE) \* OrdText \* Upper", "TWELFTH", "TWENTY-FIRST"),
(r"=SUM(ABOVE) \* MERGEFORMAT", "12", "21"),
] {
let mut s = formula_table();
let r = run(&mut s, "table.formula", json!({"formula": formula}));
assert_eq!((r["formula"].as_str(), r["result"].as_str()), (Some(formula), Some(inserted)));
run(
&mut s,
"select.range",
json!({"anchor": {"story": "body", "path": [1, 1, 1, 0], "off": 0}, "focus": {"story": "body", "path": [1, 1, 1, 0], "off": 1}}),
);
run(&mut s, "text.insert", json!({"text": "10"}));
run(&mut s, "references.updateFields", json!({}));
assert_eq!(formula_result(&s, 4, 1).as_deref(), Some(updated), "{formula}");
}
}
#[test]
fn table_formula_results_take_dollar_text_and_hex() {
for (formula, inserted, updated) in [
(r"=SUM(ABOVE) \* DollarText", "twelve and 00/100", "twenty-one and 00/100"),
(r"=SUM(ABOVE) \* DollarText \* Upper", "TWELVE AND 00/100", "TWENTY-ONE AND 00/100"),
(r"=SUM(ABOVE) \* Hex", "C", "15"),
] {
let mut s = formula_table();
let r = run(&mut s, "table.formula", json!({"formula": formula}));
assert_eq!((r["formula"].as_str(), r["result"].as_str()), (Some(formula), Some(inserted)));
run(
&mut s,
"select.range",
json!({"anchor": {"story": "body", "path": [1, 1, 1, 0], "off": 0}, "focus": {"story": "body", "path": [1, 1, 1, 0], "off": 1}}),
);
run(&mut s, "text.insert", json!({"text": "10"}));
run(&mut s, "references.updateFields", json!({}));
assert_eq!(formula_result(&s, 4, 1).as_deref(), Some(updated), "{formula}");
}
}
/// A one-column table (no header row) holding `cells`, in a document whose default language
/// is `lang`; the caret ends in the last cell.
fn column_table(lang: &str, cells: &[&str]) -> Session {
+191
View File
@@ -319,6 +319,29 @@ fn list_autoformat_does_not_override_a_style_linked_heading_number() {
assert_eq!(paras(&s)[1].0, "- Two", "the trigger stays as typed text, not stripped into a bullet");
}
/// A heading numbered only through its style's linked Multilevel List keeps "---" and "+--+--+"
/// as text on Enter, as a paragraph numbered directly does: no border, no table, and at the top
/// of the document the heading keeps its number.
#[test]
fn border_and_table_lines_stay_text_in_a_style_numbered_heading() {
for (above, keys) in [("", "---"), ("Above\n", "---"), ("", "+--+--+"), ("Above\n", "+--+--+")] {
let mut s = s();
typ(&mut s, &format!("{above}{keys}"));
run(&mut s, "para.heading1", json!({}));
run(&mut s, "list.multilevelPreset", json!({"preset": "headings"}));
let at = Pos::body(s.doc.body.len() - 1, 0);
assert_eq!(s.doc.para_at(&at).unwrap().props.numbering, None, "numbered through the style, not directly");
typ(&mut s, "\nText");
assert_eq!(s.doc.plain_text(StoryRef::Body), format!("{above}{keys}\nText"), "{above:?}{keys}");
assert!(s.doc.body.iter().all(|b| b.as_para().is_some_and(|p| p.props.borders.is_none())), "{above:?}{keys}");
let mut want = vec![num(Some("1"), "Heading1"), num(None, "Normal")];
if !above.is_empty() {
want.insert(0, num(None, "Normal"));
}
assert_eq!(numbers(&mut s), want, "{above:?}{keys}");
}
}
#[test]
fn list_autoformat_triggers() {
let s = typed("- Item\nNext");
@@ -524,6 +547,174 @@ fn ordinals_become_superscript() {
assert!(!sup("1th"));
}
#[test]
fn bold_and_italic_marks_become_formatting() {
let mut s = s();
run(&mut s, "tools.autocorrect", json!({"boldItalic": true, "capSentences": false}));
typ(&mut s, "say *very* nice, _quite_ good *two words*. ok\n*end*\nnext");
assert_eq!(s.doc.plain_text(StoryRef::Body), "say very nice, quite good two words. ok\nend\nnext");
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
let at = |t: &str| p.props_of_char(p.text.find(t).unwrap());
assert_eq!((at("very").bold, at("very").italic), (Some(true), None));
assert_eq!((at("quite").italic, at("quite").bold), (Some(true), None));
// Complex-script text (Arabic, Hebrew) takes the formatting too.
assert_eq!((at("very").bold_cs, at("quite").italic_cs), (Some(true), Some(true)));
assert_eq!(at("two").bold, Some(true));
assert_eq!(at("words").bold, Some(true));
for plain in ["say", " nice", ", ", " good", ". ok"] {
assert_eq!((at(plain).bold, at(plain).italic), (None, None), "{plain:?}");
}
// Enter finishes the marked word too, and the next paragraph is not bold.
let p = s.doc.para_at(&Pos::body(1, 0)).unwrap();
assert_eq!((p.props_of_char(0).bold, p.mark.bold), (Some(true), None));
let p = s.doc.para_at(&Pos::body(2, 0)).unwrap();
assert_eq!((p.props_of_char(0).bold, p.mark.bold), (None, None));
// With Track Changes on, the paragraph marks carry the same revisions as for unmarked text.
let marks = |keys: &str| {
let mut s = self::s();
run(&mut s, "tools.autocorrect", json!({"boldItalic": true}));
run(&mut s, "review.trackChanges", json!({"value": true}));
typ(&mut s, keys);
s.doc.body.iter().filter_map(|b| b.as_para()).map(|p| (p.mark.ins.is_some(), p.mark.del.is_some())).collect::<Vec<_>>()
};
assert_eq!(marks("x *end*\n"), marks("x end\n"));
// After a no-break space too.
let mut s = self::s();
run(&mut s, "tools.autocorrect", json!({"boldItalic": true}));
typ(&mut s, "a\u{a0}*word* ");
assert_eq!(s.doc.plain_text(StoryRef::Body), "a\u{a0}word ");
// A red word between plain marks: the next paragraph takes the closing mark's formatting.
let mut s = self::s();
run(&mut s, "tools.autocorrect", json!({"boldItalic": true}));
typ(&mut s, "*");
run(&mut s, "format.color", json!({"color": "FF0000"}));
typ(&mut s, "red");
run(&mut s, "format.color", json!({"color": "auto"}));
typ(&mut s, "*\nnext");
let red = s.doc.para_at(&Pos::body(0, 0)).unwrap().props_of_char(0).color;
let p = s.doc.para_at(&Pos::body(1, 0)).unwrap();
assert!(red.is_some());
assert_ne!(p.mark.color, red);
assert_ne!(p.props_of_char(0).color, red);
// A heading's next style starts afresh after Enter, as without the marks.
let mut s = self::s();
run(&mut s, "tools.autocorrect", json!({"boldItalic": true}));
run(&mut s, "para.heading1", json!({}));
run(&mut s, "format.bold", json!({}));
typ(&mut s, "*title*\n");
let p = s.doc.para_at(&Pos::body(1, 0)).unwrap();
assert_eq!((p.props.style.as_deref(), p.mark.bold), (Some("Normal"), None));
// Marks typed before Track Changes was turned on stay: removing them would not be tracked.
let mut s = self::s();
run(&mut s, "tools.autocorrect", json!({"boldItalic": true}));
typ(&mut s, "x *end*");
run(&mut s, "review.trackChanges", json!({"value": true}));
typ(&mut s, " ");
assert_eq!(s.doc.plain_text(StoryRef::Body), "X *end* ");
// Marks the same author typed with Track Changes on are converted.
let mut s = self::s();
run(&mut s, "tools.autocorrect", json!({"boldItalic": true}));
run(&mut s, "review.trackChanges", json!({"value": true}));
typ(&mut s, "x *end* ");
assert_eq!(s.doc.plain_text(StoryRef::Body), "X end ");
// Marks another author inserted stay.
let mut s = self::s();
run(&mut s, "tools.autocorrect", json!({"boldItalic": true}));
run(&mut s, "review.trackChanges", json!({"value": true}));
run(&mut s, "file.setAuthor", json!({"name": "Ana"}));
typ(&mut s, "x *end*");
run(&mut s, "file.setAuthor", json!({"name": "Ben"}));
typ(&mut s, " ");
assert_eq!(s.doc.plain_text(StoryRef::Body), "X *end* ");
// Undo right after brings the marks back.
let mut s = self::s();
run(&mut s, "tools.autocorrect", json!({"boldItalic": true}));
typ(&mut s, "a *b* ");
assert_eq!(s.doc.plain_text(StoryRef::Body), "A b ");
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.plain_text(StoryRef::Body), "A *b* ");
// After Enter, Undo brings the marks back and the Enter stays.
let mut s = self::s();
run(&mut s, "tools.autocorrect", json!({"boldItalic": true}));
typ(&mut s, "a *b*\n");
assert_eq!(s.doc.plain_text(StoryRef::Body), "A b\n");
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.plain_text(StoryRef::Body), "A *b*\n");
}
#[test]
fn autocorrect_goes_on_after_bold_and_italic_marks() {
// After a space or punctuation, the word without its marks is replaced and capitalized.
let mut s = s();
run(&mut s, "tools.autocorrect", json!({"boldItalic": true, "add": {"from": "wc", "to": "WordCraft"}}));
typ(&mut s, "*teh* end. _wc_.");
assert_eq!(s.doc.plain_text(StoryRef::Body), "The end. WordCraft.");
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
let at = |t: &str| p.props_of_char(p.text.find(t).unwrap()).clone();
assert_eq!((at("The").bold, at("WordCraft").italic, at(" end").bold), (Some(true), Some(true), None));
// One Undo brings the marks back as typed.
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.plain_text(StoryRef::Body), "The end. _wc_.");
// A marked web address becomes a link, and the text typed after it is neither bold nor italic.
let mut s = self::s();
run(&mut s, "tools.autocorrect", json!({"boldItalic": true}));
typ(&mut s, "see *www.example.org* and _www.example.com_ next ");
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
let at = |t: &str| p.props_of_char(p.text.find(t).unwrap()).clone();
assert_eq!((at("www.example.org").bold, at("www.example.org").link.is_some()), (Some(true), true));
assert_eq!((at("www.example.com").italic, at("www.example.com").link.is_some()), (Some(true), true));
for plain in [" and", " next"] {
assert_eq!((at(plain).bold, at(plain).italic, at(plain).link.is_some()), (None, None, false), "{plain:?}");
}
// After Enter too; a longer replacement moves the Enter with it, and Undo keeps the Enter.
let mut s = self::s();
run(&mut s, "tools.autocorrect", json!({"boldItalic": true, "add": {"from": "wc", "to": "WordCraft"}}));
typ(&mut s, "*teh*\nok. *wc*\nnext");
assert_eq!(s.doc.plain_text(StoryRef::Body), "The\nOk. WordCraft\nnext");
let p = s.doc.para_at(&Pos::body(1, 0)).unwrap();
assert_eq!(p.props_of_char(p.text.find("WordCraft").unwrap()).bold, Some(true));
let mut s = self::s();
run(&mut s, "tools.autocorrect", json!({"boldItalic": true}));
typ(&mut s, "*teh*\n");
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.plain_text(StoryRef::Body), "*teh*\n");
// In a span of several words, the first word is replaced and capitalized as it is typed.
let mut s = self::s();
run(&mut s, "tools.autocorrect", json!({"boldItalic": true}));
typ(&mut s, "*teh end* and _teh rest_ ");
assert_eq!(s.doc.plain_text(StoryRef::Body), "The end and the rest ");
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
let at = |t: &str| p.props_of_char(p.text.find(t).unwrap()).clone();
assert_eq!((at("The end").bold, at("the rest").italic, at(" and").bold), (Some(true), Some(true), None));
}
#[test]
fn bold_and_italic_marks_stay_as_typed_unless_they_enclose_words() {
let mut s = s();
run(&mut s, "tools.autocorrect", json!({"boldItalic": true, "capSentences": false}));
let keys = "2*3* file_name_ * x* *x * ** __ *?!* _-_ ";
typ(&mut s, keys);
assert_eq!(s.doc.plain_text(StoryRef::Body), keys);
// Marks stay while formatting is limited to the document's styles.
let mut s = self::s();
run(&mut s, "tools.autocorrect", json!({"boldItalic": true}));
run(&mut s, "review.restrict", json!({"mode": "none", "formatting": true}));
typ(&mut s, "*word* ");
assert_eq!(s.doc.plain_text(StoryRef::Body), "*word* ");
// Marks around an object (here a check box content control) stay.
let mut s = self::s();
run(&mut s, "tools.autocorrect", json!({"boldItalic": true}));
typ(&mut s, "x *");
run(&mut s, "developer.checkBox", json!({}));
run(&mut s, "caret.docEnd", json!({}));
typ(&mut s, "* ");
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
assert_eq!(p.text.matches('*').count(), 2, "{:?}", p.text);
// Off by default.
let s = typed("*bold* _italic_ ");
assert_eq!(s.doc.plain_text(StoryRef::Body), "*bold* _italic_ ");
}
#[test]
fn web_addresses_become_links() {
let mut s = typed("see https://example.org/log, then ");
+206 -11
View File
@@ -125,7 +125,8 @@ pub(crate) fn reference_link(instr: &str) -> Option<String> {
None
}
/// `\* FORMAT` switch value (Arabic, roman, ROMAN, alphabetic, ALPHABETIC, CardText, Ordinal…).
/// `\* FORMAT` switch value (Arabic, roman, ROMAN, alphabetic, ALPHABETIC, CardText, Ordinal,
/// ArabicDash, DollarText, Hex…). `{ PAGE \* ArabicDash }` shows "- 31 -".
pub fn format_switch(instr: &str) -> Option<NumFormat> {
let mut it = instr.split_whitespace();
while let Some(w) = it.next() {
@@ -141,6 +142,9 @@ pub fn format_switch(instr: &str) -> Option<NumFormat> {
"OrdText" | "ordtext" => NumFormat::OrdinalText,
"Ordinal" | "ordinal" => NumFormat::Ordinal,
"Arabic" | "arabic" => NumFormat::Decimal,
"ArabicDash" | "arabicdash" | "ARABICDASH" => NumFormat::NumberInDash,
"DollarText" | "dollartext" | "DOLLARTEXT" => NumFormat::DollarText,
"Hex" | "hex" | "HEX" => NumFormat::Hex,
// Chinese numerals: counting (一百零一), legal (壹佰零壹), digit by digit (一〇一).
v if v.eq_ignore_ascii_case("ChineseNum1") => NumFormat::ChineseCountingThousand,
v if v.eq_ignore_ascii_case("ChineseNum2") => NumFormat::ChineseLegalSimplified,
@@ -153,6 +157,120 @@ pub fn format_switch(instr: &str) -> Option<NumFormat> {
None
}
/// A field's capitalization switch (`\* Upper`, `\* Lower`, `\* Caps`, `\* FirstCap`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum CaseSwitch {
Upper,
Lower,
Caps,
FirstCap,
}
/// The capitalization switches in a field code, in the order they appear. Quoted text in the code
/// is not a switch.
fn case_switches(instr: &str) -> Vec<CaseSwitch> {
let mut chars = instr.chars().peekable();
let mut quoted = false;
let mut cases = Vec::new();
while let Some(c) = chars.next() {
match c {
'\\' if quoted => {
chars.next();
}
'"' => quoted = !quoted,
'\\' if chars.peek() == Some(&'*') => {
chars.next();
while chars.next_if(|c| c.is_whitespace()).is_some() {}
let mut word = String::new();
while let Some(c) = chars.next_if(|c| !c.is_whitespace() && *c != '\\' && *c != '"') {
word.push(c);
}
let case = match word.to_ascii_lowercase().as_str() {
"upper" => CaseSwitch::Upper,
"lower" => CaseSwitch::Lower,
"caps" => CaseSwitch::Caps,
"firstcap" => CaseSwitch::FirstCap,
_ => continue,
};
cases.push(case);
}
_ => {}
}
}
cases
}
/// `text` with the field's capitalization switches applied in the order they appear, so
/// `\* Lower \* Caps` shows "WEEKLY REPORT" as "Weekly Report". `\* Upper` capitalizes every
/// letter, `\* Lower` makes every letter lowercase, `\* Caps` capitalizes the first letter of each
/// word, and `\* FirstCap` capitalizes the first letter of the first word. A word's first letter is
/// its first letter or digit, after any leading quote, bracket or other punctuation. Without a
/// switch, the function returns `text` unchanged.
pub fn apply_case_switch(instr: &str, text: String) -> String {
case_switches(instr).into_iter().fold(text, apply_case)
}
/// `text` with one capitalization switch applied (see [`apply_case_switch`]).
fn apply_case(text: String, case: CaseSwitch) -> String {
match case {
CaseSwitch::Upper => text.to_uppercase(),
CaseSwitch::Lower => text.to_lowercase(),
CaseSwitch::Caps | CaseSwitch::FirstCap => {
let mut out = String::with_capacity(text.len());
let mut word_start = true;
let mut done = false;
for c in text.chars() {
if c.is_whitespace() {
word_start = true;
out.push(c);
} else if word_start && !done && c.is_alphanumeric() {
out.extend(c.to_uppercase());
word_start = false;
done = case == CaseSwitch::FirstCap;
} else {
if c.is_alphanumeric() {
word_start = false;
}
out.push(c);
}
}
out
}
}
}
/// Formats `n` in `fmt` as a field shows it. Cardinal, ordinal and dollar text is lowercase
/// (`\* CardText` shows "twenty-one", `\* DollarText` shows "twenty-one and 00/100") and writes
/// numbers up to 999,999; a larger number keeps its digits. `\* Hex` writes uppercase hexadecimal
/// digits (`1CA`). The field's capitalization switches apply after that (`\* CardText \* Caps`
/// shows "Twenty-one").
pub fn field_number(instr: &str, fmt: NumFormat, n: u32) -> String {
let text = match fmt {
NumFormat::CardinalText | NumFormat::OrdinalText | NumFormat::DollarText if n > MAX_TEXT_NUMBER => n.to_string(),
NumFormat::CardinalText | NumFormat::OrdinalText => fmt.format(n).to_lowercase(),
NumFormat::DollarText => dollar_text(f64::from(n)).unwrap_or_else(|| n.to_string()),
NumFormat::Hex => format!("{n:X}"),
_ => fmt.format(n),
};
apply_case_switch(instr, text)
}
/// The largest number cardinal, ordinal and dollar text write in words.
const MAX_TEXT_NUMBER: u32 = 999_999;
/// `value` as `\* DollarText` writes it, before any capitalization switch: the whole part in
/// lowercase cardinal text, "and", and the first two decimals (rounded) over 100
/// (14.55 gives "fourteen and 55/100"). `None` for NaN, or for a value that rounds below 0 or past
/// 999,999.99.
pub fn dollar_text(value: f64) -> Option<String> {
let cents = (value * 100.0).round();
if !(0.0..=f64::from(MAX_TEXT_NUMBER) * 100.0 + 99.0).contains(&cents) {
return None;
}
let cents = cents as u32;
Some(format!("{} and {:02}/100", NumFormat::CardinalText.format(cents / 100).to_lowercase(), cents % 100))
}
/// The light grey behind a field shown as its code.
pub const FIELD_SHADING: wordcraft_doc::props::Rgb = wordcraft_doc::props::Rgb(0xD9, 0xD9, 0xD9);
@@ -171,16 +289,16 @@ pub fn field_text_on(instr: &str, result: &str, ctx: &FieldCtx, page: u32) -> (S
let name = field_name(instr);
let fmt = format_switch(instr);
match name.as_str() {
"PAGE" => (fmt.unwrap_or(ctx.page_format).format(page), true),
"NUMPAGES" => (fmt.unwrap_or(NumFormat::Decimal).format(ctx.pages), true),
"SECTIONPAGES" => (fmt.unwrap_or(NumFormat::Decimal).format(ctx.section_pages), true),
"SECTION" => (fmt.unwrap_or(NumFormat::Decimal).format(ctx.section), true),
"PAGE" => (field_number(instr, format_switch(instr).unwrap_or(ctx.page_format), page), true),
"NUMPAGES" => (field_number(instr, fmt.unwrap_or(NumFormat::Decimal), ctx.pages), true),
"SECTIONPAGES" => (field_number(instr, fmt.unwrap_or(NumFormat::Decimal), ctx.section_pages), true),
"SECTION" => (field_number(instr, fmt.unwrap_or(NumFormat::Decimal), ctx.section), true),
"PAGEREF" => match pageref_page(instr, ctx) {
Some((n, page_format)) => (fmt.unwrap_or(page_format).format(n), true),
Some((n, page_format)) => (field_number(instr, fmt.unwrap_or(page_format), n), true),
None => (result.to_string(), false),
},
"TITLE" if result.is_empty() => (ctx.title.to_string(), false),
"AUTHOR" if result.is_empty() => (ctx.author.to_string(), false),
"TITLE" if result.is_empty() => (apply_case_switch(instr, ctx.title.to_string()), false),
"AUTHOR" if result.is_empty() => (apply_case_switch(instr, ctx.author.to_string()), false),
"FILENAME" if result.is_empty() => {
let text = if has_switch(instr, 'p') && !ctx.filepath.is_empty() {
ctx.filepath.to_string()
@@ -189,12 +307,12 @@ pub fn field_text_on(instr: &str, result: &str, ctx: &FieldCtx, page: u32) -> (S
} else {
"Document1".into()
};
(text, false)
(apply_case_switch(instr, text), false)
}
// ASK and SET only assign text to a bookmark: they never show a result (ECMA-376 §17.16.5).
"ASK" | "SET" => (String::new(), false),
"STYLEREF" => match &ctx.styleref {
Some((ix, at)) => (ix.eval(instr, *at), true),
Some((ix, at)) => (apply_case_switch(instr, ix.eval(instr, *at)), true),
None => (result.to_string(), false),
},
_ => (result.to_string(), false),
@@ -273,7 +391,7 @@ mod tests {
assert_eq!(field_text(" PAGE \\* ROMAN ", "", &ctx).0, "IV");
assert_eq!(field_text("PAGE \\*roman", "", &ctx).0, "iv");
assert_eq!(field_text("PAGE \\*roman\\*MERGEFORMAT", "", &ctx).0, "iv");
assert_eq!(field_text("NUMPAGES \\* CardText", "", &ctx).0, "Twelve");
assert_eq!(field_text("NUMPAGES \\* CardText", "", &ctx).0, "twelve");
assert_eq!(field_text("NUMPAGES \\* CHINESENUM1", "", &ctx).0, "十二");
assert_eq!(field_text("PAGE \\* ChineseNum2", "", &ctx).0, "肆");
assert_eq!(field_text("DATE \\@ \"M/d/yyyy\"", "1/2/2026", &ctx), ("1/2/2026".into(), false));
@@ -282,6 +400,83 @@ mod tests {
assert_eq!(field_code_text(""), "{ }");
}
/// Cardinal and ordinal text is lowercase unless a capitalization switch follows; `\* Upper`,
/// `\* Lower`, `\* Caps` and `\* FirstCap` apply to the page fields and the TITLE, AUTHOR and
/// FILENAME fallbacks. A switch inside quotes is text.
#[test]
fn capitalization_switches() {
let ctx = FieldCtx {
page: 21,
pages: 790,
title: "weekly report on sales".into(),
filename: "Weekly Sales Report.doc".into(),
..Default::default()
};
let text = |instr: &str| field_text(instr, "", &ctx).0;
assert_eq!(text(r"NUMPAGES \* CardText"), "seven hundred ninety");
assert_eq!(text(r"NUMPAGES \* CardText \* Caps"), "Seven Hundred Ninety");
assert_eq!(text(r"PAGE \* OrdText"), "twenty-first");
assert_eq!(text(r"PAGE \* OrdText \* FirstCap"), "Twenty-first");
assert_eq!(text(r"PAGE \* CardText \* Upper"), "TWENTY-ONE");
assert_eq!(text(r"PAGE \* roman \* upper"), "XXI");
assert_eq!(text(r"TITLE \* FirstCap \* MERGEFORMAT"), "Weekly report on sales");
assert_eq!(text(r"TITLE \*Caps"), "Weekly Report On Sales");
assert_eq!(text(r"FILENAME \* Lower"), "weekly sales report.doc");
assert_eq!(text(r"AUTHOR \* Upper"), "");
for (title, caps, first_cap) in [
("\"weekly report\"", "\"Weekly Report\"", "\"Weekly report\""),
("(weekly) report", "(Weekly) Report", "(Weekly) report"),
(" q3 - final", " Q3 - Final", " Q3 - final"),
] {
let ctx = FieldCtx { title: title.into(), ..Default::default() };
assert_eq!(field_text(r"TITLE \* Caps", "", &ctx).0, caps);
assert_eq!(field_text(r"TITLE \* FirstCap", "", &ctx).0, first_cap);
}
assert_eq!(field_text(r#"TITLE "x \* Upper""#, "", &ctx).0, "weekly report on sales");
assert_eq!(field_text(r"REF mark \* Upper", "stored", &ctx).0, "stored", "a stored result shows as it is");
}
/// `\* DollarText` writes lowercase cardinal text with "and 00/100", `\* Hex` uppercase
/// hexadecimal digits, and `\* ArabicDash` the number between hyphens. Words
/// stop at 999,999; a larger number keeps its digits.
#[test]
fn dollar_text_hex_and_arabic_dash_switches() {
let ctx = FieldCtx { page: 31, pages: 458, section: 790, ..Default::default() };
let text = |instr: &str| field_text(instr, "", &ctx).0;
assert_eq!(text(r"PAGE \* ArabicDash"), "- 31 -");
assert_eq!(text(r"PAGE \*arabicdash \* MERGEFORMAT"), "- 31 -");
assert_eq!(text(r"NUMPAGES \* ArabicDash"), "- 458 -", "any number field reads ArabicDash");
assert_eq!(text(r"NUMPAGES \* ArabicDash \* roman"), "- 458 -", "the first format switch counts");
assert_eq!(text(r"NUMPAGES \* Hex"), "1CA");
assert_eq!(text(r"NUMPAGES \* hex \* Lower"), "1ca");
assert_eq!(text(r"PAGE \* Hex"), "1F");
assert_eq!(text(r"SECTION \* DollarText"), "seven hundred ninety and 00/100");
assert_eq!(text(r"SECTION \* DollarText \* Upper"), "SEVEN HUNDRED NINETY AND 00/100");
assert_eq!(text(r"PAGE \* DollarText \* FirstCap"), "Thirty-one and 00/100");
assert_eq!(dollar_text(9.20 + 5.35).as_deref(), Some("fourteen and 55/100"));
assert_eq!(dollar_text(0.999).as_deref(), Some("one and 00/100"), "the decimals round");
assert_eq!(dollar_text(999_999.99).as_deref(), Some("nine hundred ninety-nine thousand nine hundred ninety-nine and 99/100"));
assert_eq!(dollar_text(999_999.995), None);
assert_eq!(dollar_text(-1.0), None);
assert_eq!(dollar_text(-0.004).as_deref(), Some("zero and 00/100"), "a value that rounds to 0 is not below 0");
assert_eq!(dollar_text(f64::NAN), None);
for fmt in [NumFormat::CardinalText, NumFormat::OrdinalText, NumFormat::DollarText] {
assert_eq!(field_number(r"SEQ x \* Upper", fmt, 1_000_140), "1000140", "{fmt:?}");
}
assert_eq!(field_number("SEQ x", NumFormat::CardinalText, 999_999), "nine hundred ninety-nine thousand nine hundred ninety-nine");
}
/// Several capitalization switches apply in the order they appear.
#[test]
fn capitalization_switches_apply_in_order() {
let ctx = FieldCtx { title: "WEEKLY REPORT".into(), ..Default::default() };
let text = |instr: &str| field_text(instr, "", &ctx).0;
assert_eq!(text(r"TITLE \* Lower \* Caps"), "Weekly Report");
assert_eq!(text(r"TITLE \* Lower \* FirstCap"), "Weekly report");
assert_eq!(text(r"TITLE \* Caps \* Lower"), "weekly report");
assert_eq!(text(r"TITLE \* Lower \* MERGEFORMAT \* Upper"), "WEEKLY REPORT");
}
/// #1765: ASK and SET only assign text to a bookmark; they show nothing, whatever result is stored.
#[test]
fn ask_and_set_show_nothing() {
+25 -1
View File
@@ -1039,9 +1039,15 @@ pub const FIELDS: &[(&str, &str, &str)] = &[
("SAVEDATE", "Date and Time", "When this document was most recently saved"),
("TIME", "Date and Time", "The time right now"),
("AUTHOR", "Document Information", "Who the document properties name as author"),
("COMMENTS", "Document Information", "Comments from the document properties"),
("FILENAME", "Document Information", "Name of the file this document is saved as"),
("KEYWORDS", "Document Information", "Keywords from the document properties"),
("LASTSAVEDBY", "Document Information", "Who last saved the document"),
("NUMCHARS", "Document Information", "How many characters the document holds"),
("NUMPAGES", "Document Information", "How many pages the whole document has"),
("NUMWORDS", "Document Information", "How many words the document holds"),
("REVNUM", "Document Information", "How many times the document has been saved"),
("SUBJECT", "Document Information", "Subject from the document properties"),
("TITLE", "Document Information", "Title from the document properties"),
("PAGE", "Numbering", "Number of the page the field sits on"),
("SECTION", "Numbering", "Number of the section the field sits in"),
@@ -1104,7 +1110,7 @@ pub const NUMBER_FORMATS: &[(&str, &str)] = &[
pub fn field_kind(name: &str) -> FieldKind {
match name {
"DATE" | "TIME" | "CREATEDATE" | "SAVEDATE" | "PRINTDATE" => FieldKind::Date,
"PAGE" | "NUMPAGES" | "SECTION" | "SECTIONPAGES" | "NUMWORDS" => FieldKind::Number,
"PAGE" | "NUMPAGES" | "SECTION" | "SECTIONPAGES" | "NUMWORDS" | "NUMCHARS" | "REVNUM" => FieldKind::Number,
"REF" | "PAGEREF" => FieldKind::Bookmark,
"SEQ" => FieldKind::Sequence,
"MERGEFIELD" => FieldKind::Merge,
@@ -1574,4 +1580,22 @@ mod tests {
f.code = " ".into();
assert!(f.params().is_none());
}
/// The Field dialog lists Word's document information fields, and the counts take a number
/// format.
#[test]
fn field_dialog_lists_the_document_information_fields() {
let listed: Vec<&str> = FIELDS.iter().filter(|(_, cat, _)| *cat == "Document Information").map(|(n, _, _)| *n).collect();
for name in ["COMMENTS", "KEYWORDS", "LASTSAVEDBY", "NUMCHARS", "REVNUM", "SUBJECT"] {
assert!(listed.contains(&name), "{name} in {listed:?}");
}
let a = app();
let mut f = FieldForm::new(&a);
f.select("REVNUM");
f.number_format = "ROMAN".into();
f.preserve = false;
assert_eq!(f.instruction(), r"REVNUM \* ROMAN");
f.select("SUBJECT");
assert_eq!(f.instruction(), "SUBJECT");
}
}
+77
View File
@@ -35,6 +35,7 @@ pub enum LayoutDialog {
Sort(SortForm),
SplitCells(SplitCellsForm),
TextToTable(TextToTableForm),
TableToText(TableToTextForm),
Size(SizeForm),
PageNumbers(PageNumbersForm),
}
@@ -52,6 +53,7 @@ impl LayoutDialog {
LayoutDialog::Sort(_) => "tableSort",
LayoutDialog::SplitCells(_) => "splitCells",
LayoutDialog::TextToTable(_) => "textToTable",
LayoutDialog::TableToText(_) => "tableToText",
LayoutDialog::Size(_) => "pictureSize",
LayoutDialog::PageNumbers(_) => "pageNumbers",
}
@@ -70,6 +72,7 @@ impl LayoutDialog {
LayoutDialog::Sort(_) => "Sort",
LayoutDialog::SplitCells(_) => "Split Cells",
LayoutDialog::TextToTable(_) => "Convert Text to Table",
LayoutDialog::TableToText(_) => "Convert to Text",
LayoutDialog::Size(_) => "Size",
LayoutDialog::PageNumbers(_) => "Page Number Format",
}
@@ -89,6 +92,7 @@ pub fn open(name: &str, app: &mut WordApp) -> Option<LayoutDialog> {
"tableSort" => LayoutDialog::Sort(SortForm::read(app)?),
"splitCells" => LayoutDialog::SplitCells(SplitCellsForm::read(app)?),
"textToTable" => LayoutDialog::TextToTable(TextToTableForm::read(app)?),
"tableToText" => LayoutDialog::TableToText(TableToTextForm::read(app)?),
"pictureSize" => LayoutDialog::Size(SizeForm::read(app)?),
"pageNumbers" => LayoutDialog::PageNumbers(PageNumbersForm::read(app)),
_ => return None,
@@ -108,6 +112,7 @@ pub fn body(app: &mut WordApp, ui: &mut Ui, d: &mut LayoutDialog) -> bool {
LayoutDialog::Sort(f) => sort_ui(app, ui, f),
LayoutDialog::SplitCells(f) => split_cells_ui(app, ui, f),
LayoutDialog::TextToTable(f) => text_to_table_ui(app, ui, f),
LayoutDialog::TableToText(f) => table_to_text_ui(app, ui, f),
LayoutDialog::Size(f) => size_ui(app, ui, f),
LayoutDialog::PageNumbers(f) => page_numbers_ui(app, ui, f),
}
@@ -1120,6 +1125,55 @@ fn text_to_table_ui(app: &mut WordApp, ui: &mut Ui, f: &mut TextToTableForm) ->
cancel
}
// ---------------------------------------------------------------------------------------------
// Convert to Text
/// The Convert to Text dialog (Table Layout › Data): what sets the cells of a row apart in the
/// text (`paragraph`, `tab`, `comma` or `other` with `other_char`). It opens on Tabs.
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TableToTextForm {
pub separator: String,
pub other_char: String,
}
impl TableToTextForm {
/// None when the caret isn't in a table.
pub fn read(app: &WordApp) -> Option<TableToTextForm> {
app.session.sel.focus.path.cell()?;
Some(TableToTextForm { separator: "tab".into(), other_char: "-".into() })
}
/// `table.toText` parameters for OK.
pub fn params(&self) -> Value {
let mut v = json!({"separator": self.separator});
if self.separator == "other" {
v["char"] = json!(self.other_char);
}
v
}
}
fn table_to_text_ui(app: &mut WordApp, ui: &mut Ui, f: &mut TableToTextForm) -> bool {
heading(ui, "Separate text with");
ui.radio_value(&mut f.separator, "paragraph".to_string(), tl!("Paragraph marks"));
ui.radio_value(&mut f.separator, "tab".to_string(), tl!("Tabs"));
ui.radio_value(&mut f.separator, "comma".to_string(), tl!("Commas"));
ui.horizontal(|ui| {
ui.radio_value(&mut f.separator, "other".to_string(), tl!("Other:"));
let r = ui.add(egui::TextEdit::singleline(&mut f.other_char).char_limit(1).desired_width(24.0));
if r.changed() {
f.separator = "other".into();
}
});
let ready = f.separator != "other" || !f.other_char.is_empty();
let (ok, cancel) = buttons(ui, "OK", ready);
if ok {
return run(app, "table.toText", f.params());
}
cancel
}
// ---------------------------------------------------------------------------------------------
// Size
@@ -1907,6 +1961,29 @@ mod tests {
assert_eq!(t.rows.iter().map(|r| r.cells.len()).collect::<Vec<_>>(), [3, 3, 3, 3]);
}
/// Convert to Text opens its dialog on Tabs; OK converts with the separator chosen.
#[test]
fn table_to_text_opens_its_dialog() {
let mut a = table_app(2, &["a", "b", "c", "d"]);
assert_eq!(a.run("table.toText", json!({})).unwrap(), json!({"pending": "tableToText"}));
assert!(a.session.doc.body.iter().any(|b| b.as_table().is_some()), "nothing converted yet");
let LayoutDialog::TableToText(mut f) = form(&mut a) else { panic!("table to text") };
assert_eq!((f.separator.as_str(), f.other_char.as_str()), ("tab", "-"));
assert_eq!(f.params(), json!({"separator": "tab"}));
f.separator = "other".into();
f.other_char = "/".into();
assert_eq!(f.params(), json!({"separator": "other", "char": "/"}));
a.run("table.toText", f.params()).unwrap();
assert!(a.dialog.is_none());
let texts: Vec<&str> = a.session.doc.body.iter().filter_map(|b| b.as_para()).map(|p| p.text.as_str()).collect();
assert!(texts.contains(&"a/b") && texts.contains(&"c/d"), "{texts:?}");
assert!(a.session.doc.body.iter().all(|b| b.as_table().is_none()));
// Outside a table there is nothing to convert.
let mut a = app();
assert_eq!(a.run("ui.dialog", json!({"name": "tableToText"})).unwrap(), json!({"dialog": null}));
}
/// The envelope sizes go through the interface language's catalog and use the region's
/// decimal separator (#1128; the untranslated-text audit found `#10 4.13 × 9.5 in` drawn in
/// English).
+2
View File
@@ -348,6 +348,7 @@ impl AutoCorrectForm {
"smartQuotes": o.smart_quotes,
"fractions": o.fractions,
"ordinals": o.ordinals,
"boldItalic": o.bold_italic,
"dashes": o.dashes,
"links": o.links,
"bullets": o.bullets,
@@ -397,6 +398,7 @@ fn autocorrect_ui(app: &mut WordApp, ui: &mut Ui, f: &mut AutoCorrectForm) -> bo
ui.checkbox(&mut o.smart_quotes, tl!("Smart quotes instead of straight quotes"));
ui.checkbox(&mut o.fractions, tl!("Fractions (1/2) as fraction characters (½)"));
ui.checkbox(&mut o.ordinals, tl!("Ordinals (1st) as superscript"));
ui.checkbox(&mut o.bold_italic, tl!("*Bold* and _italic_ as real formatting"));
ui.checkbox(&mut o.dashes, tl!("Hyphens (--) as dashes (—)"));
ui.checkbox(&mut o.links, tl!("Internet addresses as links"));
heading(ui, "Apply as you type");
+2
View File
@@ -2893,6 +2893,8 @@ fn input_dialog(id: &str, params: &Value) -> Option<&'static str> {
// Split Cells and Convert Text to Table without settings ask for them (#2592).
"table.split" if params.as_object().is_none_or(|m| m.is_empty()) => Some("splitCells"),
"table.fromText" if params.as_object().is_none_or(|m| m.is_empty()) => Some("textToTable"),
// Convert to Text without a separator asks for one.
"table.toText" if params.as_object().is_none_or(|m| m.is_empty()) => Some("tableToText"),
// Tabs, Borders and Shading, and Page Borders without settings show their dialogs (#320).
"para.tabs" if params.as_object().is_none_or(|m| m.is_empty()) => Some("tabs"),
"para.borders" if params.as_object().is_none_or(|m| m.is_empty()) => Some("borders"),
+4
View File
@@ -2212,12 +2212,16 @@ fn review(app: &mut WordApp, ui: &mut Ui) {
});
group(ui, "Changes", None, app, |ui, app| {
menu_button(ui, app, "accept", Some("Accept"), "Accept", true, "review.accept", |ui, app| {
mi(ui, app, "Accept and Move to Next", "review.accept", json!({"next": true}));
mi(ui, app, "Accept This Change", "review.accept", json!({}));
mi(ui, app, "Accept All Changes", "review.acceptAll", json!({}));
mi(ui, app, "Accept All Changes and Stop Tracking", "review.acceptAll", json!({"stopTracking": true}));
});
menu_button(ui, app, "reject", Some("Reject"), "Reject", true, "review.reject", |ui, app| {
mi(ui, app, "Reject and Move to Next", "review.reject", json!({"next": true}));
mi(ui, app, "Reject Change", "review.reject", json!({}));
mi(ui, app, "Reject All Changes", "review.rejectAll", json!({}));
mi(ui, app, "Reject All Changes and Stop Tracking", "review.rejectAll", json!({"stopTracking": true}));
});
stack(ui, |ui| {
small(ui, app, "prevChange", Some("Previous"), "Previous Change", "review.previousChange", json!({}), false);
+13 -1
View File
File diff suppressed because one or more lines are too long
+6 -1
View File
@@ -2,7 +2,7 @@
Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-processor feature catalog (`crates/engine/src/catalog.rs`) compared with the live command registry.
**502 of 517 catalog features have commands (97.1%).**
**504 of 520 catalog features have commands (96.9%).**
| Tab | Live | Total |
|---|---|---|
@@ -18,6 +18,7 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-
| Mailings | 23 | 23 |
| Review | 29 | 31 |
| View | 25 | 29 |
| Read Mode | 2 | 3 |
| Developer | 11 | 11 |
| Table Design | 8 | 8 |
| Table Layout | 33 | 33 |
@@ -62,6 +63,10 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-
- Window › Synchronous Scrolling (`view.syncScroll`)
- Window › Switch Windows (`view.switchWindows`)
### Read Mode
- View › Layout (`view.readLayout`)
### Equation
- Tools › Ink Equation (`insert.inkEquation`)
+3 -2
View File
@@ -1,6 +1,6 @@
# WordCraft roadmap: milestones and what's next
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (M6: Manage Sources imports Word's `Sources.xml`, #2640; previously minor: Czech proofing review fixes; previously minor: Portuguese proofing; previously minor: Polish interface and Polish proofing; previously trivial: M7: formatting revisions landed, #41; previously trivial: M5: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: alpha gate checked: six core workflows pass; previously major: milestones moved here from ROADMAP.md; Current focus set from the re-measured gaps) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** trivial (egui 0.37 migration: 27 interface languages remain pinned by `craft-i18n.rev` (9205108…), and the input-method handling has synthetic regressions while managed CI passed and native acceptance (#155–#164) remains open; previously trivial: M6: Manage Sources imports Word's `Sources.xml`, #2640; previously minor: Czech proofing review fixes; previously minor: Portuguese proofing; previously minor: Polish interface and Polish proofing; previously trivial: M7: formatting revisions landed, #41; previously trivial: M5: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: alpha gate checked: six core workflows pass; previously major: milestones moved here from ROADMAP.md; Current focus set from the re-measured gaps) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
Forward-looking plan. The summary is [`ROADMAP.md`](../ROADMAP.md); the ranked work list is
[`gaps.md`](gaps.md); the numbers are in [`target-app-parity.md`](target-app-parity.md). Hours are
@@ -62,7 +62,7 @@ partial rows are fidelity gaps (beta work), not broken workflows. WordCraft stay
| M12 | Formats breadth (PDF, ODT, RTF, HTML, MD, TXT, LaTeX, `.doc` import) | done (first versions); `.doc` write, encryption, minor formats missing | 60–100 h |
| M13 | Performance budgets | on track (1.4 ms relayout); large real-world documents unmeasured | 10–20 h |
| M14 | 1.0 polish, packaging, signing | releases v0.1.0–v0.4.0 published (signed macOS, Windows MSI x64/x86/arm64, Linux, FreeBSD, web) | — |
| M15 | Localization (12 key languages, RTL interface, proofing languages) | 15 UI languages; English, Polish and Portuguese proofing plus Czech spelling and generic checks (system Hunspell; English hyphenation fallback), a proofing language per run | 70–115 h |
| M15 | Localization (12 key languages, RTL interface, proofing languages) | 27 UI languages from the shared catalogs pinned by `craft-i18n.rev` (`9205108c…`); proofing: English, Polish, Portuguese, Italian, Croatian, Turkish, Russian, Czech, Indonesian and Spanish, plus Bulgarian and German hyphenation only; native-speaker review and native IME acceptance (#155–#164) remain open | 70–115 h |
| M16 | Right-to-left and East Asian typography | RTL paragraphs done (#207) | 30–45 h |
## Next after beta
@@ -74,6 +74,7 @@ languages, screen-reader access, ecosystem decisions (VBA, add-ins, cloud), an i
| Date | Change | Summary |
|---|---|---|
| 2026-10-25 | trivial | egui 0.37 migration: 27 interface languages remain pinned by `craft-i18n.rev` (9205108…); input-method surrounding-delete and commit handling has synthetic regressions only, with managed CI passing; native acceptance (#155–#164) and native-speaker review remain open |
| 2026-10-18 | trivial | M6: Manage Sources imports Word's `Sources.xml` (#2640) |
| 2026-10-12 | minor | Czech proofing review: short-word splits rank after deletion, NOSUGGEST excludes suggestions while accepting typed forms, affix comments apply only to headers, ratios keep spaces and repeated-word messages use Czech quotes; synthetic engine end-to-end and latency checks |
| 2026-10-12 | minor | Czech spelling from the installed Hunspell dictionary, runtime-table suggestions and generic checks; Czech interface writes cs-CZ documents; no Czech hyphenation patterns bundled; estimates unchanged |
+17 -1
View File
@@ -1,6 +1,6 @@
# Typing parity with Word
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** trivial (highlight target includes selected paragraph marks, #4085; previously trivial: full-target highlight check beyond 10,000 runs, #4085; previously trivial: queued IME replacements are rejected before mutation after their marked composition loses its body/equation target; previously trivial: the transactional equation delete proves its commit with the engine revision; previously trivial: a refused byte-counted equation deletion drops only the immediately adjacent stale replacement, so a later direct commit still applies; previously trivial: a tracked own-insertion shifts the rest of a two-sided IME deletion; previously trivial: AutoCorrect/AutoFormat classification compares the actual paragraph; previously trivial: a replaced body starts a new input-method context and interrupts the composition; a quote the engine leaves as typed is the input method's own change; equation `DeleteSurrounding` erases counted characters; previously trivial: AutoCorrect finds the word after a no-break space or an ideographic space; previously trivial: a command that changes nothing is not an edit: no undo step, no unsaved changes, no relayout, and the redo stack survives it; previously trivial: while an input method composes, the caret and its candidate window sit at the input method's cursor inside the composition; previously trivial: Ctrl+Tab types a tab in a table cell, #3137; previously trivial: List AutoFormat triggers in front of an already-written paragraph, not only a fresh empty one, checked in Word for Windows, #3352; previously trivial: AltGr types its characters without opening KeyTips on Linux, #2388; previously trivial: Delete on whole selected cells leaves one empty paragraph in each, with the cell's last paragraph's formatting; nested tables go too, #2802; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: a slash opening a word travels whole to the next line, #2963; previously trivial: typing over a selection takes the first selected character's formatting, also when the selection was made right to left, #2879; previously trivial: Turkish and Azerbaijani case mapping in AutoCorrect, #1923; previously trivial: smart quotes in Hebrew pinned: logical order, ” in acronyms as in Word, #1670; previously trivial: Backspace on a selected table, rows or columns deletes them, Delete clears them, #1844; previously trivial: typed text takes its direction from its own letters, #1543; previously trivial: an input method's request to erase text around the caret is honoured, so web typing in Cyrillic and other non-Latin text no longer repeats, #1480; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: a heading-list preset keeps Heading styles and makes other paragraphs Heading 1, as in Word, #1793; previously trivial: numbered headings: a heading-linked Multilevel List numbers new headings by their style; numbering keeps a heading a heading, #1793; previously trivial: reversing an automatic capital adds an exception, #1857; previously trivial: smart quotes open after guillemets and low quotes and nest; previously trivial: Enter on an empty list item numbered by its paragraph style moves it up a level, #2143; previously trivial: Backspace after a section break and Delete before it remove the break, also on the last paragraph, #2127; previously trivial: a table inserted in right-to-left text is right to left, so Tab moves left, #2634; previously trivial: Shift+Delete cuts the selection on every platform, #1669; previously trivial: Select All then Delete removes a table the document starts with, #1198; previously trivial: Alt+numpad character codes, #961; previously trivial: Down and Up from a line start beside a table go into it, #1007; previously trivial: sample-text formulas on Enter, #1067; previously trivial: Track Changes: Accept All after a tracked delete from a paragraph end keeps the next paragraph; Reject All after typing into an empty paragraph keeps it (#864); previously trivial: Correct TWo INitial CApitals, cAPS LOCK and table-cell capitals, #544; previously trivial: complex-script caret and Backspace, #604; Move Paragraph Up/Down, Promote/Demote, Go Back and Alt+X keys, #343; the body IME deletion maps signed placeholder growth/shrink, keeps control markers outside the range and scopes its undo join to the matching replacement) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** trivial (`*Bold* and _italic_ as real formatting` in AutoFormat As You Type; previously trivial: highlight target includes selected paragraph marks, #4085; previously trivial: full-target highlight check beyond 10,000 runs, #4085; previously trivial: queued IME replacements are rejected before mutation after their marked composition loses its body/equation target; previously trivial: the transactional equation delete proves its commit with the engine revision; previously trivial: a refused byte-counted equation deletion drops only the immediately adjacent stale replacement, so a later direct commit still applies; previously trivial: a tracked own-insertion shifts the rest of a two-sided IME deletion; previously trivial: AutoCorrect/AutoFormat classification compares the actual paragraph; previously trivial: a replaced body starts a new input-method context and interrupts the composition; a quote the engine leaves as typed is the input method's own change; equation `DeleteSurrounding` erases counted characters; previously trivial: AutoCorrect finds the word after a no-break space or an ideographic space; previously trivial: a command that changes nothing is not an edit: no undo step, no unsaved changes, no relayout, and the redo stack survives it; previously trivial: while an input method composes, the caret and its candidate window sit at the input method's cursor inside the composition; previously trivial: Ctrl+Tab types a tab in a table cell, #3137; previously trivial: List AutoFormat triggers in front of an already-written paragraph, not only a fresh empty one, checked in Word for Windows, #3352; previously trivial: AltGr types its characters without opening KeyTips on Linux, #2388; previously trivial: Delete on whole selected cells leaves one empty paragraph in each, with the cell's last paragraph's formatting; nested tables go too, #2802; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: a slash opening a word travels whole to the next line, #2963; previously trivial: typing over a selection takes the first selected character's formatting, also when the selection was made right to left, #2879; previously trivial: Turkish and Azerbaijani case mapping in AutoCorrect, #1923; previously trivial: smart quotes in Hebrew pinned: logical order, ” in acronyms as in Word, #1670; previously trivial: Backspace on a selected table, rows or columns deletes them, Delete clears them, #1844; previously trivial: typed text takes its direction from its own letters, #1543; previously trivial: an input method's request to erase text around the caret is honoured, so web typing in Cyrillic and other non-Latin text no longer repeats, #1480; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: a heading-list preset keeps Heading styles and makes other paragraphs Heading 1, as in Word, #1793; previously trivial: numbered headings: a heading-linked Multilevel List numbers new headings by their style; numbering keeps a heading a heading, #1793; previously trivial: reversing an automatic capital adds an exception, #1857; previously trivial: smart quotes open after guillemets and low quotes and nest; previously trivial: Enter on an empty list item numbered by its paragraph style moves it up a level, #2143; previously trivial: Backspace after a section break and Delete before it remove the break, also on the last paragraph, #2127; previously trivial: a table inserted in right-to-left text is right to left, so Tab moves left, #2634; previously trivial: Shift+Delete cuts the selection on every platform, #1669; previously trivial: Select All then Delete removes a table the document starts with, #1198; previously trivial: Alt+numpad character codes, #961; previously trivial: Down and Up from a line start beside a table go into it, #1007; previously trivial: sample-text formulas on Enter, #1067; previously trivial: Track Changes: Accept All after a tracked delete from a paragraph end keeps the next paragraph; Reject All after typing into an empty paragraph keeps it (#864); previously minor: Correct TWo INitial CApitals, cAPS LOCK and table-cell capitals, #544; previously trivial: complex-script caret and Backspace, #604; Move Paragraph Up/Down, Promote/Demote, Go Back and Alt+X keys, #343; the body IME deletion maps signed placeholder growth/shrink, keeps control markers outside the range and scopes its undo join to the matching replacement) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
What Word does when you type, observed black-box (same keystrokes typed into Word and
WordCraft, results read back paragraph by paragraph) and pinned by tests in
@@ -70,6 +70,7 @@ Bullets and Numbering decide whether to toggle off from the selected paragraphs'
| Undo after a heading list | one step: paragraph styles and the heading styles' numbers come back | `undo_takes_a_heading_preset_back_in_one_step` |
| A paragraph whose numbering was switched off, then Heading 2 from a heading-linked list | numbered (1.1) by the style | `applying_a_numbered_heading_style_numbers_the_paragraph` |
| `1. ` typed in a Heading 1 (also `a) ` in a Heading 2, `1. ` in a Subtitle) | numbered, the style kept; only body text becomes List Paragraph (checked in Word for Mac, 2026-10-15) | `typing_1_dot_in_a_heading_keeps_the_heading_style` |
| `---` or `+--+--+` Enter in a heading numbered by a heading-linked list, at the top of the document or below another paragraph | stays text, with no border or table, and the heading keeps its number, as in a paragraph numbered directly (WordCraft's rule; not compared with Word) | `border_and_table_lines_stay_text_in_a_style_numbered_heading` |
| ⌘Z right after an AutoFormat | undoes only the AutoFormat (`* ` comes back as text) | `undo_right_after_autoformat_undoes_only_the_autoformat` |
## Undo
@@ -189,6 +190,20 @@ ideographic space (`autocorrect_finds_the_word_after_a_wide_space`):
`capsLock`, `capCells` on `tools.autocorrect`; on by default)
(`capitalisation_fixes_undo_and_turn_off`).
- `1/2 1/4 3/4` → `½ ¼ ¾`; `1st 22nd 13th` get superscript suffixes (`ordinals_become_superscript`).
- With `*Bold* and _italic_ as real formatting` on (`tools.autocorrect {"boldItalic": true}`;
off by default), `*word*` becomes bold `word` and `_word_` italic `word`, without the marks,
when the word is finished ¹. The rest of this item is WordCraft's own rule: the marks may
enclose several words; WordCraft converts a span only when the opening mark starts a word and
the text inside holds a letter or digit and does not start or end with a space, so `2*3*`,
`file_name_` and `*?!*` stay as typed, and so do marks around a picture, field or content
control. AutoCorrect then goes on with the word without its marks, so `*teh* ` becomes bold
`The `, and it corrects the first word of a span of several words as it is typed, so
`*teh end* ` becomes bold `The end `. Undo right after brings the marks
back (after Enter, the Enter stays). With Track Changes on, WordCraft converts only marks the
current author typed as tracked insertions, and it converts nothing while formatting is
limited to the document's styles (`bold_and_italic_marks_become_formatting`,
`bold_and_italic_marks_stay_as_typed_unless_they_enclose_words`,
`autocorrect_goes_on_after_bold_and_italic_marks`).
- `word -- word` → en dash, `word - word` → en dash, `word--word` → em dash (`dashes`).
- Web addresses become links; following text isn't linked (`web_addresses_become_links`).
- A hyperlink covers only its own text: typing at either end of it, or after Enter at its end,
@@ -472,6 +487,7 @@ again.
| 2026-10-24 | trivial | Input methods: reject queued old-composition replacements before mutation after body/equation target replacement (new regression validation pending); equation `DeleteSurrounding` is one transactional command whose checked revision proves a receipt; a caret-only step in a mixed request refuses the whole deletion, and an over-cap request clears the painted pre-edit and the previous replacement join; a refused byte-counted equation deletion drops only the immediately adjacent stale replacement, never a later direct commit; a tracked own-insertion shifts the following range; a body deletion maps signed placeholder growth/shrink, keeps control markers outside the range and scopes its replacement undo join to the matching `text.insert`; refusals clear the painted pre-edit and joins; AutoCorrect/AutoFormat classification compares the paragraph as typed; equation replacement interrupts the composition |
| 2026-10-24 | trivial | AutoCorrect: a word typed after a no-break space or an ideographic space is corrected as after a plain space |
| 2026-10-24 | trivial | Highlight shortcut toggles the last chosen colour off when the whole selection has it; explicit colour commands remain setters (#4085). |
| 2026-10-24 | trivial | AutoFormat As You Type: `*Bold* and _italic_ as real formatting` turns `*word*` bold and `_word_` italic without the marks; off by default |
| 2026-10-23 | trivial | Input methods: a replaced body (Replace Document Text, version restore, Compare, Undo/Redo) starts a new context and interrupts the composition; smart quotes off keeps an IME quote an `InputMethod` change; equation `DeleteSurrounding` erases counted characters; bounded pre-edit/commit sizes; a partly refused two-sided deletion is rolled back |
| 2026-10-22 | trivial | Input methods: while composing, the caret and the candidate window follow the input method's cursor inside the marked text |
| 2026-10-21 | trivial | Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459) |
+5 -2
View File
File diff suppressed because one or more lines are too long