mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-12 08:53:27 -06:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ed29eb8bf | ||
|
|
e2ae3ac924 |
@@ -1,57 +0,0 @@
|
||||
# This file is machine-generated by `gh actions-lock`.
|
||||
# Do not edit by hand; run `gh actions-lock` to update.
|
||||
# Docs: https://gh.io/actions-lockfile
|
||||
version: 'v0.0.3'
|
||||
workflows:
|
||||
'.github/workflows/ci.yml': []
|
||||
'.github/workflows/freebsd.yml': []
|
||||
'.github/workflows/house-rules.yml': []
|
||||
'.github/workflows/lockfile.yml':
|
||||
- 'actions/checkout@v7.0.1'
|
||||
- 'step-security/harden-runner@v2.22.4'
|
||||
'.github/workflows/nix.yml': []
|
||||
'.github/workflows/packaging-lint.yml':
|
||||
- 'actions/checkout@v7.0.1'
|
||||
- 'step-security/harden-runner@v2.22.4'
|
||||
'.github/workflows/printing.yml':
|
||||
- 'actions/checkout@v7.0.1'
|
||||
- 'step-security/dtolnay-rust-toolchain@v1.0.0'
|
||||
- 'step-security/harden-runner@v2.22.4'
|
||||
- 'step-security/rust-cache@v2.9.2'
|
||||
- 'storytold/craft-common-actions@main'
|
||||
'.github/workflows/release.yml': []
|
||||
'.github/workflows/sonarcloud.yml': []
|
||||
'.github/workflows/windows-arm64.yml': []
|
||||
dependencies:
|
||||
'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1':
|
||||
ref: 'v7.0.1'
|
||||
commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1'
|
||||
owner_id: 44036562
|
||||
repo_id: 197814629
|
||||
'actions/checkout@v7.0.1':
|
||||
ref: 'v7.0.1'
|
||||
commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1'
|
||||
owner_id: 44036562
|
||||
repo_id: 197814629
|
||||
'step-security/dtolnay-rust-toolchain@v1.0.0':
|
||||
ref: 'v1.0.0'
|
||||
commit: 'sha1-8dd607977d06adf4ed49b190381e74c17a9dd25f'
|
||||
owner_id: 88700172
|
||||
repo_id: 1249865941
|
||||
'step-security/harden-runner@v2.22.4':
|
||||
ref: 'v2.22.4'
|
||||
commit: 'sha1-1d88384fd173af6ecb012dfd0264a306b711241a'
|
||||
owner_id: 88700172
|
||||
repo_id: 422287306
|
||||
'step-security/rust-cache@v2.9.2':
|
||||
ref: 'v2.9.2'
|
||||
commit: 'sha1-8871c8ba063416da7c32246932ea6ae08700453e'
|
||||
owner_id: 88700172
|
||||
repo_id: 822636713
|
||||
'storytold/craft-common-actions@main':
|
||||
ref: 'main'
|
||||
commit: 'sha1-293de72429d3ed5d9fa92aded5e500544c0fa28f'
|
||||
owner_id: 76897702
|
||||
repo_id: 1414429108
|
||||
uses:
|
||||
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
|
||||
@@ -1,34 +0,0 @@
|
||||
name: Lockfile
|
||||
|
||||
# Fails when .github/workflows/actions.lock is out of sync with the workflows, or when a locked pin
|
||||
# no longer verifies upstream, so a moved tag cannot slip a different commit into a run.
|
||||
# Docs: https://gh.io/actions-lockfile
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
|
||||
jobs:
|
||||
lockfile:
|
||||
name: Lockfile is in sync
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Harden runner
|
||||
uses: step-security/harden-runner@v2.22.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install gh-actions-lock
|
||||
run: gh extension install github/gh-actions-lock --pin v0.4.1
|
||||
- name: Verify the lockfile
|
||||
run: gh actions-lock --verify
|
||||
@@ -27,11 +27,7 @@ jobs:
|
||||
name: Windows installation policy (no app build)
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Harden runner
|
||||
uses: step-security/harden-runner@v2.22.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
- uses: actions/checkout@v7.0.1
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install the pinned packaging toolchain
|
||||
|
||||
@@ -42,11 +42,7 @@ jobs:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: '0'
|
||||
steps:
|
||||
- name: Harden runner
|
||||
uses: step-security/harden-runner@v2.22.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
- uses: actions/checkout@v7.0.1
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -68,11 +64,6 @@ 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: |
|
||||
@@ -81,13 +72,12 @@ jobs:
|
||||
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcursor-dev libxrandr-dev \
|
||||
libxi-dev libgtk-3-dev libfontconfig1-dev libfreetype-dev libvulkan1 mesa-vulkan-drivers
|
||||
|
||||
- uses: step-security/dtolnay-rust-toolchain@v1.0.0
|
||||
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
|
||||
with:
|
||||
toolchain: stable
|
||||
components: rustfmt, clippy
|
||||
targets: wasm32-unknown-unknown
|
||||
|
||||
- uses: step-security/rust-cache@v2.9.2
|
||||
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
|
||||
with:
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
# A nightly dry run on main: every release build except macOS, nothing signed or released
|
||||
# (craft-common-actions release.yml `dry-run`). Finds release-only breakage before release day.
|
||||
schedule:
|
||||
- cron: "0 3 * * *"
|
||||
push:
|
||||
branches: [release]
|
||||
workflow_dispatch:
|
||||
@@ -37,7 +33,6 @@ jobs:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
|
||||
with:
|
||||
dry-run: ${{ github.event_name == 'schedule' }}
|
||||
app-name: wordcraft
|
||||
app-title: WordCraft
|
||||
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
|
||||
Generated
+22
-26
@@ -1081,7 +1081,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-pdf"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
|
||||
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=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dunce",
|
||||
@@ -1145,7 +1145,6 @@ dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
"plist",
|
||||
"rustix 1.1.5",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -2982,7 +2981,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "krilla"
|
||||
version = "0.8.2"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bumpalo",
|
||||
@@ -6807,7 +6806,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft"
|
||||
version = "0.18.1"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"chrono",
|
||||
@@ -6839,15 +6838,13 @@ dependencies = [
|
||||
"wordcraft-engine",
|
||||
"wordcraft-fonts",
|
||||
"wordcraft-ui-egui",
|
||||
"x11-dl",
|
||||
"x11rb",
|
||||
"xkbcommon-dl",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-cli"
|
||||
version = "0.18.1"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"serde_json",
|
||||
@@ -6863,11 +6860,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-control-key"
|
||||
version = "0.18.1"
|
||||
version = "0.15.1"
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-doc"
|
||||
version = "0.18.1"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"log",
|
||||
@@ -6881,7 +6878,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-docbin"
|
||||
version = "0.18.1"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"cfb",
|
||||
"chrono",
|
||||
@@ -6898,7 +6895,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-docx"
|
||||
version = "0.18.1"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"base64 0.23.1",
|
||||
@@ -6923,7 +6920,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-engine"
|
||||
version = "0.18.1"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"cfb",
|
||||
"chrono",
|
||||
@@ -6938,7 +6935,6 @@ dependencies = [
|
||||
"log",
|
||||
"regex",
|
||||
"regex-automata",
|
||||
"rustix 1.1.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
@@ -6957,7 +6953,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-fonts"
|
||||
version = "0.18.1"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"brotli-decompressor",
|
||||
"harfrust 0.13.3",
|
||||
@@ -6971,7 +6967,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-formats"
|
||||
version = "0.18.1"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"base64 0.23.1",
|
||||
@@ -6998,7 +6994,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-geom"
|
||||
version = "0.18.1"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"serde",
|
||||
@@ -7006,7 +7002,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-layout"
|
||||
version = "0.18.1"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"craft-proof",
|
||||
"image",
|
||||
@@ -7026,7 +7022,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-mcp"
|
||||
version = "0.18.1"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde_json",
|
||||
@@ -7038,7 +7034,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-metafile"
|
||||
version = "0.18.1"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"thiserror 2.0.21",
|
||||
@@ -7046,7 +7042,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-pdf"
|
||||
version = "0.18.1"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"craft-pdf",
|
||||
@@ -7067,7 +7063,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-render"
|
||||
version = "0.18.1"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -7081,7 +7077,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-ui-egui"
|
||||
version = "0.18.1"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"accesskit_consumer",
|
||||
"cfb",
|
||||
@@ -7114,7 +7110,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-web"
|
||||
version = "0.18.1"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"craft-web-api",
|
||||
"craft-web-host",
|
||||
@@ -7135,7 +7131,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-zotero"
|
||||
version = "0.18.1"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -7298,7 +7294,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.18.1"
|
||||
version = "0.15.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.18.1"
|
||||
version = "0.15.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 = "6e03f65bf45181db77ba6229716e6624db9eb25b", version = "=0.1.0", default-features = false, features = ["write"] }
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "1b34eba41e62138fc2438d2734523b302840b45d", 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 = "6e03f65bf45181db77ba6229716e6624db9eb25b", features = ["egui"] }
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "1b34eba41e62138fc2438d2734523b302840b45d", 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 = "6e03f65bf45181db77ba6229716e6624db9eb25b" }
|
||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "1b34eba41e62138fc2438d2734523b302840b45d" }
|
||||
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,11 +354,6 @@ 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 |
|
||||
|
||||
+3
-3
File diff suppressed because one or more lines are too long
@@ -11,7 +11,7 @@ use std::path::{Path, PathBuf};
|
||||
use std::process::ExitCode;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use wordcraft_engine::{CmdError, Session};
|
||||
use wordcraft_engine::Session;
|
||||
|
||||
const USAGE: &str = "\
|
||||
wordcraft-cli — WordCraft from the command line
|
||||
@@ -19,8 +19,7 @@ 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
|
||||
(if protection blocks updates, they are skipped with a warning);
|
||||
xml); --update-fields refreshes unlocked fields before saving;
|
||||
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
|
||||
@@ -452,30 +451,6 @@ 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)?;
|
||||
@@ -498,7 +473,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") {
|
||||
update_fields(&mut s)?;
|
||||
s.run("references.updateFields", &json!({})).map_err(engine_error)?;
|
||||
}
|
||||
let r = if to_png {
|
||||
s.run("file.exportPng", &json!({"path": o, "page": 1, "markup": markup, "strict": strict}))
|
||||
|
||||
@@ -47,65 +47,3 @@ 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,11 +64,6 @@ 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::ClipboardPicture;
|
||||
use wordcraft_ui_egui::paste_picture::{self, ClipboardPicture};
|
||||
|
||||
fn wayland() -> bool {
|
||||
std::env::var_os("WAYLAND_DISPLAY").is_some()
|
||||
@@ -348,22 +348,6 @@ 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::*;
|
||||
@@ -385,6 +369,22 @@ 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,16 +276,9 @@ 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. 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.
|
||||
/// available falls back to the other instead of failing to start.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
fn uses_x11(
|
||||
setting: wordcraft_ui_egui::DisplayServer,
|
||||
env: impl Fn(&str) -> Option<std::ffi::OsString>,
|
||||
x11_libraries: impl FnOnce() -> Result<(), String>,
|
||||
) -> (bool, String) {
|
||||
fn uses_x11(setting: wordcraft_ui_egui::DisplayServer, env: impl Fn(&str) -> Option<std::ffi::OsString>) -> (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") {
|
||||
@@ -295,39 +288,17 @@ fn uses_x11(
|
||||
Some(_) => (DisplayServer::Wayland, "WORDCRAFT_WAYLAND"),
|
||||
None => (setting, "the setting"),
|
||||
};
|
||||
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 x11 = wanted != DisplayServer::Wayland && set("DISPLAY");
|
||||
let runs = if x11 {
|
||||
"X11"
|
||||
} else if wayland {
|
||||
} else if set("WAYLAND_DISPLAY") || set("WAYLAND_SOCKET") {
|
||||
"native Wayland"
|
||||
} else if set("DISPLAY") {
|
||||
"X11 (no Wayland)"
|
||||
} else {
|
||||
"neither (no Wayland or X server found)"
|
||||
};
|
||||
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("; ")) }
|
||||
(x11, format!("display: {runs}; {} asks for {}", by, wanted.code()))
|
||||
}
|
||||
|
||||
fn save_prefs(app: &WordApp) {
|
||||
@@ -587,7 +558,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), x11_libraries);
|
||||
let (x11, why) = uses_x11(saved.as_ref().map(|ui| ui.display_server).unwrap_or_default(), |name| std::env::var_os(name));
|
||||
log::info!("{why}");
|
||||
if x11 {
|
||||
options.event_loop_builder = Some(Box::new(|b| {
|
||||
@@ -719,7 +690,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)), || Ok(())).0
|
||||
uses_x11(setting, |name| vars.iter().find(|(k, _)| *k == name).map(|(_, v)| std::ffi::OsString::from(v))).0
|
||||
};
|
||||
let both = [("DISPLAY", ":0"), ("WAYLAND_DISPLAY", "wayland-0")];
|
||||
assert!(x11(Auto, &both), "Automatic keeps XWayland, for file drag-and-drop");
|
||||
@@ -735,40 +706,10 @@ 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()), || Ok(()));
|
||||
let (forced, why) = uses_x11(Wayland, |name| (name == "DISPLAY").then(|| ":0".into()));
|
||||
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]
|
||||
|
||||
@@ -348,7 +348,7 @@ fn read_limited(bytes: &[u8], password: Option<&str>, max_paragraphs: usize) ->
|
||||
0 | 1 | 4 => PartKind::Header,
|
||||
_ => PartKind::Footer,
|
||||
};
|
||||
let blocks = header_story_blocks(&ctx, a, b, &mut pics, max_paragraphs)?;
|
||||
let blocks = story_blocks(&ctx, a, b, &mut pics, max_paragraphs)?;
|
||||
if !blocks.is_empty() {
|
||||
let id = doc.add_part(kind, blocks);
|
||||
hf_ids.push((si, k, id));
|
||||
@@ -436,36 +436,6 @@ fn story_blocks(ctx: &WalkCtx<'_>, a: u32, b: u32, pics: &mut media::Pictures, m
|
||||
Ok(table::assemble(paras))
|
||||
}
|
||||
|
||||
/// A header or footer story's blocks without its guard paragraph mark. [MS-DOC] 2.3.3: a
|
||||
/// non-empty header story ends with a paragraph mark that only separates it from the next story
|
||||
/// and is not part of its contents, so a one-line header is stored as `text\r\r`. Laid out, the
|
||||
/// guard would add an empty paragraph to every header and footer and push the body down when
|
||||
/// the header reaches past the top margin (#5922).
|
||||
///
|
||||
/// Only an empty last paragraph after an ordinary one is dropped: a story that is nothing but a
|
||||
/// mark stays a part (an empty header, not one linked to the previous section), and a mark right
|
||||
/// after a table row stays the paragraph that closes the table.
|
||||
fn header_story_blocks(
|
||||
ctx: &WalkCtx<'_>,
|
||||
a: u32,
|
||||
b: u32,
|
||||
pics: &mut media::Pictures,
|
||||
max_paragraphs: usize,
|
||||
) -> Result<wordcraft_doc::Blocks, DocbinError> {
|
||||
let mut paras = walk(ctx, a, b, pics);
|
||||
if ctx.paragraphs.exceeded.get() {
|
||||
return Err(paragraph_limit(max_paragraphs));
|
||||
}
|
||||
let outside_table = |p: &table::ParaOut| p.row.as_ref().is_none_or(|r| !r.in_table && !r.ttp && !r.cell_mark);
|
||||
if let [.., prev, last] = paras.as_slice() {
|
||||
let guard = outside_table(last) && last.para.text.is_empty() && last.para.objects.is_empty() && last.para.section.is_none();
|
||||
if guard && outside_table(prev) {
|
||||
paras.pop();
|
||||
}
|
||||
}
|
||||
Ok(table::assemble(paras))
|
||||
}
|
||||
|
||||
/// A story's CP range `[a, b)`, relative to a subdocument starting at `base` with `len`
|
||||
/// CPs, as absolute CPs clamped to that subdocument; `None` when empty.
|
||||
fn story(base: u32, len: u32, (a, b): (u32, u32)) -> Option<(u32, u32)> {
|
||||
|
||||
@@ -1157,58 +1157,6 @@ fn header_story_becomes_part() {
|
||||
assert!(part.blocks.iter().any(|b| b.as_para().is_some_and(|p| p.text.contains("Header text"))));
|
||||
}
|
||||
|
||||
/// The odd header's blocks for a one-section document whose header story is `story`, with
|
||||
/// `papx_entries` over the whole stream (the header story starts at CP 5, after `body\r`):
|
||||
/// paragraph texts, a table as `<table>`; `None` when the slot has no part.
|
||||
fn odd_header_blocks(story: &'static str, papx_entries: Vec<(u32, u16, Vec<u8>)>) -> Option<Vec<String>> {
|
||||
let spec = StructSpec {
|
||||
text: "body\r",
|
||||
header_stories: vec![story],
|
||||
sections: vec![(5, Vec::new())],
|
||||
chpx_runs: vec![(0, Vec::new())],
|
||||
papx_entries,
|
||||
plf_lst: Vec::new(),
|
||||
plf_lfo: Vec::new(),
|
||||
note_stories: vec![],
|
||||
endnote_stories: vec![],
|
||||
extra_pairs: Vec::new(),
|
||||
data_stream: Vec::new(),
|
||||
};
|
||||
let doc = read(&struct_doc(&spec)).expect("opens");
|
||||
let id = doc.last_section.headers.default?;
|
||||
let part = doc.parts.get(&id).expect("part");
|
||||
Some(part.blocks.iter().map(|b| b.as_para().map_or_else(|| "<table>".to_string(), |p| p.text.clone())).collect())
|
||||
}
|
||||
|
||||
/// #5922: [MS-DOC] 2.3.3 ends every non-empty header story with a guard paragraph mark that
|
||||
/// is not part of its contents. Read as a paragraph, it put an empty line under every header
|
||||
/// and pushed the first body line down when the header reached past the top margin.
|
||||
#[test]
|
||||
fn header_story_guard_mark_is_not_a_paragraph() {
|
||||
let plain = || vec![(0, 0, Vec::new())];
|
||||
// One line typed into the header: content "Header\r" + guard.
|
||||
assert_eq!(odd_header_blocks("Header\r\r", plain()), Some(vec!["Header".to_string()]));
|
||||
// Enter pressed after it: the empty paragraph is content and stays, only the guard goes.
|
||||
assert_eq!(odd_header_blocks("Header\r\r\r", plain()), Some(vec!["Header".to_string(), String::new()]));
|
||||
// An empty header (one empty content paragraph + guard) is still a header of its own.
|
||||
assert_eq!(odd_header_blocks("\r\r", plain()), Some(vec![String::new()]));
|
||||
// A story that is only a mark (no guard) keeps its paragraph and its part.
|
||||
assert_eq!(odd_header_blocks("\r", plain()), Some(vec![String::new()]));
|
||||
// A story without a guard keeps its last paragraph.
|
||||
assert_eq!(odd_header_blocks("Header\r", plain()), Some(vec!["Header".to_string()]));
|
||||
}
|
||||
|
||||
/// #5922: a mark right after a table row in a header story is kept: it is the paragraph that
|
||||
/// closes the table, not a guard to drop.
|
||||
#[test]
|
||||
fn header_story_mark_after_a_table_row_is_kept() {
|
||||
let in_tbl = grpprl(&[(0x2416, &[0x01])]);
|
||||
let ttp: Vec<u8> = [grpprl(&[(0x2416, &[0x01]), (0x2417, &[0x01])]), t_def_table(&[0, 2880])].concat();
|
||||
// Stream: "body\r" (CPs 0-4), then the header story "a\x07" (5-6), row end "\x07" (7), "\r" (8).
|
||||
let papx = vec![(0, 0, Vec::new()), (5, 0, in_tbl), (7, 0, ttp), (8, 0, Vec::new())];
|
||||
assert_eq!(odd_header_blocks("a\u{7}\u{7}\r", papx), Some(vec!["<table>".to_string(), String::new()]));
|
||||
}
|
||||
|
||||
/// A `sprmTDefTable` Prl for `edges` twip boundaries and TC80s of 20 bytes per column,
|
||||
/// built per the spec (cb counts the bytes after it, plus one).
|
||||
fn t_def_table(edges: &[i16]) -> Vec<u8> {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
use wordcraft_doc::math::MathJc;
|
||||
|
||||
use super::{
|
||||
CharProps, El, InlineObject, Item, MAX_FORM, MAX_INSTR, NoteKind, PB, PTabAlign, PTabBase, Phase, Reader, Rels, RevisionKind, RunCtx, StoryCtx,
|
||||
TextColor, children_of_choice, clean_text, int, measure, on_off_attr, placeholder_of, sink,
|
||||
@@ -15,7 +13,6 @@ impl Reader<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Read an inline child element (`w:r`, `m:oMath`, `m:oMathPara`, `w:ins`, etc.).
|
||||
fn read_inline(&mut self, sc: &mut StoryCtx, pb: &mut PB, k: &El, rels: &Rels, ctx: &RunCtx, depth: usize) {
|
||||
match k.name.as_str() {
|
||||
"w:r" => self.read_run(sc, pb, k, rels, ctx, depth),
|
||||
@@ -79,38 +76,25 @@ impl Reader<'_> {
|
||||
}
|
||||
"w:smartTag" | "w:customXml" | "w:dir" | "w:bdo" => self.read_inline_children(sc, pb, k, rels, ctx, depth + 1),
|
||||
"m:oMath" => {
|
||||
let props = self.run_props_none(ctx);
|
||||
self.emit_omath(sc, pb, k, Default::default(), false, &props);
|
||||
let mut props = self.run_props_none(ctx);
|
||||
let mut math = crate::read::math::read_omath(k, Default::default());
|
||||
if let Some(c) = crate::read::math::lift_color(&mut math) {
|
||||
props.color = Some(TextColor::Rgb(c));
|
||||
}
|
||||
let linear = wordcraft_doc::math::to_linear(&math.nodes);
|
||||
self.emit_obj(sc, pb, InlineObject::Equation { linear, display: false, math }, &props);
|
||||
}
|
||||
"m:oMathPara" => {
|
||||
let base_props = self.run_props_none(ctx);
|
||||
let props = self.run_props_none(ctx);
|
||||
let jc = crate::read::math::para_jc(k);
|
||||
for c in k.els() {
|
||||
match c.name.as_str() {
|
||||
"m:oMath" => self.emit_omath(sc, pb, c, jc, true, &base_props),
|
||||
"w:ins" | "w:moveTo" => {
|
||||
let mut sub_ctx = ctx.clone();
|
||||
sub_ctx.ins =
|
||||
Some(if c.name == "w:moveTo" { self.move_revision(true, c, false) } else { self.revision(RevisionKind::Insert, c) });
|
||||
let sub_props = self.run_props_none(&sub_ctx);
|
||||
for m in c.children("m:oMath") {
|
||||
self.emit_omath(sc, pb, m, jc, true, &sub_props);
|
||||
}
|
||||
}
|
||||
"w:del" | "w:moveFrom" => {
|
||||
let mut sub_ctx = ctx.clone();
|
||||
sub_ctx.del = Some(if c.name == "w:moveFrom" {
|
||||
self.move_revision(false, c, false)
|
||||
} else {
|
||||
self.revision(RevisionKind::Delete, c)
|
||||
});
|
||||
let sub_props = self.run_props_none(&sub_ctx);
|
||||
for m in c.children("m:oMath") {
|
||||
self.emit_omath(sc, pb, m, jc, true, &sub_props);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
for m in k.children("m:oMath") {
|
||||
let mut math = crate::read::math::read_omath(m, jc);
|
||||
let mut props = props.clone();
|
||||
if let Some(c) = crate::read::math::lift_color(&mut math) {
|
||||
props.color = Some(TextColor::Rgb(c));
|
||||
}
|
||||
let linear = wordcraft_doc::math::to_linear(&math.nodes);
|
||||
self.emit_obj(sc, pb, InlineObject::Equation { linear, display: true, math }, &props);
|
||||
}
|
||||
}
|
||||
"mc:AlternateContent" => {
|
||||
@@ -400,17 +384,6 @@ impl Reader<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Read an `m:oMath` element and emit its [`InlineObject::Equation`].
|
||||
fn emit_omath(&mut self, sc: &mut StoryCtx, pb: &mut PB, m: &El, jc: MathJc, display: bool, props: &CharProps) {
|
||||
let mut math = crate::read::math::read_omath(m, jc);
|
||||
let mut props = props.clone();
|
||||
if let Some(color) = crate::read::math::lift_color(&mut math) {
|
||||
props.color = Some(TextColor::Rgb(color));
|
||||
}
|
||||
let linear = wordcraft_doc::math::to_linear(&math.nodes);
|
||||
self.emit_obj(sc, pb, InlineObject::Equation { linear, display, math }, &props);
|
||||
}
|
||||
|
||||
pub(super) fn emit_obj(&mut self, sc: &mut StoryCtx, pb: &mut PB, o: InlineObject, props: &CharProps) {
|
||||
match sink(sc) {
|
||||
Some(f) => {
|
||||
|
||||
@@ -4,7 +4,6 @@ use super::section::comment_reference_style;
|
||||
use super::{CharProps, Fill, InlineObject, NoteKind, PartRels, TextColor, W, Wrap, Writer, emu, n, rpr};
|
||||
|
||||
impl Writer<'_> {
|
||||
/// Write an inline object (`Bookmark`, `Comment`, `Equation`, `Field`, `Shape`, etc.).
|
||||
pub(super) fn object(&mut self, w: &mut W, o: &InlineObject, props: &CharProps, rels: &mut PartRels, depth: usize) {
|
||||
match o {
|
||||
// Written by `para_content` (inline) or `blocks` (around paragraphs).
|
||||
@@ -52,12 +51,10 @@ impl Writer<'_> {
|
||||
Some(TextColor::Rgb(c)) => Some(c),
|
||||
_ => None,
|
||||
};
|
||||
self.rev_open(w, props);
|
||||
match crate::write::math::resolve_numbers(math, *display, &mut self.eq_number) {
|
||||
Some(m) => crate::write::math::write_equation(w, linear, *display, &m, color),
|
||||
None => crate::write::math::write_equation(w, linear, *display, math, color),
|
||||
}
|
||||
self.rev_close(w, props);
|
||||
}
|
||||
InlineObject::Field { instr, result, locked, dirty, form, .. } => {
|
||||
self.field(w, FieldData { instr, result, locked: *locked, dirty: *dirty, form }, props)
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
//! Line numbering (`w:lnNumType`, ECMA-376 Part 1 §17.6.8) and the built-in "line number"
|
||||
//! character style that formats the numbers (#6018).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use wordcraft_doc::section::{Columns, LineNumberRestart, LineNumbering};
|
||||
use wordcraft_doc::{CharProps, Document, Rgb, Style, StyleKind, TextColor};
|
||||
|
||||
/// Every line-numbering option and a localised "line number" style (German Word calls its id
|
||||
/// `Zeilennummer`) survive a save and reopen, beside two columns.
|
||||
#[test]
|
||||
fn line_numbering_options_and_style_round_trip() {
|
||||
let mut d = Document::from_text("one\ntwo\nthree");
|
||||
let numbering = LineNumbering { count_by: 5, start: 3, distance: 21.6, restart: LineNumberRestart::Continuous };
|
||||
d.last_section.line_numbers = Some(numbering.clone());
|
||||
d.last_section.columns = Columns { count: 2, space: 36.0, separator: false, widths: Vec::new() };
|
||||
let red = TextColor::Rgb(Rgb(0xC0, 0, 0));
|
||||
Arc::make_mut(&mut d.styles).upsert(Style {
|
||||
id: "Zeilennummer".into(),
|
||||
name: "Line Number".into(),
|
||||
kind: StyleKind::Character,
|
||||
chr: CharProps { size: Some(8.0), color: Some(red), italic: Some(true), ..Default::default() },
|
||||
..Default::default()
|
||||
});
|
||||
let back = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||||
assert_eq!(back.last_section.line_numbers, Some(numbering));
|
||||
assert_eq!(back.last_section.columns.count, 2);
|
||||
let st = back.styles.find("line number").expect("the line number style");
|
||||
assert_eq!(st.id, "Zeilennummer");
|
||||
assert_eq!((st.chr.size, st.chr.color, st.chr.italic), (Some(8.0), Some(red), Some(true)));
|
||||
}
|
||||
@@ -20,7 +20,6 @@ mod glossary;
|
||||
mod heading_levels;
|
||||
mod legacy_forms;
|
||||
mod ligatures;
|
||||
mod line_numbering;
|
||||
mod malformed;
|
||||
mod missing_pictures;
|
||||
mod nested_block_controls;
|
||||
|
||||
@@ -994,80 +994,6 @@ fn tracked_changes_round_trip() {
|
||||
assert!(r.settings.track_changes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracked_equations_round_trip() {
|
||||
// Issue #6011: equations saved with tracked revision marks (w:ins / w:del) around math.
|
||||
let mut d = Document::new();
|
||||
d.revisions.push(Revision { kind: RevisionKind::Insert, author: "Alice".into(), date: "2026-05-01T10:00:00Z".into(), move_name: None });
|
||||
d.revisions.push(Revision { kind: RevisionKind::Delete, author: "Bob".into(), date: "2026-05-02T10:00:00Z".into(), move_name: None });
|
||||
d.settings.track_changes = true;
|
||||
let ins = CharProps { ins: Some(0), ..Default::default() };
|
||||
let del = CharProps { del: Some(1), ..Default::default() };
|
||||
let plain = CharProps::default();
|
||||
let mut p = Paragraph::with_text("Formula: ", CharProps::default());
|
||||
let end = p.len();
|
||||
p.insert_object(end, InlineObject::Equation { linear: "x+1".into(), display: false, math: Default::default() }, &ins).unwrap();
|
||||
let end = p.len();
|
||||
p.insert_object(end, InlineObject::Equation { linear: "E=mc^2".into(), display: true, math: Default::default() }, &del).unwrap();
|
||||
let end = p.len();
|
||||
p.insert_object(end, InlineObject::Equation { linear: "a^2+b^2=c^2".into(), display: true, math: Default::default() }, &ins).unwrap();
|
||||
let end = p.len();
|
||||
p.insert_object(end, InlineObject::Equation { linear: "y=mx+b".into(), display: false, math: Default::default() }, &del).unwrap();
|
||||
let end = p.len();
|
||||
p.insert_object(end, InlineObject::Equation { linear: "z=1".into(), display: false, math: Default::default() }, &plain).unwrap();
|
||||
d.body = vec![para_block(p.clone())];
|
||||
let bytes = wordcraft_docx::write(&d).expect("write");
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
|
||||
let mut xml = String::new();
|
||||
std::io::Read::read_to_string(&mut zip.by_name("word/document.xml").unwrap(), &mut xml).unwrap();
|
||||
assert!(xml.contains(r#"<w:ins w:id="1" w:author="Alice" w:date="2026-05-01T10:00:00Z"><m:oMath>"#), "ins directly encloses inline math: {xml}");
|
||||
assert!(
|
||||
xml.contains(r#"<w:del w:id="2" w:author="Bob" w:date="2026-05-02T10:00:00Z"><m:oMathPara>"#),
|
||||
"del directly encloses display math: {xml}"
|
||||
);
|
||||
assert!(
|
||||
xml.contains(r#"<w:ins w:id="3" w:author="Alice" w:date="2026-05-01T10:00:00Z"><m:oMathPara>"#),
|
||||
"ins directly encloses display math: {xml}"
|
||||
);
|
||||
assert!(xml.contains(r#"<w:del w:id="4" w:author="Bob" w:date="2026-05-02T10:00:00Z"><m:oMath>"#), "del directly encloses inline math: {xml}");
|
||||
assert!(xml.contains(r#"</w:del><m:oMath>"#), "plain equation follows closing tag without revision wrapper: {xml}");
|
||||
let r = wordcraft_docx::read(&bytes).expect("read");
|
||||
assert_eq!(r.revisions, d.revisions);
|
||||
let got = ¶s(&r)[0];
|
||||
assert_eq!(got.objects.len(), 5);
|
||||
let eq_summary: Vec<(bool, String)> = got
|
||||
.objects
|
||||
.iter()
|
||||
.map(|o| match o {
|
||||
InlineObject::Equation { display, linear, .. } => (*display, linear.clone()),
|
||||
_ => panic!("expected equation object, got {o:?}"),
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
eq_summary,
|
||||
vec![(false, "x+1".into()), (true, "E=mc^2".into()), (true, "a^2+b^2=c^2".into()), (false, "y=mx+b".into()), (false, "z=1".into()),]
|
||||
);
|
||||
assert_eq!(got.runs, p.runs);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_omath_para_revisions_read() {
|
||||
// Defensive reader check: w:ins and w:del nested inside m:oMathPara.
|
||||
let mut d = Document::new();
|
||||
d.revisions.push(Revision { kind: RevisionKind::Insert, author: "Ada".into(), date: "2026-05-01T10:00:00Z".into(), move_name: None });
|
||||
d.revisions.push(Revision { kind: RevisionKind::Delete, author: "Charles".into(), date: "2026-05-02T10:00:00Z".into(), move_name: None });
|
||||
let doc_xml = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math"><w:body><w:p><m:oMathPara><w:ins w:id="1" w:author="Ada" w:date="2026-05-01T10:00:00Z"><m:oMath><m:r><m:t>x+1</m:t></m:r></m:oMath></w:ins><w:del w:id="2" w:author="Charles" w:date="2026-05-02T10:00:00Z"><m:oMath><m:r><m:t>y-1</m:t></m:r></m:oMath></w:del></m:oMathPara></w:p></w:body></w:document>"#;
|
||||
let base = wordcraft_docx::write(&d).unwrap();
|
||||
let bytes = replace_part(&base, "word/document.xml", doc_xml.as_bytes());
|
||||
let r = wordcraft_docx::read(&bytes).expect("read");
|
||||
let got = ¶s(&r)[0];
|
||||
assert_eq!(got.objects.len(), 2);
|
||||
assert!(matches!(&got.objects[0], InlineObject::Equation { display: true, linear, .. } if linear == "x+1"));
|
||||
assert!(matches!(&got.objects[1], InlineObject::Equation { display: true, linear, .. } if linear == "y-1"));
|
||||
assert_eq!(got.runs[0].props.ins, Some(0));
|
||||
assert_eq!(got.runs[1].props.del, Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracked_paragraph_marks_round_trip() {
|
||||
// Issue #229: an inserted / deleted paragraph mark is `w:ins` / `w:del` in the mark's
|
||||
|
||||
@@ -51,11 +51,6 @@ 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,24 +347,19 @@ 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, None).map(|_| ())
|
||||
insert_text(s, text, true).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, None)
|
||||
insert_text(s, text, true)
|
||||
}
|
||||
|
||||
/// 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, 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(|_| ())
|
||||
insert_text(s, text, false).map(|_| ())
|
||||
}
|
||||
|
||||
/// Inserts typed or pasted text at the caret: resolves field ends and content-control placeholders,
|
||||
@@ -373,7 +368,7 @@ pub fn type_text_with_lang(s: &mut Session, text: &str, language: Option<&str>)
|
||||
/// 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, language: Option<&str>) -> Result<InsertEffect, CmdError> {
|
||||
fn insert_text(s: &mut Session, text: &str, enter: bool) -> 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));
|
||||
@@ -388,9 +383,6 @@ fn insert_text(s: &mut Session, text: &str, enter: bool, language: Option<&str>)
|
||||
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, type_text_with_lang};
|
||||
use super::{delete_selection, sel_result, split_para, type_text};
|
||||
use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
|
||||
|
||||
mod header_footer;
|
||||
@@ -15,9 +15,7 @@ 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, default_date_picture, drop_cap_state, format_date, format_date_at, format_date_in, format_date_parts, format_date_parts_in,
|
||||
};
|
||||
pub use items::{DropCapState, drop_cap_state, format_date, format_date_at, format_date_parts};
|
||||
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};
|
||||
@@ -25,7 +23,6 @@ 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, _| {
|
||||
@@ -116,7 +113,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, "language"?: "en-US|pt-BR"}"#),
|
||||
CommandSpec::new("insert.dateTime", "Date & Time", "Insert › Text", date_time).params(r#"{"format"?: "M/d/yyyy", "update"?: bool}"#),
|
||||
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,72 +1,8 @@
|
||||
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, type_text_with_lang,
|
||||
delete_selection, json, p, para_block, sel_result, type_text,
|
||||
};
|
||||
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");
|
||||
@@ -86,86 +22,30 @@ pub(super) fn page_number(s: &mut Session, v: &Value) -> CmdResult {
|
||||
}
|
||||
let header = position == "top";
|
||||
let id = hf_part(s, header);
|
||||
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}));
|
||||
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 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));
|
||||
s.doc.set_story(StoryRef::Part(id), vec![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 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());
|
||||
let fmt = p::str(v, "format").unwrap_or("M/d/yyyy");
|
||||
let text = format_date(fmt);
|
||||
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,
|
||||
@@ -174,87 +54,32 @@ pub(super) fn date_time(s: &mut Session, v: &Value) -> CmdResult {
|
||||
)?;
|
||||
s.sel = Selection::caret(end);
|
||||
} else {
|
||||
type_text_with_lang(s, &text, language)?;
|
||||
type_text(s, &text)?;
|
||||
}
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// Format today's date with a Word picture (`M/d/yyyy`, `MMMM d, yyyy`, `yyyy-MM-dd`, `dddd`…),
|
||||
/// in English.
|
||||
/// Format today's date with a Word picture (`M/d/yyyy`, `MMMM d, yyyy`, `yyyy-MM-dd`, `dddd`…).
|
||||
pub fn format_date(fmt: &str) -> String {
|
||||
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 {
|
||||
// Word shows the computer's local date and time (#1707), not UTC's.
|
||||
let now = crate::cmd::now_unix();
|
||||
format_date_at_in(fmt, lang, now, crate::cmd::local_offset_secs(now))
|
||||
format_date_at(fmt, 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_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_date_parts(fmt, y, m as usize, d, hms, days)
|
||||
}
|
||||
|
||||
/// 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, 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() };
|
||||
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");
|
||||
let mut out = String::new();
|
||||
let chars: Vec<char> = fmt.chars().collect();
|
||||
let mut i = 0;
|
||||
@@ -296,11 +121,11 @@ pub fn format_date_parts_in(fmt: &str, lang: Option<&str>, y: i64, m: usize, d:
|
||||
('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(&three(month)),
|
||||
('M', 3) => out.push_str(month.get(..3).unwrap_or(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(&three(day)),
|
||||
('d' | 'D', 3) => out.push_str(day.get(..3).unwrap_or(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}")),
|
||||
@@ -512,79 +337,3 @@ 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,9 +378,8 @@ 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 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()))));
|
||||
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))));
|
||||
}
|
||||
"REF" | "PAGEREF" => {
|
||||
let f = FieldCode::parse(instr);
|
||||
@@ -506,9 +505,8 @@ pub fn refresh_date_fields(doc: &mut wordcraft_doc::Document) {
|
||||
continue;
|
||||
}
|
||||
// Word's default picture when the code carries no `\@` switch.
|
||||
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()))));
|
||||
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))));
|
||||
}
|
||||
if updates.is_empty() {
|
||||
continue;
|
||||
@@ -524,16 +522,6 @@ 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" (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)}"#;
|
||||
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)}"#;
|
||||
|
||||
/// What Compare / Combine compares (the More >> settings of Word's dialog).
|
||||
struct CompareOptions {
|
||||
@@ -27,15 +27,6 @@ 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;
|
||||
@@ -44,9 +35,6 @@ 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)
|
||||
@@ -73,7 +61,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 mut not_honoured: Vec<&str> =
|
||||
let 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();
|
||||
@@ -95,9 +83,7 @@ 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;
|
||||
@@ -111,7 +97,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, &mut ctx)));
|
||||
out.push(para_block(word_diff(&s.doc, pa, pb, ins, del, &opts, &mut budget)));
|
||||
changes += 1;
|
||||
i += 1;
|
||||
j += 1;
|
||||
@@ -143,7 +129,9 @@ 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, &mut ctx))),
|
||||
(Some(Block::Para(pa)), Some(Block::Para(pb))) => {
|
||||
out.push(para_block(word_diff(&s.doc, pa, pb, ins, del, &opts, &mut budget)))
|
||||
}
|
||||
_ => {
|
||||
if let Some(x) = a {
|
||||
out.push(Arc::new(mark_block(x, &|c| c.del = Some(del), 0)));
|
||||
@@ -174,21 +162,8 @@ 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,
|
||||
"characterFallback": char_fallbacks,
|
||||
}))
|
||||
Ok(json!({"changes": changes, "author": author, "notHonoured": not_honoured}))
|
||||
}
|
||||
|
||||
/// A block's text (a table's cells in order), for matching blocks.
|
||||
@@ -202,14 +177,13 @@ fn block_text(b: &Block, depth: usize) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a copy of `b` with all text runs and paragraph marks (or nested table cells) updated by `f`.
|
||||
/// A copy of the block with every run (in tables, every cell's) changed 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) => {
|
||||
@@ -231,96 +205,59 @@ pub(super) enum Op {
|
||||
Ins,
|
||||
}
|
||||
|
||||
/// Longest sequence window per LCS comparison table chunk; sequences longer than this are
|
||||
/// aligned in successive chunked windows rather than truncated, preserving tail alignment.
|
||||
/// Longest sequence the comparison table is built for; the rest of a longer sequence is
|
||||
/// compared as deleted and inserted, never dropped.
|
||||
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;
|
||||
|
||||
/// 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.
|
||||
/// 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.
|
||||
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 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 (a, b) = (ra.get(..ra.len() - suf).unwrap_or_default(), rb.get(..rb.len() - suf).unwrap_or_default());
|
||||
let mut ops = vec![Op::Same; pre];
|
||||
|
||||
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 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();
|
||||
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;
|
||||
}
|
||||
|
||||
ops.extend(std::iter::repeat_n(Op::Del, rem_a.len()));
|
||||
ops.extend(std::iter::repeat_n(Op::Ins, rem_b.len()));
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
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::Same, suf));
|
||||
ops
|
||||
}
|
||||
@@ -473,39 +410,15 @@ fn put(p: &mut Paragraph, t: Option<&Tok<'_>>, props: &CharProps) {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
};
|
||||
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;
|
||||
let (ta, tb) = (tokens(a, chars), tokens(b, chars));
|
||||
let key = |t: &Tok<'_>| match t {
|
||||
Tok::Text(t) => Key::Text(ctx.opts.key(t)),
|
||||
Tok::Text(t) => Key::Text(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<_>>(), ctx.budget);
|
||||
let ops = lcs_ops(&ta.iter().map(key).collect::<Vec<_>>(), &tb.iter().map(key).collect::<Vec<_>>(), 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.
|
||||
@@ -523,11 +436,11 @@ fn word_diff(doc: &Document, a: &Paragraph, b: &Paragraph, ctx: &mut DiffCtx<'_>
|
||||
j += 1;
|
||||
}
|
||||
Op::Del => {
|
||||
put(&mut p, ta.get(i), &CharProps { del: Some(ctx.del), ..base.clone() });
|
||||
put(&mut p, ta.get(i), &CharProps { del: Some(del), ..base.clone() });
|
||||
i += 1;
|
||||
}
|
||||
Op::Ins => {
|
||||
put(&mut p, tb.get(j), &CharProps { ins: Some(ctx.ins), ..base.clone() });
|
||||
put(&mut p, tb.get(j), &CharProps { ins: Some(ins), ..base.clone() });
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,309 +66,6 @@ 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
|
||||
@@ -383,145 +80,6 @@ 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
|
||||
@@ -635,124 +193,6 @@ 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,14 +8,12 @@ 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| {
|
||||
@@ -145,7 +143,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), "window"?: n (make this open window 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)} → {"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"}));
|
||||
@@ -215,8 +213,6 @@ 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.
|
||||
@@ -435,7 +431,6 @@ 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()) => {
|
||||
@@ -461,13 +456,7 @@ 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(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()) {
|
||||
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,9 +11,6 @@ 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()),
|
||||
@@ -31,41 +28,11 @@ 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.
|
||||
sync_file(file)
|
||||
file.sync_all()
|
||||
});
|
||||
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.
|
||||
@@ -481,41 +448,4 @@ 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,7 +214,6 @@ 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();
|
||||
@@ -229,23 +228,10 @@ 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,36 +186,3 @@ 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);
|
||||
}
|
||||
|
||||
@@ -1,398 +0,0 @@
|
||||
//! #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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1177,8 +1177,7 @@ impl Styles {
|
||||
"graphic-properties" if in_graphic_default && cur.is_none() => self.default_graphic = super::frames::Graphic::read(&a),
|
||||
props if in_table_default == Some(props) && cur.is_none() => self.read_table_default(props, &a),
|
||||
"text-properties" if in_default && cur.is_none() => {
|
||||
// A later default style without a (usable) font keeps the earlier one (#4501).
|
||||
self.default_font = default_font(&a, &self.faces).or(self.default_font.take());
|
||||
self.default_font = default_font(&a, &self.faces);
|
||||
if let Some(sz) = get(&a, "fo:font-size").and_then(length).filter(|v| v.is_finite() && *v >= 1.0) {
|
||||
self.default_size = Some(sz.min(1638.0));
|
||||
}
|
||||
|
||||
@@ -1289,36 +1289,6 @@ fn meta_creation_date_timezones_and_fallbacks() {
|
||||
assert_eq!(m2.author, "Solo Creator");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_later_default_style_without_a_font_keeps_the_default_font() {
|
||||
// #4501 follow-up: styles read twice (styles.xml, then content.xml) must not wipe the font.
|
||||
let mut styles = styles::Styles::default();
|
||||
let with_font = format!(
|
||||
r#"<office:document-styles {NS_T}><office:styles><style:default-style style:family="paragraph"><style:text-properties style:font-name="DejaVu Serif"/></style:default-style></office:styles></office:document-styles>"#
|
||||
);
|
||||
let without = format!(
|
||||
r#"<office:document-styles {NS_T}><office:styles><style:default-style style:family="paragraph"><style:text-properties fo:font-size="12pt"/></style:default-style></office:styles></office:document-styles>"#
|
||||
);
|
||||
styles.read(with_font.as_bytes(), false);
|
||||
styles.read(without.as_bytes(), false);
|
||||
assert_eq!(styles.doc_font().as_deref(), Some("DejaVu Serif"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_later_monospaced_default_style_keeps_the_proportional_default_font() {
|
||||
// #4501 follow-up: a monospaced default font is rejected and must not wipe the earlier one.
|
||||
let mut styles = styles::Styles::default();
|
||||
let with_font = format!(
|
||||
r#"<office:document-styles {NS_T}><office:styles><style:default-style style:family="paragraph"><style:text-properties style:font-name="DejaVu Serif"/></style:default-style></office:styles></office:document-styles>"#
|
||||
);
|
||||
let mono = format!(
|
||||
r#"<office:document-styles {NS_T}><office:styles><style:default-style style:family="paragraph"><style:text-properties style:font-name="Courier New"/></style:default-style></office:styles></office:document-styles>"#
|
||||
);
|
||||
styles.read(with_font.as_bytes(), false);
|
||||
styles.read(mono.as_bytes(), false);
|
||||
assert_eq!(styles.doc_font().as_deref(), Some("DejaVu Serif"));
|
||||
}
|
||||
|
||||
/// The `content.xml` of a package.
|
||||
pub(super) fn content_of(bytes: &[u8]) -> String {
|
||||
let mut z = archive(bytes);
|
||||
|
||||
@@ -7,10 +7,8 @@ use super::{
|
||||
ParaLayout, Paragraph, Rect, SectionProps, StoryRef, ViewMode, Wrap, contour, math, para, styleref, textbox,
|
||||
};
|
||||
|
||||
/// The character style Word formats line numbers with: `w:styleId="LineNumber"` in English
|
||||
/// Word; other languages localise the id (German `Zeilennummer`) but keep the built-in name.
|
||||
/// The character style Word formats line numbers with (`w:styleId="LineNumber"`).
|
||||
const LINE_NUMBER_STYLE: &str = "LineNumber";
|
||||
const LINE_NUMBER_STYLE_NAME: &str = "line number";
|
||||
|
||||
/// A layout pass: finished, or to be done again with the page totals it found, or with the
|
||||
/// floats placed above their anchors it found ([`EarlyFloats`]).
|
||||
@@ -201,11 +199,7 @@ impl Ctx<'_> {
|
||||
// Word formats line numbers with the "Line Number" character style over the document's
|
||||
// defaults (measured in Word for Mac: Aptos 12 pt black in a document whose defaults are
|
||||
// Aptos 12 pt), not in a fixed small grey.
|
||||
let styles = &self.doc.styles;
|
||||
let style = styles
|
||||
.get(LINE_NUMBER_STYLE)
|
||||
.or_else(|| styles.find(LINE_NUMBER_STYLE_NAME).filter(|s| s.kind == wordcraft_doc::StyleKind::Character))
|
||||
.map(|s| s.id.clone());
|
||||
let style = self.doc.styles.get(LINE_NUMBER_STYLE).map(|s| s.id.clone());
|
||||
let mut p = Paragraph::with_text(&n.to_string(), CharProps { style, ..Default::default() });
|
||||
p.props.space_after = Some(0.0);
|
||||
p.props.space_before = Some(0.0);
|
||||
|
||||
@@ -777,8 +777,7 @@ fn add_notes(pb: &mut PageBuilder, mut new_notes: Vec<NoteBox>, notes: &[(usize,
|
||||
}
|
||||
}
|
||||
|
||||
/// Line numbers for lines `l0..l1` of `pl` laid out from `y`, left of the column they are in:
|
||||
/// each column of a multi-column section gets its own (#6018), as in Word.
|
||||
/// Line numbers in the left margin for lines `l0..l1` of `pl` laid out from `y`.
|
||||
fn line_numbers(ctx: &mut Ctx, pb: &mut PageBuilder, pl: &ParaLayout, (l0, l1): (usize, usize), y: f32) -> Vec<Placed> {
|
||||
let mut items = Vec::new();
|
||||
if let Some(ln) = pb.sect.line_numbers.clone().filter(|_| !pb.web && pl.rp.style != "Header" && !pl.rp.suppress_line_numbers)
|
||||
@@ -801,7 +800,7 @@ fn line_numbers(ctx: &mut Ctx, pb: &mut PageBuilder, pl: &ParaLayout, (l0, l1):
|
||||
para: num.clone(),
|
||||
l0: 0,
|
||||
l1: 1,
|
||||
x: pb.col_x() - dist - w,
|
||||
x: pb.sect.margin_left + pb.sect.gutter - dist - w,
|
||||
y: ly,
|
||||
turn: TextDirection::Horizontal,
|
||||
});
|
||||
|
||||
+10
-28
@@ -270,21 +270,12 @@ fn section_tops(ctx: &mut Ctx, hf: &HfLinks, (si, sect): (usize, &SectionProps),
|
||||
Tops { body, first, foot, first_foot }
|
||||
}
|
||||
|
||||
/// Set up the walk state for a section before its header and footer room is measured: no grid yet
|
||||
/// (the previous section's must not leak into headers, footers and separators), the field values
|
||||
/// and the footnote separators.
|
||||
fn begin_section_state<'p>(
|
||||
ctx: &mut Ctx,
|
||||
pb: &mut PageBuilder<'p>,
|
||||
(si, sect, page_format): (usize, &'p SectionProps, wordcraft_doc::section::NumFormat),
|
||||
totals: &Totals,
|
||||
web: bool,
|
||||
) {
|
||||
let doc = ctx.doc;
|
||||
ctx.grid = para::Grid::default();
|
||||
/// Set up the walk state for a section: its grid, field values and footnote separators.
|
||||
fn begin_section_state<'p>(ctx: &mut Ctx, pb: &mut PageBuilder<'p>, sec: &Sec<'p>, totals: &Totals, web: bool) {
|
||||
let (doc, sect, si) = (ctx.doc, sec.sect, sec.si);
|
||||
pb.sect = sect;
|
||||
pb.page_format = page_format;
|
||||
ctx.fields.page_format = page_format;
|
||||
pb.page_format = sec.page_format;
|
||||
ctx.fields.page_format = sec.page_format;
|
||||
pb.sect_idx = si;
|
||||
pb.sect_beneath = doc.note_options(sect, false).pos == wordcraft_doc::section::NotePos::BeneathText;
|
||||
if !web && !doc.footnote_separators.is_empty() {
|
||||
@@ -292,11 +283,7 @@ fn begin_section_state<'p>(
|
||||
}
|
||||
ctx.fields.section = u32::try_from(si).unwrap_or(u32::MAX).saturating_add(1);
|
||||
ctx.fields.section_pages = totals.section(si);
|
||||
}
|
||||
|
||||
/// The section's own grid and line numbering, once its header and footer room is known.
|
||||
fn apply_section_grid(ctx: &mut Ctx, pb: &mut PageBuilder<'_>, sect: &SectionProps) {
|
||||
ctx.grid = para::Grid::of(ctx.doc, sect);
|
||||
ctx.grid = para::Grid::of(doc, sect);
|
||||
if sect.line_numbers.as_ref().is_some_and(|l| l.restart == wordcraft_doc::section::LineNumberRestart::Section) {
|
||||
pb.line_no = 0;
|
||||
}
|
||||
@@ -554,22 +541,17 @@ pub(crate) fn layout_pass(
|
||||
_ if mode.reflow => sect_ref,
|
||||
_ => sect,
|
||||
};
|
||||
ctx.fields.page_format = page_format;
|
||||
let tops = section_tops(&mut ctx, &hf, (si, sect), mode.reflow || turn.is_turned());
|
||||
let start = if si == 0 { SectionStart::NextPage } else { sect.start };
|
||||
let sec = Sec { si, sect, page_format, start, tops };
|
||||
if !mode.web && !pb.pages.is_empty() && (start != SectionStart::Continuous || turn != prev_turn) {
|
||||
// The page the section before ends on is finished with its own margins and footnote
|
||||
// separators, before this section's replace them: the footnotes pending on it
|
||||
// belong to the section whose page it is (#5289).
|
||||
pb.finish_page();
|
||||
}
|
||||
// This section's fields and separators, without the previous section's grid, before its
|
||||
// header and footer room is measured; its own grid once that room is known (#4501).
|
||||
begin_section_state(&mut ctx, &mut pb, (si, sect, page_format), totals, mode.web);
|
||||
let tops = section_tops(&mut ctx, &hf, (si, sect), mode.reflow || turn.is_turned());
|
||||
// Measuring the room took the (reflow) section's own number format; the body keeps the
|
||||
// document section's.
|
||||
ctx.fields.page_format = page_format;
|
||||
apply_section_grid(&mut ctx, &mut pb, sect);
|
||||
let sec = Sec { si, sect, page_format, start, tops };
|
||||
begin_section_state(&mut ctx, &mut pb, &sec, totals, mode.web);
|
||||
if mode.web && si > 0 {
|
||||
// one long page
|
||||
} else if pb.pages.is_empty() || start != SectionStart::Continuous || turn != prev_turn {
|
||||
|
||||
@@ -4055,76 +4055,6 @@ fn line_numbers_follow_the_line_number_style() {
|
||||
assert!(styled.iter().all(|(size, color)| *size == 8.0 && *color == red), "{styled:?}");
|
||||
}
|
||||
|
||||
/// #6018: German Word saves the "line number" style as `Zeilennummer`; the numbers still take
|
||||
/// its formatting, found by the built-in name.
|
||||
#[test]
|
||||
fn line_numbers_follow_a_localised_line_number_style() {
|
||||
let mut d = Document::from_text("one\ntwo\nthree");
|
||||
d.last_section.line_numbers = Some(Default::default());
|
||||
let red = wordcraft_doc::TextColor::Rgb(wordcraft_doc::Rgb(0xC0, 0, 0));
|
||||
Arc::make_mut(&mut d.styles).upsert(wordcraft_doc::Style {
|
||||
id: "Zeilennummer".into(),
|
||||
name: "Line Number".into(),
|
||||
kind: wordcraft_doc::StyleKind::Character,
|
||||
chr: CharProps { size: Some(8.0), color: Some(red), ..Default::default() },
|
||||
..Default::default()
|
||||
});
|
||||
let l = lay(&d);
|
||||
let runs: Vec<_> = l.pages[0]
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|it| match it {
|
||||
Placed::Lines { story, para, .. } if *story == DECOR_STORY => para.styles.first().map(|s| (s.size, s.rc.color)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(runs.len(), 3);
|
||||
assert!(runs.iter().all(|(size, color)| *size == 8.0 && *color == red), "{runs:?}");
|
||||
}
|
||||
|
||||
/// #6018: in two columns each column has its numbers at its own left edge, as in Word; the
|
||||
/// second column's used to be drawn in the page margin over the first column's.
|
||||
#[test]
|
||||
fn line_numbers_stand_beside_each_column() {
|
||||
crate::test_fonts();
|
||||
let text = (1..=120).map(|i| format!("line {i}")).collect::<Vec<_>>().join("\n");
|
||||
let mut d = Document::from_text(&text);
|
||||
d.last_section.line_numbers = Some(Default::default());
|
||||
d.last_section.columns = wordcraft_doc::section::Columns { count: 2, space: 36.0, separator: false, widths: Vec::new() };
|
||||
let s = d.last_section.clone();
|
||||
let col2 = s.margin_left + s.gutter + (s.text_width() - 36.0) / 2.0 + 36.0;
|
||||
let l = lay(&d);
|
||||
let p = &l.pages[0];
|
||||
// Right edge and top of every number.
|
||||
let numbers: Vec<(f32, f32)> = p
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|it| match it {
|
||||
Placed::Lines { story, para, x, y, .. } if *story == DECOR_STORY => {
|
||||
Some((x + para.lines.first().and_then(|l| l.xs.last().copied()).unwrap_or(0.0), *y))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let body_xs: Vec<f32> = p
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|it| match it {
|
||||
Placed::Lines { story: StoryRef::Body, x, .. } => Some(*x),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert!(body_xs.iter().any(|x| (x - col2).abs() < 0.5), "the text runs into the second column: {body_xs:?}");
|
||||
let in_col2 = body_xs.iter().filter(|x| (*x - col2).abs() < 0.5).count();
|
||||
let right: Vec<_> = numbers.iter().filter(|(r, _)| *r > s.margin_left + 1.0).collect();
|
||||
assert_eq!(right.len(), in_col2, "one number per second-column line");
|
||||
// Numbers end 18 pt (auto distance) left of their column.
|
||||
assert!(right.iter().all(|(r, _)| (r - (col2 - 18.0)).abs() < 0.5), "{right:?}");
|
||||
let left = numbers.len() - right.len();
|
||||
assert!(numbers.iter().filter(|(r, _)| *r <= s.margin_left + 1.0).all(|(r, _)| (r - (s.margin_left - 18.0)).abs() < 0.5));
|
||||
assert!(left > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_numbers_borders_text_boxes() {
|
||||
crate::test_fonts();
|
||||
|
||||
@@ -627,61 +627,6 @@ fn a_page_ends_when_a_multiple_spaced_lines_text_no_longer_fits() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sections_header_is_measured_without_the_previous_sections_grid() {
|
||||
// #4501 follow-up: section 1 has a line grid, section 2 a tall header of its own. The header
|
||||
// room is measured before section 2's grid is on, so section 1's pitch never reaches it.
|
||||
let body_top = |grid: Option<wordcraft_doc::section::DocGrid>| {
|
||||
let mut d = Document::from_text("First section\nSecond section");
|
||||
let id = d.add_part(PartKind::Header, paras("Header line", 8));
|
||||
d.last_section.headers.default = Some(id);
|
||||
let first = SectionProps { doc_grid: grid, headers: Default::default(), ..d.last_section.clone() };
|
||||
d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().section = Some(Box::new(first));
|
||||
let l = lay(&d);
|
||||
assert_eq!(l.pages.len(), 2);
|
||||
l.pages[1].body.y
|
||||
};
|
||||
let grid = wordcraft_doc::section::DocGrid { kind: wordcraft_doc::section::DocGridType::Lines, line_pitch: 18.0, char_space: 0 };
|
||||
let plain = body_top(None);
|
||||
assert!(plain > 72.0, "the header reaches past the top margin: {plain}");
|
||||
assert_eq!(body_top(Some(grid)), plain, "section 1's grid leaked into section 2's header");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sections_header_is_measured_with_its_own_section_page_count() {
|
||||
// #4501 follow-up: section 1 has one page ("one"), section 2 twenty-three ("twenty-three").
|
||||
// Section 2's header room is measured with its own SECTIONPAGES, so its body starts below it.
|
||||
// (The total in the body makes layout run again with the page totals found.)
|
||||
let mut d = Document::from_text(&format!("First section\n{}", "Second section\n".repeat(22)));
|
||||
for i in 1..d.body.len() {
|
||||
d.para_mut(StoryRef::Body, &Path::top(i)).unwrap().props.page_break_before = Some(i > 1);
|
||||
}
|
||||
let field = InlineObject::Field {
|
||||
instr: r"SECTIONPAGES \* CardText".into(),
|
||||
result: String::new(),
|
||||
locked: false,
|
||||
dirty: true,
|
||||
code: false,
|
||||
form: String::new(),
|
||||
};
|
||||
let first_para = d.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
first_para.insert_object(first_para.len(), field.clone(), &Default::default()).unwrap();
|
||||
let mut header = Paragraph::new();
|
||||
for _ in 0..60 {
|
||||
header.insert_object(header.len(), field.clone(), &Default::default()).unwrap();
|
||||
}
|
||||
let id = d.add_part(PartKind::Header, vec![para_block(header)]);
|
||||
d.last_section.headers.default = Some(id);
|
||||
let first = SectionProps { headers: Default::default(), ..d.last_section.clone() };
|
||||
d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().section = Some(Box::new(first));
|
||||
let l = lay(&d);
|
||||
assert_eq!(l.pages.len(), 24);
|
||||
let p = &l.pages[1];
|
||||
let (_, header_bottom) = extent(&p.header, StoryRef::Part(id)).unwrap();
|
||||
assert!(header_bottom > 72.0, "the header reaches past the top margin: {header_bottom}");
|
||||
assert!(header_bottom <= p.body.y + 0.01, "the body starts at {} inside the header ending at {header_bottom}", p.body.y);
|
||||
}
|
||||
|
||||
/// A body PAGE field shows the page number in the section's number format, as Word does (a
|
||||
/// cardinal-text section shows "one"), and `\*` overrides it.
|
||||
#[test]
|
||||
|
||||
@@ -136,8 +136,14 @@ impl Picture {
|
||||
match item {
|
||||
Item::Path { segs, fill, stroke, even_odd } => {
|
||||
let path = tf * bez(segs);
|
||||
if in_range(path.bounding_box()) {
|
||||
let stroke = stroke.map(|(c, w)| (c, f64::from(w) * k));
|
||||
let bounds = path.bounding_box();
|
||||
if in_range(bounds) {
|
||||
// A pen wider than twice the distance across the frame and the path
|
||||
// already covers the whole frame: wider adds nothing the frame shows, only
|
||||
// work for the renderer flattening its round caps (the width is file data).
|
||||
let u = bounds.union(r);
|
||||
let widest = 2.0 * u.width().hypot(u.height());
|
||||
let stroke = stroke.map(|(c, w)| (c, (f64::from(w) * k).min(widest)));
|
||||
out.push(PlacedItem::Path { path, fill: *fill, stroke, even_odd: *even_odd });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -891,6 +891,34 @@ fn place_fits_the_frame_scales_strokes_and_drops_far_paths() {
|
||||
assert_eq!(stroke.map(|s| s.1), Some(8.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_huge_pen_is_held_to_what_covers_the_frame() {
|
||||
// A pen of 1e30 units on a short path: round caps that wide took the renderer forever to
|
||||
// flatten. Past twice the distance across the frame and the path it covers no more.
|
||||
let path = |w: f32| crate::Picture {
|
||||
width: 100.0,
|
||||
height: 50.0,
|
||||
items: vec![Item::Path {
|
||||
segs: vec![Seg::Move(0.0, 0.0), Seg::Line(10.0, 0.0)],
|
||||
fill: None,
|
||||
stroke: Some(([0, 0, 0, 255], w)),
|
||||
even_odd: true,
|
||||
}],
|
||||
};
|
||||
let width = |w: f32| match pic_place(&path(w)).first() {
|
||||
Some(crate::PlacedItem::Path { stroke, .. }) => stroke.map(|s| s.1),
|
||||
_ => None,
|
||||
};
|
||||
let cap = 2.0 * 200f64.hypot(100.0);
|
||||
assert_eq!(width(1e30), Some(cap));
|
||||
assert_eq!(width(f32::MAX), Some(cap));
|
||||
assert_eq!(width(4.0), Some(8.0), "an ordinary pen is untouched");
|
||||
}
|
||||
|
||||
fn pic_place(pic: &crate::Picture) -> Vec<crate::PlacedItem> {
|
||||
pic.place(kurbo::Rect::new(0.0, 0.0, 200.0, 100.0), [0.0; 4])
|
||||
}
|
||||
|
||||
/// #1899: a crop keeps what the file says down to a sliver (a header's bar is 2.7% of a page-sized
|
||||
/// metafile), and junk sides give a finite part of the source with some area.
|
||||
#[test]
|
||||
|
||||
@@ -321,9 +321,7 @@ 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 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));
|
||||
let r = Rect::from_min_size(tile.min, vec2(150.0, 194.0));
|
||||
ui.painter().rect(
|
||||
r,
|
||||
2.0,
|
||||
@@ -354,8 +352,7 @@ 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);
|
||||
}
|
||||
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());
|
||||
ui.painter().galley(pos2(r.min.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,139 +1156,3 @@ 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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -175,9 +175,7 @@ fn handle_page_pointer(app: &mut WordApp, pointer: PagePointer<'_>) {
|
||||
return;
|
||||
}
|
||||
if pressed {
|
||||
if handle_primary_press(app, &pointer, mods, hf_zone, hf_enter) {
|
||||
return;
|
||||
}
|
||||
handle_primary_press(app, &pointer, mods, hf_zone, hf_enter);
|
||||
} else if app.canvas.dragging
|
||||
&& ui.input(|i| i.pointer.primary_down())
|
||||
&& let Some((from, last)) = app.canvas.column_drag
|
||||
@@ -231,14 +229,13 @@ fn handle_page_pointer(app: &mut WordApp, pointer: PagePointer<'_>) {
|
||||
show_hover_link(app, &pointer);
|
||||
}
|
||||
|
||||
/// A press. `true` when it was handled and the pointer is done (no hover tooltip this frame).
|
||||
fn handle_primary_press(
|
||||
app: &mut WordApp,
|
||||
pointer: &PagePointer<'_>,
|
||||
mods: egui::Modifiers,
|
||||
hf_zone: Option<(Option<StoryRef>, bool)>,
|
||||
hf_enter: bool,
|
||||
) -> bool {
|
||||
) {
|
||||
let PagePointer { ui, layout, p, page, x, y, story, .. } = *pointer;
|
||||
let now = ui.input(|i| i.time);
|
||||
let (delay, dist) = ui.ctx().options(|o| (o.input_options.max_double_click_delay, o.input_options.max_click_dist));
|
||||
@@ -272,14 +269,14 @@ fn handle_primary_press(
|
||||
}
|
||||
app.session.page_hint = page;
|
||||
}
|
||||
return true;
|
||||
return;
|
||||
}
|
||||
// A press in the header/footer area from the body leaves the caret where it is, as in
|
||||
// Word (#2103): moving it to the nearest body text scrolled the view there, away from the
|
||||
// footer the double-click was aiming at.
|
||||
if hf_switch && !mods.shift && hf_zone.is_some() && story == StoryRef::Body && app.session.sel.focus.story == StoryRef::Body {
|
||||
app.canvas.column_drag = None;
|
||||
return true;
|
||||
return;
|
||||
}
|
||||
if !mods.shift
|
||||
&& let Some((at, inner)) = layout.equation_hit(page, x, y, story)
|
||||
@@ -287,7 +284,7 @@ fn handle_primary_press(
|
||||
app.session.sel = wordcraft_engine::Selection::caret(at);
|
||||
let _ = app.run("equation.edit", json!({"pos": serde_json::to_value(&inner).unwrap_or_default()}));
|
||||
app.session.page_hint = page;
|
||||
return true;
|
||||
return;
|
||||
}
|
||||
if app.session.math.is_some() {
|
||||
let _ = app.run("equation.exit", json!({}));
|
||||
@@ -327,15 +324,15 @@ fn handle_primary_press(
|
||||
};
|
||||
app.canvas.unit_drag = Some(UnitDrag { unit, clicks, start, end, last: line });
|
||||
app.canvas.dragging = true;
|
||||
return true;
|
||||
return;
|
||||
}
|
||||
let Some(pos) = layout.hit(page, x, y, story) else { return true };
|
||||
let Some(pos) = layout.hit(page, x, y, story) else { return };
|
||||
// Ctrl/⌘+click follows a hyperlink.
|
||||
if mods.command
|
||||
&& let Some(link) = link_under(app, layout, page, x, y, &pos)
|
||||
{
|
||||
follow_link(app, link);
|
||||
return true;
|
||||
return;
|
||||
}
|
||||
let pj = serde_json::to_value(&pos).unwrap_or_default();
|
||||
// Alt+drag selects a column (block) of text.
|
||||
@@ -344,7 +341,7 @@ fn handle_primary_press(
|
||||
app.session.page_hint = page;
|
||||
app.canvas.column_drag = Some(((page, x, y), (page, x, y)));
|
||||
app.canvas.dragging = true;
|
||||
return true;
|
||||
return;
|
||||
}
|
||||
app.canvas.column_drag = None;
|
||||
let _ = app.run("caret.set", json!({"pos": pj, "extend": mods.shift}));
|
||||
@@ -365,7 +362,6 @@ fn handle_primary_press(
|
||||
app.session.page_hint = page;
|
||||
app.canvas.dragging = true;
|
||||
// Format painter applies on mouse up
|
||||
false
|
||||
}
|
||||
|
||||
fn pointer_story(app: &WordApp, layout: &DocLayout, page: usize, x: f32, y: f32, story: StoryRef) -> StoryRef {
|
||||
|
||||
@@ -2887,40 +2887,6 @@ fn right_clicking_a_link_hides_the_hover_tooltip() {
|
||||
assert_eq!(tips(&h), 0, "the link tooltip is gone, so it can't cover the menu");
|
||||
}
|
||||
|
||||
/// #4501 follow-up: a press the canvas handles (here Ctrl+click following a link) is done with
|
||||
/// the pointer, so the link tooltip stands down that frame; plain hovering shows it again.
|
||||
#[test]
|
||||
fn a_handled_press_skips_the_link_tooltip() {
|
||||
use egui::{Event, Modifiers, PointerButton};
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("See here")), Default::default());
|
||||
a.run("select.text", json!({"text": "here"})).unwrap();
|
||||
a.run("insert.link", json!({"url": "https://example.com/"})).unwrap();
|
||||
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);
|
||||
},
|
||||
a,
|
||||
);
|
||||
steps(&mut h, 6);
|
||||
let c = h.state_mut().session.layout().caret(&Pos { story: StoryRef::Body, path: wordcraft_doc::Path::top(0), off: 5 }).unwrap();
|
||||
let at = screen(&mut h, c.x, c.top + c.height / 2.0);
|
||||
h.event(Event::PointerMoved(at));
|
||||
steps(&mut h, 2);
|
||||
let tips = |h: &egui_kittest::Harness<'_, WordApp>| h.query_all_by_label_contains("Click to follow link").count();
|
||||
assert!(tips(&h) > 0, "hovering the link shows its target");
|
||||
h.event(Event::ModifiersChanged(Modifiers::COMMAND));
|
||||
h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::COMMAND });
|
||||
h.step();
|
||||
assert_eq!(tips(&h), 0, "the Ctrl+click was handled: no tooltip that frame");
|
||||
h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::COMMAND });
|
||||
h.event(Event::ModifiersChanged(Modifiers::NONE));
|
||||
steps(&mut h, 2);
|
||||
assert!(tips(&h) > 0, "hovering again shows the tooltip");
|
||||
}
|
||||
|
||||
/// #688: the drag auto-scroll waits in the middle, ramps in from both edges and never leaves
|
||||
/// the scrollable range.
|
||||
#[test]
|
||||
|
||||
@@ -67,10 +67,6 @@ 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 {
|
||||
@@ -89,12 +85,10 @@ impl CaptureGeometry {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// Explains why the surface cannot be captured, when it is minimized or fully occluded, and
|
||||
/// returns `None` when it can.
|
||||
pub(crate) fn unavailable_reason(self) -> Option<&'static str> {
|
||||
if self.viewport != egui::ViewportId::ROOT {
|
||||
Some(UNSUPPORTED_SECONDARY_WINDOW_ERROR)
|
||||
} else if self.surface.minimized {
|
||||
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, UNSUPPORTED_SECONDARY_WINDOW_ERROR};
|
||||
use super::geometry::CaptureGeometry;
|
||||
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,19 +128,6 @@ 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,16 +12,11 @@ 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;
|
||||
@@ -30,8 +25,6 @@ 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),
|
||||
@@ -42,10 +35,8 @@ 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",
|
||||
@@ -60,7 +51,6 @@ 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",
|
||||
@@ -78,7 +68,6 @@ 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)),
|
||||
@@ -95,7 +84,6 @@ 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),
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
//! 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,155 +166,3 @@ 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,7 +235,6 @@ 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"),
|
||||
d("Date & Time", "dateTime"),
|
||||
c("Date & Time", "insert.dateTime"),
|
||||
c("Object", "insert.object"),
|
||||
c("Equation", "insert.equation"),
|
||||
m("Symbol", "insert.symbol"),
|
||||
|
||||
@@ -294,12 +294,6 @@ 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:
|
||||
@@ -451,7 +445,6 @@ 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(),
|
||||
@@ -462,9 +455,6 @@ 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(),
|
||||
|
||||
@@ -70,10 +70,7 @@ use shape::shape_format;
|
||||
use table::{table_design, table_layout};
|
||||
#[cfg(test)]
|
||||
use tabs::MIN_TAB_W;
|
||||
use tabs::{
|
||||
CONTENT_SWITCH_SLIDE, MORE_TABS_W, TAB_ENTRY_SECS, TAB_ENTRY_SLIDE, ease_out_cubic, note_contextual_tabs, reading_toolbar, slide_underline,
|
||||
tab_content_entry, tab_entry,
|
||||
};
|
||||
use tabs::{MORE_TABS_W, TAB_ENTRY_SECS, TAB_ENTRY_SLIDE, ease_out_cubic, note_contextual_tabs, reading_toolbar, tab_entry};
|
||||
pub use tabs::{
|
||||
StripPlan, TABS, TabSpec, contextual_tabs, fit_tabs, has_chart_selected, has_picture_selected, has_shape_selected, has_smart_art_selected,
|
||||
in_table_public, plan_strip, resolve_tab,
|
||||
@@ -280,13 +277,8 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
}
|
||||
let active = app.ui.tab == tab && !app.ui.backstage;
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, active, shown));
|
||||
let hover = ui.ctx().animate_bool_responsive(resp.id.with("hover"), resp.hovered() && !active && ghost.is_none());
|
||||
if hover > 0.0 {
|
||||
ui.painter().rect_filled(
|
||||
r.shrink2(vec2(0.0, 4.0)),
|
||||
4.0,
|
||||
egui::Color32::TRANSPARENT.lerp_to_gamma(t.hover, hover),
|
||||
);
|
||||
if resp.hovered() && !active && ghost.is_none() {
|
||||
ui.painter().rect_filled(r.shrink2(vec2(0.0, 4.0)), 4.0, t.hover);
|
||||
}
|
||||
// Entering and departing contextual tabs move and fade without changing layout.
|
||||
let entry = match ghost {
|
||||
@@ -324,7 +316,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
let resp = if cut && ghost.is_none() { resp.on_hover_text(shown) } else { resp };
|
||||
if active {
|
||||
let u = Rect::from_center_size(pos2(r.center().x + shift.x, r.max.y - 2.0), vec2(w - 16.0, 3.0));
|
||||
slide_underline(ui, tab, u, t.accent.gamma_multiply(entry), now, animate);
|
||||
ui.painter().rect_filled(u, 2.0, t.accent.gamma_multiply(entry));
|
||||
}
|
||||
if resp.clicked() {
|
||||
let _ = app.run("ui.tab", json!({"tab": tab}));
|
||||
@@ -345,13 +337,8 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
.collect();
|
||||
if !left_out.is_empty() {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(MORE_TABS_W, 30.0), Sense::click());
|
||||
let hover = ui.ctx().animate_bool_responsive(resp.id.with("hover"), resp.hovered());
|
||||
if hover > 0.0 {
|
||||
ui.painter().rect_filled(
|
||||
r.shrink2(vec2(0.0, 4.0)),
|
||||
4.0,
|
||||
egui::Color32::TRANSPARENT.lerp_to_gamma(t.hover, hover),
|
||||
);
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r.shrink2(vec2(0.0, 4.0)), 4.0, t.hover);
|
||||
}
|
||||
// The active tab is in the menu: the button says so.
|
||||
let holds_active = left_out.iter().any(|(tab, _)| *tab == app.ui.tab);
|
||||
@@ -389,13 +376,6 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
egui::Frame::NONE.fill(t.ribbon).inner_margin(egui::Margin { left: 8, right: 8, top: 4, bottom: 4 }).stroke(Stroke::new(1.0, t.border)),
|
||||
)
|
||||
.show(ui, |ui| {
|
||||
// Bring newly selected ribbon content in from the side of its tab.
|
||||
let index = tabs.iter().position(|(name, _)| name == &app.ui.tab).unwrap_or(0);
|
||||
let (settled, direction) = tab_content_entry(ui.ctx(), &app.ui.tab, index, now, animate);
|
||||
let shift = vec2(direction * (1.0 - settled) * CONTENT_SWITCH_SLIDE, 0.0);
|
||||
let mut shifted = ui.new_child(egui::UiBuilder::new().max_rect(ui.max_rect().translate(shift)).layout(*ui.layout()));
|
||||
shifted.set_opacity(0.2 + 0.8 * settled);
|
||||
let ui = &mut shifted;
|
||||
// Right-click on the ribbon (outside a button): Customize Ribbon. Registered first, so
|
||||
// the buttons drawn over it keep their own clicks.
|
||||
// No document open (#1129): every command is greyed out but Help's.
|
||||
|
||||
@@ -44,7 +44,6 @@ 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| {
|
||||
@@ -190,7 +189,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("ui.dialog", json!({"name": "dateTime"}), false));
|
||||
small(ui, app, "dateTime", None, "Date & Time", crate::widgets::ButtonAction::new("insert.dateTime", json!({}), false));
|
||||
small(ui, app, "object", None, "Object", crate::widgets::ButtonAction::new("insert.object", json!({}), false));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -158,78 +158,6 @@ pub(super) fn ease_out_cubic(p: f32) -> f32 {
|
||||
1.0 - (1.0 - p.clamp(0.0, 1.0)).powi(3)
|
||||
}
|
||||
|
||||
const UNDERLINE_SLIDE_SECS: f64 = 0.2;
|
||||
const CONTENT_SWITCH_SECS: f64 = 0.16;
|
||||
pub(super) const CONTENT_SWITCH_SLIDE: f32 = 14.0;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct Underline {
|
||||
tab: String,
|
||||
from: (f32, f32),
|
||||
to: (f32, f32),
|
||||
start: f64,
|
||||
}
|
||||
|
||||
fn underline_at(u: &Underline, now: f64) -> (f32, f32) {
|
||||
let e = ease_out_cubic(((now - u.start) / UNDERLINE_SLIDE_SECS) as f32);
|
||||
(egui::lerp(u.from.0..=u.to.0, e), egui::lerp(u.from.1..=u.to.1, e))
|
||||
}
|
||||
|
||||
pub(super) fn slide_underline(ui: &egui::Ui, tab: &str, target: egui::Rect, color: egui::Color32, now: f64, animate: bool) {
|
||||
let id = egui::Id::unique("tab_underline");
|
||||
let prev: Option<Underline> = ui.ctx().data(|d| d.get_temp(id));
|
||||
let snap = Underline { tab: tab.to_owned(), from: (target.center().x, target.width()), to: (target.center().x, target.width()), start: now };
|
||||
let next = match prev {
|
||||
Some(p) if animate && p.tab != tab => Underline { from: underline_at(&p, now), ..snap },
|
||||
Some(p) if animate && now - p.start < UNDERLINE_SLIDE_SECS => Underline { to: (target.center().x, target.width()), ..p },
|
||||
_ => snap,
|
||||
};
|
||||
let (x, width) = underline_at(&next, now);
|
||||
if next.from != next.to && now - next.start < UNDERLINE_SLIDE_SECS {
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
ui.ctx().data_mut(|d| d.insert_temp(id, next));
|
||||
ui.painter().rect_filled(egui::Rect::from_center_size(egui::pos2(x, target.center().y), egui::vec2(width, target.height())), 2.0, color);
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct TabChange {
|
||||
tab: String,
|
||||
index: usize,
|
||||
start: f64,
|
||||
dir: f32,
|
||||
}
|
||||
|
||||
pub(super) fn tab_content_entry(ctx: &egui::Context, tab: &str, index: usize, now: f64, animate: bool) -> (f32, f32) {
|
||||
let id = egui::Id::unique("ribbon_tab_change");
|
||||
let prev: Option<TabChange> = ctx.data(|d| d.get_temp(id));
|
||||
let next = match prev {
|
||||
Some(p) if p.tab != tab => TabChange {
|
||||
tab: tab.to_owned(),
|
||||
index,
|
||||
start: now,
|
||||
dir: if !animate || p.index == index {
|
||||
0.0
|
||||
} else if index > p.index {
|
||||
1.0
|
||||
} else {
|
||||
-1.0
|
||||
},
|
||||
},
|
||||
Some(p) => p,
|
||||
None => TabChange { tab: tab.to_owned(), index, start: f64::MIN, dir: 0.0 },
|
||||
};
|
||||
let p = ((now - next.start) / CONTENT_SWITCH_SECS).clamp(0.0, 1.0) as f32;
|
||||
let dir = next.dir;
|
||||
ctx.data_mut(|d| d.insert_temp(id, next));
|
||||
if p < 1.0 && dir != 0.0 {
|
||||
ctx.request_repaint();
|
||||
(ease_out_cubic(p), dir)
|
||||
} else {
|
||||
(1.0, 0.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Track contextual tabs between frames and return those still animating out.
|
||||
pub(super) fn note_contextual_tabs(ctx: &egui::Context, shown: &[(&str, usize)], now: f64, animate: bool) -> Vec<(String, usize, f64)> {
|
||||
let (shown_id, leaving_id) = (egui::Id::unique("contextual_tabs_shown"), egui::Id::unique("contextual_tabs_leaving"));
|
||||
@@ -418,40 +346,4 @@ mod transition_tests {
|
||||
note_contextual_tabs(&ctx, &both[..1], 7.0, true);
|
||||
assert!(note_contextual_tabs(&ctx, &both, 7.1, true).is_empty(), "back again");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_underline_slides_and_can_reverse_mid_motion() {
|
||||
let ctx = egui::Context::default();
|
||||
let draw = |tab: &str, x: f32, now: f64| {
|
||||
ctx.run_ui(egui::RawInput { time: Some(now), ..Default::default() }, |ui| {
|
||||
slide_underline(
|
||||
ui,
|
||||
tab,
|
||||
egui::Rect::from_min_size(egui::pos2(x - 25.0, 10.0), egui::vec2(50.0, 3.0)),
|
||||
egui::Color32::WHITE,
|
||||
now,
|
||||
true,
|
||||
);
|
||||
})
|
||||
.drop_without_applying_deltas();
|
||||
};
|
||||
draw("Home", 40.0, 1.0);
|
||||
draw("Insert", 140.0, 2.0);
|
||||
let frame = ctx.run_ui(egui::RawInput { time: Some(2.1), ..Default::default() }, |ui| {
|
||||
slide_underline(ui, "Draw", egui::Rect::from_min_size(egui::pos2(215.0, 10.0), egui::vec2(50.0, 3.0)), egui::Color32::WHITE, 2.1, true);
|
||||
});
|
||||
assert!(!frame.shapes.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_content_motion_comes_from_the_new_tab_side() {
|
||||
let ctx = egui::Context::default();
|
||||
assert_eq!(tab_content_entry(&ctx, "Home", 1, 1.0, true), (1.0, 0.0));
|
||||
let (p, dir) = tab_content_entry(&ctx, "Insert", 2, 2.0, true);
|
||||
assert_eq!((p, dir), (0.0, 1.0));
|
||||
let (p, dir) = tab_content_entry(&ctx, "Home", 1, 2.05, true);
|
||||
assert!(p < 1.0);
|
||||
assert_eq!(dir, -1.0);
|
||||
assert_eq!(tab_content_entry(&ctx, "Draw", 3, 3.0, false), (1.0, 0.0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,19 +35,12 @@ pub fn enabled(app: &WordApp, id: &str) -> bool {
|
||||
}
|
||||
|
||||
pub(crate) fn bg(ui: &Ui, r: Rect, resp: &Response, checked: bool, t: &Tokens) {
|
||||
bg_if(ui, r, resp, checked, t, true);
|
||||
}
|
||||
|
||||
/// Paints a button background with an optional hover/press response. Disabled ribbon commands
|
||||
/// pass `false` so their visual state cannot imply that they are interactive.
|
||||
pub(crate) fn bg_if(ui: &Ui, r: Rect, resp: &Response, checked: bool, t: &Tokens, on: bool) {
|
||||
let hover = ui.ctx().animate_bool_responsive(resp.id.with("hover"), resp.hovered() && on);
|
||||
let fill = if resp.is_pointer_button_down_on() && on {
|
||||
let fill = if resp.is_pointer_button_down_on() {
|
||||
t.pressed
|
||||
} else if checked {
|
||||
t.checked
|
||||
} else if hover > 0.0 {
|
||||
Color32::TRANSPARENT.lerp_to_gamma(t.hover, hover)
|
||||
} else if resp.hovered() {
|
||||
t.hover
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
@@ -112,7 +105,7 @@ fn big_button(ui: &mut Ui, app: &mut WordApp, icon: &str, label: &str, id: &str,
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(w, CONTENT_H), Sense::click());
|
||||
keytip_badge(ui, r, id, &label.replace('\n', " "));
|
||||
let on = enabled(app, id);
|
||||
bg_if(ui, r, &resp, checked, &t, on);
|
||||
bg(ui, r, &resp, checked, &t);
|
||||
let ic = Rect::from_center_size(pos2(r.center().x, r.min.y + BIG_ICON_Y), vec2(32.0, 32.0));
|
||||
let (c, a) = if on { (t.icon, t.accent) } else { (t.text_disabled, t.text_disabled) };
|
||||
icons::paint(ui.painter(), ic, icon, c, a);
|
||||
@@ -153,7 +146,7 @@ pub fn small(ui: &mut Ui, app: &mut WordApp, icon: &str, label: Option<&str>, ti
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(24.0 + text_w, 22.0), Sense::click());
|
||||
keytip_badge(ui, r, id, tip);
|
||||
let on = enabled(app, id);
|
||||
bg_if(ui, r, &resp, checked, &t, on);
|
||||
bg(ui, r, &resp, checked, &t);
|
||||
let rtl = crate::rtl::flows(ui);
|
||||
let ic = Rect::from_center_size(pos2(crate::rtl::lead_x(rtl, r, 12.0), r.center().y), vec2(17.0, 17.0));
|
||||
let (c, a) = if on { (t.icon, t.accent) } else { (t.text_disabled, t.text_disabled) };
|
||||
@@ -188,8 +181,8 @@ pub fn split(
|
||||
keytip_badge(ui, r, id, tip);
|
||||
let (ar, aresp) = ui.allocate_exact_size(vec2(11.0, 22.0), Sense::click());
|
||||
let on = enabled(app, id);
|
||||
bg_if(ui, r, &resp, checked, &t, on);
|
||||
bg_if(ui, ar, &aresp, false, &t, on);
|
||||
bg(ui, r, &resp, checked, &t);
|
||||
bg(ui, ar, &aresp, false, &t);
|
||||
let ic = Rect::from_center_size(r.center(), vec2(17.0, 17.0));
|
||||
let (c, a) = if on { (t.icon, t.accent) } else { (t.text_disabled, t.text_disabled) };
|
||||
icons::paint(ui.painter(), ic, icon, c, a);
|
||||
@@ -259,7 +252,7 @@ pub fn menu_button(ui: &mut Ui, app: &mut WordApp, options: MenuButtonOptions<'_
|
||||
let w = (galley_w + 22.0).max(44.0);
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(w, CONTENT_H), Sense::click());
|
||||
keytip_badge(ui, r, "menu", tip);
|
||||
bg_if(ui, r, &resp, false, &t, on);
|
||||
bg(ui, r, &resp, false, &t);
|
||||
icons::paint(ui.painter(), Rect::from_center_size(pos2(r.center().x, r.min.y + BIG_ICON_Y), vec2(32.0, 32.0)), icon, c, a);
|
||||
let lines: Vec<&str> = label.unwrap_or("").split('\n').collect();
|
||||
let mut y = r.min.y + BIG_LABEL_Y;
|
||||
@@ -272,7 +265,7 @@ pub fn menu_button(ui: &mut Ui, app: &mut WordApp, options: MenuButtonOptions<'_
|
||||
let text_w = label.map(|l| ui.ctx().fonts_mut(|f| f.layout_no_wrap(l.to_string(), regular(11.5), t.text).size().x) + 6.0).unwrap_or(0.0);
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(24.0 + text_w + 10.0, 22.0), Sense::click());
|
||||
keytip_badge(ui, r, "menu", tip);
|
||||
bg_if(ui, r, &resp, false, &t, on);
|
||||
bg(ui, r, &resp, false, &t);
|
||||
let rtl = crate::rtl::flows(ui);
|
||||
let lead = |inset: f32| crate::rtl::lead_x(rtl, r, inset);
|
||||
icons::paint(ui.painter(), Rect::from_center_size(pos2(lead(12.0), r.center().y), vec2(17.0, 17.0)), icon, c, a);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#[cfg(test)]
|
||||
use super::TALL_GROUPS;
|
||||
use super::buttons::{BIG_ICON_Y, BIG_LABEL_Y, CONTENT_H, DROPDOWN, LABEL_H, bg, label_line};
|
||||
use super::{Align2, Color32, Rect, Response, Sense, Stroke, Tokens, Ui, WordApp, icons, pos2, regular, vec2};
|
||||
use super::{Align2, Rect, Response, Sense, Stroke, Tokens, Ui, WordApp, icons, pos2, regular, vec2};
|
||||
|
||||
/// A ribbon group: content, a centred label below and a divider on the right.
|
||||
pub fn group(ui: &mut Ui, title: &str, launcher: Option<&str>, app: &mut WordApp, add: impl FnOnce(&mut Ui, &mut WordApp)) {
|
||||
@@ -26,8 +26,6 @@ pub fn group_titled(ui: &mut Ui, id_title: &str, title: &str, launcher: Option<&
|
||||
// Not folded: a drop-down left open while it was folded doesn't come back by itself.
|
||||
ui.data_mut(|d| d.remove::<bool>(collapsed_open_id(ui, id_title, position)));
|
||||
let label_w = ui.ctx().fonts_mut(|f| f.layout_no_wrap(title.to_string(), regular(11.0), t.group_label).size().x);
|
||||
// Reserve the paint slot before children so the glow stays beneath the controls.
|
||||
let glow = ui.painter().add(egui::Shape::Noop);
|
||||
let inner = crate::rtl::vertical(ui, |ui| {
|
||||
ui.set_min_width(label_w + if launcher.is_some() { 22.0 } else { 10.0 });
|
||||
crate::ribbon_fit::set_compact(ui.ctx(), level == crate::ribbon_fit::COMPACT);
|
||||
@@ -44,13 +42,6 @@ pub fn group_titled(ui: &mut Ui, id_title: &str, title: &str, launcher: Option<&
|
||||
let _ = content;
|
||||
});
|
||||
let r = inner.response.rect;
|
||||
let area = group_area(rtl, start, r);
|
||||
let over = ui.rect_contains_pointer(area);
|
||||
// Position is part of identity because duplicate custom group names can appear in tabs.
|
||||
let lit = ui.ctx().animate_bool_responsive(ui.scope_id().with(("group_glow", id_title, position)), over);
|
||||
if lit > 0.0 {
|
||||
ui.painter().set(glow, group_glow(area, t.accent, lit));
|
||||
}
|
||||
title_row(
|
||||
ui,
|
||||
Rect::from_min_max(pos2(if rtl { r.min.x } else { start.x }, start.y + CONTENT_H), pos2(r.max.x, start.y + CONTENT_H + LABEL_H)),
|
||||
@@ -67,13 +58,6 @@ pub fn group_titled(ui: &mut Ui, id_title: &str, title: &str, launcher: Option<&
|
||||
crate::ribbon_fit::record(ui.ctx(), id_title, level, group_width(rtl, start_cursor, ui.cursor()));
|
||||
}
|
||||
|
||||
/// The hover area of a group. A right-to-left row has no finite left edge at the cursor, so the
|
||||
/// area starts at the content's own left edge there.
|
||||
fn group_area(rtl: bool, start: egui::Pos2, content: Rect) -> Rect {
|
||||
let left = if rtl { content.min.x } else { start.x };
|
||||
Rect::from_min_max(pos2(left, start.y), pos2(content.max.x, start.y + CONTENT_H + LABEL_H))
|
||||
}
|
||||
|
||||
/// The width a group took: the cursor's way along the row, from `before` to `after`. A
|
||||
/// right-to-left row fills from its right edge, where the cursor's left edge is negative infinity,
|
||||
/// so there the width is how far the right edge moved.
|
||||
@@ -81,25 +65,6 @@ fn group_width(rtl: bool, before: Rect, after: Rect) -> f32 {
|
||||
if rtl { before.max.x - after.max.x } else { after.min.x - before.min.x }
|
||||
}
|
||||
|
||||
/// A soft accent glow along the bottom of a hovered ribbon group.
|
||||
fn group_glow(area: Rect, accent: Color32, strength: f32) -> egui::Shape {
|
||||
let color = |alpha: f32| Color32::from_rgba_unmultiplied(accent.r(), accent.g(), accent.b(), (alpha * strength * 255.0) as u8);
|
||||
let r = Rect::from_min_max(pos2(area.min.x, area.max.y - 7.0), pos2(area.max.x, area.max.y + 2.0));
|
||||
let soft = (r.width() * 0.3).min(22.0);
|
||||
let xs = [r.min.x, r.min.x + soft, r.max.x - soft, r.max.x];
|
||||
let mut mesh = egui::epaint::Mesh::default();
|
||||
for (y, alpha) in [(r.max.y, 0.3), (r.min.y, 0.0)] {
|
||||
for (i, x) in xs.iter().enumerate() {
|
||||
mesh.colored_vertex(pos2(*x, y), color(if i == 0 || i == 3 { 0.0 } else { alpha }));
|
||||
}
|
||||
}
|
||||
for i in 0..3u32 {
|
||||
mesh.add_triangle(i, i + 1, i + 5);
|
||||
mesh.add_triangle(i, i + 5, i + 4);
|
||||
}
|
||||
egui::Shape::mesh(mesh)
|
||||
}
|
||||
|
||||
/// A group's title row (`r`): the centred title and, with a `launcher`, its dialog launcher.
|
||||
fn title_row(ui: &mut Ui, r: Rect, id_title: &str, title: &str, launcher: Option<&str>, app: &mut WordApp) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
@@ -299,15 +264,4 @@ mod tests {
|
||||
let after = Rect::from_min_max(pos2(180.0, 0.0), pos2(f32::INFINITY, 90.0));
|
||||
assert_eq!(group_width(false, before, after), 160.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_group_in_a_right_to_left_row_has_a_finite_hover_area() {
|
||||
let start = pos2(f32::NEG_INFINITY, 10.0);
|
||||
let content = Rect::from_min_max(pos2(600.0, 10.0), pos2(740.0, 80.0));
|
||||
let area = group_area(true, start, content);
|
||||
assert!(area.min.x.is_finite() && area.max.x.is_finite());
|
||||
assert_eq!((area.min.x, area.max.x), (600.0, 740.0));
|
||||
let next = group_area(true, start, Rect::from_min_max(pos2(450.0, 10.0), pos2(590.0, 80.0)));
|
||||
assert!(!area.intersects(next), "neighbouring groups do not overlap");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,9 +261,6 @@ impl WordApp {
|
||||
quick_access_toolbar: _,
|
||||
recent: _,
|
||||
recent_symbols: _,
|
||||
date_time_format: _,
|
||||
date_time_language: _,
|
||||
date_time_update: _,
|
||||
recent_colors: _,
|
||||
recent_fonts: _,
|
||||
theme: _,
|
||||
|
||||
@@ -33,7 +33,6 @@ 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};
|
||||
|
||||
@@ -307,17 +306,6 @@ 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) {
|
||||
@@ -675,15 +663,6 @@ 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,7 +71,6 @@ 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;
|
||||
@@ -100,11 +99,6 @@ 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,14 +386,11 @@ fn control_typing_reaches_the_active_window() {
|
||||
assert_eq!(text(&a), "First", "the other window got none of it");
|
||||
}
|
||||
|
||||
/// `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.
|
||||
/// `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.
|
||||
#[test]
|
||||
fn screenshot_captures_the_root_window_and_refuses_secondary_windows() {
|
||||
fn screenshot_captures_the_active_window() {
|
||||
let ctx = backend();
|
||||
let mut a = app("First");
|
||||
frames(&ctx, &mut a, 2);
|
||||
@@ -406,102 +403,26 @@ fn screenshot_captures_the_root_window_and_refuses_secondary_windows() {
|
||||
|
||||
let (reply, replies) = std::sync::mpsc::channel();
|
||||
crate::control::admit_screenshot(&mut a, &ctx, None, reply);
|
||||
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);
|
||||
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])]);
|
||||
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), "the root window is captured");
|
||||
assert!(!shot_to(child), "the child window is not");
|
||||
assert!(shot_to(child), "the active window is captured");
|
||||
assert!(!shot_to(ViewportId::ROOT), "the first 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);
|
||||
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");
|
||||
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());
|
||||
}
|
||||
|
||||
/// Quitting with two documents of unsaved changes asks about each in its window, the window
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
//! 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-11-09 · #6159: refuse ui.screenshot on secondary document windows and support root window selection with view.switchWindows.
|
||||
> **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.
|
||||
|
||||
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,11 +72,9 @@ 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?, 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
|
||||
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
|
||||
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.
|
||||
|
||||
@@ -95,7 +93,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 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.screenshot` | `path?` | PNG of the window: written to `path`, or without one returned as base64 `png` in the reply |
|
||||
| `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 |
|
||||
@@ -133,7 +131,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. With `{"window": key}` it selects that window directly and brings it to the front.
|
||||
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
|
||||
@@ -156,14 +154,6 @@ 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).
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
+2
-2
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-2
File diff suppressed because one or more lines are too long
@@ -17,8 +17,6 @@ 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,8 +18,6 @@ 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,8 +25,7 @@ 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. Without the X11 libraries the app stays on
|
||||
# native Wayland rather than starting through XWayland (see `uses_x11` in main.rs).
|
||||
# program it starts, so that is a recommendation too.
|
||||
overrides:
|
||||
deb:
|
||||
depends:
|
||||
@@ -38,7 +37,6 @@ overrides:
|
||||
- libwayland-client0
|
||||
- libx11-6
|
||||
- libx11-xcb1
|
||||
- libxkbcommon-x11-0
|
||||
- libxcursor1
|
||||
- libxi6
|
||||
- libxrandr2
|
||||
@@ -57,8 +55,6 @@ overrides:
|
||||
recommends:
|
||||
- libwayland-client
|
||||
- libX11
|
||||
- libX11-xcb
|
||||
- libxkbcommon-x11
|
||||
- libXcursor
|
||||
- libXi
|
||||
- libXrandr
|
||||
|
||||
@@ -4,14 +4,6 @@ sonar.organization=storytold
|
||||
# S4790 (weak hash): SHA-1 is a start-key and "SHA1/1K" checksum algorithm that ODF package
|
||||
# encryption (ODF 1.3 Part 2 §4.16) names, and older office suites wrote it. It is needed to open
|
||||
# those files, not chosen for new security: WordCraft writes a SHA-256 start key and checksum.
|
||||
sonar.issue.ignore.multicriteria=odt_sha1,gha-lockfile
|
||||
sonar.issue.ignore.multicriteria=odt_sha1
|
||||
sonar.issue.ignore.multicriteria.odt_sha1.ruleKey=rust:S4790
|
||||
sonar.issue.ignore.multicriteria.odt_sha1.resourceKey=crates/formats/src/odt/crypt.rs
|
||||
|
||||
# githubactions:S7637 asks for every action to be pinned to a commit SHA. A workflow covered by
|
||||
# .github/workflows/actions.lock runs the commit the lockfile records, so its tag refs are pinned
|
||||
# and the lockfile covers the actions those actions use too. Sonar does not read lockfiles yet, so
|
||||
# the rule flags the tag refs anyway:
|
||||
# https://community.sonarsource.com/t/githubactions-s7637-false-positives-for-actions-pinned-through-native-actions-lock/190814
|
||||
sonar.issue.ignore.multicriteria.gha-lockfile.ruleKey=githubactions:S7637
|
||||
sonar.issue.ignore.multicriteria.gha-lockfile.resourceKey=.github/workflows/**
|
||||
|
||||
Reference in New Issue
Block a user