M-EGUI037: reconcile WordCraft main and preserve IME editing

This commit is contained in:
skylartaylor
2026-10-25 02:19:38 +02:00
116 changed files with 7133 additions and 528 deletions
+19
View File
@@ -864,6 +864,25 @@ mod editing_tests {
assert_eq!(typed, None, "a placeholder style localized in id and name");
}
/// Typed text with line breaks in it (`type_text` with paragraphs, a typed paste) is the
/// Enter key pressed between the lines, so a one-line control, where Enter does nothing, stays
/// one paragraph, and a multi-line one splits (#3973: the line breaks went in as one command
/// and split a one-line control).
#[test]
fn typed_line_breaks_do_not_split_a_one_line_control() {
for (multi_line, paragraphs) in [(false, 1), (true, 2)] {
let mut s = Session::new(wordcraft_doc::Document::new());
run(&mut s, "text.insert", json!({"text": "Name: "}));
run(&mut s, "developer.plainText", json!({"title": "Name", "tag": "name"}));
run(&mut s, "developer.properties", json!({"control": "name", "multiLine": multi_line}));
run(&mut s, "caret.right", json!({}));
run(&mut s, "text.insert", json!({"text": "Ada\rLovelace", "raw": true}));
assert_eq!(s.doc.body.len(), paragraphs, "multi-line control: {multi_line}");
let r = s.doc.control_at(&s.sel.focus).unwrap();
assert_eq!(r.control.tag, "name", "still inside the control");
}
}
#[test]
fn typing_replaces_the_placeholder_and_emptying_brings_it_back() {
let mut s = Session::new(wordcraft_doc::Document::new());
+19
View File
@@ -532,6 +532,16 @@ fn keep_lasso(s: &mut Session, sel: LassoSelection) {
s.view.draw.lasso = Some(LassoSelection { rev: s.rev().wrapping_add(1), ..sel });
}
/// After a command that changed nothing, which puts the revision back from `touched` to `rev`: a
/// lasso selection the command kept for the revision it would have reached stays selected.
pub(crate) fn rekey_lasso(s: &mut Session, touched: u64, rev: u64) {
if let Some(l) = s.view.draw.lasso.as_mut()
&& (l.rev == touched || l.rev == touched.wrapping_add(1))
{
l.rev = rev;
}
}
/// Whether (`x`, `y`) is inside the closed loop `poly` (even–odd rule).
fn inside(poly: &[[f32; 2]], x: f32, y: f32) -> bool {
let n = poly.len();
@@ -1081,6 +1091,15 @@ mod tests {
let objs = ink(&s);
let blue = objs.iter().filter(|o| matches!(o, InlineObject::Shape { stroke: Some(c), .. } if c.hex() == "0070C0")).count();
assert_eq!(blue, 1);
// Recolouring to the colour it has changes nothing, and the selection stays; an edit
// elsewhere ends it.
s.run("draw.lassoColor", &json!({"color": "0070C0"})).unwrap();
assert_eq!(s.undo_depth(), steps + 2);
assert_eq!(super::lasso_selection(&s).map(|l| l.items.clone()), Some(vec![sel.clone()]));
s.run("text.insert", &json!({"text": "x"})).unwrap();
assert!(super::lasso_selection(&s).is_none());
let r = s.run("draw.lasso", &json!({"page": 0, "points": [[80, 80], [260, 80], [260, 180], [80, 180]]})).unwrap();
assert_eq!(r["count"], 1);
// Deleted: only the selected stroke goes, and the selection with it.
s.run("draw.lassoDelete", &json!({})).unwrap();
assert_eq!(ink(&s).len(), 1);
+70 -13
View File
@@ -13,6 +13,7 @@ use crate::{CmdError, CmdResult, CommandSpec, Session, p};
/// Word's font size list.
pub const SIZES: [f32; 17] = [8.0, 9.0, 10.0, 10.5, 11.0, 12.0, 14.0, 16.0, 18.0, 20.0, 22.0, 24.0, 26.0, 28.0, 36.0, 48.0, 72.0];
/// Register character-formatting commands, their parameters and keyboard bindings.
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("format.bold", "Bold", "Home › Font", |s, v| {
@@ -107,7 +108,7 @@ pub fn specs() -> Vec<CommandSpec> {
.params(r#"{"color": "RRGGBB" | "auto"} (the underline's own colour; "auto" follows the text colour)"#),
CommandSpec::new("format.highlight", "Text Highlight Color", "Home › Font", highlight)
.params(
r#"{"color": "yellow|brightGreen|turquoise|pink|blue|red|darkBlue|teal|green|violet|darkRed|darkYellow|gray50|gray25|black|none"}"#,
r#"{"color"?: "yellow|brightGreen|turquoise|pink|blue|red|darkBlue|teal|green|violet|darkRed|darkYellow|gray50|gray25|black|none"} (explicit colour sets it; omitted toggles the last chosen colour, initially yellow)"#,
)
// ⌘⌥H hides other apps on macOS.
.key(super::non_mac("Mod+Alt+H")),
@@ -228,7 +229,8 @@ pub fn apply(s: &mut Session, f: &dyn Fn(&mut CharProps)) -> CmdResult {
sel_result(s)
}
/// Resolved formatting of every character in the target (or the caret).
/// Resolved target formatting, sampled up to a paragraph/column-row boundary after 10,000 runs.
/// Empty targets use pending caret formatting or the selected text box paragraph mark.
fn resolved(s: &Session) -> Vec<ResolvedChar> {
let mut out = Vec::new();
if let Some(rows) = column_rows(s) {
@@ -391,18 +393,73 @@ fn underline_color(s: &mut Session, v: &Value) -> CmdResult {
apply(s, &|x| x.underline_color = rgb)
}
/// Test the entire highlight target without the formatting-state query's run cap.
/// Resolve one run at a time and stop at the first mismatch, without collecting them.
fn target_has_highlight(s: &Session, h: Highlight) -> bool {
let matches = |style: Option<&str>, props: &CharProps| s.doc.styles.resolve_char(style, props).highlight == h.rgb();
let mut seen = false;
if let Some(rows) = column_rows(s) {
for (a, b) in rows {
let Some(p) = s.doc.para_at(&a) else { continue };
if a == b {
seen = true;
if !matches(p.props.style.as_deref(), &p.mark) {
return false;
}
}
for (r, c) in p.run_ranges() {
if r.end > a.off && r.start < b.off {
seen = true;
if !matches(p.props.style.as_deref(), c) {
return false;
}
}
}
}
if seen {
return true;
}
}
if let Some((a, b)) = selected_text_box(s).or_else(|| target(s)) {
for path in s.doc.paths_between(&a, &b) {
let Some(p) = s.doc.para(a.story, &path) else { continue };
let from = if path == a.path { a.off } else { 0 };
let to = if path == b.path { b.off } else { p.len() };
for (r, c) in p.run_ranges() {
if r.end > from && r.start < to {
seen = true;
if !matches(p.props.style.as_deref(), c) {
return false;
}
}
}
}
}
// No runs: retain the caret/pending and empty text-box paragraph-mark rules.
seen || resolved(s).iter().all(|r| r.highlight == h.rgb())
}
/// Explicit palette/automation colours set highlighting; the shortcut (no colour) toggles
/// the last choice off only when the whole formatting target already has that colour.
fn highlight(s: &mut Session, v: &Value) -> CmdResult {
let c = p::str(v, "color").unwrap_or("yellow");
let h = Highlight::ALL
.iter()
.copied()
.find(|h| {
h.ooxml().eq_ignore_ascii_case(c)
|| h.name().replace(['-', ' ', '%'], "").eq_ignore_ascii_case(&c.replace(['-', ' ', '%'], ""))
|| format!("{h:?}").eq_ignore_ascii_case(c)
})
.ok_or_else(|| CmdError::Params(format!("unknown highlight colour `{c}`")))?;
apply(s, &|x| x.highlight = Some(h))
let color = v.get("color").map(|_| p::req_str(v, "color")).transpose()?;
let h = match color {
None => s.last_highlight,
Some(c) => Highlight::ALL
.iter()
.copied()
.find(|h| {
h.ooxml().eq_ignore_ascii_case(c)
|| h.name().replace(['-', ' ', '%'], "").eq_ignore_ascii_case(&c.replace(['-', ' ', '%'], ""))
|| format!("{h:?}").eq_ignore_ascii_case(c)
})
.ok_or_else(|| CmdError::Params(format!("unknown highlight colour `{c}`")))?,
};
let remove = color.is_none() && target_has_highlight(s, h);
let value = if remove { Highlight::None } else { h };
let result = apply(s, &|x| x.highlight = Some(value))?;
s.last_highlight = h;
Ok(result)
}
/// `format.changeCase`: `mode` (lower, upper, title, sentence, toggle), or Shift+F3's cycle
+41 -10
View File
@@ -227,8 +227,8 @@ pub(crate) fn picture(s: &mut Session, v: &Value) -> CmdResult {
place_picture(s, bytes, p::f32(v, "width"), p::str(v, "alt").unwrap_or(""))
}
/// The media extension of an encoded image by its magic bytes, as `image_size` accepted it
/// (TIFF included: the `image` crate decodes it).
/// The media extension of an encoded image by its magic bytes, as the picture probe accepted it
/// (TIFF included when its compression is one the `image` crate decodes within limits).
pub(crate) fn image_ext(bytes: &[u8]) -> &'static str {
match bytes.get(..4) {
Some([0x89, b'P', b'N', b'G']) => "png",
@@ -249,14 +249,15 @@ fn place_picture(s: &mut Session, bytes: Vec<u8>, width: Option<f32>, alt: &str)
if bytes.len() > 200 << 20 {
return Err(CmdError::Failed("image is larger than 200 MB".into()));
}
// Natural size: pixels at 96 ppi, or a metafile's own frame (WMF, EMF: #802).
let (natural_w, natural_h, ext) = if let Some((pw, ph)) = wordcraft_render::image_size(&bytes) {
let ext = image_ext(&bytes);
(pw as f32 * 0.75, ph as f32 * 0.75, ext)
} else if let Some(size) = wordcraft_render::metafile_size(&bytes) {
size
} else {
return Err(CmdError::Failed("not a supported image (PNG, JPEG, GIF, WebP, BMP, WMF, EMF)".into()));
// Natural size: pixels at 96 ppi, or a metafile's own frame (WMF, EMF: #802). A picture that
// is never decoded (a TIFF with JPEG strips, #3645) isn't inserted.
let (natural_w, natural_h, ext) = match wordcraft_geom::picture::probe(&bytes) {
Ok(pic) => (pic.width as f32 * 0.75, pic.height as f32 * 0.75, image_ext(&bytes)),
Err(wordcraft_geom::picture::Refusal::Unsupported(why)) => return Err(CmdError::Failed(why.into())),
Err(_) => match wordcraft_render::metafile_size(&bytes) {
Some(size) => size,
None => return Err(CmdError::Failed("not a supported image (PNG, JPEG, GIF, WebP, BMP, WMF, EMF)".into())),
},
};
let key = s.doc.add_media(bytes, ext);
// Fitted to the text width.
@@ -1050,6 +1051,36 @@ pub fn drop_cap_state(s: &Session) -> DropCapState {
mod tests {
use super::*;
/// A 4 × 4 RGB TIFF that says its strips are JPEG (they aren't): its header still reads.
fn jpeg_strip_tiff() -> Vec<u8> {
let mut t = Vec::new();
image::RgbImage::new(4, 4).write_to(&mut std::io::Cursor::new(&mut t), image::ImageFormat::Tiff).unwrap();
assert_eq!(&t[..4], b"II*\0");
let ifd = u32::from_le_bytes(t[4..8].try_into().unwrap()) as usize;
let n = usize::from(u16::from_le_bytes([t[ifd], t[ifd + 1]]));
let e = (0..n).map(|i| ifd + 2 + 12 * i).find(|&e| t[e..e + 2] == 259u16.to_le_bytes()).unwrap();
t[e + 8..e + 10].copy_from_slice(&7u16.to_le_bytes());
t
}
/// #3645: a TIFF with JPEG strips is never decoded, so it is neither inserted nor swapped in;
/// before, its readable header was all insertion looked at.
#[test]
fn tiffs_with_jpeg_strips_are_refused() {
let tiff = jpeg_strip_tiff();
assert_eq!(wordcraft_render::image_size(&tiff), Some((4, 4)), "its header reads");
let mut s = Session::new(wordcraft_doc::Document::new());
let e = s.run("insert.picture", &json!({"data": base64_encode(&tiff)})).unwrap_err().to_string();
assert!(e.contains("TIFF"), "{e}");
assert!(s.doc.media.is_empty());
let mut plain = Vec::new();
image::RgbImage::new(4, 4).write_to(&mut std::io::Cursor::new(&mut plain), image::ImageFormat::Tiff).unwrap();
s.run("insert.picture", &json!({"data": base64_encode(&plain)})).unwrap();
let e = s.run("picture.change", &json!({"data": base64_encode(&tiff)})).unwrap_err().to_string();
assert!(e.contains("TIFF"), "{e}");
assert_eq!(s.doc.media.len(), 1);
}
/// Closing the header or footer (Esc, the ribbon) puts the caret back where it was in the
/// body, not on page 1 (#3331).
#[test]
+39
View File
@@ -1501,6 +1501,45 @@ mod tests {
assert_eq!(parse_csv("a;b\n1;2")[1], vec!["1", "2"]);
}
/// #4001: finishing a merge opens a new document, with a new undo history. It is not an
/// edit of the open document, even when the merged document holds the same content.
#[test]
fn finishing_a_merge_opens_a_new_document_with_a_new_history() {
let mut s = Session::new(Document::from_text("Hello"));
s.run("text.insert", &json!({"text": "!"})).unwrap();
assert_eq!(s.undo_depth(), 1);
s.run("caret.docStart", &json!({})).unwrap();
// One recipient and no merge fields: the merged document holds the same content.
s.run("mailings.recipients", &json!({"rows": [{"Name": "Ada"}]})).unwrap();
let id = s.document_id();
s.run("mailings.finish", &json!({})).unwrap();
assert_ne!(s.document_id(), id, "the document was replaced");
assert_eq!(s.doc.plain_text(StoryRef::Body), "!Hello");
assert_eq!(s.undo_depth(), 0, "the new document starts with an empty undo history");
// Undo cannot bring the old document's text back.
s.run("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc.plain_text(StoryRef::Body), "!Hello", "the old text does not come back");
}
/// #4004: finishing a merge to a file saves the merged copy there and leaves the open
/// document alone: it changes nothing, so it is not an edit.
#[test]
fn finishing_a_merge_to_a_file_is_not_an_edit() {
let dir = std::env::temp_dir().join(format!("wordcraft-merge-to-file-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let out = dir.join("merged.docx");
let mut s = Session::new(Document::from_text("Hello"));
s.run("text.insert", &json!({"text": "!"})).unwrap();
s.run("mailings.recipients", &json!({"rows": [{"Name": "Ada"}]})).unwrap();
let (depth, dirty, text) = (s.undo_depth(), s.dirty, s.doc.plain_text(StoryRef::Body));
s.run("mailings.finish", &json!({"path": out.to_string_lossy()})).unwrap();
assert!(out.exists(), "the merged copy was written");
assert_eq!(s.doc.plain_text(StoryRef::Body), text, "the open document is unchanged");
assert_eq!(s.undo_depth(), depth, "no undo step");
assert_eq!(s.dirty, dirty, "the unsaved-changes flag is unchanged");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn csv_delimiter_ignores_quoted_text() {
let r = parse_csv("\"Name, first, last\";City\nAnn;Paris\n");
+5 -1
View File
@@ -387,8 +387,12 @@ fn insert_text(s: &mut Session, text: &str, enter: bool) -> Result<InsertEffect,
let mut at = s.sel.focus.clone();
let mut first = true;
let mut inserted = false;
// Typed line breaks are the Enter key between the lines: in a one-line control it does
// nothing, so the lines run together and the control stays one paragraph (pasted text keeps
// its breaks, as before).
let no_breaks = enter && controls::refuses_paragraph(s);
for part in text.split(['\r', '\u{2029}']) {
if !first {
if !first && !no_breaks {
at = split_para_as(s, &at, enter)?;
inserted = true;
}
+232 -55
View File
@@ -134,7 +134,7 @@ pub fn specs() -> Vec<CommandSpec> {
// `blur` asserts on a subnormal width.
img = img.blur(-sharpen / 25.0);
}
img
Ok(img)
})
})
.params(r#"{"brightness"?: -100..100, "contrast"?: -100..100, "sharpen"?: -100..100}"#)
@@ -146,7 +146,7 @@ pub fn specs() -> Vec<CommandSpec> {
let (_, o) = selected(s).ok_or_else(|| CmdError::Disabled("no picture".into()))?;
let InlineObject::Image { media, .. } = o else { return Err(CmdError::Disabled("not a picture".into())) };
let base = s.originals.get(&media).cloned().unwrap_or(media.clone());
adjust_media(s, &base, move |img| recolor(img, &mode, sat))
adjust_media(s, &base, move |img| Ok(recolor(img, &mode, sat)))
})
.params(r#"{"mode": "grayscale|sepia|washout|blackAndWhite|saturation|tint", "saturation"?: 0..400}"#)
.when(has_picture),
@@ -159,7 +159,7 @@ pub fn specs() -> Vec<CommandSpec> {
for px in rgba.pixels_mut() {
px.0 = [c.0, c.1, c.2, px.0[3]];
}
image::DynamicImage::ImageRgba8(rgba)
Ok(image::DynamicImage::ImageRgba8(rgba))
})
})
.params(r#"{"color": "RRGGBB"}"#)
@@ -186,20 +186,20 @@ pub fn specs() -> Vec<CommandSpec> {
for px in rgba.pixels_mut() {
px.0[3] = (px.0[3] as f32 * (1.0 - t / 100.0)) as u8;
}
image::DynamicImage::ImageRgba8(rgba)
Ok(image::DynamicImage::ImageRgba8(rgba))
})
})
.params(r#"{"percent": 0..100}"#)
.when(has_picture),
CommandSpec::new("picture.removeBackground", "Remove Background", "Picture Format › Adjust", |s, v| {
let tol = p::f32(v, "tolerance").unwrap_or(30.0).clamp(0.0, 255.0);
adjust(s, move |img| remove_background(img, tol))
adjust(s, move |img| Ok(remove_background(img, tol)))
})
.params(r#"{"tolerance"?: 0..255}"#)
.when(has_picture),
CommandSpec::new("picture.compress", "Compress Pictures", "Picture Format › Adjust", |s, v| {
let max = p::u64(v, "maxPixels").unwrap_or(1600).clamp(32, 20000) as u32;
adjust(s, move |img| if img.width().max(img.height()) > max { img.resize(max, max, image::imageops::FilterType::Lanczos3) } else { img })
adjust(s, move |img| Ok(if img.width().max(img.height()) > max { img.resize(max, max, image::imageops::FilterType::Lanczos3) } else { img }))
})
.params(r#"{"maxPixels"?: n}"#)
.when(has_picture),
@@ -212,7 +212,10 @@ pub fn specs() -> Vec<CommandSpec> {
} else {
return Err(CmdError::Params("`path` or `data` required".into()));
};
wordcraft_render::image_size(&bytes).ok_or_else(|| CmdError::Failed("not a supported image".into()))?;
wordcraft_geom::picture::probe(&bytes).map_err(|e| match e {
wordcraft_geom::picture::Refusal::Unsupported(why) => CmdError::Failed(why.into()),
_ => CmdError::Failed("not a supported image".into()),
})?;
let key = s.doc.add_media(bytes.clone(), super::insert::image_ext(&bytes));
with_obj(s, |o| {
if let InlineObject::Image { media, .. } = o {
@@ -1182,7 +1185,7 @@ pub fn fit_size(w: f32, h: f32, keep: bool) -> (f32, f32) {
}
/// Apply an image operation to the selected picture's bitmap (stored as a new PNG).
fn adjust(s: &mut Session, f: impl Fn(image::DynamicImage) -> image::DynamicImage) -> CmdResult {
fn adjust(s: &mut Session, f: impl Fn(image::DynamicImage) -> Result<image::DynamicImage, CmdError>) -> CmdResult {
let (_, o) = selected(s).ok_or_else(|| CmdError::Disabled("no picture".into()))?;
let InlineObject::Image { media, .. } = o else { return Err(CmdError::Disabled("not a picture".into())) };
adjust_media(s, &media, f)
@@ -1191,13 +1194,14 @@ fn adjust(s: &mut Session, f: impl Fn(image::DynamicImage) -> image::DynamicImag
/// Like [`adjust`], but decodes `base` (a media key, usually the pre-edit original) instead
/// of the picture's current bitmap. The result still chains onto the originals map, so Reset
/// Picture keeps working.
fn adjust_media(s: &mut Session, base: &str, f: impl Fn(image::DynamicImage) -> image::DynamicImage) -> CmdResult {
fn adjust_media(s: &mut Session, base: &str, f: impl Fn(image::DynamicImage) -> Result<image::DynamicImage, CmdError>) -> CmdResult {
let bytes = s.doc.media.get(base).cloned().ok_or_else(|| CmdError::Failed("picture data missing".into()))?;
let img = image::load_from_memory(&bytes).map_err(|e| CmdError::Failed(format!("can't decode the picture: {e}")))?;
if (img.width() as u64) * (img.height() as u64) > 80_000_000 {
return Err(CmdError::Failed("picture is too large to edit".into()));
}
let out = f(img);
// Within the budget pictures are drawn within, checked from the header first (#3644, #3647).
let img = wordcraft_geom::picture::decode(&bytes, wordcraft_geom::MAX_DECODED_BYTES).map_err(|e| match e {
wordcraft_geom::picture::Refusal::TooLarge { .. } => too_large(),
e => CmdError::Failed(format!("can't decode the picture: {e}")),
})?;
let out = f(img)?;
let mut png = Vec::new();
out.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).map_err(|e| CmdError::Failed(e.to_string()))?;
let key = s.doc.add_media(png, "png");
@@ -1221,6 +1225,25 @@ fn adjust_media(s: &mut Session, base: &str, f: impl Fn(image::DynamicImage) ->
})
}
fn too_large() -> CmdError {
CmdError::Failed("picture is too large to edit".into())
}
/// A `w` × `h` bitmap of `fill` for an edit to draw on, refused when the picture it makes would be
/// too large to draw (two RGBA buffers within the decode budget), before it is allocated (#3611).
fn canvas(w: u32, h: u32, fill: [u8; 4]) -> Result<image::RgbaImage, CmdError> {
if !wordcraft_geom::decode_fits(w, h, wordcraft_geom::MAX_DECODED_BYTES) {
return Err(too_large());
}
Ok(image::RgbaImage::from_pixel(w, h, image::Rgba(fill)))
}
/// `w` and `h` grown by `pad` on every side, None past `u32`.
fn grown(w: u32, h: u32, pad: u32) -> Option<(u32, u32)> {
let twice = pad.checked_mul(2)?;
Some((w.checked_add(twice)?, h.checked_add(twice)?))
}
/// What Artistic Effects, Picture Styles and Picture Border drew on a picture's bitmap: the
/// bitmap they started from and the effect, style and border on it, so the next one draws again
/// from that bitmap instead of over the last result. Picking a second style replaces the first,
@@ -1267,12 +1290,12 @@ fn relook(s: &mut Session, change: impl FnOnce(&mut PictureLook)) -> CmdResult {
None => img,
};
if let Some(st) = &l.style {
img = picture_style(img, st);
img = picture_style(img, st)?;
}
if let Some((c, w)) = l.border {
img = frame(img, w, [c.0, c.1, c.2, 255]);
img = frame(img, w, [c.0, c.1, c.2, 255])?;
}
img
Ok(img)
})?;
if let Some((_, InlineObject::Image { media, .. })) = selected(s)
&& let Some(drawn) = s.doc.media.get(&media).cloned()
@@ -1356,12 +1379,22 @@ fn recolor(img: image::DynamicImage, mode: &str, sat: f32) -> image::DynamicImag
/// Flood the border-connected region close to the corner colour with transparency.
fn remove_background(img: image::DynamicImage, tol: f32) -> image::DynamicImage {
let mut rgba = img.to_rgba8();
clear_background(&mut rgba, tol);
image::DynamicImage::ImageRgba8(rgba)
}
/// [`remove_background`] on `rgba`, a row span at a time: besides the border's seeds, the stack
/// holds one for each run of matching pixels beside a filled span, instead of four for every
/// pixel filled (#3647). Returns the most seeds it held.
fn clear_background(rgba: &mut image::RgbaImage, tol: f32) -> usize {
let (w, h) = rgba.dimensions();
if w == 0 || h == 0 {
return image::DynamicImage::ImageRgba8(rgba);
}
let bg = rgba.get_pixel(0, 0).0;
let Some(bg) = rgba.get_pixel_checked(0, 0).map(|p| p.0) else { return 0 };
let close = |p: [u8; 4]| (0..3).map(|i| (p[i] as f32 - bg[i] as f32).abs()).fold(0.0f32, f32::max) <= tol;
let at = |x: u32, y: u32| y as usize * w as usize + x as usize;
// Not cleared yet, and close to the corner colour.
let open = |rgba: &image::RgbaImage, seen: &[bool], x: u32, y: u32| {
!seen.get(at(x, y)).copied().unwrap_or(true) && rgba.get_pixel_checked(x, y).is_some_and(|p| close(p.0))
};
let mut seen = vec![false; (w as usize) * (h as usize)];
let mut stack: Vec<(u32, u32)> = Vec::new();
for x in 0..w {
@@ -1372,46 +1405,55 @@ fn remove_background(img: image::DynamicImage, tol: f32) -> image::DynamicImage
stack.push((0, y));
stack.push((w - 1, y));
}
let mut most = stack.len();
while let Some((x, y)) = stack.pop() {
let i = (y as usize) * (w as usize) + x as usize;
if seen.get(i).copied().unwrap_or(true) {
if !open(rgba, &seen, x, y) {
continue;
}
if let Some(sv) = seen.get_mut(i) {
*sv = true;
let (mut x0, mut x1) = (x, x);
while x0 > 0 && open(rgba, &seen, x0 - 1, y) {
x0 -= 1;
}
let p = rgba.get_pixel(x, y).0;
if !close(p) {
continue;
while x1 + 1 < w && open(rgba, &seen, x1 + 1, y) {
x1 += 1;
}
rgba.put_pixel(x, y, image::Rgba([p[0], p[1], p[2], 0]));
if x > 0 {
stack.push((x - 1, y));
for xi in x0..=x1 {
if let Some(s) = seen.get_mut(at(xi, y)) {
*s = true;
}
if let Some(p) = rgba.get_pixel_mut_checked(xi, y) {
p.0[3] = 0;
}
}
if x + 1 < w {
stack.push((x + 1, y));
}
if y > 0 {
stack.push((x, y - 1));
}
if y + 1 < h {
stack.push((x, y + 1));
for ny in [y.checked_sub(1), y.checked_add(1).filter(|&n| n < h)].into_iter().flatten() {
let mut xi = x0;
while xi <= x1 {
if open(rgba, &seen, xi, ny) {
stack.push((xi, ny));
while xi <= x1 && open(rgba, &seen, xi, ny) {
xi += 1;
}
} else {
xi += 1;
}
}
}
most = most.max(stack.len());
}
image::DynamicImage::ImageRgba8(rgba)
most
}
fn frame(img: image::DynamicImage, border: u32, color: [u8; 4]) -> image::DynamicImage {
let (w, h) = (img.width(), img.height());
let mut out = image::RgbaImage::from_pixel(w + 2 * border, h + 2 * border, image::Rgba(color));
fn frame(img: image::DynamicImage, border: u32, color: [u8; 4]) -> Result<image::DynamicImage, CmdError> {
let (w, h) = grown(img.width(), img.height(), border).ok_or_else(too_large)?;
let mut out = canvas(w, h, color)?;
image::imageops::overlay(&mut out, &img.to_rgba8(), border as i64, border as i64);
image::DynamicImage::ImageRgba8(out)
Ok(image::DynamicImage::ImageRgba8(out))
}
fn picture_style(img: image::DynamicImage, style: &str) -> image::DynamicImage {
fn picture_style(img: image::DynamicImage, style: &str) -> Result<image::DynamicImage, CmdError> {
let m = (img.width().max(img.height()) / 40).max(4);
match style {
"thickFrame" => frame(frame(img, m * 2, [255, 255, 255, 255]), m / 2, [40, 40, 40, 255]),
Ok(match style {
"thickFrame" => frame(frame(img, m * 2, [255, 255, 255, 255])?, m / 2, [40, 40, 40, 255])?,
"rounded" | "softEdge" => {
let mut rgba = img.to_rgba8();
let (w, h) = rgba.dimensions();
@@ -1434,18 +1476,47 @@ fn picture_style(img: image::DynamicImage, style: &str) -> image::DynamicImage {
"shadow" => {
let (w, h) = (img.width(), img.height());
let off = m;
let mut out = image::RgbaImage::from_pixel(w + off * 2, h + off * 2, image::Rgba([0, 0, 0, 0]));
let shadow = image::RgbaImage::from_pixel(w, h, image::Rgba([0, 0, 0, 90]));
let shadow = image::DynamicImage::ImageRgba8(shadow);
let mut padded = image::RgbaImage::from_pixel(w + off * 2, h + off * 2, image::Rgba([0, 0, 0, 0]));
image::imageops::overlay(&mut padded, &shadow.to_rgba8(), (off + off / 2) as i64, (off + off / 2) as i64);
let blurred = image::DynamicImage::ImageRgba8(padded).blur(off as f32 / 2.0).to_rgba8();
image::imageops::overlay(&mut out, &blurred, 0, 0);
let (pw, ph) = grown(w, h, off).ok_or_else(too_large)?;
let mut out = canvas(pw, ph, [0, 0, 0, 0])?;
// A rectangle of alpha 90 the picture's size, one and a half margins in, blurred. Its
// blur is the product of its sides' blurs along each axis, so it is drawn from two
// profiles, not by blurring every pixel with a kernel that grows with the picture (#3647).
let (sigma, start) = (off as f32 / 2.0, off + off / 2);
let (xs, ys) = (blurred_span(pw, start, w, sigma), blurred_span(ph, start, h, sigma));
for (x, y, p) in out.enumerate_pixels_mut() {
let k = xs.get(x as usize).copied().unwrap_or(0.0) * ys.get(y as usize).copied().unwrap_or(0.0);
p.0[3] = (90.0 * k).round() as u8;
}
image::imageops::overlay(&mut out, &img.to_rgba8(), 0, 0);
image::DynamicImage::ImageRgba8(out)
}
_ => frame(img, m, [255, 255, 255, 255]),
_ => frame(img, m, [255, 255, 255, 255])?,
})
}
/// How much of a span `len` long from `start` covers each of `n` positions once blurred with the
/// Gaussian `image::imageops::blur` uses for `sigma` (outside the `n` positions counts as clear):
/// 0 to 1. Each position is one difference of the kernel's running sums.
fn blurred_span(n: u32, start: u32, len: u32, sigma: f32) -> Vec<f32> {
let kernel = gaussian_kernel(sigma);
let mut sums = vec![0.0f32];
for k in &kernel {
sums.push(sums.last().copied().unwrap_or(0.0) + k);
}
let (size, r) = (kernel.len() as i64, (kernel.len() / 2) as i64);
let sum_to = |k: i64| sums.get(k.clamp(0, size) as usize).copied().unwrap_or(0.0);
let (start, end) = (i64::from(start), i64::from(start) + i64::from(len));
// Position i takes weight k from i + k - r, inside the span for k in [start - i + r, end - i + r).
(0..i64::from(n)).map(|i| sum_to(end - i + r) - sum_to(start - i + r)).collect()
}
/// The normalized kernel `image::imageops::blur` (0.25) uses for `sigma`: odd, about 6.7 σ wide.
fn gaussian_kernel(sigma: f32) -> Vec<f32> {
let size = ((((sigma - 0.8) / 0.3 + 1.0) * 2.0 + 1.0).max(3.0) as usize) | 1;
let mean = (size / 2) as f32;
let weights: Vec<f32> = (0..size).map(|x| (-0.5 * ((x as f32 - mean) / sigma).powi(2)).exp()).collect();
let total: f32 = weights.iter().sum();
weights.into_iter().map(|v| v / total).collect()
}
/// The selected pictures, shapes, text boxes and groups: the one the selection holds, then
@@ -1982,6 +2053,112 @@ mod tests {
assert_eq!(s.run("arrange.selectionPane", &json!({})).unwrap().as_array().map(Vec::len), Some(1));
}
/// A PNG whose header (valid CRC) declares `w` × `h` pixels, with one pixel of data.
fn header_only_png(w: u32, h: u32) -> Vec<u8> {
let mut b = png(8, 8);
b[16..20].copy_from_slice(&w.to_be_bytes());
b[20..24].copy_from_slice(&h.to_be_bytes());
let mut crc = 0xFFFF_FFFFu32;
for &byte in &b[12..29] {
crc ^= u32::from(byte);
for _ in 0..8 {
crc = if crc & 1 == 1 { 0xEDB8_8320 ^ (crc >> 1) } else { crc >> 1 };
}
}
b[29..33].copy_from_slice(&(!crc).to_be_bytes());
b
}
#[test]
fn picture_edits_refuse_an_over_budget_header_before_decoding() {
// Inserting reads the size only; every edit then refuses it from the header (#3644, #3647).
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("insert.picture", &json!({"data": super::super::insert::base64_encode(&header_only_png(20_000, 20_000))})).unwrap();
let media = s.doc.media.len();
for (id, v) in [
("picture.corrections", json!({"brightness": 10})),
("picture.color", json!({"mode": "sepia"})),
("picture.removeBackground", json!({})),
("picture.style", json!({"style": "shadow"})),
] {
let e = s.run(id, &v).unwrap_err().to_string();
assert!(e.contains("too large to edit"), "{id}: {e}");
}
assert_eq!(s.doc.media.len(), media, "nothing was added");
}
/// The Shadow style before #3647: the rectangle blurred with `image`'s Gaussian.
fn blurred_shadow(img: &image::DynamicImage) -> image::RgbaImage {
let (w, h) = (img.width(), img.height());
let off = (w.max(h) / 40).max(4);
let mut out = image::RgbaImage::from_pixel(w + off * 2, h + off * 2, image::Rgba([0, 0, 0, 0]));
let shadow = image::RgbaImage::from_pixel(w, h, image::Rgba([0, 0, 0, 90]));
let mut padded = image::RgbaImage::from_pixel(w + off * 2, h + off * 2, image::Rgba([0, 0, 0, 0]));
image::imageops::overlay(&mut padded, &shadow, (off + off / 2) as i64, (off + off / 2) as i64);
let blurred = image::DynamicImage::ImageRgba8(padded).blur(off as f32 / 2.0).to_rgba8();
image::imageops::overlay(&mut out, &blurred, 0, 0);
image::imageops::overlay(&mut out, &img.to_rgba8(), 0, 0);
out
}
/// #3647: the shadow drawn from two profiles looks like the blur it replaces.
#[test]
fn the_shadow_style_matches_the_blur_it_replaces() {
for (w, h) in [(80, 40), (400, 220), (37, 300)] {
let img = image::DynamicImage::ImageRgba8(image::RgbaImage::from_fn(w, h, |x, y| {
image::Rgba([(x * 7) as u8, (y * 5) as u8, 90, if (x + y) % 3 == 0 { 255 } else { 120 }])
}));
let new = picture_style(img.clone(), "shadow").unwrap().to_rgba8();
let old = blurred_shadow(&img);
assert_eq!(new.dimensions(), old.dimensions());
let worst = new.pixels().zip(old.pixels()).flat_map(|(a, b)| a.0.into_iter().zip(b.0).map(|(a, b)| a.abs_diff(b))).max();
assert!(worst.is_some_and(|d| d <= 2), "{w} x {h}: {worst:?}");
}
}
/// #3647: Remove Background fills a span at a time, so its stack stays near the border's seeds
/// instead of growing with the picture, and it clears the same region.
#[test]
fn remove_background_keeps_a_small_stack() {
let mut uniform = image::RgbaImage::from_pixel(2000, 2000, image::Rgba([250, 250, 250, 255]));
let most = clear_background(&mut uniform, 30.0);
assert!(most <= 4 * (2000 + 2000) + 8, "{most}");
assert!(uniform.pixels().all(|p| p.0[3] == 0));
// A square ring keeps what it encloses: only background reachable from the border goes.
let mut ring = image::RgbaImage::from_fn(60, 60, |x, y| {
let d = (x as i32 - 30).abs().max((y as i32 - 30).abs());
if (10..14).contains(&d) { image::Rgba([200, 20, 20, 255]) } else { image::Rgba([255, 255, 255, 255]) }
});
clear_background(&mut ring, 30.0);
assert_eq!(ring.get_pixel(0, 0).0, [255, 255, 255, 0], "outside: cleared, colour kept");
assert_eq!(ring.get_pixel(5, 30).0[3], 0);
assert_eq!(ring.get_pixel(30, 18).0[3], 255, "the ring");
assert_eq!(ring.get_pixel(30, 30).0[3], 255, "inside the ring");
assert_eq!(clear_background(&mut image::RgbaImage::new(0, 0), 30.0), 0);
}
/// #3611: a style or border is drawn only when the picture it makes can still be drawn. On a
/// 1 × 2,000,000 picture Thick Frame asked for about 1.8 TB and aborted the process.
#[test]
fn picture_styles_and_borders_check_the_size_they_draw() {
let mut thin = Vec::new();
image::GrayImage::new(1, 2_000_000).write_to(&mut std::io::Cursor::new(&mut thin), image::ImageFormat::Png).unwrap();
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("insert.picture", &json!({"data": super::super::insert::base64_encode(&thin)})).unwrap();
let media = s.doc.media.len();
for (id, v) in [
("picture.style", json!({"style": "thickFrame"})),
("picture.style", json!({"style": "shadow"})),
("picture.style", json!({})),
("picture.border", json!({"width": 200})),
] {
let e = s.run(id, &v).unwrap_err().to_string();
assert!(e.contains("too large to edit"), "{id} {v}: {e}");
}
assert_eq!(s.doc.media.len(), media, "nothing was drawn");
assert_eq!(grown(u32::MAX, 1, 1), None);
}
#[test]
fn picture_pipeline() {
let mut s = Session::new(wordcraft_doc::Document::new());
+2 -2
View File
@@ -398,7 +398,7 @@ pub fn indent(s: &mut Session, _: &Value) -> CmdResult {
};
let para = s.doc.para_mut(a.story, &path)?;
match num {
Some(n) => para.props.numbering = Some(NumRef { num: n.num, level: (n.level + 1).min(8) }),
Some(n) => para.props.numbering = Some(NumRef { num: n.num, level: n.level.saturating_add(1).min(8) }),
None => {
para.props.indent_left = Some(((left / tab).floor() + 1.0) * tab);
para.props.cancel_char_indents(true, false, false);
@@ -810,7 +810,7 @@ fn outline_step(s: &mut Session, promote: bool) -> CmdResult {
let list = p.props.numbering.filter(|n| n.num != 0);
let target = match (list, heading_level(s, &p.props)) {
(Some(n), _) => {
let level = if promote { n.level.saturating_sub(1) } else { (n.level + 1).min(8) };
let level = if promote { n.level.saturating_sub(1) } else { n.level.saturating_add(1).min(8) };
if level != n.level {
let para = s.doc.para_mut(a.story, &path)?;
para.props.numbering = Some(NumRef { num: n.num, level });
+51 -13
View File
@@ -21,9 +21,7 @@ pub fn specs() -> Vec<CommandSpec> {
}),
CommandSpec::new("references.removeToc", "Remove Table of Contents", "References › Table of Contents", |s, _| {
if let Some((start, end)) = toc_range(s) {
for i in (start..=end).rev() {
s.doc.remove_block(StoryRef::Body, &Path::top(i))?;
}
remove_toc(s, start, end)?;
s.clamp_selection();
}
sel_result(s)
@@ -339,6 +337,36 @@ fn toc_range(s: &Session) -> Option<(usize, usize)> {
Some((start, end))
}
/// Removes the TOC blocks `start..=end` and returns where the TOC was. A field paragraph that ends
/// a section stays, emptied, so its section break isn't lost (#3889). A heading paragraph of its
/// own just above the field's paragraph (Word's tables start the field after their heading) goes
/// with the table.
fn remove_toc(s: &mut Session, start: usize, end: usize) -> Result<usize, CmdError> {
for i in (start + 1..=end).rev() {
s.doc.remove_block(StoryRef::Body, &Path::top(i))?;
}
let at = match s.doc.para_mut(StoryRef::Body, &Path::top(start)) {
Ok(p) if p.section.is_some() => {
*p = Paragraph { section: p.section.take(), ..Paragraph::new() };
start + 1
}
_ => {
s.doc.remove_block(StoryRef::Body, &Path::top(start))?;
start
}
};
let titled = start
.checked_sub(1)
.and_then(|i| s.doc.body.get(i))
.and_then(|b| b.as_para())
.is_some_and(|p| p.props.style.as_deref() == Some("TOCHeading") && p.section.is_none() && toc_field(p).is_none());
if titled {
s.doc.remove_block(StoryRef::Body, &Path::top(start - 1))?;
return Ok(at - 1);
}
Ok(at)
}
/// The caret is in the table of contents (its heading or an entry), where Word's context menu
/// offers Update Field for the whole table.
pub fn caret_in_toc(s: &Session) -> bool {
@@ -393,15 +421,24 @@ fn range_toc_to_field(s: &mut Session) -> Result<(), CmdError> {
// lay it out) leaves an empty paragraph behind, which would show as a blank line above the
// entries. The field goes at the end of the heading instead, as `insert_toc` places it.
let i = at.path.last();
// Or one holding only the end of Word's TOC control, still styled as an entry: Update Table
// would delete it with the old entries anyway.
let leftover = |p: &Paragraph| {
p.is_empty()
|| (p.objects.iter().all(|o| matches!(o, InlineObject::ControlEnd))
&& p.text.chars().all(|c| c == OBJ)
&& p.props.style.as_deref().is_some_and(|st| st.starts_with("TOC") && st != "TOCHeading"))
};
if at.path.depth() == 0
&& i > 0
&& s.doc.para_at(&at).is_some_and(|p| p.is_empty())
&& s.doc.para_at(&at).is_some_and(leftover)
// The paragraph before may also be the empty one the writer leaves where the engine's TOC
// field was (the field is saved opening in the first entry), so the next save and update
// doesn't add a blank line each time (#2182). A blank line the user typed right above a
// range TOC is reused the same way, so deleting it later deletes the TOC field.
// range TOC is reused the same way, so deleting it later deletes the TOC field. So is one
// ending the section before, as above entries in columns (#3889).
&& s.doc.body.get(i - 1).and_then(|b| b.as_para()).is_some_and(|p| {
p.props.style.as_deref() == Some("TOCHeading") || (p.is_empty() && p.objects.is_empty() && p.section.is_none())
p.props.style.as_deref() == Some("TOCHeading") || (p.is_empty() && p.objects.is_empty())
})
{
s.doc.remove_block(StoryRef::Body, &at.path)?;
@@ -474,9 +511,7 @@ fn insert_toc(s: &mut Session, v: &Value) -> CmdResult {
return sel_result(s);
}
Some((start, end)) => {
for i in (start..=end).rev() {
s.doc.remove_block(StoryRef::Body, &Path::top(i))?;
}
let start = remove_toc(s, start, end)?;
s.doc.insert_block(StoryRef::Body, &Path::top(start), Block::Para(Paragraph::new()))?;
placeholder = true;
s.sel = Selection::caret(Pos { story: StoryRef::Body, path: Path::top(start), off: 0 });
@@ -585,10 +620,13 @@ fn regenerate_toc(s: &mut Session, leader: Option<TabLeader>, tabs: &[(String, V
for i in (start + 1..=end).rev() {
s.doc.remove_block(StoryRef::Body, &Path::top(i))?;
}
// The text width of the TOC's own section, not the caret's: after Select All the caret sits
// at the end of the document, possibly in a wider (landscape) section.
let section = s.doc.section_index_of(start);
let width = s.doc.sections().get(section).map_or_else(|| super::page::sect(s).text_width(), |(_, x)| x.text_width());
// The column width of the TOC's own section, not the caret's: after Select All the caret sits
// at the end of the document, possibly in a wider (landscape) section. In columns, the first
// column's, as Word does (#3889); after a field paragraph ending a section, the next section's.
let after_break = s.doc.body.get(start).and_then(|b| b.as_para()).is_some_and(|p| p.section.is_some());
let section = s.doc.section_index_of(start + usize::from(after_break));
let column = |x: &wordcraft_doc::SectionProps| x.column_boxes().first().map_or(x.text_width(), |(_, w)| *w);
let width = s.doc.sections().get(section).map_or_else(|| column(&super::page::sect(s)), |(_, x)| column(x));
let entries = toc_entries(s, &f);
let links: Vec<Option<String>> = if f.has('h') {
let mut v = Vec::new();
+74 -18
View File
@@ -141,9 +141,20 @@ fn new_comment(s: &mut Session, v: &Value) -> CmdResult {
let part = s.doc.try_add_part(PartKind::Comment, vec![para_block(Paragraph::with_text(&text, Default::default()))])?;
let id = comment_id(s)?;
let initials = s.user_initials();
s.doc
.comments
.insert(id, Comment { author: s.author.clone(), initials, date: now_iso(), parent: None, resolved: false, part, ..Default::default() });
let now = now_iso();
s.doc.comments.insert(
id,
Comment {
author: s.author.clone(),
initials,
date: now.clone(),
date_utc: Some(now),
parent: None,
resolved: false,
part,
..Default::default()
},
);
let props = Default::default();
s.doc.insert_object(&b, InlineObject::CommentEnd { id }, &props)?;
s.doc.insert_object(&a, InlineObject::CommentStart { id }, &props)?;
@@ -170,9 +181,19 @@ fn reply(s: &mut Session, v: &Value) -> CmdResult {
let part = s.doc.try_add_part(PartKind::Comment, vec![para_block(Paragraph::with_text(text, Default::default()))])?;
let id = comment_id(s)?;
let initials = s.user_initials();
let now = now_iso();
s.doc.comments.insert(
id,
Comment { author: s.author.clone(), initials, date: now_iso(), parent: Some(parent), resolved: false, part, ..Default::default() },
Comment {
author: s.author.clone(),
initials,
date: now.clone(),
date_utc: Some(now),
parent: Some(parent),
resolved: false,
part,
..Default::default()
},
);
Ok(json!({"id": id}))
}
@@ -363,20 +384,8 @@ fn nav_comment(s: &mut Session, dir: i32) -> CmdResult {
sel_result(s)
}
/// Accept or reject a revision range in one paragraph; `mark` includes its paragraph mark (and
/// the section break on it), `props` the paragraph's own formatting change.
#[allow(clippy::too_many_arguments)]
fn resolve_para(
s: &mut Session,
story: StoryRef,
path: &wordcraft_doc::Path,
from: usize,
to: usize,
mark: bool,
props: bool,
accept: bool,
) -> Result<(), CmdError> {
let para = s.doc.para_mut(story, path)?;
/// Accept or reject the tracked formatting and text of `from..to` in one paragraph (not its mark).
fn resolve_runs(para: &mut wordcraft_doc::Paragraph, from: usize, to: usize, props: bool, accept: bool) -> Result<(), CmdError> {
if props && (para.props.fmt_change.is_some() || para.props.num_change.is_some()) {
fmt_revisions::resolve_para(&mut para.props, accept);
para.touch();
@@ -397,6 +406,24 @@ fn resolve_para(
})?;
}
}
Ok(())
}
/// Accept or reject a revision range in one paragraph; `mark` includes its paragraph mark (and
/// the section break on it), `props` the paragraph's own formatting change.
#[allow(clippy::too_many_arguments)]
fn resolve_para(
s: &mut Session,
story: StoryRef,
path: &wordcraft_doc::Path,
from: usize,
to: usize,
mark: bool,
props: bool,
accept: bool,
) -> Result<(), CmdError> {
let para = s.doc.para_mut(story, path)?;
resolve_runs(para, from, to, props, accept)?;
if !mark {
return Ok(());
}
@@ -513,6 +540,28 @@ pub fn move_changes(s: &Session) -> Vec<(Pos, String, bool)> {
out
}
/// Accept or reject the tracked text and paragraph marks of the top-level paragraphs in a block
/// list that is not a story (a note separator).
fn resolve_loose_blocks(blocks: &mut wordcraft_doc::Blocks, accept: bool) -> Result<(), CmdError> {
for b in blocks.iter_mut() {
let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(b) else { continue };
let len = p.len();
resolve_runs(p, 0, len, true, accept)?;
if p.mark.fmt_change.is_some() || p.section.as_ref().is_some_and(|x| x.fmt_change.is_some()) {
fmt_revisions::resolve_char(&mut p.mark, accept);
if let Some(sec) = p.section.as_deref_mut() {
fmt_revisions::resolve_section(sec, accept);
}
}
// A mark can't be removed here (no neighbour to join), so it just stops being a revision.
p.mark.ins = None;
p.mark.del = None;
p.touch();
}
fmt_revisions::resolve_containers(blocks, accept, 0);
Ok(())
}
fn resolve_all(s: &mut Session, accept: bool) -> CmdResult {
let stories: Vec<StoryRef> = std::iter::once(StoryRef::Body).chain(s.doc.parts.keys().map(|k| StoryRef::Part(*k))).collect();
for st in stories {
@@ -527,6 +576,13 @@ fn resolve_all(s: &mut Session, accept: bool) -> CmdResult {
fmt_revisions::resolve_containers(&mut part.blocks, accept, 0);
}
fmt_revisions::resolve_section(&mut s.doc.last_section, accept);
// The note separators are block lists of their own, not stories; they must not keep ids of
// revisions that are about to be dropped.
for sep in [&mut s.doc.footnote_separators, &mut s.doc.endnote_separators] {
for blocks in [&mut sep.separator, &mut sep.continuation_separator, &mut sep.continuation_notice].into_iter().flatten() {
resolve_loose_blocks(blocks, accept)?;
}
}
s.doc.revisions.clear();
s.clamp_selection();
sel_result(s)
+6 -2
View File
@@ -243,14 +243,15 @@ fn autofit(s: &mut Session, v: &Value) -> CmdResult {
pub(crate) fn sync_cell_widths(t: &mut Table) {
let grid = t.grid.clone();
for row in &mut t.rows {
let mut g = 0usize;
// A row's cells start after the grid columns it skips (`w:gridBefore`).
let mut g = row.first_grid();
for c in &mut row.cells {
let span = c.span();
let w: f32 = grid.get(g..g.saturating_add(span).min(grid.len())).unwrap_or(&[]).iter().sum();
if w > 0.0 {
c.props.width = Some(w);
}
g += span;
g = g.saturating_add(span);
}
}
}
@@ -516,6 +517,9 @@ pub(super) fn col(s: &mut Session, right: bool) -> CmdResult {
let k = before / total;
t.grid.iter_mut().for_each(|w| *w *= k);
}
// The inserted cells carry the pre-scale column width, and the layout prefers a cell width
// over the grid: sync them, or the shrink never reaches the layout or the saved `w:tcW`.
sync_cell_widths(t);
fix_caret(s, &tp, r, if right { c + 1 } else { c })
}
+169 -17
View File
@@ -254,9 +254,12 @@ enum EnterAutoFormat {
Border(Border),
/// "+---+-----+": a one-row table; the dash count of each column.
Table(Vec<usize>),
/// A short line before an empty paragraph: Heading 1, 2 or 3.
Heading(u8),
}
/// AutoFormat border lines: Enter after "---", "___", "===", "***", "~~~" or "###".
/// AutoFormat border lines: Enter after "---", "___", "===", "***", "~~~" or "###". A line
/// holding a tracked deletion stays as it is.
fn border_line(s: &Session, at: &Pos) -> Option<Border> {
let para = s.doc.para_at(at)?;
let text = para.text.as_str();
@@ -267,6 +270,7 @@ fn border_line(s: &Session, at: &Pos) -> Option<Border> {
|| text.len() < 3
|| !text.chars().all(|x| x == c)
|| para.props.numbering.is_some_and(|n| n.num != 0)
|| para.run_ranges().any(|(_, r)| r.del.is_some())
{
return None;
}
@@ -283,8 +287,8 @@ fn border_line(s: &Session, at: &Pos) -> Option<Border> {
}
/// AutoFormat tables: a body paragraph that is exactly "+---+-----+" (plus signs around runs
/// of hyphens). Returns the hyphen count of each column; more columns than a table can hold
/// leave the text alone.
/// of hyphens). Returns the hyphen count of each column; more columns than a table can hold,
/// or a line holding a tracked deletion, leave the text alone.
fn table_line(s: &Session, at: &Pos) -> Option<Vec<usize>> {
let para = s.doc.para_at(at)?;
let text = para.text.as_str();
@@ -293,6 +297,7 @@ fn table_line(s: &Session, at: &Pos) -> Option<Vec<usize>> {
|| at.path.0.len() != 1
|| at.off != text.len()
|| para.props.numbering.is_some_and(|n| n.num != 0)
|| para.run_ranges().any(|(_, c)| c.del.is_some())
{
return None;
}
@@ -307,8 +312,76 @@ fn table_line(s: &Session, at: &Pos) -> Option<Vec<usize>> {
Some(cols)
}
/// AutoFormat Built-in Heading styles: Enter in an empty body paragraph whose previous paragraph
/// is a line of Normal text with one to five words that ends in a letter or digit and holds no
/// tracked deletion. The line becomes Heading 1, or Heading 2 or 3 when it starts with one or
/// two tabs.
fn heading_line(s: &Session, at: &Pos) -> Option<u8> {
let ac = &s.prefs.autocorrect;
let cur = s.doc.para_at(at)?;
let i = at.path.last();
if !ac.enabled || !ac.heading_styles || at.story != StoryRef::Body || at.path.0.len() != 1 || i == 0 || !cur.is_empty() {
return None;
}
let Some(Block::Para(prev)) = s.doc.block(at.story, &at.path.with_last(i - 1)) else { return None };
// Normal and outside a list, also one the style sheet gives (a numbered Normal style).
let plain = |p: &wordcraft_doc::Paragraph| {
let r = s.doc.styles.resolve_para(&p.props);
p.props.style.as_deref().is_none_or(|st| st == "Normal") && r.numbering.is_none_or(|n| n.num == 0) && r.outline_level.is_none_or(|l| l >= 9)
};
let text = prev.text.trim_start_matches('\t');
let tabs = prev.text.len() - text.len();
if !plain(cur)
|| !plain(prev)
|| !prev.objects.is_empty()
|| prev.section.is_some()
|| prev.run_ranges().any(|(_, c)| c.del.is_some())
|| tabs > 2
|| !(1..=5).contains(&text.split_whitespace().count())
|| text.starts_with(char::is_whitespace)
|| text.contains(char::is_control)
|| !text.ends_with(char::is_alphanumeric)
{
return None;
}
let level = tabs as u8 + 1;
// Formatting limited to the document's styles: a locked heading style isn't applied.
let id = format!("Heading{level}");
if super::protect::enforced(&s.doc) && s.doc.settings.protect_formatting && s.doc.styles.get(&id).is_some_and(|st| st.locked) {
return None;
}
Some(level)
}
/// Give the paragraph before the empty one at `at` Heading `level` without its leading tabs;
/// the caret stays in the empty paragraph. With Track Changes on, the tabs are marked deleted
/// (or removed, when this author typed them) and the style change is a formatting revision.
fn apply_heading_line(s: &mut Session, at: &Pos, level: u8) -> Result<(), CmdError> {
let prev = at.path.with_last(at.path.last().saturating_sub(1));
let Some(before) = s.doc.para(at.story, &prev).cloned() else { return Ok(()) };
let tabs = before.text.len() - before.text.trim_start_matches('\t').len();
let start = Pos { story: at.story, path: prev.clone(), off: 0 };
s.sel = Selection { anchor: start.clone(), focus: Pos { off: tabs, ..start } };
delete_selection(s)?;
super::para::set_heading(s, at.story, &prev, Some(level))?;
// Removed tabs change the text, and the formatting comparison after the command only looks
// at paragraphs that keep theirs, so the style change is recorded here.
if s.doc.settings.track_changes
&& s.prefs.markup.track_formatting
&& s.doc.para(at.story, &prev).is_some_and(|p| p.text != before.text && p.props.fmt_change.is_none())
{
let rev = super::new_revision(s, wordcraft_doc::RevisionKind::Format);
let p = s.doc.para_mut(at.story, &prev)?;
p.props.fmt_change = wordcraft_doc::props::PropChange::boxed(rev, before.props.formatting());
p.touch();
}
s.sel = Selection::caret(at.clone());
Ok(())
}
/// Turn the border-line characters into a bottom border on the paragraph above (or this one
/// at the top of a story).
/// at the top of a story). With Track Changes on, the characters are marked deleted (or
/// removed, when this author typed them) and the border is a formatting revision.
fn apply_border_line(s: &mut Session, at: &Pos, line: Border) -> Result<(), CmdError> {
let set_bottom = |p: &mut wordcraft_doc::Paragraph| {
let mut b = p.props.borders.unwrap_or_default();
@@ -318,31 +391,52 @@ fn apply_border_line(s: &mut Session, at: &Pos, line: Border) -> Result<(), CmdE
};
let i = at.path.last();
let prev = if i > 0 { Some(at.path.with_last(i - 1)) } else { None };
let cur = s.doc.para_mut(at.story, &at.path)?;
let len = cur.len();
cur.delete(0, len)?;
let start = Pos { off: 0, ..at.clone() };
let end = delete_typed_line(s, at)?;
match prev.filter(|q| matches!(s.doc.block(at.story, q), Some(Block::Para(_)))) {
Some(q) => {
// The characters' paragraph becomes the one typing continues in.
set_bottom(s.doc.para_mut(at.story, &q)?);
s.sel = Selection::caret(start);
s.sel = Selection::caret(end);
}
None => {
let before = s.doc.para(at.story, &at.path).map(|p| p.props.clone());
set_bottom(s.doc.para_mut(at.story, &at.path)?);
let new = split_para(s, &start)?;
let new = split_para(s, &end)?;
let p = s.doc.para_mut(new.story, &new.path)?;
p.props.borders = None;
p.touch();
// The split changes the block count, and the formatting comparison after the command
// skips a container whose block count changed, so the border is recorded here.
if s.doc.settings.track_changes
&& s.prefs.markup.track_formatting
&& let Some(before) = before.filter(|b| b.fmt_change.is_none())
{
let rev = super::new_revision(s, wordcraft_doc::RevisionKind::Format);
let p = s.doc.para_mut(at.story, &at.path)?;
p.props.fmt_change = wordcraft_doc::props::PropChange::boxed(rev, before.formatting());
p.touch();
}
s.sel = Selection::caret(new);
}
}
Ok(())
}
/// Delete the AutoFormat characters of the paragraph at `at`, tracked when Track Changes is on.
/// Returns the end of what is left: the paragraph's end, after any characters marked deleted.
fn delete_typed_line(s: &mut Session, at: &Pos) -> Result<Pos, CmdError> {
let len = s.doc.para(at.story, &at.path).map_or(0, |p| p.len());
s.sel = Selection { anchor: Pos { off: 0, ..at.clone() }, focus: Pos { off: len, ..at.clone() } };
delete_selection(s)?;
let left = s.doc.para(at.story, &at.path).map_or(0, |p| p.len());
Ok(Pos { off: left, ..at.clone() })
}
/// Replace the "+---+" characters with a one-row table across the text width, each column as
/// wide as its share of the hyphens; the emptied paragraph stays after the table and the caret
/// goes into the first cell.
/// goes into the first cell. With Track Changes on, the characters are marked deleted (or
/// removed, when this author typed them); the table is inserted untracked, as Insert › Table
/// inserts one.
fn apply_table_line(s: &mut Session, at: &Pos, cols: &[usize]) -> Result<(), CmdError> {
let total = cols.iter().sum::<usize>().max(1) as f32;
let block = at.path.0.first().copied().unwrap_or(0) as usize;
@@ -357,9 +451,7 @@ fn apply_table_line(s: &mut Session, at: &Pos, cols: &[usize]) -> Result<(), Cmd
c.props.width = Some(w);
}
}
let p = s.doc.para_mut(at.story, &at.path)?;
let len = p.len();
p.delete(0, len)?;
delete_typed_line(s, at)?;
s.doc.insert_block(at.story, &at.path, Block::Table(t))?;
let mut first = at.path.0.clone();
first.extend([0, 0, 0]);
@@ -607,7 +699,10 @@ fn new_paragraph(s: &mut Session, _: &Value) -> CmdResult {
return sel_result(s);
}
let mut at = delete_selection(s)?;
let auto = border_line(s, &at).map(EnterAutoFormat::Border).or_else(|| table_line(s, &at).map(EnterAutoFormat::Table));
let auto = border_line(s, &at)
.map(EnterAutoFormat::Border)
.or_else(|| table_line(s, &at).map(EnterAutoFormat::Table))
.or_else(|| heading_line(s, &at).map(EnterAutoFormat::Heading));
if let Some(auto) = auto {
// Word: Undo right after reverts only the AutoFormat, so the typed characters come back
// and the Enter stays. Record that plain Enter as its own undo step.
@@ -619,6 +714,7 @@ fn new_paragraph(s: &mut Session, _: &Value) -> CmdResult {
match auto {
EnterAutoFormat::Border(line) => apply_border_line(s, &at, line)?,
EnterAutoFormat::Table(cols) => apply_table_line(s, &at, &cols)?,
EnterAutoFormat::Heading(level) => apply_heading_line(s, &at, level)?,
}
s.goal_x = None;
s.push_undo("AutoFormat", plain_doc, plain_sel);
@@ -636,7 +732,9 @@ fn new_paragraph(s: &mut Session, _: &Value) -> CmdResult {
sel_result(s)
}
/// In a table: next cell (adds a row at the end). At the start of a list item: demote. Else a tab.
/// In a table: next cell (adds a row at the end). At the start of a list item: demote. With "Set
/// left- and first-indent with tabs and backspaces" on, at the start of a line of a paragraph with
/// text: indent. Else a tab.
fn tab(s: &mut Session, _: &Value) -> CmdResult {
if let Some((tpath, r, c)) = s.sel.focus.path.cell() {
let story = s.sel.focus.story;
@@ -666,10 +764,13 @@ fn tab(s: &mut Session, _: &Value) -> CmdResult {
&& n.num != 0
{
let para = s.doc.para_mut(f.story, &f.path)?;
para.props.numbering = Some(NumRef { num: n.num, level: (n.level + 1).min(8) });
para.props.numbering = Some(NumRef { num: n.num, level: n.level.saturating_add(1).min(8) });
para.touch();
return sel_result(s);
}
if s.sel.is_collapsed() && tab_indent_on(s) && tab_indents(s, &f) {
return indent_by_tab(s, &f);
}
if !s.sel.is_collapsed() && s.sel.anchor.path != s.sel.focus.path {
// Multiple paragraphs selected: increase indent.
return super::para::indent(s, &Value::Null);
@@ -677,6 +778,52 @@ fn tab(s: &mut Session, _: &Value) -> CmdResult {
ins_char(s, '\t')
}
/// Is AutoFormat As You Type's "Set left- and first-indent with tabs and backspaces" on? Not
/// while the document limits formatting to its styles: the indents are direct formatting.
fn tab_indent_on(s: &Session) -> bool {
let ac = &s.prefs.autocorrect;
ac.enabled && ac.tab_indent && !(super::protect::enforced(&s.doc) && s.doc.settings.protect_formatting)
}
/// Does Tab at `f` indent instead of typing a tab? Only at the start of a line of a paragraph
/// with text. Not before the first line of a list item numbered by its style, where Tab types a
/// tab character as with the option off.
fn tab_indents(s: &mut Session, f: &Pos) -> bool {
let Some(para) = s.doc.para_at(f).filter(|p| !p.is_empty()) else { return false };
if f.off == 0 {
return !s.doc.styles.resolve_para(&para.props).numbering.is_some_and(|n| n.num != 0);
}
let l = s.layout();
l.line_bounds(f, s.page_hint).is_some_and(|(a, _)| a == *f)
}
/// Tab before the first line indents the first line by one default tab stop; before any other
/// line, or when the first line is indented or hanging already, it indents the whole paragraph.
/// Before a later line of a list item, numbered directly or by its style, it takes the item down
/// a level.
fn indent_by_tab(s: &mut Session, f: &Pos) -> CmdResult {
let Some(para) = s.doc.para_at(f) else { return sel_result(s) };
let r = s.doc.styles.resolve_para(&para.props);
if f.off > 0
&& para.props.numbering.is_none()
&& let Some(n) = r.numbering.filter(|n| n.num != 0)
{
let p = s.doc.para_mut(f.story, &f.path)?;
p.props.numbering = Some(NumRef { num: n.num, level: n.level.saturating_add(1).min(8) });
p.touch();
return sel_result(s);
}
if f.off == 0 && r.indent_first == 0.0 && r.indent_chars[2] == 0.0 {
let tab = s.doc.settings.default_tab.max(1.0);
let p = s.doc.para_mut(f.story, &f.path)?;
p.props.indent_first = Some(tab);
p.props.cancel_char_indents(false, false, true);
p.touch();
return sel_result(s);
}
super::para::indent(s, &Value::Null)
}
fn back_tab(s: &mut Session, _: &Value) -> CmdResult {
if let Some((tpath, r, c)) = s.sel.focus.path.cell() {
let story = s.sel.focus.story;
@@ -770,6 +917,11 @@ fn backspace_inner(s: &mut Session, _: &Value) -> CmdResult {
p.touch();
return sel_result(s);
}
if tab_indent_on(s) && (para.props.indent_left.is_some_and(|x| x > 0.0) || para.props.indent_left_chars.is_some_and(|x| x > 0.0)) {
// Set left- and first-indent with tabs and backspaces: the paragraph's own left indent
// (in points or characters) goes back one tab stop at a time before the paragraphs join.
return super::para::outdent(s, &Value::Null);
}
let i = f.path.last();
if i == 0 {
return sel_result(s); // start of a story or cell
+36 -8
View File
@@ -103,6 +103,14 @@ pub struct AutoCorrectPrefs {
pub numbering: bool,
/// Border lines.
pub border_lines: bool,
/// Set left- and first-indent with tabs and backspaces: Tab at the start of a line of a
/// paragraph with text indents it instead of typing a tab, and Backspace at its start takes
/// the indent back. Off by default.
pub tab_indent: bool,
/// Built-in Heading styles: a second Enter after a line of five or fewer words that ends in a
/// letter or digit makes it Heading 1 (Heading 2 or 3 after one or two leading tabs).
/// Off by default.
pub heading_styles: bool,
/// The user's entries: added, or replacing a built-in one.
pub entries: Vec<(String, String)>,
/// Built-in entries the user deleted.
@@ -138,6 +146,8 @@ impl Default for AutoCorrectPrefs {
bullets: true,
numbering: true,
border_lines: true,
tab_indent: false,
heading_styles: false,
entries: Vec::new(),
removed: Vec::new(),
exceptions: Vec::new(),
@@ -327,6 +337,8 @@ fn autocorrect_options(s: &mut Session, v: &Value) -> CmdResult {
("bullets", &mut ac.bullets),
("numbering", &mut ac.numbering),
("borderLines", &mut ac.border_lines),
("tabIndent", &mut ac.tab_indent),
("headingStyles", &mut ac.heading_styles),
("autoAddFirstLetter", &mut ac.auto_add_first_letter),
("autoAddInitialCaps", &mut ac.auto_add_initial_caps),
] {
@@ -400,6 +412,8 @@ fn autocorrect_options(s: &mut Session, v: &Value) -> CmdResult {
"bullets": ac.bullets,
"numbering": ac.numbering,
"borderLines": ac.border_lines,
"tabIndent": ac.tab_indent,
"headingStyles": ac.heading_styles,
"autoAddFirstLetter": ac.auto_add_first_letter,
"autoAddInitialCaps": ac.auto_add_initial_caps,
"entries": ac.list().into_iter().map(|(a, b, builtin)| json!({"from": a, "to": b, "builtIn": builtin})).collect::<Vec<_>>(),
@@ -437,7 +451,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, "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, "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)
@@ -466,18 +480,30 @@ pub fn specs() -> Vec<CommandSpec> {
})
.params(r#"{"mode": "none|readOnly|comments|trackedChanges"} (starting enforcement; "none" doesn't stop a protected document — use review.restrict {"stop": true})"#),
CommandSpec::new("file.versions", "Version History", "File › Info", |s, v| {
// Validate a restore before anything is saved: a rejected command must leave the
// version list as it was. A `restore` that is there but not a non-negative integer
// is refused, not read as absent.
let restore = if v.get("restore").is_some_and(|x| !x.is_null()) {
let i = p::u64(v, "restore").ok_or_else(|| CmdError::Params("restore (non-negative integer) is required".into()))?;
// A `u64` that doesn't fit in `usize` names no version: `as usize` would wrap it
// to a small index on a 32-bit target and restore the wrong document.
let i = usize::try_from(i).map_err(|_| CmdError::Params("no such version".into()))?;
let version = s.versions.get(i).cloned().ok_or_else(|| CmdError::Params("no such version".into()))?;
// The restore replaces the whole document, including the content the protection
// kept read-only, so the protection is stopped first (with its password, when it
// has one) — a restore must not go around it.
super::protect::check_version_restore(s)?;
Some(version)
} else {
None
};
if let Some(label) = p::str(v, "save") {
s.versions.push((label.to_string(), super::now_iso(), s.doc.clone()));
if s.versions.len() > 50 {
s.versions.remove(0);
}
}
if let Some(i) = p::u64(v, "restore") {
let (_, _, d) = s.versions.get(i as usize).cloned().ok_or_else(|| CmdError::Params("no such version".into()))?;
// The restore replaces the whole document, including the content the protection
// kept read-only, so the protection is stopped first (with its password, when it
// has one) — a restore must not go around it.
super::protect::check_version_restore(s)?;
if let Some((_, _, d)) = restore {
s.checkpoint("Restore Version");
// A snapshot's own protection is stale: it was taken at some earlier moment, and
// restoring it must not bring a protection back that the live document doesn't
@@ -875,7 +901,9 @@ pub fn autocorrect_word(s: &mut Session, on_enter: bool) -> Result<(), CmdError>
}
let trigger = if on_enter { 0 } else { last.len_utf8() };
let Some(body) = before.get(..before.len() - trigger) else { return Ok(()) };
let start = body.rfind(char::is_whitespace).map(|i| i + 1).unwrap_or(0);
// 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 Some(mut word) = body.get(start..).map(str::to_string) else { return Ok(()) };
if word.is_empty() {
return Ok(());
+29
View File
@@ -37,6 +37,9 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("view.readLineSpacing", "Reading Line Spacing", "Reading › Layout", read_line_spacing)
.params(r#"{"value": "document" | 1.0 | 1.25 | 1.5 | 2.0}"#)
.pure(),
CommandSpec::new("view.readPageColor", "Reading Page Color", "Reading › Layout", read_page_color)
.params(r#"{"value": "none" | "sepia" | "inverse"}"#)
.pure(),
// Print Layout: hide the white space (margins, headers, footers) between pages. Screen only.
CommandSpec::new("view.hideWhiteSpace", "Hide White Space", "View › Show", |s, v| toggle(s, v, |x| &mut x.hide_white_space))
.params(r#"{"value"?: bool}"#)
@@ -171,6 +174,7 @@ fn mode(s: &mut Session, m: ViewMode, read: bool) -> CmdResult {
s.view.read_column_width = None;
s.view.read_zoom = 1.0;
s.view.read_line_spacing = None;
s.view.read_page_color = ReadPageColor::None;
}
s.view.mode = m;
s.view.read_mode = read;
@@ -213,6 +217,31 @@ fn read_line_spacing(s: &mut Session, v: &Value) -> CmdResult {
Ok(json!({"spacing": spacing}))
}
/// Read Mode › View › Page Color, as in Word. Screen only: the document and its own page color
/// are unchanged.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ReadPageColor {
/// White paper; the document's page color and page picture are not shown.
#[default]
None,
/// Sepia paper (Word's RGB 112, 66, 20 at 80% transparency over white); the text keeps its colors.
Sepia,
/// A dark page with light text, as View › Switch Modes draws it.
Inverse,
}
fn read_page_color(s: &mut Session, v: &Value) -> CmdResult {
let color = match v.get("value").and_then(Value::as_str) {
Some("none") => ReadPageColor::None,
Some("sepia") => ReadPageColor::Sepia,
Some("inverse") => ReadPageColor::Inverse,
_ => return Err(CmdError::Params("reading page color must be none, sepia, or inverse".into())),
};
s.view.read_page_color = color;
Ok(json!({"pageColor": color}))
}
/// The zoom a document is saved with (#995): the fit mode, else the percentage; `None` at 100%.
pub(crate) fn saved_zoom(v: &crate::ViewState) -> Option<Zoom> {
let fit = match v.fit.as_str() {
+2
View File
@@ -202,6 +202,8 @@ mod tests_bidi;
#[cfg(test)]
mod tests_fonts;
#[cfg(test)]
mod tests_ime_noop;
#[cfg(test)]
mod tests_revision_checks;
#[cfg(test)]
mod tests_typing;
+6 -2
View File
@@ -39,7 +39,11 @@ pub fn parse_page_range(spec: &str, total: usize) -> Result<Vec<usize>, String>
continue;
}
let page = |t: &str| -> Result<usize, String> {
let n: usize = t.parse().map_err(|_| format!("`{item}` isn't a page or a range like 2-4"))?;
let not_a_page = || format!("`{item}` isn't a page or a range like 2-4");
if !t.bytes().all(|b| b.is_ascii_digit()) {
return Err(not_a_page());
}
let n: usize = t.parse().map_err(|_| not_a_page())?;
if n == 0 || n > total {
return Err(format!("page {n} doesn't exist; the document has {total} page{}", if total == 1 { "" } else { "s" }));
}
@@ -194,7 +198,7 @@ mod tests {
assert_eq!(parse_page_range("5,1-2,2", 6), Ok(vec![0, 1, 4]), "document order, no repeats");
assert_eq!(parse_page_range("4-", 6), Ok(vec![3, 4, 5]));
assert_eq!(parse_page_range("-2", 6), Ok(vec![0, 1]));
for bad in ["", " , ", "0", "7", "3-1", "a", "1-2-3", "99999999999999999999999", "-", "1..3", &"1,".repeat(400)] {
for bad in ["", " , ", "0", "7", "3-1", "a", "1-2-3", "99999999999999999999999", "-", "1..3", "+3", "1-+3", "+2-+4", &"1,".repeat(400)] {
assert!(parse_page_range(bad, 6).is_err(), "`{bad}` is refused");
}
assert!(parse_page_range("1", 0).is_err());
+166 -16
View File
@@ -178,6 +178,9 @@ pub struct ViewState {
pub read_zoom: f32,
#[serde(skip)]
pub read_line_spacing: Option<f32>,
/// Read Mode's Page Color: the paper the reading columns are drawn on.
#[serde(skip)]
pub read_page_color: crate::cmd::view::ReadPageColor,
pub dark_mode: bool,
pub show_markup: bool,
pub track_changes_pane: bool,
@@ -265,6 +268,7 @@ impl Default for ViewState {
read_column_width: None,
read_zoom: default_read_zoom(),
read_line_spacing: None,
read_page_color: Default::default(),
dark_mode: false,
show_markup: true,
track_changes_pane: false,
@@ -324,8 +328,9 @@ struct Undo {
/// What [`Session::restore`] needs to take commands back exactly: the document, the selection,
/// the undo and redo stacks (also the oldest undo steps the undo limit pushes out meanwhile), the
/// open typing group, the pending
/// [`Session::join_next_undo`]/[`Session::join_next_equation_edit`]/body IME replacement promises
/// and the dirty flag. See [`Session::edit_snapshot`].
/// [`Session::join_next_undo`]/[`Session::join_next_equation_edit`]/body IME replacement promises,
/// whether the group has a step of its own, and the dirty flag. See
/// [`Session::edit_snapshot`].
pub struct EditSnapshot {
doc: Document,
sel: Selection,
@@ -341,6 +346,7 @@ pub struct EditSnapshot {
equation_join_next: bool,
/// The body `DeleteSurrounding` replacement promise then, with the exact caret it expects.
ime_text_join: Option<Selection>,
join_anchor: bool,
dirty: bool,
/// The body's replacement epoch then: a previewed replacement that is rolled back is not a
/// replacement after all.
@@ -373,6 +379,8 @@ pub struct Session {
pub view: ViewState,
/// Formatting picked with a collapsed caret, applied to the next typed text.
pub pending: Option<CharProps>,
/// Last highlighter choice for the shortcut; palette state, outside document undo.
pub(crate) last_highlight: wordcraft_doc::props::Highlight,
pub path: Option<std::path::PathBuf>,
/// Name given to a document not saved yet (File › Info › Rename): what Save suggests. A file
/// name, not document metadata, so it is not part of the document or its undo history.
@@ -431,6 +439,10 @@ pub struct Session {
/// `DeleteSurrounding`'s undo step. Any other command consumes and discards it, so an
/// intervening edit owns its own checkpoint instead of being merged into the deletion.
ime_text_join: Option<Selection>,
/// Whether the undo group has a step of its own for the next joined command to merge
/// into. A command that changed nothing leaves the group without one, so the next
/// command in the group starts the step the group needs.
join_anchor: bool,
/// The newest automatic capitalisation, for Word's "Automatically add words to list" (#1857).
pub(crate) autocap: Option<crate::cmd::tools::AutoCap>,
rev: u64,
@@ -742,10 +754,13 @@ impl Session {
}
}
/// Start an editing session at the document beginning with empty history and
/// default view, preferences and a yellow highlighter independent of document undo.
pub fn new(doc: Document) -> Self {
Self::with_registry(doc, Arc::new(crate::cmd::registry()))
}
/// Initialise session defaults while sharing the immutable command registry with previews.
fn with_registry(doc: Document, registry: Arc<Registry>) -> Self {
let start = doc.start_of(StoryRef::Body);
let bib_style = doc_bib_style(&doc);
@@ -754,6 +769,7 @@ impl Session {
sel: Selection::caret(start),
view: ViewState::default(),
pending: None,
last_highlight: wordcraft_doc::props::Highlight::Yellow,
path: None,
file_name: None,
user_dict_path: None,
@@ -780,6 +796,7 @@ impl Session {
join_next: false,
equation_join_next: false,
ime_text_join: None,
join_anchor: false,
autocap: None,
rev: 1,
protect_facts: std::sync::Mutex::new(None),
@@ -1098,6 +1115,7 @@ impl Session {
copy.path = self.path.clone();
copy.file_name = self.file_name.clone();
copy.author = self.author.clone();
copy.last_highlight = self.last_highlight;
copy.system_region = self.system_region.clone();
copy.editing_lang = self.editing_lang.clone();
// The copy shares the paragraph layouts, so only what the command changed is laid out.
@@ -1163,10 +1181,11 @@ impl Session {
}
}
/// Snapshot for undo before a change.
pub fn checkpoint(&mut self, label: &str) {
/// Snapshot for undo before a change. Returns whether it took a step: typing that is
/// already in an open step is not a new one.
pub fn checkpoint(&mut self, label: &str) -> bool {
if label == "Typing" && self.typing_open {
return;
return false;
}
self.typing_open = label == "Typing";
self.history.push(Arc::new(Undo {
@@ -1181,6 +1200,9 @@ impl Session {
self.undo_evicted += 1;
}
self.redo.clear();
// The newest step is what a joined command merges into.
self.join_anchor = true;
true
}
/// Record `doc`/`sel` as their own undo step after the fact and close the typing group, so
/// Undo right after an automatic change (AutoFormat, AutoCorrect) reverts only that change.
@@ -1191,6 +1213,7 @@ impl Session {
self.undo_evicted += 1;
}
self.typing_open = false;
self.join_anchor = true;
}
/// Make the next mutating command part of the previous undo step instead of a new one, so a
/// drag that runs a command every frame is a single Undo. Call it on every frame of the drag
@@ -1372,6 +1395,7 @@ impl Session {
self.history.clear();
self.redo.clear();
self.typing_open = false;
self.join_anchor = false;
}
/// The version snapshots belong to the document they were taken from: keeping the old ones
@@ -1400,6 +1424,18 @@ impl Session {
.and_then(|t| wordcraft_proof::lang::normalize_tag(&t))
}
/// The Spanish of the region in effect (`es-MX` on a Mexican system, `es-UY` on a Uruguayan
/// one; the ISO 639-2 `spa` counts as Spanish), or Spain's (`es-ES`) when the region's
/// language isn't Spanish or it names no territory. A Spanish interface writes new documents in it, so they are proofed in the
/// writer's Spanish from the first word (#3360).
pub fn region_spanish(&self) -> String {
let r = self.region();
(matches!(r.language.as_str(), "es" | "spa") && !r.territory.is_empty())
.then(|| format!("es-{}", r.territory))
.and_then(|t| wordcraft_proof::lang::normalize_tag(&t))
.unwrap_or_else(|| "es-ES".into())
}
/// The unit rulers and dialogs show lengths in: the user's choice, else the region's.
pub fn units(&self) -> wordcraft_geom::Unit {
self.prefs.units.unwrap_or_else(|| self.region().units())
@@ -1452,8 +1488,9 @@ impl Session {
/// may want to take it back without a trace (cheap: the document's blocks are shared).
///
/// Covered: `doc`, `sel`, the undo stack (its length, and its oldest steps when it is near
/// the limit), `redo`, the open typing group, the pending `join_next_undo` and
/// `join_next_equation_edit` promises and `dirty`. `rev`,
/// the limit), `redo`, the open typing group, the pending `join_next_undo`,
/// `join_next_equation_edit` and body replacement promises, whether the typing group has a
/// step of its own, and `dirty`. `rev`,
/// `cache` and `layout` are derived (`restore` bumps `rev`). The other fields are not
/// covered: a caller that lets the command change them (the view, the find state, the
/// clipboard, macros, the file path…) saves and puts them back itself.
@@ -1471,6 +1508,7 @@ impl Session {
sel,
view: _,
pending: _,
last_highlight: _,
path: _,
file_name: _,
// The custom dictionary file, like the file path: host wiring, not an edit.
@@ -1500,6 +1538,7 @@ impl Session {
join_next,
equation_join_next,
ime_text_join,
join_anchor,
autocap: _,
rev: _,
protect_facts: _,
@@ -1566,6 +1605,7 @@ impl Session {
join_next: *join_next,
equation_join_next: *equation_join_next,
ime_text_join: ime_text_join.clone(),
join_anchor: *join_anchor,
dirty: *dirty,
ime_body_epoch: *ime_body_epoch,
}
@@ -1586,6 +1626,7 @@ impl Session {
join_next,
equation_join_next,
ime_text_join,
join_anchor,
dirty,
ime_body_epoch,
} = snap;
@@ -1606,6 +1647,7 @@ impl Session {
self.equation_join_next = equation_join_next;
self.ime_text_join = ime_text_join;
self.ime_body_epoch = ime_body_epoch;
self.join_anchor = join_anchor;
self.touch();
self.dirty = dirty;
}
@@ -1638,6 +1680,26 @@ impl Session {
(spec.enabled)(self).or_else(|| if spec.mutates { crate::cmd::protect::greyed_out(self, id, || self.protect_facts()) } else { None })
}
/// Put the revision back to `rev` after a command that changed nothing or failed. What the
/// command cached at the revision it touched to is dropped: that number comes round again
/// with the next edit, for a different document.
fn restore_rev(&mut self, rev: u64) {
if self.rev == rev {
return;
}
self.rev = rev;
if self.layout.as_ref().is_some_and(|l| l.0 != rev) {
self.layout = None;
}
if self.find.highlights.as_ref().is_some_and(|(r, _)| *r != rev) {
self.find.highlights = None;
}
let facts = self.protect_facts.get_mut().unwrap_or_else(std::sync::PoisonError::into_inner);
if facts.as_ref().is_some_and(|((_, r, _), _)| *r != rev) {
*facts = None;
}
}
/// [`crate::cmd::protect::Facts`] for the document as it is now, cached per revision.
fn protect_facts(&self) -> crate::cmd::protect::Facts {
let key = (self.document_id, self.rev, self.author.clone());
@@ -1729,6 +1791,15 @@ impl Session {
result
}
/// Whether the command changed nothing: the document, the selection, the formatting
/// waiting at the caret and the column selection are as they were before it ran.
///
/// A command that changes nothing is not an edit: it adds no undo step, marks no
/// unsaved changes, forces no relayout and leaves the redo stack alone (#23).
fn unchanged(&self, doc: &Document, sel: &Selection, column: &Option<ColumnBlock>, pending: &Option<CharProps>) -> bool {
doc.same_content(&self.doc) && *sel == self.sel && *column == self.column && *pending == self.pending
}
/// Run a command by id with JSON params (the single entry point for the UI, CLI, MCP and
/// control channel). Mutating commands are undoable; a failed command leaves the document as
/// it was; a panic inside a command becomes an error.
@@ -1805,10 +1876,25 @@ impl Session {
} else {
None
};
// The formatting waiting at the caret (⌘B with nothing selected): an undo step restores
// it, so a command that only changes it is an edit.
let pending_before = spec.mutates.then(|| self.pending.clone());
// Which document is open. A command that replaces the document (a mail merge) opens a
// new one, with a new undo history: it is not an edit of this document, whatever the
// new document holds.
let document_id_before = self.document_id;
// Whether the document has unsaved changes, and the revision its layout is keyed on: a
// command that changes nothing leaves both as they were, and a failed one leaves no
// trace.
let dirty_before = spec.mutates.then_some(self.dirty);
let rev_restore = spec.mutates.then_some(self.rev);
// These mutating commands can replace the open document and discard its saved versions.
// Keep the versions until the command and the protected-region post-check have both
// succeeded; rejected commands must leave the old document and its snapshots together.
let replaces_document = matches!(id, "review.compare" | "review.combine" | "mailings.finish" | "mailings.envelopes" | "mailings.labels");
// `mailings.finish` with a path saves the merged copy there and leaves the open
// document alone, so it replaces nothing.
let replaces_document = matches!(id, "review.compare" | "review.combine" | "mailings.envelopes" | "mailings.labels")
|| (id == "mailings.finish" && crate::p::str(params, "path").is_none());
let versions_before = replaces_document.then(|| self.versions.clone());
let identity_before = replaces_document.then(|| {
(
@@ -1825,10 +1911,15 @@ impl Session {
if spec.mutates && !typing {
self.typing_open = false;
}
// Whether this command pushed an undo step (a no-op command's step is dropped below,
// leaving its undo group without one).
let mut pushed = false;
if spec.mutates {
let label = if typing { "Typing" } else { spec.label };
if !join || self.history.is_empty() {
self.checkpoint(label);
// A joined command merges into its group's step; a group whose first command
// changed nothing has none, so the next command in it starts one (#4001).
if !join || self.history.is_empty() || !self.join_anchor {
pushed = self.checkpoint(label);
}
} else if !spec.id.starts_with("caret.") && !spec.id.starts_with("view.") {
// Non-mutating commands other than caret movement keep the typing group.
@@ -1866,18 +1957,64 @@ impl Session {
}
match &result {
Ok(_) => {
let mut no_op = false;
if spec.mutates {
// Formatting changed while tracking becomes a formatting revision.
if let Some((before, ..)) = &before_doc {
crate::cmd::fmt_revisions::record(self, id, before);
}
self.touch();
self.doc.ensure_nonempty();
self.doc.prune_text_boxes();
// Glued connectors follow the shapes they join.
if crate::cmd::connectors::reroutes_after(id) && crate::cmd::connectors::reroute(self) {
// Whether the command left the document, the selection and the formatting
// waiting at the caret as they were. A command that replaces the document —
// a mail merge, a comparison — is not an edit of this one, whatever the new
// document holds: `replaces_document` names the commands that may replace
// it, and `document_id` catches any other that does (through
// `set_document`, which starts a new history the old one must not overwrite).
let unchanged = |s: &Session| match (&before_doc, &pending_before) {
(Some((doc, sel, _, _, _, _, column)), Some(pending)) => {
!replaces_document && s.document_id == document_id_before && s.unchanged(doc, sel, column, pending)
}
_ => false,
};
let changed = !unchanged(self);
if changed {
// The layout the connectors are rerouted against is the new one.
self.touch();
}
// Glued connectors follow the shapes they join.
let rerouted = crate::cmd::connectors::reroutes_after(id) && crate::cmd::connectors::reroute(self);
if rerouted {
self.touch();
}
// A command that changed nothing is not an edit (#23): it adds no undo
// step, marks no unsaved changes, forces no relayout and leaves the redo
// stack alone. What it left behind is compared with what it started from,
// after the reroute: a reroute that moved a connector is an edit of its own.
no_op = !changed && !rerouted && unchanged(self);
if no_op {
// The step the dispatch pushed is not an edit: put the stacks back as
// they were, so the redo stack survives and no step is evicted. The
// group it belonged to is left without a step of its own, so the next
// command in it starts one.
if let Some((_, _, history, redo, typing_open, evicted, _)) = &before_doc {
self.history = history.clone();
self.redo = redo.clone();
self.typing_open = *typing_open;
self.undo_evicted = *evicted;
}
if let Some(dirty) = dirty_before {
self.dirty = dirty;
}
if let Some(rev) = rev_restore {
let touched = self.rev;
self.restore_rev(rev);
crate::cmd::draw::rekey_lasso(self, touched, rev);
}
if pushed {
self.join_anchor = false;
}
}
}
self.clamp_selection();
let mut ime_outcome = self.ime_edit_outcome;
@@ -1886,7 +2023,10 @@ impl Session {
}
// An unclassified mutating command is conservatively a state change. It can make
// a frame Other, but it can never mint a receipt because `classified` is false.
if !ime_outcome.classified && mutates {
// A command that changed nothing is not an edit (#23), so it is not an IME
// state change either: otherwise Backspace at the document start would still
// signal an application change and move the settled IME revision.
if !ime_outcome.classified && mutates && !no_op {
ime_outcome.state_changed = true;
}
ime_outcome.selection_changed = self.current_ime_caret_state() != ime_caret_before;
@@ -1896,7 +2036,7 @@ impl Session {
self.ime_state_seen = Some(caret);
self.reset_ime_state_revision();
}
if !self.ime_origin && (ime_outcome.content_changed || ime_outcome.state_changed || (!ime_outcome.classified && mutates)) {
if !self.ime_origin && !no_op && (ime_outcome.content_changed || ime_outcome.state_changed || (!ime_outcome.classified && mutates)) {
self.pending_ime_application_change = true;
}
if sel_before.focus.story == StoryRef::Body && self.in_header_footer() {
@@ -1907,7 +2047,7 @@ impl Session {
if !spec.mutates && self.sel != sel_before {
self.typing_open = false;
}
if spec.mutates && !matches!(id, "edit.undo" | "edit.redo") {
if spec.mutates && !no_op && !matches!(id, "edit.undo" | "edit.redo") {
self.note_edit();
}
// Outline view: collapsed headings follow edits; the caret stays where it shows.
@@ -1952,6 +2092,16 @@ impl Session {
// The steps the command pushed out are back, so they no longer count as gone.
self.undo_evicted = ev;
self.column = c;
// Its step is gone with them: its undo group has none to merge into.
if pushed {
self.join_anchor = false;
}
}
if let Some(dirty) = dirty_before {
self.dirty = dirty;
}
if let Some(rev) = rev_restore {
self.restore_rev(rev);
}
if let Some(b) = blocks_before {
self.building_blocks = b;
+573 -4
View File
@@ -275,6 +275,136 @@ fn typing_enter_undo() {
assert_eq!(text(&s), "Hello world");
}
/// A command that changes nothing is not an edit (#23): no undo step, no unsaved changes
/// and no relayout.
#[test]
fn a_command_that_changes_nothing_is_not_an_edit() {
let mut s = s();
let rev = s.rev();
// Backspace at the start of the document: there is nothing to delete.
run(&mut s, "text.backspace", json!({}));
assert_eq!(s.undo_depth(), 0, "no undo step");
assert!(!s.dirty, "no unsaved changes");
assert_eq!(s.rev(), rev, "no relayout");
// Typing nothing is the same.
run(&mut s, "text.insert", json!({"text": ""}));
assert_eq!(s.undo_depth(), 0, "no undo step");
assert!(!s.dirty, "no unsaved changes");
// A command that changes something is an edit.
run(&mut s, "text.insert", json!({"text": "Hello"}));
assert_eq!(s.undo_depth(), 1, "one undo step");
assert!(s.dirty, "the document is edited");
// Setting the alignment a paragraph already has changes nothing.
let depth = s.undo_depth();
run(&mut s, "para.align", json!({"value": "center"}));
assert_eq!(s.undo_depth(), depth + 1, "one undo step");
let depth = s.undo_depth();
run(&mut s, "para.align", json!({"value": "center"}));
assert_eq!(s.undo_depth(), depth, "no undo step");
// A command that changes nothing leaves the unsaved-changes flag and the layout revision as
// they were, even when it calls `touch` itself: a saved document is not marked edited for
// nothing, and its layout is not laid out again.
let mut clean = Session::new(wordcraft_doc::Document::new());
run(&mut clean, "para.align", json!({"value": "left"}));
clean.dirty = false;
let rev = clean.rev();
run(&mut clean, "para.align", json!({"value": "left"}));
assert!(!clean.dirty, "no unsaved changes");
assert_eq!(clean.rev(), rev, "no relayout");
assert_eq!(clean.undo_depth(), 1, "only the first align is a step");
}
/// Five hundred commands that change nothing used to fill the whole undo stack and push the
/// user's own edits out of it, so they could never be undone again (#23).
#[test]
fn no_op_commands_do_not_evict_the_undo_history() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Hello"}));
run(&mut s, "caret.docStart", json!({}));
for _ in 0..600 {
run(&mut s, "text.backspace", json!({}));
}
assert_eq!(s.undo_depth(), 1, "the typing step is still there");
run(&mut s, "edit.undo", json!({}));
assert_eq!(text(&s), "", "the typed text is still undoable");
}
/// A command that changes nothing does not throw the redo stack away (#23).
#[test]
fn a_no_op_command_keeps_the_redo_stack() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Hello"}));
run(&mut s, "edit.undo", json!({}));
assert!(s.can_redo());
run(&mut s, "text.backspace", json!({}));
assert!(s.can_redo(), "the redo stack survives a command that changed nothing");
run(&mut s, "edit.redo", json!({}));
assert_eq!(text(&s), "Hello");
}
/// Undo and Redo move between steps even when a step's document is the one showing: they are
/// never taken back as commands that changed nothing, so Undo can't get stuck on such a step.
#[test]
fn undo_moves_past_a_step_that_changed_nothing() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Hello"}));
s.checkpoint("Nothing");
let depth = s.undo_depth();
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.undo_depth(), depth - 1);
assert!(s.can_redo());
run(&mut s, "edit.redo", json!({}));
assert_eq!(s.undo_depth(), depth);
run(&mut s, "edit.undo", json!({}));
run(&mut s, "edit.undo", json!({}));
assert_eq!(text(&s), "");
}
/// Commands that run as one undo step (`join_next_undo`: a dialog's OK, the frames of a drag):
/// a group whose first command changes nothing has no step to merge into, so the next command
/// in it starts the group's step instead of merging into an older edit (#23 follow-up).
#[test]
fn a_joined_command_starts_its_group_when_the_first_changed_nothing() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Hello"}));
let (depth, margins) = (s.undo_depth(), (s.doc.last_section.margin_left, s.doc.last_section.margin_top));
// Portrait on a portrait page changes nothing.
run(&mut s, "layout.orientation", json!({"value": "portrait"}));
assert_eq!(s.undo_depth(), depth, "the first command of the group is no edit");
// The next command of the group starts the step the group needs.
s.join_next_undo();
run(&mut s, "layout.margins", json!({"left": 100.0}));
assert_eq!(s.doc.last_section.margin_left, 100.0);
assert_eq!(s.undo_depth(), depth + 1, "the group's one step");
// A command that changes nothing in the middle of the group leaves that step alone, and
// the next one merges into it.
s.join_next_undo();
run(&mut s, "layout.orientation", json!({"value": "portrait"}));
s.join_next_undo();
run(&mut s, "layout.margins", json!({"top": 60.0}));
assert_eq!(s.undo_depth(), depth + 1, "still the group's one step");
// Undo takes the whole group back and no more: the typing is still there.
run(&mut s, "edit.undo", json!({}));
assert_eq!((s.doc.last_section.margin_left, s.doc.last_section.margin_top), margins, "the margins the group started from");
assert_eq!(text(&s), "Hello");
}
/// Bold with nothing selected arms the formatting for the next word: the document is the
/// same, so only the caret's formatting changed — and that is an edit (#23).
#[test]
fn a_command_that_only_arms_the_caret_formatting_is_an_edit() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Hello"}));
run(&mut s, "caret.docStart", json!({}));
let depth = s.undo_depth();
run(&mut s, "format.bold", json!({}));
assert_eq!(s.undo_depth(), depth + 1, "one undo step");
// Undo takes the armed formatting back, as Word does (docs/typing-parity.md).
run(&mut s, "edit.undo", json!({}));
run(&mut s, "text.insert", json!({"text": "x"}));
assert_eq!(s.doc.para_at(&s.sel.focus).map(|p| p.props_of_char(0).bold), Some(None));
}
/// The Undo button's list (#701): several steps undone at once, newest first.
#[test]
fn undo_to_takes_several_steps_back_at_once() {
@@ -514,7 +644,10 @@ fn failed_command_restores_history_changed_by_nested_commands() {
// Comments are stamped with the time to the second, and the two sessions compared
// are built a moment apart, so leave the stamp out of the comparison.
let mut doc = s.doc.clone();
doc.comments.values_mut().for_each(|c| c.date.clear());
doc.comments.values_mut().for_each(|c| {
c.date.clear();
c.date_utc = None;
});
out.push((s.undo_labels(), doc));
if !s.can_undo() {
return out;
@@ -1674,6 +1807,70 @@ fn tables_commands() {
assert!(s.run("table.merge", &json!({})).is_err());
}
#[test]
fn inserting_a_column_keeps_cell_widths_in_step_with_the_grid() {
let mut s = s();
run(&mut s, "insert.table", json!({"rows": 2, "cols": 2}));
let widths = |s: &Session| {
let t = s.doc.body.iter().find_map(|b| b.as_table()).unwrap();
(t.grid.clone(), t.rows[0].cells.iter().map(|c| c.props.width.unwrap_or(0.0)).collect::<Vec<_>>())
};
let (grid_before, _) = widths(&s);
assert!(grid_before.iter().all(|w| *w > 0.0));
run(&mut s, "table.insertColumnRight", json!({}));
let (grid_after, cells) = widths(&s);
// The table keeps its total width, and each cell carries its grid columns' width: the
// layout and the saved `w:tcW` see the same shrink the grid got.
let sum = |v: &[f32]| v.iter().sum::<f32>();
assert!((sum(&grid_after) - sum(&grid_before)).abs() < 0.5, "{grid_after:?} vs {grid_before:?}");
assert_eq!(cells.len(), grid_after.len());
for (g, w) in grid_after.iter().zip(cells.iter()) {
assert!((w - g).abs() < 0.01, "cell width {w} vs grid {g}");
}
}
/// A row that skips grid columns (`w:gridBefore`) gives its cells the widths of the columns
/// they sit in, not of the first ones.
#[test]
fn cell_widths_follow_the_grid_after_skipped_columns() {
let mut s = s();
run(&mut s, "insert.table", json!({"rows": 2, "cols": 3}));
let i = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap();
let wordcraft_doc::Block::Table(t) = std::sync::Arc::make_mut(&mut s.doc.body[i]) else { panic!("no table") };
t.grid = vec![100.0, 200.0, 300.0];
t.rows[1].props.grid_before = 1;
t.rows[1].cells.pop();
crate::cmd::table::sync_cell_widths(t);
let widths: Vec<f32> = t.rows[1].cells.iter().map(|c| c.props.width.unwrap_or(0.0)).collect();
assert_eq!(widths, [200.0, 300.0]);
}
#[test]
fn a_rejected_version_restore_saves_nothing() {
let mut s = Session::new(wordcraft_doc::Document::from_text("Original text."));
s.run("review.restrict", &json!({"mode": "readOnly", "password": "pw"})).unwrap();
s.run("file.versions", &json!({"save": "before"})).unwrap();
// One command asking for both a save and a restore the protection refuses: the save must
// not happen either.
let e = s.run("file.versions", &json!({"save": "mid", "restore": 0})).unwrap_err().to_string();
assert!(e.contains("stop protection"), "{e}");
assert_eq!(s.versions.len(), 1, "the refused command saved a version anyway");
// A bad index is refused before anything is saved, too.
let e = s.run("file.versions", &json!({"save": "mid", "restore": 99})).unwrap_err().to_string();
assert!(e.contains("no such version"), "{e}");
assert_eq!(s.versions.len(), 1, "the refused command saved a version anyway");
// A restore that is there but not a non-negative integer is refused, not read as absent:
// {"save": "mid", "restore": -1} must not save quietly without restoring.
let e = s.run("file.versions", &json!({"save": "mid", "restore": -1})).unwrap_err().to_string();
assert!(e.contains("restore (non-negative integer)"), "{e}");
assert_eq!(s.versions.len(), 1, "the refused command saved a version anyway");
// An index too large for `usize` names no version: on a 32-bit target `as usize` would wrap
// it to 0 and restore the first version instead.
let e = s.run("file.versions", &json!({"save": "mid", "restore": 4294967296u64})).unwrap_err().to_string();
assert!(e.contains("no such version"), "{e}");
assert_eq!(s.versions.len(), 1, "the refused command saved a version anyway");
}
/// #3931: the page as a command would leave it, laid out without running it on the document:
/// Insert › Table's grid previews the table it would insert. The document, the caret, the undo
/// history and Repeat stay as they were.
@@ -2873,6 +3070,40 @@ fn toc_and_fields() {
assert_eq!(s.doc.plain_text(StoryRef::Body).matches("Summary\t").count(), 1, "one TOC entry after update");
}
/// A comment or reply made here carries its UTC time as `date_utc` too, so it shows in local time
/// and is saved as `dateUtc`.
#[test]
fn new_comments_and_replies_carry_their_utc_time() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "Some text"}));
run(&mut s, "select.all", json!({}));
let id = run(&mut s, "review.newComment", json!({"text": "one"}))["id"].as_u64().unwrap();
run(&mut s, "review.reply", json!({"id": id, "text": "two"}));
assert_eq!(s.doc.comments.len(), 2);
for c in s.doc.comments.values() {
assert!(c.date.ends_with('Z'), "{}", c.date);
assert_eq!(c.date_utc.as_deref(), Some(c.date.as_str()));
}
}
/// Update Table with nothing to change lays the document out at the revisions it touches, then
/// puts the revision back. The next edits reach those numbers again with a different document, so
/// the layout must be the new one.
#[test]
fn a_command_that_changes_nothing_leaves_no_stale_layout() {
let mut s = Session::new(crate::sample::report());
run(&mut s, "caret.docStart", json!({}));
run(&mut s, "references.toc", json!({}));
let (rev, steps) = (s.rev(), s.undo_depth());
let pages = s.layout().pages.len();
run(&mut s, "references.updateToc", json!({}));
assert_eq!((s.rev(), s.undo_depth()), (rev, steps), "Update Table changed nothing");
run(&mut s, "caret.docEnd", json!({}));
run(&mut s, "text.insert", json!({"text": "A"}));
run(&mut s, "text.insert", json!({"text": "word ".repeat(4000)}));
assert!(s.layout().pages.len() > pages, "the layout has the inserted text");
}
#[test]
fn toc_refresh_after_save_and_reopen() {
for extension in ["docx", "json"] {
@@ -3089,6 +3320,172 @@ fn word_toc_control_with_the_field_end_after_the_entries_survives_update_and_sav
}
}
/// A paragraph style `id` named `name`, based on Normal.
fn style_xml(id: &str, name: &str, ppr: &str, rpr: &str) -> String {
format!(
r#"<w:style w:type="paragraph" w:styleId="{id}"><w:name w:val="{name}"/><w:basedOn w:val="Normal"/><w:pPr>{ppr}</w:pPr><w:rPr>{rpr}</w:rPr></w:style>"#
)
}
/// A DOCX package with `body` and the paragraph `styles` after a default Normal style.
fn docx_with_styles(body: &str, styles: &str) -> Vec<u8> {
use std::io::Write;
const W: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main""#;
const RELS: &str = r#"xmlns="http://schemas.openxmlformats.org/package/2006/relationships""#;
const TYPE: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, value) in [
("word/document.xml", format!(r#"<w:document {W}><w:body>{body}</w:body></w:document>"#)),
(
"word/styles.xml",
format!(
r#"<w:styles {W}><w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/></w:style>{styles}</w:styles>"#
),
),
(
"word/_rels/document.xml.rels",
format!(r#"<Relationships {RELS}><Relationship Id="rId1" Type="{TYPE}/styles" Target="styles.xml"/></Relationships>"#),
),
(
"_rels/.rels",
format!(r#"<Relationships {RELS}><Relationship Id="main" Type="{TYPE}/officeDocument" Target="word/document.xml"/></Relationships>"#),
),
] {
zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
zip.write_all(value.as_bytes()).unwrap();
}
zip.finish().unwrap().into_inner()
}
/// #3891: Update Table on a TOC Word wrote in Portuguese keeps the formatting of its TOC styles,
/// stored under the ids `Sumrio1`/`Sumrio3` (`toc 1`/`toc 3`): bold Arial at level 1, italic and
/// indented at level 3.
#[test]
fn update_table_keeps_the_formatting_of_localized_toc_styles() {
let t = |s: &str| format!(r#"<w:r><w:t xml:space="preserve">{s}</w:t></w:r>"#);
let p = |st: &str, inner: &str| format!(r#"<w:p><w:pPr><w:pStyle w:val="{st}"/></w:pPr>{inner}</w:p>"#);
let begin = r#"<w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> TOC \o "1-3" \h \z \u </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r>"#;
let body = [
p("CabealhodoSumrio", &t("Contents")),
p("Sumrio1", &format!("{begin}{}", t("Chapter\t1"))),
p("Sumrio3", &t("Detail\t1")),
r#"<w:p><w:r><w:fldChar w:fldCharType="end"/></w:r></w:p>"#.to_string(),
p("Ttulo1", &t("Chapter")),
p("Ttulo3", &t("Detail")),
]
.concat();
let styles = [
style_xml("Ttulo1", "heading 1", r#"<w:outlineLvl w:val="0"/>"#, ""),
style_xml("Ttulo3", "heading 3", r#"<w:outlineLvl w:val="2"/>"#, ""),
style_xml("CabealhodoSumrio", "TOC Heading", "", ""),
style_xml("Sumrio1", "toc 1", "", r#"<w:rFonts w:ascii="Arial" w:hAnsi="Arial"/><w:b/>"#),
style_xml("Sumrio3", "toc 3", r#"<w:ind w:left="220"/>"#, "<w:i/>"),
]
.concat();
let mut s = Session::new(wordcraft_docx::read(&docx_with_styles(&body, &styles)).unwrap());
run(&mut s, "references.updateToc", json!({}));
let entries: Vec<(String, bool, bool, f32)> = s
.doc
.body
.iter()
.filter_map(|b| b.as_para())
.filter(|p| p.props.style.as_deref().is_some_and(|st| st.starts_with("TOC") && st != "TOCHeading"))
.map(|p| {
let c = s.doc.styles.resolve_char(p.props.style.as_deref(), p.props_of_char(0));
(c.font, c.bold, c.italic, s.doc.styles.resolve_para(&p.props).indent_left)
})
.collect();
let level3 = entries.get(1).map(|e| e.0.clone()).unwrap_or_default();
assert_eq!(entries, [("Arial".to_string(), true, false, 0.0), (level3, false, true, 11.0)], "{}", text(&s));
}
/// #3889: Word's TOC in two columns, written by Word in Portuguese. The TOC heading ends a
/// one-column section; the entries, styled `Sumrio1`/`Sumrio2` (`toc 1`/`toc 2`), fill a continuous
/// two-column one, and the field's end and the control's end share a paragraph after them. Update
/// Table keeps the entries' styles, puts the page numbers at the first column's right edge (3888
/// twips, as Word's own entries have them) and adds no blank line above the first column; update,
/// save and reopen keeps it so.
#[test]
fn toc_in_columns_with_localized_styles_keeps_its_layout_through_update_table() {
let t = |s: &str| format!(r#"<w:r><w:t xml:space="preserve">{s}</w:t></w:r>"#);
let p = |st: &str, inner: &str| format!(r#"<w:p><w:pPr><w:pStyle w:val="{st}"/></w:pPr>{inner}</w:p>"#);
let tab = r#"<w:tabs><w:tab w:val="right" w:leader="dot" w:pos="3888"/></w:tabs>"#;
let entry = |st: &str, inner: &str| format!(r#"<w:p><w:pPr><w:pStyle w:val="{st}"/>{tab}</w:pPr>{inner}</w:p>"#);
let sect = |cols: &str, kind: &str| {
format!(
r#"<w:sectPr>{kind}<w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1417" w:right="1701" w:bottom="1417" w:left="1701" w:header="708" w:footer="708" w:gutter="0"/><w:cols {cols} w:space="708"/></w:sectPr>"#
)
};
let continuous = r#"<w:type w:val="continuous"/>"#;
let begin = r#"<w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> TOC \o "1-2" \h \z \u </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r>"#;
let toc = [
p("CabealhodoSumrio", &t("Contents")),
format!("<w:p><w:pPr>{}</w:pPr></w:p>", sect("", "")),
entry("Sumrio1", &format!("{begin}{}", t("Chapter One\t1"))),
entry("Sumrio2", &t("Section\t1")),
r#"<w:p><w:r><w:fldChar w:fldCharType="end"/></w:r></w:p>"#.to_string(),
]
.concat();
let body = [
format!(
r#"<w:sdt><w:sdtPr><w:id w:val="-1360965913"/><w:docPartObj><w:docPartGallery w:val="Table of Contents"/><w:docPartUnique/></w:docPartObj></w:sdtPr><w:sdtContent>{toc}</w:sdtContent></w:sdt>"#
),
format!(r#"<w:p><w:pPr>{}</w:pPr></w:p>"#, sect(r#"w:num="2""#, continuous)),
p("Ttulo1", &t("Chapter One")),
p("Ttulo2", &t("Section")),
sect("", ""),
]
.concat();
let styles = [
style_xml("Ttulo1", "heading 1", r#"<w:outlineLvl w:val="0"/>"#, ""),
style_xml("Ttulo2", "heading 2", r#"<w:outlineLvl w:val="1"/>"#, ""),
style_xml("CabealhodoSumrio", "TOC Heading", "", ""),
style_xml("Sumrio1", "toc 1", r#"<w:spacing w:after="100"/>"#, ""),
style_xml("Sumrio2", "toc 2", r#"<w:spacing w:after="100"/><w:ind w:left="220"/>"#, ""),
]
.concat();
let mut s = Session::new(wordcraft_docx::read(&docx_with_styles(&body, &styles)).unwrap());
let mut blocks = None;
for round in 0..3 {
run(&mut s, "references.updateToc", json!({}));
let entries: Vec<(usize, &wordcraft_doc::Paragraph)> = s
.doc
.body
.iter()
.enumerate()
.filter_map(|(i, b)| b.as_para().map(|p| (i, p)))
.filter(|(_, p)| p.props.style.as_deref().is_some_and(|st| st.starts_with("TOC") && st != "TOCHeading"))
.collect();
// The chapters' section starts a new page.
let found: Vec<(Option<&str>, String)> = entries.iter().map(|(_, p)| (p.props.style.as_deref(), p.plain_text())).collect();
assert_eq!(found, [(Some("TOC1"), "Chapter One\t2".to_string()), (Some("TOC2"), "Section\t2".to_string())], "round {round}: {}", text(&s));
assert_eq!(s.doc.styles.get("TOC2").and_then(|st| st.para.indent_left), Some(11.0), "round {round}: the document's own TOC 2");
for (_, e) in &entries {
let right = e.props.tabs.as_ref().and_then(|t| t.iter().find(|t| t.align == wordcraft_doc::props::TabAlign::Right)).unwrap();
assert!((right.pos - 194.4).abs() < 0.1, "round {round}: page numbers at the first column's edge, not the page's: {}", right.pos);
}
// The paragraph above the first entry ends the one-column section: no blank line tops the
// first column.
let above = entries[0].0 - 1;
assert!(s.doc.body[above].as_para().unwrap().section.is_some(), "round {round}: a blank line above the entries: {}", text(&s));
let bytes = wordcraft_docx::write(&s.doc).unwrap();
s = Session::new(wordcraft_docx::read(&bytes).unwrap());
assert!(blocks.is_none_or(|n| n == s.doc.body.len()), "round {round}: the document grew: {}", text(&s));
blocks = Some(s.doc.body.len());
}
// The field's paragraph ends the one-column section: replacing or removing the TOC keeps it.
let columns = |s: &Session| s.doc.sections().iter().map(|(_, x)| x.columns.count).collect::<Vec<_>>();
assert_eq!(columns(&s), [1, 2, 1]);
// The heading goes with the table: one "Contents" after Replace, none after Remove.
let titles = |s: &Session| s.doc.body.iter().filter_map(|b| b.as_para()).filter(|p| p.plain_text() == "Contents").count();
run(&mut s, "references.toc", json!({"levels": 2}));
assert_eq!(columns(&s), [1, 2, 1], "{}", text(&s));
assert_eq!(titles(&s), 1, "{}", text(&s));
run(&mut s, "references.removeToc", json!({}));
assert_eq!(columns(&s), [1, 2, 1], "{}", text(&s));
assert_eq!(titles(&s), 0, "{}", text(&s));
}
/// #2182: with two TOCs only the one Update Table would refresh is offered Update Field; the other
/// range TOC doesn't claim the menu, so it never refreshes a different table.
#[test]
@@ -5181,6 +5578,32 @@ fn reading_mode_reflows_per_viewport_without_changing_document_or_export() {
assert_eq!(s.view.read_line_spacing, None);
}
/// Read Mode › View › Page Color: None, Sepia or Inverse, a view setting that leaves the
/// document alone, is not saved, and starts at None on each entry to Read Mode.
#[test]
fn reading_page_color_is_a_transient_view_setting() {
use crate::cmd::view::ReadPageColor;
let mut s = s();
let original = s.doc.clone();
let dirty = s.dirty;
run(&mut s, "view.readMode", json!({}));
assert_eq!(s.view.read_page_color, ReadPageColor::None);
assert_eq!(s.run("view.readPageColor", &json!({"value": "sepia"})).unwrap(), json!({"pageColor": "sepia"}));
assert_eq!(s.view.read_page_color, ReadPageColor::Sepia);
run(&mut s, "view.readPageColor", json!({"value": "inverse"}));
assert_eq!(s.view.read_page_color, ReadPageColor::Inverse);
for bad in [json!({"value": "black"}), json!({"value": 1}), json!({})] {
assert!(s.run("view.readPageColor", &bad).is_err(), "{bad}");
}
assert_eq!(s.view.read_page_color, ReadPageColor::Inverse, "rejected values keep the color");
assert!(serde_json::to_value(&s.view).unwrap().get("readPageColor").is_none());
assert_eq!(s.doc, original);
assert_eq!(s.dirty, dirty);
run(&mut s, "view.printLayout", json!({}));
run(&mut s, "view.readMode", json!({}));
assert_eq!(s.view.read_page_color, ReadPageColor::None);
}
/// File › Options › Advanced › Numeral: the display mode for digits (display only).
#[test]
fn numeral_mode_sets_gets_and_rejects_junk() {
@@ -7032,6 +7455,10 @@ fn dictionaries_folder_option_is_saved_searched_and_reported() {
("pl_PL", POLISH_TEST_AFF, POLISH_TEST_DIC),
("pt_BR", PORTUGUESE_TEST_AFF, PORTUGUESE_TEST_DIC),
("id_ID", INDONESIAN_TEST_AFF, INDONESIAN_TEST_DIC),
// The others the tests install, so reloading from the folder keeps their words.
("it_IT", ITALIAN_TEST_AFF, ITALIAN_TEST_DIC),
("es_ES", SPANISH_TEST_AFF, SPANISH_TEST_DIC),
("es_UY", SPANISH_TEST_AFF, URUGUAYAN_TEST_DIC),
// #3252: set_dictionaries_folder() now reloads Czech too (it used to leave its cache
// alone), so a run of this test overlapping czech_proofing_through_the_review_commands
// would otherwise force a reload that finds no cs_CZ here and empties it mid-test. Same
@@ -7055,6 +7482,11 @@ fn dictionaries_folder_option_is_saved_searched_and_reported() {
assert_eq!(file("pl"), Some(json!(dir.join("pl_PL.dic").to_string_lossy())));
assert_eq!(file("pt"), Some(json!(dir.join("pt_BR.dic").to_string_lossy())));
assert_eq!(file("id"), Some(json!(dir.join("id_ID.dic").to_string_lossy())));
// Each Spanish variety reports its own file (Spain, Mexico, Latin America, Uruguay, in order).
let spanish: Vec<serde_json::Value> = dicts.iter().filter(|d| d["language"] == "es").map(|d| d["file"].clone()).collect();
assert_eq!(spanish.len(), 4);
assert_eq!(spanish.first(), Some(&json!(dir.join("es_ES.dic").to_string_lossy())));
assert_eq!(spanish.last(), Some(&json!(dir.join("es_UY.dic").to_string_lossy())));
// Polish text is checked against the folder's dictionary.
run(&mut s, "document.setText", json!({"text": "rzeka płynie żeka"}));
run(&mut s, "select.all", json!({}));
@@ -7247,6 +7679,119 @@ fn new_documents_follow_the_regions_english() {
assert_eq!(s.region_english(), None);
}
/// A Spanish interface writes new documents in the region's Spanish (#3360): Uruguay's on a
/// Uruguayan system, Mexico's on a Mexican one, Spain's elsewhere.
#[test]
fn new_documents_follow_the_regions_spanish() {
let mut s = s();
s.system_region = wordcraft_geom::Region::from_tag("es_UY.UTF-8");
assert_eq!(s.region_spanish(), "es-UY");
// The ISO 639-2 code is Spanish too (CodeRabbit review on #3562).
s.system_region = wordcraft_geom::Region::from_tag("spa_MX.UTF-8");
assert_eq!(s.region_spanish(), "es-MX");
s.system_region = wordcraft_geom::Region::from_tag("es_UY.UTF-8");
s.set_editing_language(Some(&s.region_spanish()));
assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("es-UY"), "the untouched document follows");
for (tag, spanish) in [("es-MX", "es-MX"), ("es-419", "es-419"), ("es-ES", "es-ES"), ("en-US", "es-ES"), ("fr-FR", "es-ES"), ("auto", "es-UY")] {
run(&mut s, "file.options", json!({"region": tag}));
assert_eq!(s.region_spanish(), spanish, "{tag}");
}
}
// Hand-written synthetic fixtures (no system dictionary data): Spain's has `vosotros` forms,
// Uruguay's has `vos` ones' verbs only (the forms come from the voseo rules).
const SPANISH_TEST_AFF: &str =
"SET UTF-8\nFLAG UTF-8\nTRY aeosrnidlctumpbgvyqhfzjñáéíóú\nMAP 2\nMAP eé\nMAP ií\nREP 2\nREP v b\nREP b v\nSFX S Y 1\nSFX S 0 s [aeiou]\n";
const SPANISH_TEST_DIC: &str = "10\nesto\nes\nuna\nprueba/S\ndel\ncorrector\ntambién\nhabéis\nvenido\ndecir\n";
const URUGUAYAN_TEST_DIC: &str = "10\nesto\nes\nuna\nprueba/S\ndel\ncorrector\ntambién\ntener\nvenir\ndecir\n";
fn spanish_test_dictionaries() {
use wordcraft_proof::ProofLang;
for (lang, dic) in [(ProofLang::Es, SPANISH_TEST_DIC), (ProofLang::EsUy, URUGUAYAN_TEST_DIC)] {
wordcraft_proof::es::install_dictionary(lang, SPANISH_TEST_AFF, dic).unwrap();
}
}
/// #3553: Spanish proofing through the same commands as Review, F7, Change and Add to
/// Dictionary: `prueva` and `corector` are flagged with `prueba` and `corrector` offered.
#[test]
fn spanish_proofing_through_the_review_commands() {
spanish_test_dictionaries();
let mut s = s();
run(&mut s, "document.setText", json!({"text": "esto es una prueva del corector. Habéis venido?"}));
run(&mut s, "select.all", json!({}));
run(&mut s, "review.language", json!({"lang": "es_es", "default": true}));
assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("es-ES"));
let issues = run(&mut s, "review.issues", json!({}));
let arr = issues.as_array().unwrap();
assert!(arr.iter().all(|i| i["lang"] == "es"), "{issues}");
let has = |text: &str, kind: &str, sugg: &str| {
arr.iter().any(|i| i["text"] == text && i["kind"] == kind && i["suggestions"].as_array().unwrap().iter().any(|x| x == sugg))
};
assert!(has("prueva", "spelling", "prueba"), "{issues}");
assert!(has("corector", "spelling", "corrector"), "{issues}");
assert!(has("Habéis venido?", "grammar", "¿Habéis venido?"), "{issues}");
assert_eq!(arr.len(), 3, "{issues}");
assert!(arr.iter().any(|i| i["message"] == "Posible error ortográfico"), "{issues}");
run(&mut s, "caret.docStart", json!({}));
assert_eq!(run(&mut s, "review.spelling", json!({}))["text"], "prueva");
run(&mut s, "review.applySuggestion", json!({"text": "prueba"}));
assert!(text(&s).starts_with("esto es una prueba del"), "{}", text(&s));
run(&mut s, "review.addToDictionary", json!({"word": "corector"}));
let left = run(&mut s, "review.issues", json!({}));
assert_eq!(left.as_array().unwrap().len(), 1, "{left}");
}
/// #3562 review: Add to Dictionary and Ignore All get the issue's own text, decomposed (NFD) as
/// typed; the word is then accepted decomposed and composed, and its issue range was the typed
/// bytes.
#[test]
fn add_and_ignore_all_accept_both_normalization_forms() {
spanish_test_dictionaries();
let mut s = s();
let text = "esto zqanade\u{301}do y zqignora\u{301}do";
run(&mut s, "document.setText", json!({"text": text}));
run(&mut s, "select.all", json!({}));
run(&mut s, "review.language", json!({"lang": "es-ES"}));
let issues = run(&mut s, "review.issues", json!({}));
let words: Vec<(String, u64)> = issues
.as_array()
.unwrap()
.iter()
.filter(|i| i["kind"] == "spelling")
.map(|i| (i["text"].as_str().unwrap().to_string(), i["start"]["off"].as_u64().unwrap()))
.collect();
assert_eq!(words, [("zqanade\u{301}do".to_string(), 5), ("zqignora\u{301}do".to_string(), text.find("zqign").unwrap() as u64)], "{issues}");
run(&mut s, "review.addToDictionary", json!({"word": words[0].0}));
run(&mut s, "review.ignoreAll", json!({"word": words[1].0}));
assert!(run(&mut s, "review.issues", json!({})).as_array().unwrap().is_empty());
// The composed spellings are the same words.
run(&mut s, "document.setText", json!({"text": "esto zqanadédo y zqignorádo"}));
run(&mut s, "select.all", json!({}));
run(&mut s, "review.language", json!({"lang": "es-ES"}));
assert!(run(&mut s, "review.issues", json!({})).as_array().unwrap().is_empty());
wordcraft_proof::remove_word("zqanadédo");
}
/// Uruguayan Spanish accepts the `vos` forms, imperatives included, that Spain's flags.
#[test]
fn uruguayan_spanish_accepts_vos_forms() {
spanish_test_dictionaries();
let mut s = s();
run(&mut s, "document.setText", json!({"text": "Vení, decime qué tenés."}));
run(&mut s, "select.all", json!({}));
run(&mut s, "review.language", json!({"lang": "es-UY"}));
assert_eq!(run(&mut s, "format.state", json!({}))["lang"], "es-UY");
let spelling = |s: &mut Session| -> Vec<String> {
let issues = run(s, "review.issues", json!({}));
issues.as_array().unwrap().iter().filter(|i| i["kind"] == "spelling").filter_map(|i| i["text"].as_str().map(String::from)).collect()
};
assert_eq!(spelling(&mut s), ["qué"], "only the word the fixture lacks");
run(&mut s, "select.all", json!({}));
run(&mut s, "review.language", json!({"lang": "es-ES"}));
assert_eq!(spelling(&mut s), ["Vení", "decime", "qué", "tenés"]);
}
/// Czech proofing through the same commands as Review, F7, Change and Add to Dictionary.
#[test]
fn czech_proofing_through_the_review_commands() {
@@ -7286,10 +7831,11 @@ fn czech_proofing_through_the_review_commands() {
/// Review › Language and the proofing commands work in Polish end to end: the language makes
/// the document's words Polish, F7 and the issue list use the Polish dictionary and grammar,
/// suggestions come from it, and a suggestion fixes the text.
const ITALIAN_TEST_AFF: &str = "SET UTF-8\nTRY aeiortnsclcaffèéè\nMAP 2\nMAP aà\nMAP eéè\nREP 1\nREP caffe caffè\n";
const ITALIAN_TEST_DIC: &str = "6\ncasa\ncaffè\nroma\ndisse\ne\nper\n";
fn italian_test_dictionary() {
let aff = "SET UTF-8\nTRY aeiortnsclcaffèéè\nMAP 2\nMAP aà\nMAP eéè\nREP 1\nREP caffe caffè\n";
let dic = "6\ncasa\ncaffè\nroma\ndisse\ne\nper\n";
wordcraft_proof::it::install_dictionary(aff, dic).unwrap();
wordcraft_proof::it::install_dictionary(ITALIAN_TEST_AFF, ITALIAN_TEST_DIC).unwrap();
}
/// Review › Language and the proofing commands work in Italian end to end: the language makes
@@ -8630,6 +9176,29 @@ fn a_version_snapshot_does_not_survive_an_untitled_comparison() {
}
}
/// A comparison with an `original` replaces the open document in place — the handler loads the
/// original into `s.doc` without `set_document` — so `document_id` does not move and two
/// identical comparisons produce identical documents. A comparison is an edit of its own all
/// the same: the `replaces_document` guard keeps its undo step (#4004 review).
#[test]
fn a_repeated_comparison_is_still_an_edit() {
let dir = std::env::temp_dir().join(format!("wordcraft-compare-twice-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let original = dir.join("original.docx");
let mut other = Session::new(wordcraft_doc::Document::from_text("Original document."));
other.run("file.save", &json!({"path": original.to_string_lossy()})).unwrap();
let mut s = Session::new(wordcraft_doc::Document::from_text("Current document."));
let compare = json!({"original": original.to_string_lossy(), "text": "Revised document."});
s.run("review.compare", &compare).unwrap();
let depth = s.undo_depth();
assert!(s.dirty);
s.run("review.compare", &compare).unwrap();
assert_eq!(s.undo_depth(), depth + 1, "the second comparison is an edit of its own");
assert!(s.dirty, "the comparison result is unsaved");
let _ = std::fs::remove_dir_all(&dir);
}
/// A rejected comparison leaves the current document and its snapshots together.
#[test]
fn a_rejected_comparison_keeps_the_current_versions() {
+62
View File
@@ -0,0 +1,62 @@
//! Regressions where main's "a command that changes nothing is not an edit" (#23) meets the
//! migration's input-method application-change and state-revision tracking.
//!
//! Kept in its own module so the merged `tests.rs` is not touched.
use serde_json::json;
use crate::Session;
fn s() -> Session {
Session::new(wordcraft_doc::Document::new())
}
fn run(s: &mut Session, id: &str, v: serde_json::Value) -> serde_json::Value {
s.run(id, &v).unwrap_or_else(|e| panic!("{id}: {e}"))
}
/// An *unclassified* no-op (`text.backspace` at the very start of the body) is not an edit and
/// must not look like one to the input method: no effect flags, no application-change signal, no
/// settled-revision move, and no history/dirty/revision side effects.
#[test]
fn a_no_op_command_is_not_an_ime_state_change() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "x"}));
run(&mut s, "caret.docStart", json!({}));
assert!(s.pending.is_none(), "caret movement dropped any pending formatting");
s.take_pending_ime_application_change();
let revision = s.ime_state_revision().expect("an IME revision");
let rev = s.rev();
let depth = s.undo_depth();
let dirty = s.dirty;
run(&mut s, "text.backspace", json!({}));
let outcome = s.ime_edit_outcome();
assert!(!outcome.classified, "the handler leaves this no-op unclassified: {outcome:?}");
assert!(!outcome.content_changed && !outcome.state_changed, "{outcome:?}");
assert!(!s.take_pending_ime_application_change(), "a no-op is not an application change");
assert_eq!(s.ime_state_revision(), Some(revision), "the settled IME revision did not move");
assert_eq!((s.undo_depth(), s.dirty, s.rev()), (depth, dirty, rev));
}
/// `document.setText` with identical text replaces the body under the same document id, so the
/// IME replacement epoch must advance — but because nothing observable changed it is not an edit
/// and must not signal an application-origin change (main's #23 no-op rule).
#[test]
fn a_same_text_set_text_advances_the_body_epoch_without_an_application_change() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "Same body"}));
s.take_pending_ime_application_change();
let epoch = s.ime_body_epoch();
let document_id = s.document_id();
let rev = s.rev();
let depth = s.undo_depth();
run(&mut s, "document.setText", json!({"text": "Same body"}));
assert!(s.ime_body_epoch() > epoch, "the body is replaced again under the same document id");
assert_eq!(s.document_id(), document_id, "the document id itself is unchanged");
assert!(!s.take_pending_ime_application_change(), "an identical setText is not an application change");
assert_eq!((s.undo_depth(), s.rev()), (depth, rev));
}
@@ -401,3 +401,74 @@ fn tracked_delete_of_struck_table_text_keeps_it_for_reject_all() {
assert_eq!(cells(&s), [["A1", "B1"], ["A2", "B2"]], "{id}");
}
}
#[derive(Clone, Copy, Debug)]
enum Slot {
FootnoteSeparator,
EndnoteSeparator,
FootnoteContinuationSeparator,
EndnoteContinuationNotice,
}
const SLOTS: [Slot; 4] = [Slot::FootnoteSeparator, Slot::EndnoteSeparator, Slot::FootnoteContinuationSeparator, Slot::EndnoteContinuationNotice];
fn slot_mut(doc: &mut wordcraft_doc::Document, slot: Slot) -> &mut Option<wordcraft_doc::Blocks> {
match slot {
Slot::FootnoteSeparator => &mut doc.footnote_separators.separator,
Slot::EndnoteSeparator => &mut doc.endnote_separators.separator,
Slot::FootnoteContinuationSeparator => &mut doc.footnote_separators.continuation_separator,
Slot::EndnoteContinuationNotice => &mut doc.endnote_separators.continuation_notice,
}
}
/// A session whose note separator `slot` holds the text "SEP" as a tracked insertion (revision 0).
fn tracked_separator_session(slot: Slot) -> Session {
let mut doc = wordcraft_doc::Document::new();
doc.revisions.push(wordcraft_doc::Revision {
kind: wordcraft_doc::RevisionKind::Insert,
author: "Alice".into(),
date: String::new(),
move_name: None,
});
let props = wordcraft_doc::CharProps { ins: Some(0), ..Default::default() };
let p = wordcraft_doc::Paragraph::with_text("SEP", props);
*slot_mut(&mut doc, slot) = Some(vec![wordcraft_doc::para_block(p)]);
Session::new(doc)
}
fn separator_para(s: &mut Session, slot: Slot) -> wordcraft_doc::Paragraph {
slot_mut(&mut s.doc, slot).as_ref().and_then(|b| b.iter().find_map(|b| b.as_para())).expect("separator").clone()
}
#[test]
fn accept_all_resolves_note_separator_revisions() {
for slot in SLOTS {
let mut s = tracked_separator_session(slot);
run(&mut s, "review.acceptAll", json!({}));
let p = separator_para(&mut s, slot);
assert_eq!(p.plain_text(), "SEP", "{slot:?}");
assert!(p.run_ranges().all(|(_, c)| c.ins.is_none() && c.del.is_none()), "{slot:?}");
}
}
#[test]
fn reject_all_resolves_note_separator_revisions() {
for slot in SLOTS {
let mut s = tracked_separator_session(slot);
run(&mut s, "review.rejectAll", json!({}));
assert_eq!(separator_para(&mut s, slot).plain_text(), "", "{slot:?}");
}
}
#[test]
fn new_tracked_change_does_not_alias_separator_revision() {
for slot in SLOTS {
let mut s = tracked_separator_session(slot);
run(&mut s, "review.acceptAll", json!({}));
s.author = "Bob".into();
run(&mut s, "review.trackChanges", json!({"value": true}));
run(&mut s, "text.insert", json!({"text": "NEW "}));
let p = separator_para(&mut s, slot);
assert!(p.run_ranges().all(|(_, c)| c.ins.is_none() && c.del.is_none()), "{slot:?}");
}
}
+477
View File
@@ -8,10 +8,12 @@ use wordcraft_doc::{Pos, StoryRef};
use crate::Session;
/// Create an empty session for command-driven typing fixtures.
fn s() -> Session {
Session::new(wordcraft_doc::Document::new())
}
/// Execute a registry command and report its id on a test failure.
fn run(s: &mut Session, id: &str, v: serde_json::Value) {
s.run(id, &v).unwrap_or_else(|e| panic!("{id}: {e}"));
}
@@ -39,10 +41,12 @@ fn paras(s: &Session) -> Vec<(String, Option<u8>, bool)> {
.collect()
}
/// Expected paragraph text and list membership for typing assertions.
fn para(text: &str, level: Option<u8>) -> (String, Option<u8>, bool) {
(text.to_string(), level, level.is_some())
}
/// Create a session and enter its fixture through the actual typing commands.
fn typed(keys: &str) -> Session {
let mut s = s();
typ(&mut s, keys);
@@ -489,6 +493,25 @@ fn enter_finishes_a_word_for_autocorrect() {
assert_eq!(s.doc.plain_text(StoryRef::Body), "Hello\nThe end\n");
}
/// A word after a no-break space or an ideographic space is corrected as after a plain space:
/// replacements, sentence capitals, ordinals and links.
#[test]
fn autocorrect_finds_the_word_after_a_wide_space() {
let s = typed("a\u{a0}teh x\u{3000}teh ");
assert_eq!(s.doc.plain_text(StoryRef::Body), "a\u{a0}the x\u{3000}the ");
let s = typed("end.\u{a0}hello end.\u{3000}world ");
assert_eq!(s.doc.plain_text(StoryRef::Body), "End.\u{a0}Hello end.\u{3000}World ");
let s = typed("the\u{a0}1st and\u{3000}22nd ");
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
let sup = |t: &str| p.props_of_char(p.text.find(t).unwrap() + t.len() - 2).vert_align == Some(VertAlign::Superscript);
assert!(sup("1st") && sup("22nd"));
let s = typed("see\u{a0}https://example.org/log, and\u{3000}https://example.net/day, then ");
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
let link = |t: &str| p.props_of_char(p.text.find(t).unwrap()).link.clone();
assert_eq!(link("https://example.org").as_deref(), Some("https://example.org/log"));
assert_eq!(link("https://example.net").as_deref(), Some("https://example.net/day"));
}
#[test]
fn ordinals_become_superscript() {
let s = typed("the 1st and 22nd and 13th but not 1th ");
@@ -568,6 +591,71 @@ fn a_capitalised_word_after_a_symbol_is_not_in_the_symbol_font() {
assert_eq!(p.props_of_char(m).sym_font, None);
}
/// Whether every character of the body paragraph `i` is marked deleted.
fn all_deleted(s: &Session, i: usize, text: &str) -> bool {
s.doc.para_at(&Pos::body(i, 0)).is_some_and(|p| p.text == text && p.run_ranges().all(|(_, c)| c.del.is_some()))
}
#[test]
fn a_tracked_border_line_is_a_deletion_and_a_formatting_change() {
let bottom = |s: &Session, i| s.doc.para_at(&Pos::body(i, 0)).and_then(|p| p.props.borders).and_then(|b| b.bottom);
let tracked = |s: &Session, i| s.doc.para_at(&Pos::body(i, 0)).is_some_and(|p| p.props.fmt_change.is_some());
// Characters that were there before Track Changes was turned on are marked deleted.
let mut s = typed("Above\n---");
run(&mut s, "review.trackChanges", json!({"value": true}));
typ(&mut s, "\n");
assert!(bottom(&s, 0).is_some() && tracked(&s, 0));
assert!(all_deleted(&s, 1, "---"), "{:?}", paras(&s));
// Enter again after the deleted characters is a plain Enter.
typ(&mut s, "\n");
assert_eq!(texts(&s), ["above", "---", ""]);
assert_eq!(s.sel.focus, Pos::body(2, 0));
run(&mut s, "edit.undo", json!({}));
run(&mut s, "review.rejectAll", json!({}));
assert_eq!(texts(&s), ["above", "---"]);
assert!(bottom(&s, 0).is_none());
run(&mut s, "edit.undo", json!({}));
run(&mut s, "review.acceptAll", json!({}));
assert_eq!(texts(&s), ["above", ""]);
assert!(bottom(&s, 0).is_some());
// At the top of the document the border goes on the characters' paragraph, and typing
// continues in a new one.
let mut s = typed("---");
run(&mut s, "review.trackChanges", json!({"value": true}));
typ(&mut s, "\nText");
assert!(bottom(&s, 0).is_some() && tracked(&s, 0));
assert!(all_deleted(&s, 0, "---"), "{:?}", paras(&s));
assert_eq!(texts(&s)[1], "text");
run(&mut s, "review.rejectAll", json!({}));
assert_eq!(texts(&s), ["---"]);
assert!(bottom(&s, 0).is_none());
// Typed with Track Changes on, the characters go and the border is still a change.
let mut s = typed("");
run(&mut s, "review.trackChanges", json!({"value": true}));
typ(&mut s, "---\n");
assert_eq!(texts(&s), ["", ""]);
assert!(bottom(&s, 0).is_some() && tracked(&s, 0));
run(&mut s, "review.rejectAll", json!({}));
assert!(bottom(&s, 0).is_none());
}
#[test]
fn a_tracked_table_line_keeps_its_characters_as_a_deletion() {
let mut s = typed("+--+------+");
run(&mut s, "review.trackChanges", json!({"value": true}));
typ(&mut s, "\nA");
let Some(wordcraft_doc::Block::Table(t)) = s.doc.body.first().map(|b| &**b) else { panic!("no table: {:?}", paras(&s)) };
assert_eq!(t.rows[0].cells[0].blocks[0].as_para().map(|p| p.text.as_str()), Some("A"), "the caret is in the first cell");
assert!(all_deleted(&s, 1, "+--+------+"), "{:?}", paras(&s));
// Enter after the deleted characters makes no second table.
run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 11}}));
typ(&mut s, "\n");
assert_eq!(s.doc.body.iter().filter(|b| b.as_para().is_none()).count(), 1, "{:?}", paras(&s));
run(&mut s, "edit.undo", json!({}));
run(&mut s, "review.rejectAll", json!({}));
assert!(s.doc.body.get(1).and_then(|b| b.as_para()).is_some_and(|p| p.text == "+--+------+"));
}
#[test]
fn border_line_autoformat() {
let s = typed("Above\n---\nBelow");
@@ -600,6 +688,155 @@ fn undo_right_after_a_border_line_brings_the_characters_back() {
assert_eq!(s.doc.plain_text(StoryRef::Body), "Above\n---");
}
/// The first paragraph's (left, first-line) indents as set on the paragraph itself.
fn indents(s: &Session) -> (Option<f32>, Option<f32>) {
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
(p.props.indent_left, p.props.indent_first)
}
#[test]
fn tab_and_backspace_at_a_paragraph_start_set_its_indents() {
let mut s = s();
run(&mut s, "tools.autocorrect", json!({"tabIndent": true}));
typ(&mut s, "Some text");
run(&mut s, "caret.docStart", json!({}));
// The first Tab indents the first line, the next one the whole paragraph.
typ(&mut s, "\t");
assert_eq!(indents(&s), (None, Some(36.0)));
typ(&mut s, "\t");
assert_eq!(indents(&s), (Some(36.0), Some(36.0)));
assert_eq!(s.doc.plain_text(StoryRef::Body), "Some text");
// Backspace takes the first-line indent back, then the left indent, and stops there.
typ(&mut s, "\u{8}");
assert_eq!(indents(&s), (Some(36.0), Some(0.0)));
typ(&mut s, "\u{8}\u{8}");
assert_eq!(indents(&s), (Some(0.0), Some(0.0)));
assert_eq!(s.doc.plain_text(StoryRef::Body), "Some text");
// Each Tab is one undo step.
typ(&mut s, "\t");
run(&mut s, "edit.undo", json!({}));
assert_eq!(indents(&s), (Some(0.0), Some(0.0)));
// A left indent in characters goes back one tab stop too, instead of the paragraphs joining:
// six characters of 11 or 12 points are 66 or 72 points, two tab stops.
let mut s = self::s();
run(&mut s, "tools.autocorrect", json!({"tabIndent": true}));
typ(&mut s, "One\nTwo");
let second = Pos::body(1, 0);
s.doc.para_mut(StoryRef::Body, &second.path).unwrap().props.indent_left_chars = Some(6.0);
s.sel = crate::Selection::caret(second.clone());
typ(&mut s, "\u{8}");
assert_eq!(s.doc.plain_text(StoryRef::Body), "One\nTwo");
let p = s.doc.para_at(&second).unwrap();
assert_eq!((p.props.indent_left, p.props.indent_left_chars), (Some(36.0), Some(0.0)));
// With formatting limited to the document's styles the option is off: Tab types a tab
// character at the start of the first line and of a later one, and Backspace at the start
// keeps the paragraph's left indent.
let mut s = self::s();
run(&mut s, "tools.autocorrect", json!({"tabIndent": true}));
typ(&mut s, "One");
run(&mut s, "text.lineBreak", json!({}));
typ(&mut s, "Two");
run(&mut s, "review.restrict", json!({"mode": "none", "formatting": true}));
run(&mut s, "caret.home", json!({}));
typ(&mut s, "\t");
run(&mut s, "caret.docStart", json!({}));
typ(&mut s, "\t");
assert_eq!(s.doc.plain_text(StoryRef::Body), "\tOne\n\tTwo");
assert_eq!(indents(&s), (None, None));
s.doc.para_mut(StoryRef::Body, &Pos::body(0, 0).path).unwrap().props.indent_left = Some(36.0);
typ(&mut s, "\u{8}\u{8}");
assert_eq!(s.doc.plain_text(StoryRef::Body), "One\n\tTwo");
assert_eq!(indents(&s), (Some(36.0), None));
// An empty paragraph takes a tab character.
let mut s = self::s();
run(&mut s, "tools.autocorrect", json!({"tabIndent": true}));
typ(&mut s, "\tText");
assert_eq!(s.doc.plain_text(StoryRef::Body), "\tText");
assert_eq!(indents(&s), (None, None));
// At the start of a list item numbered by its style, Tab types a tab character, as with the
// option off.
let mut s = style_inherited_list(wordcraft_doc::numbering::ListKind::Numbered, false);
run(&mut s, "tools.autocorrect", json!({"tabIndent": true}));
run(&mut s, "caret.docStart", json!({}));
typ(&mut s, "\t");
assert_eq!(s.doc.plain_text(StoryRef::Body), "\tAlpha\nBeta");
assert_eq!(indents(&s), (None, None));
// With the option off (the default), Tab types a tab character.
let mut s = self::s();
typ(&mut s, "Some text");
run(&mut s, "caret.docStart", json!({}));
typ(&mut s, "\t");
assert_eq!(s.doc.plain_text(StoryRef::Body), "\tSome text");
assert_eq!(indents(&s), (None, None));
}
#[test]
fn tab_before_a_later_line_of_a_paragraph_indents_the_whole_paragraph() {
let mut s = s();
run(&mut s, "tools.autocorrect", json!({"tabIndent": true}));
typ(&mut s, &"Words that run on past the end of one line. ".repeat(6));
run(&mut s, "caret.docStart", json!({}));
run(&mut s, "caret.down", json!({}));
run(&mut s, "caret.home", json!({}));
assert!(s.sel.focus.off > 0, "the caret is on the second line");
typ(&mut s, "\t");
assert_eq!(indents(&s), (Some(36.0), None));
assert!(!s.doc.plain_text(StoryRef::Body).contains('\t'));
// After a line break too; inside a line Tab still types a tab character.
let mut s = self::s();
run(&mut s, "tools.autocorrect", json!({"tabIndent": true}));
typ(&mut s, "One");
run(&mut s, "text.lineBreak", json!({}));
typ(&mut s, "Two");
run(&mut s, "caret.home", json!({}));
typ(&mut s, "\t");
assert_eq!(indents(&s), (Some(36.0), None));
run(&mut s, "caret.right", json!({}));
typ(&mut s, "\t");
assert_eq!(s.doc.plain_text(StoryRef::Body), "One\nT\two");
// Before a later line of a list item numbered by its style, Tab takes it down a level, as
// for a list item numbered directly.
let mut s = style_inherited_list(wordcraft_doc::numbering::ListKind::Numbered, false);
run(&mut s, "tools.autocorrect", json!({"tabIndent": true}));
run(&mut s, "caret.docStart", json!({}));
run(&mut s, "caret.end", json!({}));
run(&mut s, "text.lineBreak", json!({}));
typ(&mut s, "More");
run(&mut s, "caret.home", json!({}));
typ(&mut s, "\t");
assert!(!s.doc.plain_text(StoryRef::Body).contains('\t'));
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
assert_eq!(p.props.numbering.map(|n| n.level), Some(1));
assert_eq!(indents(&s), (None, None));
// A list level past the last one, from a style or set directly, goes to the last level.
let mut s = style_inherited_list(wordcraft_doc::numbering::ListKind::Numbered, false);
let style = s.doc.styles.styles.iter_mut().find(|x| x.id == "ListBase").unwrap();
style.para.numbering = style.para.numbering.map(|n| wordcraft_doc::props::NumRef { level: u8::MAX, ..n });
run(&mut s, "tools.autocorrect", json!({"tabIndent": true}));
run(&mut s, "caret.docStart", json!({}));
run(&mut s, "caret.end", json!({}));
run(&mut s, "text.lineBreak", json!({}));
typ(&mut s, "More");
run(&mut s, "caret.home", json!({}));
typ(&mut s, "\t");
assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().props.numbering.map(|n| n.level), Some(8));
let num = s.doc.para_at(&Pos::body(0, 0)).unwrap().props.numbering.unwrap().num;
s.doc.para_mut(StoryRef::Body, &Pos::body(1, 0).path).unwrap().props.numbering = Some(wordcraft_doc::props::NumRef { num, level: u8::MAX });
s.sel = crate::Selection::caret(Pos::body(1, 0));
typ(&mut s, "\t");
assert_eq!(s.doc.para_at(&Pos::body(1, 0)).unwrap().props.numbering.map(|n| n.level), Some(8));
s.doc.para_mut(StoryRef::Body, &Pos::body(0, 0).path).unwrap().props.numbering = Some(wordcraft_doc::props::NumRef { num, level: u8::MAX });
run(&mut s, "caret.docStart", json!({}));
run(&mut s, "caret.down", json!({}));
run(&mut s, "caret.home", json!({}));
assert_eq!((s.sel.focus.path.clone(), s.sel.focus.off), (Pos::body(0, 0).path, 6), "the caret is before \"More\"");
typ(&mut s, "\t");
assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().props.numbering.map(|n| n.level), Some(8));
s.doc.para_mut(StoryRef::Body, &Pos::body(0, 0).path).unwrap().props.numbering = Some(wordcraft_doc::props::NumRef { num, level: u8::MAX });
run(&mut s, "outline.demote", json!({}));
assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().props.numbering.map(|n| n.level), Some(8));
}
#[test]
fn plus_and_hyphen_line_becomes_a_table() {
let mut s = typed("+--+------+\nA");
@@ -621,6 +858,118 @@ fn plus_and_hyphen_line_becomes_a_table() {
assert!(s.doc.body.iter().all(|b| b.as_para().is_some()));
}
/// Body paragraphs as (text, paragraph style).
fn styled(s: &Session) -> Vec<(String, Option<String>)> {
s.doc.body.iter().filter_map(|b| b.as_para()).map(|p| (p.text.clone(), p.props.style.clone())).collect()
}
fn with_heading_styles(keys: &str) -> Session {
let mut s = s();
run(&mut s, "tools.autocorrect", json!({"headingStyles": true}));
typ(&mut s, keys);
s
}
#[test]
fn two_enters_after_a_short_line_make_it_a_heading() {
let h = |t: &str, st: Option<&str>| (t.to_string(), st.map(str::to_string));
// No tab: Heading 1; one or two tabs: Heading 2 or 3, without the tabs. Typing goes on in
// the paragraph after the heading.
for (keys, style) in [("Introduction\n\nText", "Heading1"), ("\tOur methods\n\nText", "Heading2"), ("\t\tStep 2\n\nText", "Heading3")] {
let s = with_heading_styles(keys);
let text = keys.trim_start_matches('\t').split('\n').next().unwrap_or_default();
assert_eq!(styled(&s), [h(text, Some(style)), h("Text", None)], "{keys:?}");
}
// Five words is the most; one Enter only ends the line.
let s = with_heading_styles("One two three four five\n\n");
assert_eq!(s.doc.body.first().and_then(|b| b.as_para()).and_then(|p| p.props.style.as_deref()), Some("Heading1"));
assert_eq!(styled(&with_heading_styles("Introduction\nText")), [h("Introduction", None), h("Text", None)]);
// Not a heading: six words, a closing punctuation mark, three tabs, a list item, a style.
for keys in ["One two three four five six\n\n", "Dear Sir,\n\n", "Is it?\n\n", "\t\t\tDeep\n\n", "* Item\n\n"] {
let s = with_heading_styles(keys);
assert!(
s.doc.body.iter().filter_map(|b| b.as_para()).all(|p| p.props.style.as_deref().is_none_or(|st| !st.starts_with("Heading"))),
"{keys:?}: {:?}",
styled(&s)
);
}
let mut q = self::s();
run(&mut q, "tools.autocorrect", json!({"headingStyles": true}));
run(&mut q, "para.style", json!({"style": "Quote"}));
typ(&mut q, "Quoted\n\n");
assert_eq!(q.doc.body.first().and_then(|b| b.as_para()).and_then(|p| p.props.style.as_deref()), Some("Quote"));
// A list item numbered by the Normal style is not body text either.
let mut q = self::s();
run(&mut q, "tools.autocorrect", json!({"headingStyles": true}));
let num = q.doc.numbering.try_add_list(wordcraft_doc::ListKind::Bullet).unwrap();
if let Some(normal) = q.doc.styles.get_mut("Normal") {
normal.para.numbering = Some(wordcraft_doc::props::NumRef { num, level: 0 });
}
typ(&mut q, "Item\n\n");
assert!(styled(&q).iter().all(|(_, st)| st.as_deref().is_none_or(|st| !st.starts_with("Heading"))), "{:?}", styled(&q));
// Off by default.
let s = typed("Introduction\n\n");
assert_eq!(styled(&s), [h("Introduction", None), h("", None), h("", None)]);
}
#[test]
fn undo_right_after_a_heading_autoformat_keeps_the_enter() {
let mut s = with_heading_styles("\tMethods\n\n");
assert_eq!(styled(&s), [("Methods".to_string(), Some("Heading2".to_string())), (String::new(), None)]);
assert_eq!(s.sel.focus, Pos::body(1, 0));
run(&mut s, "edit.undo", json!({}));
// The AutoFormat is undone on its own: the tab and Normal text are back, the Enter stays.
assert_eq!(s.doc.plain_text(StoryRef::Body), "\tMethods\n\n");
assert!(styled(&s).iter().all(|(_, st)| st.is_none()));
assert_eq!(s.sel.focus, Pos::body(2, 0));
}
#[test]
fn a_tracked_heading_autoformat_can_be_rejected() {
// With Track Changes on, the tab is marked deleted and the style change is a formatting
// revision, so Reject All brings the line back as it was.
let mut s = with_heading_styles("\tMethods\n");
run(&mut s, "review.trackChanges", json!({"value": true}));
typ(&mut s, "\n");
let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default();
assert_eq!((p.text.as_str(), p.props.style.as_deref()), ("\tMethods", Some("Heading2")));
assert!(p.run_ranges().any(|(r, c)| r.start == 0 && c.del.is_some()), "the tab is marked deleted");
assert!(p.props.fmt_change.is_some(), "the style change is tracked");
run(&mut s, "review.rejectAll", json!({}));
assert_eq!(styled(&s).first(), Some(&("\tMethods".to_string(), None)));
// A tab this author inserted is removed outright, and the style change is still tracked.
let mut s = with_heading_styles("Title\n");
run(&mut s, "review.trackChanges", json!({"value": true}));
run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 0}}));
typ(&mut s, "\t");
run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 0}}));
typ(&mut s, "\n");
let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default();
assert_eq!((p.text.as_str(), p.props.style.as_deref()), ("Title", Some("Heading2")));
assert!(p.props.fmt_change.is_some(), "the style change is tracked");
run(&mut s, "review.rejectAll", json!({}));
assert_eq!(styled(&s).first(), Some(&("Title".to_string(), None)));
// A line holding a tracked deletion is left alone.
let mut s = with_heading_styles("Old title\n");
run(&mut s, "review.trackChanges", json!({"value": true}));
run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 3}}));
typ(&mut s, "\u{8}\u{8}\u{8}");
run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 0}}));
typ(&mut s, "\n");
assert!(styled(&s).iter().all(|(_, st)| st.is_none()), "{:?}", styled(&s));
}
#[test]
fn a_locked_heading_style_is_not_applied_as_you_type() {
let mut s = s();
run(&mut s, "tools.autocorrect", json!({"headingStyles": true}));
run(&mut s, "review.restrict", json!({"mode": "none", "formatting": true, "lockedStyles": ["Heading1"]}));
typ(&mut s, "Locked\n\n\tOpen\n\n");
let st = styled(&s);
assert_eq!(st.first(), Some(&("Locked".to_string(), None)), "{st:?}");
assert!(st.contains(&("Open".to_string(), Some("Heading2".to_string()))), "{st:?}");
}
#[test]
fn arrow_bullets() {
for (keys, bullet) in [("> ", "\u{27A2}"), ("-> ", "\u{2794}"), ("=> ", "\u{21E8}")] {
@@ -799,6 +1148,20 @@ fn typing_then_selecting_then_typing_are_two_undo_steps() {
assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().text, "");
}
#[test]
fn backspace_at_the_start_of_the_document_is_not_an_edit() {
// #23: a key that changes nothing adds no undo step, marks no unsaved changes and
// leaves the redo stack alone.
let mut s = typed("alpha");
run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 0}}));
s.dirty = false;
let depth = s.undo_depth();
typ(&mut s, "\u{8}");
assert_eq!(s.undo_depth(), depth, "no undo step");
assert!(!s.dirty, "no unsaved changes");
assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().text, "alpha");
}
#[test]
fn undoing_bold_at_the_caret_turns_bold_typing_off_again() {
// #424: Bold at a caret in an empty paragraph, Undo, type: the text isn't bold.
@@ -2511,3 +2874,117 @@ fn a_new_list_uses_the_requested_kind_after_a_mixed_format_list() {
assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap(), &before);
}
}
/// #4085: the registered shortcut toggles the last selected highlight, while explicit
/// colour commands remain idempotent setters for the palette, CLI and automation.
#[test]
#[cfg(not(target_os = "macos"))]
fn highlight_shortcut_toggles_the_last_colour() {
let mut s = typed("Alpha Beta");
run(&mut s, "select.all", json!({}));
let colour = |s: &mut Session| s.run("format.state", &json!({})).unwrap()["highlight"].clone();
press(&mut s, "Mod+Alt+H").unwrap();
assert_eq!(colour(&mut s), json!("FFFF00"));
press(&mut s, "Mod+Alt+H").unwrap();
assert_eq!(colour(&mut s), serde_json::Value::Null);
run(&mut s, "edit.undo", json!({}));
assert_eq!(colour(&mut s), json!("FFFF00"));
run(&mut s, "edit.redo", json!({}));
assert_eq!(colour(&mut s), serde_json::Value::Null);
run(&mut s, "format.highlight", json!({"color":"blue"}));
run(&mut s, "format.highlight", json!({"color":"blue"}));
assert_eq!(colour(&mut s), json!("0000FF"));
press(&mut s, "Mod+Alt+H").unwrap();
assert_eq!(colour(&mut s), serde_json::Value::Null);
press(&mut s, "Mod+Alt+H").unwrap();
assert_eq!(colour(&mut s), json!("0000FF"));
run(&mut s, "format.highlight", json!({"color":"none"}));
press(&mut s, "Mod+Alt+H").unwrap();
assert_eq!(colour(&mut s), serde_json::Value::Null);
assert_eq!(s.doc.body[0].as_para().unwrap().text, "Alpha Beta");
}
/// #4085: mixed selections take one colour; removing an inherited colour writes an
/// explicit No Color, and a collapsed caret toggles pending formatting for future typing.
#[test]
fn highlight_toggle_handles_mixed_inherited_and_pending_formatting() {
let mut s = typed("Alpha Beta ");
run(&mut s, "select.text", json!({"text":"Alpha"}));
run(&mut s, "format.highlight", json!({"color":"blue"}));
run(&mut s, "select.all", json!({}));
run(&mut s, "format.highlight", json!({}));
assert_eq!(s.run("format.state", &json!({})).unwrap()["highlight"], json!("0000FF"));
run(&mut s, "format.highlight", json!({}));
assert_eq!(s.run("format.state", &json!({})).unwrap()["highlight"], serde_json::Value::Null);
s.doc.styles.default_chr.highlight = Some(wordcraft_doc::props::Highlight::Blue);
run(&mut s, "format.highlight", json!({"color":"blue"}));
run(&mut s, "format.highlight", json!({}));
assert!(s.doc.body[0].as_para().unwrap().runs.iter().all(|r| r.props.highlight == Some(wordcraft_doc::props::Highlight::None)));
run(&mut s, "caret.docEnd", json!({}));
run(&mut s, "format.highlight", json!({}));
assert_eq!(s.pending.as_ref().unwrap().highlight, Some(wordcraft_doc::props::Highlight::Blue));
run(&mut s, "format.highlight", json!({}));
assert_eq!(s.pending.as_ref().unwrap().highlight, Some(wordcraft_doc::props::Highlight::None));
run(&mut s, "text.insert", json!({"text":"X"}));
run(&mut s, "select.text", json!({"text":"X"}));
assert_eq!(s.run("format.state", &json!({})).unwrap()["highlight"], serde_json::Value::Null);
let before = serde_json::to_value(&s.doc).unwrap();
assert!(s.run("format.highlight", &json!({"color":123})).is_err());
assert_eq!(serde_json::to_value(&s.doc).unwrap(), before);
}
/// A differently coloured final paragraph must remain visible to the shortcut beyond
/// the formatting-state query limit, including a style-inherited tail and Undo/Redo.
#[cfg(not(target_os = "macos"))]
#[test]
fn highlight_toggle_checks_past_the_resolved_run_limit() {
use wordcraft_doc::para::Run;
use wordcraft_doc::props::{CharProps, Highlight};
for count in [10_000, 10_001, 10_002] {
for inherited in [false, true] {
let mut doc = wordcraft_doc::Document::from_text(&format!("{}\nZ", "x".repeat(count)));
doc.para_mut(StoryRef::Body, &Pos::body(0, 0).path).unwrap().runs = (0..count)
.map(|i| Run { len: 1, props: CharProps { highlight: Some(Highlight::Blue), bold: Some(i % 2 == 0), ..Default::default() } })
.collect();
if inherited {
doc.styles.default_chr.highlight = Some(Highlight::Yellow);
} else {
doc.para_mut(StoryRef::Body, &Pos::body(1, 0).path).unwrap().runs[0].props.highlight = Some(Highlight::Yellow);
}
let original = doc.clone();
let mut s = Session::new(doc);
s.last_highlight = Highlight::Blue;
run(&mut s, "select.all", json!({}));
press(&mut s, "Mod+Alt+H").unwrap();
assert!(
s.doc.body.iter().filter_map(|b| b.as_para()).flat_map(|p| &p.runs).all(|r| r.props.highlight == Some(Highlight::Blue)),
"count={count}, inherited={inherited}"
);
press(&mut s, "Mod+Alt+H").unwrap();
assert!(s.doc.body.iter().filter_map(|b| b.as_para()).flat_map(|p| &p.runs).all(|r| r.props.highlight == Some(Highlight::None)));
run(&mut s, "edit.undo", json!({}));
assert!(s.doc.body[1].as_para().unwrap().runs.iter().all(|r| r.props.highlight == Some(Highlight::Blue)));
run(&mut s, "edit.undo", json!({}));
assert_eq!(serde_json::to_value(&s.doc).unwrap(), serde_json::to_value(&original).unwrap());
run(&mut s, "edit.redo", json!({}));
assert!(s.doc.body[1].as_para().unwrap().runs.iter().all(|r| r.props.highlight == Some(Highlight::Blue)));
}
}
}
/// A command preview uses the real session's remembered colour without changing its
/// document, colour choice or undo history when the preview toggles highlighting off.
#[test]
fn highlight_preview_preserves_the_session_colour_and_document() {
use wordcraft_doc::props::Highlight;
let mut s = typed("Alpha");
run(&mut s, "select.all", json!({}));
run(&mut s, "format.highlight", json!({"color":"blue"}));
let original = serde_json::to_value(&s.doc).unwrap();
let depth = s.undo_depth();
let (preview, _, _) = s.preview_layout("format.highlight", &json!({})).unwrap();
assert!(preview.body[0].as_para().unwrap().runs.iter().all(|r| r.props.highlight == Some(Highlight::None)));
assert_eq!(serde_json::to_value(&s.doc).unwrap(), original);
assert_eq!(s.last_highlight, Highlight::Blue);
assert_eq!(s.undo_depth(), depth);
}
+16
View File
@@ -76,6 +76,22 @@ fn moving_a_joined_shape_reroutes_its_connector() {
assert_eq!(connector_ends(&mut s), ((172.0, 248.0), (300.0, 218.0)));
}
/// A command that changes nothing but reroutes a stale connector is still an edit: the
/// reroute must not be left without an undo step (#23).
#[test]
fn a_rerouted_connector_is_still_undoable() {
let mut s = joined();
select(&mut s, 5);
let before = connector_ends(&mut s);
// A nudge by nothing moves nothing, but the connector is stale and gets rerouted.
run(&mut s, "arrange.nudge", json!({"dx": 0, "dy": 0}));
assert_ne!(connector_ends(&mut s), before, "the connector was rerouted");
assert_eq!(s.undo_depth(), 1, "the reroute is an undo step of its own");
// One undo takes the reroute back.
run(&mut s, "edit.undo", json!({}));
assert_eq!(connector_ends(&mut s), before, "one undo takes the reroute back");
}
#[test]
fn dragging_a_connector_end_glues_it_to_the_nearest_site() {
let mut s = joined();