mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-26 19:37:03 -06:00
M-egui037: reconcile upstream and document migration review fixes
This commit is contained in:
+13
-1
File diff suppressed because one or more lines are too long
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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"], &["", "", ""]]);
|
||||
|
||||
@@ -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(¶.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(¶.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();
|
||||
|
||||
@@ -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())?;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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(¢s) {
|
||||
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() {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
File diff suppressed because one or more lines are too long
@@ -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
@@ -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
@@ -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
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user