mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-11 16:31:54 -06:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61a57cd655 | ||
|
|
d86d2b69f8 | ||
|
|
a8db235e05 | ||
|
|
4706420e94 | ||
|
|
ae7ae89eb2 | ||
|
|
1327d20055 | ||
|
|
9dba000e86 | ||
|
|
7013411a2f | ||
|
|
411a4915a1 | ||
|
|
639b8133ea | ||
|
|
e0659e45ce | ||
|
|
ad5e1a65fc | ||
|
|
bf426c1804 | ||
|
|
b67d3b8735 | ||
|
|
e3fef2a9c3 | ||
|
|
060768f1ed | ||
|
|
45db128c15 |
@@ -27,6 +27,10 @@ jobs:
|
||||
name: Windows installation policy (no app build)
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Harden runner
|
||||
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -42,6 +42,10 @@ jobs:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: '0'
|
||||
steps:
|
||||
- name: Harden runner
|
||||
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
@@ -64,6 +68,11 @@ jobs:
|
||||
echo "CRAFT_FONTS_DIR=$GITHUB_WORKSPACE/craft-fonts" >> "$GITHUB_ENV"
|
||||
echo "CRAFT_FONTS_REQUIRED=1" >> "$GITHUB_ENV"
|
||||
|
||||
# apt falls back to mirrors outside ubuntu.com when it doesn't resolve (as on 11 Nov 2026).
|
||||
- name: Configure apt mirrors
|
||||
if: runner.os == 'Linux'
|
||||
uses: storytold/craft-common-actions/actions/configure-apt-mirrors@main
|
||||
|
||||
- name: Install Linux build and software-rendering dependencies
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
|
||||
Generated
+26
-22
@@ -1081,7 +1081,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-pdf"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
|
||||
dependencies = [
|
||||
"krilla",
|
||||
"thiserror 2.0.21",
|
||||
@@ -1135,7 +1135,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-update"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dunce",
|
||||
@@ -1145,6 +1145,7 @@ dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
"plist",
|
||||
"rustix 1.1.5",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -2981,7 +2982,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "krilla"
|
||||
version = "0.8.2"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bumpalo",
|
||||
@@ -6806,7 +6807,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft"
|
||||
version = "0.15.1"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"chrono",
|
||||
@@ -6838,13 +6839,15 @@ dependencies = [
|
||||
"wordcraft-engine",
|
||||
"wordcraft-fonts",
|
||||
"wordcraft-ui-egui",
|
||||
"x11-dl",
|
||||
"x11rb",
|
||||
"xkbcommon-dl",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-cli"
|
||||
version = "0.15.1"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"serde_json",
|
||||
@@ -6860,11 +6863,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-control-key"
|
||||
version = "0.15.1"
|
||||
version = "0.18.1"
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-doc"
|
||||
version = "0.15.1"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"log",
|
||||
@@ -6878,7 +6881,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-docbin"
|
||||
version = "0.15.1"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"cfb",
|
||||
"chrono",
|
||||
@@ -6895,7 +6898,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-docx"
|
||||
version = "0.15.1"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"base64 0.23.1",
|
||||
@@ -6920,7 +6923,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-engine"
|
||||
version = "0.15.1"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"cfb",
|
||||
"chrono",
|
||||
@@ -6935,6 +6938,7 @@ dependencies = [
|
||||
"log",
|
||||
"regex",
|
||||
"regex-automata",
|
||||
"rustix 1.1.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
@@ -6953,7 +6957,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-fonts"
|
||||
version = "0.15.1"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"brotli-decompressor",
|
||||
"harfrust 0.13.3",
|
||||
@@ -6967,7 +6971,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-formats"
|
||||
version = "0.15.1"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"base64 0.23.1",
|
||||
@@ -6994,7 +6998,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-geom"
|
||||
version = "0.15.1"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"serde",
|
||||
@@ -7002,7 +7006,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-layout"
|
||||
version = "0.15.1"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"craft-proof",
|
||||
"image",
|
||||
@@ -7022,7 +7026,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-mcp"
|
||||
version = "0.15.1"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde_json",
|
||||
@@ -7034,7 +7038,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-metafile"
|
||||
version = "0.15.1"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"thiserror 2.0.21",
|
||||
@@ -7042,7 +7046,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-pdf"
|
||||
version = "0.15.1"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"craft-pdf",
|
||||
@@ -7063,7 +7067,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-render"
|
||||
version = "0.15.1"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -7077,7 +7081,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-ui-egui"
|
||||
version = "0.15.1"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"accesskit_consumer",
|
||||
"cfb",
|
||||
@@ -7110,7 +7114,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-web"
|
||||
version = "0.15.1"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"craft-web-api",
|
||||
"craft-web-host",
|
||||
@@ -7131,7 +7135,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-zotero"
|
||||
version = "0.15.1"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -7294,7 +7298,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.15.1"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+4
-4
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/wordcraft", "apps/wordcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.15.1"
|
||||
version = "0.18.1"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -67,9 +67,9 @@ wuff = "0.2.9"
|
||||
brotli-decompressor = "5"
|
||||
vello_cpu = "0.2"
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp", "tiff"] }
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "1b34eba41e62138fc2438d2734523b302840b45d", version = "=0.1.0", default-features = false, features = ["write"] }
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", version = "=0.1.0", default-features = false, features = ["write"] }
|
||||
# craft-update: app-specific pretend versions apply to startup and manual checks.
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "1b34eba41e62138fc2438d2734523b302840b45d", features = ["egui"] }
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", features = ["egui"] }
|
||||
quick-xml = "0.41"
|
||||
zip = { version = "3", default-features = false, features = ["deflate"] }
|
||||
# zlib streams (compressed metafile pictures in `.doc` drawings); already in the lock as a dependency of other crates.
|
||||
@@ -134,7 +134,7 @@ debug = 0
|
||||
|
||||
# Required by craft-pdf: the backend rejects writers that failed during unwinding.
|
||||
[patch.crates-io]
|
||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "1b34eba41e62138fc2438d2734523b302840b45d" }
|
||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b" }
|
||||
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
|
||||
@@ -354,6 +354,11 @@ XWayland:
|
||||
WORDCRAFT_WAYLAND=1 wordcraft
|
||||
```
|
||||
|
||||
XWayland needs the X11 libraries, which the packages only recommend and the tarball doesn't
|
||||
install: libX11, libX11-xcb, libXcursor, libXi and libxkbcommon-x11 (on Debian and Ubuntu,
|
||||
`libx11-6`, `libx11-xcb1`, `libxcursor1`, `libxi6` and `libxkbcommon-x11-0`). Without them the app
|
||||
opens on native Wayland, whatever the setting says, and logs which ones are missing.
|
||||
|
||||
**Arch Linux (AUR).** Community members maintain three AUR packages; the project doesn't publish
|
||||
to the AUR itself. Install one of them:
|
||||
| Package | What it installs | Choose it when |
|
||||
|
||||
@@ -11,7 +11,7 @@ use std::path::{Path, PathBuf};
|
||||
use std::process::ExitCode;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use wordcraft_engine::Session;
|
||||
use wordcraft_engine::{CmdError, Session};
|
||||
|
||||
const USAGE: &str = "\
|
||||
wordcraft-cli — WordCraft from the command line
|
||||
@@ -19,7 +19,8 @@ wordcraft-cli — WordCraft from the command line
|
||||
USAGE:
|
||||
wordcraft-cli convert <in> <out> [--markup] [--update-fields] [--strict] [--format wordml2003|flatopc]
|
||||
convert between formats (docx, doc [read], pdf, odt, rtf, html, md, tex, txt, json, png,
|
||||
xml); --update-fields refreshes unlocked fields before saving;
|
||||
xml); --update-fields refreshes unlocked fields before saving
|
||||
(if protection blocks updates, they are skipped with a warning);
|
||||
an .xml file is a Word XML Document (Flat OPC), or with
|
||||
--format wordml2003 a Word 2003 XML Document;
|
||||
--markup (Print Markup): comments beside each page and
|
||||
@@ -451,6 +452,30 @@ fn report_lines(json: &Value) -> Vec<String> {
|
||||
wordcraft_engine::strict::lines_from_json(json)
|
||||
}
|
||||
|
||||
/// Explicitly update unlocked fields in a session before saving the document.
|
||||
///
|
||||
/// If field updates are disabled by document protection (`CmdError::Disabled`), logs a warning to
|
||||
/// stderr and continues conversion rather than aborting. Other command failures are converted to
|
||||
/// [`Failure`] via [`engine_error`].
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `s` - The active document [`Session`] whose unlocked fields should be refreshed.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `Err(Failure)` if the command fails with an error other than `CmdError::Disabled`.
|
||||
fn update_fields(s: &mut Session) -> Result<(), Failure> {
|
||||
if let Err(e) = s.run("references.updateFields", &json!({})) {
|
||||
if matches!(e, CmdError::Disabled(_)) {
|
||||
eprintln!("wordcraft-cli: warning: {e}");
|
||||
} else {
|
||||
return Err(engine_error(e));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `convert <in> <out>`: `rest` is the command line after `convert`, `open` opens the input.
|
||||
fn run_convert(rest: &[String], open: impl Fn(&str, Option<&str>) -> Result<Session, Failure>) -> Result<(), Failure> {
|
||||
validate_options("convert", rest, CONVERT_OPTIONS)?;
|
||||
@@ -473,7 +498,7 @@ fn run_convert(rest: &[String], open: impl Fn(&str, Option<&str>) -> Result<Sess
|
||||
let password = password_from(rest)?;
|
||||
let mut s = open(&i, password.as_deref())?;
|
||||
if rest.iter().any(|a| a == "--update-fields") {
|
||||
s.run("references.updateFields", &json!({})).map_err(engine_error)?;
|
||||
update_fields(&mut s)?;
|
||||
}
|
||||
let r = if to_png {
|
||||
s.run("file.exportPng", &json!({"path": o, "page": 1, "markup": markup, "strict": strict}))
|
||||
|
||||
@@ -47,3 +47,65 @@ fn convert_refresh_is_opt_in() {
|
||||
}
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
/// Converting a protected document with `--update-fields` skips updating fields with a warning,
|
||||
/// retains the cached field result, and preserves the protection mode on output.
|
||||
#[test]
|
||||
fn convert_protected_document_with_update_fields_skips_with_warning() {
|
||||
let dir = std::env::temp_dir().join(format!("wc-convert-fields-prot-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
|
||||
for (mode, expected_warning) in [("readOnly", "protected"), ("forms", "protected"), ("comments", "comments")] {
|
||||
let mut doc = Document::from_text("Contents\nTarget");
|
||||
doc.para_mut(StoryRef::Body, &Path::top(1)).unwrap().props.page_break_before = Some(true);
|
||||
doc.insert_object(&Pos::body(1, 0), InlineObject::BookmarkStart { name: "target".into() }, &Default::default()).unwrap();
|
||||
doc.insert_object(
|
||||
&Pos::body(0, 0),
|
||||
InlineObject::Field {
|
||||
instr: "PAGEREF target".into(),
|
||||
result: "104".into(),
|
||||
locked: false,
|
||||
dirty: true,
|
||||
code: false,
|
||||
form: String::new(),
|
||||
},
|
||||
&Default::default(),
|
||||
)
|
||||
.unwrap();
|
||||
doc.settings.protection = Some(mode.into());
|
||||
let input = dir.join(format!("in_{mode}.json"));
|
||||
std::fs::write(&input, serde_json::to_vec(&doc).unwrap()).unwrap();
|
||||
let docx = dir.join(format!("in_{mode}.docx"));
|
||||
let make = Command::new(env!("CARGO_BIN_EXE_wordcraft-cli")).arg("convert").arg(&input).arg(&docx).output().unwrap();
|
||||
assert!(make.status.success(), "{}", String::from_utf8_lossy(&make.stderr));
|
||||
|
||||
let output = dir.join(format!("out_{mode}.json"));
|
||||
let _ = std::fs::remove_file(&output);
|
||||
|
||||
let mut cmd = Command::new(env!("CARGO_BIN_EXE_wordcraft-cli"));
|
||||
cmd.arg("convert").arg(&docx).arg(&output).arg("--update-fields");
|
||||
let run = cmd.output().unwrap();
|
||||
let stderr = String::from_utf8_lossy(&run.stderr);
|
||||
assert!(run.status.success(), "exit status was failure for mode {mode}: {stderr}");
|
||||
assert!(stderr.contains("warning:"), "stderr missing warning for mode {mode}: {stderr}");
|
||||
assert!(stderr.contains(expected_warning), "stderr missing '{expected_warning}' for mode {mode}: {stderr}");
|
||||
assert!(output.exists(), "output file was not created for mode {mode}");
|
||||
|
||||
let out: Document = serde_json::from_slice(&std::fs::read(&output).unwrap()).unwrap();
|
||||
let results: Vec<_> = out
|
||||
.para(StoryRef::Body, &Path::top(0))
|
||||
.unwrap()
|
||||
.objects
|
||||
.iter()
|
||||
.filter_map(|o| match o {
|
||||
InlineObject::Field { result, .. } => Some(result.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
// Result should remain "104" (cached) because the field could not be updated under protection
|
||||
assert_eq!(results, vec!["104"], "field result was unexpectedly modified for mode {mode}");
|
||||
assert_eq!(out.settings.protection.as_deref(), Some(mode), "protection mode was not preserved for mode {mode}");
|
||||
}
|
||||
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
@@ -64,6 +64,11 @@ wl-clipboard-rs = "0.9"
|
||||
# Another program's RTF on X11 (`text/rtf`), which arboard doesn't read. Already in the build
|
||||
# through arboard, at this version.
|
||||
x11rb = "0.13"
|
||||
# Check that the libraries winit's X11 backend opens are installed before choosing XWayland (see
|
||||
# `x11_libraries` in main.rs). Both are already in the build through winit's `x11` feature, at
|
||||
# these versions and with this feature.
|
||||
x11-dl = "2.21"
|
||||
xkbcommon-dl = { version = "0.4.2", features = ["x11"] }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
# The upstream default-apps button opens Windows Settings; printing uses craft-print instead.
|
||||
|
||||
@@ -164,7 +164,7 @@ mod platform {
|
||||
use std::io::Read;
|
||||
|
||||
use wl_clipboard_rs::paste::{ClipboardType, MimeType, Seat, get_contents, get_mime_types_ordered};
|
||||
use wordcraft_ui_egui::paste_picture::{self, ClipboardPicture};
|
||||
use wordcraft_ui_egui::paste_picture::ClipboardPicture;
|
||||
|
||||
fn wayland() -> bool {
|
||||
std::env::var_os("WAYLAND_DISPLAY").is_some()
|
||||
@@ -348,6 +348,22 @@ mod platform {
|
||||
}
|
||||
}
|
||||
|
||||
/// The Wayland clipboard's picture, read encoded under the paste's byte cap and refused from
|
||||
/// its header when a paste can't take its size, before anything decodes it. arboard decodes
|
||||
/// and converts the PNG to RGBA with no size limits. `None` when Wayland offers no picture
|
||||
/// (or isn't reachable): arboard is asked then, which also covers X11.
|
||||
pub fn picture() -> Option<ClipboardPicture> {
|
||||
if !wayland() {
|
||||
return None;
|
||||
}
|
||||
let offered = formats();
|
||||
let mime = super::PICTURE_MIMES.into_iter().find(|m| offered.iter().any(|o| o == m))?;
|
||||
Some(match read(mime, wordcraft_ui_egui::paste_picture::MAX_FILE_BYTES) {
|
||||
Some(bytes) => wordcraft_ui_egui::paste_picture::encoded_picture(bytes),
|
||||
None => ClipboardPicture::Unreadable("the clipboard picture is larger than 200 MB, or couldn't be read".into()),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -369,22 +385,6 @@ mod platform {
|
||||
assert!(complete(0, 0, 0));
|
||||
}
|
||||
}
|
||||
|
||||
/// The Wayland clipboard's picture, read encoded under the paste's byte cap and refused from
|
||||
/// its header when a paste can't take its size, before anything decodes it. arboard decodes
|
||||
/// and converts the PNG to RGBA with no size limits. `None` when Wayland offers no picture
|
||||
/// (or isn't reachable): arboard is asked then, which also covers X11.
|
||||
pub fn picture() -> Option<ClipboardPicture> {
|
||||
if !wayland() {
|
||||
return None;
|
||||
}
|
||||
let offered = formats();
|
||||
let mime = super::PICTURE_MIMES.into_iter().find(|m| offered.iter().any(|o| o == m))?;
|
||||
Some(match read(mime, wordcraft_ui_egui::paste_picture::MAX_FILE_BYTES) {
|
||||
Some(bytes) => wordcraft_ui_egui::paste_picture::encoded_picture(bytes),
|
||||
None => ClipboardPicture::Unreadable("the clipboard picture is larger than 200 MB, or couldn't be read".into()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
|
||||
@@ -276,9 +276,16 @@ fn saved_ui() -> Option<UiState> {
|
||||
/// whenever there's an X server: winit has no file drag-and-drop on Wayland, so dropping files from
|
||||
/// the file manager showed a "no" cursor. X11 is only forced with an X server (`DISPLAY`), and
|
||||
/// unforced winit picks Wayland when it's there and X11 otherwise, so a choice that isn't
|
||||
/// available falls back to the other instead of failing to start.
|
||||
/// available falls back to the other instead of failing to start. The X11 libraries are only
|
||||
/// recommended by the packages, so when `x11_libraries` reports some missing (it's asked only when
|
||||
/// X11 would be forced) the app stays on Wayland: forced X11 without them failed to start, and
|
||||
/// winit panicked without libxkbcommon-x11.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
fn uses_x11(setting: wordcraft_ui_egui::DisplayServer, env: impl Fn(&str) -> Option<std::ffi::OsString>) -> (bool, String) {
|
||||
fn uses_x11(
|
||||
setting: wordcraft_ui_egui::DisplayServer,
|
||||
env: impl Fn(&str) -> Option<std::ffi::OsString>,
|
||||
x11_libraries: impl FnOnce() -> Result<(), String>,
|
||||
) -> (bool, String) {
|
||||
use wordcraft_ui_egui::DisplayServer;
|
||||
let set = |name: &str| env(name).is_some_and(|v| !v.is_empty());
|
||||
let (wanted, by) = match env("WORDCRAFT_WAYLAND") {
|
||||
@@ -288,17 +295,39 @@ fn uses_x11(setting: wordcraft_ui_egui::DisplayServer, env: impl Fn(&str) -> Opt
|
||||
Some(_) => (DisplayServer::Wayland, "WORDCRAFT_WAYLAND"),
|
||||
None => (setting, "the setting"),
|
||||
};
|
||||
let x11 = wanted != DisplayServer::Wayland && set("DISPLAY");
|
||||
let missing = if wanted != DisplayServer::Wayland && set("DISPLAY") { x11_libraries().err() } else { None };
|
||||
let x11 = wanted != DisplayServer::Wayland && set("DISPLAY") && missing.is_none();
|
||||
let wayland = set("WAYLAND_DISPLAY") || set("WAYLAND_SOCKET");
|
||||
let runs = if x11 {
|
||||
"X11"
|
||||
} else if set("WAYLAND_DISPLAY") || set("WAYLAND_SOCKET") {
|
||||
} else if wayland {
|
||||
"native Wayland"
|
||||
} else if set("DISPLAY") {
|
||||
"X11 (no Wayland)"
|
||||
} else {
|
||||
"neither (no Wayland or X server found)"
|
||||
};
|
||||
(x11, format!("display: {runs}; {} asks for {}", by, wanted.code()))
|
||||
let mut why = format!("display: {runs}; {} asks for {}", by, wanted.code());
|
||||
if let Some(missing) = missing {
|
||||
why += &format!("; not X11 because its libraries are missing: {missing}");
|
||||
}
|
||||
(x11, why)
|
||||
}
|
||||
|
||||
/// `Err` naming the libraries winit's X11 backend opens at startup that can't be loaded: libX11,
|
||||
/// libXcursor, libX11-xcb (which brings in libxcb), libXi, and libxkbcommon-x11 for the keyboard.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
fn x11_libraries() -> Result<(), String> {
|
||||
use x11_dl::{xcursor::Xcursor, xinput2::XInput2, xlib::Xlib, xlib_xcb::Xlib_xcb};
|
||||
let mut missing: Vec<String> = [Xlib::open().err(), Xcursor::open().err(), Xlib_xcb::open().err(), XInput2::open().err()]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|e| e.to_string())
|
||||
.collect();
|
||||
if xkbcommon_dl::x11::xkbcommon_x11_option().is_none() {
|
||||
missing.push("libxkbcommon-x11.so.0 could not be loaded".into());
|
||||
}
|
||||
if missing.is_empty() { Ok(()) } else { Err(missing.join("; ")) }
|
||||
}
|
||||
|
||||
fn save_prefs(app: &WordApp) {
|
||||
@@ -558,7 +587,7 @@ fn main() -> eframe::Result {
|
||||
}
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
{
|
||||
let (x11, why) = uses_x11(saved.as_ref().map(|ui| ui.display_server).unwrap_or_default(), |name| std::env::var_os(name));
|
||||
let (x11, why) = uses_x11(saved.as_ref().map(|ui| ui.display_server).unwrap_or_default(), |name| std::env::var_os(name), x11_libraries);
|
||||
log::info!("{why}");
|
||||
if x11 {
|
||||
options.event_loop_builder = Some(Box::new(|b| {
|
||||
@@ -690,7 +719,7 @@ mod tests {
|
||||
fn the_display_server_follows_the_variable_then_the_setting() {
|
||||
use wordcraft_ui_egui::DisplayServer::{Auto, Wayland, X11};
|
||||
let x11 = |setting, vars: &[(&str, &str)]| {
|
||||
uses_x11(setting, |name| vars.iter().find(|(k, _)| *k == name).map(|(_, v)| std::ffi::OsString::from(v))).0
|
||||
uses_x11(setting, |name| vars.iter().find(|(k, _)| *k == name).map(|(_, v)| std::ffi::OsString::from(v)), || Ok(())).0
|
||||
};
|
||||
let both = [("DISPLAY", ":0"), ("WAYLAND_DISPLAY", "wayland-0")];
|
||||
assert!(x11(Auto, &both), "Automatic keeps XWayland, for file drag-and-drop");
|
||||
@@ -706,10 +735,40 @@ mod tests {
|
||||
let wayland_only = [("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", "")];
|
||||
assert!(!x11(X11, &wayland_only) && !x11(Auto, &wayland_only));
|
||||
// Without Wayland, Wayland isn't forced either: unforced winit picks X11.
|
||||
let (forced, why) = uses_x11(Wayland, |name| (name == "DISPLAY").then(|| ":0".into()));
|
||||
let (forced, why) = uses_x11(Wayland, |name| (name == "DISPLAY").then(|| ":0".into()), || Ok(()));
|
||||
assert!(!forced && why.contains("X11 (no Wayland)"), "{why}");
|
||||
}
|
||||
|
||||
/// Without the X11 libraries (the packages only recommend them) X11 isn't forced, whatever asks
|
||||
/// for it: forced X11 failed to start, and winit panicked without libxkbcommon-x11. The check
|
||||
/// runs only when X11 would be forced.
|
||||
#[test]
|
||||
fn missing_x11_libraries_leave_the_app_on_wayland() {
|
||||
use wordcraft_ui_egui::DisplayServer::{Auto, Wayland, X11};
|
||||
let both = [("DISPLAY", ":0"), ("WAYLAND_DISPLAY", "wayland-0")];
|
||||
for (setting, variable) in [(Auto, None), (X11, None), (Wayland, Some("0"))] {
|
||||
let env = |name: &str| {
|
||||
both.iter()
|
||||
.find(|(k, _)| *k == name)
|
||||
.map(|(_, v)| (*v).into())
|
||||
.or_else(|| (name == "WORDCRAFT_WAYLAND").then_some(variable).flatten().map(Into::into))
|
||||
};
|
||||
let (x11, why) = uses_x11(setting, env, || Err("libxkbcommon-x11.so.0 could not be loaded".into()));
|
||||
assert!(!x11, "{setting:?} with WORDCRAFT_WAYLAND={variable:?}: {why}");
|
||||
assert!(why.contains("native Wayland") && why.contains("libxkbcommon-x11"), "{why}");
|
||||
}
|
||||
let checked = std::cell::Cell::new(false);
|
||||
let (x11, _) = uses_x11(
|
||||
Wayland,
|
||||
|name| both.iter().find(|(k, _)| *k == name).map(|(_, v)| (*v).into()),
|
||||
|| {
|
||||
checked.set(true);
|
||||
Ok(())
|
||||
},
|
||||
);
|
||||
assert!(!x11 && !checked.get(), "asked for Wayland, the libraries aren't checked");
|
||||
}
|
||||
|
||||
/// A named pipe or a device under such a name is a warning, not a read: the pipe blocked
|
||||
/// start-up until something wrote to it, and `/dev/zero` read until memory ran out.
|
||||
#[test]
|
||||
|
||||
@@ -51,6 +51,11 @@ chrono = { workspace = true, features = ["clock"] }
|
||||
# O_NONBLOCK, so a path swapped for a named pipe can't block `open` (#3666).
|
||||
libc = { workspace = true }
|
||||
|
||||
# A plain fsync where a file system refuses the full flush (`sync_file`, safe_save.rs).
|
||||
# Already in Cargo.lock on Apple targets (craft-atomic), so this adds no new crate.
|
||||
[target.'cfg(target_vendor = "apple")'.dependencies]
|
||||
rustix = { version = "1.1", default-features = false, features = ["std", "fs"] }
|
||||
|
||||
[dev-dependencies]
|
||||
wordcraft-doc = { workspace = true, features = ["test-support"] }
|
||||
encoding_rs = { workspace = true }
|
||||
|
||||
@@ -347,19 +347,24 @@ pub(crate) struct InsertEffect {
|
||||
|
||||
/// Insert typed text at the caret (replacing the selection), with pending formatting and track changes.
|
||||
pub fn type_text(s: &mut Session, text: &str) -> Result<(), CmdError> {
|
||||
insert_text(s, text, true).map(|_| ())
|
||||
insert_text(s, text, true, None).map(|_| ())
|
||||
}
|
||||
|
||||
/// [`type_text`] with the effect the insertion had.
|
||||
pub(crate) fn type_text_effect(s: &mut Session, text: &str) -> Result<InsertEffect, CmdError> {
|
||||
insert_text(s, text, true)
|
||||
insert_text(s, text, true, None)
|
||||
}
|
||||
|
||||
/// Insert pasted plain text like [`type_text`], except that its paragraph separators are text
|
||||
/// being transferred, not Enter key presses: an empty list item between them stays (Enter on
|
||||
/// one would end the list).
|
||||
pub fn paste_text(s: &mut Session, text: &str) -> Result<(), CmdError> {
|
||||
insert_text(s, text, false).map(|_| ())
|
||||
insert_text(s, text, false, None).map(|_| ())
|
||||
}
|
||||
|
||||
/// Insert typed text in a chosen language without losing caret formatting or tracked changes.
|
||||
pub fn type_text_with_lang(s: &mut Session, text: &str, language: Option<&str>) -> Result<(), CmdError> {
|
||||
insert_text(s, text, true, language).map(|_| ())
|
||||
}
|
||||
|
||||
/// Inserts typed or pasted text at the caret: resolves field ends and content-control placeholders,
|
||||
@@ -368,7 +373,7 @@ pub fn paste_text(s: &mut Session, text: &str) -> Result<(), CmdError> {
|
||||
/// text; a one-line control suppresses typed breaks only. It gives an otherwise undirected paragraph
|
||||
/// right-to-left direction when its first strong letter is RTL, and returns whether content or state
|
||||
/// changed.
|
||||
fn insert_text(s: &mut Session, text: &str, enter: bool) -> Result<InsertEffect, CmdError> {
|
||||
fn insert_text(s: &mut Session, text: &str, enter: bool, language: Option<&str>) -> Result<InsertEffect, CmdError> {
|
||||
let mut props = s.typing_props();
|
||||
if s.sel.is_collapsed() {
|
||||
s.sel = crate::Selection::caret(s.doc.past_field_ends(&s.sel.focus));
|
||||
@@ -383,6 +388,9 @@ fn insert_text(s: &mut Session, text: &str, enter: bool) -> Result<InsertEffect,
|
||||
if !enter {
|
||||
props = props.without_symbol();
|
||||
}
|
||||
if let Some(language) = language {
|
||||
props.lang = Some(language.to_string());
|
||||
}
|
||||
let deletion = delete_selection_effect(s)?;
|
||||
// Typed text is new: never deleted, and not a formatting change of the text around it.
|
||||
props.fmt_change = None;
|
||||
|
||||
@@ -5,7 +5,7 @@ use wordcraft_doc::para::{Float, InlineObject, ShapeKind, Wrap};
|
||||
use wordcraft_doc::props::{Align, Border, Borders, CharProps, Rgb, TabAlign, TabStop, TextColor};
|
||||
use wordcraft_doc::{Block, Paragraph, PartKind, Path, Pos, StoryRef, Table, para_block};
|
||||
|
||||
use super::{delete_selection, sel_result, split_para, type_text};
|
||||
use super::{delete_selection, sel_result, split_para, type_text, type_text_with_lang};
|
||||
use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
|
||||
|
||||
mod header_footer;
|
||||
@@ -15,7 +15,9 @@ mod objects;
|
||||
|
||||
pub(crate) use header_footer::saved_copy;
|
||||
use header_footer::{create_hf, edit_hf, header_footer, new_empty_headers, strip_headers};
|
||||
pub use items::{DropCapState, drop_cap_state, format_date, format_date_at, format_date_parts};
|
||||
pub use items::{
|
||||
DropCapState, default_date_picture, drop_cap_state, format_date, format_date_at, format_date_in, format_date_parts, format_date_parts_in,
|
||||
};
|
||||
use items::{cover_page, date_time, drop_cap, drop_cap_off, field, page_number, symbol};
|
||||
use links::{bookmark, delete_bookmark, link, link_targets};
|
||||
pub use links::{bookmark_name_ok, bookmark_range, bookmark_span};
|
||||
@@ -23,6 +25,7 @@ pub use objects::{base64_decode, base64_encode};
|
||||
use objects::{icon, shape, table, text_box};
|
||||
pub(crate) use objects::{image_ext, picture};
|
||||
|
||||
/// Command specifications for the Insert ribbon tab and menu.
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("insert.pageBreak", "Page Break", "Insert › Pages", |s, _| {
|
||||
@@ -113,7 +116,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
s.doc.last_section.footers = Default::default();
|
||||
sel_result(s)
|
||||
}),
|
||||
CommandSpec::new("insert.dateTime", "Date & Time", "Insert › Text", date_time).params(r#"{"format"?: "M/d/yyyy", "update"?: bool}"#),
|
||||
CommandSpec::new("insert.dateTime", "Date & Time", "Insert › Text", date_time).params(r#"{"format"?: "M/d/yyyy", "update"?: bool, "language"?: "en-US|pt-BR"}"#),
|
||||
CommandSpec::new("insert.symbol", "Symbol", "Insert › Symbols", symbol).params(r#"{"char": string, "font"?: family (the symbol's own font)}"#),
|
||||
CommandSpec::new("insert.field", "Field", "Insert › Text › Quick Parts", field).key("Mod+F9").params(r#"{"instr": string, "result"?: string}"#),
|
||||
// Word's Alt+Shift+D / T / P (Control+Shift+… in Word for Mac): DATE, TIME and PAGE fields.
|
||||
|
||||
@@ -1,8 +1,72 @@
|
||||
use super::PartKind;
|
||||
use super::header_footer::hf_part;
|
||||
use super::{
|
||||
Align, Block, CharProps, CmdError, CmdResult, InlineObject, Paragraph, Rgb, Selection, Session, StoryRef, TabAlign, TabStop, TextColor, Value,
|
||||
delete_selection, json, p, para_block, sel_result, type_text,
|
||||
delete_selection, json, p, para_block, sel_result, type_text, type_text_with_lang,
|
||||
};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
fn story_has_page_field(doc: &wordcraft_doc::Document, story: StoryRef, except: Option<usize>) -> bool {
|
||||
let Some(blocks) = doc.story(story) else { return false };
|
||||
let mut seen = BTreeSet::from([story]);
|
||||
let mut pending: Vec<&Block> = blocks.iter().enumerate().filter(|(i, _)| Some(*i) != except).map(|(_, b)| &**b).collect();
|
||||
while let Some(block) = pending.pop() {
|
||||
let para = match block {
|
||||
Block::Para(para) => para,
|
||||
Block::Table(table) => {
|
||||
pending.extend(table.rows.iter().flat_map(|r| &r.cells).flat_map(|c| &c.blocks).map(|b| &**b));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
for object in ¶.objects {
|
||||
if matches!(object, InlineObject::Field { instr, .. } | InlineObject::FieldStart { instr, .. } if instr.split_whitespace().next().is_some_and(|name| name.eq_ignore_ascii_case("PAGE")))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
for id in object.text_boxes() {
|
||||
if let Some(part) = doc.parts.get(&id).filter(|part| part.kind == PartKind::TextBox)
|
||||
&& seen.insert(StoryRef::Part(id))
|
||||
{
|
||||
pending.extend(part.blocks.iter().map(|b| &**b));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn page_number_paragraph(style: &str, align: Align, x_of_y: bool) -> Result<Paragraph, CmdError> {
|
||||
let field = InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false, dirty: false, code: false, form: String::new() };
|
||||
let mut para = Paragraph::new().styled(style);
|
||||
para.props.align = Some(align);
|
||||
para.props.tabs = Some(vec![TabStop { pos: 0.0, align: TabAlign::Clear, leader: Default::default() }]);
|
||||
if x_of_y {
|
||||
para.insert_text(0, "Page of ", &CharProps::default())?;
|
||||
para.insert_object(5, field, &CharProps { bold: Some(true), ..Default::default() })?;
|
||||
let end = para.len();
|
||||
para.insert_object(
|
||||
end,
|
||||
InlineObject::Field { instr: "NUMPAGES".into(), result: "1".into(), locked: false, dirty: false, code: false, form: String::new() },
|
||||
&CharProps { bold: Some(true), ..Default::default() },
|
||||
)?;
|
||||
} else {
|
||||
para.insert_object(0, field, &CharProps::default())?;
|
||||
}
|
||||
Ok(para)
|
||||
}
|
||||
|
||||
// Only cached display text is transferable; instructions, flags and formatting remain part of ownership.
|
||||
fn copy_page_number_results(to: &mut Paragraph, from: &Paragraph) {
|
||||
for object in &mut to.objects {
|
||||
let InlineObject::Field { instr, result, .. } = object else { continue };
|
||||
if let Some(cached) = from.objects.iter().find_map(|object| match object {
|
||||
InlineObject::Field { instr: source, result, .. } if source.as_str() == instr.as_str() => Some(result),
|
||||
_ => None,
|
||||
}) {
|
||||
result.clone_from(cached);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn page_number(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let position = p::str(v, "position").unwrap_or("bottom");
|
||||
@@ -22,30 +86,86 @@ pub(super) fn page_number(s: &mut Session, v: &Value) -> CmdResult {
|
||||
}
|
||||
let header = position == "top";
|
||||
let id = hf_part(s, header);
|
||||
let mut para = Paragraph::new().styled(if header { "Header" } else { "Footer" });
|
||||
para.props.align = Some(align);
|
||||
para.props.tabs = Some(vec![TabStop { pos: 0.0, align: TabAlign::Clear, leader: Default::default() }]);
|
||||
if x_of_y {
|
||||
para.insert_text(0, "Page of ", &CharProps::default())?;
|
||||
para.insert_object(5, field, &CharProps { bold: Some(true), ..Default::default() })?;
|
||||
let end = para.len();
|
||||
para.insert_object(
|
||||
end,
|
||||
InlineObject::Field { instr: "NUMPAGES".into(), result: "1".into(), locked: false, dirty: false, code: false, form: String::new() },
|
||||
&CharProps { bold: Some(true), ..Default::default() },
|
||||
)?;
|
||||
} else {
|
||||
para.insert_object(0, field, &CharProps::default())?;
|
||||
let story = StoryRef::Part(id);
|
||||
let style = if header { "Header" } else { "Footer" };
|
||||
let mut para = page_number_paragraph(style, align, x_of_y)?;
|
||||
// Replace only a top-level paragraph matching our generated structure. User text,
|
||||
// field switches/metadata and paragraph/run formatting must not be mistaken for a gallery number.
|
||||
// Cached field results and paragraph-mark formatting survive gallery changes and identical requests.
|
||||
if story_has_page_field(&s.doc, story, None) {
|
||||
let generated = s.doc.story(story).and_then(|blocks| {
|
||||
blocks.iter().enumerate().find_map(|(i, block)| {
|
||||
let Block::Para(existing) = &**block else { return None };
|
||||
let Some(align @ (Align::Left | Align::Center | Align::Right)) = existing.props.align else { return None };
|
||||
[false, true]
|
||||
.into_iter()
|
||||
.any(|x_of_y| {
|
||||
page_number_paragraph(style, align, x_of_y).is_ok_and(|mut p| {
|
||||
if p.objects.len() != existing.objects.len() {
|
||||
return false;
|
||||
}
|
||||
p.mark = existing.mark.clone();
|
||||
copy_page_number_results(&mut p, existing);
|
||||
*existing == p
|
||||
})
|
||||
})
|
||||
.then_some((i, existing))
|
||||
})
|
||||
});
|
||||
// More than one PAGE makes ownership ambiguous, including in tables or anchored text
|
||||
// boxes. The traversal visits each story once so cycles cannot hang insertion.
|
||||
if let Some((i, existing)) = generated
|
||||
&& !story_has_page_field(&s.doc, story, Some(i))
|
||||
{
|
||||
para.mark = existing.mark.clone();
|
||||
copy_page_number_results(&mut para, existing);
|
||||
let replacement = para_block(para);
|
||||
if s.doc.story(story).and_then(|blocks| blocks.get(i)) != Some(&replacement) {
|
||||
let block = s.doc.story_mut(story)?.get_mut(i).ok_or_else(|| CmdError::Failed("page-number paragraph is missing".into()))?;
|
||||
*block = replacement;
|
||||
}
|
||||
}
|
||||
return Ok(json!({"story": id}));
|
||||
}
|
||||
s.doc.set_story(StoryRef::Part(id), vec![para_block(para)])?;
|
||||
let blocks = s.doc.story_mut(story)?;
|
||||
// A DOCX round trip can add paragraph-mark formatting to the otherwise untouched
|
||||
// Header/Footer placeholder. Reuse it without losing that formatting or adding a blank line.
|
||||
if let [block] = blocks.as_slice()
|
||||
&& let Block::Para(existing) = &**block
|
||||
{
|
||||
let mut placeholder = Paragraph::new().styled(style);
|
||||
placeholder.mark = existing.mark.clone();
|
||||
if *existing == placeholder {
|
||||
para.mark = existing.mark.clone();
|
||||
blocks.clear();
|
||||
}
|
||||
}
|
||||
blocks.push(para_block(para));
|
||||
Ok(json!({"story": id}))
|
||||
}
|
||||
|
||||
/// Insert date and time at the caret or selection, formatted with a Word picture or default format.
|
||||
pub(super) fn date_time(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let fmt = p::str(v, "format").unwrap_or("M/d/yyyy");
|
||||
let text = format_date(fmt);
|
||||
let language = p::str(v, "language");
|
||||
if language.is_some_and(|l| !matches!(l, "en-US" | "pt-BR")) {
|
||||
return Err(CmdError::Params("language must be en-US or pt-BR".into()));
|
||||
}
|
||||
let mut props = s.typing_props();
|
||||
if let Some(lang) = language {
|
||||
props.lang = Some(lang.to_string());
|
||||
}
|
||||
// Resolve the same run language used by refresh_date_fields on subsequent updates.
|
||||
// Resolving language from the selection start ordered().0 is intentional for the fixed-text path
|
||||
// so the typed text takes the character/paragraph styling where the insertion begins.
|
||||
let effective_language = s.doc.para_at(&s.sel.ordered().0).and_then(|p| s.doc.styles.resolve_char(p.props.style.as_deref(), &props).lang);
|
||||
// A missing picture follows the selected or inherited language, just like a DATE field
|
||||
// without a \\@ switch; an explicitly supplied picture is never overridden.
|
||||
let fmt = p::str(v, "format").unwrap_or_else(|| default_date_picture(effective_language.as_deref()));
|
||||
if fmt.is_empty() || fmt.len() > 120 || fmt.contains('"') || fmt.contains('\\') {
|
||||
return Err(CmdError::Params("invalid date picture".into()));
|
||||
}
|
||||
let text = format_date_in(fmt, effective_language.as_deref());
|
||||
if p::bool(v, "update").unwrap_or(false) {
|
||||
let props = s.typing_props();
|
||||
let at = delete_selection(s)?;
|
||||
let end = s.doc.insert_object(
|
||||
&at,
|
||||
@@ -54,32 +174,87 @@ pub(super) fn date_time(s: &mut Session, v: &Value) -> CmdResult {
|
||||
)?;
|
||||
s.sel = Selection::caret(end);
|
||||
} else {
|
||||
type_text(s, &text)?;
|
||||
type_text_with_lang(s, &text, language)?;
|
||||
}
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// Format today's date with a Word picture (`M/d/yyyy`, `MMMM d, yyyy`, `yyyy-MM-dd`, `dddd`…).
|
||||
/// Format today's date with a Word picture (`M/d/yyyy`, `MMMM d, yyyy`, `yyyy-MM-dd`, `dddd`…),
|
||||
/// in English.
|
||||
pub fn format_date(fmt: &str) -> String {
|
||||
// Word shows the computer's local date and time (#1707), not UTC's.
|
||||
format_date_in(fmt, None)
|
||||
}
|
||||
|
||||
/// Format today's date with a Word picture, month and weekday names in the language `lang` (a
|
||||
/// tag such as `pt-BR`; English when `None` or a language without names here).
|
||||
pub fn format_date_in(fmt: &str, lang: Option<&str>) -> String {
|
||||
let now = crate::cmd::now_unix();
|
||||
format_date_at(fmt, now, crate::cmd::local_offset_secs(now))
|
||||
format_date_at_in(fmt, lang, now, crate::cmd::local_offset_secs(now))
|
||||
}
|
||||
|
||||
/// Format the instant `unix` (seconds since the epoch, UTC) with a Word picture, in the zone
|
||||
/// `offset_secs` east of UTC.
|
||||
pub fn format_date_at(fmt: &str, unix: u64, offset_secs: i64) -> String {
|
||||
format_date_at_in(fmt, None, unix, offset_secs)
|
||||
}
|
||||
|
||||
/// Format the instant `unix` (seconds since the epoch, UTC) with a Word picture, in the zone
|
||||
/// `offset_secs` east of UTC, with language `lang`.
|
||||
pub fn format_date_at_in(fmt: &str, lang: Option<&str>, unix: u64, offset_secs: i64) -> String {
|
||||
let (y, m, d, hms, days) = crate::cmd::local_civil(unix, offset_secs);
|
||||
format_date_parts(fmt, y, m as usize, d, hms, days)
|
||||
format_date_parts_in(fmt, lang, y, m as usize, d, hms, days)
|
||||
}
|
||||
|
||||
/// Month and weekday names of a language, as Word writes them in date fields: full names, the
|
||||
/// three-letter forms (`MMM`, `ddd`) being their first three letters, without a full stop.
|
||||
/// Weekdays start on Thursday (1970-01-01).
|
||||
struct DateNames {
|
||||
/// Full month names (January through December).
|
||||
months: [&'static str; 12],
|
||||
/// Weekday names starting on Thursday (1970-01-01).
|
||||
days: [&'static str; 7],
|
||||
/// The picture of a `DATE` field without `\@`.
|
||||
default: &'static str,
|
||||
}
|
||||
|
||||
const ENGLISH: DateNames = DateNames {
|
||||
months: ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"],
|
||||
days: ["Thursday", "Friday", "Saturday", "Sunday", "Monday", "Tuesday", "Wednesday"],
|
||||
default: "M/d/yyyy",
|
||||
};
|
||||
|
||||
/// Portuguese (observed in Word for Mac with a pt-BR document): lower-case names, `MMM` = `out`,
|
||||
/// `ddd` = `qui`, and `dd/MM/yyyy` for a field without a picture.
|
||||
const PORTUGUESE: DateNames = DateNames {
|
||||
months: ["janeiro", "fevereiro", "março", "abril", "maio", "junho", "julho", "agosto", "setembro", "outubro", "novembro", "dezembro"],
|
||||
days: ["quinta-feira", "sexta-feira", "sábado", "domingo", "segunda-feira", "terça-feira", "quarta-feira"],
|
||||
default: "dd/MM/yyyy",
|
||||
};
|
||||
|
||||
/// Choose month and weekday names according to the primary language subtag.
|
||||
fn date_names(lang: Option<&str>) -> &'static DateNames {
|
||||
let primary = lang.and_then(|l| l.trim().split(['-', '_']).next()).unwrap_or("");
|
||||
if primary.eq_ignore_ascii_case("pt") { &PORTUGUESE } else { &ENGLISH }
|
||||
}
|
||||
|
||||
/// The picture Word gives a `DATE` field that has no `\@` switch, in the field's language.
|
||||
pub fn default_date_picture(lang: Option<&str>) -> &'static str {
|
||||
date_names(lang).default
|
||||
}
|
||||
|
||||
/// Format a date and time with a Word picture; `days_since` (days since 1970-01-01) gives the weekday.
|
||||
pub fn format_date_parts(fmt: &str, y: i64, m: usize, d: u32, (hh, mm, ss): (u32, u32, u32), days_since: i64) -> String {
|
||||
const MONTHS: [&str; 12] =
|
||||
["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
|
||||
const DAYS: [&str; 7] = ["Thursday", "Friday", "Saturday", "Sunday", "Monday", "Tuesday", "Wednesday"];
|
||||
let month = MONTHS.get(m.saturating_sub(1)).copied().unwrap_or("January");
|
||||
let day = DAYS.get(days_since.rem_euclid(7) as usize).copied().unwrap_or("Monday");
|
||||
pub fn format_date_parts(fmt: &str, y: i64, m: usize, d: u32, hms: (u32, u32, u32), days_since: i64) -> String {
|
||||
format_date_parts_in(fmt, None, y, m, d, hms, days_since)
|
||||
}
|
||||
|
||||
/// [`format_date_parts`] with month and weekday names in `lang`. Text between single quotes is
|
||||
/// copied as it is (`'de'`), while doubled apostrophes emit one literal quote anywhere.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn format_date_parts_in(fmt: &str, lang: Option<&str>, y: i64, m: usize, d: u32, (hh, mm, ss): (u32, u32, u32), days_since: i64) -> String {
|
||||
let names = date_names(lang);
|
||||
let month = names.months.get(m.saturating_sub(1)).copied().unwrap_or(names.months[0]);
|
||||
let day = names.days.get(days_since.rem_euclid(7) as usize).copied().unwrap_or(names.days[0]);
|
||||
let three = |s: &str| -> String { s.chars().take(3).collect() };
|
||||
let mut out = String::new();
|
||||
let chars: Vec<char> = fmt.chars().collect();
|
||||
let mut i = 0;
|
||||
@@ -121,11 +296,11 @@ pub fn format_date_parts(fmt: &str, y: i64, m: usize, d: u32, (hh, mm, ss): (u32
|
||||
('y' | 'Y', 4..) => out.push_str(&format!("{y:04}")),
|
||||
('y' | 'Y', _) => out.push_str(&format!("{:02}", y % 100)),
|
||||
('M', 4..) => out.push_str(month),
|
||||
('M', 3) => out.push_str(month.get(..3).unwrap_or(month)),
|
||||
('M', 3) => out.push_str(&three(month)),
|
||||
('M', 2) => out.push_str(&format!("{m:02}")),
|
||||
('M', 1) => out.push_str(&m.to_string()),
|
||||
('d' | 'D', 4..) => out.push_str(day),
|
||||
('d' | 'D', 3) => out.push_str(day.get(..3).unwrap_or(day)),
|
||||
('d' | 'D', 3) => out.push_str(&three(day)),
|
||||
('d' | 'D', 2) => out.push_str(&format!("{d:02}")),
|
||||
('d' | 'D', 1) => out.push_str(&d.to_string()),
|
||||
('H', 2..) => out.push_str(&format!("{hh:02}")),
|
||||
@@ -337,3 +512,79 @@ pub fn drop_cap_state(s: &Session) -> DropCapState {
|
||||
text_font,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod date_time_locale_tests {
|
||||
use super::*;
|
||||
/// A DATE field without an explicit language initially uses the paragraph's and document's
|
||||
/// inherited pt-BR language, just as the subsequent refresh will.
|
||||
#[test]
|
||||
fn date_field_inherits_document_default_locale_on_first_render() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
std::sync::Arc::make_mut(&mut s.doc.styles).default_chr.lang = Some("pt-BR".into());
|
||||
s.run(
|
||||
"insert.dateTime",
|
||||
&json!({
|
||||
"format": "d 'de' MMMM 'de' yyyy", "update": true
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let path = s.doc.para_paths(StoryRef::Body).remove(0);
|
||||
let p = s.doc.para(StoryRef::Body, &path).unwrap();
|
||||
let InlineObject::Field { result, .. } = &p.objects[0] else { panic!("expected DATE field") };
|
||||
let expected = format_date_in("d 'de' MMMM 'de' yyyy", Some("pt-BR"));
|
||||
assert_eq!(*result, expected);
|
||||
assert!(result.contains(" de "));
|
||||
let before = result.clone();
|
||||
crate::cmd::references::refresh_date_fields(&mut s.doc);
|
||||
let p = s.doc.para(StoryRef::Body, &path).unwrap();
|
||||
let InlineObject::Field { result, .. } = &p.objects[0] else { panic!("expected DATE field") };
|
||||
assert_eq!(*result, before, "refresh must not switch languages");
|
||||
}
|
||||
|
||||
/// Fixed date text uses the selected pt-BR editing language, without dropping the
|
||||
/// caret's other character formatting or making the insertion refreshable.
|
||||
#[test]
|
||||
fn fixed_date_text_keeps_selected_language() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.run(
|
||||
"insert.dateTime",
|
||||
&json!({
|
||||
"format": "d 'de' MMMM 'de' yyyy", "language": "pt-BR", "update": false
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
let path = s.doc.para_paths(StoryRef::Body).remove(0);
|
||||
let p = s.doc.para(StoryRef::Body, &path).unwrap();
|
||||
assert_eq!(p.props_of_char(0).lang.as_deref(), Some("pt-BR"));
|
||||
assert_eq!(p.plain_text(), format_date_in("d 'de' MMMM 'de' yyyy", Some("pt-BR")));
|
||||
assert!(p.objects.iter().all(|o| !matches!(o, InlineObject::Field { .. })));
|
||||
}
|
||||
|
||||
/// Word's doubled apostrophe escapes one literal quote both before and inside a quoted run.
|
||||
/// The existing three-character abbreviations must preserve accented Portuguese characters.
|
||||
#[test]
|
||||
fn word_date_picture_doubled_apostrophes_and_accented_abbreviations() {
|
||||
let format = |pic, day| format_date_parts_in(pic, Some("pt-BR"), 2026, 10, day, (12, 0, 0), 2);
|
||||
assert_eq!(format("d''MM", 20), "20'10");
|
||||
assert_eq!(format("d 'd''água' MMMM", 20), "20 d'água outubro");
|
||||
assert_eq!(format("ddd", 20), "sáb");
|
||||
assert_eq!(format("MMM", 20), "out");
|
||||
assert_eq!(format("MMMM", 20), "outubro");
|
||||
}
|
||||
|
||||
/// A pt-BR dynamic field keeps its result after DOCX round-trip and refresh.
|
||||
#[test]
|
||||
fn pt_br_date_time_dialog_inserts_dynamic_field() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.run("insert.dateTime", &json!({"format": "d 'de' MMMM 'de' yyyy", "language": "pt-BR", "update": true})).unwrap();
|
||||
let bytes = wordcraft_docx::write(&s.doc).unwrap();
|
||||
let mut doc = wordcraft_docx::read(&bytes).unwrap();
|
||||
crate::cmd::references::refresh_date_fields(&mut doc);
|
||||
let paths = doc.para_paths(StoryRef::Body);
|
||||
let p = doc.para(StoryRef::Body, &paths[0]).unwrap();
|
||||
let InlineObject::Field { result, .. } = &p.objects[0] else { panic!("expected field") };
|
||||
assert_eq!(result, &format_date_in("d 'de' MMMM 'de' yyyy", Some("pt-BR")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -378,8 +378,9 @@ pub fn update_fields_in(s: &mut Session, all: bool) -> Result<(), CmdError> {
|
||||
let name = wordcraft_layout::fields::field_name(instr);
|
||||
match name.as_str() {
|
||||
"DATE" | "TIME" | "CREATEDATE" | "SAVEDATE" | "PRINTDATE" => {
|
||||
let pic = date_picture(instr).unwrap_or_else(|| if name == "TIME" { "h:mm am/pm".into() } else { "M/d/yyyy".into() });
|
||||
updates.push((k, text_result(instr, crate::cmd::insert::format_date(&pic))));
|
||||
let lang = field_lang(&s.doc, p, k);
|
||||
let pic = date_picture(instr).unwrap_or_else(|| default_picture(&name, lang.as_deref()));
|
||||
updates.push((k, text_result(instr, crate::cmd::insert::format_date_in(&pic, lang.as_deref()))));
|
||||
}
|
||||
"REF" | "PAGEREF" => {
|
||||
let f = FieldCode::parse(instr);
|
||||
@@ -505,8 +506,9 @@ pub fn refresh_date_fields(doc: &mut wordcraft_doc::Document) {
|
||||
continue;
|
||||
}
|
||||
// Word's default picture when the code carries no `\@` switch.
|
||||
let pic = date_picture(instr).unwrap_or_else(|| if name == "TIME" { "h:mm am/pm".into() } else { "M/d/yyyy".into() });
|
||||
updates.push((k, text_result(instr, crate::cmd::insert::format_date(&pic))));
|
||||
let lang = field_lang(doc, p, k);
|
||||
let pic = date_picture(instr).unwrap_or_else(|| default_picture(&name, lang.as_deref()));
|
||||
updates.push((k, text_result(instr, crate::cmd::insert::format_date_in(&pic, lang.as_deref()))));
|
||||
}
|
||||
if updates.is_empty() {
|
||||
continue;
|
||||
@@ -522,6 +524,16 @@ pub fn refresh_date_fields(doc: &mut wordcraft_doc::Document) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a field object's language through the paragraph and document defaults.
|
||||
fn field_lang(doc: &wordcraft_doc::Document, p: &wordcraft_doc::Paragraph, k: usize) -> Option<String> {
|
||||
let off = p.object_offsets().get(k).copied()?;
|
||||
doc.styles.resolve_char(p.props.style.as_deref(), p.props_of_char(off)).lang
|
||||
}
|
||||
|
||||
/// Word's default date or time picture when a field instruction carries no `\@` picture switch.
|
||||
fn default_picture(name: &str, lang: Option<&str>) -> String {
|
||||
if name == "TIME" { "h:mm am/pm".into() } else { crate::cmd::insert::default_date_picture(lang).into() }
|
||||
}
|
||||
/// Most names SET fields give values.
|
||||
const MAX_SET: usize = 10_000;
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ use super::{
|
||||
use wordcraft_doc::para::OBJ;
|
||||
|
||||
/// Compare and Combine take the same settings.
|
||||
pub(super) const COMPARE_PARAMS: &str = r#"{"path"?: string (revised document), "text"?: string (revised text), "original"?: string (original document; default: this one), "author"?: string (label changes with), "caseChanges"?: bool, "whiteSpace"?: bool, "formatting"?: bool (default true: differences count), "level"?: "word|character", "showIn"?: "original|revised|new" (revised/new: an untitled document), "moves"|"comments"|"tables"|"headersFooters"|"footnotes"|"textBoxes"|"fields"?: bool (accepted; not honoured yet except tables, always compared)}"#;
|
||||
pub(super) const COMPARE_PARAMS: &str = r#"{"path"?: string (revised document), "text"?: string (revised text), "original"?: string (original document; default: this one), "author"?: string (label changes with), "caseChanges"?: bool, "whiteSpace"?: bool, "formatting"?: bool (default true: differences count), "level"?: "word|character" (character: paragraphs over 2000 characters fall back to word; see characterFallback/notHonoured), "showIn"?: "original|revised|new" (revised/new: an untitled document), "moves"|"comments"|"tables"|"headersFooters"|"footnotes"|"textBoxes"|"fields"?: bool (accepted; not honoured yet except tables, always compared)}"#;
|
||||
|
||||
/// What Compare / Combine compares (the More >> settings of Word's dialog).
|
||||
struct CompareOptions {
|
||||
@@ -27,6 +27,15 @@ impl CompareOptions {
|
||||
}
|
||||
}
|
||||
|
||||
/// Context and tracking counters for diffing paragraph contents.
|
||||
struct DiffCtx<'a> {
|
||||
ins: u32,
|
||||
del: u32,
|
||||
opts: &'a CompareOptions,
|
||||
budget: &'a mut u64,
|
||||
char_fallbacks: &'a mut usize,
|
||||
}
|
||||
|
||||
/// Changed paragraphs longer than this (in characters) are compared word by word even at
|
||||
/// character level, so the comparison table stays small.
|
||||
const MAX_CHAR_DIFF: usize = 2000;
|
||||
@@ -35,6 +44,9 @@ const MAX_CHAR_DIFF: usize = 2000;
|
||||
/// result shows the differences as tracked insertions, deletions and formatting changes, labelled
|
||||
/// with `author`. Options Word offers that the comparison can't honour yet (moves, comments,
|
||||
/// headers and footers, footnotes, text boxes, fields) are accepted and listed in the result.
|
||||
/// When character-level comparison is requested (`"level": "character"`) but any paragraph exceeds
|
||||
/// [`MAX_CHAR_DIFF`] characters, it falls back to word-level diff for that paragraph, surfaces the
|
||||
/// fallback count in `"characterFallback"`, and includes `"level"` in `"notHonoured"`.
|
||||
pub(super) fn compare(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let revised = if let Some(t) = p::str(v, "text") {
|
||||
Document::from_text(t)
|
||||
@@ -61,7 +73,7 @@ pub(super) fn compare(s: &mut Session, v: &Value) -> CmdResult {
|
||||
formatting: p::bool(v, "formatting").unwrap_or(true),
|
||||
characters: matches!(level, "character" | "char"),
|
||||
};
|
||||
let not_honoured: Vec<&str> =
|
||||
let mut not_honoured: Vec<&str> =
|
||||
["moves", "comments", "headersFooters", "footnotes", "textBoxes", "fields"].into_iter().filter(|k| v.get(*k).is_some()).collect();
|
||||
let author = p::str(v, "author").map(str::trim).filter(|a| !a.is_empty()).map(str::to_string).unwrap_or_else(|| s.author.clone());
|
||||
let date = crate::cmd::now_iso();
|
||||
@@ -83,7 +95,9 @@ pub(super) fn compare(s: &mut Session, v: &Value) -> CmdResult {
|
||||
Block::Table(_) => format!("\u{0}table\u{0}{}", opts.key(&block_text(b, 0))),
|
||||
};
|
||||
let mut budget = MAX_COMPARE_CELLS;
|
||||
let mut char_fallbacks = 0usize;
|
||||
let ops = lcs_ops(&old.iter().map(key).collect::<Vec<_>>(), &new.iter().map(key).collect::<Vec<_>>(), &mut budget);
|
||||
let mut ctx = DiffCtx { ins, del, opts: &opts, budget: &mut budget, char_fallbacks: &mut char_fallbacks };
|
||||
let mut out: Vec<Arc<Block>> = Vec::new();
|
||||
let (mut i, mut j) = (0usize, 0usize);
|
||||
let mut changes = 0usize;
|
||||
@@ -97,7 +111,7 @@ pub(super) fn compare(s: &mut Session, v: &Value) -> CmdResult {
|
||||
if !same_objects(&s.doc, pa, pb) {
|
||||
// Same text, but objects (text boxes, pictures…) differ: diffed, so the
|
||||
// original's objects aren't lost.
|
||||
out.push(para_block(word_diff(&s.doc, pa, pb, ins, del, &opts, &mut budget)));
|
||||
out.push(para_block(word_diff(&s.doc, pa, pb, &mut ctx)));
|
||||
changes += 1;
|
||||
i += 1;
|
||||
j += 1;
|
||||
@@ -129,9 +143,7 @@ pub(super) fn compare(s: &mut Session, v: &Value) -> CmdResult {
|
||||
for x in 0..dels.max(inss) {
|
||||
let (a, b) = (if x < dels { old.get(i + x) } else { None }, if x < inss { new.get(j + x) } else { None });
|
||||
match (a.map(|x| &**x), b.map(|x| &**x)) {
|
||||
(Some(Block::Para(pa)), Some(Block::Para(pb))) => {
|
||||
out.push(para_block(word_diff(&s.doc, pa, pb, ins, del, &opts, &mut budget)))
|
||||
}
|
||||
(Some(Block::Para(pa)), Some(Block::Para(pb))) => out.push(para_block(word_diff(&s.doc, pa, pb, &mut ctx))),
|
||||
_ => {
|
||||
if let Some(x) = a {
|
||||
out.push(Arc::new(mark_block(x, &|c| c.del = Some(del), 0)));
|
||||
@@ -162,8 +174,21 @@ pub(super) fn compare(s: &mut Session, v: &Value) -> CmdResult {
|
||||
// Compare creates an untitled result from the current document.
|
||||
s.clear_versions();
|
||||
}
|
||||
if char_fallbacks > 0 {
|
||||
log::warn!(
|
||||
"compare: {char_fallbacks} paragraph(s) exceeded character diff limit of {MAX_CHAR_DIFF}; fell back from character to word level diff"
|
||||
);
|
||||
if !not_honoured.contains(&"level") {
|
||||
not_honoured.push("level");
|
||||
}
|
||||
}
|
||||
s.sel = Selection::caret(s.doc.start_of(StoryRef::Body));
|
||||
Ok(json!({"changes": changes, "author": author, "notHonoured": not_honoured}))
|
||||
Ok(json!({
|
||||
"changes": changes,
|
||||
"author": author,
|
||||
"notHonoured": not_honoured,
|
||||
"characterFallback": char_fallbacks,
|
||||
}))
|
||||
}
|
||||
|
||||
/// A block's text (a table's cells in order), for matching blocks.
|
||||
@@ -177,13 +202,14 @@ fn block_text(b: &Block, depth: usize) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// A copy of the block with every run (in tables, every cell's) changed by `f`.
|
||||
/// Returns a copy of `b` with all text runs and paragraph marks (or nested table cells) updated by `f`.
|
||||
fn mark_block(b: &Block, f: &dyn Fn(&mut CharProps), depth: usize) -> Block {
|
||||
match b {
|
||||
Block::Para(p) => {
|
||||
let mut p = p.clone();
|
||||
let len = p.len();
|
||||
let _ = p.format(0, len, f);
|
||||
f(&mut p.mark);
|
||||
Block::Para(p)
|
||||
}
|
||||
Block::Table(t) => {
|
||||
@@ -205,59 +231,96 @@ pub(super) enum Op {
|
||||
Ins,
|
||||
}
|
||||
|
||||
/// Longest sequence the comparison table is built for; the rest of a longer sequence is
|
||||
/// compared as deleted and inserted, never dropped.
|
||||
/// Longest sequence window per LCS comparison table chunk; sequences longer than this are
|
||||
/// aligned in successive chunked windows rather than truncated, preserving tail alignment.
|
||||
const MAX_LCS: usize = 4000;
|
||||
|
||||
/// Comparison table cells one compare may fill over all its tables (blocks and every changed
|
||||
/// paragraph pair); once spent, the remaining middles are compared as deleted and inserted.
|
||||
const MAX_COMPARE_CELLS: u64 = 50_000_000;
|
||||
|
||||
/// LCS edit script between two sequences: the common start and end are matched first, then the
|
||||
/// middle (bounded size). The middle's table is charged to `budget`; when it doesn't fit, the
|
||||
/// middle is deleted and inserted whole.
|
||||
/// Computes the longest common subsequence (LCS) edit script (`Op::Same`, `Op::Del`, `Op::Ins`)
|
||||
/// between sequences `a` and `b`.
|
||||
///
|
||||
/// Strips common prefix and suffix first, then iterates over windows capped at [`MAX_LCS`],
|
||||
/// charging comparison cells against `budget`. Between windows, intermediate common prefixes
|
||||
/// are trimmed to re-anchor alignment without table allocations. When the remaining cells
|
||||
/// exceed `budget`, the unaligned tail degrades honestly as whole deletions and insertions.
|
||||
pub(super) fn lcs_ops<T: PartialEq>(a: &[T], b: &[T], budget: &mut u64) -> Vec<Op> {
|
||||
let pre = a.iter().zip(b).take_while(|(x, y)| x == y).count();
|
||||
let (ra, rb) = (a.get(pre..).unwrap_or_default(), b.get(pre..).unwrap_or_default());
|
||||
let suf = ra.iter().rev().zip(rb.iter().rev()).take_while(|(x, y)| x == y).count();
|
||||
let (a, b) = (ra.get(..ra.len() - suf).unwrap_or_default(), rb.get(..rb.len() - suf).unwrap_or_default());
|
||||
let mut rem_a = ra.get(..ra.len().saturating_sub(suf)).unwrap_or_default();
|
||||
let mut rem_b = rb.get(..rb.len().saturating_sub(suf)).unwrap_or_default();
|
||||
let mut ops = vec![Op::Same; pre];
|
||||
let (mut n, mut m) = (a.len().min(MAX_LCS), b.len().min(MAX_LCS));
|
||||
let cells = (n as u64).saturating_mul(m as u64);
|
||||
if cells > *budget {
|
||||
(n, m) = (0, 0);
|
||||
} else {
|
||||
*budget -= cells;
|
||||
}
|
||||
let mut t = vec![vec![0u32; m + 1]; n + 1];
|
||||
for i in (0..n).rev() {
|
||||
for j in (0..m).rev() {
|
||||
let v = if a.get(i) == b.get(j) {
|
||||
t.get(i + 1).and_then(|r| r.get(j + 1)).copied().unwrap_or(0) + 1
|
||||
} else {
|
||||
t.get(i + 1).and_then(|r| r.get(j)).copied().unwrap_or(0).max(t.get(i).and_then(|r| r.get(j + 1)).copied().unwrap_or(0))
|
||||
};
|
||||
if let Some(c) = t.get_mut(i).and_then(|r| r.get_mut(j)) {
|
||||
*c = v;
|
||||
|
||||
while !rem_a.is_empty() && !rem_b.is_empty() {
|
||||
let p = rem_a.iter().zip(rem_b).take_while(|(x, y)| x == y).count();
|
||||
if p > 0 {
|
||||
ops.extend(std::iter::repeat_n(Op::Same, p));
|
||||
rem_a = rem_a.get(p..).unwrap_or_default();
|
||||
rem_b = rem_b.get(p..).unwrap_or_default();
|
||||
if rem_a.is_empty() || rem_b.is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let (mut i, mut j) = (0, 0);
|
||||
while i < n && j < m {
|
||||
if a.get(i) == b.get(j) {
|
||||
ops.push(Op::Same);
|
||||
i += 1;
|
||||
j += 1;
|
||||
} else if t.get(i + 1).and_then(|r| r.get(j)) >= t.get(i).and_then(|r| r.get(j + 1)) {
|
||||
ops.push(Op::Del);
|
||||
i += 1;
|
||||
} else {
|
||||
ops.push(Op::Ins);
|
||||
j += 1;
|
||||
|
||||
let n = rem_a.len().min(MAX_LCS);
|
||||
let m = rem_b.len().min(MAX_LCS);
|
||||
let cells = (n as u64).saturating_mul(m as u64);
|
||||
if cells > *budget || n == 0 || m == 0 {
|
||||
break;
|
||||
}
|
||||
*budget = budget.saturating_sub(cells);
|
||||
|
||||
let stride = m.saturating_add(1);
|
||||
let table_len = n.saturating_add(1).saturating_mul(stride);
|
||||
let mut t = vec![0u32; table_len];
|
||||
|
||||
for i in (0..n).rev() {
|
||||
let row = i.saturating_mul(stride);
|
||||
let next_row = i.saturating_add(1).saturating_mul(stride);
|
||||
for j in (0..m).rev() {
|
||||
let v = if rem_a.get(i) == rem_b.get(j) {
|
||||
t.get(next_row.saturating_add(j).saturating_add(1)).copied().unwrap_or(0).saturating_add(1)
|
||||
} else {
|
||||
let down = t.get(next_row.saturating_add(j)).copied().unwrap_or(0);
|
||||
let right = t.get(row.saturating_add(j).saturating_add(1)).copied().unwrap_or(0);
|
||||
down.max(right)
|
||||
};
|
||||
if let Some(c) = t.get_mut(row.saturating_add(j)) {
|
||||
*c = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let (mut i, mut j) = (0usize, 0usize);
|
||||
while i < n && j < m {
|
||||
let row = i.saturating_mul(stride);
|
||||
let next_row = i.saturating_add(1).saturating_mul(stride);
|
||||
if rem_a.get(i) == rem_b.get(j) {
|
||||
ops.push(Op::Same);
|
||||
i = i.saturating_add(1);
|
||||
j = j.saturating_add(1);
|
||||
} else {
|
||||
let down = t.get(next_row.saturating_add(j)).copied().unwrap_or(0);
|
||||
let right = t.get(row.saturating_add(j).saturating_add(1)).copied().unwrap_or(0);
|
||||
if down >= right {
|
||||
ops.push(Op::Del);
|
||||
i = i.saturating_add(1);
|
||||
} else {
|
||||
ops.push(Op::Ins);
|
||||
j = j.saturating_add(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rem_a = rem_a.get(i..).unwrap_or_default();
|
||||
rem_b = rem_b.get(j..).unwrap_or_default();
|
||||
}
|
||||
ops.extend(std::iter::repeat_n(Op::Del, a.len() - i));
|
||||
ops.extend(std::iter::repeat_n(Op::Ins, b.len() - j));
|
||||
|
||||
ops.extend(std::iter::repeat_n(Op::Del, rem_a.len()));
|
||||
ops.extend(std::iter::repeat_n(Op::Ins, rem_b.len()));
|
||||
ops.extend(std::iter::repeat_n(Op::Same, suf));
|
||||
ops
|
||||
}
|
||||
@@ -410,15 +473,39 @@ fn put(p: &mut Paragraph, t: Option<&Tok<'_>>, props: &CharProps) {
|
||||
}
|
||||
}
|
||||
|
||||
fn word_diff(doc: &Document, a: &Paragraph, b: &Paragraph, ins: u32, del: u32, opts: &CompareOptions, budget: &mut u64) -> Paragraph {
|
||||
let (at, bt) = (a.plain_text(), b.plain_text());
|
||||
let chars = opts.characters && at.chars().count().max(bt.chars().count()) <= MAX_CHAR_DIFF;
|
||||
/// Returns `true` if paragraph `p` contains more characters than `limit`.
|
||||
///
|
||||
/// Bounded to scan at most `limit + 1` characters without allocating unless inline objects
|
||||
/// are present, avoiding O(n) traversal of large paragraphs.
|
||||
fn para_exceeds_char_limit(p: &Paragraph, limit: usize) -> bool {
|
||||
if p.objects.is_empty() {
|
||||
p.text.chars().take(limit.saturating_add(1)).count() > limit
|
||||
} else {
|
||||
p.plain_text().chars().take(limit.saturating_add(1)).count() > limit
|
||||
}
|
||||
}
|
||||
|
||||
/// Diffs two matched paragraphs either at word granularity or at character granularity according
|
||||
/// to `ctx.opts.characters`. When character-level diff is requested but either paragraph exceeds
|
||||
/// [`MAX_CHAR_DIFF`] characters, falls back to word-level diff and increments `ctx.char_fallbacks`
|
||||
/// so callers can discover the fallback.
|
||||
fn word_diff(doc: &Document, a: &Paragraph, b: &Paragraph, ctx: &mut DiffCtx<'_>) -> Paragraph {
|
||||
let chars = if ctx.opts.characters {
|
||||
if para_exceeds_char_limit(a, MAX_CHAR_DIFF) || para_exceeds_char_limit(b, MAX_CHAR_DIFF) {
|
||||
*ctx.char_fallbacks = ctx.char_fallbacks.saturating_add(1);
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
} else {
|
||||
false
|
||||
};
|
||||
let (ta, tb) = (tokens(a, chars), tokens(b, chars));
|
||||
let key = |t: &Tok<'_>| match t {
|
||||
Tok::Text(t) => Key::Text(opts.key(t)),
|
||||
Tok::Text(t) => Key::Text(ctx.opts.key(t)),
|
||||
Tok::Obj(o) => obj_key(doc, o),
|
||||
};
|
||||
let ops = lcs_ops(&ta.iter().map(key).collect::<Vec<_>>(), &tb.iter().map(key).collect::<Vec<_>>(), budget);
|
||||
let ops = lcs_ops(&ta.iter().map(key).collect::<Vec<_>>(), &tb.iter().map(key).collect::<Vec<_>>(), ctx.budget);
|
||||
// Only at character granularity: a matched whole word almost never happens by coincidence,
|
||||
// but a matched single character very often does (digits, punctuation), so only there does
|
||||
// a short interior match need folding back into its surrounding edit.
|
||||
@@ -436,11 +523,11 @@ fn word_diff(doc: &Document, a: &Paragraph, b: &Paragraph, ins: u32, del: u32, o
|
||||
j += 1;
|
||||
}
|
||||
Op::Del => {
|
||||
put(&mut p, ta.get(i), &CharProps { del: Some(del), ..base.clone() });
|
||||
put(&mut p, ta.get(i), &CharProps { del: Some(ctx.del), ..base.clone() });
|
||||
i += 1;
|
||||
}
|
||||
Op::Ins => {
|
||||
put(&mut p, tb.get(j), &CharProps { ins: Some(ins), ..base.clone() });
|
||||
put(&mut p, tb.get(j), &CharProps { ins: Some(ctx.ins), ..base.clone() });
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +66,309 @@ fn collapse_noise_matches_merges_short_interior_runs_but_keeps_prefix_suffix() {
|
||||
assert_eq!(collapse_noise_matches(&ops), ops);
|
||||
}
|
||||
|
||||
/// Verifies that `lcs_ops` chunks sequence alignment across multiple `MAX_LCS` windows,
|
||||
/// replaying operations to prove exact reconstruction and asserting specific edit indices.
|
||||
#[test]
|
||||
fn lcs_ops_chunks_across_multiple_windows() {
|
||||
// Two sequences of 9,000 elements (spanning three 4,000-element MAX_LCS windows).
|
||||
// Element at 50 moves to 8,500. Substitutions at 0 and 8,999 zero the outer prefix/suffix.
|
||||
let total = 9000;
|
||||
let mut a: Vec<usize> = (0..total).collect();
|
||||
let mut b = a.clone();
|
||||
a[0] = 99999;
|
||||
b[0] = 88888;
|
||||
a[total - 1] = 77777;
|
||||
b[total - 1] = 66666;
|
||||
let moved = a[50];
|
||||
b.remove(50);
|
||||
b.insert(8500, moved);
|
||||
|
||||
let mut budget = u64::MAX;
|
||||
let ops = lcs_ops(&a, &b, &mut budget);
|
||||
|
||||
// Replay ops against `a` and `b` to verify exact sequence reconstruction and order.
|
||||
let mut ia = 0;
|
||||
let mut ib = 0;
|
||||
let mut dels_seen = Vec::new();
|
||||
let mut inss_seen = Vec::new();
|
||||
let mut reconstructed = Vec::new();
|
||||
|
||||
for op in &ops {
|
||||
match op {
|
||||
Op::Same => {
|
||||
assert_eq!(a.get(ia), b.get(ib), "Same op must match elements at ia={ia}, ib={ib}");
|
||||
if let Some(&val) = a.get(ia) {
|
||||
reconstructed.push(val);
|
||||
}
|
||||
ia += 1;
|
||||
ib += 1;
|
||||
}
|
||||
Op::Del => {
|
||||
if let Some(&val) = a.get(ia) {
|
||||
dels_seen.push((ia, val));
|
||||
}
|
||||
ia += 1;
|
||||
}
|
||||
Op::Ins => {
|
||||
if let Some(&val) = b.get(ib) {
|
||||
inss_seen.push((ib, val));
|
||||
reconstructed.push(val);
|
||||
}
|
||||
ib += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(ia, a.len());
|
||||
assert_eq!(ib, b.len());
|
||||
assert_eq!(reconstructed, b);
|
||||
// Truth: 2 substitutions (indices 0 and 8999), 1 deletion at 50, 1 insertion at 8500.
|
||||
assert_eq!(dels_seen, vec![(0, 99999), (50, 50), (8999, 77777)]);
|
||||
assert_eq!(inss_seen, vec![(0, 88888), (8500, 50), (8999, 66666)]);
|
||||
}
|
||||
|
||||
/// Verifies that `lcs_ops` forces successive full 4,000-element windows without
|
||||
/// intermediate prefix trimming collapsing them, asserting exact budget exhaustion.
|
||||
#[test]
|
||||
fn lcs_ops_forces_successive_full_windows_without_intermediate_prefix_trim() {
|
||||
// Force successive full 4,000-element windows without intermediate prefix trimming
|
||||
// collapsing the middle into fewer tables.
|
||||
// Length: 9,000 elements across 3 windows (4,000 + 4,000 + 1,000).
|
||||
// Boundary substitutions at indices 0, 4000, 8000, and 8999 guarantee rem_a[0] != rem_b[0]
|
||||
// at the beginning of each window, forcing intermediate prefix trim to find p == 0 so
|
||||
// all three full DP tables must execute.
|
||||
let total = 9000;
|
||||
let mut a: Vec<usize> = (0..total).collect();
|
||||
let mut b = a.clone();
|
||||
a[0] = 11111;
|
||||
b[0] = 22222;
|
||||
a[4000] = 33333;
|
||||
b[4000] = 44444;
|
||||
a[8000] = 55555;
|
||||
b[8000] = 66666;
|
||||
a[total - 1] = 77777;
|
||||
b[total - 1] = 88888;
|
||||
|
||||
// Budget for 3 full windows:
|
||||
// Window 1: 4,000 * 4,000 = 16,000,000 cells
|
||||
// Window 2: 4,000 * 4,000 = 16,000,000 cells
|
||||
// Window 3: 1,000 * 1,000 = 1,000,000 cells
|
||||
// Total cells = 33,000,000.
|
||||
let mut budget = 33_000_000u64;
|
||||
let ops = lcs_ops(&a, &b, &mut budget);
|
||||
|
||||
// Exact budget is fully exhausted (budget == 0), proving all 3 full DP tables were built.
|
||||
assert_eq!(budget, 0, "all 3 successive windows must execute their DP tables without collapsing");
|
||||
|
||||
let mut ia = 0;
|
||||
let mut ib = 0;
|
||||
let mut reconstructed = Vec::new();
|
||||
let mut dels_seen = Vec::new();
|
||||
let mut inss_seen = Vec::new();
|
||||
|
||||
for op in &ops {
|
||||
match op {
|
||||
Op::Same => {
|
||||
assert_eq!(a.get(ia), b.get(ib));
|
||||
if let Some(&val) = a.get(ia) {
|
||||
reconstructed.push(val);
|
||||
}
|
||||
ia += 1;
|
||||
ib += 1;
|
||||
}
|
||||
Op::Del => {
|
||||
if let Some(&val) = a.get(ia) {
|
||||
dels_seen.push((ia, val));
|
||||
}
|
||||
ia += 1;
|
||||
}
|
||||
Op::Ins => {
|
||||
if let Some(&val) = b.get(ib) {
|
||||
inss_seen.push((ib, val));
|
||||
reconstructed.push(val);
|
||||
}
|
||||
ib += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(ia, a.len());
|
||||
assert_eq!(ib, b.len());
|
||||
assert_eq!(reconstructed, b);
|
||||
assert_eq!(dels_seen, vec![(0, 11111), (4000, 33333), (8000, 55555), (8999, 77777)]);
|
||||
assert_eq!(inss_seen, vec![(0, 22222), (4000, 44444), (8000, 66666), (8999, 88888)]);
|
||||
|
||||
// Also verify completely disjoint sequences across multiple windows with zero common items.
|
||||
// Window 1: 4,000 * 4,000 = 16,000,000 cells (deletes 4,000 of a_diff)
|
||||
// Window 2: 4,000 * 4,000 = 16,000,000 cells (deletes next 4,000 of a_diff)
|
||||
// Window 3: 1,000 of a_diff * 4,000 of b_diff = 4,000,000 cells
|
||||
// Total cells = 36,000,000.
|
||||
let a_diff: Vec<usize> = (0..9000).collect();
|
||||
let b_diff: Vec<usize> = (10_000..19_000).collect();
|
||||
let mut budget_diff = 36_000_000u64;
|
||||
let ops_diff = lcs_ops(&a_diff, &b_diff, &mut budget_diff);
|
||||
assert_eq!(budget_diff, 0, "all 3 windows in disjoint comparison must exhaust exact 36,000,000 budget");
|
||||
assert_eq!(ops_diff.iter().filter(|o| **o == Op::Del).count(), 9000);
|
||||
assert_eq!(ops_diff.iter().filter(|o| **o == Op::Ins).count(), 9000);
|
||||
assert_eq!(ops_diff.iter().filter(|o| **o == Op::Same).count(), 0);
|
||||
}
|
||||
|
||||
/// Tests `lcs_ops` budget behavior across edge cases: zero initial budget, exact-fit allocation,
|
||||
/// one-short exhaustion, and multi-window exhaustion after the first chunk.
|
||||
#[test]
|
||||
fn lcs_ops_exhausted_budget_falls_back_whole() {
|
||||
// 1. Budget 0 before any table: middle degrades whole, budget remains 0
|
||||
let a = vec![1, 2, 3, 4, 5];
|
||||
let b = vec![1, 8, 9, 5];
|
||||
let mut budget = 0;
|
||||
let ops = lcs_ops(&a, &b, &mut budget);
|
||||
assert_eq!(ops, vec![Op::Same, Op::Del, Op::Del, Op::Del, Op::Ins, Op::Ins, Op::Same]);
|
||||
assert_eq!(budget, 0, "budget must remain 0");
|
||||
|
||||
// 2. Exact-fit: cells == budget
|
||||
// Middle has 3 elements in a (2, 3, 4) and 2 in b (8, 9) -> 3 * 2 = 6 cells.
|
||||
let mut budget_exact = 6;
|
||||
let ops_exact = lcs_ops(&a, &b, &mut budget_exact);
|
||||
assert_eq!(budget_exact, 0, "budget exactly exhausted to 0");
|
||||
assert_eq!(ops_exact, vec![Op::Same, Op::Del, Op::Del, Op::Del, Op::Ins, Op::Ins, Op::Same]);
|
||||
|
||||
// Exact-fit with shared elements in middle
|
||||
let a_mid = vec![1, 2, 3, 5];
|
||||
let b_mid = vec![1, 2, 9, 5];
|
||||
// Middle is (3) vs (9): 1 * 1 = 1 cell
|
||||
let mut budget_mid = 1;
|
||||
let ops_mid = lcs_ops(&a_mid, &b_mid, &mut budget_mid);
|
||||
assert_eq!(budget_mid, 0);
|
||||
assert_eq!(ops_mid, vec![Op::Same, Op::Same, Op::Del, Op::Ins, Op::Same]);
|
||||
|
||||
// 3. One-short: budget == cells - 1
|
||||
let mut budget_short = 5;
|
||||
let ops_short = lcs_ops(&a, &b, &mut budget_short);
|
||||
assert_eq!(budget_short, 5, "one-short budget must not be charged");
|
||||
assert_eq!(ops_short, vec![Op::Same, Op::Del, Op::Del, Op::Del, Op::Ins, Op::Ins, Op::Same]);
|
||||
|
||||
// 4. Multi-window: Exhaustion after the first window
|
||||
// Two windows: window 1 has 4,000 elements (16,000,000 cells), window 2 has 500 elements (250,000 cells).
|
||||
let mut a_multi: Vec<usize> = (0..4500).collect();
|
||||
let mut b_multi = a_multi.clone();
|
||||
a_multi[0] = 99999;
|
||||
b_multi[0] = 88888;
|
||||
a_multi[4000] = 77777;
|
||||
b_multi[4000] = 66666;
|
||||
a_multi[4499] = 55555;
|
||||
b_multi[4499] = 44444;
|
||||
|
||||
// Case 4A: budget fits window 1 exactly (16,000,000), exhausts for window 2
|
||||
let mut budget_w1_only = 16_000_000u64;
|
||||
let ops_w1_only = lcs_ops(&a_multi, &b_multi, &mut budget_w1_only);
|
||||
assert_eq!(budget_w1_only, 0, "budget spent on window 1, 0 remaining for window 2");
|
||||
// Window 1 aligned via DP table, window 2 fell back to 500 Dels and 500 Inss
|
||||
let tail_dels = ops_w1_only.iter().rev().skip(500).take(500).filter(|o| **o == Op::Del).count();
|
||||
let tail_inss = ops_w1_only.iter().rev().take(500).filter(|o| **o == Op::Ins).count();
|
||||
assert_eq!(tail_dels, 500);
|
||||
assert_eq!(tail_inss, 500);
|
||||
|
||||
// Case 4B: budget fits both windows exactly: 16,250,000
|
||||
let mut budget_both = 16_250_000u64;
|
||||
let ops_both = lcs_ops(&a_multi, &b_multi, &mut budget_both);
|
||||
assert_eq!(budget_both, 0, "both windows charged exactly to 0");
|
||||
assert_eq!(ops_both.iter().filter(|o| **o == Op::Del).count(), 3);
|
||||
assert_eq!(ops_both.iter().filter(|o| **o == Op::Ins).count(), 3);
|
||||
|
||||
// Case 4C: one short on window 2: 16_249_999
|
||||
let mut budget_both_short = 16_249_999u64;
|
||||
let _ = lcs_ops(&a_multi, &b_multi, &mut budget_both_short);
|
||||
assert_eq!(budget_both_short, 249_999, "remaining budget must reflect window 1 spent and window 2 unspent");
|
||||
}
|
||||
|
||||
/// Tests boundary conditions for `lcs_ops`: empty slices, one-sided empty, exact `MAX_LCS`,
|
||||
/// `MAX_LCS + 1`, `2 * MAX_LCS`, and prefix-trim loop break paths.
|
||||
#[test]
|
||||
fn lcs_ops_boundary_cases_and_break_paths() {
|
||||
// 1. Empty sequences
|
||||
let mut b = u64::MAX;
|
||||
assert_eq!(lcs_ops::<i32>(&[], &[], &mut b), Vec::<Op>::new());
|
||||
|
||||
// 2. One-sided empty
|
||||
let items = [1, 2, 3];
|
||||
assert_eq!(lcs_ops(&[], &items, &mut b), vec![Op::Ins, Op::Ins, Op::Ins]);
|
||||
assert_eq!(lcs_ops(&items, &[], &mut b), vec![Op::Del, Op::Del, Op::Del]);
|
||||
|
||||
// 3. Exact MAX_LCS (4,000)
|
||||
let len_max = 4000;
|
||||
let mut a_max: Vec<usize> = (0..len_max).collect();
|
||||
let mut b_max = a_max.clone();
|
||||
a_max[0] = 99999;
|
||||
b_max[0] = 88888;
|
||||
a_max[len_max - 1] = 77777;
|
||||
b_max[len_max - 1] = 66666;
|
||||
let mut b_budget = u64::MAX;
|
||||
let ops_max = lcs_ops(&a_max, &b_max, &mut b_budget);
|
||||
assert_eq!(ops_max.len(), len_max + 2);
|
||||
assert_eq!(ops_max.iter().filter(|o| **o == Op::Same).count(), len_max - 2);
|
||||
|
||||
// 4. Exact MAX_LCS + 1 (4,001)
|
||||
let len_plus1 = 4001;
|
||||
let mut a_p1: Vec<usize> = (0..len_plus1).collect();
|
||||
let mut b_p1 = a_p1.clone();
|
||||
a_p1[0] = 99999;
|
||||
b_p1[0] = 88888;
|
||||
a_p1[len_plus1 - 1] = 77777;
|
||||
b_p1[len_plus1 - 1] = 66666;
|
||||
let mut b_budget2 = u64::MAX;
|
||||
let ops_p1 = lcs_ops(&a_p1, &b_p1, &mut b_budget2);
|
||||
assert_eq!(ops_p1.len(), len_plus1 + 2);
|
||||
assert_eq!(ops_p1.iter().filter(|o| **o == Op::Same).count(), len_plus1 - 2);
|
||||
|
||||
// 5. Exact 2 * MAX_LCS (8,000)
|
||||
let len_2m = 8000;
|
||||
let mut a_2m: Vec<usize> = (0..len_2m).collect();
|
||||
let mut b_2m = a_2m.clone();
|
||||
a_2m[0] = 11111;
|
||||
b_2m[0] = 22222;
|
||||
a_2m[4000] = 33333;
|
||||
b_2m[4000] = 44444;
|
||||
a_2m[len_2m - 1] = 55555;
|
||||
b_2m[len_2m - 1] = 66666;
|
||||
let mut b_budget3 = u64::MAX;
|
||||
let ops_2m = lcs_ops(&a_2m, &b_2m, &mut b_budget3);
|
||||
assert_eq!(ops_2m.iter().filter(|o| **o == Op::Del).count(), 3);
|
||||
assert_eq!(ops_2m.iter().filter(|o| **o == Op::Ins).count(), 3);
|
||||
assert_eq!(ops_2m.iter().filter(|o| **o == Op::Same).count(), len_2m - 3);
|
||||
|
||||
// 6. Prefix-trim break paths inside the while loop:
|
||||
// Case 6A: rem_a becomes empty after matching p elements
|
||||
let mut a_break_a: Vec<usize> = (0..4005).collect();
|
||||
let mut b_break_a: Vec<usize> = (0..4008).collect();
|
||||
a_break_a[0] = 99999;
|
||||
b_break_a[0] = 88888;
|
||||
let mut b_break1 = u64::MAX;
|
||||
let ops_break_a = lcs_ops(&a_break_a, &b_break_a, &mut b_break1);
|
||||
assert_eq!(ops_break_a.iter().filter(|o| **o == Op::Del).count(), 1);
|
||||
assert_eq!(ops_break_a.iter().filter(|o| **o == Op::Ins).count(), 4);
|
||||
|
||||
// Case 6B: rem_b becomes empty after matching p elements
|
||||
let mut a_break_b: Vec<usize> = (0..4008).collect();
|
||||
let mut b_break_b: Vec<usize> = (0..4005).collect();
|
||||
a_break_b[0] = 99999;
|
||||
b_break_b[0] = 88888;
|
||||
let mut b_break2 = u64::MAX;
|
||||
let ops_break_b = lcs_ops(&a_break_b, &b_break_b, &mut b_break2);
|
||||
assert_eq!(ops_break_b.iter().filter(|o| **o == Op::Del).count(), 4);
|
||||
assert_eq!(ops_break_b.iter().filter(|o| **o == Op::Ins).count(), 1);
|
||||
|
||||
// Case 6C: both become empty simultaneously after matching p elements
|
||||
let mut a_break_both: Vec<usize> = (0..4005).collect();
|
||||
let mut b_break_both: Vec<usize> = (0..4005).collect();
|
||||
a_break_both[0] = 99999;
|
||||
b_break_both[0] = 88888;
|
||||
let mut b_break3 = u64::MAX;
|
||||
let ops_break_both = lcs_ops(&a_break_both, &b_break_both, &mut b_break3);
|
||||
assert_eq!(ops_break_both.iter().filter(|o| **o == Op::Del).count(), 1);
|
||||
assert_eq!(ops_break_both.iter().filter(|o| **o == Op::Ins).count(), 1);
|
||||
assert_eq!(ops_break_both.iter().filter(|o| **o == Op::Same).count(), 4004);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compare_at_word_level_still_matches_a_whole_unchanged_word() {
|
||||
// `collapse_noise_matches` only runs for character-level compares (wired up in
|
||||
@@ -80,6 +383,145 @@ fn compare_at_word_level_still_matches_a_whole_unchanged_word() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compare_character_level_surfaces_fallback_on_long_paragraphs() {
|
||||
// Under MAX_CHAR_DIFF (2,000 characters), character-level diff engages normally:
|
||||
// sub-word fragment is marked, characterFallback is 0, and notHonoured does not contain "level".
|
||||
let original_short = "The quick brown fox jumps over the lazy dog. ".repeat(10);
|
||||
let revised_short = original_short.replacen("jumps", "jumped", 1);
|
||||
assert!(original_short.chars().count() < 2000);
|
||||
let mut s = Session::new(Document::from_text(&original_short));
|
||||
let r = s.run("review.compare", &json!({"text": revised_short, "level": "character"})).unwrap();
|
||||
assert_eq!(r["characterFallback"].as_u64(), Some(0));
|
||||
let not_honoured = r["notHonoured"].as_array().expect("notHonoured array");
|
||||
assert!(!not_honoured.iter().any(|v| v == "level"), "level should be honoured for short paragraphs");
|
||||
let p = s.doc.body[0].as_para().expect("body paragraph");
|
||||
let deleted: String = p.run_ranges().filter(|(_, c)| c.del.is_some()).map(|(r, _)| p.text[r].to_string()).collect();
|
||||
let inserted: String = p.run_ranges().filter(|(_, c)| c.ins.is_some()).map(|(r, _)| p.text[r].to_string()).collect();
|
||||
assert_eq!(deleted, "s", "character level marks only the sub-word suffix for short paragraphs");
|
||||
assert_eq!(inserted, "ed", "character level marks only the sub-word suffix for short paragraphs");
|
||||
|
||||
// Above MAX_CHAR_DIFF (2,000 characters), character-level diff falls back to word-level diff.
|
||||
// The fallback is surfaced in result metadata: characterFallback is incremented and
|
||||
// notHonoured reports "level" rather than silently degrading without signal.
|
||||
// In the output document, the fallback marks the whole word rather than sub-word fragments.
|
||||
let original_long = "The quick brown fox jumps over the lazy dog. ".repeat(50);
|
||||
let revised_long = original_long.replacen("jumps", "jumped", 1);
|
||||
assert!(original_long.chars().count() > 2000);
|
||||
let mut s = Session::new(Document::from_text(&original_long));
|
||||
let r = s.run("review.compare", &json!({"text": revised_long, "level": "character"})).unwrap();
|
||||
assert_eq!(r["characterFallback"].as_u64(), Some(1));
|
||||
let not_honoured = r["notHonoured"].as_array().expect("notHonoured array");
|
||||
assert!(not_honoured.iter().any(|v| v == "level"), "level should be listed in notHonoured when fallback occurs");
|
||||
let p = s.doc.body[0].as_para().expect("body paragraph");
|
||||
let deleted: String = p.run_ranges().filter(|(_, c)| c.del.is_some()).map(|(r, _)| p.text[r].to_string()).collect();
|
||||
let inserted: String = p.run_ranges().filter(|(_, c)| c.ins.is_some()).map(|(r, _)| p.text[r].to_string()).collect();
|
||||
assert_eq!(deleted, "jumps ", "long paragraph fallback marks the whole word");
|
||||
assert_eq!(inserted, "jumped ", "long paragraph fallback marks the whole word");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compare_character_level_exact_boundaries_at_limit() {
|
||||
let prefix = "The quick brown fox jumps over the lazy dog. ".repeat(44); // 1980 chars
|
||||
|
||||
// Exactly MAX_CHAR_DIFF (2000) characters: character-level diff engages without fallback.
|
||||
let original_2000 = format!("{prefix}1234567890123456789x");
|
||||
let revised_2000 = format!("{prefix}1234567890123456789y");
|
||||
assert_eq!(original_2000.chars().count(), 2000);
|
||||
assert_eq!(revised_2000.chars().count(), 2000);
|
||||
let mut s = Session::new(Document::from_text(&original_2000));
|
||||
let r = s.run("review.compare", &json!({"text": revised_2000, "level": "character"})).unwrap();
|
||||
assert_eq!(r["characterFallback"].as_u64(), Some(0));
|
||||
let not_honoured = r["notHonoured"].as_array().expect("notHonoured array");
|
||||
assert!(!not_honoured.iter().any(|v| v == "level"), "2000 chars should not fall back");
|
||||
let p = s.doc.body[0].as_para().expect("body paragraph");
|
||||
let deleted: String = p.run_ranges().filter(|(_, c)| c.del.is_some()).map(|(r, _)| p.text[r].to_string()).collect();
|
||||
let inserted: String = p.run_ranges().filter(|(_, c)| c.ins.is_some()).map(|(r, _)| p.text[r].to_string()).collect();
|
||||
assert_eq!(deleted, "x", "character level marks single changed character at exactly 2000 chars");
|
||||
assert_eq!(inserted, "y", "character level marks single changed character at exactly 2000 chars");
|
||||
|
||||
// Exactly 2001 characters (MAX_CHAR_DIFF + 1): triggers fallback to word-level diff.
|
||||
let original_2001 = format!("{prefix}12345678901234567890x");
|
||||
let revised_2001 = format!("{prefix}12345678901234567890y");
|
||||
assert_eq!(original_2001.chars().count(), 2001);
|
||||
assert_eq!(revised_2001.chars().count(), 2001);
|
||||
let mut s = Session::new(Document::from_text(&original_2001));
|
||||
let r = s.run("review.compare", &json!({"text": revised_2001, "level": "character"})).unwrap();
|
||||
assert_eq!(r["characterFallback"].as_u64(), Some(1));
|
||||
let not_honoured = r["notHonoured"].as_array().expect("notHonoured array");
|
||||
assert!(not_honoured.iter().any(|v| v == "level"), "2001 chars must trigger fallback");
|
||||
let p = s.doc.body[0].as_para().expect("body paragraph");
|
||||
let deleted: String = p.run_ranges().filter(|(_, c)| c.del.is_some()).map(|(r, _)| p.text[r].to_string()).collect();
|
||||
let inserted: String = p.run_ranges().filter(|(_, c)| c.ins.is_some()).map(|(r, _)| p.text[r].to_string()).collect();
|
||||
assert_eq!(deleted, "12345678901234567890x", "fallback marks whole word at 2001 chars");
|
||||
assert_eq!(inserted, "12345678901234567890y", "fallback marks whole word at 2001 chars");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compare_character_level_multiple_paragraphs_and_asymmetric_lengths() {
|
||||
let short_a = "Short original text here.";
|
||||
let short_b = "Short revised text here.";
|
||||
let long_a = "The quick brown fox jumps over the lazy dog. ".repeat(50);
|
||||
let long_b = long_a.replacen("jumps", "jumped", 1);
|
||||
let long_c = "Another long paragraph that exceeds two thousand chars. ".repeat(40);
|
||||
let long_d = long_c.replacen("Another", "Different", 1);
|
||||
|
||||
// Document with 1 short modified paragraph and 1 long modified paragraph: fallback count is 1.
|
||||
let doc_mixed_a = format!("{short_a}\n{long_a}");
|
||||
let doc_mixed_b = format!("{short_b}\n{long_b}");
|
||||
let mut s = Session::new(Document::from_text(&doc_mixed_a));
|
||||
let r = s.run("review.compare", &json!({"text": doc_mixed_b, "level": "character"})).unwrap();
|
||||
assert_eq!(r["characterFallback"].as_u64(), Some(1));
|
||||
|
||||
// Document with 2 long modified paragraphs: fallback count is 2.
|
||||
let doc_2long_a = format!("{long_a}\n{long_c}");
|
||||
let doc_2long_b = format!("{long_b}\n{long_d}");
|
||||
let mut s = Session::new(Document::from_text(&doc_2long_a));
|
||||
let r = s.run("review.compare", &json!({"text": doc_2long_b, "level": "character"})).unwrap();
|
||||
assert_eq!(r["characterFallback"].as_u64(), Some(2));
|
||||
|
||||
// Asymmetric lengths: original short, revised long (> 2000 chars) -> fallback count is 1.
|
||||
let mut s = Session::new(Document::from_text(short_a));
|
||||
let r = s.run("review.compare", &json!({"text": long_b, "level": "character"})).unwrap();
|
||||
assert_eq!(r["characterFallback"].as_u64(), Some(1));
|
||||
|
||||
// Asymmetric lengths: original long (> 2000 chars), revised short -> fallback count is 1.
|
||||
let mut s = Session::new(Document::from_text(&long_a));
|
||||
let r = s.run("review.compare", &json!({"text": short_b, "level": "character"})).unwrap();
|
||||
assert_eq!(r["characterFallback"].as_u64(), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compare_word_level_default_and_explicit_never_flag_fallback() {
|
||||
let original_long = "The quick brown fox jumps over the lazy dog. ".repeat(50);
|
||||
let revised_long = original_long.replacen("jumps", "jumped", 1);
|
||||
assert!(original_long.chars().count() > 2000);
|
||||
|
||||
// Explicit "level": "word" on long paragraph: characterFallback is 0 and "level" not in notHonoured.
|
||||
let mut s = Session::new(Document::from_text(&original_long));
|
||||
let r = s.run("review.compare", &json!({"text": revised_long, "level": "word"})).unwrap();
|
||||
assert_eq!(r["characterFallback"].as_u64(), Some(0));
|
||||
let not_honoured = r["notHonoured"].as_array().expect("notHonoured array");
|
||||
assert!(!not_honoured.iter().any(|v| v == "level"));
|
||||
let p = s.doc.body[0].as_para().expect("body paragraph");
|
||||
let deleted: String = p.run_ranges().filter(|(_, c)| c.del.is_some()).map(|(r, _)| p.text[r].to_string()).collect();
|
||||
let inserted: String = p.run_ranges().filter(|(_, c)| c.ins.is_some()).map(|(r, _)| p.text[r].to_string()).collect();
|
||||
assert_eq!(deleted, "jumps ");
|
||||
assert_eq!(inserted, "jumped ");
|
||||
|
||||
// Default (no "level" param provided): behaves as word level, characterFallback is 0 and "level" not in notHonoured.
|
||||
let mut s = Session::new(Document::from_text(&original_long));
|
||||
let r = s.run("review.compare", &json!({"text": revised_long})).unwrap();
|
||||
assert_eq!(r["characterFallback"].as_u64(), Some(0));
|
||||
let not_honoured = r["notHonoured"].as_array().expect("notHonoured array");
|
||||
assert!(!not_honoured.iter().any(|v| v == "level"));
|
||||
let p = s.doc.body[0].as_para().expect("body paragraph");
|
||||
let deleted: String = p.run_ranges().filter(|(_, c)| c.del.is_some()).map(|(r, _)| p.text[r].to_string()).collect();
|
||||
let inserted: String = p.run_ranges().filter(|(_, c)| c.ins.is_some()).map(|(r, _)| p.text[r].to_string()).collect();
|
||||
assert_eq!(deleted, "jumps ");
|
||||
assert_eq!(inserted, "jumped ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compare_keeps_text_boxes_of_the_original() {
|
||||
// Text boxes are anchored in body paragraphs: one after changed text, one alone in an
|
||||
@@ -193,6 +635,124 @@ fn compare_bounds_total_diff_work() {
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), revised);
|
||||
}
|
||||
|
||||
/// Verifies that moving a paragraph past `MAX_LCS` in a long document does not cascade into false
|
||||
/// revisions in the tail, asserting exact changed block indices, text, and edit types.
|
||||
#[test]
|
||||
fn compare_lcs_window_move_past_cap_does_not_cascade_false_revisions() {
|
||||
// Issue #6110: 4,600 blocks where prefix/suffix trim is zeroed (substitutions at blocks 0
|
||||
// and 4599) and a record moves from index 100 to index 4400 (past MAX_LCS = 4000).
|
||||
// Without chunking/continuation past the window, the tail misaligns and positional pairing
|
||||
// marks ~202 paragraphs instead of the true 4 changes (substitutions at 0 and 4599,
|
||||
// deletion at 100, insertion at 4400).
|
||||
let total = 4600;
|
||||
let mut orig_paras: Vec<String> = (0..total).map(|i| format!("Record {i} has alpha beta gamma payload.")).collect();
|
||||
let mut rev_paras = orig_paras.clone();
|
||||
// Zero out prefix trim: substitution at 0
|
||||
orig_paras[0] = "Record 0 has alpha beta gamma original.".into();
|
||||
rev_paras[0] = "Record 0 has alpha beta gamma revised.".into();
|
||||
// Zero out suffix trim: substitution at 4599
|
||||
orig_paras[total - 1] = format!("Record {} has alpha beta gamma original.", total - 1);
|
||||
rev_paras[total - 1] = format!("Record {} has alpha beta gamma revised.", total - 1);
|
||||
// Move record 100 to 4400
|
||||
let moved = orig_paras[100].clone();
|
||||
rev_paras.remove(100);
|
||||
rev_paras.insert(4400, moved);
|
||||
|
||||
let original = orig_paras.join("\n");
|
||||
let revised = rev_paras.join("\n");
|
||||
|
||||
let mut s = Session::new(Document::from_text(&original));
|
||||
let r = s.run("review.compare", &json!({"text": revised})).unwrap();
|
||||
|
||||
// The true number of changed paragraphs is 4 (the two substitutions, the move's deletion,
|
||||
// and the move's insertion). On unpatched code, window truncation misaligned the tail
|
||||
// and marked ~202 paragraphs.
|
||||
assert_eq!(r["changes"], 4, "move past MAX_LCS cap must not cascade into false revisions in the tail");
|
||||
|
||||
// In s.doc.body (total 4601 blocks: 4598 unchanged, 1 del at 100, 1 ins at 4401, 2 substituted at 0 and 4600):
|
||||
let changed_blocks: Vec<(usize, String)> = s
|
||||
.doc
|
||||
.body
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(idx, b)| {
|
||||
let p = b.as_para()?;
|
||||
let has_change = p.mark.del.is_some() || p.mark.ins.is_some() || p.runs.iter().any(|r| r.props.del.is_some() || r.props.ins.is_some());
|
||||
if has_change { Some((idx, p.plain_text())) } else { None }
|
||||
})
|
||||
.collect();
|
||||
|
||||
assert_eq!(changed_blocks.len(), 4);
|
||||
assert_eq!(changed_blocks[0], (0, "Record 0 has alpha beta gamma original.revised.".into()));
|
||||
assert_eq!(changed_blocks[1], (100, "Record 100 has alpha beta gamma payload.".into()));
|
||||
assert_eq!(changed_blocks[2], (4401, "Record 100 has alpha beta gamma payload.".into()));
|
||||
assert_eq!(changed_blocks[3], (4600, "Record 4599 has alpha beta gamma original.revised.".into()));
|
||||
|
||||
// Verify exact change types on those blocks:
|
||||
let b0 = s.doc.body[0].as_para().unwrap();
|
||||
assert!(b0.runs.iter().any(|r| r.props.del.is_some()) && b0.runs.iter().any(|r| r.props.ins.is_some()), "block 0 is text replacement");
|
||||
let b100 = s.doc.body[100].as_para().unwrap();
|
||||
assert!(b100.runs.iter().all(|r| r.props.del.is_some()) && b100.mark.del.is_some(), "block 100 is deleted whole");
|
||||
let b4401 = s.doc.body[4401].as_para().unwrap();
|
||||
assert!(b4401.runs.iter().all(|r| r.props.ins.is_some()) && b4401.mark.ins.is_some(), "block 4401 is inserted whole");
|
||||
let b4600 = s.doc.body[4600].as_para().unwrap();
|
||||
assert!(b4600.runs.iter().any(|r| r.props.del.is_some()) && b4600.runs.iter().any(|r| r.props.ins.is_some()), "block 4600 is text replacement");
|
||||
|
||||
s.run("review.acceptAll", &json!({})).unwrap();
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), revised);
|
||||
}
|
||||
|
||||
/// Verifies that `mark_block` records paragraph mark revisions on whole-block deletions,
|
||||
/// whole-block insertions, empty paragraphs, and ensures clean resolution via `review.acceptAll`.
|
||||
#[test]
|
||||
fn compare_mark_block_records_paragraph_mark_revisions_on_whole_block_changes() {
|
||||
// Whole block delete/insert: test with differing paragraph count so whole-block paths trigger
|
||||
let orig_multi = "Keep start.\nDelete me.\nKeep end.";
|
||||
let rev_multi = "Keep start.\nKeep end.";
|
||||
let mut s2 = Session::new(Document::from_text(orig_multi));
|
||||
if let Some(Block::Para(p)) = s2.doc.body.get_mut(1).map(Arc::make_mut) {
|
||||
p.mark.bold = Some(true);
|
||||
}
|
||||
s2.run("review.compare", &json!({"text": rev_multi})).unwrap();
|
||||
|
||||
// In s2.doc.body, block 1 was deleted whole.
|
||||
// Verify that p.mark.del is recorded by mark_block and formatting is preserved.
|
||||
let del_para = s2.doc.body[1].as_para().expect("block 1 must be paragraph");
|
||||
assert!(del_para.mark.del.is_some(), "whole-block delete must record revision on paragraph mark");
|
||||
assert_eq!(del_para.mark.bold, Some(true), "paragraph mark formatting must be preserved on deleted block");
|
||||
|
||||
// Accept all changes: deleted paragraph is removed
|
||||
s2.run("review.acceptAll", &json!({})).unwrap();
|
||||
assert_eq!(s2.doc.plain_text(StoryRef::Body), rev_multi);
|
||||
|
||||
// Test whole-block insertion
|
||||
let mut s3 = Session::new(Document::from_text(rev_multi));
|
||||
s3.run("review.compare", &json!({"text": orig_multi})).unwrap();
|
||||
let ins_para = s3.doc.body[1].as_para().expect("block 1 must be paragraph");
|
||||
assert!(ins_para.mark.ins.is_some(), "whole-block insert must record revision on paragraph mark");
|
||||
s3.run("review.acceptAll", &json!({})).unwrap();
|
||||
assert_eq!(s3.doc.plain_text(StoryRef::Body), orig_multi);
|
||||
|
||||
// Test empty paragraph deletion and insertion (len == 0, where only mark carries the revision)
|
||||
let orig_empty = "Before.\n\nAfter.";
|
||||
let rev_empty = "Before.\nAfter.";
|
||||
let mut s4 = Session::new(Document::from_text(orig_empty));
|
||||
s4.run("review.compare", &json!({"text": rev_empty})).unwrap();
|
||||
let empty_del = s4.doc.body[1].as_para().expect("empty paragraph block");
|
||||
assert_eq!(empty_del.len(), 0);
|
||||
assert!(empty_del.mark.del.is_some(), "empty paragraph deletion must set mark.del");
|
||||
s4.run("review.acceptAll", &json!({})).unwrap();
|
||||
assert_eq!(s4.doc.plain_text(StoryRef::Body), rev_empty);
|
||||
|
||||
let mut s5 = Session::new(Document::from_text(rev_empty));
|
||||
s5.run("review.compare", &json!({"text": orig_empty})).unwrap();
|
||||
let empty_ins = s5.doc.body[1].as_para().expect("empty paragraph block");
|
||||
assert_eq!(empty_ins.len(), 0);
|
||||
assert!(empty_ins.mark.ins.is_some(), "empty paragraph insertion must set mark.ins");
|
||||
s5.run("review.acceptAll", &json!({})).unwrap();
|
||||
assert_eq!(s5.doc.plain_text(StoryRef::Body), orig_empty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macros_record_and_run() {
|
||||
let mut s = Session::new(Document::new());
|
||||
|
||||
@@ -8,12 +8,14 @@ use wordcraft_layout::{NumeralMode, ViewMode};
|
||||
use super::sel_result;
|
||||
use crate::{CmdError, CmdResult, CommandSpec, Session, p};
|
||||
|
||||
/// Toggles or sets a boolean view property, returning the updated state.
|
||||
fn toggle(s: &mut Session, v: &Value, f: fn(&mut crate::ViewState) -> &mut bool) -> CmdResult {
|
||||
let cur = *f(&mut s.view);
|
||||
*f(&mut s.view) = p::bool(v, "value").unwrap_or(!cur);
|
||||
Ok(json!({"value": *f(&mut s.view)}))
|
||||
}
|
||||
|
||||
/// Returns the command specifications for the View ribbon tab and window management commands.
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("view.marks", "Show/Hide ¶", "Home › Paragraph", |s, v| {
|
||||
@@ -143,7 +145,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
})
|
||||
.pure(),
|
||||
CommandSpec::new("view.switchWindows", "Switch Windows", "View › Window", switch_windows)
|
||||
.params(r#"{"id"?: n (make this open document the active one)} → {"documents": [{"id", "title", "path", "dirty", "active"}]} in the order they were opened"#)
|
||||
.params(r#"{"id"?: n (make this open document the active one), "window"?: n (make this open window the active one)} → {"documents": [{"id", "title", "path", "dirty", "active"}]} in the order they were opened"#)
|
||||
.pure(),
|
||||
CommandSpec::new("view.split", "Split", "View › Window", |s, _| {
|
||||
s.push_ui_request(json!({"toggle": "split"}));
|
||||
@@ -213,6 +215,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
pub struct Windows {
|
||||
/// The host can list and move its windows (the desktop app).
|
||||
pub available: bool,
|
||||
/// The active window, when known.
|
||||
pub current: Option<u64>,
|
||||
/// The other open windows, by id.
|
||||
pub others: Vec<OtherWindow>,
|
||||
/// View Side by Side is on, with this window.
|
||||
@@ -431,6 +435,7 @@ pub(crate) fn restore_zoom(s: &mut Session, z: Zoom) {
|
||||
s.view.single_column = fit == "onePage" || fit == "pageWidth";
|
||||
}
|
||||
|
||||
/// Sets the view zoom level or fit mode, or requests opening the Zoom dialog.
|
||||
fn zoom(s: &mut Session, v: &Value) -> CmdResult {
|
||||
match v.get("value") {
|
||||
Some(Value::String(f)) if ["pageWidth", "onePage", "multiplePages", "textWidth"].contains(&f.as_str()) => {
|
||||
@@ -456,7 +461,13 @@ fn zoom(s: &mut Session, v: &Value) -> CmdResult {
|
||||
|
||||
/// View › Window › Switch Windows: the open documents, or make one of them active.
|
||||
fn switch_windows(s: &mut Session, v: &Value) -> CmdResult {
|
||||
if let Some(x) = v.get("id").filter(|x| !x.is_null()) {
|
||||
if let Some(w) = v.get("window").filter(|x| !x.is_null()) {
|
||||
let wid = w.as_u64().ok_or_else(|| CmdError::Params("`window` must be an open window's id".into()))?;
|
||||
if s.windows.available && s.windows.current != Some(wid) && !s.windows.others.iter().any(|other| other.id == wid) {
|
||||
return Err(CmdError::Params(format!("no open window {wid}")));
|
||||
}
|
||||
s.push_ui_request(json!({"windows": "switchWindow", "window": wid}));
|
||||
} else if let Some(x) = v.get("id").filter(|x| !x.is_null()) {
|
||||
let id = x.as_u64().ok_or_else(|| CmdError::Params("`id` must be an open document's id".into()))?;
|
||||
s.activate(id)?;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,9 @@ use std::io::{self, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
/// Writes `bytes` safely to `path`, replacing an existing destination atomically via a
|
||||
/// temporary file. If the destination's folder refuses the temporary file, a new file
|
||||
/// is created directly and removed on failure; an existing file is left untouched.
|
||||
pub(super) fn write(path: &Path, bytes: &[u8]) -> io::Result<()> {
|
||||
let permissions = match fs::metadata(path) {
|
||||
Ok(meta) if meta.is_file() => Some(meta.permissions()),
|
||||
@@ -28,11 +31,41 @@ pub(super) fn write(path: &Path, bytes: &[u8]) -> io::Result<()> {
|
||||
}
|
||||
// Synchronize the prepared file before replacing the destination. Directory durability
|
||||
// and network-filesystem/power-loss guarantees are outside this operation's contract.
|
||||
file.sync_all()
|
||||
sync_file(file)
|
||||
});
|
||||
written.map_err(explain)
|
||||
}
|
||||
|
||||
/// Flushes `file` to stable storage (`sync_all`). On Apple platforms that is a full flush
|
||||
/// (`F_FULLFSYNC`); where the file system can't do one (SMB shares and virtual file systems
|
||||
/// refuse it with `ENOTSUP` #6155, `EINVAL`, or `ENOTTY`), this falls back to a plain `fsync`,
|
||||
/// as Apple's own `removefile` and SQLite do. Plain `fsync` flushes to the drive controller but
|
||||
/// does not guarantee disk cache durability; any other error is returned as it is.
|
||||
fn sync_file(file: &File) -> io::Result<()> {
|
||||
#[cfg(target_vendor = "apple")]
|
||||
let plain = || rustix::fs::fsync(file).map_err(io::Error::from);
|
||||
#[cfg(not(target_vendor = "apple"))]
|
||||
let plain = || Ok(());
|
||||
or_plain_fsync(file.sync_all(), plain)
|
||||
}
|
||||
|
||||
/// Returns `full`, or calls `fsync()` when `full` failed only because the file system can't do a full flush.
|
||||
fn or_plain_fsync(full: io::Result<()>, fsync: impl FnOnce() -> io::Result<()>) -> io::Result<()> {
|
||||
match full {
|
||||
Err(e) if full_flush_unsupported(&e) => fsync(),
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `e` is a file system refusing a full flush (`F_FULLFSYNC`): `ENOTSUP` (45, SMB),
|
||||
/// `EINVAL` (22), or `ENOTTY` (25) on Apple platforms. Never elsewhere: there `sync_all` is a plain `fsync`.
|
||||
fn full_flush_unsupported(e: &io::Error) -> bool {
|
||||
const EINVAL: i32 = 22;
|
||||
const ENOTTY: i32 = 25;
|
||||
const ENOTSUP: i32 = 45;
|
||||
cfg!(target_vendor = "apple") && matches!(e.raw_os_error(), Some(ENOTSUP | EINVAL | ENOTTY))
|
||||
}
|
||||
|
||||
/// A folder that keeps no Unix modes refuses to set one with "Operation not supported": a NAS
|
||||
/// share mounted by GVFS (`smb://` in the Linux file manager) refuses every mode change (#3003).
|
||||
/// There is no mode to keep there, so the save goes on; any other failure still stops it.
|
||||
@@ -448,4 +481,41 @@ mod tests {
|
||||
assert_eq!(fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o640);
|
||||
assert_eq!(fs::read(&path).unwrap(), b"Saved again.");
|
||||
}
|
||||
|
||||
/// A share that refuses the full flush (`F_FULLFSYNC`, #6155) still saves through a plain fsync
|
||||
/// on Apple platforms; every other sync error still fails the save.
|
||||
#[test]
|
||||
fn a_refused_full_flush_falls_back_to_a_plain_fsync_on_apple_only() {
|
||||
use std::cell::Cell;
|
||||
let refused = |errno| Err(io::Error::from_raw_os_error(errno));
|
||||
for (errno, name) in [(45, "ENOTSUP, SMB"), (22, "EINVAL"), (25, "ENOTTY")] {
|
||||
let fsynced = Cell::new(false);
|
||||
let r = or_plain_fsync(refused(errno), || {
|
||||
fsynced.set(true);
|
||||
Ok(())
|
||||
});
|
||||
let apple = cfg!(target_vendor = "apple");
|
||||
assert_eq!(r.is_ok(), apple, "{name}");
|
||||
assert_eq!(fsynced.get(), apple, "{name}: the plain fsync runs only on Apple platforms");
|
||||
}
|
||||
// The plain fsync's own failure is reported.
|
||||
if cfg!(target_vendor = "apple") {
|
||||
let r = or_plain_fsync(refused(45), || Err(io::Error::from_raw_os_error(5)));
|
||||
assert_eq!(r.map_err(|e| e.raw_os_error()), Err(Some(5)));
|
||||
}
|
||||
// Real errors (EIO, ENOSPC, EACCES) never fall back, and success needs no fallback.
|
||||
for errno in [5, 28, 13] {
|
||||
let r = or_plain_fsync(refused(errno), || panic!("no fallback for errno {errno}"));
|
||||
assert_eq!(r.map_err(|e| e.raw_os_error()), Err(Some(errno)));
|
||||
}
|
||||
assert!(or_plain_fsync(Ok(()), || panic!("no fallback after a full flush")).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_file_flushes_a_local_file() {
|
||||
let d = Scratch::new();
|
||||
let path = d.0.join("f.bin");
|
||||
let file = OpenOptions::new().write(true).create_new(true).open(&path).unwrap();
|
||||
sync_file(&file).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,6 +214,7 @@ fn read_aloud_belongs_to_its_document() {
|
||||
assert_eq!(s.read_aloud.document, Some(b));
|
||||
}
|
||||
|
||||
/// Tests that `view.switchWindows` lists open documents, activates by document ID, and requests switching by window ID.
|
||||
#[test]
|
||||
fn switch_windows_lists_and_activates() {
|
||||
let mut s = s();
|
||||
@@ -228,10 +229,23 @@ fn switch_windows_lists_and_activates() {
|
||||
assert!(list[0].get("path").is_some() && list[0].get("dirty").is_some());
|
||||
run(&mut s, "view.switchWindows", json!({"id": a}));
|
||||
assert_eq!(s.document_id(), a);
|
||||
s.ui_requests.clear();
|
||||
run(&mut s, "view.switchWindows", json!({"window": 1}));
|
||||
assert_eq!(s.ui_requests, vec![json!({"windows": "switchWindow", "window": 1})]);
|
||||
s.windows.available = true;
|
||||
s.windows.current = Some(1);
|
||||
s.windows.others = vec![crate::cmd::view::OtherWindow { id: 2, title: "Second".into() }];
|
||||
s.ui_requests.clear();
|
||||
run(&mut s, "view.switchWindows", json!({"window": 2}));
|
||||
assert_eq!(s.ui_requests, vec![json!({"windows": "switchWindow", "window": 2})]);
|
||||
assert!(s.run("view.switchWindows", &json!({"window": 3})).is_err(), "unknown window key fails when windows are available");
|
||||
for bad in [json!({"id": 0}), json!({"id": u64::MAX}), json!({"id": -5}), json!({"id": "1"}), json!({"id": 1e300}), json!({"id": {}})] {
|
||||
assert!(s.run("view.switchWindows", &bad).is_err(), "{bad}");
|
||||
assert_eq!(s.document_id(), a);
|
||||
}
|
||||
for bad in [json!({"window": "1"}), json!({"window": -1}), json!({"window": {}})] {
|
||||
assert!(s.run("view.switchWindows", &bad).is_err(), "{bad}");
|
||||
}
|
||||
let junk = [json!(null), json!([]), json!({"id": null}), json!({"text": 5})];
|
||||
for id in ["view.switchWindows", "file.closeDocument", "file.new"] {
|
||||
for j in &junk {
|
||||
|
||||
@@ -186,3 +186,36 @@ fn binary_doc_opened_by_extension_refreshes_date_fields() {
|
||||
assert_eq!(got, expected, "{name}: DATE field stale after opening");
|
||||
}
|
||||
}
|
||||
|
||||
/// Regression from a synthetic Word document: a visible Brazilian long date is
|
||||
/// stored in a TIME field and pt-BR is present only in docDefaults, not in field runs.
|
||||
#[test]
|
||||
fn a_word_time_field_inherits_pt_br_from_document_defaults() {
|
||||
const PICTURE: &str = "d' de 'MMMM' de 'yyyy";
|
||||
const CODE: &str = r#"TIME \@ "d' de 'MMMM' de 'yyyy""#;
|
||||
let rels = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
|
||||
let doc_rels = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdStyles" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>"#;
|
||||
let styles = r#"<?xml version="1.0" encoding="UTF-8"?><w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:docDefaults><w:rPrDefault><w:rPr><w:lang w:val="pt-BR"/></w:rPr></w:rPrDefault></w:docDefaults><w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/></w:style></w:styles>"#;
|
||||
let doc = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>Cidade, </w:t></w:r>{}</w:p><w:p><w:r><w:t>Cidade, </w:t></w:r>{}</w:p></w:body></w:document>"#,
|
||||
field(CODE, "22 de setembro de 2026", false),
|
||||
field(CODE, "22 de setembro de 2026", false)
|
||||
);
|
||||
let source = zip(&[("_rels/.rels", rels), ("word/document.xml", &doc), ("word/_rels/document.xml.rels", doc_rels), ("word/styles.xml", styles)]);
|
||||
let original = wordcraft_engine::io::open_bytes("pt-br-word.docx", &source).unwrap();
|
||||
let expected = wordcraft_engine::cmd::insert::format_date_in(PICTURE, Some("pt-BR"));
|
||||
let assert_correct = |document: &wordcraft_doc::Document| {
|
||||
let got = fields(document);
|
||||
assert_eq!(got.len(), 2, "both original fields survive");
|
||||
for (code, result) in got {
|
||||
assert_eq!(code, CODE, "field instruction remains editable");
|
||||
assert_eq!(result, expected, "quoted Portuguese words and month names");
|
||||
assert!(!result.contains("October") && !result.contains('\''));
|
||||
assert_eq!(result.matches(" de ").count(), 2);
|
||||
}
|
||||
};
|
||||
assert_correct(&original);
|
||||
let written = wordcraft_docx::write(&original).unwrap();
|
||||
let reopened = wordcraft_engine::io::open_bytes("reopened.docx", &written).unwrap();
|
||||
assert_correct(&reopened);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
//! #2201: inserting page numbers must preserve the header/footer story.
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::json;
|
||||
use wordcraft_doc::para::{GroupChild, InlineObject};
|
||||
use wordcraft_doc::props::{Align, CharProps};
|
||||
use wordcraft_doc::{Block, Document, Paragraph, PartKind, Path, StoryRef, Table, para_block};
|
||||
use wordcraft_engine::{Selection, Session};
|
||||
|
||||
fn fields(doc: &Document, story: StoryRef, name: &str) -> usize {
|
||||
doc.para_paths(story)
|
||||
.iter()
|
||||
.filter_map(|path| doc.para(story, path))
|
||||
.flat_map(|p| &p.objects)
|
||||
.filter(|o| matches!(o, InlineObject::Field { instr, .. } | InlineObject::FieldStart { instr, .. } if instr.split_whitespace().next().is_some_and(|token| token.eq_ignore_ascii_case(name))))
|
||||
.count()
|
||||
}
|
||||
|
||||
fn field(instr: &str) -> InlineObject {
|
||||
InlineObject::Field { instr: instr.into(), result: "1".into(), locked: false, dirty: false, code: false, form: String::new() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numbering_preserves_content_formatting_selection_and_undo_redo() {
|
||||
for (command, position) in [("insert.header", "top"), ("insert.footer", "bottom")] {
|
||||
for format in ["plain", "x of y"] {
|
||||
let mut s = Session::new(Document::from_text("Body text"));
|
||||
let id = s.run(command, &json!({"text": "Keep this\nSecond line"})).unwrap()["story"].as_u64().unwrap() as u32;
|
||||
let story = StoryRef::Part(id);
|
||||
s.doc.para_mut(story, &Path::top(0)).unwrap().props.align = Some(Align::Right);
|
||||
let mut table = Table::new(1, 2, 300.0);
|
||||
table.rows[0].cells[0].blocks =
|
||||
vec![para_block(Paragraph::with_text("Keep table", CharProps { bold: Some(true), ..Default::default() }))];
|
||||
s.doc.story_mut(story).unwrap().push(Arc::new(Block::Table(table)));
|
||||
let before = s.doc.clone();
|
||||
let selection = s.sel.clone();
|
||||
let blocks = s.doc.story(story).unwrap().clone();
|
||||
let depth = s.undo_depth();
|
||||
s.run("insert.pageNumber", &json!({"position": position, "format": format})).unwrap();
|
||||
assert_eq!(&s.doc.story(story).unwrap()[..blocks.len()], blocks.as_slice());
|
||||
assert_eq!(s.sel, selection);
|
||||
assert_eq!(fields(&s.doc, story, "PAGE"), 1);
|
||||
assert_eq!(fields(&s.doc, story, "NUMPAGES"), usize::from(format == "x of y"));
|
||||
assert_eq!(s.undo_depth(), depth + 1);
|
||||
let after = s.doc.clone();
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc, before);
|
||||
assert_eq!(s.sel, selection);
|
||||
s.run("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc, after);
|
||||
s.run("insert.pageNumber", &json!({"position": position, "format": format})).unwrap();
|
||||
assert_eq!(s.doc, after, "repeated numbering must not duplicate or replace content");
|
||||
let mut back = after.clone();
|
||||
for _ in 0..2 {
|
||||
back = wordcraft_docx::read(&wordcraft_docx::write(&back).unwrap()).unwrap();
|
||||
let id = if position == "top" { back.last_section.headers.default } else { back.last_section.footers.default }.unwrap();
|
||||
let reopened = StoryRef::Part(id);
|
||||
assert_eq!(back.plain_text(StoryRef::Body), "Body text");
|
||||
assert!(back.plain_text(reopened).starts_with("Keep this\nSecond line\nKeep table"));
|
||||
assert_eq!(back.para(reopened, &Path::top(0)).unwrap().props.align, Some(Align::Right));
|
||||
let Block::Table(table) = &*back.story(reopened).unwrap()[2] else { panic!("lost table") };
|
||||
let Block::Para(p) = &*table.rows[0].cells[0].blocks[0] else { panic!("lost cell") };
|
||||
assert_eq!(p.props_at(0).bold, Some(true));
|
||||
assert_eq!(fields(&back, reopened, "PAGE"), 1);
|
||||
assert_eq!(fields(&back, reopened, "NUMPAGES"), usize::from(format == "x of y"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_page_field_in_a_table_is_preserved_with_its_switches() {
|
||||
for depth in [1, 17] {
|
||||
let mut s = Session::new(Document::from_text("Body"));
|
||||
let id = s.run("insert.footer", &json!({"text": "Keep footer"})).unwrap()["story"].as_u64().unwrap() as u32;
|
||||
let mut para = Paragraph::with_text("Existing page: ", CharProps::default());
|
||||
para.insert_object(para.len(), field(" page \\* MERGEFORMAT "), &CharProps::default()).unwrap();
|
||||
let mut blocks = vec![para_block(para)];
|
||||
for _ in 0..depth {
|
||||
let mut table = Table::new(1, 1, 300.0);
|
||||
table.rows[0].cells[0].blocks = blocks;
|
||||
blocks = vec![Arc::new(Block::Table(table))];
|
||||
}
|
||||
s.doc.story_mut(StoryRef::Part(id)).unwrap().extend(blocks);
|
||||
let before = s.doc.clone();
|
||||
s.run("insert.pageNumber", &json!({"position": "bottom", "format": "x of y"})).unwrap();
|
||||
assert!(s.doc == before, "PAGE in a table nested {depth} deep must not be duplicated");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_range_page_field_is_not_duplicated() {
|
||||
let mut s = Session::new(Document::new());
|
||||
let id = s.run("insert.header", &json!({"text": "Keep header"})).unwrap()["story"].as_u64().unwrap() as u32;
|
||||
let story = StoryRef::Part(id);
|
||||
let para = s.doc.para_mut(story, &Path::top(0)).unwrap();
|
||||
para.insert_object(para.len(), InlineObject::FieldStart { instr: "PAGE".into(), locked: true, dirty: true }, &CharProps::default()).unwrap();
|
||||
para.insert_text(para.len(), "7", &CharProps::default()).unwrap();
|
||||
para.insert_object(para.len(), InlineObject::FieldEnd, &CharProps::default()).unwrap();
|
||||
let before = s.doc.clone();
|
||||
s.run("insert.pageNumber", &json!({"position": "top"})).unwrap();
|
||||
assert_eq!(s.doc, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unrelated_fields_do_not_prevent_numbering_and_formatted_empty_paragraphs_survive() {
|
||||
let mut s = Session::new(Document::new());
|
||||
let id = s.run("insert.header", &json!({"text": ""})).unwrap()["story"].as_u64().unwrap() as u32;
|
||||
let story = StoryRef::Part(id);
|
||||
let para = s.doc.para_mut(story, &Path::top(0)).unwrap();
|
||||
para.props.align = Some(Align::Right);
|
||||
let empty = para.clone();
|
||||
let mut para = Paragraph::new();
|
||||
para.insert_object(0, field("PAGEREF bookmark"), &CharProps::default()).unwrap();
|
||||
s.doc.story_mut(story).unwrap().push(para_block(para));
|
||||
s.run("insert.pageNumber", &json!({"position": "top"})).unwrap();
|
||||
assert_eq!(s.doc.para(story, &Path::top(0)).unwrap(), &empty);
|
||||
assert_eq!(fields(&s.doc, story, "PAGEREF"), 1);
|
||||
assert_eq!(fields(&s.doc, story, "PAGE"), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fresh_numbering_uses_one_paragraph_and_current_position_stays_inline() {
|
||||
for position in ["top", "bottom"] {
|
||||
let mut s = Session::new(Document::new());
|
||||
let id = s.run("insert.pageNumber", &json!({"position": position})).unwrap()["story"].as_u64().unwrap() as u32;
|
||||
assert_eq!(s.doc.story(StoryRef::Part(id)).unwrap().len(), 1);
|
||||
}
|
||||
let mut s = Session::new(Document::from_text("Before selected after"));
|
||||
s.run("select.text", &json!({"text": "selected"})).unwrap();
|
||||
let before = s.doc.clone();
|
||||
s.run("insert.pageNumber", &json!({"position": "current"})).unwrap();
|
||||
assert!(s.doc.parts.is_empty());
|
||||
assert_eq!(fields(&s.doc, StoryRef::Body, "PAGE"), 1);
|
||||
assert!(!s.doc.plain_text(StoryRef::Body).contains("selected"));
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_page_field_in_nested_text_boxes_is_preserved() {
|
||||
for (command, position) in [("insert.header", "top"), ("insert.footer", "bottom")] {
|
||||
for levels in [1, 2] {
|
||||
let mut s = Session::new(Document::from_text("Body"));
|
||||
let id = s.run(command, &json!({"text": "Keep this"})).unwrap()["story"].as_u64().unwrap() as u32;
|
||||
let story = StoryRef::Part(id);
|
||||
let box_id = s.run("insert.textBox", &json!({"text": "Existing page: "})).unwrap()["story"].as_u64().unwrap() as u32;
|
||||
if levels == 2 {
|
||||
// Imported stories can contain a nested box, even though the insert command
|
||||
// does not offer one while the caret is already in a text box.
|
||||
let inner = s.doc.add_part(PartKind::TextBox, vec![para_block(Paragraph::new())]);
|
||||
let mut anchor = s.doc.para(story, &Path::top(0)).unwrap().objects.iter().find(|o| o.text_box() == Some(box_id)).unwrap().clone();
|
||||
if let InlineObject::Shape { story, .. } = &mut anchor {
|
||||
*story = Some(inner);
|
||||
}
|
||||
s.doc.para_mut(StoryRef::Part(box_id), &Path::top(0)).unwrap().insert_object(0, anchor, &CharProps::default()).unwrap();
|
||||
s.sel = Selection::caret(s.doc.end_of(StoryRef::Part(inner)));
|
||||
}
|
||||
s.run("insert.pageNumber", &json!({"position": "current"})).unwrap();
|
||||
let before = s.doc.clone();
|
||||
let selection = s.sel.clone();
|
||||
s.run("insert.pageNumber", &json!({"position": position, "format": "x of y"})).unwrap();
|
||||
assert_eq!(fields(&s.doc, story, "PAGE"), 0, "the text box already contains the page number");
|
||||
assert_eq!(s.doc, before);
|
||||
assert_eq!(s.sel, selection);
|
||||
let mut back = s.doc.clone();
|
||||
for _ in 0..2 {
|
||||
back = wordcraft_docx::read(&wordcraft_docx::write(&back).unwrap()).unwrap();
|
||||
assert_eq!(back.parts.keys().map(|id| fields(&back, StoryRef::Part(*id), "PAGE")).sum::<usize>(), 1);
|
||||
assert_eq!(back.parts.keys().map(|id| fields(&back, StoryRef::Part(*id), "NUMPAGES")).sum::<usize>(), 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_page_field_in_grouped_text_boxes_is_preserved() {
|
||||
for (command, position) in [("insert.header", "top"), ("insert.footer", "bottom")] {
|
||||
let mut s = Session::new(Document::from_text("Body"));
|
||||
let id = s.run(command, &json!({"text": "Keep this"})).unwrap()["story"].as_u64().unwrap() as u32;
|
||||
let story = StoryRef::Part(id);
|
||||
let box_id = s.run("insert.textBox", &json!({"text": "Existing page: "})).unwrap()["story"].as_u64().unwrap() as u32;
|
||||
s.run("insert.pageNumber", &json!({"position": "current"})).unwrap();
|
||||
let anchor = s.doc.para_mut(story, &Path::top(0)).unwrap().objects.iter_mut().find(|o| o.text_box() == Some(box_id)).unwrap();
|
||||
*anchor = InlineObject::Group {
|
||||
w: 144.0,
|
||||
h: 72.0,
|
||||
float: Default::default(),
|
||||
ch_w: 144.0,
|
||||
ch_h: 72.0,
|
||||
children: vec![GroupChild { x: 0.0, y: 0.0, obj: anchor.clone() }],
|
||||
canvas: None,
|
||||
};
|
||||
let before = s.doc.clone();
|
||||
let selection = s.sel.clone();
|
||||
s.run("insert.pageNumber", &json!({"position": position})).unwrap();
|
||||
assert_eq!(fields(&s.doc, story, "PAGE"), 0, "the grouped text box already contains the page number");
|
||||
assert_eq!(s.doc, before);
|
||||
assert_eq!(s.sel, selection);
|
||||
let mut back = s.doc.clone();
|
||||
for _ in 0..2 {
|
||||
back = wordcraft_docx::read(&wordcraft_docx::write(&back).unwrap()).unwrap();
|
||||
assert_eq!(back.parts.keys().map(|id| fields(&back, StoryRef::Part(*id), "PAGE")).sum::<usize>(), 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_fields_in_unrelated_text_boxes_do_not_prevent_numbering() {
|
||||
let mut s = Session::new(Document::from_text("Body"));
|
||||
let box_id = s.run("insert.textBox", &json!({"text": "Body page: "})).unwrap()["story"].as_u64().unwrap() as u32;
|
||||
s.run("insert.pageNumber", &json!({"position": "current"})).unwrap();
|
||||
let footer = s.run("insert.footer", &json!({"text": "Keep footer"})).unwrap()["story"].as_u64().unwrap() as u32;
|
||||
s.run("insert.pageNumber", &json!({"position": "bottom"})).unwrap();
|
||||
assert_eq!(fields(&s.doc, StoryRef::Part(box_id), "PAGE"), 1);
|
||||
assert_eq!(fields(&s.doc, StoryRef::Part(footer), "PAGE"), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cyclic_text_box_references_do_not_block_numbering() {
|
||||
let mut s = Session::new(Document::from_text("Body"));
|
||||
let footer = s.run("insert.footer", &json!({"text": "Keep footer"})).unwrap()["story"].as_u64().unwrap() as u32;
|
||||
let box_id = s.run("insert.textBox", &json!({"text": "A box"})).unwrap()["story"].as_u64().unwrap() as u32;
|
||||
let anchor = s.doc.para(StoryRef::Part(footer), &Path::top(0)).unwrap().objects.iter().find(|o| o.text_box() == Some(box_id)).unwrap().clone();
|
||||
s.doc.para_mut(StoryRef::Part(box_id), &Path::top(0)).unwrap().insert_object(0, anchor, &CharProps::default()).unwrap();
|
||||
s.run("insert.pageNumber", &json!({"position": "bottom"})).unwrap();
|
||||
assert_eq!(fields(&s.doc, StoryRef::Part(footer), "PAGE"), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_numbers_switch_format_without_losing_neighboring_content() {
|
||||
for (command, position) in [("insert.header", "top"), ("insert.footer", "bottom")] {
|
||||
let mut s = Session::new(Document::from_text("Body"));
|
||||
let id = s.run(command, &json!({"text": "Keep this"})).unwrap()["story"].as_u64().unwrap() as u32;
|
||||
let story = StoryRef::Part(id);
|
||||
let mut table = Table::new(1, 1, 300.0);
|
||||
table.rows[0].cells[0].blocks = vec![para_block(Paragraph::with_text("Keep table", CharProps::default()))];
|
||||
s.doc.story_mut(story).unwrap().push(Arc::new(Block::Table(table)));
|
||||
let blocks = s.doc.story(story).unwrap().clone();
|
||||
let selection = s.sel.clone();
|
||||
for (format, align) in [("plain", "left"), ("x of y", "right"), ("plain", "center")] {
|
||||
let before = s.doc.clone();
|
||||
s.run("insert.pageNumber", &json!({"position": position, "format": format, "align": align})).unwrap();
|
||||
assert_eq!(&s.doc.story(story).unwrap()[..blocks.len()], blocks.as_slice());
|
||||
assert_eq!(s.sel, selection);
|
||||
assert_eq!(fields(&s.doc, story, "PAGE"), 1);
|
||||
assert_eq!(fields(&s.doc, story, "NUMPAGES"), usize::from(format == "x of y"));
|
||||
let expected = match align {
|
||||
"left" => Align::Left,
|
||||
"right" => Align::Right,
|
||||
_ => Align::Center,
|
||||
};
|
||||
assert_eq!(s.doc.para(story, &Path::top(blocks.len())).unwrap().props.align, Some(expected));
|
||||
let after = s.doc.clone();
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc, before);
|
||||
s.run("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc, after);
|
||||
}
|
||||
let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap();
|
||||
let id = if position == "top" { back.last_section.headers.default } else { back.last_section.footers.default }.unwrap();
|
||||
assert!(back.plain_text(StoryRef::Part(id)).starts_with("Keep this\nKeep table"));
|
||||
assert_eq!(fields(&back, StoryRef::Part(id), "PAGE"), 1);
|
||||
assert_eq!(fields(&back, StoryRef::Part(id), "NUMPAGES"), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_numbering_keeps_clean_state_and_undo_depth() {
|
||||
for position in ["top", "bottom"] {
|
||||
for format in ["plain", "x of y"] {
|
||||
let mut s = Session::new(Document::new());
|
||||
s.run("insert.pageNumber", &json!({"position": position, "format": format})).unwrap();
|
||||
s.dirty = false;
|
||||
let doc = s.doc.clone();
|
||||
let depth = s.undo_depth();
|
||||
s.run("insert.pageNumber", &json!({"position": position, "format": format})).unwrap();
|
||||
assert_eq!(s.doc, doc);
|
||||
assert_eq!(s.undo_depth(), depth, "an identical request must not add an undo step");
|
||||
assert!(!s.dirty, "an identical request must not dirty the document");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reopened_empty_header_footer_mark_is_reused_without_a_blank_line() {
|
||||
for (command, position) in [("insert.header", "top"), ("insert.footer", "bottom")] {
|
||||
let mut s = Session::new(Document::from_text("Body"));
|
||||
let id = s.run(command, &json!({"text": ""})).unwrap()["story"].as_u64().unwrap() as u32;
|
||||
s.doc.para_mut(StoryRef::Part(id), &Path::top(0)).unwrap().mark = CharProps { bold: Some(true), italic: Some(true), ..Default::default() };
|
||||
let doc = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap();
|
||||
let id = if position == "top" { doc.last_section.headers.default } else { doc.last_section.footers.default }.unwrap();
|
||||
let story = StoryRef::Part(id);
|
||||
let mark = doc.para(story, &Path::top(0)).unwrap().mark.clone();
|
||||
assert_eq!(mark.bold, Some(true));
|
||||
let mut s = Session::new(doc);
|
||||
let before = s.doc.clone();
|
||||
s.run("insert.pageNumber", &json!({"position": position})).unwrap();
|
||||
assert_eq!(s.doc.story(story).unwrap().len(), 1, "reuse the imported empty paragraph rather than add a blank line");
|
||||
assert_eq!(s.doc.para(story, &Path::top(0)).unwrap().mark, mark);
|
||||
assert_eq!(fields(&s.doc, story, "PAGE"), 1);
|
||||
let inserted = s.doc.clone();
|
||||
s.run("insert.pageNumber", &json!({"position": position, "format": "x of y", "align": "right"})).unwrap();
|
||||
assert_eq!(s.doc.story(story).unwrap().len(), 1);
|
||||
assert_eq!(s.doc.para(story, &Path::top(0)).unwrap().mark, mark);
|
||||
assert_eq!(s.doc.para(story, &Path::top(0)).unwrap().props.align, Some(Align::Right));
|
||||
assert_eq!(fields(&s.doc, story, "PAGE"), 1);
|
||||
assert_eq!(fields(&s.doc, story, "NUMPAGES"), 1);
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc, inserted);
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc, before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_owned_page_number_lookalikes_are_preserved() {
|
||||
for kind in ["formatting", "metadata", "another field"] {
|
||||
let mut s = Session::new(Document::from_text("Body"));
|
||||
let id = s.run("insert.pageNumber", &json!({"position": "bottom"})).unwrap()["story"].as_u64().unwrap() as u32;
|
||||
let story = StoryRef::Part(id);
|
||||
match kind {
|
||||
"formatting" => s.doc.para_mut(story, &Path::top(0)).unwrap().props.indent_left = Some(12.0),
|
||||
"metadata" => {
|
||||
let InlineObject::Field { locked, .. } = &mut s.doc.para_mut(story, &Path::top(0)).unwrap().objects[0] else {
|
||||
panic!("no page field")
|
||||
};
|
||||
*locked = true;
|
||||
}
|
||||
_ => {
|
||||
let mut para = Paragraph::with_text("User page: ", CharProps::default());
|
||||
para.insert_object(para.len(), field("PAGE"), &CharProps::default()).unwrap();
|
||||
s.doc.story_mut(story).unwrap().push(para_block(para));
|
||||
}
|
||||
}
|
||||
let before = s.doc.clone();
|
||||
s.run("insert.pageNumber", &json!({"position": "bottom", "format": "x of y"})).unwrap();
|
||||
assert_eq!(s.doc, before, "preserve {kind} rather than claim the PAGE as generated");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reopened_cached_page_numbers_keep_noops_and_gallery_changes() {
|
||||
for (command, position) in [("insert.header", "top"), ("insert.footer", "bottom")] {
|
||||
for format in ["plain", "x of y"] {
|
||||
let mut s = Session::new(Document::from_text("Body"));
|
||||
let id = s.run(command, &json!({"text": "Keep this"})).unwrap()["story"].as_u64().unwrap() as u32;
|
||||
let story = StoryRef::Part(id);
|
||||
s.run("insert.pageNumber", &json!({"position": position, "format": format})).unwrap();
|
||||
for object in &mut s.doc.para_mut(story, &Path::top(1)).unwrap().objects {
|
||||
if let InlineObject::Field { instr, result, .. } = object {
|
||||
*result = if instr == "PAGE" { "7" } else { "12" }.into();
|
||||
}
|
||||
}
|
||||
let doc = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap();
|
||||
let id = if position == "top" { doc.last_section.headers.default } else { doc.last_section.footers.default }.unwrap();
|
||||
let story = StoryRef::Part(id);
|
||||
let mut s = Session::new(doc);
|
||||
let cached = |s: &Session, name: &str| {
|
||||
s.doc.para(story, &Path::top(1)).unwrap().objects.iter().find_map(|object| match object {
|
||||
InlineObject::Field { instr, result, .. } if instr == name => Some(result.clone()),
|
||||
_ => None,
|
||||
})
|
||||
};
|
||||
assert_eq!(cached(&s, "PAGE").as_deref(), Some("7"), "the fixture must retain the refreshed PAGE cache after reopen");
|
||||
assert_eq!(cached(&s, "NUMPAGES").as_deref(), (format == "x of y").then_some("12"));
|
||||
let kept = s.doc.story(story).unwrap()[0].clone();
|
||||
let mark = s.doc.para(story, &Path::top(1)).unwrap().mark.clone();
|
||||
let selection = s.sel.clone();
|
||||
s.dirty = false;
|
||||
let before = s.doc.clone();
|
||||
let depth = s.undo_depth();
|
||||
s.run("insert.pageNumber", &json!({"position": position, "format": format})).unwrap();
|
||||
assert_eq!(s.doc, before, "identical requests must retain refreshed results");
|
||||
assert_eq!(s.undo_depth(), depth);
|
||||
assert!(!s.dirty);
|
||||
for (next_format, align) in [("x of y", "right"), ("plain", "left")] {
|
||||
let before = s.doc.clone();
|
||||
s.run("insert.pageNumber", &json!({"position": position, "format": next_format, "align": align})).unwrap();
|
||||
assert_eq!(s.doc.story(story).unwrap()[0], kept);
|
||||
assert_eq!(s.sel, selection);
|
||||
assert_eq!(s.doc.para(story, &Path::top(1)).unwrap().mark, mark);
|
||||
assert_eq!(s.doc.para(story, &Path::top(1)).unwrap().props.align, Some(if align == "right" { Align::Right } else { Align::Left }));
|
||||
assert_eq!(fields(&s.doc, story, "PAGE"), 1);
|
||||
assert_eq!(fields(&s.doc, story, "NUMPAGES"), usize::from(next_format == "x of y"));
|
||||
assert_eq!(cached(&s, "PAGE").as_deref(), Some("7"));
|
||||
if format == "x of y" && next_format == "x of y" {
|
||||
assert_eq!(cached(&s, "NUMPAGES").as_deref(), Some("12"));
|
||||
}
|
||||
let after = s.doc.clone();
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc, before);
|
||||
s.run("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc, after);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -321,7 +321,9 @@ fn template_tile(ui: &mut Ui, app: &mut WordApp, label: &str, template: &str) {
|
||||
let galley = ui.painter().layout_no_wrap(caption.to_string(), regular(12.5), ui.visuals().text_color());
|
||||
let (tile, resp) = ui.allocate_exact_size(vec2(galley.size().x.max(150.0), 194.0 + galley.size().y), Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), caption));
|
||||
let r = Rect::from_min_size(tile.min, vec2(150.0, 194.0));
|
||||
let flows = crate::rtl::flows(ui);
|
||||
let r_min_x = if flows { tile.max.x - 150.0 } else { tile.min.x };
|
||||
let r = Rect::from_min_size(pos2(r_min_x, tile.min.y), vec2(150.0, 194.0));
|
||||
ui.painter().rect(
|
||||
r,
|
||||
2.0,
|
||||
@@ -352,7 +354,8 @@ fn template_tile(ui: &mut Ui, app: &mut WordApp, label: &str, template: &str) {
|
||||
if let Some(h) = tex {
|
||||
ui.painter().image(h.id(), r.shrink(1.0), Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), egui::Color32::WHITE);
|
||||
}
|
||||
ui.painter().galley(pos2(r.min.x, r.max.y), galley, ui.visuals().text_color());
|
||||
let x = if flows { r.max.x - galley.size().x } else { r.min.x };
|
||||
ui.painter().galley(pos2(x, r.max.y), galley, ui.visuals().text_color());
|
||||
if resp.clicked() {
|
||||
let _ = app.run("file.new", json!({"template": template}));
|
||||
app.ui.backstage = false;
|
||||
|
||||
@@ -1156,3 +1156,139 @@ fn the_file_page_scrolls_with_the_wheel_at_the_window_edge() {
|
||||
assert!(after < before - 1.0, "expected doc0.docx to scroll up at the edge: before={before}, after={after}");
|
||||
assert!((bar.right() - 1432.0).abs() < 1.0, "expected the scrollbar at the window edge: {bar:?}");
|
||||
}
|
||||
|
||||
/// Recursively collects bounding rectangles and text entries from egui shapes.
|
||||
fn collect_shapes(shape: &egui::Shape, rects: &mut Vec<egui::Rect>, texts: &mut Vec<(String, egui::Pos2, f32)>) {
|
||||
match shape {
|
||||
egui::Shape::Rect(r) => rects.push(r.rect),
|
||||
egui::Shape::Text(t) => texts.push((t.galley.text().to_string(), t.pos, t.galley.size().x)),
|
||||
egui::Shape::Vec(v) => {
|
||||
for s in v {
|
||||
collect_shapes(s, rects, texts);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// #6151: in an RTL interface (Arabic), template titles on the Start/Home page
|
||||
/// and New page are right-aligned to their preview thumbnails rather than left-aligned.
|
||||
#[test]
|
||||
fn rtl_template_titles_are_right_aligned() {
|
||||
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Services::default());
|
||||
a.ui.language = "ar".into();
|
||||
a.ui.backstage = true;
|
||||
a.ui.backstage_page = "home".into();
|
||||
let ctx = egui::Context::default();
|
||||
let mut last_shapes = Vec::new();
|
||||
for frame in 0..4 {
|
||||
let input = egui::RawInput {
|
||||
time: Some(f64::from(frame) * 0.1),
|
||||
screen_rect: Some(egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(1440.0, 900.0))),
|
||||
..Default::default()
|
||||
};
|
||||
let mut out = ctx.run_ui(input, |ui| {
|
||||
a.logic(&ctx.clone());
|
||||
a.ui(ui);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
if frame == 3 {
|
||||
last_shapes = out.shapes;
|
||||
}
|
||||
}
|
||||
|
||||
let mut rects = Vec::new();
|
||||
let mut texts = Vec::new();
|
||||
for s in &last_shapes {
|
||||
collect_shapes(&s.shape, &mut rects, &mut texts);
|
||||
}
|
||||
|
||||
let blank_caption = crate::i18n::tc("template", "Blank document");
|
||||
let (_, pos, width) = texts.iter().find(|(t, _, _)| t == blank_caption).expect("blank template caption drawn");
|
||||
let thumbnail = rects
|
||||
.iter()
|
||||
.find(|r| (r.width() - 150.0).abs() < 1.0 && (r.height() - 194.0).abs() < 1.0 && (r.max.y - pos.y).abs() < 1.0)
|
||||
.expect("blank template thumbnail rect");
|
||||
|
||||
let text_right = pos.x + width;
|
||||
assert!(
|
||||
(thumbnail.max.x - text_right).abs() < 1.0,
|
||||
"template caption should be right-aligned with thumbnail in RTL: thumbnail.max.x={}, text_right={}, text_left={}, caption={}",
|
||||
thumbnail.max.x,
|
||||
text_right,
|
||||
pos.x,
|
||||
blank_caption,
|
||||
);
|
||||
|
||||
// Verify the New page through the normal UI path in Arabic as well (#6151).
|
||||
a.ui.backstage_page = "new".into();
|
||||
let mut new_out = ctx.run_ui(
|
||||
egui::RawInput {
|
||||
time: Some(0.4),
|
||||
screen_rect: Some(egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(1440.0, 900.0))),
|
||||
..Default::default()
|
||||
},
|
||||
|ui| {
|
||||
a.logic(&ctx.clone());
|
||||
a.ui(ui);
|
||||
},
|
||||
);
|
||||
new_out.textures_delta.clear();
|
||||
let mut new_rects = Vec::new();
|
||||
let mut new_texts = Vec::new();
|
||||
for s in &new_out.shapes {
|
||||
collect_shapes(&s.shape, &mut new_rects, &mut new_texts);
|
||||
}
|
||||
for (label, _) in super::TEMPLATES {
|
||||
let caption = crate::i18n::tc("template", label);
|
||||
if let Some((_, new_pos, new_width)) = new_texts.iter().find(|(t, _, _)| t == caption) {
|
||||
let right = new_pos.x + new_width;
|
||||
let has_matching_thumbnail = new_rects.iter().any(|r| {
|
||||
(r.width() - 150.0).abs() < 1.0
|
||||
&& (r.height() - 194.0).abs() < 1.0
|
||||
&& (r.max.y - new_pos.y).abs() < 1.0
|
||||
&& (r.max.x - right).abs() < 1.0
|
||||
});
|
||||
assert!(
|
||||
has_matching_thumbnail,
|
||||
"New page: caption '{caption}' must be right-aligned with its 150pt thumbnail (right={right}, y={})",
|
||||
new_pos.y
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Also verify that a template tile with a wide caption (>150 pt) aligns both the thumbnail
|
||||
// and caption to the right edge in RTL.
|
||||
let wide_label = "A very long template title that exceeds 150 points width";
|
||||
let mut wide_out = ctx.run_ui(
|
||||
egui::RawInput {
|
||||
time: Some(0.5),
|
||||
screen_rect: Some(egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(1440.0, 900.0))),
|
||||
..Default::default()
|
||||
},
|
||||
|ui| {
|
||||
crate::rtl::root(ui, |ui| {
|
||||
super::template_tile(ui, &mut a, wide_label, "sample");
|
||||
});
|
||||
},
|
||||
);
|
||||
wide_out.textures_delta.clear();
|
||||
let mut wide_rects = Vec::new();
|
||||
let mut wide_texts = Vec::new();
|
||||
for s in &wide_out.shapes {
|
||||
collect_shapes(&s.shape, &mut wide_rects, &mut wide_texts);
|
||||
}
|
||||
let (_, wide_pos, wide_width) = wide_texts.iter().find(|(t, _, _)| t == wide_label).expect("wide template caption drawn");
|
||||
assert!(*wide_width > 150.0, "wide caption should exceed 150 points: {wide_width}");
|
||||
let wide_thumbnail = wide_rects
|
||||
.iter()
|
||||
.find(|r| (r.width() - 150.0).abs() < 1.0 && (r.height() - 194.0).abs() < 1.0 && (r.max.y - wide_pos.y).abs() < 1.0)
|
||||
.expect("wide template thumbnail rect");
|
||||
let wide_text_right = wide_pos.x + wide_width;
|
||||
assert!(
|
||||
(wide_thumbnail.max.x - wide_text_right).abs() < 1.0,
|
||||
"wide template caption and thumbnail should share the same right edge in RTL: thumb.max.x={}, text_right={}",
|
||||
wide_thumbnail.max.x,
|
||||
wide_text_right,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -67,6 +67,10 @@ pub(crate) struct CaptureGeometry {
|
||||
pub(super) document: u64,
|
||||
}
|
||||
|
||||
/// Explanation returned when a capture is attempted on a secondary viewport.
|
||||
pub(crate) const UNSUPPORTED_SECONDARY_WINDOW_ERROR: &str =
|
||||
"capturing a secondary document window is not supported by the viewport renderer; ui.screenshot captures only the window in the root viewport";
|
||||
|
||||
impl CaptureGeometry {
|
||||
/// Returns the document id the capture was admitted for.
|
||||
pub(crate) fn document(self) -> u64 {
|
||||
@@ -85,10 +89,12 @@ impl CaptureGeometry {
|
||||
}
|
||||
}
|
||||
|
||||
/// Explains why the surface cannot be captured, when it is minimized or fully occluded, and
|
||||
/// returns `None` when it can.
|
||||
/// Explains why the surface cannot be captured: when it is in a secondary viewport that the
|
||||
/// viewport renderer cannot capture, or when the window is minimized or fully occluded.
|
||||
pub(crate) fn unavailable_reason(self) -> Option<&'static str> {
|
||||
if self.surface.minimized {
|
||||
if self.viewport != egui::ViewportId::ROOT {
|
||||
Some(UNSUPPORTED_SECONDARY_WINDOW_ERROR)
|
||||
} else if self.surface.minimized {
|
||||
Some("the window is minimized")
|
||||
} else if self.surface.occluded {
|
||||
Some("the window is fully occluded")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::geometry::CaptureGeometry;
|
||||
use super::geometry::{CaptureGeometry, UNSUPPORTED_SECONDARY_WINDOW_ERROR};
|
||||
use super::{CaptureSlot, Sender, Value, WordApp, json};
|
||||
|
||||
/// A page rendered by the engine, as base64 PNG in the reply (the same shape as the headless
|
||||
@@ -128,6 +128,19 @@ pub(crate) fn admit_screenshot(app: &mut WordApp, ctx: &egui::Context, path: Opt
|
||||
reject_shot(reply, token, "screenshot failed: the window geometry is not known yet");
|
||||
return;
|
||||
};
|
||||
if geometry.viewport != egui::ViewportId::ROOT {
|
||||
let msg = match app.root_window_info() {
|
||||
Some((root_key, _doc_id, root_title)) if !root_title.is_empty() => format!(
|
||||
"screenshot failed: {UNSUPPORTED_SECONDARY_WINDOW_ERROR}. Select the root window first (view.switchWindows {{\"window\": {root_key}}}) for {root_title:?}."
|
||||
),
|
||||
Some((root_key, ..)) => format!(
|
||||
"screenshot failed: {UNSUPPORTED_SECONDARY_WINDOW_ERROR}. Select the root window first (view.switchWindows {{\"window\": {root_key}}})."
|
||||
),
|
||||
None => format!("screenshot failed: {UNSUPPORTED_SECONDARY_WINDOW_ERROR}."),
|
||||
};
|
||||
reject_shot(reply, token, msg);
|
||||
return;
|
||||
}
|
||||
if let Some(reason) = geometry.unavailable_reason() {
|
||||
reject_shot(reply, token, format!("screenshot failed: {reason}"));
|
||||
return;
|
||||
|
||||
@@ -12,11 +12,16 @@ use crate::options::MeasureExt;
|
||||
use crate::theme::{Tokens, regular, semibold};
|
||||
|
||||
mod columns;
|
||||
mod date_time;
|
||||
mod field;
|
||||
mod symbol;
|
||||
|
||||
pub use columns::{ColumnsForm, DropCapForm};
|
||||
use columns::{columns_ui, drop_cap_ui};
|
||||
pub use date_time::DateTimeForm;
|
||||
#[cfg(test)]
|
||||
pub(crate) use date_time::date_time_preview_matches;
|
||||
use date_time::date_time_ui;
|
||||
use field::field_ui;
|
||||
pub use field::{DATE_FORMATS, FIELD_CATEGORIES, FIELDS, FieldForm, FieldKind, NUMBER_FORMATS, STYLEREF_SWITCHES, field_kind};
|
||||
use symbol::symbol_ui;
|
||||
@@ -25,6 +30,8 @@ pub use symbol::{RECENT_SYMBOLS, RecentSymbol, SPECIAL, SUBSETS, SymbolForm, rem
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(tag = "form", rename_all = "camelCase")]
|
||||
pub enum InsertDialog {
|
||||
/// Date & Time insertion dialog.
|
||||
DateTime(DateTimeForm),
|
||||
Columns(ColumnsForm),
|
||||
Symbol(SymbolForm),
|
||||
Field(FieldForm),
|
||||
@@ -35,8 +42,10 @@ pub enum InsertDialog {
|
||||
}
|
||||
|
||||
impl InsertDialog {
|
||||
/// The dialog name used in command dispatch and UI state.
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self {
|
||||
InsertDialog::DateTime(_) => "dateTime",
|
||||
InsertDialog::Columns(_) => "columns",
|
||||
InsertDialog::Symbol(_) => "symbol",
|
||||
InsertDialog::Field(_) => "field",
|
||||
@@ -51,6 +60,7 @@ impl InsertDialog {
|
||||
/// The window title (English; the caller translates it).
|
||||
pub fn title(&self) -> &'static str {
|
||||
match self {
|
||||
InsertDialog::DateTime(_) => "Date and Time",
|
||||
InsertDialog::Columns(_) => "Columns",
|
||||
InsertDialog::Symbol(_) => "Symbol",
|
||||
InsertDialog::Field(_) => "Field",
|
||||
@@ -68,6 +78,7 @@ impl InsertDialog {
|
||||
/// `insertSmartArt`, or `dropCap` (Drop Cap Options…, where the paragraph can take one).
|
||||
pub fn open(name: &str, app: &WordApp) -> Option<InsertDialog> {
|
||||
Some(match name {
|
||||
"dateTime" => InsertDialog::DateTime(DateTimeForm::new(app)),
|
||||
"columns" => InsertDialog::Columns(ColumnsForm::read(app)),
|
||||
"symbol" => InsertDialog::Symbol(SymbolForm::new(app, 0)),
|
||||
"specialCharacters" => InsertDialog::Symbol(SymbolForm::new(app, 1)),
|
||||
@@ -84,6 +95,7 @@ pub fn open(name: &str, app: &WordApp) -> Option<InsertDialog> {
|
||||
/// Draws the dialog; returns true to close it.
|
||||
pub fn body(app: &mut WordApp, ui: &mut Ui, d: &mut InsertDialog) -> bool {
|
||||
match d {
|
||||
InsertDialog::DateTime(f) => date_time_ui(app, ui, f),
|
||||
InsertDialog::Columns(f) => columns_ui(app, ui, f),
|
||||
InsertDialog::Symbol(f) => symbol_ui(app, ui, f),
|
||||
InsertDialog::Field(f) => field_ui(app, ui, f),
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
//! Date & Time insertion dialog (Word-style format and language selection).
|
||||
use egui::{Stroke, Ui};
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::buttons;
|
||||
use super::field::DATE_FORMATS;
|
||||
use crate::WordApp;
|
||||
use crate::theme::Tokens;
|
||||
|
||||
/// Predefined Portuguese (Brazil) date and time format pictures.
|
||||
const PT_DATE_FORMATS: &[&str] = &[
|
||||
"dd/MM/yyyy",
|
||||
"dddd, d 'de' MMMM 'de' yyyy",
|
||||
"d 'de' MMMM 'de' yyyy",
|
||||
"dd/MM/yy",
|
||||
"yyyy-MM-dd",
|
||||
"d-MMM-yy",
|
||||
"dd.MM.yyyy",
|
||||
"d MMMM yyyy",
|
||||
"MMM-yy",
|
||||
"dd/MM/yyyy HH:mm",
|
||||
"dd/MM/yyyy HH:mm:ss",
|
||||
"HH:mm",
|
||||
"HH:mm:ss",
|
||||
];
|
||||
|
||||
/// The date preview list consists of document values (not interface strings).
|
||||
/// The i18n audit recognizes these dynamic values instead of treating month names as UI labels.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn date_time_preview_matches(value: &str) -> bool {
|
||||
PT_DATE_FORMATS.iter().chain(DATE_FORMATS.iter()).any(|picture| {
|
||||
wordcraft_engine::cmd::insert::format_date_in(picture, Some("pt-BR")) == value
|
||||
|| wordcraft_engine::cmd::insert::format_date_in(picture, Some("en-US")) == value
|
||||
})
|
||||
}
|
||||
|
||||
/// State of the Word-like Insert > Date & Time dialog, including an optional validation error.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DateTimeForm {
|
||||
/// Selected Word field picture, not a Rust strftime format.
|
||||
pub format: String,
|
||||
/// Language of the resulting text or DATE field.
|
||||
pub language: String,
|
||||
/// Whether to insert a refreshable field rather than fixed text.
|
||||
pub update: bool,
|
||||
/// Last insertion failure; retain the window so the user can change the choice.
|
||||
#[serde(skip)]
|
||||
pub error: Option<String>,
|
||||
/// Brief status confirmation message (e.g. after setting default preferences).
|
||||
#[serde(skip)]
|
||||
pub status: Option<&'static str>,
|
||||
}
|
||||
impl DateTimeForm {
|
||||
/// Restore a saved format/language or choose a sensible default for the UI language.
|
||||
pub fn new(app: &WordApp) -> Self {
|
||||
let language = if app.ui.date_time_language.is_empty() {
|
||||
if crate::i18n::current().code().to_ascii_lowercase().starts_with("pt") { "pt-BR" } else { "en-US" }.to_string()
|
||||
} else if app.ui.date_time_language.to_ascii_lowercase().starts_with("pt") {
|
||||
"pt-BR".to_string()
|
||||
} else {
|
||||
"en-US".to_string()
|
||||
};
|
||||
let list = if language == "pt-BR" { PT_DATE_FORMATS } else { DATE_FORMATS };
|
||||
let format = if list.contains(&app.ui.date_time_format.as_str()) { app.ui.date_time_format.clone() } else { list[0].to_string() };
|
||||
Self { format, language, update: app.ui.date_time_update, error: None, status: None }
|
||||
}
|
||||
/// Pictures available in the chosen language.
|
||||
fn formats(&self) -> &'static [&'static str] {
|
||||
if self.language == "pt-BR" { PT_DATE_FORMATS } else { DATE_FORMATS }
|
||||
}
|
||||
/// Engine command parameters corresponding to the current controls.
|
||||
pub fn params(&self) -> Value {
|
||||
json!({"format": self.format, "language": self.language, "update": self.update})
|
||||
}
|
||||
}
|
||||
/// Draw the interactive format list, language picker and update checkbox; close on successful
|
||||
/// insertion or Cancel, keeping errors visible rather than silently dismissing the dialog.
|
||||
pub(super) fn date_time_ui(app: &mut WordApp, ui: &mut Ui, f: &mut DateTimeForm) -> bool {
|
||||
let tokens = Tokens::get(ui.ctx());
|
||||
ui.set_width(520.0);
|
||||
ui.horizontal_top(|ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.set_width(350.0);
|
||||
ui.label(tl!("Date formats:"));
|
||||
egui::Frame::new().stroke(Stroke::new(1.0, tokens.border)).inner_margin(2.0).show(ui, |ui| {
|
||||
egui::ScrollArea::vertical().id_salt("date_time_formats").max_height(245.0).auto_shrink([false, false]).show(ui, |ui| {
|
||||
for picture in f.formats() {
|
||||
let sample = wordcraft_engine::cmd::insert::format_date_in(picture, Some(&f.language));
|
||||
if ui.selectable_label(f.format == *picture, sample).on_hover_text(*picture).clicked() {
|
||||
f.format = (*picture).to_string();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
ui.add_space(12.0);
|
||||
ui.vertical(|ui| {
|
||||
ui.label(tl!("Language"));
|
||||
craft_ui::ComboBox::from_id_salt("date_time_lang")
|
||||
.selected_text(if f.language == "pt-BR" { tl!("Portuguese (Brazil)") } else { tl!("English (United States)") })
|
||||
.show_ui(ui, |ui| {
|
||||
if ui.selectable_label(f.language == "pt-BR", tl!("Portuguese (Brazil)")).clicked() {
|
||||
f.language = "pt-BR".into();
|
||||
f.format = PT_DATE_FORMATS[0].into();
|
||||
}
|
||||
if ui.selectable_label(f.language == "en-US", tl!("English (United States)")).clicked() {
|
||||
f.language = "en-US".into();
|
||||
f.format = DATE_FORMATS[0].into();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
ui.checkbox(&mut f.update, tl!("Update automatically"));
|
||||
ui.add_space(6.0);
|
||||
if ui.button(tl!("Set as Default")).clicked() {
|
||||
app.ui.date_time_format = f.format.clone();
|
||||
app.ui.date_time_language = f.language.clone();
|
||||
app.ui.date_time_update = f.update;
|
||||
f.status = Some(tl!("Default format saved."));
|
||||
}
|
||||
if let Some(status) = f.status {
|
||||
ui.colored_label(tokens.text_dim, status);
|
||||
}
|
||||
if let Some(error) = &f.error {
|
||||
ui.colored_label(ui.visuals().error_fg_color, error);
|
||||
}
|
||||
let (ok, cancel) = buttons(ui, "OK", "Cancel", true);
|
||||
if ok {
|
||||
match app.run("insert.dateTime", f.params()) {
|
||||
Ok(_) => return true,
|
||||
Err(error) => {
|
||||
f.error = Some(error.to_string());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
cancel
|
||||
}
|
||||
@@ -166,3 +166,155 @@ fn field_dialog_lists_the_document_information_fields() {
|
||||
f.select("SUBJECT");
|
||||
assert_eq!(f.instruction(), "SUBJECT");
|
||||
}
|
||||
|
||||
/// The Date & Time dialog uses the saved pt-BR editing language when present in UI preferences.
|
||||
#[test]
|
||||
fn date_time_dialog_uses_saved_pt_br_language() {
|
||||
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default());
|
||||
a.ui.date_time_language = "pt-BR".into();
|
||||
let InsertDialog::DateTime(mut f) = open("dateTime", &a).expect("dialog") else { panic!("not DateTime") };
|
||||
assert_eq!(f.format, "dd/MM/yyyy");
|
||||
assert_eq!(f.params()["language"], "pt-BR");
|
||||
assert!(!f.update);
|
||||
f.update = false;
|
||||
assert_eq!(f.params()["update"], false);
|
||||
}
|
||||
|
||||
/// Language tags with primary subtag `pt` (such as `pt_BR` or `pt-PT`) are normalized to `pt-BR`.
|
||||
#[test]
|
||||
fn date_time_dialog_normalizes_portuguese_language_tags() {
|
||||
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default());
|
||||
a.ui.date_time_language = "pt_BR".into();
|
||||
let InsertDialog::DateTime(f) = open("dateTime", &a).expect("dialog") else { panic!("not DateTime") };
|
||||
assert_eq!(f.language, "pt-BR");
|
||||
assert_eq!(f.format, "dd/MM/yyyy");
|
||||
|
||||
a.ui.date_time_language = "pt-PT".into();
|
||||
let InsertDialog::DateTime(f) = open("dateTime", &a).expect("dialog") else { panic!("not DateTime") };
|
||||
assert_eq!(f.language, "pt-BR");
|
||||
}
|
||||
|
||||
/// Click the real egui language menu, date picture, checkbox and OK; ensure the resulting
|
||||
/// document contains pt-BR fixed text with the language on its run, not just JSON params.
|
||||
#[test]
|
||||
fn date_time_dialog_controls_insert_portuguese_fixed_text() {
|
||||
use egui::accesskit::Role;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
|
||||
let mut app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default());
|
||||
app.ui.language = "en".into();
|
||||
app.ui.date_time_language = "en-US".into();
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(
|
||||
|ui, app: &mut WordApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.state_mut().run("ui.dialog", json!({"name": "dateTime"})).unwrap();
|
||||
h.run_steps(4);
|
||||
|
||||
// Go through the actual language combo box and then select a long Portuguese date.
|
||||
// Scope the click to this dialog: the ribbon has another English-language button.
|
||||
// The popup choice has a plain accessible label; the main ribbon uses a different control.
|
||||
h.get_by_role_and_label(Role::Window, "Date and Time").get_by_role(Role::ComboBox).click();
|
||||
h.run_steps(3);
|
||||
h.get_by_role_and_label(Role::Button, "Portuguese (Brazil)").click();
|
||||
h.run_steps(3);
|
||||
let sample = wordcraft_engine::cmd::insert::format_date_in("d 'de' MMMM 'de' yyyy", Some("pt-BR"));
|
||||
h.get_by_label(&sample).click();
|
||||
h.run_steps(2);
|
||||
// By default, update is unchecked (false). Toggle it on and off to verify the checkbox.
|
||||
assert!(matches!(
|
||||
h.state().dialog.as_ref(),
|
||||
Some(crate::dialogs::Dialog::Insert(d)) if matches!(&**d, InsertDialog::DateTime(f) if !f.update)
|
||||
));
|
||||
h.get_by_label("Update automatically").click();
|
||||
h.run_steps(2);
|
||||
assert!(matches!(
|
||||
h.state().dialog.as_ref(),
|
||||
Some(crate::dialogs::Dialog::Insert(d)) if matches!(&**d, InsertDialog::DateTime(f) if f.update)
|
||||
));
|
||||
h.get_by_label("Update automatically").click();
|
||||
h.run_steps(2);
|
||||
assert!(matches!(
|
||||
h.state().dialog.as_ref(),
|
||||
Some(crate::dialogs::Dialog::Insert(d)) if matches!(&**d, InsertDialog::DateTime(f) if f.language == "pt-BR" && f.format == "d 'de' MMMM 'de' yyyy" && !f.update)
|
||||
));
|
||||
h.get_by_role_and_label(Role::Button, "OK").click();
|
||||
h.run_steps(3);
|
||||
assert!(h.state().dialog.is_none(), "successful insertion closes the dialog");
|
||||
let doc = &h.state().session.doc;
|
||||
assert_eq!(doc.plain_text(wordcraft_doc::StoryRef::Body), sample);
|
||||
let para = doc.para_at(&doc.start_of(wordcraft_doc::StoryRef::Body)).expect("inserted paragraph");
|
||||
assert_eq!(para.props_of_char(0).lang.as_deref(), Some("pt-BR"));
|
||||
assert!(para.objects.iter().all(|o| !matches!(o, wordcraft_doc::para::InlineObject::Field { .. })));
|
||||
}
|
||||
|
||||
/// Checking 'Update automatically' inserts a refreshable dynamic DATE field.
|
||||
#[test]
|
||||
fn date_time_dialog_controls_insert_dynamic_field() {
|
||||
use egui::accesskit::Role;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
|
||||
let mut app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default());
|
||||
app.ui.language = "en".into();
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(
|
||||
|ui, app: &mut WordApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.state_mut().run("ui.dialog", json!({"name": "dateTime"})).unwrap();
|
||||
h.run_steps(4);
|
||||
|
||||
h.get_by_label("Update automatically").click();
|
||||
h.run_steps(2);
|
||||
assert!(matches!(
|
||||
h.state().dialog.as_ref(),
|
||||
Some(crate::dialogs::Dialog::Insert(d)) if matches!(&**d, InsertDialog::DateTime(f) if f.update)
|
||||
));
|
||||
h.get_by_role_and_label(Role::Button, "OK").click();
|
||||
h.run_steps(3);
|
||||
assert!(h.state().dialog.is_none(), "successful insertion closes the dialog");
|
||||
let doc = &h.state().session.doc;
|
||||
let para = doc.para_at(&doc.start_of(wordcraft_doc::StoryRef::Body)).expect("inserted paragraph");
|
||||
assert!(para.objects.iter().any(|o| matches!(o, wordcraft_doc::para::InlineObject::Field { .. })));
|
||||
}
|
||||
|
||||
/// An invalid saved picture is reported in the modal, not silently dismissed when OK is clicked.
|
||||
#[test]
|
||||
fn date_time_dialog_keeps_open_and_displays_invalid_picture_error() {
|
||||
use egui::accesskit::Role;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
|
||||
let mut app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default());
|
||||
app.ui.language = "en".into();
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(
|
||||
|ui, app: &mut WordApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.state_mut().run("ui.dialog", json!({"name": "dateTime"})).unwrap();
|
||||
if let Some(crate::dialogs::Dialog::Insert(d)) = h.state_mut().dialog.as_mut()
|
||||
&& let InsertDialog::DateTime(f) = &mut **d
|
||||
{
|
||||
f.format = "invalid\\picture".into();
|
||||
} else {
|
||||
panic!("date/time dialog expected");
|
||||
}
|
||||
h.run_steps(3);
|
||||
h.get_by_role_and_label(Role::Button, "OK").click();
|
||||
h.run_steps(3);
|
||||
assert!(matches!(
|
||||
h.state().dialog.as_ref(),
|
||||
Some(crate::dialogs::Dialog::Insert(d)) if matches!(&**d, InsertDialog::DateTime(f) if f.error.as_deref().is_some_and(|e| e.contains("invalid date picture")))
|
||||
));
|
||||
assert_eq!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body), "");
|
||||
}
|
||||
|
||||
@@ -235,6 +235,7 @@ impl<'a> Exempt<'a> {
|
||||
|| matches!(plain, "WordCraft" | "WordCraft User" | "Discord" | "https://" | "auto" | "I. II. III." | "i. ii. iii." | "I, II, III, …" | "i, ii, iii, …")
|
||||
|| plain == "CONFIDENTIAL" // the watermark dialog's text: document content, kept as typed
|
||||
|| plain == "Figure 1" // the caption dialog's sample: the label goes into the document as is
|
||||
|| crate::dialogs_insert::date_time_preview_matches(plain)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -254,7 +254,7 @@ fn controls(tab: &str) -> Vec<Control> {
|
||||
m("WordArt", "insert.wordArt"),
|
||||
m("Drop Cap", "insert.dropCap"),
|
||||
c("Signature Line", "insert.signatureLine"),
|
||||
c("Date & Time", "insert.dateTime"),
|
||||
d("Date & Time", "dateTime"),
|
||||
c("Object", "insert.object"),
|
||||
c("Equation", "insert.equation"),
|
||||
m("Symbol", "insert.symbol"),
|
||||
|
||||
@@ -294,6 +294,12 @@ pub struct UiState {
|
||||
pub recent: Vec<String>,
|
||||
/// Symbols inserted from the Symbol dialog, most recent first (#321).
|
||||
pub recent_symbols: Vec<dialogs_insert::RecentSymbol>,
|
||||
/// Saved choices in Insert > Date & Time: format picture.
|
||||
pub date_time_format: String,
|
||||
/// Saved language tag in Insert > Date & Time.
|
||||
pub date_time_language: String,
|
||||
/// Saved preference for updating inserted date/time fields automatically (defaults to false).
|
||||
pub date_time_update: bool,
|
||||
/// Colours picked in the Colors dialog (More Colors…), most recent first (#606).
|
||||
pub recent_colors: Vec<String>,
|
||||
/// Fonts applied from the font box, the mini toolbar or the Font dialog, most recent first:
|
||||
@@ -445,6 +451,7 @@ pub const MAX_RECENT_FONTS: usize = 10;
|
||||
pub const MAX_FONT_NAME_BYTES: usize = 128;
|
||||
|
||||
impl Default for UiState {
|
||||
/// Default user interface preferences matching Word defaults.
|
||||
fn default() -> Self {
|
||||
UiState {
|
||||
tab: DEFAULT_TAB.into(),
|
||||
@@ -455,6 +462,9 @@ impl Default for UiState {
|
||||
quick_access_toolbar: true,
|
||||
recent: Vec::new(),
|
||||
recent_symbols: Vec::new(),
|
||||
date_time_format: String::new(),
|
||||
date_time_language: String::new(),
|
||||
date_time_update: false,
|
||||
recent_colors: Vec::new(),
|
||||
recent_fonts: Vec::new(),
|
||||
theme: theme::Appearance::default(),
|
||||
|
||||
@@ -44,6 +44,7 @@ pub(super) fn shape_gallery(ui: &mut Ui, app: &mut WordApp) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Render the Insert ribbon tab controls.
|
||||
pub(super) fn insert(app: &mut WordApp, ui: &mut Ui) {
|
||||
group(ui, "Pages", None, app, |ui, app| {
|
||||
stack(ui, |ui| {
|
||||
@@ -189,7 +190,7 @@ pub(super) fn insert(app: &mut WordApp, ui: &mut Ui) {
|
||||
});
|
||||
stack(ui, |ui| {
|
||||
small(ui, app, "signature", None, "Signature Line", crate::widgets::ButtonAction::new("insert.signatureLine", json!({}), false));
|
||||
small(ui, app, "dateTime", None, "Date & Time", crate::widgets::ButtonAction::new("insert.dateTime", json!({}), false));
|
||||
small(ui, app, "dateTime", None, "Date & Time", crate::widgets::ButtonAction::new("ui.dialog", json!({"name": "dateTime"}), false));
|
||||
small(ui, app, "object", None, "Object", crate::widgets::ButtonAction::new("insert.object", json!({}), false));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -261,6 +261,9 @@ impl WordApp {
|
||||
quick_access_toolbar: _,
|
||||
recent: _,
|
||||
recent_symbols: _,
|
||||
date_time_format: _,
|
||||
date_time_language: _,
|
||||
date_time_update: _,
|
||||
recent_colors: _,
|
||||
recent_fonts: _,
|
||||
theme: _,
|
||||
|
||||
@@ -33,6 +33,7 @@ use craft_window::{Document, Frame, Viewports, WindowEvent, WindowId, WindowList
|
||||
use egui::{ViewportCommand, ViewportId};
|
||||
use serde_json::{Value, json};
|
||||
use wordcraft_engine::WindowView;
|
||||
use wordcraft_engine::cmd::view::OtherWindow;
|
||||
|
||||
use crate::{WordApp, documents, window_ui::WindowUi};
|
||||
|
||||
@@ -306,6 +307,17 @@ impl WordApp {
|
||||
(viewport, crate::control::SurfaceGeometry::from_viewport(&info, screen_rect, max_texture_side, ctx))
|
||||
}
|
||||
|
||||
/// Returns the window key, document ID and display title of the window currently occupying
|
||||
/// the root viewport, if one exists and native viewports are in use.
|
||||
pub(crate) fn root_window_info(&self) -> Option<(u64, u64, String)> {
|
||||
let viewports = self.windows.viewports.as_ref()?;
|
||||
let root_id = viewports.root()?;
|
||||
let root_win = self.windows.open.iter().find(|w| w.id == Some(root_id))?;
|
||||
let doc_id = root_win.doc;
|
||||
let title = self.session.documents().iter().find(|d| d.id == doc_id).map(documents::display_title).unwrap_or_default();
|
||||
Some((root_win.key, doc_id, title))
|
||||
}
|
||||
|
||||
/// The viewport new windows start from: the host's size, icon and app id. Their title and
|
||||
/// place are the app's.
|
||||
pub fn set_window_builder(&mut self, builder: egui::ViewportBuilder) {
|
||||
@@ -663,6 +675,15 @@ impl WordApp {
|
||||
self.settle_windows();
|
||||
if let Some(&active) = self.windows.recent_keys().first() {
|
||||
self.enter_window(active);
|
||||
self.session.windows.current = Some(active);
|
||||
if self.session.windows.available {
|
||||
self.session.windows.others = self
|
||||
.switch_windows_entries()
|
||||
.into_iter()
|
||||
.filter(|(k, ..)| *k != active)
|
||||
.map(|(id, title, _)| OtherWindow { id, title })
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -71,6 +71,7 @@ pub(crate) fn poll(app: &mut WordApp, _ctx: &egui::Context) {
|
||||
};
|
||||
let w = &mut app.session.windows;
|
||||
w.available = native;
|
||||
w.current = Some(key);
|
||||
w.others = others;
|
||||
w.side_by_side = partner;
|
||||
w.sync_scroll = sync;
|
||||
@@ -99,6 +100,11 @@ pub(crate) fn request(app: &mut WordApp, req: &Value) {
|
||||
let key = app.windows.shown;
|
||||
let id = |k: &str| req.get(k).and_then(Value::as_u64);
|
||||
match what {
|
||||
"switchWindow" => {
|
||||
if let Some(w) = id("window") {
|
||||
app.show_window(w);
|
||||
}
|
||||
}
|
||||
"sideBySide" => {
|
||||
app.canvas.sync_in = 0.0;
|
||||
if id("off").is_some() {
|
||||
|
||||
@@ -386,11 +386,14 @@ fn control_typing_reaches_the_active_window() {
|
||||
assert_eq!(text(&a), "First", "the other window got none of it");
|
||||
}
|
||||
|
||||
/// `ui.screenshot` captures the active window: the capture is sized from its viewport and the
|
||||
/// command goes to that viewport. A capture still pending when another window comes forward
|
||||
/// fails with an error instead of capturing that window.
|
||||
/// `ui.screenshot` captures the window in the root viewport: capturing a secondary document
|
||||
/// window in a separate native viewport is not supported by the viewport renderer and is refused
|
||||
/// immediately at admission with an error directing to `view.switchWindows` (#6159). A capture
|
||||
/// still pending when another window comes forward fails with an error instead of capturing that
|
||||
/// window. When a window is adopted into the root viewport after the first window closes, it can
|
||||
/// be captured.
|
||||
#[test]
|
||||
fn screenshot_captures_the_active_window() {
|
||||
fn screenshot_captures_the_root_window_and_refuses_secondary_windows() {
|
||||
let ctx = backend();
|
||||
let mut a = app("First");
|
||||
frames(&ctx, &mut a, 2);
|
||||
@@ -403,26 +406,102 @@ fn screenshot_captures_the_active_window() {
|
||||
|
||||
let (reply, replies) = std::sync::mpsc::channel();
|
||||
crate::control::admit_screenshot(&mut a, &ctx, None, reply);
|
||||
assert!(replies.try_recv().is_err(), "the capture is admitted");
|
||||
assert_eq!(a.shots.requests.iter().map(|s| (s.viewport, s.expected)).collect::<Vec<_>>(), [(child, [800, 600])]);
|
||||
let v = replies.try_recv().expect("a secondary window capture is refused at admission");
|
||||
assert_eq!(v["ok"], false, "{v}");
|
||||
let err = v["error"].as_str().expect("error message");
|
||||
assert!(err.contains("capturing a secondary document window is not supported by the viewport renderer"), "error explains why: {err}");
|
||||
assert!(err.contains("view.switchWindows {\"window\": 1}"), "error tells how to activate the root window: {err}");
|
||||
assert!(a.shots.requests.is_empty(), "refused capture is never queued");
|
||||
|
||||
focus(ViewportId::ROOT);
|
||||
frames(&ctx, &mut a, 2);
|
||||
assert_eq!(a.active_viewport(), ViewportId::ROOT, "the root window is now active");
|
||||
let (reply, replies) = std::sync::mpsc::channel();
|
||||
crate::control::admit_screenshot(&mut a, &ctx, None, reply);
|
||||
assert!(replies.try_recv().is_err(), "the capture of the root window is admitted");
|
||||
assert_eq!(a.shots.requests.len(), 1);
|
||||
assert_eq!(a.shots.requests[0].viewport, ViewportId::ROOT);
|
||||
a.shots.requests.iter_mut().for_each(|s| s.ready_at = 0.0);
|
||||
COMMANDS.with(|c| c.borrow_mut().clear());
|
||||
frames(&ctx, &mut a, 4);
|
||||
let shot_to =
|
||||
|id: ViewportId| COMMANDS.with(|c| c.borrow().get(&id).is_some_and(|v| v.iter().any(|c| matches!(c, ViewportCommand::Screenshot(_)))));
|
||||
assert!(shot_to(child), "the active window is captured");
|
||||
assert!(!shot_to(ViewportId::ROOT), "the first window is not");
|
||||
assert!(shot_to(ViewportId::ROOT), "the root window is captured");
|
||||
assert!(!shot_to(child), "the child window is not");
|
||||
|
||||
let (reply, later) = std::sync::mpsc::channel();
|
||||
crate::control::admit_screenshot(&mut a, &ctx, None, reply);
|
||||
focus(child);
|
||||
frames(&ctx, &mut a, 2);
|
||||
let v = later.try_recv().expect("the capture was answered");
|
||||
assert_eq!(v["ok"], false);
|
||||
assert!(v["error"].as_str().is_some_and(|e| e.contains("another window")), "{v}");
|
||||
assert!(a.shots.requests.is_empty());
|
||||
|
||||
// When the first window closes, the child window is adopted into the root viewport:
|
||||
let first_key = a.windows.open.iter().find(|w| w.id == a.windows.viewports.as_ref().unwrap().root()).unwrap().key;
|
||||
let child_id = a.windows.open.iter().find(|w| w.key != first_key).unwrap().id;
|
||||
a.close_window(first_key);
|
||||
a.run("ui.saveChanges", json!({"answer": "dontSave"})).unwrap();
|
||||
frames(&ctx, &mut a, 2);
|
||||
assert_eq!(a.window_count(), 1);
|
||||
assert_eq!(a.windows.viewports.as_ref().unwrap().root(), child_id);
|
||||
assert_eq!(a.active_viewport(), ViewportId::ROOT, "the adopted window now occupies the root viewport");
|
||||
let (reply, replies) = std::sync::mpsc::channel();
|
||||
crate::control::admit_screenshot(&mut a, &ctx, None, reply);
|
||||
assert!(replies.try_recv().is_err(), "the adopted window's capture is admitted");
|
||||
assert_eq!(a.shots.requests.len(), 1);
|
||||
assert_eq!(a.shots.requests[0].viewport, ViewportId::ROOT);
|
||||
}
|
||||
|
||||
/// #6159: when two windows are open on the same document (`view.newWindow`), `ui.screenshot`
|
||||
/// while the secondary window is active is refused at admission. Using `view.switchWindows` with
|
||||
/// `window` selects the root window, allowing a screenshot retry to be admitted and issued.
|
||||
#[test]
|
||||
fn screenshot_refuses_secondary_window_on_same_document_and_recovers_via_switch_windows() {
|
||||
let ctx = backend();
|
||||
let mut a = app("Shared Doc");
|
||||
frames(&ctx, &mut a, 2);
|
||||
a.run("view.newWindow", json!({})).unwrap();
|
||||
let child = a.active_viewport();
|
||||
let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0));
|
||||
INFOS.with(|i| i.borrow_mut().entry(child).or_default().inner_rect = Some(rect));
|
||||
focus(child);
|
||||
frames(&ctx, &mut a, 4);
|
||||
assert_ne!(a.active_viewport(), ViewportId::ROOT);
|
||||
|
||||
let (reply, replies) = std::sync::mpsc::channel();
|
||||
crate::control::admit_screenshot(&mut a, &ctx, None, reply);
|
||||
let v = replies.try_recv().expect("secondary window screenshot refused at admission");
|
||||
assert_eq!(v["ok"], false, "{v}");
|
||||
let err = v["error"].as_str().expect("error message");
|
||||
assert!(err.contains("capturing a secondary document window is not supported by the viewport renderer"), "error: {err}");
|
||||
assert!(err.contains("view.switchWindows {\"window\": 1}"), "error recommends root window selection: {err}");
|
||||
|
||||
// Recovery path: select the root window via view.switchWindows {"window": 1}
|
||||
COMMANDS.with(|c| c.borrow_mut().clear());
|
||||
a.run("view.switchWindows", json!({"window": 1})).unwrap();
|
||||
assert_eq!(a.active_viewport(), ViewportId::ROOT, "switchWindows selected the root window");
|
||||
// Simulate the desktop applying the focus request:
|
||||
focus(ViewportId::ROOT);
|
||||
frames(&ctx, &mut a, 2);
|
||||
for r in [replies, later] {
|
||||
let v = r.try_recv().expect("the capture was answered");
|
||||
assert_eq!(v["ok"], false);
|
||||
assert!(v["error"].as_str().is_some_and(|e| e.contains("another window")), "{v}");
|
||||
}
|
||||
assert!(a.shots.requests.is_empty());
|
||||
let root_focused = COMMANDS.with(|c| c.borrow().get(&ViewportId::ROOT).is_some_and(|v| v.iter().any(|c| matches!(c, ViewportCommand::Focus))));
|
||||
assert!(root_focused, "switchWindows requested focus on root viewport");
|
||||
assert_eq!(a.active_viewport(), ViewportId::ROOT, "root window is now active");
|
||||
|
||||
let (reply, replies) = std::sync::mpsc::channel();
|
||||
crate::control::admit_screenshot(&mut a, &ctx, None, reply);
|
||||
assert!(replies.try_recv().is_err(), "screenshot admitted on retry");
|
||||
assert_eq!(a.shots.requests.len(), 1);
|
||||
assert_eq!(a.shots.requests[0].viewport, ViewportId::ROOT);
|
||||
|
||||
a.shots.requests.iter_mut().for_each(|s| s.ready_at = 0.0);
|
||||
COMMANDS.with(|c| c.borrow_mut().clear());
|
||||
frames(&ctx, &mut a, 4);
|
||||
let shot_to =
|
||||
|id: ViewportId| COMMANDS.with(|c| c.borrow().get(&id).is_some_and(|v| v.iter().any(|c| matches!(c, ViewportCommand::Screenshot(_)))));
|
||||
assert!(shot_to(ViewportId::ROOT), "root window screenshot was issued");
|
||||
assert!(!shot_to(child), "secondary window was not issued");
|
||||
}
|
||||
|
||||
/// Quitting with two documents of unsaved changes asks about each in its window, the window
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
//! Release and source Flatpaks must allow release checks and host update helpers.
|
||||
fn finish_args(manifest: &str) -> Vec<&str> {
|
||||
manifest
|
||||
.split("finish-args:")
|
||||
.nth(1)
|
||||
.unwrap_or_default()
|
||||
.lines()
|
||||
.take_while(|line| line.is_empty() || line.starts_with(' ') || line.starts_with('#'))
|
||||
.filter_map(|line| line.trim().strip_prefix("- "))
|
||||
.map(|arg| arg.trim_matches(['\'', '"']))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flatpak_update_permissions() {
|
||||
let manifests = [
|
||||
include_str!("../../../packaging/linux/flatpak/ai.storyteller.wordcraft.bundle.yml"),
|
||||
include_str!("../../../packaging/linux/flatpak/ai.storyteller.wordcraft.yml"),
|
||||
];
|
||||
let args = manifests.map(finish_args);
|
||||
assert_eq!(args[0], args[1], "source and release permissions must agree");
|
||||
for permissions in args {
|
||||
for required in ["--share=network", "--talk-name=org.freedesktop.Flatpak"] {
|
||||
assert!(permissions.contains(&required), "missing update permission: {required}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
# Control protocol
|
||||
|
||||
> **Last updated:** 2026-10-31 · #2596: accept-thread startup failure disables the server and removes its key file; MCP retry policy distinguishes file exports from in-memory images.
|
||||
> **Last updated:** 2026-11-09 · #6159: refuse ui.screenshot on secondary document windows and support root window selection with view.switchWindows.
|
||||
|
||||
Start the app with a control port: `wordcraft --control 7981` (or `WORDCRAFT_CONTROL_PORT=7981`).
|
||||
A missing or invalid port after `--control` (not 1–65535) prints an error and exits with status 2.
|
||||
@@ -72,9 +72,11 @@ Unauthenticated connections are limited to 64 while they wait for their first au
|
||||
The app's native windows (one per document, or several on one document) take part in View ›
|
||||
Window (#322): `view.arrangeAll` tiles them over the monitor, `view.sideBySide {value?, window?}`
|
||||
places this window and another one in the left and right halves and turns on Synchronous
|
||||
Scrolling, `view.syncScroll {value?}` turns that on or off, and `view.switchWindows {id?}` lists
|
||||
and switches documents. `window` is a window id from `ui.inspect` → `windows.others`, the windows
|
||||
other than the one the command runs in (optional when only one other window is open). The
|
||||
Scrolling, `view.syncScroll {value?}` turns that on or off, and `view.switchWindows {id?, window?}`
|
||||
lists documents and switches documents or windows. For Side by Side, `window` is an id from
|
||||
`ui.inspect` → `windows.others` and is optional when only one other window is open. For Switch
|
||||
Windows, `window` is an open window's key. A secondary-window screenshot refusal can include
|
||||
the root window key in the suggested `view.switchWindows` command. The
|
||||
commands are disabled where there is one window (web, CLI, MCP without the app). Wayland doesn't
|
||||
let apps move windows, so there Arrange All and Side by Side only resize.
|
||||
|
||||
@@ -93,7 +95,7 @@ let apps move windows, so there Arrange All and Side by Side only resize.
|
||||
| `ui.wheel` | `x`, `y`, `dy`, `dx?`, `unit?` (`point`/`line`/`page`), `phase?` (`start`/`move`/`end`), modifiers | mouse-wheel or trackpad scroll at window coordinates (`dy` < 0 scrolls down) |
|
||||
| `ui.key` | `key` (`B`, `Enter`, `Left`…), `shift?`, `alt?`, `cmd?` | key press through egui (shortcuts apply) |
|
||||
| `ui.text` | `text` | typed text through egui |
|
||||
| `ui.screenshot` | `path?` | PNG of the window: written to `path`, or without one returned as base64 `png` in the reply |
|
||||
| `ui.screenshot` | `path?` | PNG of the window in the root viewport: written to `path`, or without one returned as base64 `png` in the reply. Capturing a secondary document window is not supported by the viewport renderer; select the root window with `view.switchWindows {"window": root}` first |
|
||||
| `ui.render` | `path?`, `page?` (1-based), `scale?`, `doc?` | PNG of a page, rendered by the engine: written to `path`, or without one returned as base64 `png` in the reply (never a fixed name in the shared temporary folder) |
|
||||
| `ui.parity` | — | feature-catalog parity |
|
||||
| `ui.resize` | `width`, `height` | asks for that window content size in points; sizes that aren't positive and finite are refused, the rest are clamped to the app's minimum (760 × 480) and to the largest surface the GPU can draw (at most 16384; that limit wins over the minimum), and the reply's `width` and `height` are the size asked for |
|
||||
@@ -131,7 +133,7 @@ window); in the browser, the one the window shows. Commands work on it unless to
|
||||
(`"alreadyOpen": true`). Its result carries the document's `id`.
|
||||
- `view.switchWindows` lists the open documents (`{"documents": [{"id", "title", "path",
|
||||
"dirty", "active"}]}`); with `{"id": n}` it makes that document active and brings its window to
|
||||
the front.
|
||||
the front. With `{"window": key}` it selects that window directly and brings it to the front.
|
||||
- `file.closeDocument {"id"?, "force"?}` closes a document; its window closes with it unless it
|
||||
is the last window.
|
||||
- `engine.execute`, `document.inspect` and `ui.render` take `doc`, the id of the document to work
|
||||
@@ -154,6 +156,14 @@ Input and window methods go to the active window, the one focused last: `ui.clic
|
||||
`ui.screenshot` (a PNG of that window), `ui.inspect` (its size), `ui.resize` and `ui.focus`. To
|
||||
work in another window, make its document active first with `view.switchWindows {"id": n}`.
|
||||
|
||||
`ui.screenshot` is the exception when multiple document windows are open: the desktop renderer
|
||||
captures only the window in the root viewport (the first window opened, until that window closes and
|
||||
another is adopted in its place). While the active window is a secondary document window in a separate
|
||||
native viewport, `ui.screenshot` returns an error directing the caller to select the root window
|
||||
instead of hanging for 8 seconds and timing out. Select the root window with
|
||||
`view.switchWindows {"window": root}` and request the capture again. The error gives the root
|
||||
window key when it is available.
|
||||
|
||||
The `CharProps` and `ParaProps` that `format.set`, `para.set` and `styles.modify` take are listed
|
||||
in [MCP server](mcp.md#character-and-paragraph-properties).
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ runtime-version: '25.08'
|
||||
sdk: org.freedesktop.Sdk
|
||||
command: wordcraft
|
||||
finish-args:
|
||||
# The shared updater inspects and updates this installation via flatpak-spawn --host.
|
||||
- --talk-name=org.freedesktop.Flatpak
|
||||
- --filesystem=xdg-data/ArtCraft/versions:ro
|
||||
# Display: Wayland first; X11 only when no Wayland compositor is running.
|
||||
- --socket=wayland
|
||||
|
||||
@@ -18,6 +18,8 @@ sdk-extensions:
|
||||
- org.freedesktop.Sdk.Extension.rust-stable
|
||||
command: wordcraft
|
||||
finish-args:
|
||||
# The shared updater inspects and updates this installation via flatpak-spawn --host.
|
||||
- --talk-name=org.freedesktop.Flatpak
|
||||
- --filesystem=xdg-data/ArtCraft/versions:ro
|
||||
# Display: Wayland first; X11 only when no Wayland compositor is running.
|
||||
- --socket=wayland
|
||||
|
||||
@@ -25,7 +25,8 @@ description: |-
|
||||
# The binaries link only glibc and libgcc_s directly. Windowing (X11/Wayland, xkbcommon) and the
|
||||
# GPU (Vulkan, EGL/GL) are loaded at runtime, so they are dependencies/recommendations here
|
||||
# rather than ELF NEEDED entries. Read Aloud speaks through espeak-ng (or Speech Dispatcher), a
|
||||
# program it starts, so that is a recommendation too.
|
||||
# program it starts, so that is a recommendation too. Without the X11 libraries the app stays on
|
||||
# native Wayland rather than starting through XWayland (see `uses_x11` in main.rs).
|
||||
overrides:
|
||||
deb:
|
||||
depends:
|
||||
@@ -37,6 +38,7 @@ overrides:
|
||||
- libwayland-client0
|
||||
- libx11-6
|
||||
- libx11-xcb1
|
||||
- libxkbcommon-x11-0
|
||||
- libxcursor1
|
||||
- libxi6
|
||||
- libxrandr2
|
||||
@@ -55,6 +57,8 @@ overrides:
|
||||
recommends:
|
||||
- libwayland-client
|
||||
- libX11
|
||||
- libX11-xcb
|
||||
- libxkbcommon-x11
|
||||
- libXcursor
|
||||
- libXi
|
||||
- libXrandr
|
||||
|
||||
Reference in New Issue
Block a user