Compare commits

..
Author SHA1 Message Date
Martin BallandClaude Sonnet 5.5 4ebfc3c2a7 #3518: whole-document OK sends only what was changed in the Language dialog
With "Apply to the whole document" ticked, OK sends noProof only if the user
changed it from the dialog's opening value, and lang if changed or if neither
changed (never the engine's en-US default). Heading reads "Mark the whole
document as:" while ticked. Glossary walker uses doc's PARA_WALK_DEPTH (now pub).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCVCCEo7u5wetHaJRU4
2026-11-06 16:09:02 +00:00
Martin BallandClaude Sonnet 5.5 c09da43475 #3518: Review › Language can set the whole document's proofing language
review.language takes scope "selection" (default, unchanged) or "document":
every run and paragraph mark of every story (body, headers, footers, notes,
comments, text boxes, table cells, glossary) takes lang/noProof in one undo
step, selection unchanged, pending typing format follows. The Language dialog
gets an "Apply to the whole document" check box.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCVCCEo7u5wetHaJRU4
2026-11-06 15:30:26 +00:00
108 changed files with 586 additions and 3962 deletions
-8
View File
@@ -18,14 +18,7 @@ concurrency:
cancel-in-progress: false
jobs:
# Regenerates contributors/contributors.json for the release commit; every build bundles it.
credits:
permissions:
contents: read
pull-requests: read
uses: storytold/craft-common-actions/.github/workflows/refresh-contributors.yml@main
release:
needs: credits
# Only this job publishes the GitHub release, so only it gets write access.
permissions:
contents: write
@@ -38,7 +31,6 @@ jobs:
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
web-craft-fonts: true
version: ${{ inputs.version }}
contributors-artifact: ${{ needs.credits.outputs.artifact-name }}
secrets:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
+3 -43
View File
File diff suppressed because one or more lines are too long
+5 -16
View File
@@ -98,8 +98,8 @@ fn open(path: &Path, password: Option<&str>) -> Result<Session, String> {
Ok(s)
}
/// The commands that open a file, and which positional argument it is, excluding option values.
/// `run` opens the value of `--file`.
/// The commands that open a file, and which of their arguments not starting with `--` it is (as
/// `pos` counts). `run` opens the value of `--file`.
const OPENS_FILE: &[(&str, usize)] = &[("convert", 0), ("info", 0), ("text", 0), ("inspect", 0), ("render", 0), ("zotero", 1)];
/// The arguments as text. Only the file the command opens may have a name that isn't valid Unicode,
@@ -108,14 +108,8 @@ const OPENS_FILE: &[(&str, usize)] = &[("convert", 0), ("info", 0), ("text", 0),
fn text_args<S: AsRef<OsStr>>(raw: &[S]) -> Result<(Vec<String>, Option<PathBuf>), String> {
let args: Vec<String> = raw.iter().map(|a| a.as_ref().to_string_lossy().into_owned()).collect();
let cmd = args.first().map(String::as_str).unwrap_or("");
let specs = match cmd {
"convert" => CONVERT_OPTIONS,
"render" => RENDER_OPTIONS,
"zotero" => ZOTERO_OPTIONS,
_ => PASSWORD_OPTIONS,
};
let file_at = match OPENS_FILE.iter().find(|(c, _)| *c == cmd) {
Some((_, n)) => positional_indices(args.get(1..).unwrap_or_default(), specs).get(*n).map(|i| i + 1),
Some((_, n)) => args.iter().enumerate().skip(1).filter(|(_, a)| !a.starts_with("--")).nth(*n).map(|(i, _)| i),
None if cmd == "run" => args.iter().position(|a| a == "--file").map(|i| i + 1),
None => None,
};
@@ -151,13 +145,8 @@ fn password_from(args: &[String]) -> Result<Option<String>, String> {
}
/// The positional arguments, skipping every option's value so an option value is never read as a
/// path. Uses the same option specifications as [`validate_options`].
/// path. Mirrors [`validate_options`], which has already checked the options are known.
fn positionals<'a>(args: &'a [String], specs: &[OptionSpec]) -> Vec<&'a String> {
positional_indices(args, specs).into_iter().filter_map(|i| args.get(i)).collect()
}
/// Keep the original indices too, so [`text_args`] can open a positional path by its native name.
fn positional_indices(args: &[String], specs: &[OptionSpec]) -> Vec<usize> {
let mut out = Vec::new();
let mut i = 0;
while let Some(arg) = args.get(i) {
@@ -167,7 +156,7 @@ fn positional_indices(args: &[String], specs: &[OptionSpec]) -> Vec<usize> {
i += 2;
} else {
if !arg.starts_with("--") {
out.push(i);
out.push(arg);
}
i += 1;
}
-5
View File
@@ -211,10 +211,6 @@ fn only_the_opened_file_may_be_other_than_unicode() {
let ok = |raw: Vec<OsString>| text_args(&raw).unwrap().1;
assert_eq!(ok(vec!["zotero".into(), "refresh".into(), latin1()]), Some(PathBuf::from(latin1())));
assert_eq!(ok(vec!["render".into(), latin1(), "out.png".into(), "--page".into(), "2".into()]), Some(PathBuf::from(latin1())));
assert_eq!(ok(vec!["render".into(), "--page".into(), "2".into(), latin1(), "out.png".into()]), Some(PathBuf::from(latin1())));
assert_eq!(ok(vec!["convert".into(), "--format".into(), "flatopc".into(), latin1(), "out.xml".into()]), Some(PathBuf::from(latin1())));
assert_eq!(ok(vec!["info".into(), "--password".into(), "secret".into(), latin1()]), Some(PathBuf::from(latin1())));
assert_eq!(ok(vec!["zotero".into(), "--port".into(), "23119".into(), "refresh".into(), latin1()]), Some(PathBuf::from(latin1())));
assert_eq!(ok(vec!["run".into(), "--print".into(), "--file".into(), latin1()]), Some(PathBuf::from(latin1())));
assert_eq!(ok(vec!["info".into(), "in.docx".into()]), None);
// Every command that opens a file reaches the open with it (no such file here), so
@@ -232,7 +228,6 @@ fn only_the_opened_file_may_be_other_than_unicode() {
for (raw, n) in [
(vec!["zotero".into(), latin1(), "in.docx".into()], 2),
(vec!["render".into(), "in.docx".into(), latin1()], 3),
(vec!["render".into(), "--page".into(), latin1(), "in.docx".into(), "out.png".into()], 3),
(vec!["run".into(), "--save".into(), latin1()], 3),
(vec!["commands".into(), latin1()], 2),
] {
@@ -53,15 +53,6 @@ fn the_file_a_command_opens_may_have_any_name() {
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn an_option_before_the_input_keeps_its_native_name() {
let (dir, file) = folder("option-first");
let saved = dir.join("out.xml");
ok(&cli(&["convert".into(), "--format".into(), "flatopc".into(), file, saved.clone().into()]));
assert!(std::fs::read_to_string(saved).unwrap().contains("Bonjour"));
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn any_other_such_argument_is_an_error_naming_it() {
let (dir, file) = folder("error");
-139
View File
@@ -1,139 +0,0 @@
//! An AppImage's environment inherited from another program (#5653).
//!
//! The AppImage runtime gives the program it starts `APPIMAGE` (the image's file), `APPDIR` (where
//! it is mounted), `ARGV0` and `OWD`, and whatever that program starts inherits them. So an AppImage
//! app that opens a document through `xdg-open` starts an installed WordCraft with *its* `APPIMAGE`,
//! and the self-update, which trusts that variable, replaces the other app's file with WordCraft.
//!
//! This copy is in the image only when its executable is under `APPDIR`. Otherwise the variables
//! are another program's, and the process starts over without them, before anything reads them.
//! (Removing them in place is `unsafe` under this edition, and `exec` keeps the process id, the
//! arguments and the standard streams.)
use std::ffi::OsStr;
use std::io::Write;
use std::os::unix::process::CommandExt;
use std::path::Path;
use std::process::Command;
/// What the AppImage runtime sets; the same list as the documented workaround on the issue.
const RUNTIME_VARS: [&str; 4] = ["APPIMAGE", "APPDIR", "ARGV0", "OWD"];
/// Whether the executable runs from the image mounted at `appdir`. Anything unclear counts as not:
/// the update then goes through the package or the plain file this copy really is, and at worst
/// says it can't, where the other answer would overwrite a file that isn't WordCraft's.
fn runs_from_image(exe: Option<&Path>, appdir: Option<&OsStr>) -> bool {
let (Some(exe), Some(appdir)) = (exe, appdir) else { return false };
let appdir = Path::new(appdir);
if !appdir.is_absolute() {
return false;
}
let Ok(appdir) = appdir.canonicalize() else { return false };
// No image mounts at the root, where every program on the machine would count as inside it.
appdir.parent().is_some() && exe.starts_with(&appdir)
}
/// Starts this process over without another program's AppImage variables; returns only when there
/// are none to drop (`None`) or starting over failed. Call it first, before any other thread starts.
pub(crate) fn drop_inherited() -> Option<std::io::Error> {
let appimage = std::env::var_os("APPIMAGE")?;
let exe = std::env::current_exe().ok();
if runs_from_image(exe.as_deref(), std::env::var_os("APPDIR").as_deref()) {
return None;
}
// The logger isn't up yet, and wouldn't survive `exec`. Unlike `eprintln!`, never panics.
let _ = writeln!(std::io::stderr(), "wordcraft: ignoring the AppImage {:?} inherited from another program", Path::new(&appimage));
let mut args = std::env::args_os();
// This file, whatever has happened to its path since it started.
let mut again = Command::new("/proc/self/exe");
if let Some(arg0) = args.next() {
again.arg0(arg0);
}
again.args(args);
for var in RUNTIME_VARS {
again.env_remove(var);
}
Some(again.exec())
}
#[cfg(test)]
mod tests {
use super::runs_from_image;
use std::path::{Path, PathBuf};
/// A folder of its own for one test, removed when it ends.
struct Tmp(PathBuf);
impl Tmp {
/// An empty folder named after `test` and this process, made afresh.
fn new(test: &str) -> Tmp {
let dir = std::env::temp_dir().join(format!("wordcraft-appimage-{test}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
Tmp(dir)
}
}
impl Drop for Tmp {
/// Removes the folder and what the test left in it.
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// A mount with the executable inside it, by real path (`/tmp` may be a link).
fn mount(root: &Path, name: &str) -> (PathBuf, PathBuf) {
let dir = root.join(name);
std::fs::create_dir_all(dir.join("usr/bin")).unwrap();
let dir = dir.canonicalize().unwrap();
let exe = dir.join("usr/bin/wordcraft");
(dir, exe)
}
#[test]
fn an_executable_in_the_mount_is_the_image() {
let tmp = Tmp::new("own");
let (dir, exe) = mount(&tmp.0, ".mount_WordCr");
assert!(runs_from_image(Some(&exe), Some(dir.as_os_str())));
}
#[test]
fn a_link_to_the_mount_is_the_same_mount() {
let tmp = Tmp::new("link");
let (dir, exe) = mount(&tmp.0, ".mount_WordCr");
let link = tmp.0.join("link");
std::os::unix::fs::symlink(&dir, &link).unwrap();
assert!(runs_from_image(Some(&exe), Some(link.as_os_str())));
}
#[test]
fn an_installed_executable_is_not_in_another_programs_mount() {
let tmp = Tmp::new("other");
let (other, _) = mount(&tmp.0, ".mount_other");
assert!(!runs_from_image(Some(Path::new("/usr/bin/wordcraft")), Some(other.as_os_str())));
}
/// Paths compare by component: one mount's name can start with another's.
#[test]
fn a_mount_whose_name_starts_the_same_is_another_mount() {
let tmp = Tmp::new("prefix");
let (other, _) = mount(&tmp.0, ".mount_ab");
let (_, exe) = mount(&tmp.0, ".mount_abc");
assert!(!runs_from_image(Some(&exe), Some(other.as_os_str())));
}
#[test]
fn anything_unclear_is_not_the_image() {
let tmp = Tmp::new("unclear");
let (dir, exe) = mount(&tmp.0, ".mount_WordCr");
// No `APPDIR`, one that's gone (the other program has exited) or empty.
assert!(!runs_from_image(Some(&exe), None));
assert!(!runs_from_image(Some(&exe), Some(tmp.0.join("gone").as_os_str())));
assert!(!runs_from_image(Some(&exe), Some("".as_ref())));
// The root, and a relative path that happens to lead here.
assert!(!runs_from_image(Some(&exe), Some("/".as_ref())));
assert!(!runs_from_image(Some(&exe), Some(".".as_ref())));
// No executable path at all.
assert!(!runs_from_image(None, Some(dir.as_os_str())));
}
}
+2 -3
View File
@@ -36,15 +36,14 @@ impl AppleEvents {
/// Open the documents and act on the quit requests that arrived since the last frame. A document
/// opens like File › Open: beside the open ones, in place of an untouched blank one, or, when it
/// is already open, by showing it; a Word template starts a new document based on it, as Word's
/// default verb for one does (#3433). Quitting closes the window, which asks about every document
/// is already open, by showing it. Quitting closes the window, which asks about every document
/// with unsaved changes.
pub fn poll(inbox: &Inbox, app: &mut WordApp, ctx: &egui::Context) {
for e in inbox.drain() {
match e {
Event::Open(paths) => {
for p in paths.iter().filter_map(|p| p.to_str()) {
match app.open_from_platform(p, None) {
match app.open_file(p, None) {
// Leave Backstage, as opening from the Open dialog does.
Ok(_) => app.ui.backstage = false,
Err(e) => {
+4 -25
View File
@@ -10,8 +10,6 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod animations;
#[cfg(target_os = "linux")]
mod appimage_env;
#[cfg(target_os = "macos")]
mod apple_events;
mod clipboard;
@@ -423,15 +421,14 @@ fn without_launch_args(args: Vec<String>) -> Vec<String> {
/// Open a file whose name isn't valid Unicode (#1440). `file.open` takes its path as text, so it
/// gets the file's bytes under the lossy name, and the document then keeps the real path to save to.
/// The bytes are read as `file.open` reads a path: only a regular file, and at most 2 GB. A Word
/// template starts a new document instead ([`WordApp::open_from_platform`]), which has no path.
/// The bytes are read as `file.open` reads a path: only a regular file, and at most 2 GB.
fn open_non_unicode(app: &mut WordApp, path: &std::path::Path) {
let name = path.to_string_lossy();
let opened = wordcraft_engine::io::read_document(path)
.and_then(|bytes| app.open_from_platform(&name, Some(&wordcraft_engine::cmd::insert::base64_encode(&bytes))));
.and_then(|bytes| app.run("file.open", serde_json::json!({"path": name, "data": wordcraft_engine::cmd::insert::base64_encode(&bytes)})));
match opened {
// Not when it waits for a password: that open names the document itself.
Ok(r) if r.get("pending").is_none() && !wordcraft_ui_egui::documents::is_word_template(&name) => app.session.path = Some(path.into()),
Ok(r) if r.get("pending").is_none() => app.session.path = Some(path.into()),
Ok(_) => {}
Err(e) => {
app.report_open_failure(&name, &e);
@@ -452,22 +449,9 @@ fn main() -> eframe::Result {
if let Some(result) = craft_update::self_update::finish(&wordcraft_ui_egui::UPDATE_APP, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
std::process::exit(i32::from(result.is_err()));
}
// Linux: before anything reads another program's AppImage variables, or a thread starts (#5653).
#[cfg(target_os = "linux")]
let start_over_failed = appimage_env::drop_inherited();
// Still carrying them, this process must never update itself: the update would replace the
// other program's file.
#[cfg(target_os = "linux")]
let updates_unsafe = start_over_failed.is_some();
#[cfg(not(target_os = "linux"))]
let updates_unsafe = false;
// First, so the panic hook and every start-up warning are recorded (`logging`).
let logger = logging::install();
logging::install_panic_hook();
#[cfg(target_os = "linux")]
if let Some(e) = start_over_failed {
log::error!("couldn't start again without another program's AppImage variables, so the self-update is off in this window: {e}");
}
let mut control_port: Option<u16> = std::env::var("WORDCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
let mut files = Vec::new();
let mut sample = false;
@@ -582,10 +566,6 @@ fn main() -> eframe::Result {
let mut app = WordApp::new(session, services(file_dialog, &cc.egui_ctx));
app.set_startup_update_check(true);
load_prefs(&mut app);
// After the preferences, so the reason it gives is in the interface language.
if updates_unsafe {
app.disable_self_update();
}
load_user_dictionary(&mut app);
// Language packs from the suite's font store; an Install click is the person's own.
wordcraft_engine::packs::use_font_store(true);
@@ -625,9 +605,8 @@ fn main() -> eframe::Result {
if files.is_empty() && other_files.is_empty() && !sample && control_port.is_none() {
app.start_screen();
}
// A Word template named here starts a new document based on it (#3433).
for f in files {
if let Err(e) = app.open_from_platform(&f, None) {
if let Err(e) = app.run("file.open", serde_json::json!({"path": f})) {
app.report_open_failure(&f, &e);
}
}
-99
View File
@@ -1,99 +0,0 @@
//! A WordCraft started by an AppImage app inherits that app's `APPIMAGE`, which the self-update
//! would take for this copy's file and replace (#5653). The real program, started both ways.
use std::ffi::OsString;
use std::os::unix::ffi::OsStringExt;
use std::path::{Path, PathBuf};
use std::process::{Command, Output, Stdio};
use std::time::{Duration, Instant};
const NOTICE: &str = "ignoring the AppImage";
/// A folder of its own for one test, removed when it ends.
struct Tmp(PathBuf);
impl Tmp {
/// An empty folder named after `test` and this process, made afresh.
fn new(test: &str) -> Tmp {
let dir = std::env::temp_dir().join(format!("wordcraft-appimage-it-{test}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
Tmp(dir)
}
}
impl Drop for Tmp {
/// Removes the folder and what the test left in it.
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// Runs the real app with `args`, as an AppImage app with `appimage` mounted at `appdir` would
/// start it, and waits up to 30 s for it to exit (with no display it can't open a window).
fn app(args: &[OsString], appimage: &Path, appdir: &Path) -> Output {
let mut child = Command::new(env!("CARGO_BIN_EXE_wordcraft"))
.args(args)
.env("APPIMAGE", appimage)
.env("APPDIR", appdir)
.env("ARGV0", "other-app")
.env("OWD", "/")
// With no display, an app that tries to open a window fails at once instead.
.env_remove("DISPLAY")
.env_remove("WAYLAND_DISPLAY")
.env_remove("WAYLAND_SOCKET")
.env_remove("WORDCRAFT_CONTROL_PORT")
.env("WORDCRAFT_NO_PREFS", "1")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
// A start-over that kept the variables would start over for ever, and end here.
let deadline = Instant::now() + Duration::from_secs(30);
while child.try_wait().unwrap().is_none() {
if Instant::now() > deadline {
let _ = child.kill();
panic!("wordcraft {args:?} was still running after 30 s");
}
std::thread::sleep(Duration::from_millis(50));
}
child.wait_with_output().unwrap()
}
/// The notice comes once: the program it started over as no longer sees the variables.
#[test]
fn another_programs_variables_are_dropped_once_and_the_program_carries_on() {
let tmp = Tmp::new("dropped");
let other = tmp.0.join("other-app.AppImage");
std::fs::write(&other, b"another program").unwrap();
let out = app(&["--version".into()], &other, &tmp.0);
let (stdout, stderr) = (String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr));
assert!(out.status.success(), "status {:?}, stderr: {stderr}", out.status);
assert!(stdout.starts_with("wordcraft "), "stdout: {stdout}");
assert_eq!(stderr.matches(NOTICE).count(), 1, "stderr: {stderr}");
assert!(stderr.contains("other-app.AppImage"), "stderr: {stderr}");
assert_eq!(std::fs::read(&other).unwrap(), b"another program");
}
/// Arguments reach the program it starts over as byte for byte, even one that isn't Unicode.
#[test]
fn starting_over_keeps_the_arguments() {
let tmp = Tmp::new("args");
let out = app(&["--sample".into(), "--control".into(), OsString::from_vec(b"79\xe981".to_vec())], &tmp.0.join("gone"), &tmp.0);
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(2), "stderr: {stderr}");
assert!(stderr.contains("argument 3 is not valid Unicode: 79\u{FFFD}81"), "stderr: {stderr}");
}
/// Its own image's variables stay, or the self-update couldn't find the file to replace.
#[test]
fn its_own_images_variables_stay() {
let exe = Path::new(env!("CARGO_BIN_EXE_wordcraft")).canonicalize().unwrap();
let mount = exe.parent().unwrap();
let out = app(&["--version".into()], &mount.join("WordCraft.AppImage"), mount);
let (stdout, stderr) = (String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr));
assert!(out.status.success(), "status {:?}, stderr: {stderr}", out.status);
assert!(stdout.starts_with("wordcraft "), "stdout: {stdout}");
assert!(!stderr.contains(NOTICE), "stderr: {stderr}");
}
-2
View File
@@ -1,5 +1,3 @@
#[cfg(target_os = "linux")]
mod appimage_env;
mod control_arg;
mod help;
#[cfg(unix)]
+10 -19
View File
@@ -7,9 +7,8 @@
/// makes it allocate gigabytes. Here the DIFAT is walked the way `cfb` walks it (header entries,
/// then the DIFAT chain, at most one step per sector; trailing zero and free entries trimmed
/// the same way) and the file is refused when it lists one FAT sector twice, or more FAT sectors
/// than the file has sectors — every FAT sector is a sector of the file, so a longer list is
/// duplicates or names `cfb` refuses anyway, and the FAT `cfb` builds (one sector of entries per
/// listed name) stays within about the file's own size. Anything else that is wrong is left for
/// than its sectors need (one FAT sector covers `sector_len / 4` of them, plus one spare), so the
/// FAT `cfb` builds is about the size the file needs. Anything else that is wrong is left for
/// `cfb` to report.
pub fn check(bytes: &[u8]) -> Result<(), String> {
const MAGIC: [u8; 8] = [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1];
@@ -53,14 +52,12 @@ pub fn check(bytes: &[u8]) -> Result<(), String> {
while difat.last() == Some(&FREE) {
difat.pop();
}
// Each listed FAT sector adds a whole sector of entries. A real file lists no more FAT
// sectors than the file has — every FAT sector is a sector of the file, and none of them
// twice (checked below) — so the FAT `cfb` builds stays within about the file's own size.
// The #3642 blowup listed 2048 × 127 names in a ~1 MB file. A tighter bound (one FAT sector
// per `sector_len / 4` sectors plus a spare) refused real files, whose writers reserve FAT
// for growth: an encrypted package of 78 sectors lists 4 and didn't open (#5109).
// Each listed FAT sector adds a whole sector of entries. A FAT sector holds the entries of
// `sector_len / 4` sectors, so a file needs no more FAT sectors than that covers (one spare),
// and none of them twice. A free entry in the middle is left for `cfb` to refuse.
let listed: Vec<u32> = difat.into_iter().filter(|&s| s != FREE).collect();
if listed.len() > num_sectors {
let needed = num_sectors.div_ceil(sector_len / 4).saturating_add(1);
if listed.len() > needed {
return Err(format!("the DIFAT lists {} FAT sectors in a file of {num_sectors} sectors", listed.len()));
}
let mut seen = std::collections::HashSet::with_capacity(listed.len());
@@ -126,16 +123,10 @@ mod tests {
}
#[test]
fn a_fat_sector_listed_twice_or_more_than_the_file_has_is_refused() {
// A duplicate, and a list longer than the file has sectors: every FAT sector is a
// sector of the file, so a longer list is one of the two.
fn a_fat_sector_listed_twice_or_more_than_the_file_needs_is_refused() {
// Each list is shorter than the file has sectors, which the guard used to accept.
assert!(super::check(&header_difat(10, &[0, 0])).is_err_and(|e| e.contains("FAT sector 0 twice")));
assert!(
super::check(&header_difat(10, &[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10])).is_err_and(|e| e.contains("11 FAT sectors in a file of 10 sectors"))
);
// A writer may reserve FAT for growth: a real encrypted package of 78 sectors lists
// 4 FAT sectors and didn't open since the #3642 guard landed (#5109).
assert!(super::check(&header_difat(78, &[0, 1, 2, 3])).is_ok());
assert!(super::check(&header_difat(10, &[0, 1, 2])).is_err_and(|e| e.contains("3 FAT sectors")));
assert!(super::check(&header_difat(10, &[0])).is_ok());
assert!(super::check(&header_difat(200, &[0, 1])).is_ok());
}
+2 -5
View File
@@ -478,9 +478,8 @@ fn family_fingerprint(doc: &crate::Document, root: u32, replies: &HashMap<u32, V
}
/// What `doc`'s page setup is: each section's setup (where it starts, its size, margins,
/// columns, headers and footers), the headers' and footers' content, whether odd and even pages
/// differ, the page colour and picture, the watermark (one with no text isn't written), and the
/// notes' numbering.
/// columns, headers and footers), the headers' and footers' content, the page colour and
/// picture, and the notes' numbering.
pub fn pages_fingerprint(doc: &crate::Document) -> u64 {
let mut sections: Vec<&crate::SectionProps> = doc.body.iter().filter_map(|b| b.as_para()).filter_map(|p| p.section.as_deref()).collect();
sections.push(&doc.last_section);
@@ -491,10 +490,8 @@ pub fn pages_fingerprint(doc: &crate::Document) -> u64 {
match serde_json::to_vec(&(
sections,
parts,
s.even_odd_headers,
&s.page_color,
&s.page_picture,
s.watermark.as_ref().filter(|w| !w.text.trim().is_empty()),
(&s.footnote_format, &s.endnote_format, &s.footnote_pr, &s.endnote_pr),
(&doc.footnote_separators, &doc.endnote_separators),
)) {
+2 -2
View File
@@ -90,8 +90,8 @@ pub use settings::{
COMPAT_MODE_CURRENT, CoreProps, CustomProp, DEFAULT_GRID, DEFAULT_HYPHENATION_ZONE, LEGACY_COMPAT_MODE, OdfLayout, PagePicture, Settings, Source,
THEME_COLORS, Watermark, Zoom, ZoomFit,
};
pub(crate) use text_walk::{PARA_WALK_DEPTH, walk_between};
pub use text_walk::{count_chars, count_chars_no_spaces, count_words};
pub(crate) use text_walk::walk_between;
pub use text_walk::{PARA_WALK_DEPTH, count_chars, count_chars_no_spaces, count_words};
/// A document.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
-1
View File
@@ -125,7 +125,6 @@ mod tests {
("HYPERLINK\t\"http://example.com\"", Some("http://example.com")),
(r#"HYPERLINK"http://example.com""#, Some("http://example.com")),
(r#"HYPERLINK "http://example.com/path\latest""#, Some("http://example.com/pathlatest")),
(r#"HYPERLINK "x \* Upper""#, Some("x * Upper")),
(r#"HYPERLINK "C:\\Docs\\My file.docx""#, Some(r"C:\Docs\My file.docx")),
(r#"HYPERLINK "say \"hi\"""#, Some(r#"say "hi""#)),
(r#"HYPERLINK " http://example.com ""#, Some("http://example.com")),
+9 -34
View File
@@ -228,7 +228,7 @@ impl Numbering {
/// Whether a paragraph in list `num` at `level` gets a label ([`Counters::next_label`] gives
/// one), without counting it.
pub fn shows_label(&self, num: u32, level: u8) -> bool {
self.abstract_of(num).is_some() && self.level(num, level.min(8)).is_some()
self.abstract_of(num).is_some() && self.level(num, level.min(8)).is_some_and(|d| d.format != NumFormat::None)
}
pub fn level(&self, num: u32, level: u8) -> Option<&Level> {
if let Some((_, l)) = self.num(num)?.level_overrides.iter().find(|(i, _)| *i == level) {
@@ -325,9 +325,12 @@ impl Counters {
let lv = (level as usize).min(8);
// This list's own definition of a level wins over its abstract list's.
let level_def = |k: usize| numbering.level(num, k as u8);
// A "none" level (Word's numFmt none) still counts and restarts the levels below it; only
// its number reads as nothing, so its literal text ("RESPONSE:") shows (#4985).
let def = level_def(lv)?.clone();
// A "none" level shows no label at all (Word's numFmt none) — not even its text.
if def.format == NumFormat::None {
self.labels.insert((abs.id, lv as u8), CountedLabel { label: None, levels: Vec::new() });
return None;
}
let st = self.state.entry(abs.id).or_insert([0; 9]);
let fl = self.flags.entry(abs.id).or_default();
// Only this level's override, not the num's others: the counters are shared by every
@@ -379,14 +382,7 @@ impl Counters {
let started = *fl;
let numeric = def.format != NumFormat::Bullet;
let label = if numeric { render_label(numbering, num, &def, &vals, &started) } else { def.text.clone() };
// A "none" level shows its literal text, but has no number of its own for a reference to
// show (#4985): it is kept as one that shows none.
let counted = if def.format == NumFormat::None {
CountedLabel { label: None, levels: Vec::new() }
} else {
CountedLabel { label: numeric.then(|| label.clone()), levels: template_levels(&def.text) }
};
self.labels.insert((abs.id, lv as u8), counted);
self.labels.insert((abs.id, lv as u8), CountedLabel { label: numeric.then(|| label.clone()), levels: template_levels(&def.text) });
Some((label, def))
}
/// Which levels of `num`'s list have counted a paragraph yet (a level never used has no
@@ -459,9 +455,9 @@ fn render_label(numbering: &Numbering, num: u32, def: &Level, vals: &[u32; 9], s
let counted = started.get(k).is_some_and(|f| f.started);
let val = if counted { val } else { level_def(k).map(|l| l.start).unwrap_or(1) };
// Legal numbering (w:isLgl) shows every level in Arabic digits, whatever its own
// format (二 of a Chinese outline becomes 2 in "2.1"); a "none" level stays empty.
// format (二 of a Chinese outline becomes 2 in "2.1").
let fmt = match level_def(k).map(|l| l.format).unwrap_or(NumFormat::Decimal) {
f @ (NumFormat::DecimalZero | NumFormat::None) => f,
NumFormat::DecimalZero => NumFormat::DecimalZero,
_ if def.legal => NumFormat::Decimal,
f => f,
};
@@ -560,27 +556,6 @@ mod tests {
assert_eq!(n.level(id, 2).map(|l| l.format), Some(NumFormat::DecimalZero));
}
#[test]
fn a_none_level_shows_its_text_and_still_counts() {
// As Word shows them (#4985): a "none" level's literal text is its label, its own number
// reads as nothing (also in legal numbering), and it restarts the levels below it.
let mut n = Numbering::default();
let id = n.add_list(ListKind::Numbered);
let lvl = |format, text: &str| Level { format, text: text.into(), ..Level::default() };
let legal = Level { legal: true, ..lvl(NumFormat::Decimal, "%2.%3") };
if let Some(num) = n.nums.iter_mut().find(|x| x.id == id) {
num.level_overrides = vec![(0, lvl(NumFormat::None, "RESPONSE:")), (1, lvl(NumFormat::None, "[%2]")), (2, legal)];
}
assert!(n.shows_label(id, 0));
let mut c = Counters::default();
assert_eq!(c.next_label(&n, id, 0).unwrap().0, "RESPONSE:");
assert_eq!(c.next_label(&n, id, 1).unwrap().0, "[]");
assert_eq!(c.next_label(&n, id, 2).unwrap().0, ".1");
assert_eq!(c.next_label(&n, id, 2).unwrap().0, ".2");
assert_eq!(c.next_label(&n, id, 1).unwrap().0, "[]");
assert_eq!(c.next_label(&n, id, 2).unwrap().0, ".1");
}
#[test]
fn a_start_override_restarts_only_its_own_level_of_a_shared_list() {
// One abstract list numbers the headings through two nums: one for levels 1-4, and one
+4 -21
View File
@@ -289,14 +289,13 @@ impl NumFormat {
pub fn from_ooxml(s: &str) -> NumFormat {
NumFormat::ALL.into_iter().find(|f| f.ooxml() == s).unwrap_or(NumFormat::Decimal)
}
/// Format `n` (1-based). Formats WordCraft doesn't draw yet (Hebrew, Hindi, Thai letters
/// and counting, Vietnamese, text amounts) show decimal, as do values past a sequence's end
/// (the 21st item of ①…⑳). Thai numbers are the decimal number in Thai digits (๑, ๒ … ๑๐).
/// Format `n` (1-based). Formats WordCraft doesn't draw yet (Hebrew, Hindi, Thai,
/// Vietnamese, text amounts) show decimal, as do values past a sequence's end
/// (the 21st item of ①…⑳).
pub fn format(self, n: u32) -> String {
use crate::cjk_numbers as cjk;
match self {
NumFormat::DecimalZero => format!("{n:02}"),
NumFormat::ThaiNumbers => thai_digits(n),
NumFormat::UpperRoman => roman(n).to_uppercase(),
NumFormat::LowerRoman => roman(n),
NumFormat::UpperLetter => letters(n).to_uppercase(),
@@ -350,6 +349,7 @@ impl NumFormat {
| NumFormat::HindiNumbers
| NumFormat::HindiCounting
| NumFormat::ThaiLetters
| NumFormat::ThaiNumbers
| NumFormat::ThaiCounting
| NumFormat::BahtText
| NumFormat::DollarText => n.to_string(),
@@ -357,11 +357,6 @@ impl NumFormat {
}
}
/// `n` in Thai digits, ๐ (U+0E50) to ๙ (#5757).
fn thai_digits(n: u32) -> String {
n.to_string().chars().filter_map(|c| c.to_digit(10).and_then(|d| char::from_u32(0x0E50 + d))).collect()
}
/// Chicago Manual of Style note marks: *, †, ‡, §, then doubled (**, ††, …) and so on.
fn chicago(n: u32) -> String {
const MARKS: [char; 4] = ['*', '\u{2020}', '\u{2021}', '\u{a7}'];
@@ -1004,18 +999,6 @@ mod tests {
assert_eq!(NumFormat::from_ooxml("klingon"), NumFormat::Decimal, "unknown formats are decimal");
}
/// #5757: Thai numbers are the decimal number in Thai digits, every digit, at any length.
#[test]
fn thai_numbers_use_thai_digits() {
let labels: Vec<String> = [1, 2, 3, 9, 10, 11, 20, 105, 2569].iter().map(|n| NumFormat::ThaiNumbers.format(*n)).collect();
assert_eq!(labels.join(" "), "๑ ๒ ๓ ๙ ๑๐ ๑๑ ๒๐ ๑๐๕ ๒๕๖๙");
assert_eq!(NumFormat::ThaiNumbers.format(0), "๐");
assert_eq!(NumFormat::ThaiNumbers.format(u32::MAX), "๔๒๙๔๙๖๗๒๙๕");
assert_eq!(NumFormat::from_ooxml("thaiNumbers"), NumFormat::ThaiNumbers);
// Formats not drawn yet still show decimal.
assert_eq!(NumFormat::ThaiLetters.format(3), "3");
}
#[test]
fn russian_letters_follow_word() {
// Word's own labels for a russianLower list: no ё, й, ъ or ь, then doubled letters.
+1 -1
View File
@@ -48,7 +48,7 @@ pub fn count_chars_no_spaces(s: &str) -> usize {
}
/// How deep in nested tables [`Document::para_paths`] goes: cells below this are skipped.
pub(crate) const PARA_WALK_DEPTH: usize = 16;
pub const PARA_WALK_DEPTH: usize = 16;
/// The note and text box stories an object shows, for `Document::reachable`.
pub(crate) fn notes_and_boxes(o: &InlineObject) -> Vec<(u32, &'static [PartKind])> {
+13 -52
View File
@@ -7,26 +7,9 @@ use crate::DocbinError;
/// Largest bin table (entries) we accept.
const MAX_BINS: usize = 100_000;
/// A Chpx grpprl and the FCs `[lo, hi)` for which it is the one [`Bins::chpx`] returns.
#[derive(Clone, Copy)]
pub(crate) struct ChpxRun<'w> {
pub(crate) grpprl: &'w [u8],
lo: u32,
hi: u32,
}
impl ChpxRun<'_> {
pub(crate) fn contains(&self, fc: u32) -> bool {
self.lo <= fc && fc < self.hi
}
}
/// A parsed `PlcBte*`: FC breakpoints and the FKP page each range lives in.
pub(crate) struct Bins {
fcs: Vec<u32>,
/// `fcs` ascends, so the bin of an FC holds every FC up to the next breakpoint (a corrupt
/// table that does not gets no cached ranges, [`ChpxRun`]).
sorted: bool,
/// Page number (× 512 = byte offset in the WordDocument stream).
pns: Vec<u32>,
}
@@ -37,7 +20,7 @@ impl Bins {
let fc = fc as usize;
let lcb = lcb as usize;
if lcb < 4 || !(lcb - 4).is_multiple_of(8) {
return Ok(Bins { fcs: Vec::new(), sorted: true, pns: Vec::new() });
return Ok(Bins { fcs: Vec::new(), pns: Vec::new() });
}
let end = fc.checked_add(lcb).ok_or_else(|| DocbinError::Malformed("bin table overflow".into()))?;
let plc = table.get(fc..end).ok_or_else(|| DocbinError::Malformed("bin table outside the Table stream".into()))?;
@@ -55,58 +38,36 @@ impl Bins {
// PnBte* keeps the page number in its low bits; the rest must be zero.
pns.push(at(n + 1 + i).ok_or_else(|| DocbinError::Malformed("truncated bin aPn".into()))? & 0x003F_FFFF);
}
let sorted = fcs.windows(2).all(|w| w.first() <= w.get(1));
Ok(Bins { fcs, sorted, pns })
Ok(Bins { fcs, pns })
}
/// The page containing `fc`, as a slice of the WordDocument stream, and the FC range
/// `[lo, hi)` of its bin (`hi` is `u32::MAX` for the last; an empty range when the table
/// is not ascending, so nothing is cached from it).
fn page<'w>(&self, word: &'w [u8], fc: u32) -> Option<(&'w [u8], u32, u32)> {
/// The page containing `fc`, as a slice of the WordDocument stream.
fn page<'w>(&self, word: &'w [u8], fc: u32) -> Option<&'w [u8]> {
let i = self.fcs.partition_point(|&f| f <= fc).checked_sub(1)?;
let pn = *self.pns.get(i)?;
let at = pn.checked_mul(512)? as usize;
let page = word.get(at..at.checked_add(512)?)?;
if !self.sorted {
return Some((page, 1, 0));
}
let lo = self.fcs.get(i).copied().unwrap_or(0);
let hi = self.fcs.get(i + 1).copied().unwrap_or(u32::MAX);
Some((page, lo, hi))
word.get(at..at.checked_add(512)?)
}
/// The grpprl of the Chpx covering `fc` ([MS-DOC] ChpxFkp: rgfc u32s, rgb offsets ×2,
/// crun in the last byte).
pub(crate) fn chpx<'w>(&self, word: &'w [u8], fc: u32) -> &'w [u8] {
self.chpx_run(word, fc).grpprl
}
/// [`Bins::chpx`] with the FC range over which the same grpprl is the answer, so a walk
/// over consecutive characters looks it up once per run, not once per character.
pub(crate) fn chpx_run<'w>(&self, word: &'w [u8], fc: u32) -> ChpxRun<'w> {
let none = ChpxRun { grpprl: &[], lo: 1, hi: 0 };
let Some((page, page_lo, page_hi)) = self.page(word, fc) else { return none };
let Some(&crun) = page.get(511) else { return none };
let Some(page) = self.page(word, fc) else { return &[] };
let Some(&crun) = page.get(511) else { return &[] };
let crun = crun.min(0x65) as usize;
let rgfc = |i: usize| -> Option<u32> { page.get(i * 4..i * 4 + 4).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]])) };
// rgb array sits right after rgfc (crun+1 u32s); each byte is a half-offset into the
// region between rgb's end and crun.
let rgb_at = (crun + 1) * 4;
// The last run starting at or before `fc`. It stays the answer for every FC from its own
// start up to the smallest start after it (also in a page whose starts do not ascend).
let j = (0..crun).rev().find(|&j| rgfc(j).is_some_and(|f| f <= fc));
let first_after = j.map_or(0, |j| j + 1);
let hi = (first_after..crun).filter_map(rgfc).min().map_or(page_hi, |m| m.min(page_hi));
let lo = j.and_then(rgfc).map_or(page_lo, |f| f.max(page_lo));
let run = |grpprl: &'w [u8]| ChpxRun { grpprl, lo, hi };
let Some(j) = j else { return run(&[]) };
let Some(&off) = page.get(rgb_at + j) else { return run(&[]) };
let Some(j) = j else { return &[] };
let Some(&off) = page.get(rgb_at + j) else { return &[] };
if off == 0 {
return run(&[]);
return &[];
}
let at = off as usize * 2;
let Some(&cb) = page.get(at) else { return run(&[]) };
run(page.get(at + 1..at + 1 + cb as usize).unwrap_or(&[]))
let Some(&cb) = page.get(at) else { return &[] };
page.get(at + 1..at + 1 + cb as usize).unwrap_or(&[])
}
/// The (istd, grpprl) of the Papx of the paragraph whose mark is just before `fc`
@@ -114,7 +75,7 @@ impl Bins {
/// entries (1 offset byte + 12 PHE bytes), crun in the last byte; the PAPX itself is a
/// PapxInFkp with the cb/cb' size quirk). Query with the FC of the character after the mark.
pub(crate) fn papx<'w>(&self, word: &'w [u8], fc: u32) -> (u16, &'w [u8]) {
let Some((page, _, _)) = self.page(word, fc) else { return (0, &[]) };
let Some(page) = self.page(word, fc) else { return (0, &[]) };
let Some(&crun) = page.get(511) else { return (0, &[]) };
// BX is 13 bytes; cap crun at what fits before byte 511.
let max_run = (511usize.saturating_sub((crun as usize + 1) * 4)) / 13;
+44 -83
View File
@@ -502,37 +502,29 @@ struct ParaBuild {
runs: Vec<Run>,
props: wordcraft_doc::props::ParaProps,
mark: CharProps,
/// Which resolved formatting ([`FmtState::generation`]) the last character came from, while
/// the last run is still the one it extended: the same generation means equal properties.
run_gen: Option<u64>,
/// The current run's properties, so runs only break when formatting changes.
cur: Option<CharProps>,
/// Inline objects, each anchored at a U+FFFC pushed into `text`.
objects: Vec<InlineObject>,
}
impl ParaBuild {
fn push(&mut self, c: char, props: CharProps) {
self.run_gen = None;
let len = c.len_utf8();
match self.runs.last_mut() {
Some(r) if r.props == props => r.len += len,
_ => self.runs.push(Run { len, props }),
match &self.cur {
Some(p) if *p == props => {
if let Some(r) = self.runs.last_mut() {
r.len += len;
}
}
_ => {
self.runs.push(Run { len, props: props.clone() });
self.cur = Some(props);
}
}
self.text.push(c);
}
/// [`ParaBuild::push`] for properties that are resolved once and shared by many characters:
/// they are cloned only when a run starts, and not even compared while `generation` stays
/// the same.
fn push_shared(&mut self, c: char, props: &CharProps, generation: u64) {
let len = c.len_utf8();
match self.runs.last_mut() {
Some(r) if self.run_gen == Some(generation) || r.props == *props => r.len += len,
_ => self.runs.push(Run { len, props: props.clone() }),
}
self.run_gen = Some(generation);
self.text.push(c);
}
/// Append `c` to the current run, whose properties the caller knows it matches.
fn extend(&mut self, c: char) {
if let Some(r) = self.runs.last_mut() {
@@ -560,7 +552,7 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic
// Field assembly state: instruction characters are swallowed, the result is kept until
// the end character, then emitted as one object (hyperlinks keep their result text).
let mut field: Option<FieldBuild> = None;
let mut fmt = FmtState::default();
let mut memo: Option<FmtMemo<'a>> = None;
while cp < cp_end {
// Bookmark boundaries at this CP (main-document walks only; subdocument walks get
// an empty table and `cp` never matches the main-document CPs).
@@ -723,16 +715,16 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic
pb.objects.push(InlineObject::NoteRef { kind: *kind, id: *id, custom: String::new() });
}
}
'\t' => push_formatted(&mut pb, cp, '\t', ctx, field.as_mut(), &mut fmt),
'\u{B}' => push_formatted(&mut pb, cp, '\n', ctx, field.as_mut(), &mut fmt),
'\u{C}' => push_formatted(&mut pb, cp, '\u{C}', ctx, field.as_mut(), &mut fmt),
'\u{E}' => push_formatted(&mut pb, cp, '\u{E}', ctx, field.as_mut(), &mut fmt),
'\u{1E}' => push_formatted(&mut pb, cp, '\u{2011}', ctx, field.as_mut(), &mut fmt),
'\u{1F}' => push_formatted(&mut pb, cp, '\u{AD}', ctx, field.as_mut(), &mut fmt),
'\t' => push_formatted(&mut pb, cp, '\t', ctx, field.as_mut(), &mut memo),
'\u{B}' => push_formatted(&mut pb, cp, '\n', ctx, field.as_mut(), &mut memo),
'\u{C}' => push_formatted(&mut pb, cp, '\u{C}', ctx, field.as_mut(), &mut memo),
'\u{E}' => push_formatted(&mut pb, cp, '\u{E}', ctx, field.as_mut(), &mut memo),
'\u{1E}' => push_formatted(&mut pb, cp, '\u{2011}', ctx, field.as_mut(), &mut memo),
'\u{1F}' => push_formatted(&mut pb, cp, '\u{AD}', ctx, field.as_mut(), &mut memo),
// Object anchors, comment references and other special characters are dropped
// (or handled by later passes).
c if (c as u32) < 0x20 => {}
c => push_formatted(&mut pb, cp, c, ctx, field.as_mut(), &mut fmt),
c => push_formatted(&mut pb, cp, c, ctx, field.as_mut(), &mut memo),
}
cp = cp.saturating_add(units);
}
@@ -862,53 +854,34 @@ fn num_of(ilfo: i32) -> Option<u32> {
/// plus the piece's Prm, over the paragraph style's base character props. Inside a field,
/// the character is recorded into the instruction or the cached result instead of (or as
/// well as) the visible text.
fn push_formatted<'a>(pb: &mut ParaBuild, cp: u32, c: char, ctx: &WalkCtx<'a>, field: Option<&mut FieldBuild>, st: &mut FmtState<'a>) {
// The piece and the CHPX run of the previous character usually cover this one too.
let span = match st.span.filter(|s| s.contains(cp)) {
Some(s) => Some(s),
None => {
st.span = ctx.pieces.span(cp);
st.span
}
};
let fc = span.and_then(|s| s.fc_at(cp)).unwrap_or(0);
let run = match st.chpx.filter(|r| r.contains(fc)) {
Some(r) => r,
None => {
let r = ctx.chpx_bins.chpx_run(ctx.word, fc);
st.chpx = Some(r);
r
}
};
let grpprl = run.grpprl;
let prm = span.map_or(0, |s| s.prm());
fn push_formatted<'a>(pb: &mut ParaBuild, cp: u32, c: char, ctx: &WalkCtx<'a>, field: Option<&mut FieldBuild>, memo: &mut Option<FmtMemo<'a>>) {
let fc = ctx.pieces.fc_of_cp(cp).unwrap_or(0);
let grpprl = ctx.chpx_bins.chpx(ctx.word, fc);
let prm = ctx.pieces.prm_of(cp);
let style = pb.props.style.as_deref();
let hit = st.memo.as_ref().is_some_and(|m| std::ptr::eq(m.chpx, grpprl) && m.prm == prm && m.style.as_deref() == style);
if !hit {
let default;
let base = match style.and_then(|id| ctx.sheet.get(id)) {
Some(sty) => &sty.chr,
None => {
default = CharProps::default();
&default
}
};
let mut props = char_props(grpprl, ctx.fonts, base);
apply_prm(&mut props, ctx.pieces.piece_prm(cp), ctx.fonts, base, ctx.work);
st.memo = Some(FmtMemo { chpx: grpprl, prm, style: style.map(str::to_owned), props });
st.generation += 1;
}
let Some(m) = st.memo.as_ref() else { return };
let mut props = match memo.as_ref().filter(|m| std::ptr::eq(m.chpx, grpprl) && m.prm == prm && m.style.as_deref() == style) {
Some(m) => m.props.clone(),
None => {
let default;
let base = match style.and_then(|id| ctx.sheet.get(id)) {
Some(st) => &st.chr,
None => {
default = CharProps::default();
&default
}
};
let mut props = char_props(grpprl, ctx.fonts, base);
apply_prm(&mut props, ctx.pieces.piece_prm(cp), ctx.fonts, base, ctx.work);
*memo = Some(FmtMemo { chpx: grpprl, prm, style: style.map(str::to_owned), props: props.clone() });
props
}
};
// A symbol character (Insert › Symbol) is stored as the placeholder U+0028 with
// sprmCSymbol carrying the font and the character code ([MS-DOC] §2.6.1).
let symbol = if c == '(' { symbol_operand(grpprl) } else { None };
if field.is_none() && symbol.is_none() {
pb.push_shared(c, &m.props, st.generation);
return;
}
let mut props = m.props.clone();
let mut c = c;
if let Some((ftc, xchar)) = symbol {
if c == '('
&& let Some((ftc, xchar)) = symbol_operand(grpprl)
{
if let Some(font) = ctx.fonts.get(usize::from(ftc)) {
props.font = Some(font.clone());
}
@@ -927,18 +900,6 @@ fn push_formatted<'a>(pb: &mut ParaBuild, cp: u32, c: char, ctx: &WalkCtx<'a>, f
}
}
/// What the walk carries from one character to the next while it resolves formatting: the piece
/// and the CHPX run the last character was in (so most characters need no search), and the last
/// formatting resolved.
#[derive(Default)]
struct FmtState<'a> {
span: Option<piece::PieceSpan<'a>>,
chpx: Option<fkp::ChpxRun<'a>>,
memo: Option<FmtMemo<'a>>,
/// Counts the memo's replacements, so equal values mean the same properties.
generation: u64,
}
/// The last character formatting resolved, reused while the CHPX, the piece's Prm and the
/// paragraph style stay the same, so a Prm grpprl (up to 64 KB) is interpreted once per
/// formatting change, not once per character (#3692).
+6 -36
View File
@@ -117,6 +117,12 @@ impl PieceTable {
}
}
/// The raw Prm of the piece holding `cp` (0, no properties, when there is none): two CPs
/// with the same value get the same [`PrmRef`].
pub(crate) fn prm_of(&self, cp: u32) -> u16 {
self.piece_of(cp).map_or(0, |p| p.prm)
}
fn piece_of(&self, cp: u32) -> Option<&Pcd> {
let i = self.cps.partition_point(|&c| c <= cp).checked_sub(1)?;
if i >= self.pieces.len() {
@@ -137,17 +143,6 @@ impl PieceTable {
if p.compressed { p.fc.checked_add(step)?.checked_div(2) } else { p.fc.checked_add(step) }
}
/// The piece holding `cp`, with the CP range it covers: for every CP in that range the
/// answers of [`PieceTable::fc_of_cp`] and the piece's Prm come from this span alone,
/// so a sequential walk searches the piece table once per piece.
pub(crate) fn span(&self, cp: u32) -> Option<PieceSpan<'_>> {
let i = self.cps.partition_point(|&c| c <= cp).checked_sub(1)?;
let pcd = self.pieces.get(i)?;
let cp_lo = *self.cps.get(i)?;
let cp_hi = *self.cps.get(i + 1)?;
Some(PieceSpan { cp_lo, cp_hi, pcd })
}
/// The CP just past the last piece: no text exists at or beyond it.
pub(crate) fn cp_end(&self) -> u32 {
self.cps.last().copied().unwrap_or(0)
@@ -192,31 +187,6 @@ impl PieceTable {
}
}
/// One piece and the CPs `[cp_lo, cp_hi)` it covers ([`PieceTable::span`]).
#[derive(Clone, Copy)]
pub(crate) struct PieceSpan<'a> {
cp_lo: u32,
cp_hi: u32,
pcd: &'a Pcd,
}
impl PieceSpan<'_> {
pub(crate) fn contains(&self, cp: u32) -> bool {
self.cp_lo <= cp && cp < self.cp_hi
}
/// [`PieceTable::fc_of_cp`] for a CP inside the span.
pub(crate) fn fc_at(&self, cp: u32) -> Option<u32> {
let step = cp.checked_sub(self.cp_lo)?.checked_mul(2)?;
if self.pcd.compressed { self.pcd.fc.checked_add(step)?.checked_div(2) } else { self.pcd.fc.checked_add(step) }
}
/// The raw Prm of the piece: two CPs with the same value get the same [`PrmRef`].
pub(crate) fn prm(&self) -> u16 {
self.pcd.prm
}
}
/// What a piece's `Prm` refers to.
pub(crate) enum PrmRef<'a> {
None,
-150
View File
@@ -2880,153 +2880,3 @@ fn field_results_keep_their_text_and_runs() {
p.objects
);
}
/// A one-page CHPX bin table over `page`: FCs 0..`end` live in page 1 of the returned stream.
fn one_page_bins(page: &[u8], end: u32) -> (Vec<u8>, crate::fkp::Bins) {
let mut word = vec![0u8; 512];
word.extend_from_slice(page);
let plc = [0u32.to_le_bytes().as_slice(), end.to_le_bytes().as_slice(), 1u32.to_le_bytes().as_slice()].concat();
let bins = crate::fkp::Bins::parse(&plc, 0, 12).expect("bins");
(word, bins)
}
/// The CHPX run a lookup reports covers exactly the FCs that give the same grpprl: the walk
/// reuses it for the following characters without asking again.
#[test]
fn a_chpx_run_covers_the_fcs_with_the_same_answer() {
let runs = [(0u32, grpprl(&[(0x0835, &[1])])), (5, Vec::new()), (9, grpprl(&[(0x0836, &[1])])), (30, grpprl(&[(0x0835, &[1])]))];
let (word, bins) = one_page_bins(&chpx_fkp(&runs, 60), 0x2000);
for fc in (fc_of(0) - 4)..(fc_of(70)) {
let run = bins.chpx_run(&word, fc);
assert_eq!(run.grpprl, bins.chpx(&word, fc), "fc {fc}");
for other in (fc_of(0) - 4)..(fc_of(70)) {
if run.contains(other) {
assert_eq!(bins.chpx(&word, other), run.grpprl, "fc {fc} run reused at {other}");
}
}
}
assert!(bins.chpx_run(&word, fc_of(6)).contains(fc_of(8)) && !bins.chpx_run(&word, fc_of(6)).contains(fc_of(9)));
}
/// A page whose run starts do not ascend (a corrupt file) gives the same answers as before and
/// a range that never claims an FC with a different answer.
#[test]
fn a_chpx_run_is_sound_on_a_page_whose_starts_do_not_ascend() {
let runs = [(10u32, grpprl(&[(0x0835, &[1])])), (3, grpprl(&[(0x0836, &[1])])), (20, Vec::new()), (7, grpprl(&[(0x0835, &[1])]))];
let (word, bins) = one_page_bins(&chpx_fkp(&runs, 60), 0x2000);
for fc in (fc_of(0) - 4)..(fc_of(70)) {
let run = bins.chpx_run(&word, fc);
assert_eq!(run.grpprl, bins.chpx(&word, fc));
for other in (fc_of(0) - 4)..(fc_of(70)) {
if run.contains(other) {
assert_eq!(bins.chpx(&word, other), run.grpprl, "fc {fc} run reused at {other}");
}
}
}
}
/// A piece span answers exactly what the per-CP lookup answers, in every piece (compressed
/// and UTF-16) and past the last.
#[test]
fn a_piece_span_matches_the_per_cp_lookups() {
let doc = text_doc(
&[
Piece { compressed: true, text: "abc".into() },
Piece { compressed: false, text: "d\u{e9}fgh".into() },
Piece { compressed: true, text: "ij".into() },
],
&[],
0,
);
let mut comp = crate::open_container(&doc).expect("container");
let table = crate::stream(&mut comp, "0Table").expect("table");
let pieces = crate::piece::PieceTable::parse(&table, CLX_AT, table.len() as u32 - CLX_AT).expect("pieces");
for cp in 0..14 {
let span = pieces.span(cp);
assert_eq!(span.and_then(|s| s.fc_at(cp)), pieces.fc_of_cp(cp), "cp {cp}");
for other in 0..14 {
if span.is_some_and(|s| s.contains(other)) {
assert_eq!(pieces.span(other).and_then(|s| s.fc_at(other)), pieces.fc_of_cp(other));
}
}
}
assert!(pieces.span(10).is_none(), "past the last piece");
}
/// Characters of one formatting share one `CharProps`: it is cloned when a run starts, and not
/// even compared while the formatting stays the same.
#[test]
fn shared_props_start_one_run_per_formatting() {
use wordcraft_doc::props::CharProps;
let bold = CharProps { bold: Some(true), ..Default::default() };
let mut pb = super::ParaBuild::default();
for _ in 0..100_000 {
pb.push_shared('a', &bold, 1);
}
pb.push_shared('b', &CharProps::default(), 2);
pb.push_shared('c', &bold, 3);
pb.push_shared('d', &bold, 4);
assert_eq!(pb.runs.iter().map(|r| r.len).collect::<Vec<_>>(), [100_000, 1, 2], "equal props under a new generation join the run");
pb.push('e', bold.clone());
pb.push_shared('f', &bold, 4);
assert_eq!(
pb.runs.iter().map(|r| r.len).collect::<Vec<_>>(),
[100_000, 1, 4],
"a plain push forgets the generation but still joins an equal run"
);
assert_eq!(pb.text.len(), 100_005);
}
/// Several bins, equal and descending breakpoints, a missing page and the last FC: wherever a
/// run is reported as valid it gives the answer a fresh lookup gives.
#[test]
fn chpx_runs_are_sound_across_bins() {
let page_a = chpx_fkp(&[(0, grpprl(&[(0x0835, &[1])])), (8, grpprl(&[(0x0836, &[1])]))], 20);
let page_b = chpx_fkp(&[(20, Vec::new()), (26, grpprl(&[(0x0835, &[1])]))], 40);
let mut word = vec![0u8; 512];
word.extend_from_slice(&page_a);
word.extend_from_slice(&page_b);
let (a, b, end) = (fc_of(20), fc_of(40), fc_of(60));
let plc = |fcs: &[u32], pns: &[u32]| {
[fcs.iter().flat_map(|f| f.to_le_bytes()).collect::<Vec<u8>>(), pns.iter().flat_map(|p| p.to_le_bytes()).collect()].concat()
};
let tables = [
("two bins", plc(&[0, a, end], &[1, 2])),
("an empty bin", plc(&[0, a, a, end], &[1, 2, 2])),
("a missing page", plc(&[0, a, end], &[1, 9])),
("descending breakpoints", plc(&[0, b, a, end], &[1, 2, 1])),
("fcs up to the maximum", plc(&[0, a, u32::MAX], &[1, 2])),
];
for (what, t) in &tables {
let bins = crate::fkp::Bins::parse(t, 0, t.len() as u32).expect("bins");
let probes: Vec<u32> = (0..fc_of(50)).step_by(2).chain([u32::MAX - 1, u32::MAX]).collect();
for &fc in &probes {
let run = bins.chpx_run(&word, fc);
assert_eq!(run.grpprl, bins.chpx(&word, fc), "{what}: fc {fc}");
for &other in &probes {
if run.contains(other) {
assert_eq!(bins.chpx(&word, other), run.grpprl, "{what}: fc {fc} run reused at {other}");
}
}
}
}
}
/// The same formatting changes through a whole read: runs break exactly where the CHPX changes
/// (with a field and a symbol character in between), and nowhere else.
#[test]
fn formatting_runs_break_where_the_chpx_changes() {
let spec = RichSpec {
text: "aaaabbbb\u{13}PAGE\u{14}12\u{15}ccdd(ee\r",
styles: vec![(1, 0x0FFF, "Normal", Vec::new(), Vec::new())],
fonts: vec!["Times New Roman", "Arial"],
chpx_runs: vec![(0, grpprl(&[(0x0835, &[1])])), (4, Vec::new()), (8, grpprl(&[(0x0835, &[1])])), (20, grpprl(&[(0x0836, &[1])]))],
papx_entries: vec![(30, 0, Vec::new())],
};
let doc = read(&rich_doc(&spec)).expect("opens");
let p = para0(&doc);
let shape: Vec<(usize, Option<bool>, Option<bool>)> = p.runs.iter().map(|r| (r.len, r.props.bold, r.props.italic)).collect();
// "aaaa" bold, "bbbb" plain plus the field's anchor (3 bytes, default formatting), "ccd" bold
// (the run from the field to CP 20), "d(ee" italic.
assert_eq!(shape, [(4, Some(true), None), (7, None, None), (3, Some(true), None), (4, None, Some(true))], "{shape:?}");
}
-25
View File
@@ -2462,31 +2462,6 @@ fn east_asian_number_formats_read_label_and_round_trip() {
assert_eq!(formats(&back), want);
}
/// #4985: a level with numFmt none and literal text (`RESPONSE:`) labels its paragraph with that
/// text, as in Word, and keeps both through a save.
#[test]
fn a_none_level_s_literal_text_is_its_label() {
use wordcraft_doc::numbering::Counters;
use wordcraft_doc::section::NumFormat;
let numbering = format!(
r#"<w:numbering {W_NS}><w:abstractNum w:abstractNumId="0"><w:lvl w:ilvl="0"><w:start w:val="1"/><w:numFmt w:val="none"/><w:lvlText w:val="RESPONSE:"/><w:pPr><w:ind w:left="1440" w:hanging="1440"/></w:pPr></w:lvl></w:abstractNum><w:num w:numId="1"><w:abstractNumId w:val="0"/></w:num></w:numbering>"#
);
let body = r#"<w:p><w:pPr><w:numPr><w:ilvl w:val="0"/><w:numId w:val="1"/></w:numPr></w:pPr><w:r><w:t>X</w:t></w:r></w:p>"#;
let bytes = docx(body, &[("rId1", "numbering", "numbering.xml")], &[("word/numbering.xml", &numbering)]);
let label = |d: &Document| {
let n = paras(d).first().and_then(|p| p.props.numbering)?;
Counters::default().next_label(&d.numbering, n.num, n.level).map(|l| l.0)
};
let d = wordcraft_docx::read(&bytes).unwrap();
assert_eq!(label(&d).as_deref(), Some("RESPONSE:"));
let back = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
assert_eq!(
back.numbering.level(1, 0).map(|l| (l.format, l.text.as_str(), l.indent, l.hanging)),
Some((NumFormat::None, "RESPONSE:", 72.0, 72.0))
);
assert_eq!(label(&back).as_deref(), Some("RESPONSE:"));
}
/// #464: a bookmark whose end follows the document's final table keeps its end (it goes into
/// the table's last cell) and is saved as a matched pair.
#[test]
-1
View File
@@ -228,7 +228,6 @@ fn hyperlink_field_targets_follow_the_field_code() {
for (para, link) in [
(complex(r#"HYPERLINK "https://example.org/a" \l "sec""#), Some("https://example.org/a#sec")),
(complex(r#"hyperlink \o "A tip" \t "_blank" "https://example.org/""#), Some("https://example.org/")),
(complex(r#"HYPERLINK "x \* Upper""#), Some("x * Upper")),
(complex(r#"HYPERLINK "C:\\Docs\\My file.docx""#), Some(r"C:\Docs\My file.docx")),
(simple(r#"HYPERLINK \l &quot;Sec&quot; \m"#), Some("#Sec")),
(simple("HYPERLINK https://example.org/b"), Some("https://example.org/b")),
-6
View File
@@ -323,7 +323,6 @@ fn select_all(s: &mut Session, v: &Value) -> CmdResult {
}
let st = s.sel.focus.story;
s.sel = Selection { anchor: s.doc.start_of(st), focus: s.doc.end_of(st) };
s.pending = None;
s.column = None;
s.column_mode = false;
sel_result(s)
@@ -333,7 +332,6 @@ fn select_range(s: &mut Session, v: &Value) -> CmdResult {
let a = v.get("anchor").and_then(parse_pos).ok_or_else(|| CmdError::Params("bad `anchor`".into()))?;
let b = v.get("focus").and_then(parse_pos).ok_or_else(|| CmdError::Params("bad `focus`".into()))?;
s.sel = Selection { anchor: s.doc.clamp(&a), focus: s.doc.clamp(&b) };
s.pending = None;
sel_result(s)
}
@@ -394,7 +392,6 @@ fn select_unit(s: &mut Session, unit: Unit) -> CmdResult {
if s.doc.para_at(&f).is_some() {
let (a, b) = unit_range(s, &f, unit);
s.sel = Selection { anchor: a, focus: b };
s.pending = None;
}
sel_result(s)
}
@@ -489,9 +486,6 @@ fn select_text(s: &mut Session, v: &Value) -> CmdResult {
if count == n {
s.sel =
Selection { anchor: Pos { story, path: path.clone(), off: i }, focus: Pos { story, path: path.clone(), off: i + needle.len() } };
// Formatting picked for the old caret doesn't carry over (as `select.word`): what
// replaces the selection takes its formatting.
s.pending = None;
return sel_result(s);
}
}
+21 -49
View File
@@ -253,8 +253,27 @@ fn open(s: &mut Session, v: &Value) -> CmdResult {
);
}
let password = p::str(v, "password");
// An empty file opens as a new blank document that saves back to it (#1384).
let (doc, encrypted) = read_document(s, path, v)?;
let (mut doc, encrypted) = if let Some(data) = p::str(v, "data") {
let bytes = super::insert::base64_decode(data).ok_or_else(|| CmdError::Params("bad base64".into()))?;
// An empty file (File Explorer's New › Microsoft Word Document makes one) opens as a new
// blank document that saves back to it (#1384).
if bytes.is_empty() {
(normal_document(s), false)
} else {
crate::io::open_bytes_in(path, &bytes, password, &s.region()).map_err(CmdError::Failed)?
}
} else if crate::io::is_empty_file(std::path::Path::new(path)) {
(normal_document(s), false)
} else {
crate::io::open_path_in(std::path::Path::new(path), password, &s.region()).map_err(CmdError::Failed)?
};
// Plain text and Markdown carry no language: they are in the editing language.
let ext = std::path::Path::new(path).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("").to_ascii_lowercase();
if let Some(lang) = s.prefs.default_language.as_ref().or(s.editing_lang.as_ref())
&& matches!(ext.as_str(), "txt" | "text" | "md" | "markdown")
{
Arc::make_mut(&mut doc.styles).default_chr.lang = Some(lang.clone());
}
match s.used_because() {
None => s.set_document(doc),
Some(why) => {
@@ -275,53 +294,6 @@ fn open(s: &mut Session, v: &Value) -> CmdResult {
)
}
/// The document the file at `path` holds (or its bytes, base64 in `data`, with `path` only
/// naming it), read with `password`, and whether it was encrypted: what `file.open` and
/// `file.newFromTemplate` read. An empty file (File Explorer's New › Microsoft Word Document makes
/// one) is a new blank document (#1384). Plain text and Markdown carry no language, so they are
/// in the editing language.
fn read_document(s: &Session, path: &str, v: &Value) -> Result<(Document, bool), CmdError> {
let password = p::str(v, "password");
let (mut doc, encrypted) = if let Some(data) = p::str(v, "data") {
let bytes = super::insert::base64_decode(data).ok_or_else(|| CmdError::Params("bad base64".into()))?;
if bytes.is_empty() {
(normal_document(s), false)
} else {
crate::io::open_bytes_in(path, &bytes, password, &s.region()).map_err(CmdError::Failed)?
}
} else if crate::io::is_empty_file(std::path::Path::new(path)) {
(normal_document(s), false)
} else {
crate::io::open_path_in(std::path::Path::new(path), password, &s.region()).map_err(CmdError::Failed)?
};
let ext = std::path::Path::new(path).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("").to_ascii_lowercase();
if let Some(lang) = s.prefs.default_language.as_ref().or(s.editing_lang.as_ref())
&& matches!(ext.as_str(), "txt" | "text" | "md" | "markdown")
{
Arc::make_mut(&mut doc.styles).default_chr.lang = Some(lang.clone());
}
Ok((doc, encrypted))
}
/// File › New from a template (#2852): a new, untitled document with the template's content,
/// attached to the template as in Word, read as `file.open` reads a file (`data`, `password`).
/// It opens beside the open documents, as `file.new` does. With `replaceBlank` an untouched blank
/// document gives way to it, as to a file `file.open` opens: that is how a template the platform
/// hands the app starts (#3433). The template is only read; the new document has no file, so
/// saving it asks where to write, and a protected template's password doesn't come along.
pub(crate) fn new_from_template(s: &mut Session, v: &Value) -> CmdResult {
let path = p::req_str(v, "path")?;
let (mut doc, _) = read_document(s, path, v)?;
doc.settings.attached_template = Some(path.to_string());
if p::bool(v, "replaceBlank") == Some(true) && s.used_because().is_none() {
s.set_document(doc);
} else {
s.add_document(doc);
}
s.dirty = true;
sel_result(s)
}
/// Close a document with no window to ask about it (agents, headless sessions).
fn close_document(s: &mut Session, v: &Value) -> CmdResult {
let id = match v.get("id").filter(|x| !x.is_null()) {
-38
View File
@@ -63,17 +63,6 @@ pub(super) fn link(s: &mut Session, v: &Value) -> CmdResult {
let end = s.doc.insert_text(&at, &text, &props)?;
s.sel = Selection::caret(end);
s.pending = Some(CharProps::default());
} else if let Some(text) = retyped_text(s, v) {
// Text to display changed: the selection is retyped with it, then linked (as in Word).
crate::cmd::paste_text(s, &text)?;
let end = s.sel.focus.clone();
let start = Pos { off: end.off.saturating_sub(text.len()), ..end.clone() };
s.doc.format_range(&start, &end, &|c| {
c.link = Some(url.clone());
c.style = style.clone();
})?;
s.sel = Selection::caret(end);
s.pending = Some(CharProps::default());
} else {
let (a, b) = s.sel.ordered();
s.doc.format_range(&a, &b, &|c| {
@@ -84,33 +73,6 @@ pub(super) fn link(s: &mut Session, v: &Value) -> CmdResult {
sel_result(s)
}
/// The `text` to retype a selection with before linking it: only when it differs from the selected
/// text and both fit in one line of one paragraph without pictures or fields, outside any field's
/// result. Otherwise (as Word shows `<<Selection in Document>>`) the selection keeps its text.
fn retyped_text(s: &Session, v: &Value) -> Option<String> {
use wordcraft_doc::para::{COLUMN_BREAK, LINE_BREAK, OBJ, PAGE_BREAK};
/// What can't stand in one line of text: breaks, and pictures or fields.
const NOT_ONE_LINE: [char; 7] = [LINE_BREAK, '\r', '\u{2029}', '\u{b}', PAGE_BREAK, COLUMN_BREAK, OBJ];
let text = p::str(v, "text").filter(|t| !t.is_empty() && t.len() <= 4096)?;
if text.contains(NOT_ONE_LINE) {
return None;
}
let (a, b) = s.sel.ordered();
if a.story != b.story || a.path != b.path {
return None;
}
let shown = s.doc.para(a.story, &a.path)?.text.get(a.off..b.off)?;
if shown.contains(NOT_ONE_LINE) || shown == text {
return None;
}
// A selection in a field's result (between its markers, which it doesn't hold) is retyped by
// the next update of the field, not here.
if s.doc.field_ranges(a.story).iter().any(|r| r.start < b && a <= r.end) {
return None;
}
Some(text.to_string())
}
/// Whether `name` can name a bookmark: starts with a letter or underscore, contains only alphanumeric characters
/// or underscores, and is at most 40 characters (#3491). Word allows leading underscores for hidden bookmarks (_Toc, _Ref).
pub fn bookmark_name_ok(name: &str) -> bool {
+1 -32
View File
@@ -452,36 +452,6 @@ mod tests {
assert_eq!(numbered(&mut b), want);
}
/// #5757: a list numbered in Thai digits shows them on the page, with the level's text around
/// them (`๑.`, `(๒)`), and keeps its format through a save to .docx and back.
#[test]
fn a_thai_numbered_list_shows_thai_digits_and_survives_docx() {
let mut s = Session::new(Document::new());
for (i, t) in ["หนึ่ง", "สอง", "สาม", "a", "b", "c", "d", "e", "f", "g", "h"].iter().enumerate() {
if i > 0 {
s.run("text.newParagraph", &json!({})).unwrap();
}
s.run("text.insert", &json!({"text": t})).unwrap();
}
s.run("select.all", &json!({})).unwrap();
let levels = json!([{"format": "thaiNumbers", "text": "%1.", "start": 1}]);
s.run("list.define", &json!({"levels": levels})).unwrap();
let page: Vec<Option<String>> = numbered(&mut s).into_iter().map(|x| x.0).collect();
let want: Vec<Option<String>> =
["๑.", "๒.", "๓.", "๔.", "๕.", "๖.", "๗.", "๘.", "๙.", "๑๐.", "๑๑."].iter().map(|l| Some(l.to_string())).collect();
assert_eq!(page, want);
let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap();
let mut b = Session::new(back);
let again: Vec<Option<String>> = numbered(&mut b).into_iter().map(|x| x.0).collect();
assert_eq!(again, want, "thaiNumbers survives .docx");
// In parentheses too.
let mut s = Session::new(Document::new());
s.run("text.insert", &json!({"text": "x"})).unwrap();
s.run("select.all", &json!({})).unwrap();
s.run("list.define", &json!({"levels": [{"format": "thaiNumbers", "text": "(%1)", "start": 2}]})).unwrap();
assert_eq!(numbered(&mut s)[0].0.as_deref(), Some("(๒)"));
}
/// #1793: the other heading presets of Word's List Library, level by level.
#[test]
fn every_heading_preset_numbers_three_heading_levels() {
@@ -489,8 +459,7 @@ mod tests {
("headings", [Some("1"), Some("1.1"), Some("1.1.1")]),
("article", [Some("Article I."), Some("Section 1.01"), Some("(a)")]),
("outlineHeadings", [Some("I."), Some("A."), Some("1.")]),
// The lower levels are "none" levels with no text: an empty label, as in Word (#4985).
("chapter", [Some("Chapter 1"), Some(""), Some("")]),
("chapter", [Some("Chapter 1"), None, None]),
] {
let mut s = headings("Heading3");
s.run("list.multilevelPreset", &json!({"preset": preset})).unwrap();
+1 -27
View File
@@ -183,17 +183,9 @@ pub fn specs() -> Vec<CommandSpec> {
.key(if cfg!(target_os = "macos") { "Ctrl+Shift+Right" } else { "Alt+Shift+Right" }),
CommandSpec::new("para.shading", "Shading", "Home › Paragraph", |s, v| {
let c = p::str(v, "color").and_then(Rgb::parse);
// Text selected within one paragraph is shaded itself, as in Word, so No Color takes
// off a shading a file gave the text (#4685); a caret or paragraphs shade paragraphs.
// `paragraph` (Borders and Shading › Apply to: Paragraph) shades the paragraphs anyway.
if p::bool(v, "paragraph") != Some(true) && shades_text(s) {
return super::format::apply(s, &|x| x.shading = c);
}
fmt(s, &|p| p.shading = c)
})
.params(
r#"{"color": "RRGGBB" | null, "paragraph"?: bool} (text selected inside one paragraph is shaded itself, as in Word; `paragraph: true` shades the paragraphs instead, as Borders and Shading › Apply to: Paragraph)"#,
),
.params(r#"{"color": "RRGGBB" | null}"#),
CommandSpec::new("para.borders", "Borders", "Home › Paragraph", borders).params(
r#"{"kind": "bottom|top|left|right|none|all|outside|inside|horizontalLine", "width"?: pt, "color"?: "RRGGBB", "style"?: "single|double|dotted|dashed|thick|triple|dotDash|wave", "sides"?: Sides} where Sides = {"top"|"left"|"bottom"|"right"|"between": {"style"?, "width"?: pt, "color"?: "RRGGBB"} | null}: exactly these sides (others off), instead of `kind`"#,
),
@@ -272,21 +264,3 @@ pub fn specs() -> Vec<CommandSpec> {
}),
]
}
/// Whether Shading applies to the selected text rather than to paragraphs: a selection inside one
/// paragraph, short of its mark. A selection reaching past the mark ends at the next paragraph's
/// start, so all of a paragraph's text is still text, except in the last paragraph of its story,
/// cell or box, where it stands for the paragraph with its mark. A selected picture, shape or text
/// box is its object character, not text (a field's object character is).
fn shades_text(s: &Session) -> bool {
let (a, b) = s.sel.ordered();
if a == b || a.story != b.story || a.path != b.path {
return false;
}
let Some(p) = s.doc.para_at(&a) else { return false };
if super::objects::object_selection(s).is_some() {
return false;
}
let last = s.doc.next_para(a.story, &a.path).is_none_or(|q| q.parent() != a.path.parent());
!(last && a.off == 0 && b.off >= p.len())
}
+3 -4
View File
@@ -103,9 +103,8 @@ pub fn specs() -> Vec<CommandSpec> {
/// A field instruction split into its name, positional arguments and switches (ECMA-376
/// §17.16.1): `TOC \o "1-3" \h` → name `TOC`, switches `o` = `1-3` and `h`. Quoted text stays
/// whole (without the quotes; a backslash inside it makes the next character literal, as in Word:
/// `\"` is a quote, `\\` a backslash, `\*` an asterisk). A switch takes the word after it as its
/// argument unless that word is another switch.
/// whole (without the quotes; `\"` and `\\` inside it are a quote and a backslash). A switch
/// takes the word after it as its argument unless that word is another switch.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct FieldCode {
pub name: String,
@@ -127,7 +126,7 @@ pub fn field_words(instr: &str) -> Vec<(String, bool)> {
while let Some(c) = chars.next() {
match c {
'"' => break,
'\\' if chars.peek().is_some() => w.extend(chars.next()),
'\\' if matches!(chars.peek(), Some('"' | '\\')) => w.extend(chars.next()),
c => w.push(c),
}
}
+1 -14
View File
@@ -1,4 +1,3 @@
use super::properties::{new_property_value, text_result};
use super::*;
fn fields(s: &Session) -> Vec<(String, String)> {
@@ -447,7 +446,7 @@ fn update_fields_apply_the_capitalization_switches() {
"seventh",
"Seventh",
"BUDGET",
"new * Upper"
"new \\* Upper"
]
);
}
@@ -549,18 +548,6 @@ fn caption_for_an_object_in_a_cell_stays_in_the_cell() {
assert_eq!(texts.get(1).map(String::as_str), Some("Figure 1"), "{texts:?}");
}
/// A backslash in a quoted field argument makes the next character literal, so `\*` there is an
/// asterisk and not the `\* Upper` switch (Word shows `x * Upper` for `TITLE "x \* Upper"`).
#[test]
fn quoted_field_argument_backslash_escapes() {
for (instr, shown) in [(r#"TITLE "x \* Upper""#, "x * Upper"), (r#"TITLE "x \\* Upper""#, r"x \* Upper"), (r#"TITLE "a \"b\" c""#, r#"a "b" c"#)]
{
let value = new_property_value(instr).unwrap();
assert_eq!(value, shown, "{instr}");
assert_eq!(text_result(instr, value), shown, "{instr}");
}
assert_eq!(text_result(r#"TITLE "ab" \* Upper"#, new_property_value(r#"TITLE "ab" \* Upper"#).unwrap()), "AB");
}
/// Output (PDF, print, page images) shows the body's unlocked SEQ, STYLEREF and REF fields with
/// their fresh results, as Word fills them in when it prints or exports even with "Update fields
/// before printing" off; NUMWORDS and a locked field keep their stored results there, and the
+66 -3
View File
@@ -1,4 +1,5 @@
use super::{Arc, CmdError, CmdResult, Document, Pos, Selection, Session, StoryRef, Value, delete_selection, json, p, sel_result};
use wordcraft_doc::CharProps;
/// Options › Language › Set as default: only the saved default proofing language (what future
/// blank documents are written in), as Word's Set as Preferred. A setting, like `file.options`: the
@@ -14,8 +15,15 @@ pub(super) fn preferred_language(s: &mut Session, v: &Value) -> CmdResult {
/// Set Proofing Language: the selection's language and "do not check spelling or grammar",
/// or (`default`) the document's default and the saved language for future blank documents;
/// `detect` is the per-user detection preference.
/// Nothing at all sets US English, as before.
/// Nothing at all sets US English, as before. `scope: "document"` sets it for every story.
pub(super) fn language(s: &mut Session, v: &Value) -> CmdResult {
// Checked first, so a bad scope changes nothing (not even the detect preference).
let whole_document = match v.get("scope") {
None | Some(Value::Null) => false,
Some(Value::String(t)) if t == "selection" => false,
Some(Value::String(t)) if t == "document" => true,
Some(other) => return Err(CmdError::Params(format!("`scope` must be \"selection\" or \"document\", not {other}"))),
};
let lang = match p::str(v, "lang") {
Some(t) => Some(
wordcraft_proof::lang::normalize_tag(t)
@@ -45,20 +53,75 @@ pub(super) fn language(s: &mut Session, v: &Value) -> CmdResult {
if lang.is_none() && no_proof.is_none() {
return Ok(json!({"detect": s.prefs.detect_language}));
}
let result = crate::cmd::format::apply(s, &|c| {
let change = |c: &mut CharProps| {
if let Some(l) = &lang {
c.lang = Some(l.clone());
}
if let Some(np) = no_proof {
c.no_proof = Some(np);
}
})?;
};
let result = if whole_document {
set_document_props(s, &change)?;
json!({"scope": "document"})
} else {
crate::cmd::format::apply(s, &change)?
};
if p::bool(v, "default") == Some(true) {
s.prefs.default_language = lang;
}
Ok(result)
}
/// Apply `f` to the character formatting of every story of the document (Language dialog, "Apply
/// to the whole document"): text and paragraph marks of the body, headers, footers, notes,
/// comments, text boxes (all `Document::parts`), table cells, and the glossary's building blocks.
/// The selection is untouched; formatting waiting for typing follows.
fn set_document_props(s: &mut Session, f: &dyn Fn(&mut CharProps)) -> Result<(), CmdError> {
let stories: Vec<StoryRef> = std::iter::once(StoryRef::Body).chain(s.doc.parts.keys().map(|k| StoryRef::Part(*k))).collect();
for story in stories {
for path in s.doc.para_paths(story) {
let para = s.doc.para_mut(story, &path)?;
let len = para.len();
para.format(0, len, f)?;
f(&mut para.mark);
para.touch();
}
}
for block in &mut s.doc.glossary {
format_blocks(&mut block.blocks, f, 0);
}
if let Some(pend) = s.pending.as_mut() {
f(pend);
}
Ok(())
}
/// [`set_document_props`] for a block list outside any story (depth-limited like `para_paths`).
fn format_blocks(blocks: &mut wordcraft_doc::Blocks, f: &dyn Fn(&mut CharProps), depth: usize) {
if depth > wordcraft_doc::PARA_WALK_DEPTH {
return;
}
for blk in blocks.iter_mut() {
match Arc::make_mut(blk) {
wordcraft_doc::Block::Para(p) => {
let len = p.len();
// A range inside the paragraph can't fail; the mark is formatted regardless.
let _ = p.format(0, len, f);
f(&mut p.mark);
p.touch();
}
wordcraft_doc::Block::Table(t) => {
for row in &mut t.rows {
for cell in &mut row.cells {
format_blocks(&mut cell.blocks, f, depth + 1);
}
}
}
}
}
}
pub(super) fn macros(s: &mut Session, v: &Value) -> CmdResult {
if let Some(d) = v.get("define") {
let name = d.get("name").and_then(Value::as_str).unwrap_or("Macro").to_string();
+12 -6
View File
@@ -121,11 +121,17 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("file.recover", "Recover Unsaved Documents", "File › Info", |s, v| s.recover(v))
.params(r#"{"restore"?: recoveryId, "discard"?: recoveryId, "refresh"?: true}; desktop only; returns {enabled,busy,documents,error}; restore loads asynchronously without a file path"#)
.pure(),
CommandSpec::new("file.newFromTemplate", "New from Template", "File › New", crate::cmd::file::new_from_template)
.params(
r#"{"path": string, "data"?: base64 (the template's bytes; `path` then only names it), "password"?: string (for a password-protected template), "replaceBlank"?: bool (an untouched blank document gives way to it, as to a file file.open opens)} → a new, untitled document from the template, attached to it"#,
)
.pure(),
CommandSpec::new("file.newFromTemplate", "New from Template", "File › New", |s, v| {
let path = p::req_str(v, "path")?;
let mut doc = crate::io::open_path(std::path::Path::new(path)).map_err(CmdError::Failed)?;
// As in Word, the new document is attached to the template it came from (#2852).
doc.settings.attached_template = Some(path.to_string());
s.add_document(doc);
s.dirty = true;
sel_result(s)
})
.params(r#"{"path": string} → a new, untitled document from the template, attached to it"#)
.pure(),
CommandSpec::new("file.saveTemplate", "Save as Template", "File › Save As", |s, v| {
let path = p::req_str(v, "path")?;
// A .dotm keeps its name (#2852); anything else becomes a .dotx.
@@ -334,7 +340,7 @@ pub fn specs() -> Vec<CommandSpec> {
})
.params(r#"{"format": "decimal|upperRoman|lowerLetter…", "text"?: "%1.", "start"?: n}"#),
CommandSpec::new("review.language", "Language", "Review › Language", language).params(
r#"{"lang"?: "en-US|en-GB|pl-PL|fr-FR|…" (BCP 47; default en-US), "noProof"?: bool, "detect"?: bool (detect language automatically, a preference), "default"?: bool (also the document's default and the saved language for future blank documents)}"#,
r#"{"lang"?: "en-US|en-GB|pl-PL|fr-FR|…" (BCP 47; default en-US), "noProof"?: bool, "detect"?: bool (detect language automatically, a preference), "default"?: bool (also the document's default and the saved language for future blank documents), "scope"?: "selection" (default) | "document" (every run and paragraph mark of every story: body, headers, footers, notes, comments, text boxes, tables, glossary; one undo step, selection unchanged)}"#,
),
CommandSpec::new("review.language.preferred", "Set as Default Language", "File › Options › Language", preferred_language)
.params(r#"{"lang": "en-US|en-GB|pl-PL|nl-NL|…" (BCP 47)} — only the saved default proofing language, what future blank documents are written in, as Options › Language › Set as default does; the open document, the selection and the undo history don't change. Returns {"preferred": tag}."#)
+101
View File
@@ -290,3 +290,104 @@ fn inspect_removals_work_when_the_document_does_not_track_changes() {
assert!(!text.contains("Hidden"), "the hidden text is still there: {text}");
assert!(text.contains("Visible text."), "{text}");
}
/// #3518: every run and paragraph mark of a story, as (story, path, lang, noProof) rows.
fn proofing_rows(s: &Session) -> Vec<(StoryRef, Option<String>, Option<bool>)> {
let stories = std::iter::once(StoryRef::Body).chain(s.doc.parts.keys().map(|k| StoryRef::Part(*k)));
let mut out = Vec::new();
for st in stories {
for path in s.doc.para_paths(st) {
let Some(p) = s.doc.para(st, &path) else { continue };
out.push((st, p.mark.lang.clone(), p.mark.no_proof));
out.extend(p.runs.iter().map(|r| (st, r.props.lang.clone(), r.props.no_proof)));
}
}
out
}
/// A document with its text in German in the body, a table cell, a header, a footnote, a comment
/// and a text box, as a Word DOCX keeps a `w:lang` on each run, plus a glossary entry.
fn multi_story_doc() -> Session {
use wordcraft_doc::{Block, BuildingBlock, CharProps, Paragraph, PartKind, Table};
let de = || CharProps { lang: Some("de-DE".into()), ..Default::default() };
let blk = |t: &str| Arc::new(Block::Para(Paragraph::with_text(t, de())));
let mut d = Document::from_text("Guten Tag");
d.body = vec![blk("Guten Tag")];
let mut t = Table::new(1, 2, 200.0);
if let Some(c) = t.rows.first_mut().and_then(|r| r.cells.first_mut()) {
c.blocks = vec![blk("Zelle")];
}
d.body.push(Arc::new(Block::Table(t)));
d.body.push(blk("Ende"));
for kind in [PartKind::Header, PartKind::Footnote, PartKind::Comment, PartKind::TextBox] {
d.add_part(kind, vec![blk("Teil")]);
}
d.glossary.push(BuildingBlock { name: "g".into(), blocks: vec![blk("Baustein")], ..Default::default() });
Session::new(d)
}
#[test]
fn language_scope_document_sets_every_story() {
let mut s = multi_story_doc();
s.run("select.text", &json!({"text": "Guten"})).unwrap();
let (sel, before) = (s.sel.clone(), s.doc.clone());
let rows = proofing_rows(&s);
assert!(rows.len() >= 10 && rows.iter().any(|r| r.1.as_deref() == Some("de-DE")));
s.run("review.language", &json!({"lang": "fr-FR", "scope": "document"})).unwrap();
// Runs that had their own language, the paragraph marks and the empty cell: all French.
let rows = proofing_rows(&s);
assert!(rows.iter().all(|r| r.1.as_deref() == Some("fr-FR")), "{rows:?}");
for kind_story in s.doc.parts.keys() {
assert!(rows.iter().any(|r| r.0 == StoryRef::Part(*kind_story)));
}
assert!(s.doc.plain_text(StoryRef::Body).contains("Zelle"));
let g = &s.doc.glossary[0].blocks;
assert_eq!(g[0].as_para().map(|p| p.runs[0].props.lang.clone()), Some(Some("fr-FR".into())));
assert_eq!(g[0].as_para().map(|p| p.mark.lang.clone()), Some(Some("fr-FR".into())));
// The selection stays; the document is modified.
assert_eq!(s.sel, sel);
assert!(s.doc != before);
// One undo restores everything.
s.run("edit.undo", &json!({})).unwrap();
assert!(s.doc == before);
assert_eq!(s.sel, sel);
}
#[test]
fn language_scope_document_no_proof_and_pending() {
let mut s = multi_story_doc();
s.run("review.language", &json!({"noProof": true, "scope": "document"})).unwrap();
assert!(proofing_rows(&s).iter().all(|r| r.2 == Some(true)));
// The language itself wasn't asked for and stays.
assert!(proofing_rows(&s).iter().any(|r| r.1.as_deref() == Some("de-DE")));
// Formatting waiting for typing follows.
s.run("format.bold", &json!({})).unwrap();
s.run("review.language", &json!({"lang": "pl-PL", "scope": "document"})).unwrap();
assert_eq!(s.pending.as_ref().and_then(|p| p.lang.as_deref()), Some("pl-PL"));
}
#[test]
fn language_default_scope_is_selection_only() {
let mut s = multi_story_doc();
s.run("select.text", &json!({"text": "Guten"})).unwrap();
s.run("review.language", &json!({"lang": "fr-FR"})).unwrap();
let rows = proofing_rows(&s);
assert!(rows.iter().any(|r| r.1.as_deref() == Some("fr-FR")));
assert!(rows.iter().filter(|r| r.1.as_deref() == Some("fr-FR")).count() < rows.len() / 2);
let mut s = multi_story_doc();
s.run("select.text", &json!({"text": "Guten"})).unwrap();
s.run("review.language", &json!({"lang": "fr-FR", "scope": "selection"})).unwrap();
assert_eq!(proofing_rows(&s), rows);
}
#[test]
fn language_bad_scope_is_a_params_error() {
let mut s = multi_story_doc();
let before = s.doc.clone();
for bad in [json!("everything"), json!(1), json!(true), json!(["document"])] {
let e = s.run("review.language", &json!({"lang": "fr-FR", "detect": false, "scope": bad})).unwrap_err();
assert!(matches!(e, CmdError::Params(_)), "{e:?}");
}
assert!(s.doc == before);
assert!(s.prefs.detect_language);
}
-67
View File
@@ -337,73 +337,6 @@ mod tests {
let _ = std::fs::remove_dir_all(&d);
}
/// #3433: a template the platform hands the app takes an untouched blank document's place
/// (`replaceBlank`), as a file `file.open` opens does; otherwise, and without the flag, the
/// new document opens beside the others, as File › New's does. Either way the template on
/// disk is only read: the new document has no path, so a save can't write over it.
#[test]
fn new_from_template_replaces_an_untouched_blank_document_only_when_asked() {
let d = scratch("replace-blank");
let path = d.join("Letterhead.dotx");
template(&path, "Letterhead text", "");
let bytes = std::fs::read(&path).unwrap();
let from = json!({"path": path.to_string_lossy(), "replaceBlank": true});
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("file.newFromTemplate", &from).unwrap();
assert_eq!(s.documents().len(), 1, "the untouched blank document gave way");
assert_eq!(s.path, None);
assert!(s.dirty);
assert_eq!(s.doc.settings.attached_template.as_deref(), Some(path.to_string_lossy().as_ref()));
assert!(s.doc.plain_text(wordcraft_doc::StoryRef::Body).contains("Letterhead text"));
let saved = s.run("file.save", &json!({})).unwrap();
assert_eq!(saved["saved"], json!(false), "no file to save to: Save As asks where");
assert_eq!(std::fs::read(&path).unwrap(), bytes, "the template is never written over");
// The new document has unsaved changes, so a second one opens beside it.
s.run("file.newFromTemplate", &from).unwrap();
assert_eq!(s.documents().len(), 2);
// Without the flag, even an untouched blank document stays (File › New).
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("file.newFromTemplate", &json!({"path": path.to_string_lossy()})).unwrap();
assert_eq!(s.documents().len(), 2);
assert_eq!(std::fs::read(&path).unwrap(), bytes);
let _ = std::fs::remove_dir_all(&d);
}
/// #3433: a protected template asks for its password like `file.open`, and the document made
/// from it isn't protected; a template's bytes can come in `data` (a file name that isn't
/// valid Unicode, #1440), its path then only naming it.
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn new_from_template_reads_a_protected_template_and_bytes_like_file_open() {
let d = scratch("protected");
let path = d.join("Locked.dotx");
let mut t = Session::new(wordcraft_doc::Document::from_text("Locked template"));
t.run("file.save", &json!({"path": path.to_string_lossy(), "password": "sesame"})).unwrap();
let name = path.to_string_lossy();
let mut s = Session::new(wordcraft_doc::Document::new());
let e = s.run("file.newFromTemplate", &json!({"path": name})).unwrap_err().to_string();
assert!(crate::io::needs_password(&e), "{e}");
let e = s.run("file.newFromTemplate", &json!({"path": name, "password": "wrong"})).unwrap_err().to_string();
assert!(crate::io::wrong_password(&e), "{e}");
assert_eq!(s.documents().len(), 1, "nothing opened yet");
s.run("file.newFromTemplate", &json!({"path": name, "password": "sesame", "replaceBlank": true})).unwrap();
assert!(s.doc.plain_text(wordcraft_doc::StoryRef::Body).contains("Locked template"));
assert_eq!((s.path.as_ref(), s.password.is_some()), (None, false));
let plain = d.join("Plain.dotx");
template(&plain, "From bytes", "");
let data = crate::cmd::insert::base64_encode(&std::fs::read(&plain).unwrap());
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("file.newFromTemplate", &json!({"path": "Pl\u{FFFD}in.dotx", "data": data, "replaceBlank": true})).unwrap();
assert!(s.doc.plain_text(wordcraft_doc::StoryRef::Body).contains("From bytes"));
assert_eq!(s.doc.settings.attached_template.as_deref(), Some("Pl\u{FFFD}in.dotx"));
assert_eq!(s.path, None);
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn the_templates_folder_option_is_saved_and_checked() {
let d = scratch("option");
+8 -230
View File
@@ -4681,8 +4681,10 @@ fn typed_paragraphs_are_capped_for_every_caller() {
assert!(!body_texts(&s).iter().any(|p| p.contains('y')));
}
fn hostile_junk() -> [serde_json::Value; 8] {
[
#[test]
fn hostile_params_never_panic() {
let reg = cmd::registry();
let junk = [
json!(null),
json!({}),
json!({"text": 5, "size": "x", "path": [], "pos": {"block": 9999, "off": 99999}}),
@@ -4691,23 +4693,11 @@ fn hostile_junk() -> [serde_json::Value; 8] {
json!({"page": 0, "x": 1e30, "y": -1e30, "extend": 1}),
json!({"width": 1e-9, "height": 1e30, "top": -1e9, "left": 1e9}),
json!({"section": {"pageW": -1e308, "pageH": 0, "marginTop": 1e30, "header": -5, "columns": {"count": 1000000, "space": -1e300, "widths": [[0, 0]]}}}),
]
}
/// `file.*`, `insert.picture` and `insert.textFromFile`: `hostile_file_params_never_panic` runs
/// them, held to a folder of its own, and `hostile_params_never_panic` every other command.
fn reaches_files(id: &str) -> bool {
id.starts_with("file.") || id == "insert.picture" || id == "insert.textFromFile"
}
#[test]
fn hostile_params_never_panic() {
let reg = cmd::registry();
let junk = hostile_junk();
];
for spec in reg.all() {
// Read Aloud (`review.readAloud`, `readAloud.*`) is fuzzed too: under `cfg(test)` its
// backend is the silent `Hold`, asserted below.
if reaches_files(spec.id) {
// These reach outside the session (files). Read Aloud (`review.readAloud`, `readAloud.*`)
// is fuzzed too: under `cfg(test)` its backend is the silent `Hold`, asserted below.
if spec.id.starts_with("file.") || spec.id == "insert.picture" || spec.id == "insert.textFromFile" {
continue;
}
for j in &junk {
@@ -4721,127 +4711,6 @@ fn hostile_params_never_panic() {
}
}
/// `file.*`, `insert.picture` and `insert.textFromFile` take junk params and paths to files they
/// can't use (#3719). They may read only `in` and write only `out`, so a save can't replace a file
/// another run is meant to find broken, and nothing outside those folders is touched.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn hostile_file_params_never_panic() {
/// What is directly in `dir`: each name with its bytes (`None` for a folder).
fn contents(dir: &std::path::Path) -> std::collections::BTreeMap<String, Option<Vec<u8>>> {
let entries = std::fs::read_dir(dir).unwrap().map(|e| e.unwrap().path());
entries.map(|p| (p.file_name().unwrap().to_string_lossy().into_owned(), std::fs::read(&p).ok())).collect()
}
/// The names whose bytes differ, so a failure lists them instead of printing every file.
fn changed(before: &std::collections::BTreeMap<String, Option<Vec<u8>>>, dir: &std::path::Path) -> Vec<String> {
let after = contents(dir);
let names: std::collections::BTreeSet<_> = before.keys().chain(after.keys()).collect();
names.into_iter().filter(|n| before.get(*n) != after.get(*n)).cloned().collect()
}
let reg = cmd::registry();
let base = scratch_dir("hostile-files");
let (input, output, outside) = (base.join("in"), base.join("out"), base.join("outside"));
for dir in [&outside, &input.join("folder.docx"), &output.join("folder.docx")] {
std::fs::create_dir_all(dir).unwrap();
}
let at = |dir: &std::path::Path, name: &str| dir.join(name).to_string_lossy().into_owned();
// Fixed bytes, so a failure comes back on the next run.
let noise: Vec<u8> = (0..512u32).map(|i| (i.wrapping_mul(2_654_435_761) >> 24) as u8).collect();
let picture_exts = ["png", "jpg", "gif", "bmp", "svg", "emf"];
let mut paths = vec![];
for ext in crate::io::OPEN_EXTS.iter().chain(&picture_exts) {
let name = format!("random.{ext}");
std::fs::write(input.join(&name), &noise).unwrap();
paths.push(at(&input, &name));
}
paths.extend(crate::io::SAVE_EXTS.iter().map(|ext| at(&output, &format!("new.{ext}"))));
std::fs::write(input.join("empty"), b"").unwrap();
std::fs::write(input.join("empty.docx"), b"").unwrap();
std::fs::write(input.join("good.txt"), "Good text").unwrap();
std::fs::write(input.join("good.png"), tiny_png()).unwrap();
paths.extend(["empty", "empty.docx", "folder.docx", "missing", "missing.docx"].map(|name| at(&input, name)));
paths.extend(["folder.docx", "missing/new.docx", &format!("{}.docx", "x".repeat(5000))].map(|name| at(&output, name)));
// Outside the roots, however it is spelled, and not paths at all.
paths.extend(["new.docx", "new"].map(|name| at(&outside, name)));
paths.push(format!("{}/../outside/new.docx", input.to_string_lossy()));
paths.extend(["hostile-relative.docx", "hostile-relative", "", "\0", "a\0b.docx"].map(String::from));
// Every path alone; each of these also with every other kind of junk param. The numbers that
// cost time (`copies`, `scale`) stay small: huge ones are #3720.
let mut params: Vec<serde_json::Value> = hostile_junk().into();
params.extend(paths.iter().map(|p| json!({"path": p})));
for name in [
"in/random.docx",
"in/empty.docx",
"in/folder.docx",
"in/missing.docx",
"in/good.txt",
"in/good.png",
"out/new.docx",
"out/new.pdf",
"out/new.png",
] {
let path = at(&base, name);
params.extend([
json!({"path": path, "password": "x", "format": "wordml2003", "markup": "x", "confirmDamaged": 1}),
json!({"path": path, "password": 5, "format": 5, "markup": true}),
json!({"path": path, "page": 0, "scale": 0, "width": -1e308, "alt": 5}),
json!({"path": path, "data": "not base64!"}),
json!({"path": path, "data": "AAAA"}),
json!({"path": path, "pagesPerSheet": 7, "range": "x", "pages": "9-1,,", "parity": "x", "copies": 3, "paper": "x"}),
json!({"dir": path, "name": path, "template": path, "userTemplatesFolder": path, "id": 1e30, "restore": 1e30, "save": path}),
]);
}
let before = contents(&input);
let working_dir = std::path::Path::new(".");
let working_dir_before = contents(working_dir);
let roots = crate::file_access::AutomationRoots::new(Some(&input), Some(&output)).unwrap();
let confined = |s: &mut Session, id: &str, v: &serde_json::Value| crate::file_access::confine(roots.as_ref(), || s.run(id, v));
for spec in reg.all() {
if !reaches_files(spec.id) {
continue;
}
for v in &params {
let mut s = Session::new(crate::sample::sample_document());
let r = confined(&mut s, spec.id, v);
assert!(!caught_panic(&reg, &r), "{} panicked on {v}: {r:?}", spec.id);
s.clamp_selection();
let _ = s.layout();
}
}
let nothing = Vec::<String>::new();
assert_eq!(changed(&before, &input), nothing, "the read root is only read");
assert_eq!(changed(&Default::default(), &outside), nothing, "nothing outside the write root");
// Every name counts: an empty path comes out as `.pdf` or `.dotx` here.
assert_eq!(changed(&working_dir_before, working_dir), nothing, "nothing in the working directory");
// The junk above met the files, not a closed door: usable files work through the same roots...
let mut s = s();
confined(&mut s, "file.open", &json!({"path": at(&input, "good.txt")})).unwrap();
assert_eq!(s.doc.plain_text(StoryRef::Body), "Good text");
confined(&mut s, "insert.textFromFile", &json!({"path": at(&input, "good.txt")})).unwrap();
confined(&mut s, "insert.picture", &json!({"path": at(&input, "good.png")})).unwrap();
let saved: [(&str, &str, &[u8]); 3] =
[("file.saveAs", "control.docx", b"PK\x03\x04"), ("file.exportPdf", "control.pdf", b"%PDF-"), ("file.exportPng", "control.png", b"\x89PNG")];
for (id, name, signature) in saved {
confined(&mut s, id, &json!({"path": at(&output, name)})).unwrap();
assert_eq!(std::fs::read(output.join(name)).unwrap().get(..signature.len()), Some(signature), "{name}");
}
// ...the noise is refused by the reader, and only a path outside by the roots.
let refused = |s: &mut Session, path: String| match confined(s, "file.open", &json!({"path": path})) {
Err(crate::CmdError::Failed(m)) => m,
r => panic!("not refused: {r:?}"),
};
let unreadable = refused(&mut s, at(&input, "random.docx"));
assert!(!unreadable.contains("automation path rejected"), "{unreadable}");
let rejected = refused(&mut s, at(&outside, "new.docx"));
assert!(rejected.contains("automation path rejected"), "{rejected}");
let _ = std::fs::remove_dir_all(&base);
}
/// A panic that `Session::run`'s guard turned into an error, in a command or one it ran: still a
/// crash path (#3718). Only the guard's own `"{id} panicked: "` counts, not an error that echoes
/// those words.
@@ -7943,97 +7812,6 @@ fn colour_commands_take_any_custom_hex() {
assert_eq!(s.doc.settings.page_color, wordcraft_doc::Rgb::parse("123ABC"));
}
/// #4685: Home › Paragraph › Shading on text selected inside a paragraph shades that text, as in
/// Word, so No Color takes off a text shading a file gave it (an orange "highlight" from a .docx is
/// `w:shd`, which Text Highlight Color › No Color leaves, in Word too). A caret or a whole
/// paragraph still shades the paragraph.
#[test]
fn shading_selected_text_shades_the_text_and_no_color_clears_it() {
let orange = wordcraft_doc::Rgb::parse("FFA500");
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Orange text here"}));
run(&mut s, "select.text", json!({"text": "Orange"}));
run(&mut s, "para.shading", json!({"color": "FFA500"}));
let p = s.doc.para_at(&s.sel.focus).unwrap();
assert_eq!((p.props.shading, p.props_of_char(0).shading, p.props_of_char(7).shading), (None, orange, None));
// Highlight › No Color doesn't take it off; Shading › No Color does.
run(&mut s, "format.highlight", json!({"color": "none"}));
assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().props_of_char(0).shading, orange);
run(&mut s, "para.shading", json!({"color": null}));
assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().props_of_char(0).shading, None);
// A caret, or the whole paragraph, shades the paragraph and leaves the text alone.
for whole in [false, true] {
let mut s = self::s();
run(&mut s, "text.insert", json!({"text": "Orange text here"}));
run(&mut s, if whole { "select.all" } else { "caret.left" }, json!({}));
run(&mut s, "para.shading", json!({"color": "FFA500"}));
let p = s.doc.para_at(&s.sel.focus).unwrap();
assert_eq!((p.props.shading, p.props_of_char(0).shading), (orange, None), "whole: {whole}");
}
// All the text of a paragraph that isn't the last is still text: a selection with its mark
// would end at the next paragraph's start.
let mut s = self::s();
run(
&mut s,
"document.setText",
json!({"text": "Word
More"}),
);
run(&mut s, "select.text", json!({"text": "Word"}));
run(&mut s, "para.shading", json!({"color": "FFA500"}));
let p = s.doc.para_at(&s.sel.focus).unwrap();
assert_eq!((p.props.shading, p.props_of_char(0).shading), (None, orange));
// A selected text box shades its anchor's paragraph, not every line of the box.
let mut s = self::s();
run(&mut s, "text.insert", json!({"text": "Anchor"}));
run(&mut s, "insert.textBox", json!({"text": "Inside"}));
let id = s.doc.parts.iter().find(|(_, p)| p.kind == wordcraft_doc::PartKind::TextBox).map(|(id, _)| *id).unwrap();
let end = s.doc.text_box_anchor(id).unwrap();
let start = wordcraft_doc::Pos { off: end.off - wordcraft_doc::para::OBJ.len_utf8(), ..end.clone() };
run(&mut s, "select.range", json!({"anchor": start, "focus": end}));
run(&mut s, "para.shading", json!({"color": "FFA500"}));
let boxed = s.doc.parts.get(&id).and_then(|p| p.blocks.first()).and_then(|b| b.as_para()).unwrap();
assert_eq!(boxed.props_of_char(0).shading, None, "the box's text isn't shaded");
assert_eq!(s.doc.para_at(&end).unwrap().props.shading, orange);
// A selected field is text (its object character isn't a drawing): No Color clears its shading.
let mut s = self::s();
run(&mut s, "text.insert", json!({"text": "Page "}));
run(&mut s, "insert.pageNumber", json!({"position": "current"}));
let end = s.sel.focus.clone();
let start = wordcraft_doc::Pos { off: end.off - wordcraft_doc::para::OBJ.len_utf8(), ..end.clone() };
run(&mut s, "select.range", json!({"anchor": start, "focus": end}));
run(&mut s, "para.shading", json!({"color": "FFA500"}));
let p = s.doc.para_at(&end).unwrap();
assert_eq!((p.props.shading, p.props_of_char(start.off).shading), (None, orange), "the field is shaded");
run(&mut s, "para.shading", json!({"color": null}));
assert_eq!(s.doc.para_at(&end).unwrap().props_of_char(start.off).shading, None);
// Borders and Shading › Apply to: Paragraph shades the paragraph, whatever is selected.
run(&mut s, "select.text", json!({"text": "Page"}));
run(&mut s, "para.shading", json!({"color": "FFA500", "paragraph": true}));
let p = s.doc.para_at(&s.sel.focus).unwrap();
assert_eq!((p.props.shading, p.props_of_char(0).shading), (orange, None));
}
/// #4685 review: in a cell's last paragraph all of its text stands for the paragraph with its
/// mark (a selection can't reach the next paragraph's start there), so Shading shades the
/// paragraph; part of the text is text.
#[test]
fn shading_all_of_a_cells_last_paragraph_shades_the_paragraph() {
let orange = wordcraft_doc::Rgb::parse("FFA500");
for (from, to, paragraph) in [(0, 9, true), (0, 4, false)] {
let mut s = s();
run(&mut s, "insert.table", json!({"rows": 1, "cols": 1}));
run(&mut s, "text.insert", json!({"text": "Cell text"}));
let cell = s.sel.focus.clone();
assert!(cell.path.cell().is_some());
run(&mut s, "select.range", json!({"anchor": Pos { off: from, ..cell.clone() }, "focus": Pos { off: to, ..cell.clone() }}));
run(&mut s, "para.shading", json!({"color": "FFA500"}));
let p = s.doc.para_at(&cell).unwrap();
let want = if paragraph { (orange, None) } else { (None, orange) };
assert_eq!((p.props.shading, p.props_of_char(0).shading), want, "{from}..{to}");
}
}
/// #2738: Home › Underline › Underline Color sets the underline's own colour and leaves its style
/// alone; `auto` drops it again. One undo step each, kept through a DOCX round trip, and junk is
/// a params error that changes nothing.
-104
View File
@@ -1539,110 +1539,6 @@ fn typing_at_either_end_of_a_link_is_not_linked() {
assert_eq!(links(&s), [("sixte".into(), false), (String::new(), false)]);
}
#[test]
fn a_link_on_a_selection_shows_the_text_to_display() {
// Ctrl+K on selected text with a new Text to display: the selection is retyped with it and
// linked, keeping its formatting; typing after it stays outside the link (as in Word).
let mut s = typed("go see kiln now");
run(&mut s, "select.text", json!({"text": "kiln"}));
run(&mut s, "format.bold", json!({}));
run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "Oven"}));
typ(&mut s, "!");
assert_eq!(texts(&s), ["go see oven! now"]);
assert_eq!(links(&s), [("Oven".into(), false)]);
let p = s.doc.body.iter().find_map(|b| b.as_para()).unwrap();
assert!(p.run_ranges().any(|(r, c)| &p.text[r] == "Oven" && c.bold == Some(true)), "the new text keeps the selection's formatting");
// The same text, or text that can't stand in one line, leaves the selection as it is.
run(&mut s, "select.text", json!({"text": "see"}));
run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "see"}));
run(&mut s, "select.text", json!({"text": "now"}));
run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "two\nlines"}));
assert_eq!(texts(&s), ["go see oven! now"]);
}
#[test]
fn a_new_selection_drops_the_formatting_left_after_a_link() {
// A link leaves plain formatting for what is typed after it. A selection made next doesn't
// take it: retyping that selection keeps its own formatting.
let bold_run = |s: &Session, text: &str| {
let p = s.doc.body.iter().find_map(|b| b.as_para()).unwrap();
p.run_ranges().any(|(r, c)| &p.text[r] == text && c.bold == Some(true))
};
let mut s = typed("kiln");
run(&mut s, "select.text", json!({"text": "kiln"}));
run(&mut s, "format.bold", json!({}));
run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "Oven"}));
run(&mut s, "select.all", json!({}));
run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "Stove"}));
assert!(bold_run(&s, "Stove"), "select.all");
run(&mut s, "select.word", json!({}));
run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "Range"}));
assert!(bold_run(&s, "Range"), "select.word");
let anchor = serde_json::to_value(s.doc.start_of(s.sel.focus.story)).unwrap();
let focus = serde_json::to_value(s.sel.focus.clone()).unwrap();
run(&mut s, "select.range", json!({"anchor": anchor, "focus": focus}));
run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "Hob"}));
assert!(bold_run(&s, "Hob"), "select.range");
}
#[test]
fn a_link_over_two_paragraphs_keeps_their_text() {
// Word shows <<Selection in Document>> for such a selection: the text to display is ignored.
let mut s = typed("one\ntwo");
run(&mut s, "select.all", json!({}));
run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "both"}));
assert_eq!(texts(&s), ["one", "two"]);
assert_eq!(links(&s), [("One".into(), false), ("two".into(), false)]);
}
#[test]
fn a_retyped_link_text_is_tracked() {
let mut s = typed("go see kiln now");
run(&mut s, "review.trackChanges", json!({"value": true}));
run(&mut s, "select.text", json!({"text": "kiln"}));
run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "Oven"}));
let p = s.doc.body.iter().find_map(|b| b.as_para()).unwrap();
assert!(p.run_ranges().any(|(r, c)| &p.text[r] == "kiln" && c.del.is_some()), "the old text stays as a deletion");
assert!(p.run_ranges().any(|(r, c)| &p.text[r] == "Oven" && c.ins.is_some() && c.link.is_some()));
}
#[test]
fn a_link_keeps_a_selection_it_cannot_retype() {
use wordcraft_doc::props::CharProps;
use wordcraft_doc::{InlineObject, Path};
let url = "https://example.org/";
// A second retyped link keeps its own selection's formatting: the first one's left no
// formatting pending for it.
let mut s = typed("go see kiln and vat now");
run(&mut s, "select.text", json!({"text": "kiln"}));
run(&mut s, "insert.link", json!({"url": url, "text": "Oven"}));
run(&mut s, "select.text", json!({"text": "vat"}));
run(&mut s, "format.bold", json!({}));
run(&mut s, "insert.link", json!({"url": url, "text": "Tub"}));
let p = s.doc.body.iter().find_map(|b| b.as_para()).unwrap();
assert!(p.run_ranges().any(|(r, c)| &p.text[r] == "Tub" && c.bold == Some(true) && c.link.is_some()), "{:?}", p.text);
// A selection holding a manual line break keeps both lines.
let mut s = typed("one");
run(&mut s, "text.lineBreak", json!({}));
typ(&mut s, "two");
run(&mut s, "select.all", json!({}));
run(&mut s, "insert.link", json!({"url": url, "text": "both"}));
assert_eq!(s.doc.plain_text(StoryRef::Body), "one\ntwo");
// A selection in a field's result is only linked; the field's next update owns that text.
let mut doc = wordcraft_doc::Document::from_text("See ");
let c = CharProps::default();
let p = doc.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
let start = p.len();
p.insert_object(start, InlineObject::FieldStart { instr: "ADDIN ZOTERO_ITEM CSL_CITATION {}".into(), locked: false, dirty: false }, &c).unwrap();
let n = p.insert_text(p.len(), "(Smith 2020)", &c).unwrap();
p.insert_object(start + 3 + n, InlineObject::FieldEnd, &c).unwrap();
let mut s = Session::new(doc);
run(&mut s, "select.text", json!({"text": "Smith"}));
run(&mut s, "insert.link", json!({"url": url, "text": "Jones"}));
assert_eq!(s.doc.plain_text(StoryRef::Body), "See (Smith 2020)");
assert_eq!(s.doc.field_ranges(StoryRef::Body).len(), 1);
}
#[test]
fn typing_then_selecting_then_typing_are_two_undo_steps() {
// #419: a selection that moves the insertion point ends the typing group, like a caret move.
+7 -65
View File
@@ -3,10 +3,6 @@
/// Decode an entity body (`amp`, `#38`, `#x26`) to its text.
pub fn decode_entity(name: &str) -> Option<String> {
decode_char(name).map(String::from)
}
fn decode_char(name: &str) -> Option<char> {
if let Some(num) = name.strip_prefix('#') {
let (digits, radix) = match num.strip_prefix(['x', 'X']) {
Some(h) => (h, 16),
@@ -21,7 +17,7 @@ fn decode_char(name: &str) -> Option<char> {
0x80..=0x9F => crate::txt::cp1252(v as u8),
_ => char::from_u32(v).unwrap_or('\u{FFFD}'),
};
return Some(c);
return Some(c.to_string());
}
let c = match name {
"amp" | "AMP" => '&',
@@ -233,54 +229,11 @@ fn decode_char(name: &str) -> Option<char> {
"zwnj" => '\u{200C}',
_ => return None,
};
Some(c)
Some(c.to_string())
}
/// Legacy names HTML5 also decodes without a `;` (only those [`decode_char`] knows), sorted by bytes.
const BARE_NAMES: &[&str] = &[
"AElig", "AMP", "Aacute", "Acirc", "Agrave", "Aring", "Atilde", "Auml", "COPY", "Ccedil", "Eacute", "Ecirc", "Egrave", "Euml", "GT", "Iacute",
"Icirc", "Igrave", "Iuml", "LT", "Ntilde", "Oacute", "Ocirc", "Ograve", "Oslash", "Otilde", "Ouml", "QUOT", "REG", "Uacute", "Ucirc", "Ugrave",
"Uuml", "Yacute", "aacute", "acirc", "aelig", "agrave", "amp", "aring", "atilde", "auml", "ccedil", "cent", "copy", "curren", "deg", "divide",
"eacute", "ecirc", "egrave", "euml", "frac12", "frac14", "frac34", "gt", "iacute", "icirc", "iexcl", "igrave", "iquest", "iuml", "laquo", "lt",
"micro", "middot", "nbsp", "ntilde", "oacute", "ocirc", "ograve", "oslash", "otilde", "ouml", "para", "plusmn", "pound", "quot", "raquo", "reg",
"sect", "shy", "sup1", "sup2", "sup3", "szlig", "times", "uacute", "ucirc", "ugrave", "uuml", "yacute", "yen", "yuml",
];
/// A reference written without its `;` at the start of `after` (the text after `&`): a numeric one,
/// or the longest legacy name. In attribute values a name followed by `=` or an alphanumeric stays text.
fn bare_reference(after: &str, attr: bool) -> Option<(char, usize)> {
if let Some(num) = after.strip_prefix('#') {
let hex = num.starts_with(['x', 'X']);
let radix = if hex { 16 } else { 10 };
let skip = usize::from(hex);
let n = num.get(skip..)?.bytes().take_while(|b| char::from(*b).is_digit(radix)).count();
if n == 0 {
return None;
}
let len = 1 + skip + n;
return decode_char(after.get(..len)?).map(|c| (c, len));
}
// Legacy names are 2 to 6 ASCII alphanumerics: try the longest prefix first.
let run = after.bytes().take(6).take_while(u8::is_ascii_alphanumeric).count();
let len = (2..=run).rev().find(|&l| after.get(..l).is_some_and(|p| BARE_NAMES.binary_search(&p).is_ok()))?;
let next = after.as_bytes().get(len);
if attr && next.is_some_and(|b| b.is_ascii_alphanumeric() || *b == b'=') {
return None;
}
decode_char(after.get(..len)?).map(|c| (c, len))
}
/// Replace entity references in text `s`.
/// Replace entity references in `s`.
pub fn unescape(s: &str) -> String {
unescape_in(s, false)
}
/// Replace entity references in an attribute value `s`.
pub fn unescape_attr(s: &str) -> String {
unescape_in(s, true)
}
fn unescape_in(s: &str, attr: bool) -> String {
if !s.contains('&') {
return s.to_string();
}
@@ -295,10 +248,10 @@ fn unescape_in(s: &str, attr: bool) -> String {
cut -= 1;
}
let end = after.get(..cut).and_then(|w| w.find(';'));
match end.and_then(|e| decode_char(&after[..e]).map(|c| (c, e + 1))).or_else(|| bare_reference(after, attr)) {
Some((c, e)) => {
out.push(c);
rest = &after[e..];
match end.and_then(|e| decode_entity(&after[..e]).map(|t| (t, e))) {
Some((t, e)) => {
out.push_str(&t);
rest = &after[e + 1..];
}
None => {
out.push('&');
@@ -357,14 +310,3 @@ pub fn decode_as(b: &[u8], charset: Option<&str>) -> String {
},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bare_names_are_sorted_and_known() {
assert!(BARE_NAMES.windows(2).all(|w| w[0] < w[1]));
assert!(BARE_NAMES.iter().all(|n| (2..=6).contains(&n.len()) && decode_char(n).is_some()));
}
}
+3 -3
View File
@@ -1,4 +1,4 @@
use super::entity::{unescape, unescape_attr};
use super::entity::unescape;
// ---------------------------------------------------------------------------------------------
// Tokenizer
@@ -154,7 +154,7 @@ impl Lexer<'_> {
Some(&q) if q == b'"' || q == b'\'' => {
let v0 = i + 1;
let e = rest.get(v0..).and_then(|r| r.find(q as char)).map(|k| v0 + k).unwrap_or(rest.len());
val = unescape_attr(rest.get(v0..e).unwrap_or(""));
val = unescape(rest.get(v0..e).unwrap_or(""));
i = e + 1;
}
_ => {
@@ -162,7 +162,7 @@ impl Lexer<'_> {
while b.get(i).is_some_and(|c| !c.is_ascii_whitespace() && *c != b'>') {
i += 1;
}
val = unescape_attr(rest.get(v0..i).unwrap_or(""));
val = unescape(rest.get(v0..i).unwrap_or(""));
}
}
}
-15
View File
@@ -232,21 +232,6 @@ fn entities() {
assert_eq!(unescape("&theta; &lambda; &Delta; &nabla; &sum; &radic; &ne; &le; &ge; &hearts;"), "θ λ Δ ∇ ∑ √ ≠ ≤ ≥ ♥");
}
/// Browsers decode numeric and legacy named references without their `;` in text,
/// but keep `&name=` and `&nameX` intact inside attribute values (query strings).
#[test]
fn references_without_semicolon() {
assert_eq!(unescape("&copy 2024 Acme"), "© 2024 Acme");
assert_eq!(unescape("&#169 2024 Acme"), "© 2024 Acme");
assert_eq!(unescape("&#xA9 2024 &amp &lt"), "© 2024 & <");
assert_eq!(unescape("&bogus &# &#x &"), "&bogus &# &#x &");
assert_eq!(unescape("&nbsp;x"), "\u{00A0}x");
let f = parse("<p><a href=\"/q?a=1&copy=2&reg2=3&amp;b=&lt\">l</a> &copy 2024</p>");
let FBlock::Para(p) = &f.blocks[0] else { panic!() };
assert!(p.inlines.iter().any(|i| matches!(i, Inline::Text(t, f) if t == "l" && f.link.as_deref() == Some("/q?a=1&copy=2&reg2=3&b=<"))));
assert!(p.text().ends_with("© 2024"));
}
#[test]
fn wbr_word_break_opportunity() {
let f = parse("<p>Super<wbr>cali<wbr>fragilistic</p>");
+2 -14
View File
@@ -330,18 +330,6 @@ pub fn export(doc: &Document) -> String {
s
}
/// A heading's text on one line, with a trailing run of '#' escaped so a reader does not take it
/// for the optional closing sequence (`## Item #` reads back as "Item").
fn heading_text(body: &str) -> String {
let mut s = body.replace("\\\n", " ");
let t = s.trim_end();
let core = t.trim_end_matches('#');
if core.len() != t.len() && (core.is_empty() || core.ends_with([' ', '\t'])) {
s.insert(core.len(), '\\');
}
s
}
pub(super) fn write_blocks(blocks: &[FBlock], out: &mut Vec<String>) {
let mut i = 0;
let mut counters = [0u32; 9];
@@ -422,8 +410,8 @@ pub(super) fn write_blocks(blocks: &[FBlock], out: &mut Vec<String>) {
None => {
prev_list = false;
let line = match p.kind {
Kind::Heading(n) => format!("{} {}", "#".repeat(n.clamp(1, 6) as usize), heading_text(&body)),
Kind::Title => format!("# {}", heading_text(&body)),
Kind::Heading(n) => format!("{} {}", "#".repeat(n.clamp(1, 6) as usize), body.replace("\\\n", " ")),
Kind::Title => format!("# {}", body.replace("\\\n", " ")),
Kind::Rule => "---".to_string(),
Kind::Quote => {
let b = escape_lines(body);
-22
View File
@@ -775,25 +775,3 @@ fn exported_link_destinations_read_back_unchanged() {
assert_eq!(md("https://example.com/a b"), "[x](<https://example.com/a b>)");
assert_eq!(md("https://e.com/x\ny"), "[x](https://e.com/xy)");
}
#[test]
fn heading_trailing_hash_survives_round_trip() {
let heading = |md: &str| {
let f = parse(md);
let Some(FBlock::Para(p)) = f.blocks.first() else { panic!("no paragraph in {md:?}") };
assert_eq!(p.kind, Kind::Heading(2), "{md:?}");
p.text()
};
for (src, text, want) in [
("## Item \\#\n", "Item #", "## Item \\#\n"),
("## Issue \\##\n", "Issue ##", "## Issue \\##\n"),
("## \\#\n", "#", "## \\#\n"),
("## C#\n", "C#", "## C#\n"),
] {
assert_eq!(heading(src), text, "{src:?}");
let md = export(&import(src));
assert_eq!(md, want, "first export of {src:?}");
assert_eq!(heading(&md), text, "md was {md:?}");
assert_eq!(export(&import(&md)), want, "second export of {src:?}");
}
}
-1
View File
@@ -1122,7 +1122,6 @@ fn flow_table(doc: &Document, t: &Table, depth: usize, tracked: FlowOpts) -> FTa
width: t.props.width,
width_pct: t.props.width_pct,
heights,
keep_rows: t.rows.iter().map(|r| r.props.cant_split).collect(),
fixed: t.props.fixed,
space: [t.props.space_before, t.props.space_after],
header_rows: t.rows.iter().take_while(|r| r.props.header).count(),
-1
View File
@@ -844,7 +844,6 @@ impl<'a> Builder<'a> {
row.props.height = Some(h.min(1584.0));
row.props.height_rule = if exact { wordcraft_doc::props::HeightRule::Exact } else { wordcraft_doc::props::HeightRule::AtLeast };
}
row.props.cant_split = t.extra.as_ref().is_some_and(|x| x.keep_rows.get(ri) == Some(&true));
let mut g = 0usize;
for c in r.iter().take(wordcraft_doc::table::MAX_COLS) {
let span = (c.colspan.clamp(1, 63) as usize).min(cols.saturating_sub(g).max(1));
-2
View File
@@ -943,8 +943,6 @@ pub struct TableExtra {
pub width_pct: Option<f32>,
/// Each row's height (points) and whether it is exact (else at least), by row.
pub heights: Vec<Option<(f32, bool)>>,
/// Whether each row keeps from breaking across pages, by row.
pub keep_rows: Vec<bool>,
/// Its columns keep their widths, never fitted to their content (Word's fixed layout,
/// `w:tblLayout`): an ODT table's always do.
pub fixed: bool,
+9 -58
View File
@@ -79,10 +79,6 @@ pub(super) struct Body<'a> {
/// drawings were waiting before it.
loose_open: Option<(usize, usize, usize)>,
fmt: Vec<Fmt>,
/// For each paragraph being read: the size its heading, title or code style gives its text.
para_sizes: Vec<(Option<f32>, bool)>,
/// That size for the paragraph being finished.
para_size: Option<f32>,
para: Option<Para>,
/// The text read last ended in a space collapsed from XML whitespace (not a `text:s`).
xml_space_end: bool,
@@ -132,9 +128,6 @@ pub(super) struct Body<'a> {
paras_done: usize,
/// `(width, height, picture bytes, alt text, equation read from an embedded `draw:object`)`.
frame: Option<Frame>,
/// The first picture the open frame offers that WordCraft can't show: an object coming after it
/// keeps it as its replacement.
frame_unread: Option<Vec<u8>>,
in_desc: bool,
/// The master pages' page setups.
pages: &'a Pages,
@@ -244,8 +237,6 @@ impl<'a> Body<'a> {
xml_space_end: false,
spaces_left: MAX_EXTRA_SPACES,
para_cont: false,
para_sizes: Vec::new(),
para_size: None,
lists: Vec::new(),
lists_saved: Vec::new(),
item_used: Vec::new(),
@@ -268,7 +259,6 @@ impl<'a> Body<'a> {
pending_master: None,
paras_done: 0,
frame: None,
frame_unread: None,
in_desc: false,
pages,
sec: page.clone(),
@@ -962,8 +952,6 @@ impl Body<'_> {
} else {
self.para_cont = false;
}
let kind_size = matches!(kind, Kind::Code | Kind::Heading(_) | Kind::Title).then(|| style.and_then(|s| self.styles.named_size(s))).flatten();
self.para_sizes.push((kind_size, style.is_some_and(|s| self.styles.states_size(s))));
self.fmt.push(base);
// Drawings waiting outside a paragraph come first in it, when it is the text's own (not
// a text box's, a note's or a cell's).
@@ -982,7 +970,6 @@ impl Body<'_> {
self.index_marks.clear();
let Some(mut p) = self.para.take() else { return };
self.glue_connectors(&mut p);
self.para_size = self.para_sizes.pop().and_then(|(size, stated)| size.filter(|_| stated));
// A paragraph without text has its own text formatting as its mark's: its size is its
// height, and its drawings' runs have it.
if let Some(base) = self.fmt.pop()
@@ -1033,18 +1020,13 @@ impl Body<'_> {
p.kind = Kind::Normal;
}
if matches!(p.kind, Kind::Code | Kind::Heading(_) | Kind::Title) {
// The style says the size of its text, stated again or not: text of another size keeps
// its own.
let style_size = self.para_size;
for i in &mut p.inlines {
if let Inline::Text(_, f) = i {
if p.kind == Kind::Code {
f.code = false;
f.font = None;
}
if f.size == style_size {
f.size = None;
}
f.size = None;
}
}
}
@@ -1341,7 +1323,6 @@ impl Body<'_> {
let w = get(a, "svg:width").and_then(length);
let h = get(a, "svg:height").and_then(length);
self.frame = Some((w, h, None, String::new(), None, None, None));
self.frame_unread = None;
let g = get(a, "draw:style-name").map(|s| self.styles.graphic(s)).unwrap_or_default();
self.frame_float = Some(super::frames::place(a, w.unwrap_or(0.0), h.unwrap_or(0.0), &g, self.text_size()));
self.member_start(a, w.unwrap_or(0.0), h.unwrap_or(0.0), None);
@@ -1391,23 +1372,19 @@ impl Body<'_> {
// LibreOffice's tables keep their columns' widths, whatever their content (and however
// far past the margins): Word's fixed layout.
let space = get(a, "table:style-name").map(|s| self.styles.table_space(s)).unwrap_or_default();
let extra =
model::TableExtra { align, width: None, width_pct, heights: Vec::new(), keep_rows: Vec::new(), fixed: true, space, header_rows: 0 };
let extra = model::TableExtra { align, width: None, width_pct, heights: Vec::new(), fixed: true, space, header_rows: 0 };
self.tables.push(TableB { rows: Vec::new(), row: Vec::new(), hcover: 0, widths: Vec::new(), extra });
}
/// A table row (`table:table-row`) begins: its height, kept as rows are (see the row's end).
fn start_row(&mut self, a: &Attrs) {
let height = get(a, "table:style-name").and_then(|s| self.styles.row_height(s));
let keep = self.styles.row_keep(get(a, "table:style-name"));
if let Some(t) = self.tables.last_mut() {
t.row.clear();
t.hcover = 0;
if t.extra.heights.len() < wordcraft_doc::table::MAX_ROWS {
t.extra.heights.truncate(t.rows.len());
t.extra.heights.push(height);
t.extra.keep_rows.truncate(t.rows.len());
t.extra.keep_rows.push(keep);
}
}
}
@@ -1452,25 +1429,12 @@ impl Body<'_> {
&& fr.2.is_none()
&& fr.4.is_none()
&& fr.5.is_none()
&& fr.6.as_ref().is_none_or(|o| o.replacement.is_none())
{
let path = href.trim_start_matches("./");
// A frame may offer one picture in several formats, in order of preference
// (LibreOffice writes its own `.svm` metafile before a PNG): the first
// WordCraft can read. An object's replacement is the first, kept whatever it
// is, also when the object is still to come.
if let Some(d) = (self.zip)(path) {
let readable = drawing::readable_picture(&d);
match &mut fr.6 {
Some(o) if o.replacement.is_none() => o.replacement = Some(std::sync::Arc::new(d.clone())),
Some(_) => {}
None if !readable => {
self.frame_unread.get_or_insert_with(|| d.clone());
}
None => {}
}
if readable {
fr.2 = Some(d);
}
fr.2 = (self.zip)(path);
if let Some(o) = &mut fr.6 {
o.replacement = fr.2.clone().map(std::sync::Arc::new);
}
}
}
@@ -1514,9 +1478,9 @@ impl Body<'_> {
// A chart is kept whole too, to be written back as it was while it is unchanged.
if fr.4.is_none() {
fr.6 = super::objects::gather(href, ole, self.entries, self.manifest, &mut *self.zip);
// A replacement picture read before the object: the first the frame offered.
// A replacement picture read before the object.
if let Some(o) = &mut fr.6 {
o.replacement = self.frame_unread.take().or_else(|| fr.2.clone()).map(std::sync::Arc::new);
o.replacement = fr.2.clone().map(std::sync::Arc::new);
}
}
}
@@ -1562,20 +1526,7 @@ impl Body<'_> {
match local(q) {
"span" => {
let base = self.cur_fmt();
// A size in percent is of the size it is in: its heading's, where nothing nearer
// states one.
let sized = Fmt { size: base.size.or(self.para_sizes.last().and_then(|(size, _)| *size)), ..base.clone() };
let fmt = get(a, "text:style-name").map_or_else(
|| base.clone(),
|s| {
let mut fmt = self.styles.span_fmt(s, &sized);
// That size is only for working out a percent: saying nothing of size, it has none.
if base.size.is_none() && self.styles.span_fmt(s, &base).size.is_none() {
fmt.size = None;
}
fmt
},
);
let fmt = get(a, "text:style-name").map_or_else(|| base.clone(), |s| self.styles.span_fmt(s, &base));
self.fmt.push(fmt);
}
"a" => {
-5
View File
@@ -385,11 +385,6 @@ impl Body<'_> {
}
}
/// Whether WordCraft can show these bytes as a picture (see the function below).
pub(in crate::odt) fn readable_picture(data: &[u8]) -> bool {
wordcraft_metafile::is_metafile(data) || model::sniff_image(data).is_some()
}
/// A picture from a package's bytes: a raster image, or a Windows metafile (WMF, EMF: LibreOffice
/// keeps clip art and logos so), sized `w` × `h` (points) when the frame says.
pub(in crate::odt) fn picture(data: Vec<u8>, w: Option<f32>, h: Option<f32>, alt: &str) -> Option<model::Img> {
+1 -5
View File
@@ -87,11 +87,7 @@ impl TableB {
let stated = rows.iter().flatten().filter(|c| !c.covered).all(|c| c.borders.is_some());
let borderless = stated && rows.iter().flatten().all(|c| !c.borders.is_some_and(|b| b.any_visible()));
let extra = self.extra;
let kept = extra.fixed
|| extra.align.is_some()
|| extra.width_pct.is_some()
|| extra.heights.iter().any(Option::is_some)
|| extra.keep_rows.contains(&true);
let kept = extra.fixed || extra.align.is_some() || extra.width_pct.is_some() || extra.heights.iter().any(Option::is_some);
FTable { rows, widths, borderless, extra: kept.then(|| Box::new(extra)), kept: None, wrap: None }
}
}
+8 -42
View File
@@ -27,7 +27,6 @@ pub(super) fn defs(xml: &[u8], content: bool) -> Vec<(String, Arc<str>)> {
let mut path: Vec<String> = Vec::new();
let mut open: Option<(String, usize, usize)> = None;
let mut before_1_3 = false;
let mut prefixes = std::collections::BTreeSet::new();
events_at(xml, |ev, at| match ev {
Ev::Start(q, a) => {
path.push(q.clone());
@@ -40,23 +39,11 @@ pub(super) fn defs(xml: &[u8], content: bool) -> Vec<(String, Arc<str>)> {
if let Some(p) = k.strip_prefix("xmlns:").filter(|p| !p.is_empty() && p.len() <= 64)
&& out.len() < MAX_DEFS
{
prefixes.insert(p.to_string());
out.push((format!("ns:{p}"), Arc::from(v.as_str())));
}
}
return;
}
// Those a part's container declares too: what is kept of it leaves the container behind.
if depth == 2 {
for (k, v) in &a {
if let Some(p) = k.strip_prefix("xmlns:").filter(|p| !p.is_empty() && p.len() <= 64)
&& out.len() < MAX_DEFS
&& prefixes.insert(p.to_string())
{
out.push((format!("ns:{p}"), Arc::from(v.as_str())));
}
}
}
// A reference mark (§6.6.2), which `text:reference-ref` names: WordCraft reads it as a
// bookmark, and writes it as both.
if content
@@ -196,41 +183,20 @@ fn space(q: &str, family: &str) -> String {
pub(super) fn defined(xml: &str, def: &str) -> bool {
let head = def.split('>').next().unwrap_or("");
let el = head.trim_start_matches('<').split([' ', '/', '\t', '\n', '\r']).next().unwrap_or("");
let name = ["style:name", "draw:name"].into_iter().find_map(|a| Some((a, raw_attr(head, a)?)));
let (Some((attr, name)), false) = (name, el.is_empty()) else { return false };
let name = ["style:name=\"", "draw:name=\""].into_iter().find_map(|a| {
let at = head.find(a)? + a.len();
let len = head.get(at..)?.find('"')?;
head.get(at - a.len()..at + len + 1)
});
let (Some(name), false) = (name, el.is_empty()) else { return false };
let open = format!("<{el} ");
xml.match_indices(&open).any(|(i, _)| {
xml.get(i..).and_then(|t| t.split('>').next()).is_some_and(|tag| raw_attr(tag, attr).is_some_and(|n| attr_text(n) == attr_text(name)))
})
}
/// An attribute's value as it reads, however XML spells it (`O'Brien`, `O&apos;Brien`).
pub(super) fn attr_text(raw: &str) -> std::borrow::Cow<'_, str> {
quick_xml::escape::unescape(raw).unwrap_or(std::borrow::Cow::Borrowed(raw))
}
/// Attribute `name`'s value as `tag` (a start tag) writes it, spaced and quoted however XML allows.
pub(super) fn raw_attr<'a>(tag: &'a str, name: &str) -> Option<&'a str> {
let past_space = |at: usize| Some(at + tag.get(at..)?.find(|c: char| !c.is_ascii_whitespace())?);
let mut at = tag.find(|c: char| c.is_ascii_whitespace())?;
loop {
let name_at = past_space(at)?;
let eq = name_at + tag.get(name_at..)?.find('=')?;
let value_at = past_space(eq + 1)?;
let rest = tag.get(value_at..)?;
let quote = rest.chars().next().filter(|c| matches!(c, '"' | '\''))?;
let len = rest.get(1..)?.find(quote)?;
if tag.get(name_at..eq)?.trim_end() == name {
return rest.get(1..1 + len);
}
at = value_at + 1 + len + 1;
}
xml.match_indices(&open).any(|(i, _)| xml.get(i..).and_then(|t| t.split('>').next()).is_some_and(|tag| tag.contains(name)))
}
/// The children of `container` (a child of the root) in `xml` (a part WordCraft wrote): each
/// one's key in `group` (as [`defs`] gives it) and its markup's byte range; and the range of
/// the container's content (`None` when there is no such container).
pub(super) fn children(xml: &str, container: &str, group: &str) -> (Vec<(String, (usize, usize))>, Option<(usize, usize)>) {
fn children(xml: &str, container: &str, group: &str) -> (Vec<(String, (usize, usize))>, Option<(usize, usize)>) {
let mut out = Vec::new();
let (mut depth, mut inside) = (0usize, false);
let mut open: Option<(String, usize)> = None;
-53
View File
@@ -900,11 +900,6 @@ fn with_kept_defs(
};
add(&mut content, "</office:automatic-styles>", auto);
add(&mut styles, "</office:styles>", common);
// A font the file declares is declared as the file does: a reader without the font picks its
// substitute by this, and a different one would reflow every page.
let faces: Vec<&str> = defs.iter().filter(|(k, _)| k.starts_with("font:")).map(|(_, d)| &**d).collect();
with_file_faces(&mut content, &faces);
with_file_faces(&mut styles, &faces);
for (xml, before) in [(&mut content, "<office:automatic-styles"), (&mut styles, "<office:styles")] {
// No declarations yet: the element they go in, where it belongs (before the styles).
if !fonts.is_empty()
@@ -918,54 +913,6 @@ fn with_kept_defs(
(content, styles)
}
/// `xml` (a part WordCraft wrote) with each font it declares that `faces` declare too (the file's
/// own `style:font-face` elements) as `faces` has it: under that name, or else of that family (a
/// face named `F1` of the family `Mystery`, which WordCraft names by its family), then keeping
/// WordCraft's name for it.
fn with_file_faces(xml: &mut String, faces: &[&str]) {
use super::kept::{attr_text, raw_attr};
let mut named = std::collections::HashMap::new();
let mut of_family = std::collections::HashMap::new();
for face in faces {
let Some(head) = face.split('>').next() else { continue };
if let Some(name) = raw_attr(head, "style:name") {
named.entry(attr_text(name).into_owned()).or_insert(*face);
}
if let Some(family) = raw_attr(head, "svg:font-family") {
let family = attr_text(family);
let first = family.split(',').next().unwrap_or("").trim().trim_matches(['\'', '"']);
if let Some(font) = model::kept_font(first) {
of_family.entry(font).or_insert(*face);
}
}
}
// WordCraft's own declarations are empty elements.
let (kids, _) = super::kept::children(xml, "office:font-face-decls", "font");
let mut out = String::with_capacity(xml.len());
let mut done = 0;
for (_, (from, to)) in kids {
let (Some(before), Some(tag)) = (xml.get(done..from), xml.get(from..to)) else { continue };
out.push_str(before);
let name = raw_attr(tag, "style:name").map(attr_text);
let same = name.as_deref().and_then(|n| named.get(n)).map(|face| face.to_string());
let family = || name.as_deref().and_then(|n| of_family.get(n).and_then(|face| face_named(face, n)));
let empty = tag.find('>').is_some_and(|end| end + 1 == tag.len());
out.push_str(same.or_else(family).filter(|_| empty).as_deref().unwrap_or(tag));
done = to;
}
out.push_str(xml.get(done..).unwrap_or(""));
*xml = out;
}
/// `face` (a `style:font-face` element) with the name `name`.
fn face_named(face: &str, name: &str) -> Option<String> {
let head = face.split('>').next()?;
let was = super::kept::raw_attr(head, "style:name")?;
// `was` is a piece of `head`, which starts `face`.
let at = (was.as_ptr() as usize).checked_sub(head.as_ptr() as usize)?;
Some(format!("{}{}{}", face.get(..at)?, x(name), face.get(at + was.len()..)?))
}
/// An OpenDocument text document's media type, and a template's (ODF 1.3 Part 2 §3.3, Appendix C).
pub const TEXT_MIME: &str = "application/vnd.oasis.opendocument.text";
pub const TEMPLATE_MIME: &str = "application/vnd.oasis.opendocument.text-template";
+1 -41
View File
@@ -252,8 +252,6 @@ struct StyleInfo {
/// A table-row style's `style:table-row-properties`: its height (`style:row-height`, exact)
/// or least height (`style:min-row-height`), points.
row_height: Option<(f32, bool)>,
/// `fo:keep-together` of a table-row style: the row doesn't break across pages.
row_keep: Option<bool>,
/// `style:master-page-name` of a paragraph or table style: its paragraphs (tables) start a
/// page of that master page. Empty: explicitly none, which stops the lookup up the chain.
master_page: Option<String>,
@@ -625,9 +623,6 @@ fn style_child(name: &str, q: &str, a: &Attrs, s: &mut StyleInfo) -> bool {
} else if let Some(h) = get(a, "style:min-row-height").and_then(length).filter(|h| h.is_finite() && *h > 0.0) {
s.row_height = Some((h.min(MAX_LEN), false));
}
if let Some(k) = get(a, "fo:keep-together") {
s.row_keep = Some(k.trim() == "always");
}
}
"table-column-properties" => {
if let Some(w) = get(a, "style:column-width").and_then(length) {
@@ -1048,8 +1043,6 @@ pub(super) struct Styles {
default_color: Option<wordcraft_doc::props::Rgb>,
/// `style:default-style` for graphics: what every drawing shape's style falls back on.
default_graphic: super::frames::Graphic,
/// `style:default-style` for table rows: `fo:keep-together`, for a row whose style doesn't say.
default_row_keep: Option<bool>,
/// The named dashes and gradients graphic styles use.
pub(super) defs: super::strokes::Defs,
/// Date and time styles (`number:date-style`, `number:time-style`) as Word pictures.
@@ -1062,7 +1055,6 @@ impl Styles {
let mut cur: Option<(String, StyleInfo)> = None;
let mut in_default = false;
let mut in_graphic_default = false;
let mut in_row_default = false;
let mut dates = DateStyles::default();
let mut list: Option<(String, Vec<ListLevel>)> = None;
// The list or outline level being read.
@@ -1091,12 +1083,8 @@ impl Styles {
"default-style" if q.starts_with("style:") => {
in_default = get(&a, "style:family") == Some("paragraph");
in_graphic_default = get(&a, "style:family") == Some("graphic");
in_row_default = get(&a, "style:family") == Some("table-row");
}
"graphic-properties" if in_graphic_default && cur.is_none() => self.default_graphic = super::frames::Graphic::read(&a),
"table-row-properties" if in_row_default && cur.is_none() => {
self.default_row_keep = get(&a, "fo:keep-together").map(|k| k.trim() == "always")
}
"text-properties" if in_default && cur.is_none() => {
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) {
@@ -1163,7 +1151,7 @@ impl Styles {
},
Ev::End(q) => match local(&q) {
"automatic-styles" => in_auto = false,
"default-style" if q.starts_with("style:") => (in_default, in_graphic_default, in_row_default) = (false, false, false),
"default-style" if q.starts_with("style:") => (in_default, in_graphic_default) = (false, false),
"style" if q.starts_with("style:") => {
if let Some((n, s)) = cur.take()
&& self.map.len() < 100_000
@@ -1275,24 +1263,6 @@ impl Styles {
/// The formatting of a span in text style `name` over `base`: the automatic styles' over the
/// nearest custom character style, which the span then names ([`Fmt::extra`]'s style, by ODF
/// name until import makes it a document style id); without one, the whole chain's.
/// The size of text in paragraph style `name` by its named styles, not its automatic ones,
/// which are the paragraph's own: what they say on what the document says.
pub(super) fn named_size(&self, name: &str) -> Option<f32> {
// The default style's: Standard's counts only for a style based on it, in the chain.
let size = self.default_size.filter(|v| v.is_finite() && *v >= 1.0).map(|v| v.min(1638.0));
let mut f = Fmt { size, ..Fmt::default() };
for (_, s) in self.chain(name).iter().rev().filter(|(_, s)| !s.auto) {
s.fmt.apply(&mut f);
}
f.size
}
/// Whether paragraph style `name`'s named styles state a size. Those that don't leave the size
/// to WordCraft's style for its kind, so text of any size isn't known to be the style's.
pub(super) fn states_size(&self, name: &str) -> bool {
self.chain(name).iter().any(|(_, s)| !s.auto && (s.fmt.size.is_some() || s.fmt.size_pct.is_some()))
}
pub(super) fn span_fmt(&self, name: &str, base: &Fmt) -> Fmt {
let chain = self.chain(name);
let Some(at) = chain.iter().position(|(n, _)| self.is_named_text(n)) else { return self.text_fmt(name, base) };
@@ -1367,12 +1337,6 @@ impl Styles {
self.chain(name).iter().find_map(|(_, s)| s.row_height)
}
/// Whether a table row doesn't break across pages: its style says (whole chain), else the
/// default style.
pub(super) fn row_keep(&self, name: Option<&str>) -> bool {
name.and_then(|n| self.chain(n).iter().find_map(|(_, s)| s.row_keep)).or(self.default_row_keep).unwrap_or(false)
}
/// Background colour of a table-cell style (whole chain).
/// A table-cell style's background picture: its package path, repeat and position.
pub(super) fn cell_picture(&self, name: &str) -> Option<(String, String, String)> {
@@ -1498,10 +1462,6 @@ impl Styles {
};
let mut fmt = Fmt::default();
s.fmt.apply(&mut fmt);
// A percent is of what's under it: the parent's size, or with none the default style's.
if s.fmt.size_pct.is_some() {
fmt.size = self.named_size(odt);
}
// A style with no parent inherits the default style, not Standard (the document's
// defaults here): its size, when Standard's differs.
if s.parent.is_none() && fmt.size.is_none() && self.doc_size() != self.default_size {
-123
View File
@@ -284,126 +284,3 @@ fn objects_in_hidden_text_are_kept() {
assert!(p.text.contains("Hidden"), "{:?}", p.text);
assert!(p.objects.iter().any(|o| matches!(o, wordcraft_doc::para::InlineObject::BookmarkStart { name } if name == "Secret")), "{:?}", p.objects);
}
#[test]
fn a_headings_text_of_another_size_keeps_it() {
// Only the size its style gives goes with the style: a smaller word in a heading came back
// from a save at the heading's size.
let styles = format!(
r#"<office:document-styles {NS_T}><office:styles><style:style style:name="Heading_20_1" style:display-name="Heading 1" style:family="paragraph" style:default-outline-level="1"><style:text-properties fo:font-size="16pt"/></style:style></office:styles></office:document-styles>"#
);
let content = format!(
r#"<office:document-content {NS_T}><office:automatic-styles><style:style style:name="T1" style:family="text"><style:text-properties fo:font-size="10pt"/></style:style></office:automatic-styles><office:body><office:text><text:h text:style-name="Heading_20_1" text:outline-level="1">big<text:span text:style-name="T1">small</text:span></text:h></office:text></office:body></office:document-content>"#
);
let check = |d: &Document, what: &str| {
let p = d.body[0].as_para().unwrap();
let size = |run: usize| d.styles.resolve_char(p.props.style.as_deref(), &p.runs[run].props).size;
assert_eq!((size(0), size(1)), (16.0, 10.0), "{what}");
};
let d = import(&package(&content, Some(&styles))).unwrap();
check(&d, "read");
assert_eq!(d.body[0].as_para().unwrap().runs[0].props.size, None, "the style's stays the style's");
check(&import(&crate::odt::tests::export_model(&d).unwrap()).unwrap(), "written from the model");
}
/// In a heading style with no parent, where Standard's size and the default's differ, text stating
/// a size keeps that size.
#[test]
fn a_parentless_headings_text_keeps_its_size() {
let styles = format!(
r#"<office:document-styles {NS_T}><office:styles><style:default-style style:family="paragraph"><style:text-properties fo:font-size="10pt"/></style:default-style><style:style style:name="Standard" style:family="paragraph" style:class="text"><style:text-properties fo:font-size="14pt"/></style:style><style:style style:name="H" style:family="paragraph" style:default-outline-level="1"/></office:styles></office:document-styles>"#
);
for size in ["10pt", "14pt"] {
let content = format!(
r#"<office:document-content {NS_T}><office:automatic-styles><style:style style:name="T1" style:family="text"><style:text-properties fo:font-size="{size}"/></style:style><style:style style:name="T2" style:family="text"><style:text-properties fo:font-weight="bold"/></style:style></office:automatic-styles><office:body><office:text><text:h text:style-name="H" text:outline-level="1"><text:span text:style-name="T1">One</text:span><text:span text:style-name="T2">Two</text:span></text:h></office:text></office:body></office:document-content>"#
);
let want: f32 = size.trim_end_matches("pt").parse().unwrap();
let check = |d: &Document, what: &str| {
let p = d.body[0].as_para().unwrap();
assert_eq!(p.runs[1].props.size, None, "{size} {what}: a span stating no size has none");
assert_eq!(
d.styles.resolve_char(p.props.style.as_deref(), &p.runs[0].props).size,
want,
"{size} {what}: {:?} {:?}",
p.props.style,
p.runs[0].props.size
);
};
let d = import(&package(&content, Some(&styles))).unwrap();
check(&d, "read");
check(&import(&crate::odt::tests::export_model(&d).unwrap()).unwrap(), "written from the model");
}
}
/// A percent in a heading style with no parent is of the default style's size, not Standard's.
#[test]
fn a_percent_in_a_parentless_heading_is_of_the_default_size() {
let styles = format!(
r#"<office:document-styles {NS_T}><office:styles><style:default-style style:family="paragraph"><style:text-properties fo:font-size="10pt"/></style:default-style><style:style style:name="Standard" style:family="paragraph" style:class="text"><style:text-properties fo:font-size="14pt"/></style:style><style:style style:name="H" style:family="paragraph" style:default-outline-level="1"/></office:styles></office:document-styles>"#
);
let content = format!(
r#"<office:document-content {NS_T}><office:automatic-styles><style:style style:name="T1" style:family="text"><style:text-properties fo:font-size="50%"/></style:style></office:automatic-styles><office:body><office:text><text:h text:style-name="H" text:outline-level="1"><text:span text:style-name="T1">One</text:span></text:h></office:text></office:body></office:document-content>"#
);
let d = import(&package(&content, Some(&styles))).unwrap();
assert_eq!(d.body[0].as_para().unwrap().runs[0].props.size, Some(5.0));
}
/// A heading style with no parent and a percent size is that percent of the default's size, so text
/// stating that size is at the style's size.
#[test]
fn a_parentless_heading_style_in_percent_is_of_the_default_size() {
let styles = format!(
r#"<office:document-styles {NS_T}><office:styles><style:default-style style:family="paragraph"><style:text-properties fo:font-size="10pt"/></style:default-style><style:style style:name="H" style:family="paragraph"><style:text-properties fo:font-size="50%"/></style:style></office:styles></office:document-styles>"#
);
let content = format!(
r#"<office:document-content {NS_T}><office:automatic-styles><style:style style:name="T1" style:family="text"><style:text-properties fo:font-size="5pt"/></style:style></office:automatic-styles><office:body><office:text><text:h text:style-name="H" text:outline-level="1"><text:span text:style-name="T1">One</text:span>Two</text:h></office:text></office:body></office:document-content>"#
);
let d = import(&package(&content, Some(&styles))).unwrap();
let p = d.body[0].as_para().unwrap();
for r in &p.runs {
assert_eq!(d.styles.resolve_char(p.props.style.as_deref(), &r.props).size, 5.0, "{:?} {:?}", p.props.style, r.props.size);
}
}
/// Text at its heading's size goes with the heading style, stated or not, and a size in percent
/// is of the heading's size; text of another size keeps it, and so does a paragraph's own.
#[test]
fn a_headings_text_sizes_are_measured_against_the_heading() {
let styles = format!(
r#"<office:document-styles {NS_T}><office:styles><style:style style:name="Heading" style:family="paragraph"><style:text-properties fo:font-size="20pt"/></style:style><style:style style:name="Heading_20_1" style:display-name="Heading 1" style:family="paragraph" style:parent-style-name="Heading" style:default-outline-level="1"><style:text-properties fo:font-size="80%"/></style:style></office:styles></office:document-styles>"#
);
let span = |name: &str, size: &str| {
format!(r#"<style:style style:name="{name}" style:family="text"><style:text-properties fo:font-size="{size}"/></style:style>"#)
};
let content = format!(
r#"<office:document-content {NS_T}><office:automatic-styles>{}{}{}<style:style style:name="P1" style:family="paragraph" style:parent-style-name="Heading_20_1"><style:text-properties fo:font-size="30pt"/></style:style></office:automatic-styles><office:body><office:text><text:h text:style-name="Heading_20_1" text:outline-level="1">plain <text:span text:style-name="Same">same</text:span> <text:span text:style-name="Pct">pct</text:span> <text:span text:style-name="Other">other</text:span></text:h><text:h text:style-name="P1" text:outline-level="1">own</text:h></office:text></office:body></office:document-content>"#,
span("Same", "16pt"),
span("Pct", "50%"),
span("Other", "10pt"),
);
let mut d = import(&package(&content, Some(&styles))).unwrap();
let sizes = |d: &Document| {
let p = d.body[0].as_para().unwrap();
let of = |word: &str| {
let at = p.text.find(word).unwrap();
let mut from = 0;
let run = p.runs.iter().find(|r| {
from += r.len;
at < from
});
d.styles.resolve_char(p.props.style.as_deref(), &run.unwrap().props).size
};
[of("plain"), of("same"), of("pct"), of("other")]
};
let want = |t: f32| [t, t, 8.0, 10.0];
let plain = |d: &Document| d.body[0].as_para().unwrap().runs.iter().filter(|r| r.props.size.is_some()).count();
assert_eq!(sizes(&d), want(16.0));
assert_eq!(plain(&d), 2, "only the two of another size state one");
// The paragraph's own size stays with its text.
let own = d.body[1].as_para().unwrap();
assert_eq!(d.styles.resolve_char(own.props.style.as_deref(), &own.runs[0].props).size, 30.0);
// A change to the style reaches what goes with it.
let id = d.body[0].as_para().unwrap().props.style.clone().unwrap();
std::sync::Arc::make_mut(&mut d.styles).get_mut(&id).unwrap().chr.size = Some(24.0);
assert_eq!(sizes(&d), want(24.0));
}
@@ -367,33 +367,3 @@ fn a_frame_keeps_its_place_in_the_stacking_order() {
assert_eq!(float(&d).z, 0);
assert!(crate::odt::tests::entry(&crate::odt::tests::export_model(&d).unwrap(), "content.xml").contains(r#"draw:z-index="0""#));
}
#[test]
fn a_frame_offering_several_formats_shows_the_first_it_can_read() {
// LibreOffice's letter templates: its own metafile, then a PNG. Taking the first left nothing
// to show, and the picture was gone once the paragraph was written from the model.
let png = [b"\x89PNG\r\n\x1a\n".as_slice(), &[7u8; 64]].concat();
let other = [b"\x89PNG\r\n\x1a\n".as_slice(), &[9u8; 64]].concat();
let svm = b"VCLMTF a metafile WordCraft can't draw".to_vec();
let frame = |images: &[&str]| {
let images: String = images.iter().map(|h| format!(r#"<draw:image xlink:href="{h}"/>"#)).collect();
format!(
r#"<office:document-content {NS_T} {NS_D}><office:body><office:text><text:p><draw:frame svg:width="2in" svg:height="1in" text:anchor-type="as-char">{images}</draw:frame></text:p></office:text></office:body></office:document-content>"#
)
};
let pictures = |d: &Document| d.body[0].as_para().unwrap().objects.iter().filter(|o| matches!(o, InlineObject::Image { .. })).count();
let both = frame(&["Pictures/a.svm", "Pictures/a.png"]);
let d = import(&zip_of(&[("content.xml", both.as_bytes()), ("Pictures/a.svm", &svm), ("Pictures/a.png", &png)])).unwrap();
assert_eq!(pictures(&d), 1, "the PNG");
let back = import(&crate::odt::tests::export_model(&d).unwrap()).unwrap();
assert_eq!(pictures(&back), 1, "written from the model");
// The first it can read, not the last.
let order = frame(&["Pictures/a.png", "Pictures/b.png"]);
let d = import(&zip_of(&[("content.xml", order.as_bytes()), ("Pictures/a.png", &png), ("Pictures/b.png", &other)])).unwrap();
assert_eq!(pictures(&d), 1);
assert_eq!(d.media.values().next().and_then(|m| m.get(8).copied()), Some(7), "the first");
// Nothing it can read: no picture, and no failure.
let only = frame(&["Pictures/a.svm"]);
let d = import(&zip_of(&[("content.xml", only.as_bytes()), ("Pictures/a.svm", &svm)])).unwrap();
assert_eq!(pictures(&d), 0);
}
@@ -159,75 +159,3 @@ fn a_table_starting_a_page_starts_it() {
let c = content_of(&crate::odt::tests::export_model(&d).unwrap());
assert_eq!(c.matches(r#"fo:break-before="page""#).count(), 2, "{c}");
}
#[test]
fn a_row_that_keeps_together_says_so() {
// Table › Text Flow: a row not to break across pages. It came back from a save free to break.
let styles = r#"<style:style style:name="R" style:family="table-row"><style:table-row-properties fo:keep-together="always"/></style:style><style:style style:name="H" style:family="table-row"><style:table-row-properties style:min-row-height="1in" fo:keep-together="always"/></style:style>"#;
let row = |s: &str, t: &str| format!(r#"<table:table-row{s}><table:table-cell><text:p>{t}</text:p></table:table-cell></table:table-row>"#);
let table = format!(
"<table:table>{}{}{}</table:table>",
row(r#" table:style-name="R""#, "kept"),
row("", "free"),
row(r#" table:style-name="H""#, "kept and tall")
);
let shape = |d: &wordcraft_doc::Document| {
d.body
.iter()
.find_map(|b| b.as_table())
.map(|t| t.rows.iter().map(|r| (r.props.cant_split, r.props.height)).collect::<Vec<_>>())
.unwrap_or_default()
};
let d = doc(styles, &table);
assert_eq!(shape(&d), [(true, None), (false, None), (true, Some(72.0))]);
let saved = crate::odt::tests::export_model(&d).unwrap();
assert!(content_of(&saved).contains(r#"fo:keep-together="always""#));
assert_eq!(shape(&import(&saved).unwrap()), [(true, None), (false, None), (true, Some(72.0))]);
}
/// A row whose style says nothing of it, or a row with no style, follows the default style; one
/// that says otherwise doesn't.
#[test]
fn a_rows_keep_together_defaults_to_the_default_style() {
let styles = r#"<office:document-styles xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0"><office:styles><style:default-style style:family="table-row"><style:table-row-properties fo:keep-together="always"/></style:default-style></office:styles></office:document-styles>"#;
let row = |s: &str| format!(r#"<table:table-row{s}><table:table-cell><text:p>x</text:p></table:table-cell></table:table-row>"#);
let content = format!(
r#"<office:document-content {NS_T}><office:automatic-styles><style:style style:name="H" style:family="table-row"><style:table-row-properties style:min-row-height="1in"/></style:style><style:style style:name="F" style:family="table-row"><style:table-row-properties fo:keep-together="auto"/></style:style></office:automatic-styles><office:body><office:text><table:table>{}{}{}</table:table></office:text></office:body></office:document-content>"#,
row(""),
row(r#" table:style-name="H""#),
row(r#" table:style-name="F""#)
);
let keeps = |d: &wordcraft_doc::Document| {
d.body.iter().find_map(|b| b.as_table()).map(|t| t.rows.iter().map(|r| r.props.cant_split).collect::<Vec<_>>()).unwrap_or_default()
};
let d = import(&package(&content, Some(styles))).unwrap();
assert_eq!(keeps(&d), [true, true, false]);
assert_eq!(keeps(&import(&crate::odt::tests::export_model(&d).unwrap()).unwrap()), [true, true, false]);
}
/// A row free to break under a default row style that keeps rows together says so when written from
/// the model, or a save takes that away: the file's default style stays.
#[test]
fn a_row_free_to_break_stays_so_over_the_default_style() {
let styles = r#"<office:document-styles xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0"><office:styles><style:default-style style:family="table-row"><style:table-row-properties fo:keep-together="always"/></style:default-style></office:styles></office:document-styles>"#;
let row = |s: &str| format!(r#"<table:table-row{s}><table:table-cell><text:p>x</text:p></table:table-cell></table:table-row>"#);
let content = format!(
r#"<office:document-content {NS_T}><office:automatic-styles><style:style style:name="F" style:family="table-row"><style:table-row-properties fo:keep-together="auto"/></style:style></office:automatic-styles><office:body><office:text><table:table><table:table-column/>{}{}</table:table></office:text></office:body></office:document-content>"#,
row(r#" table:style-name="F""#),
row("")
);
let mut d = import(&package(&content, Some(styles))).unwrap();
let keeps = |d: &wordcraft_doc::Document| {
d.body.iter().find_map(|b| b.as_table()).map(|t| t.rows.iter().map(|r| r.props.cant_split).collect::<Vec<_>>()).unwrap_or_default()
};
assert_eq!(keeps(&d), [false, true]);
if let Some(wordcraft_doc::Block::Table(t)) = d.body.get_mut(0).map(std::sync::Arc::make_mut)
&& let Some(wordcraft_doc::Block::Para(p)) = t.rows[0].cells[0].blocks.get_mut(0).map(std::sync::Arc::make_mut)
{
p.text.push('!');
if let Some(r) = p.runs.last_mut() {
r.len += 1;
}
}
assert_eq!(keeps(&import(&export(&d).unwrap()).unwrap()), [false, true], "after an edit and a save");
}
@@ -287,97 +287,6 @@ fn a_watermark_is_a_shape_in_the_header_and_reads_back_as_one() {
);
}
/// A watermark added, changed or removed in WordCraft reaches a file's page definitions.
#[test]
fn a_watermark_change_to_a_file_is_saved() {
// A file's own page definitions were written back whatever happened to its watermark, so one
// added, changed or removed in WordCraft was gone from the saved file.
let ns = crate::odt::tests::NS_T;
let styles = format!(
r#"<office:document-styles {ns}><office:automatic-styles><style:page-layout style:name="pm1" style:page-usage="mirrored"><style:page-layout-properties fo:page-width="21cm" fo:page-height="29.7cm" fo:margin-top="2cm" fo:margin-bottom="2cm" fo:margin-left="2cm" fo:margin-right="2cm"/></style:page-layout></office:automatic-styles><office:master-styles><style:master-page style:name="Standard" style:page-layout-name="pm1"/></office:master-styles></office:document-styles>"#
);
let content =
format!("<office:document-content {ns}><office:body><office:text><text:p>x</text:p></office:text></office:body></office:document-content>");
let plain = crate::odt::tests::package(&content, Some(&styles));
let styles_of = |d: &Document| crate::odt::tests::entry(&export(d).unwrap(), "styles.xml");
let shape = "PowerPlusWaterMarkObject";
// Added.
let mut d = import(&plain).unwrap();
d.settings.watermark = Some(wordcraft_doc::Watermark { text: "DRAFT".into(), ..Default::default() });
let saved = export(&d).unwrap();
let s = crate::odt::tests::entry(&saved, "styles.xml");
assert!(s.contains(shape) && s.contains("DRAFT"), "added: {s}");
assert_eq!(import(&saved).unwrap().settings.watermark.map(|w| w.text).as_deref(), Some("DRAFT"));
// Saved again with nothing changed, it stays as the file has it.
let read = import(&saved).unwrap();
let again = styles_of(&read);
assert_eq!(again.matches(shape).count(), s.matches(shape).count(), "{again}");
// Changed.
let mut d = import(&saved).unwrap();
if let Some(w) = d.settings.watermark.as_mut() {
w.text = "COPY".into();
}
let s = styles_of(&d);
assert!(s.contains("COPY") && !s.contains("DRAFT"), "changed: {s}");
// Removed.
let mut d = import(&saved).unwrap();
d.settings.watermark = None;
let s = styles_of(&d);
assert!(!s.contains(shape) && !s.contains("DRAFT"), "removed: {s}");
// One with no text isn't written, so it's no change to the file's page definitions.
let mut d = import(&plain).unwrap();
d.settings.watermark = Some(wordcraft_doc::Watermark { text: " ".into(), ..Default::default() });
let s = styles_of(&d);
assert!(s.contains(r#"style:page-usage="mirrored""#) && !s.contains(shape), "no text: {s}");
}
/// Different Odd & Even Pages turned on or off in WordCraft reaches a file's page definitions.
#[test]
fn different_odd_and_even_pages_changed_in_a_file_is_saved() {
// A file's own page definitions were written back whatever happened to this setting, so the
// left pages' header stayed after it was turned off.
let ns = crate::odt::tests::NS_T;
let file = |left: &str| {
let styles = format!(
r#"<office:document-styles {ns}><office:automatic-styles><style:page-layout style:name="pm1" style:page-usage="mirrored"><style:page-layout-properties fo:page-width="21cm" fo:page-height="29.7cm" fo:margin-top="2cm" fo:margin-bottom="2cm" fo:margin-left="2cm" fo:margin-right="2cm"/><style:header-style><style:header-footer-properties fo:min-height="1cm"/></style:header-style></style:page-layout></office:automatic-styles><office:master-styles><style:master-page style:name="Standard" style:page-layout-name="pm1"><style:header><text:p>Right head</text:p></style:header>{left}</style:master-page></office:master-styles></office:document-styles>"#
);
let content = format!(
"<office:document-content {ns}><office:body><office:text><text:p>x</text:p></office:text></office:body></office:document-content>"
);
crate::odt::tests::package(&content, Some(&styles))
};
let styles_of = |d: &Document| crate::odt::tests::entry(&export(d).unwrap(), "styles.xml");
// On in the file, and nothing changed: the file's own.
let both = file("<style:header-left><text:p>Left head</text:p></style:header-left>");
let d = import(&both).unwrap();
assert!(d.settings.even_odd_headers);
let s = styles_of(&d);
assert!(s.contains(r#"style:page-usage="mirrored""#) && s.contains("Left head"), "as read: {s}");
// Turned off.
let mut d = import(&both).unwrap();
d.settings.even_odd_headers = false;
let saved = export(&d).unwrap();
let s = crate::odt::tests::entry(&saved, "styles.xml");
assert!(!s.contains("<style:header-left") && s.contains("Right head"), "off: {s}");
assert!(!import(&saved).unwrap().settings.even_odd_headers);
// Off in the file, and turned on: left pages have their own header, an empty one so far.
let mut d = import(&file("")).unwrap();
assert!(!d.settings.even_odd_headers);
d.settings.even_odd_headers = true;
let saved = export(&d).unwrap();
let s = crate::odt::tests::entry(&saved, "styles.xml");
assert!(s.contains("<style:header-left") && s.contains("Right head"), "on: {s}");
assert!(import(&saved).unwrap().settings.even_odd_headers);
}
#[test]
fn libreoffices_watermark_reads_as_the_watermark() {
// As LibreOffice's Format › Watermark writes it (observed): turned, half see-through.
-158
View File
@@ -159,161 +159,3 @@ fn a_file_a_kept_definition_names_is_written_with_it() {
let m = crate::odt::tests::entry(&saved, "META-INF/manifest.xml");
assert!(m.contains(r#"manifest:full-path="Pictures/paper.png""#), "{m}");
}
#[test]
fn the_files_font_declarations_are_kept() {
// A reader without a font picks its substitute by how the file declares it. WordCraft's own
// guess at a font's kind replaced the file's, and a document came back from a save with no
// edits 13 pages shorter in LibreOffice on a computer without its font.
let ns = NS_T;
let face = r#"<style:font-face style:name="Odd Roman" svg:font-family="&apos;Odd Roman&apos;" style:font-family-generic="roman"/>"#;
let styles = format!(
r#"<office:document-styles {ns}><office:font-face-decls>{face}</office:font-face-decls><office:styles><style:default-style style:family="paragraph"><style:text-properties style:font-name="Odd Roman" fo:font-size="12pt"/></style:default-style></office:styles></office:document-styles>"#
);
let content = format!(
r#"<office:document-content {ns}><office:font-face-decls>{face}</office:font-face-decls><office:automatic-styles><style:style style:name="P1" style:family="paragraph"><style:text-properties style:font-name="Odd Roman"/></style:style></office:automatic-styles><office:body><office:text><text:p text:style-name="P1">Text</text:p></office:text></office:body></office:document-content>"#
);
let check = |saved: &[u8], what: &str| {
for part in [styles_of(saved), content_of(saved)] {
assert!(part.contains(face), "{what}: {part}");
assert_eq!(part.matches(r#"<style:font-face style:name="Odd Roman""#).count(), 1, "{what}: declared once: {part}");
}
};
let mut d = import(&package(&content, Some(&styles))).unwrap();
check(&export(&d).unwrap(), "no edits");
// Text edited, written from the model: still the file's declaration.
if let Some(wordcraft_doc::Block::Para(p)) = d.body.get_mut(0).map(std::sync::Arc::make_mut) {
p.text.push('!');
if let Some(r) = p.runs.last_mut() {
r.len += 1;
}
}
check(&export(&d).unwrap(), "an edit");
}
/// A font's name spelled another way in XML (`O'Brien`, `O&#39;Brien`) is the same font: its
/// declaration is the file's, once.
#[test]
fn a_fonts_name_spelled_another_way_is_declared_once_as_the_file_has_it() {
for spelled in ["O'Brien", "O&#39;Brien"] {
let face = format!(r#"<style:font-face style:name="{spelled}" svg:font-family="O&apos;Brien" style:font-family-generic="roman"/>"#);
let styles = format!(
r#"<office:document-styles {NS_T}><office:font-face-decls>{face}</office:font-face-decls><office:styles><style:default-style style:family="paragraph"><style:text-properties style:font-name="{spelled}" fo:font-size="12pt"/></style:default-style></office:styles></office:document-styles>"#
);
let content = format!(
r#"<office:document-content {NS_T}><office:font-face-decls>{face}</office:font-face-decls><office:automatic-styles><style:style style:name="T1" style:family="text"><style:text-properties style:font-name="{spelled}"/></style:style></office:automatic-styles><office:body><office:text><text:p><text:span text:style-name="T1">One</text:span></text:p></office:text></office:body></office:document-content>"#
);
let mut d = import(&package(&content, Some(&styles))).unwrap();
// Text edited, written from the model.
if let Some(wordcraft_doc::Block::Para(p)) = d.body.get_mut(0).map(std::sync::Arc::make_mut) {
p.text.push('!');
if let Some(r) = p.runs.last_mut() {
r.len += 1;
}
}
let saved = export(&d).unwrap();
for part in [styles_of(&saved), content_of(&saved)] {
assert!(part.contains(&face), "{spelled}: the file's: {part}");
let is_it = |t: &&str| {
let name = t.split('>').next().and_then(|tag| tag.split("style:name=\"").nth(1)).and_then(|v| v.split('"').next());
name.is_some_and(|n| n == "O'Brien" || n == "O&#39;Brien" || n == "O&apos;Brien")
};
assert_eq!(part.split("<style:font-face ").skip(1).filter(is_it).count(), 1, "{spelled}: declared once: {part}");
}
}
}
/// A face named other than its family (`F1`, `Mystery`): text written from the model names the
/// family, and that declaration says what the file's does, not WordCraft's guess at the font's kind.
#[test]
fn a_face_named_other_than_its_family_gives_the_family_the_files_declaration() {
let face =
r#"<style:font-face style:name="F1" svg:font-family="&apos;Mystery&apos;" style:font-family-generic="roman" style:font-pitch="variable"/>"#;
let styles = format!(
r#"<office:document-styles {NS_T}><office:font-face-decls>{face}</office:font-face-decls><office:styles><style:default-style style:family="paragraph"><style:text-properties fo:font-size="12pt"/></style:default-style></office:styles></office:document-styles>"#
);
let content = format!(
r#"<office:document-content {NS_T}><office:font-face-decls>{face}</office:font-face-decls><office:automatic-styles><style:style style:name="T1" style:family="text"><style:text-properties style:font-name="F1"/></style:style></office:automatic-styles><office:body><office:text><text:p><text:span text:style-name="T1">One</text:span></text:p></office:text></office:body></office:document-content>"#
);
let mut d = import(&package(&content, Some(&styles))).unwrap();
if let Some(wordcraft_doc::Block::Para(p)) = d.body.get_mut(0).map(std::sync::Arc::make_mut) {
p.text.push('!');
if let Some(r) = p.runs.last_mut() {
r.len += 1;
}
}
let saved = export(&d).unwrap();
let part = content_of(&saved);
assert!(part.contains(r#"style:font-name="Mystery""#), "{part}");
let mystery = part.split("<style:font-face ").find(|t| t.starts_with(r#"style:name="Mystery""#)).and_then(|t| t.split("/>").next());
let mystery = mystery.unwrap_or_else(|| panic!("declared: {part}"));
assert!(mystery.contains(r#"style:font-family-generic="roman""#), "{mystery}");
}
/// Only declarations are declarations: not a script's text that reads like one. And a face's name
/// is its own attribute, not another with `style:name` in its name.
#[test]
fn a_face_is_found_and_named_by_its_own_markup() {
let script =
r#"<office:scripts><office:script script:language="x"><![CDATA[<style:font-face style:name="Mystery" />]]></office:script></office:scripts>"#;
let face = r#"<style:font-face xstyle:name="F1" style:name="F1" svg:font-family="&apos;Mystery&apos;" style:font-family-generic="roman"/>"#;
let styles = format!(
r#"<office:document-styles {NS_T}><office:font-face-decls>{face}</office:font-face-decls><office:styles><style:default-style style:family="paragraph"><style:text-properties fo:font-size="12pt"/></style:default-style></office:styles></office:document-styles>"#
);
let content = format!(
r#"<office:document-content {NS_T}>{script}<office:font-face-decls>{face}</office:font-face-decls><office:automatic-styles><style:style style:name="T1" style:family="text"><style:text-properties style:font-name="F1"/></style:style></office:automatic-styles><office:body><office:text><text:p><text:span text:style-name="T1">One</text:span></text:p></office:text></office:body></office:document-content>"#
);
let mut d = import(&package(&content, Some(&styles))).unwrap();
if let Some(wordcraft_doc::Block::Para(p)) = d.body.get_mut(0).map(std::sync::Arc::make_mut) {
p.text.push('!');
if let Some(r) = p.runs.last_mut() {
r.len += 1;
}
}
let saved = export(&d).unwrap();
let part = content_of(&saved);
assert!(part.contains(script), "the script as it was: {part}");
let decls = part.split("<office:font-face-decls>").last().and_then(|t| t.split("</office:font-face-decls>").next()).unwrap();
assert!(decls.contains(r#"xstyle:name="F1" style:name="Mystery""#), "{decls}");
assert!(import(&saved).is_ok());
}
/// A font declaration in single quotes is the file's too: it replaces WordCraft's, and isn't
/// declared a second time.
#[test]
fn a_font_declaration_in_single_quotes_is_kept() {
let ns = format!(r#"{NS_T} xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0""#);
let face = r#"<style:font-face style:name='Odd Roman' svg:font-family='Odd Roman' style:font-family-generic='roman'/>"#;
let styles = format!(
r#"<office:document-styles {ns}><office:font-face-decls>{face}</office:font-face-decls><office:styles><style:default-style style:family="paragraph"><style:text-properties style:font-name="Odd Roman" fo:font-size="12pt"/></style:default-style></office:styles></office:document-styles>"#
);
let content = format!(
r#"<office:document-content {ns}><office:font-face-decls>{face}</office:font-face-decls><office:automatic-styles><style:style style:name="P1" style:family="paragraph"><style:text-properties style:font-name="Odd Roman"/></style:style></office:automatic-styles><office:body><office:text><text:p text:style-name="P1">x</text:p></office:text></office:body></office:document-content>"#
);
let saved = export(&import(&package(&content, Some(&styles))).unwrap()).unwrap();
for part in [styles_of(&saved), content_of(&saved)] {
assert!(part.contains(face), "{part}");
assert_eq!(part.matches("<style:font-face").count(), part.matches("<style:font-face style:name=").count());
assert_eq!(part.matches(r#"style:name="Odd Roman""#).count(), 0, "declared once: {part}");
assert_eq!(part.matches("style:name='Odd Roman'").count(), 1, "declared once: {part}");
}
}
/// A name space only the declarations' container states goes with the declaration: without it the
/// saved part isn't well-formed in its name spaces.
#[test]
fn a_name_space_on_the_font_declarations_goes_with_them() {
let ns = format!(r#"{NS_T} xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0""#);
let decls = r#"<office:font-face-decls xmlns:x="urn:example:x"><style:font-face style:name="Odd Roman" svg:font-family="Odd Roman" x:odd="1"/></office:font-face-decls>"#;
let styles = format!(
r#"<office:document-styles {ns}>{decls}<office:styles><style:default-style style:family="paragraph"><style:text-properties style:font-name="Odd Roman" fo:font-size="12pt"/></style:default-style></office:styles></office:document-styles>"#
);
let content = format!(
r#"<office:document-content {ns}>{decls}<office:automatic-styles><style:style style:name="P1" style:family="paragraph"><style:text-properties style:font-name="Odd Roman"/></style:style></office:automatic-styles><office:body><office:text><text:p text:style-name="P1">x</text:p></office:text></office:body></office:document-content>"#
);
let saved = export(&import(&package(&content, Some(&styles))).unwrap()).unwrap();
for part in [styles_of(&saved), content_of(&saved)] {
assert!(part.contains(r#"x:odd="1""#), "{part}");
assert!(part.contains(r#"xmlns:x="urn:example:x""#), "{part}");
}
}
@@ -167,84 +167,3 @@ fn an_object_in_a_header_is_kept_too() {
assert!(z.file_names().any(|n| n.ends_with("/content.xml") && n.starts_with("Object M")), "{:?}", z.file_names().collect::<Vec<_>>());
assert!(entry(&saved, "styles.xml").contains("<draw:object xlink:href=\"./Object M"));
}
/// A replacement picture may come before its object. It is kept whatever it is, though WordCraft
/// can't show it, and a save from the model writes it back.
#[test]
fn a_replacement_picture_before_its_object_is_kept() {
let content = format!(
r#"<office:document-content {NS_T} {NS_D}><office:body><office:text><text:p>Sheet <draw:frame draw:name="Object1" text:anchor-type="as-char" svg:width="3in" svg:height="1in"><draw:image xlink:href="./ObjectReplacements/Object 1" xlink:type="simple"/><draw:object xlink:href="./Object 1" xlink:type="simple"/></draw:frame></text:p></office:text></office:body></office:document-content>"#
);
let bytes = zip_of(&[
("META-INF/manifest.xml", MANIFEST.as_bytes()),
("content.xml", content.as_bytes()),
("Object 1/content.xml", SHEET.as_bytes()),
("Object 1/styles.xml", b"<office:document-styles/>"),
("ObjectReplacements/Object 1", METAFILE),
]);
let replacement = |bytes: &[u8]| {
let flow = parse(bytes).unwrap();
paras(&flow)
.into_iter()
.flat_map(|p| &p.inlines)
.find_map(|i| if let Inline::Image(img) = i { img.object.clone() } else { None })
.and_then(|o| o.replacement.clone())
};
assert_eq!(replacement(&bytes).as_deref().map(Vec::as_slice), Some(METAFILE));
let saved = crate::odt::tests::export_model(&import(&bytes).unwrap()).unwrap();
assert_eq!(replacement(&saved).as_deref().map(Vec::as_slice), Some(METAFILE), "written from the model");
}
/// A readable picture after an unreadable replacement is what WordCraft shows, wherever the object
/// comes between them, and a save from the model keeps both.
#[test]
fn a_readable_picture_after_an_unreadable_replacement_is_shown() {
let mut png = std::io::Cursor::new(Vec::new());
image::RgbImage::new(4, 4).write_to(&mut png, image::ImageFormat::Png).unwrap();
let png = png.into_inner();
let object = r#"<draw:object xlink:href="./Object 1"/>"#;
let unread = r#"<draw:image xlink:href="./ObjectReplacements/Object 1"/>"#;
let readable = r#"<draw:image xlink:href="./Pictures/a.png"/>"#;
for frame in [format!("{unread}{object}{readable}"), format!("{object}{unread}{readable}")] {
let content = format!(
r#"<office:document-content {NS_T} {NS_D}><office:body><office:text><text:p>Sheet <draw:frame draw:name="Object1" text:anchor-type="as-char" svg:width="3in" svg:height="1in">{frame}</draw:frame></text:p></office:text></office:body></office:document-content>"#
);
let bytes = zip_of(&[
("META-INF/manifest.xml", MANIFEST.as_bytes()),
("content.xml", content.as_bytes()),
("Object 1/content.xml", SHEET.as_bytes()),
("Object 1/styles.xml", b"<office:document-styles/>"),
("ObjectReplacements/Object 1", METAFILE),
("Pictures/a.png", &png),
]);
let check = |bytes: &[u8], what: &str| {
let flow = parse(bytes).unwrap();
let img = paras(&flow)
.into_iter()
.flat_map(|p| &p.inlines)
.find_map(|i| if let Inline::Image(img) = i { Some(img.clone()) } else { None })
.unwrap();
assert_eq!(img.data.as_slice(), png.as_slice(), "{what}: shows the picture it can read");
assert_eq!(img.object.unwrap().replacement.as_deref().map(Vec::as_slice), Some(METAFILE), "{what}: keeps the replacement");
};
check(&bytes, "read");
check(&crate::odt::tests::export_model(&import(&bytes).unwrap()).unwrap(), "written from the model");
}
}
/// With only an unreadable replacement, the placeholder drawn for it isn't written as a picture.
#[test]
fn the_placeholder_for_an_object_is_not_saved_as_its_picture() {
let content = format!(
r#"<office:document-content {NS_T} {NS_D}><office:body><office:text><text:p>Sheet <draw:frame draw:name="Object1" text:anchor-type="as-char" svg:width="3in" svg:height="1in"><draw:object xlink:href="./Object 1"/><draw:image xlink:href="./ObjectReplacements/Object 1"/></draw:frame></text:p></office:text></office:body></office:document-content>"#
);
let bytes = zip_of(&[
("META-INF/manifest.xml", MANIFEST.as_bytes()),
("content.xml", content.as_bytes()),
("Object 1/content.xml", SHEET.as_bytes()),
("Object 1/styles.xml", b"<office:document-styles/>"),
("ObjectReplacements/Object 1", METAFILE),
]);
let saved = crate::odt::tests::export_model(&import(&bytes).unwrap()).unwrap();
assert_eq!(entry(&saved, "content.xml").matches("<draw:image ").count(), 1);
}
+2 -10
View File
@@ -244,19 +244,11 @@ impl Writer {
None => self.pictures.push((replacement.clone(), img.data.clone(), mime_of(&img.ext).to_string())),
}
let el = if o.ole { "draw:object-ole" } else { "draw:object" };
let mut inner = format!(
format!(
"<{el} xlink:href=\"./{}\" xlink:type=\"simple\" xlink:show=\"embed\" xlink:actuate=\"onLoad\"/><draw:image xlink:href=\"./{}\" xlink:type=\"simple\" xlink:show=\"embed\" xlink:actuate=\"onLoad\"/>",
x(&name),
x(&replacement)
);
// A picture shown that isn't the replacement (one WordCraft can't read) follows it; a
// placeholder is WordCraft's own, not the file's.
let other = o.replacement.as_ref().is_some_and(|r| r.as_slice() != img.data.as_slice());
if other && crate::odt::objects::placeholder(&o.mime).as_deref() != Some(img.data.as_slice()) {
let href = self.picture(img);
inner.push_str(&format!("<draw:image xlink:href=\"{href}\" xlink:type=\"simple\" xlink:show=\"embed\" xlink:actuate=\"onLoad\"/>"));
}
inner
)
}
/// Object `o` (a chart as read) in the package under the next object name, with its
+13 -27
View File
@@ -161,33 +161,19 @@ impl Writer {
out.push_str("<table:table-header-rows>");
}
// A row of a set height: exact (`style:row-height`) or at least (`style:min-row-height`).
let height = match t.extra.as_ref().and_then(|x| x.heights.get(ri).copied().flatten()) {
Some((h, true)) => format!(" style:row-height=\"{}\"", pt(h)),
Some((h, false)) => format!(" style:min-row-height=\"{}\"", pt(h)),
None => String::new(),
};
// A row that doesn't break across pages (§20.247 on a row). One that may says so where
// the file's default row style, which stays, would keep it together.
let keep = if t.extra.as_ref().is_some_and(|x| x.keep_rows.get(ri) == Some(&true)) {
" fo:keep-together=\"always\""
} else if self.kept_defs.get("common~style:default-style~table-row").is_some_and(|d| d.contains("keep-together") && d.contains("always"))
{
" fo:keep-together=\"auto\""
} else {
""
};
let props = format!("{height}{keep}");
if props.is_empty() {
out.push_str("<table:table-row>");
} else {
let k = match self.row_styles.iter().position(|s| *s == props) {
Some(k) => k,
None => {
self.row_styles.push(props);
self.row_styles.len() - 1
}
};
out.push_str(&format!("<table:table-row table:style-name=\"{}Row{}\">", self.prefix, k + 1));
match t.extra.as_ref().and_then(|x| x.heights.get(ri).copied().flatten()) {
Some((h, exact)) => {
let props = if exact { format!(" style:row-height=\"{}\"", pt(h)) } else { format!(" style:min-row-height=\"{}\"", pt(h)) };
let k = match self.row_styles.iter().position(|s| *s == props) {
Some(k) => k,
None => {
self.row_styles.push(props);
self.row_styles.len() - 1
}
};
out.push_str(&format!("<table:table-row table:style-name=\"{}Row{}\">", self.prefix, k + 1));
}
None => out.push_str("<table:table-row>"),
}
let mut g = 0usize;
for c in row {
+5 -10
View File
@@ -105,27 +105,22 @@ fn parse_full(bytes: &[u8]) -> Result<Parsed, String> {
i = end;
continue;
}
if word == "info" {
r.info_depth = Some(r.depth());
}
if r.skip_chars > 0
&& matches!(r.st.dest, Dest::Body | Dest::Info(_) | Dest::FldInst | Dest::Bookmark | Dest::FontTbl | Dest::RevTbl)
&& matches!(r.st.dest, Dest::Body | Dest::Info(_) | Dest::FldInst | Dest::Bookmark | Dest::FontTbl)
&& !matches!(word, "par" | "pard" | "cell" | "row")
{
r.skip_chars -= 1;
first_in_group = false;
continue;
}
if word == "info" {
r.info_depth = Some(r.depth());
}
r.word(word, param, starred, first_in_group);
first_in_group = false;
starred = false;
continue;
}
if r.skip_chars > 0 && n != b'\'' {
r.skip_chars -= 1;
i += 2;
first_in_group = false;
continue;
}
match n {
b'\'' => {
let h = bytes.get(i + 2..i + 4).and_then(|h| std::str::from_utf8(h).ok()).and_then(|h| u8::from_str_radix(h, 16).ok());
-56
View File
@@ -37,62 +37,6 @@ fn basic_reader() {
assert!(matches!(&l.inlines[0], Inline::Text(_, f) if f.link.as_deref() == Some("http://a.b")));
}
#[test]
fn unicode_fallback_uses_the_declared_count() {
for (rtf, expected) in [
(br"{\rtf1\uc18\u8804less than or equal rest}".as_slice(), "≤ rest"),
(br"{\rtf1\uc10\u233\'30\'31\'32\'33\'34\'35\'36\'37\'38\'39 rest}", "é rest"),
(br"{\rtf1\uc2\u8804\:ab rest}", "≤b rest"),
(br"{\rtf1\uc18\u8804\:12345678901234567 rest}", "≤ rest"),
(br"{\rtf1\uc1\u8804\:ab rest}", "≤ab rest"),
(br"{\rtf1\uc0\u8804\:ab rest}", "≤ab rest"),
(br"{\rtf1\uc9\u233abcdefgh\par VISIBLE}", "éVISIBLE"),
(br"{\rtf1\uc1\u233\pard VISIBLE}", "éVISIBLE"),
(br"{\rtf1\uc1\u233\cell VISIBLE}", "éVISIBLE"),
(br"{\rtf1\uc1\u233\row VISIBLE}", "éVISIBLE"),
(br"{\rtf1\uc0\u233\par VISIBLE}", "é\nVISIBLE"),
(b"{\\rtf1\\uc9\\u233abcdefgh\\\nVISIBLE}", "éVISIBLE"),
(b"{\\rtf1\\uc9\\u233abcdefgh\\\rVISIBLE}", "éVISIBLE"),
(br"{\rtf1\uc9\u233abcdefgh\*VISIBLE}", "éVISIBLE"),
(b"{\\rtf1\\uc0\\u233\\\nVISIBLE}", "é\nVISIBLE"),
(b"{\\rtf1\\uc0\\u233\\\rVISIBLE}", "é\nVISIBLE"),
(br"{\rtf1\uc0\u233\*VISIBLE}", "éVISIBLE"),
(br"{\rtf1\uc0\u233X}", "éX"),
(br"{\rtf1\uc1\u233?X}", "éX"),
(br"{\rtf1{\uc2147483647\u233?}X}", "éX"),
(br"{\rtf1\uc-1\u233X}", "éX"),
] {
let doc = import(rtf).unwrap();
assert_eq!(doc.plain_text(wordcraft_doc::StoryRef::Body).trim(), expected, "{rtf:?}");
}
}
#[test]
fn unicode_fallback_info_words_preserve_following_metadata() {
for (rtf, title, body) in [
(br"{\rtf1{\info{\title\uc9\u233abcdefgh\info X}{\author Alice}} Text}".as_slice(), "éX", "Text"),
(br"{\rtf1{\info{\title\uc1\u233\info X}{\author Alice}} Text}", "éX", "Text"),
(br"{\rtf1{\info{\title\uc0\u233X}{\author Alice}} Text}", "éX", "Text"),
(br"{\rtf1\uc0\u233{\info{\title Plain}{\author Alice}}Text}", "Plain", "éText"),
] {
let doc = import(rtf).unwrap();
assert_eq!(doc.core.title, title, "{rtf:?}");
assert_eq!(doc.core.creator, "Alice", "{rtf:?}");
assert_eq!(doc.plain_text(wordcraft_doc::StoryRef::Body).trim(), body, "{rtf:?}");
}
}
#[test]
fn unicode_fallback_in_revision_authors_counts_control_words() {
for (rtf, expected) in [
(br"{\rtf1{\*\revtbl{Unknown;}{\uc9\u233abcdefgh\par Zed;}}{\revised\revauth1 text}}".as_slice(), "éZed"),
(br"{\rtf1{\*\revtbl{Unknown;}{\uc0\u233\par Zed;}}{\revised\revauth1 text}}", "éZed"),
] {
let doc = import(rtf).unwrap();
assert_eq!(doc.revisions.first().map(|r| r.author.as_str()), Some(expected), "{rtf:?}");
}
}
#[test]
fn paragraph_borders() {
// A heading with a grey rule under it, as hh.ru résumés write their section titles.
+1 -1
View File
@@ -89,7 +89,7 @@ impl Reader {
}
self.skip_chars = self.st.uc;
}
"uc" => self.st.uc = pv.max(0) as usize,
"uc" => self.st.uc = pv.clamp(0, 8) as usize,
"plain" => {
self.st.fmt = Fmt { link: self.st.fmt.link.clone(), ..Default::default() };
self.st.rev = RevSt::default();
+4 -86
View File
@@ -59,9 +59,9 @@ pub(crate) struct Ctx<'a> {
pub(crate) pin: Option<Pin>,
pub(crate) footer_wraps: HashMap<(usize, u32, bool), Vec<WrapArea>>,
pub(crate) header_tables: HashMap<(usize, u32, bool), Vec<Rect>>,
/// Heights of text boxes that resize to fit their text, by story, width and page fields,
/// worked out once per matching layout context ([`textbox::fitted_height`]).
pub(crate) fitted: HashMap<(u32, u32, (u32, u32, u32, u32, u64, wordcraft_doc::section::NumFormat)), Option<f32>>,
/// Heights of text boxes that resize to fit their text, by (story, width), worked out once
/// per layout ([`textbox::fitted_height`]).
pub(crate) fitted: HashMap<(u32, u32), Option<f32>>,
/// Each body float's latest placement: on which page and with what wrap area, when it is
/// above its anchor paragraph with text before it ([`EarlyFloats`]).
pub(crate) floats_above: HashMap<(usize, usize), Option<(usize, WrapArea)>>,
@@ -344,7 +344,7 @@ impl Ctx<'_> {
/// The height of text box `id`, `w` wide, when it resizes to fit its text (once per layout).
fn fitted_height(&mut self, id: u32, w: f32) -> Option<f32> {
let key = (id, w.to_bits(), self.fields.page_key());
let key = (id, w.to_bits());
if let Some(h) = self.fitted.get(&key) {
return *h;
}
@@ -455,85 +455,3 @@ pub(crate) fn with_pct_base<T>(ctx: &mut Ctx, base: Option<f32>, f: impl FnOnce(
ctx.pct_base = before;
out
}
#[cfg(test)]
mod tests {
use super::*;
use wordcraft_doc::para::{Float, ShapeKind, Wrap};
use wordcraft_doc::{PartKind, TextBody, para_block};
#[test]
fn fitted_text_box_cache_separates_page_field_contexts() {
let mut doc = Document::from_text("Parent");
let mut story_para = Paragraph::new();
story_para
.insert_object(
0,
InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false, dirty: false, code: false, form: String::new() },
&Default::default(),
)
.unwrap();
let id = doc.add_part(PartKind::TextBox, vec![para_block(story_para)]);
if let Some(part) = doc.parts.get_mut(&id) {
part.body = TextBody { auto_fit: true, ..Default::default() };
}
let mut p = Paragraph::new();
p.insert_object(
0,
InlineObject::Shape {
kind: ShapeKind::TextBox,
w: 100.0,
h: 20.0,
fill: None,
stroke: None,
stroke_width: 0.0,
float: Float { wrap: Wrap::Inline, ..Default::default() },
story: Some(id),
freeform: None,
effects: Default::default(),
extra: Default::default(),
},
&Default::default(),
)
.unwrap();
let opts = LayoutOptions::default();
let mut cache = LayoutCache::new();
let mut ctx = Ctx {
doc: &doc,
cache: &mut cache,
opts: &opts,
counters: Default::default(),
fields: FieldCtx::default(),
notes_hash: 0,
numbers: Default::default(),
eq_count: 0,
boxes: Default::default(),
chains: Default::default(),
grid: Default::default(),
hide_deleted: opts.hides_deleted(StoryRef::Body),
uses_totals: false,
track_totals: false,
pct_base: None,
quiet_marks: Vec::new(),
stacked_marks: Vec::new(),
mirror: false,
table_keys: Vec::new(),
pin: None,
footer_wraps: Default::default(),
header_tables: Default::default(),
fitted: Default::default(),
floats_above: Default::default(),
hf: Arc::new(HfLinks::new(doc.sections())),
alphas: Default::default(),
header_repeats: 0,
};
ctx.fitted_boxes(&p);
assert_eq!(ctx.fitted.len(), 1);
ctx.fields.page_format = wordcraft_doc::section::NumFormat::CardinalText;
ctx.fitted_boxes(&p);
assert_eq!(ctx.fitted.len(), 2, "the same box with a different PAGE format needs its own height");
ctx.fitted_boxes(&p);
assert_eq!(ctx.fitted.len(), 2, "matching field context reuses the fitted height");
}
}
+1 -1
View File
@@ -656,7 +656,7 @@ fn inline_objects(ctx: &LineContext<'_, '_, '_>, out: &mut Vec<Draw>) {
Some(o @ InlineObject::Field { .. }) => {
if let Some(wordcraft_doc::legacy_form::LegacyForm::CheckBox { checked, .. }) = o.legacy_form() {
let color = pl.styles.get(c.style as usize).map_or(Rgb::BLACK, |st| text_color(&st.rc.color, None));
let frame = Rect::new(cx, base - c.obj_h.max(0.0), c.adv, c.obj_h + c.obj_d);
let frame = Rect::new(cx, base - c.obj_h, c.adv, c.obj_h + c.obj_d);
check_box(frame, checked, color, alpha, out);
}
}
+1 -2
View File
@@ -491,8 +491,7 @@ pub(crate) fn effects_in(effects: wordcraft_doc::effects::ShapeEffects, spin: Sp
pub(crate) fn inline_rect(obj: Option<&InlineObject>, cx: f32, base: f32, adv: f32, (obj_h, obj_d): (f32, f32), line: f32) -> Rect {
let frame = obj.and_then(InlineObject::frame);
let [l, t, r, b] = frame.map_or([0.0; 4], |(_, _, float)| float.effect_extent());
// Lowered further than its height, it is drawn with its top on the baseline (Word).
let room = Rect::new(cx + l, base - obj_h.max(0.0) + t, (adv - l - r).max(0.0), (obj_h + obj_d - t - b).max(0.0));
let room = Rect::new(cx + l, base - obj_h + t, (adv - l - r).max(0.0), (obj_h + obj_d - t - b).max(0.0));
// A horizontal rule is as wide as its share of the paragraph's width, whatever narrows its
// line, and is placed by its alignment in what the line has left: it runs past the margin
// beside a float or a first-line indent, as in Word (see `ParaLayout::rule_room`).
-2
View File
@@ -463,8 +463,6 @@ fn anchor_floats_but(
late: &[usize],
) -> (HashMap<usize, Rect>, Arc<ParaLayout>, Vec<para::Exclusion>) {
let FloatAnchor { p, block, y0, before, excl_mark, label, place } = anchor;
ctx.fields.page = pb.number;
ctx.fields.page_format = pb.page_format;
let (col_x, width) = (pb.col_x(), pb.col_w());
let mut rects = HashMap::new();
if !pb.web && place {
-1
View File
@@ -204,7 +204,6 @@ pub(crate) fn header_table_areas(ctx: &mut Ctx, pb: &PageBuilder) -> Vec<Rect> {
let fields = ctx.fields.clone();
let grid = std::mem::take(&mut ctx.grid);
let track = std::mem::replace(&mut ctx.track_totals, false);
ctx.fields.page_format = sect.page_num_format;
ctx.fields.page = page.number;
let mut areas = again(ctx, 0, |ctx| {
with_pct_base(ctx, base, |ctx| layout_box(ctx, BoxInput::other(StoryRef::Part(id), &blocks, sect.text_width(), 0, Some(frame)))).table_areas
-2
View File
@@ -9,8 +9,6 @@ use super::{Block, Border, FloatAlign, Page, ParaLayout, Rect, SectionProps, Sto
pub(crate) struct PageBuilder<'a> {
pub(crate) pages: Vec<Page>,
pub(crate) sect: &'a SectionProps,
/// The current document section's numbering format, even when reflow uses first-section page geometry.
pub(crate) page_format: wordcraft_doc::section::NumFormat,
pub(crate) sect_idx: usize,
pub(crate) col: usize,
pub(crate) cols: Vec<(f32, f32)>,
+2 -7
View File
@@ -297,9 +297,6 @@ impl<'e> Breaker<'e> {
};
let end = lab.x + lab.width;
match self.level.map(|l| l.suffix).unwrap_or(LevelSuffix::Tab) {
// An empty label (a "none" level with no text) that ends right at the left indent takes
// no tab: the text starts there, as in Word (#4985), and not at the next stop.
LevelSuffix::Tab if lab.width <= 0.01 && (end - self.rp.indent_left).abs() <= 0.01 => end,
LevelSuffix::Tab => {
let t = next_tab(end, &self.rp.tabs, self.default_tab, self.hanging_at, self.tab_origin).pos;
// The level's own tab stop after the number, when it comes first.
@@ -441,10 +438,8 @@ impl<'e> Breaker<'e> {
if matches!(rp.line_spacing, LineSpacing::Exactly(_)) { (st.ascent, st.descent) } else { (c.obj_h, c.obj_d) }
} else {
ex.obj_asc = ex.obj_asc.max(c.obj_h);
// A lowered picture reaches below the baseline, with exact spacing no further
// than its height (the line's box is fixed; its baseline stays where it was).
let reach = if matches!(rp.line_spacing, LineSpacing::Exactly(_)) { c.obj_d.min(c.obj_h + c.obj_d) } else { c.obj_d };
ex.obj_desc = ex.obj_desc.max(reach);
// A lowered picture reaches below the baseline.
ex.obj_desc = ex.obj_desc.max(c.obj_d);
(st.ascent, st.descent)
}
} else {
+5 -6
View File
@@ -338,8 +338,7 @@ impl<'a> Shaped<'a> {
/// A picture, shape or text box: inline, as wide and high as it is; a floating one takes no room.
/// An inline drawing: it stands on the baseline, raised or lowered by its run's position
/// (`raise`, points, as Word moves an inline picture with `w:position`). Its cluster reaches
/// `obj_h` above the baseline and `obj_d` below it (negative when raised clear of it; `obj_h`
/// negative when lowered further than its height, drawn with its top on the baseline).
/// `obj_h` above the baseline and `obj_d` below it (negative when raised clear of it).
fn drawing(&mut self, cx: &Cx, (k, o): (usize, &InlineObject), slot: Slot, raise: f32) {
let env = cx.env;
// A hidden one takes no room, and with no height its line doesn't draw it.
@@ -369,10 +368,10 @@ impl<'a> Shaped<'a> {
let [el, et, er, eb] = float.effect_extent();
let (px, py) = float.spin_pad(w * s, h * s);
let tall = h * s + et + eb + 2.0 * py;
// Lowered further than its height, its top stays on the baseline while the line
// reaches as far down as the lowering (Word, measured: a 6 pt picture lowered 30 pt
// makes a 30 pt line); `obj_h` is then negative. Word's positions run to 1584 pt.
let raise = if raise.is_finite() { raise.clamp(-1584.0, 1584.0) } else { 0.0 };
// Lowered at most until its top is on the baseline. The bound is made finite and
// not above `clamp`'s top whatever the extents give (`clamp` panics otherwise).
let floor = if tall.is_finite() { -tall.max(0.0) } else { 0.0 };
let raise = if raise.is_finite() { raise.clamp(floor, 1584.0) } else { 0.0 };
self.b.clusters.push(slot.cluster(ClKind::Object(k), w * s + el + er + 2.0 * px, (tall + raise, -raise)));
} else {
self.push(slot, ClKind::Object(k), 0.0, 0.0);
+3 -21
View File
@@ -43,7 +43,6 @@ struct Tops {
struct Sec<'p> {
si: usize,
sect: &'p SectionProps,
page_format: wordcraft_doc::section::NumFormat,
start: SectionStart,
tops: Tops,
}
@@ -159,17 +158,10 @@ fn reflow_sect(first: &SectionProps, opts: &LayoutOptions) -> SectionProps {
s
}
fn new_page_builder<'p>(
sect: &'p SectionProps,
page_format: wordcraft_doc::section::NumFormat,
early: &'p EarlyFloats,
web: bool,
settle: bool,
) -> PageBuilder<'p> {
fn new_page_builder<'p>(sect: &'p SectionProps, early: &'p EarlyFloats, web: bool, settle: bool) -> PageBuilder<'p> {
PageBuilder {
pages: Vec::new(),
sect,
page_format,
sect_idx: 0,
col: 0,
cols: Vec::new(),
@@ -229,7 +221,6 @@ fn turned_sections(ctx: &mut Ctx, sections: &[(usize, &SectionProps)], web: bool
.iter()
.map(|(_, s)| {
(!web && s.text_direction.is_turned()).then(|| {
ctx.fields.page_format = s.page_num_format;
let top = body_top_for(ctx, s, s.headers.default);
vertical::unturned_sect(s, top)
})
@@ -241,7 +232,6 @@ fn turned_sections(ctx: &mut Ctx, sections: &[(usize, &SectionProps)], web: bool
/// previous section's, and take its room. `fixed`: the margins are the room (continuous views and
/// turned sections).
fn section_tops(ctx: &mut Ctx, hf: &HfLinks, (si, sect): (usize, &SectionProps), fixed: bool) -> Tops {
ctx.fields.page_format = sect.page_num_format;
let (heads, feet) = (hf.heads.set(si), hf.feet.set(si));
let hb = |i: Option<u32>| hf.defining_width(false, si, i);
let fb = |i: Option<u32>| hf.defining_width(true, si, i);
@@ -256,8 +246,6 @@ fn section_tops(ctx: &mut Ctx, hf: &HfLinks, (si, sect): (usize, &SectionProps),
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 = 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() {
@@ -275,8 +263,6 @@ fn begin_section_state<'p>(ctx: &mut Ctx, pb: &mut PageBuilder<'p>, sec: &Sec<'p
fn end_previous_section<'p>(ctx: &mut Ctx, pb: &mut PageBuilder<'p>, hf: &HfLinks<'p>, si: usize, block: usize) {
let Some((_, prev)) = si.checked_sub(1).and_then(|k| hf.sections.get(k)) else { return };
pb.sect = prev;
pb.page_format = prev.page_num_format;
ctx.fields.page_format = prev.page_num_format;
pb.sect_idx = si - 1;
let (ph, pf) = (hf.heads.set(si - 1), hf.feet.set(si - 1));
let top = body_top_for(ctx, prev, ph.default);
@@ -295,8 +281,6 @@ fn start_section_page<'p>(ctx: &mut Ctx, pb: &mut PageBuilder<'p>, hf: &HfLinks<
if filler && !mode.web {
end_previous_section(ctx, pb, hf, sec.si, block);
pb.sect = sec.sect;
pb.page_format = sec.page_format;
ctx.fields.page_format = sec.page_format;
pb.sect_idx = sec.si;
}
if restart.is_some() {
@@ -508,7 +492,7 @@ pub(crate) fn layout_pass(
let first_sect = sections.first().map_or(&default_sect, |(_, s)| *s);
let web_sect = mode.reflow.then(|| reflow_sect(first_sect, opts));
let sect_ref = web_sect.as_ref().unwrap_or(first_sect);
let mut pb = new_page_builder(sect_ref, first_sect.page_num_format, early, mode.web, !may_rewrap && !mode.reflow);
let mut pb = new_page_builder(sect_ref, early, mode.web, !may_rewrap && !mode.reflow);
let mut endnotes = endnote_queue(&ctx);
// Text boxes laid out in balancing attempts that were thrown away.
let mut boxes_discarded = 0usize;
@@ -516,17 +500,15 @@ pub(crate) fn layout_pass(
let turned = turned_sections(&mut ctx, sections, mode.web);
let mut prev_turn = TextDirection::Horizontal;
for (si, (end, sect)) in sections.iter().enumerate() {
let page_format = sect.page_num_format;
let turn = if mode.reflow { TextDirection::Horizontal } else { sect.text_direction };
let sect: &SectionProps = match turned.get(si) {
Some(Some(t)) => t,
_ 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 };
let sec = Sec { si, sect, 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
+17 -74
View File
@@ -125,11 +125,9 @@ struct Entry {
runs: Vec<(String, String)>,
/// The list label as shown (`Chapter 2.`).
label: Option<String>,
/// Each level up to the paragraph's that has a number (not a bullet, nor a "none" level):
/// its index, its value formatted, and whether the label's own template shows it.
levels: Vec<(u8, String, bool)>,
/// The paragraph's own list level.
level: u8,
/// Each level's value up to the paragraph's, formatted; and whether the label's own
/// template shows that level.
levels: Vec<(String, bool)>,
/// How many paragraphs of its style there are up to this one.
ordinal: u32,
}
@@ -182,7 +180,7 @@ impl StyleIndex {
*ord = ord.saturating_add(1);
let ordinal = *ord;
let num = p.props.numbering.or_else(|| doc.styles.resolve_para(&p.props).numbering).filter(|n| n.num != 0);
let (mut label, mut levels, mut level) = (None, Vec::new(), 0u8);
let (mut label, mut levels) = (None, Vec::new());
if let Some(n) = num
&& let Some((l, def)) = counters.next_label(&doc.numbering, n.num, n.level)
&& def.format != NumFormat::Bullet
@@ -195,10 +193,9 @@ impl StyleIndex {
}
let v = if *v == 0 { lv.start } else { *v };
let fmt = if def.legal && lv.format != NumFormat::DecimalZero { NumFormat::Decimal } else { lv.format };
levels.push((k as u8, fmt.format(v), def.text.contains(&format!("%{}", k + 1))));
levels.push((fmt.format(v), def.text.contains(&format!("%{}", k + 1))));
}
label = Some(l);
level = n.level.min(8);
}
let mut runs: Vec<(String, String)> = Vec::new();
let mut off = 0usize;
@@ -224,7 +221,7 @@ impl StyleIndex {
list.push(i);
}
}
entries.push(Entry { path, style, text: clean(&p.plain_text()), runs, label, levels, level, ordinal });
entries.push(Entry { path, style, text: clean(&p.plain_text()), runs, label, levels, ordinal });
}
StyleIndex { entries, by_path, styles, para_entries, char_entries }
}
@@ -326,32 +323,18 @@ impl StyleIndex {
let mut out = match code.number {
Number::Text if code.position => String::new(),
Number::Text => text,
// The last number the label shows (`3` of `3 Response`, or of a "none" level's `%1`);
// a label of the paragraph's own number that shows none recognisably, its last word;
// else, as for an unnumbered heading or a "none" level's `Note`, the heading's count.
Number::Chapter => {
let own = e.levels.iter().any(|(k, ..)| *k == e.level);
e.label
.as_deref()
.and_then(|l| {
let shown = l.split_whitespace().rev().find_map(|w| number_span(w, &e.levels));
shown.map(|n| n.trim_matches(|c: char| !c.is_alphanumeric()).to_string()).or_else(|| own.then(|| chapter_number(l)))
})
.filter(|s| !s.is_empty())
.unwrap_or_else(|| e.ordinal.to_string())
}
Number::Chapter => e.label.as_deref().map(chapter_number).filter(|s| !s.is_empty()).unwrap_or_else(|| e.ordinal.to_string()),
Number::Plain | Number::Full => {
let own = match &e.label {
Some(l) if code.suppress => suppress(l, &e.levels, e.level),
Some(l) if code.suppress => suppress(l, &e.levels),
Some(l) => l.trim().trim_end_matches('.').to_string(),
None => e.ordinal.to_string(),
};
if code.number == Number::Full && e.label.is_some() {
// Levels above the paragraph's that the label's own template doesn't show. By
// level, not position: a "none" level has no entry, its own (#4985) included.
let above: Vec<&str> = e.levels.iter().filter(|(k, _, shown)| *k < e.level && !shown).map(|(_, v, _)| v.as_str()).collect();
// An empty label of its own (a "none" level with no text) adds no part: `3`, not `3.`.
above.into_iter().chain(Some(own.as_str()).filter(|o| !o.is_empty())).collect::<Vec<_>>().join(".")
// Levels above that the label's own template doesn't show.
let above: Vec<&str> =
e.levels.iter().take(e.levels.len().saturating_sub(1)).filter(|(_, shown)| !shown).map(|(v, _)| v.as_str()).collect();
if above.is_empty() { own } else { format!("{}.{own}", above.join(".")) }
} else {
own
}
@@ -373,46 +356,13 @@ fn chapter_number(label: &str) -> String {
word.trim_matches(|c: char| !c.is_alphanumeric()).to_string()
}
/// `\t`: the label without its words that hold no number (`Chapter 2.` → `2`). A "none" level's
/// label (`level` has no number of its own) holding none is left with nothing (`Note`, #4985);
/// any other label that holds none is kept whole.
fn suppress(label: &str, levels: &[(u8, String, bool)], level: u8) -> String {
// Each word's numbers, without the text attached to them (`Section3` → `3`).
let kept: Vec<&str> = label.split_whitespace().filter_map(|w| number_span(w, levels)).collect();
let none = !levels.iter().any(|(k, ..)| *k == level);
let s = match kept.is_empty() {
true if none => String::new(),
true => label.trim().to_string(),
false => kept.join(" "),
};
/// `\t`: the label without its words that hold no number (`Chapter 2.` → `2`).
fn suppress(label: &str, levels: &[(String, bool)]) -> String {
let kept: Vec<&str> = label.split_whitespace().filter(|w| levels.iter().any(|(v, _)| !v.is_empty() && w.contains(v.as_str()))).collect();
let s = if kept.is_empty() { label.trim().to_string() } else { kept.join(" ") };
s.trim_end_matches('.').to_string()
}
/// The part of `word` that holds the numbers the label's template shows (`levels` flagged shown),
/// with the delimiters around it (`(3)`, `1.1`, `2.`), without the text attached (`Section3` →
/// `3`); `None` when it holds none. A number written in letters (`a`, `iv`) counts only standing
/// apart from other letters: the `a` of `Chapter` isn't one.
fn number_span<'w>(word: &'w str, levels: &[(u8, String, bool)]) -> Option<&'w str> {
let alpha = |c: Option<char>| c.is_some_and(char::is_alphabetic);
let mut span: Option<(usize, usize)> = None;
for (_, v, _) in levels.iter().filter(|(_, v, shown)| *shown && !v.is_empty()) {
let letters = v.chars().any(char::is_alphabetic);
for (i, _) in word.match_indices(v.as_str()) {
let end = i.saturating_add(v.len());
let before = word.get(..i).and_then(|s| s.chars().next_back());
let after = word.get(end..).and_then(|s| s.chars().next());
if letters && (alpha(before) || alpha(after)) {
continue;
}
span = Some(span.map_or((i, end), |(a, b)| (a.min(i), b.max(end))));
}
}
let (a, b) = span?;
let lead: usize = word.get(..a)?.chars().rev().take_while(|c| !c.is_alphanumeric()).map(char::len_utf8).sum();
let trail: usize = word.get(b..)?.chars().take_while(|c| !c.is_alphanumeric()).map(char::len_utf8).sum();
word.get(a.saturating_sub(lead)..b.saturating_add(trail))
}
/// Does `p` hold a STYLEREF field?
pub fn has_styleref(p: &wordcraft_doc::Paragraph) -> bool {
p.objects.iter().any(is_styleref)
@@ -491,14 +441,7 @@ mod tests {
#[test]
fn suppress_keeps_the_number() {
assert_eq!(suppress("Chapter 2.", &[(0, "2".into(), true)], 0), "2");
assert_eq!(suppress("Chapter", &[(0, "a".into(), false)], 1), "", "an ancestor's number the label doesn't show");
assert_eq!(suppress("Section3", &[(0, "3".into(), true)], 1), "3", "the text attached to the number");
assert_eq!(suppress("Chapter a.", &[(0, "a".into(), true)], 0), "a", "a letter number standing apart, not Chapter's a");
assert_eq!(suppress("(iv)", &[(0, "iv".into(), true)], 0), "(iv)", "delimiters stay");
assert_eq!(suppress("1.2", &[(0, "1".into(), true), (1, "2".into(), true)], 1), "1.2");
assert_eq!(suppress("Note", &[(0, "3".into(), false)], 1), "", "a none level's label");
assert_eq!(suppress("Appendix", &[(0, "3".into(), false)], 0), "Appendix", "kept whole when the level has a number");
assert_eq!(suppress("Chapter 2.", &[("2".into(), true)]), "2");
assert_eq!(chapter_number("Chapter IV."), "IV");
}
}
+3 -58
View File
@@ -1820,30 +1820,6 @@ fn lists_get_labels_and_indent() {
assert!((x - 108.0).abs() < 0.5, "{x}");
}
#[test]
fn a_none_format_label_shows_its_text_and_tabs_to_the_indent() {
// #4985: Word draws a "none" level's literal text ("RESPONSE:"), and an empty one still tabs
// the text to a hanging indent, as a numbered item's. An empty label already at the left indent
// takes no tab (Word's Chapter 1 list: Heading 2 starts at the margin, not at 0.5").
use wordcraft_doc::section::NumFormat;
for (text, indent, hanging, want) in [("RESPONSE:", 72.0, 72.0, 144.0), ("", 72.0, 72.0, 144.0), ("", 0.0, 0.0, 72.0), ("", 50.0, 0.0, 122.0)] {
let mut d = Document::from_text("X");
let id = std::sync::Arc::make_mut(&mut d.numbering).add_list(wordcraft_doc::ListKind::Numbered);
for a in &mut std::sync::Arc::make_mut(&mut d.numbering).abstracts {
if let Some(l) = a.levels.first_mut() {
l.format = NumFormat::None;
l.text = text.into();
(l.indent, l.hanging) = (indent, hanging);
}
}
d.format_paragraphs(&Pos::body(0, 0), &Pos::body(0, 1), &|p| p.numbering = Some(wordcraft_doc::props::NumRef { num: id, level: 0 })).unwrap();
let l = lay(&d);
assert_eq!(labels(&l), [text]);
let x = l.caret(&Pos::body(0, 0)).unwrap().x;
assert!((x - want).abs() < 0.5, "{text:?} at {indent}/{hanging}: {x}, not {want}");
}
}
#[test]
fn break_only_list_has_no_label() {
use wordcraft_doc::ListKind;
@@ -5622,13 +5598,8 @@ fn an_inline_picture_moves_with_its_runs_position() {
assert!(line.top + line.height - line.baseline >= 30.0 - 0.01, "{line:?}");
let r = display::inline_rect(Some(&picture(40.0, 30.0, Default::default())), 0.0, 100.0, c.adv, (c.obj_h, c.obj_d), c.adv);
assert_eq!((r.y, r.h), (100.0, 30.0));
// Lowered further than that: the line reaches as far down, the picture's top staying on the
// baseline (Word).
let (c, line, _) = line_of(-500.0);
assert_eq!((c.obj_h, c.obj_d), (-470.0, 500.0));
assert!(line.top + line.height - line.baseline >= 500.0 - 0.01, "{line:?}");
let r = display::inline_rect(Some(&picture(40.0, 30.0, Default::default())), 0.0, 100.0, c.adv, (c.obj_h, c.obj_d), c.adv);
assert_eq!((r.y, r.h), (100.0, 30.0));
// Lowered further than that: still only to its top.
assert_eq!(line_of(-500.0).0.obj_d, 30.0);
// Raised: it stands clear of the baseline, as tall as before.
let (c, _, _) = line_of(6.0);
assert_eq!((c.obj_h, c.obj_d), (36.0, -6.0));
@@ -13723,7 +13694,7 @@ fn lowered_pictures_and_check_boxes_take_words_line_heights() {
let Some(Placed::Lines { para, .. }) = l.pages[0].items.iter().find(|i| matches!(i, Placed::Lines { .. })) else { panic!() };
let c = para.clusters.iter().find(|c| matches!(c.kind, para::ClKind::Object(_))).unwrap();
let (line, st) = (&para.lines[0], &para.styles[c.style as usize]);
(line.height, line.baseline - c.obj_h.max(0.0) - line.top, st.ascent + st.descent)
(line.height, line.baseline - c.obj_h - line.top, st.ascent + st.descent)
};
for m in [1.0, 1.15, 1.5] {
let share = |font: f32| (m - 1.0) * font;
@@ -13740,36 +13711,10 @@ fn lowered_pictures_and_check_boxes_take_words_line_heights() {
let (h, top, font) = line_of("", m, 6.0, raise);
assert!((h - font * m).abs() < 0.01 && (top - (font - reach)).abs() < 0.01, "{raise} at {m}: {h}, {top}");
}
// Lowered further than its height: as far down as it's lowered, the font's height at least,
// its top on the baseline (Word: a 6 pt picture lowered 10 pt takes the font's 11.5 pt,
// lowered 30 pt 30 pt; beside text, the text's ascent and the 30 pt).
let (h, top, font) = line_of("", m, 6.0, -10.0);
assert!((h - font * m).abs() < 0.01 && (top - (font - 10.0)).abs() < 0.01, "6 lowered 10 at {m}: {h}, {top}");
let (h, top, font) = line_of("", m, 6.0, -30.0);
assert!((h - 30.0 - share(font)).abs() < 0.01 && top.abs() < 0.01, "6 lowered 30 at {m}: {h}, {top}");
let (h, top, font) = line_of("x", m, 6.0, -30.0);
let asc = h - 30.0 - share(font);
assert!(asc > 0.0 && asc < font && (top - asc).abs() < 0.01, "6 lowered 30 beside text at {m}: {h}, {top}");
// Beside text, lowered: the text's ascent is no matter, the picture reaching further.
let (h, top, font) = line_of("x", m, 30.0, -10.0);
assert!((h - 30.0 - share(font)).abs() < 0.01 && top.abs() < 0.01, "beside text at {m}: {h}, {top}");
}
// Exact spacing keeps the line's box and its baseline: a picture lowered past its height sits
// where one lowered by its height does.
let exact = |raise: f32| {
let mut d = Document::from_text("x");
let props = wordcraft_doc::CharProps { position: Some(raise), ..Default::default() };
let p = d.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
p.props.line_spacing = Some(LineSpacing::Exactly(20.0));
let at = p.len();
p.insert_object(at, picture(40.0, 6.0, Default::default()), &props).unwrap();
let l = lay(&d);
let Some(Placed::Lines { para, .. }) = l.pages[0].items.iter().find(|i| matches!(i, Placed::Lines { .. })) else { panic!() };
(para.lines[0].height, para.lines[0].baseline - para.lines[0].top)
};
let (h, base) = exact(-6.0);
assert!((h - 20.0).abs() < 0.01, "{h}");
assert_eq!(exact(-30.0), (h, base));
// A check box sizes its line as text of its size, alone or beside text.
let box_line = |lead: &str, m: f32, size: u32| {
let mut d = Document::from_text(lead);
-90
View File
@@ -612,93 +612,3 @@ fn a_page_ends_when_a_multiple_spaced_lines_text_no_longer_fits() {
assert!(past_margin > 0, "{multiple}: some shift should leave the last line's leading past the margin");
}
}
/// 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]
fn body_page_field_uses_the_section_number_format() {
let mut d = Document::from_text("Page \nPage ");
d.last_section.page_num_format = wordcraft_doc::section::NumFormat::CardinalText;
for (i, instr) in ["PAGE", "PAGE \\* Upper"].into_iter().enumerate() {
let p = d.para_mut(StoryRef::Body, &Path::top(i)).unwrap();
let off = p.len();
let field = InlineObject::Field { instr: instr.into(), result: "1".into(), locked: false, dirty: false, code: false, form: String::new() };
p.insert_object(off, field, &Default::default()).unwrap();
}
let l = lay(&d);
let shown: Vec<String> = l.pages[0]
.items
.iter()
.filter_map(|it| if let Placed::Lines { para, .. } = it { Some(para.shown.iter().map(|x| x.1.clone()).collect::<String>()) } else { None })
.collect();
assert_eq!(shown, ["one", "ONE"], "{shown:?}");
}
#[test]
fn reflow_page_field_uses_the_current_section_number_format() {
let mut d = Document::from_text("First section\nSecond section");
let first = d.last_section.clone();
d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().section = Some(Box::new(first));
d.last_section.page_num_format = wordcraft_doc::section::NumFormat::CardinalText;
let p = d.para_mut(StoryRef::Body, &Path::top(1)).unwrap();
let off = p.len();
p.insert_object(
off,
InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false, dirty: false, code: false, form: String::new() },
&Default::default(),
)
.unwrap();
for view in [ViewMode::Web, ViewMode::Draft, ViewMode::Read, ViewMode::Outline] {
let mut cache = LayoutCache::new();
let layout = layout(&d, &mut cache, &LayoutOptions { view, ..Default::default() });
let shown: Vec<_> = layout
.pages
.iter()
.flat_map(|page| {
page.items.iter().filter_map(move |item| {
if let Placed::Lines { para, path, .. } = item {
(path == &Path::top(1)).then(|| (page.number, para.shown.iter().map(|x| x.1.as_str()).collect::<String>()))
} else {
None
}
})
})
.collect();
assert!(!shown.is_empty(), "{view:?}: no second-section text");
for (number, text) in shown {
let expected = wordcraft_doc::section::NumFormat::CardinalText.format(number).to_lowercase();
assert!(text.ends_with(&expected), "{view:?}, page {number}: {text:?}, expected {expected:?}");
}
}
}
/// A PAGE field in a table that opens the body uses the section's number format too: the table
/// is placed without the paragraph path that sets it.
#[test]
fn table_cell_page_field_uses_the_section_number_format() {
use wordcraft_doc::Table;
let mut d = Document::from_text("after");
d.last_section.page_num_format = wordcraft_doc::section::NumFormat::CardinalText;
let mut t = Table::new(1, 1, 200.0);
let mut p = wordcraft_doc::Paragraph::with_text("", Default::default());
let field = InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false, dirty: false, code: false, form: String::new() };
p.insert_object(0, field, &Default::default()).unwrap();
if let Some(c) = t.rows.first_mut().and_then(|r| r.cells.first_mut()) {
c.blocks = vec![para_block(p)];
}
d.insert_block(StoryRef::Body, &Path::top(0), Block::Table(t)).unwrap();
let l = lay(&d);
let shown: String = l.pages[0]
.items
.iter()
.filter_map(|it| {
if let Placed::Lines { para, path, .. } = it {
(path.0.len() > 1).then(|| para.shown.iter().map(|x| x.1.clone()).collect::<String>())
} else {
None
}
})
.collect();
assert_eq!(shown, "one");
}
-43
View File
@@ -97,49 +97,6 @@ fn character_styles_and_paragraph_numbers() {
assert_eq!(h[3], ["two", "3", "3.a", "3"]);
}
/// #4985: a heading on a "none" list level (its label is its text, `Note`) shows its parent's
/// number in full context (`\w`): the parent is not taken for the heading's own level, which
/// has no number to list.
#[test]
fn full_context_keeps_the_parent_of_a_none_level_heading() {
let mut d = doc(CHAPTERS, &[r#"STYLEREF "Heading 2" \w"#, r#"STYLEREF "Heading 2" \r"#]);
let num = Arc::make_mut(&mut d.numbering).add_list(ListKind::Numbered);
if let Some(n) = Arc::make_mut(&mut d.numbering).nums.iter_mut().find(|n| n.id == num) {
let note = wordcraft_doc::numbering::Level { format: wordcraft_doc::section::NumFormat::None, text: "Note".into(), ..Default::default() };
n.level_overrides = vec![(1, note)];
}
for (i, lvl) in [(0, 0u8), (2, 0), (5, 0), (6, 1)] {
d.para_mut(StoryRef::Body, &Path::top(i)).unwrap().props.numbering = Some(NumRef { num, level: lvl });
}
assert_eq!(headers(&d)[3], ["3.Note", "3.Note"]);
}
/// #4985 review: `\t` leaves a "none" level's label, which holds no number, with nothing, and an
/// empty label of its own adds no part in full context: `3`, not `3.`.
#[test]
fn a_none_level_heading_s_number_has_no_text_and_no_trailing_period() {
let numbered = |text: &str, codes: &[&str]| {
let mut d = doc(CHAPTERS, codes);
let num = Arc::make_mut(&mut d.numbering).add_list(ListKind::Numbered);
if let Some(n) = Arc::make_mut(&mut d.numbering).nums.iter_mut().find(|n| n.id == num) {
let level = wordcraft_doc::numbering::Level { format: wordcraft_doc::section::NumFormat::None, text: text.into(), ..Default::default() };
n.level_overrides = vec![(1, level)];
}
for (i, lvl) in [(0, 0u8), (2, 0), (5, 0), (6, 1)] {
d.para_mut(StoryRef::Body, &Path::top(i)).unwrap().props.numbering = Some(NumRef { num, level: lvl });
}
headers(&d)[3].clone()
};
assert!(numbered("Note", &[r#"STYLEREF "Heading 2" \n \t"#]).is_empty(), "nothing is drawn");
// `\s` is a chapter number: a "none" label isn't one, so the heading's count stands in; a
// label showing its parent's number (`%1`, `%1 Response`) gives that number.
assert_eq!(numbered("Note", &[r#"STYLEREF "Heading 2" \s"#]), ["1"]);
assert_eq!(numbered("%1", &[r#"STYLEREF "Heading 2" \s"#]), ["3"]);
assert_eq!(numbered("%1 Response", &[r#"STYLEREF "Heading 2" \s"#, r#"STYLEREF "Heading 2" \n \t"#]), ["3", "3"]);
assert_eq!(numbered("Section%1", &[r#"STYLEREF "Heading 2" \n \t"#]), ["3"]);
assert_eq!(numbered("", &[r#"STYLEREF "Heading 2" \w"#, r#"STYLEREF "Heading 2" \r"#]), ["3", "3"]);
}
/// Editing a heading updates the headers that show it, through the paragraph cache.
#[test]
fn headers_follow_heading_edits() {
+1 -7
View File
@@ -13,8 +13,6 @@ use super::{
mod commands;
mod control;
mod files;
#[cfg(test)]
pub(crate) use files::suggested_stem;
mod fonts;
mod frame;
mod input;
@@ -441,11 +439,7 @@ impl WordApp {
return;
}
let t = theme::Tokens::get(&ctx);
self.escape_free = self.dialog.is_none()
&& !self.ui.backstage
&& self.ui.keytips == keytips::Phase::Off
&& !egui::Popup::is_any_open(&ctx)
&& !crate::chrome::title_search_open(self, &ctx);
self.escape_free = self.dialog.is_none() && !self.ui.backstage && self.ui.keytips == keytips::Phase::Off && !egui::Popup::is_any_open(&ctx);
// Keytips are read before layout so the frame paints the new state; badges paint after
// the ribbon so they sit on top of it.
crate::keytips::logic(self, &ctx);
+2 -4
View File
@@ -318,8 +318,7 @@ impl WordApp {
file_dialogs::Asked::Busy => Ok(json!({"saved": false})),
};
}
// Opening a file, or a new document from a template, reads a file that may be protected.
let retry = matches!(id, "file.open" | "file.newFromTemplate").then(|| params.clone());
let retry = (id == "file.open").then(|| params.clone());
let retry_path = retry.clone();
let r = self.execute(id, params);
if let (Err(e), Some(params)) = (&r, retry)
@@ -327,8 +326,7 @@ impl WordApp {
{
let path = params.get("path").and_then(Value::as_str).unwrap_or("");
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
self.dialog =
Some(dialogs::Dialog::Password { name, then: id.to_string(), params, password: Default::default(), message: String::new() });
self.dialog = Some(dialogs::Dialog::Password { name, params, password: Default::default(), message: String::new() });
return Ok(json!({"pending": "password"}));
}
// Whoever asked, a failed open names the file and the cause (#1103).
+3 -37
View File
@@ -298,16 +298,12 @@ impl WordApp {
}
/// The name Save suggests: the open file's own Word format (so a .docm keeps its macros),
/// otherwise .docx. A document never saved and without a title of its own is named after its
/// first words, as Word suggests them (#5793), instead of `Document1`.
/// otherwise .docx.
pub(crate) fn default_save_name(&self) -> String {
let ext = self.session.path.as_ref().and_then(|p| p.extension()).map(|e| e.to_string_lossy().to_string());
let unnamed = self.session.path.is_none() && self.session.file_name.is_none() && self.session.doc.core.title.trim().is_empty();
let first = || self.session.doc.body.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).find_map(|t| suggested_stem(&t));
let stem = unnamed.then(first).flatten().unwrap_or_else(|| self.title_stem());
match ext {
Some(e) if wordcraft_engine::io::is_word_package(&e) => format!("{stem}.{e}"),
_ => format!("{stem}.docx"),
Some(e) if wordcraft_engine::io::is_word_package(&e) => format!("{}.{e}", self.title_stem()),
_ => format!("{}.docx", self.title_stem()),
}
}
@@ -467,33 +463,3 @@ impl WordApp {
}
}
}
/// The most characters a name taken from the text keeps ([`suggested_stem`]).
const SUGGESTED_MAX: usize = 50;
/// A file name from a paragraph's text (#5793): up to its first sentence or clause end
/// (`.`, `!`, `?`, `;`, `:` and their full-width forms) or line break, without the characters a
/// file name can't hold (`\ / : * ? " < > |` and controls), white space collapsed, at most
/// [`SUGGESTED_MAX`] characters cut at a word's end, and no dot or space at either end (Windows
/// drops them). `None` when nothing is left, so the next paragraph, or `Document1`, is used.
pub(crate) fn suggested_stem(text: &str) -> Option<String> {
let end = |c: char| matches!(c, '.' | '!' | '?' | ';' | ':' | '\n' | '\r' | '\u{0B}' | '\u{2029}' | '。' | '!' | '?' | ';' | ':');
let head = text.split(end).next().unwrap_or("");
let kept: String = head.chars().map(|c| if c.is_control() || matches!(c, '\\' | '/' | '*' | '"' | '<' | '>' | '|') { ' ' } else { c }).collect();
let mut words = String::new();
for w in kept.split_whitespace() {
let next = if words.is_empty() { w.chars().count() } else { words.chars().count() + 1 + w.chars().count() };
if next > SUGGESTED_MAX {
if words.is_empty() {
words = w.chars().take(SUGGESTED_MAX).collect();
}
break;
}
if !words.is_empty() {
words.push(' ');
}
words.push_str(w);
}
let words = words.trim_matches(|c: char| c == '.' || c.is_whitespace()).to_string();
(!words.is_empty()).then_some(words)
}
+1 -12
View File
@@ -55,17 +55,6 @@ fn back_chevron_c(band_min_x: f32, center_y: f32, fullscreen: bool, content_left
pos2(band_min_x + x, center_y)
}
/// The bottom of the backstage menu: About, and the community link unless the user turned the
/// community button off — the title bar pill honors the same setting, so the menu hides the
/// link with it.
fn bottom_links(app: &WordApp) -> Vec<(&'static str, &'static str)> {
let mut links = vec![("About", "about")];
if app.ui.show_discord {
links.push(("Discord community", "discord"));
}
links
}
/// Draws the Backstage side menu and the active page. Without a document only the Home, New, Open
/// and Options pages work, and it handles Back, Escape, Save, Save As and Close routing.
pub fn show(app: &mut WordApp, ui: &mut Ui) {
@@ -166,7 +155,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
}
}
ui.with_layout(egui::Layout::bottom_up(egui::Align::Min), |ui| {
for (label, id) in bottom_links(app) {
for (label, id) in [("About", "about"), ("Discord community", "discord")] {
let (r, resp) = ui.allocate_exact_size(vec2(200.0, 32.0), Sense::click());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!(label)));
if resp.hovered() {
-23
View File
@@ -90,24 +90,6 @@ const TILE_HEIGHT_PX: usize = 1024;
/// are rendered again when they come back.
const TEXTURE_KEEP_SCREENS: f32 = 3.0;
/// The document's scroll bars (#5746): solid, so they show wherever the pointer is (egui's
/// floating ones are invisible until the pointer nears them), and with their margin exactly as
/// wide as the [`SCROLLBAR`] room the pages leave, so they neither cover nor move the pages.
fn canvas_scroll_style() -> egui::style::ScrollStyle {
let solid = egui::style::ScrollStyle::solid();
egui::style::ScrollStyle { bar_width: SCROLLBAR - solid.bar_inner_margin - solid.bar_outer_margin, ..solid }
}
/// The document's scroll bar thumb (#5746): egui paints a solid bar's track in
/// `extreme_bg_color` and its thumb in the widget fills, which our palette sets alike (`input`), so
/// the thumb would vanish into its track. Grey at rest, darker (lighter in the dark theme) under
/// the pointer and while dragged. Only the scroll area's own visuals: the pages' contents keep theirs.
fn canvas_scroll_thumb(visuals: &mut egui::Visuals, t: &Tokens) {
visuals.widgets.inactive.bg_fill = t.border_strong;
visuals.widgets.hovered.bg_fill = t.input_border;
visuals.widgets.active.bg_fill = t.text_dim;
}
pub struct CanvasState {
textures: HashMap<usize, (u64, TextureHandle)>,
/// Visible full-resolution tiles of pages too tall for one GPU texture.
@@ -1408,14 +1390,9 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
let CanvasView { area, hruler, vruler, paper, geo, dim_body, grid_story, caret, .. } = view;
let mut origin = area.min;
let mut ui_area = ui.new_child(egui::UiBuilder::new().max_rect(area));
// The bars are drawn with `ui_area`'s style; what the pages draw gets the inherited one back.
let inherited = std::sync::Arc::clone(ui_area.style());
ui_area.spacing_mut().scroll = canvas_scroll_style();
canvas_scroll_thumb(ui_area.visuals_mut(), &t);
let mut ime_geometry: Option<(Rect, Rect)> = None;
let mut ime_interrupt = false;
let out = scroll_area.show_viewport(&mut ui_area, |ui, viewport| {
ui.set_style(inherited);
origin = ui.min_rect().min - viewport.min.to_vec2();
let content = Rect::from_min_size(ui.min_rect().min, geo.size);
let resp = canvas_response(ui, content, scroll_target);
-80
View File
@@ -755,86 +755,6 @@ fn dragging_the_scrollbar_names_the_page_at_the_top() {
}
}
/// #5746: the document's scroll bars are drawn wherever the pointer is: before it has moved and
/// with it over the page, not only at the window's edge, with a thumb that stands out from its
/// track in the light and the dark theme. The vertical one fills the room the pages leave for it,
/// so it covers none of them; zoomed in, the horizontal one shows the same way.
#[test]
fn the_document_scroll_bars_show_with_the_pointer_away() {
use egui::accesskit::Role;
use egui_kittest::kittest::Queryable;
let bar = |h: &H, vertical: bool| {
h.query_all_by_role(Role::ScrollBar)
.map(|n| n.rect())
.filter(|r| (r.height() > r.width()) == vertical)
.max_by(|a, b| a.area().total_cmp(&b.area()))
};
// The opaque fills across the bar's whole width, longest first: its track, then its thumb.
let drawn = |h: &H, bar: Rect, vertical: bool| {
let across = |r: &Rect| if vertical { r.width() } else { r.height() };
let along = |r: &Rect| if vertical { r.height() } else { r.width() };
let mut fills: Vec<(f32, egui::Color32)> = h
.output()
.shapes
.iter()
.filter_map(|c| match &c.shape {
egui::Shape::Rect(r) if r.fill.a() > 0 && bar.expand(0.5).contains_rect(r.rect) && across(&r.rect) >= across(&bar) - 0.5 => {
Some((along(&r.rect), r.fill))
}
_ => None,
})
.collect();
fills.sort_by(|a, b| b.0.total_cmp(&a.0));
fills
};
// The thumb shows on its track: a clear step in grey, not the same fill.
let thumb_shows = |fills: &[(f32, egui::Color32)], what: &str| {
let [(track_len, track), (thumb_len, thumb), ..] = fills else { panic!("{what}: a track and a thumb are drawn: {fills:?}") };
assert!(thumb_len < track_len, "{what}: the thumb is shorter than its track: {fills:?}");
let step = (0..3).map(|i| (i32::from(track[i]) - i32::from(thumb[i])).abs()).max().unwrap_or(0);
assert!(step >= 40, "{what}: the thumb {thumb:?} stands out from its track {track:?}");
};
for theme in ["light", "dark"] {
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default());
for _ in 0..3 {
a.run("text.insert", json!({"text": "Page text"})).unwrap();
a.run("layout.break", json!({"kind": "page"})).unwrap();
}
a.run("view.commentsPane", json!({"value": false})).unwrap();
a.run("ui.theme", json!({"value": theme})).unwrap();
let mut h: H = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state(
|ui, app: &mut WordApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
a,
);
for _ in 0..6 {
h.step();
}
assert_eq!(Tokens::get(&h.ctx).dark, theme == "dark", "the {theme} theme is on");
let v = bar(&h, true).expect("the document's vertical scroll bar");
thumb_shows(&drawn(&h, v, true), &format!("{theme}, before the pointer has moved"));
let viewport = h.state().canvas.viewport.expect("the canvas is shown");
assert!((v.right() - viewport.right() - SCROLLBAR).abs() < 0.5, "the bar fills the room left for it: {v:?} beside {viewport:?}");
assert!(h.state().canvas.page_rects.iter().all(|p| p.right() <= v.left()), "the bar covers no page");
let page = h.state().canvas.page_rects.first().copied().expect("a page on screen");
h.event(egui::Event::PointerMoved(page.center()));
for _ in 0..30 {
h.step();
}
let v = bar(&h, true).expect("the document's vertical scroll bar");
thumb_shows(&drawn(&h, v, true), &format!("{theme}, the pointer over the page"));
h.state_mut().run("view.zoom", json!({"value": 400.0})).unwrap();
for _ in 0..30 {
h.step();
}
let across = bar(&h, false).expect("zoomed in, a horizontal scroll bar");
thumb_shows(&drawn(&h, across, false), &format!("{theme}, the horizontal bar"));
}
}
/// #3880: the status bar names the page being looked at. With the caret scrolled out of view, that
/// is the page at the top of the window; while the caret is on screen, its page.
#[test]
+2 -138
View File
@@ -446,148 +446,12 @@ pub fn title_bar(app: &mut WordApp, ui: &mut Ui) {
});
}
/// The title bar's Search box while it is typed in (#5398): the query, whether the text of the
/// shortcut that opened it (Alt+Q's `q` on some platforms) is still to be dropped, and where the
/// results showed last frame.
#[derive(Clone, Default)]
struct TitleSearch {
query: String,
swallow: bool,
results: Option<Rect>,
}
/// Width of the Search box, and of its results under it.
const SEARCH_BOX_W: f32 = 260.0;
const SEARCH_RESULTS_W: f32 = 380.0;
/// Search in the title bar's box, as Word's: when this window drew the box in its last frame
/// (the title bar, or Full Screen's Commands bar), Search opens there, typed into in place with
/// its results under it, instead of in the Search Commands dialog. `true` when it did.
pub(crate) fn open_title_search(app: &mut WordApp) -> bool {
let Some(ctx) = app.ctx.clone() else { return false };
// Focus hides the box even when the last frame, before Focus came on, drew it.
if Screen::of(&app.session.view) == Screen::Focus || !search_box_drawn(app, &ctx) {
return false;
}
let id = app.window_id("title_search");
// Only Alt+Q may send its `q` as text after it; a click sends none. Alt is read from the
// shortcut's key event too: the box takes the keys from its first frame, that same text
// included.
let swallow =
ctx.input(|i| i.modifiers.alt || i.events.iter().any(|e| matches!(e, egui::Event::Key { pressed: true, modifiers, .. } if modifiers.alt)));
ctx.data_mut(|d| d.insert_temp(id, TitleSearch { swallow, ..Default::default() }));
ctx.request_repaint();
true
}
/// What is typed in the title bar's Search, while it is open.
pub(crate) fn title_search_query(app: &WordApp, ctx: &egui::Context) -> Option<String> {
ctx.data(|d| d.get_temp::<TitleSearch>(app.window_id("title_search"))).map(|s| s.query)
}
/// Whether this window drew the Search box in its last frame.
fn search_box_drawn(app: &WordApp, ctx: &egui::Context) -> bool {
let shown = ctx.data(|d| d.get_temp::<u64>(app.window_id("title_search_shown")));
shown.is_some_and(|f| f.saturating_add(1) >= ctx.cumulative_frame_nr())
}
/// Whether the title bar's Search is being typed in (Escape closes it, not Full Screen). Search
/// whose box is no longer drawn (Focus came on) is dropped, so it can't keep Escape.
pub(crate) fn title_search_open(app: &WordApp, ctx: &egui::Context) -> bool {
if title_search_query(app, ctx).is_none() {
return false;
}
if search_box_drawn(app, ctx) {
return true;
}
ctx.data_mut(|d| d.remove::<TitleSearch>(app.window_id("title_search")));
false
}
/// The Search box being typed in: a text box in its place and the results under it. `false`
/// once it closes: a command or the find ran, Escape, or a click outside both.
fn typing_search(app: &mut WordApp, ui: &mut Ui, control_h: f32, s: &mut TitleSearch) -> bool {
if s.swallow {
ui.ctx().input_mut(|i| i.events.retain(|e| !crate::dialogs::swallow_shortcut_text(&mut s.swallow, e)));
}
let ph = tl!("Search commands and help");
// The box takes the keys from its first frame: focused before it reads this frame's input,
// so what is typed straight after Alt+Q goes into it. It takes the focus back whenever it lost
// it, except to one of its results (Tab moves there, so a keyboard user can pick one), and
// not while a press on a result is under way (#2986).
let id = app.window_id("title_search_edit");
let on_result = ui
.memory(|m| m.focused())
.filter(|f| *f != id)
.and_then(|f| ui.ctx().read_response(f))
.is_some_and(|r| s.results.is_some_and(|x| x.contains_rect(r.rect)));
if !on_result && !ui.memory(|m| m.has_focus(id)) && !ui.input(|i| i.pointer.any_down()) {
ui.memory_mut(|m| m.request_focus(id));
}
let typing = ui.memory(|m| m.has_focus(id));
let edit = ui.add_sized(
vec2(SEARCH_BOX_W, control_h),
egui::TextEdit::singleline(&mut s.query).id(id).hint_text(ph).align(if crate::rtl::on() {
Align2::RIGHT_CENTER
} else {
Align2::LEFT_CENTER
}),
);
let r = edit.rect;
// Enter in the box runs the first result, and is used up: a dialog that result opens (Word
// Count) mustn't take the same Enter as its OK. A result Tab moved to takes Enter or Space
// itself.
let (enter, escape, pressed_at) = ui.input_mut(|i| {
let pressed = i.pointer.any_pressed().then(|| i.pointer.press_origin()).flatten();
(typing && i.consume_key(egui::Modifiers::NONE, egui::Key::Enter), i.key_pressed(egui::Key::Escape), pressed)
});
let inside = |p: egui::Pos2| r.contains(p) || s.results.is_some_and(|x| x.contains(p));
if escape || pressed_at.is_some_and(|p| !inside(p)) {
return false;
}
let rtl = crate::rtl::on();
let (pivot, at) = if rtl { (Align2::RIGHT_TOP, r.right_bottom()) } else { (Align2::LEFT_TOP, r.left_bottom()) };
let mut done = false;
let shown = egui::Area::new(app.window_id("title_search_results"))
.order(egui::Order::Foreground)
.pivot(pivot)
.fixed_pos(at + vec2(0.0, 2.0))
.show(ui.ctx(), |ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
ui.set_width(SEARCH_RESULTS_W);
// Rows of a list, as a menu's, not a stack of framed buttons.
ui.style_mut().visuals.button_frame = false;
done = crate::dialogs::search_results(app, ui, &s.query, enter, SEARCH_RESULTS_W);
});
});
s.results = Some(shown.response.rect);
if done {
// A result run with Enter from the keyboard: the dialog it opens mustn't take that Enter.
ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter));
}
!done
}
/// Search ("Tell me"): a box that opens command search. In the title bar, and in the Commands bar
/// of Full Screen (#1678). Clicked (or Alt+Q), it takes the typing in place (#5398).
/// of Full Screen (#1678).
fn search_box(app: &mut WordApp, ui: &mut Ui, control_h: f32) {
let frame = ui.ctx().cumulative_frame_nr();
ui.ctx().data_mut(|d| d.insert_temp(app.window_id("title_search_shown"), frame));
let id = app.window_id("title_search");
if let Some(mut s) = ui.ctx().data(|d| d.get_temp::<TitleSearch>(id)) {
let open = typing_search(app, ui, control_h, &mut s);
ui.ctx().data_mut(|d| {
if open {
d.insert_temp(id, s);
} else {
d.remove::<TitleSearch>(id);
}
});
return;
}
let t = Tokens::get(ui.ctx());
let rtl = crate::rtl::on();
let (r, resp) = ui.allocate_exact_size(vec2(SEARCH_BOX_W, control_h), Sense::click());
let (r, resp) = ui.allocate_exact_size(vec2(260.0, control_h), Sense::click());
let ph = tl!("Search commands and help");
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, ph));
ui.painter().rect(r, 6.0, if resp.hovered() { t.input } else { t.ribbon }, Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
+3 -11
View File
@@ -35,11 +35,9 @@ use bookmarks::bookmark_at_caret;
use bookmarks::bookmark_names;
pub use chrome::show;
pub(crate) use chrome::{block_behind, buttons, cancel_key_pressed, color_menu, forget_nudge, ok_cancel_responses, release_keyboard};
pub(crate) use dispatch::search_results;
pub(crate) use find::swallow_shortcut_text;
pub use find::{FindExtra, FindTab, GoToWhat};
#[cfg(test)]
use find::{command_matches, goto_page};
use find::{command_matches, goto_page, swallow_shortcut_text};
#[cfg(test)]
use font::{apply_font, preview_size};
use font::{east_asian_char, font_props};
@@ -416,12 +414,10 @@ pub enum Dialog {
#[serde(skip)]
basis: Box<CellMarginsForm>,
},
/// Opening a password-protected document (#55): the password, then the command that asked
/// for it (`then`: `file.open`, or `file.newFromTemplate` for a protected template, #3433)
/// with `params` again. The password is never serialised or printed.
/// Opening a password-protected document (#55): the password, then `file.open` with
/// `params` again. The password is never serialised or printed.
Password {
name: String,
then: String,
#[serde(skip)]
params: Value,
#[serde(skip)]
@@ -579,10 +575,6 @@ impl Dialog {
}
pub fn open(name: &str, app: &mut WordApp) -> Option<Dialog> {
// Search opens in the title bar's box when the window shows it (#5398).
if name == "commands" && crate::chrome::open_title_search(app) {
return None;
}
if let Some(d) = crate::dialogs_insert::open(name, app) {
return Some(Dialog::Insert(Box::new(d)));
}
@@ -208,7 +208,13 @@ fn language_dialog_default_survives_a_restart() {
use egui_kittest::kittest::Queryable;
let mut a = app();
a.ui.language = "en".into();
a.dialog = Some(Dialog::Layout(Box::new(LayoutDialog::Language(LanguageForm { lang: "fr-FR".into(), no_proof: false, detect: false }))));
a.dialog = Some(Dialog::Layout(Box::new(LayoutDialog::Language(LanguageForm {
lang: "fr-FR".into(),
no_proof: false,
detect: false,
whole_document: false,
initial: ("fr-FR".into(), false),
}))));
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(
|ui, app: &mut WordApp| {
app.logic(&ui.ctx().clone());
@@ -232,6 +238,96 @@ fn language_dialog_default_survives_a_restart() {
assert_eq!(LanguageForm::read(&b).lang, "fr-FR");
}
/// #3518: ticking "Apply to the whole document" makes OK send `scope: "document"`; unticked sends none.
#[test]
fn language_dialog_whole_document_checkbox_sends_scope() {
use egui_kittest::kittest::Queryable;
let f = LanguageForm { lang: "fr-FR".into(), no_proof: false, detect: false, whole_document: false, initial: ("fr-FR".into(), false) };
assert!(f.params().get("scope").is_none());
assert_eq!(LanguageForm { whole_document: true, ..f.clone() }.params()["scope"], "document");
let mut a = app();
a.ui.language = "en".into();
let para = wordcraft_doc::Paragraph::with_text("Header", Default::default());
let header = a.session.doc.add_part(wordcraft_doc::PartKind::Header, vec![Arc::new(wordcraft_doc::Block::Para(para))]);
a.dialog = Some(Dialog::Layout(Box::new(LayoutDialog::Language(f))));
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(
|ui, app: &mut WordApp| {
app.logic(&ui.ctx().clone());
app.ui(ui);
},
a,
);
h.run_steps(3);
h.get_by_label("Apply to the whole document").click();
h.run_steps(3);
h.get_by_label("OK").click();
h.run_steps(3);
// The header, which the caret isn't in, took the language.
let doc = &h.state().session.doc;
let hp = doc.para(wordcraft_doc::StoryRef::Part(header), &wordcraft_doc::Path::top(0)).unwrap();
assert_eq!(hp.runs.first().and_then(|r| r.props.lang.as_deref()), Some("fr-FR"));
}
/// "Plain text, then a Do-not-check run", the caret in the plain text.
fn proofing_mix_app() -> WordApp {
let mut a = app();
a.run("text.insert", json!({"text": "Plain code"})).unwrap();
a.run("select.text", json!({"text": "code"})).unwrap();
a.run("review.language", json!({"lang": "de-DE", "noProof": true})).unwrap();
a.run("select.text", json!({"text": "Plain"})).unwrap();
a.run("review.language", json!({"lang": "en-US", "noProof": false})).unwrap();
a.run("select.text", json!({"text": "Plain"})).unwrap();
a
}
fn run_proofing(a: &mut WordApp, text: &str) -> (Option<String>, Option<bool>) {
a.run("select.text", json!({"text": text})).unwrap();
let st = a.run("format.state", json!({})).unwrap();
(st["lang"].as_str().map(String::from), st["noProof"].as_bool())
}
/// #3518: whole-document OK sends only what the user changed.
#[test]
fn language_dialog_whole_document_sends_only_changes() {
// Changing only the language leaves the Do-not-check run alone.
let mut a = proofing_mix_app();
let mut f = LanguageForm::read(&a);
f.whole_document = true;
f.lang = "en-GB".into();
let p = f.params();
assert!(p.get("noProof").is_none());
a.run("review.language", p).unwrap();
assert_eq!(run_proofing(&mut a, "code"), (Some("en-GB".into()), Some(true)));
assert_eq!(run_proofing(&mut a, "Plain"), (Some("en-GB".into()), Some(false)));
// Changing only Do-not-check leaves each run's language alone.
let mut a = proofing_mix_app();
let mut f = LanguageForm::read(&a);
f.whole_document = true;
f.no_proof = true;
let p = f.params();
assert!(p.get("lang").is_none());
a.run("review.language", p).unwrap();
assert_eq!(run_proofing(&mut a, "code"), (Some("de-DE".into()), Some(true)));
assert_eq!(run_proofing(&mut a, "Plain"), (Some("en-US".into()), Some(true)));
// No edits: the caret's language, no noProof (never the engine's en-US default).
let mut a = proofing_mix_app();
let mut f = LanguageForm::read(&a);
f.lang = "fr-FR".into();
f.initial.0 = "fr-FR".into();
f.whole_document = true;
let p = f.params();
assert_eq!(p["lang"], "fr-FR");
assert!(p.get("noProof").is_none());
a.run("review.language", p).unwrap();
assert_eq!(run_proofing(&mut a, "code"), (Some("fr-FR".into()), Some(true)));
// Selection mode is unchanged: both are sent.
f.whole_document = false;
assert_eq!(f.params()["noProof"], false);
}
/// #407: Envelopes starts from the selected address; Add to Document puts an envelope
/// section before the letter without asking to save (nothing is replaced).
#[test]
@@ -320,6 +320,11 @@ pub struct LanguageForm {
pub lang: String,
pub no_proof: bool,
pub detect: bool,
/// "Apply to the whole document": OK sends `scope: "document"` (every story), not the selection.
pub whole_document: bool,
/// The language and Do-not-check state the dialog opened with, to tell what the user changed.
#[serde(skip)]
pub initial: (String, bool),
}
impl LanguageForm {
@@ -328,12 +333,27 @@ impl LanguageForm {
let style = s.doc.para_at(&s.sel.focus).and_then(|p| p.props.style.as_deref());
let here = s.doc.styles.resolve_char(style, &s.typing_props());
let lang = here.lang.unwrap_or_else(|| "en-US".into());
LanguageForm { lang, no_proof: here.no_proof, detect: s.prefs.detect_language }
LanguageForm { initial: (lang.clone(), here.no_proof), lang, no_proof: here.no_proof, detect: s.prefs.detect_language, whole_document: false }
}
/// `review.language` parameters for OK.
pub fn params(&self) -> Value {
json!({"lang": self.lang, "noProof": self.no_proof, "detect": self.detect})
if !self.whole_document {
return json!({"lang": self.lang, "noProof": self.no_proof, "detect": self.detect});
}
// Whole document: the caret's values must not flatten what differs elsewhere, so send only
// what was changed. With no edits at all the language goes (never the engine's en-US default).
let (lang_changed, proof_changed) = (self.lang != self.initial.0, self.no_proof != self.initial.1);
let mut v = json!({"scope": "document", "detect": self.detect});
if let Some(m) = v.as_object_mut() {
if lang_changed || !proof_changed {
m.insert("lang".into(), json!(self.lang));
}
if proof_changed {
m.insert("noProof".into(), json!(self.no_proof));
}
}
v
}
}
@@ -343,7 +363,7 @@ pub(super) fn language_name(tag: &str) -> String {
}
pub(super) fn language_ui(app: &mut WordApp, ui: &mut Ui, f: &mut LanguageForm) -> bool {
ui.label(tl!("Mark selected text as:"));
ui.label(if f.whole_document { tl!("Mark the whole document as:") } else { tl!("Mark selected text as:") });
let list = crate::ribbon::language_list(&f.lang);
egui::Frame::NONE.stroke(egui::Stroke::new(1.0, crate::theme::Tokens::get(ui.ctx()).border_strong)).inner_margin(4.0).show(ui, |ui| {
egui::ScrollArea::vertical().max_height(220.0).min_scrolled_height(220.0).show(ui, |ui| {
@@ -360,6 +380,7 @@ pub(super) fn language_ui(app: &mut WordApp, ui: &mut Ui, f: &mut LanguageForm)
ui.add_space(4.0);
ui.checkbox(&mut f.no_proof, tl!("Do not check spelling or grammar"));
ui.checkbox(&mut f.detect, tl!("Detect language automatically"));
ui.checkbox(&mut f.whole_document, tl!("Apply to the whole document"));
ui.add_space(4.0);
let set_default = ui.button(tl!("Set As Default")).clicked();
if set_default && run(app, "review.language", json!({"lang": f.lang, "default": true, "detect": f.detect})) {
+1 -6
View File
@@ -534,12 +534,7 @@ impl BordersForm {
}
if self.fill != b.fill || self.fill_to_text != b.fill_to_text {
let color = Rgb::parse(&self.fill).map(|c| json!(c.hex())).unwrap_or(Value::Null);
// Apply to: Paragraph shades the paragraphs even for text selected inside one.
out.push(if self.fill_to_text {
("format.shading", json!({"color": color}))
} else {
("para.shading", json!({"color": color, "paragraph": true}))
});
out.push((if self.fill_to_text { "format.shading" } else { "para.shading" }, json!({"color": color})));
}
out
}
+11 -32
View File
@@ -581,20 +581,8 @@ fn insert_table_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
}
/// A margin's number box, in the unit the interface measures in.
fn margin(value: &mut f32) -> impl egui::Widget + '_ {
move |ui: &mut Ui| {
// DragValue's unfocused button extends to fit its value; keep it inside the field too.
let (_, rect) = ui.allocate_space(egui::vec2(80.0, ui.spacing().interact_size.y));
let mut field = ui.new_child(egui::UiBuilder::new().max_rect(rect).layout(crate::rtl::row_layout(ui, egui::Align::Center)));
field.shrink_clip_rect(rect);
field.add(
egui::DragValue::new(value)
.speed(3.6 / crate::options::unit().pt_per_unit())
.measure(crate::options::unit())
.max_decimals(2)
.clip_text(true),
)
}
fn margin(value: &mut f32) -> egui::DragValue<'_> {
egui::DragValue::new(value).speed(3.6 / crate::options::unit().pt_per_unit()).measure(crate::options::unit()).max_decimals(2)
}
fn page_setup_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
@@ -652,9 +640,9 @@ fn page_setup_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
crate::rtl::Table::new("ps_grid").columns(4).show(ui, |t| {
t.row(|r| {
let chars_label = r.cell(|ui| ui.add_enabled(grid.0 >= 2, egui::Label::new(tl!("Characters per line:"))));
r.cell(|ui| ui.add_enabled(grid.0 >= 2, egui::DragValue::new(&mut grid.2).range(1..=1000).clip_text(true)).labelled_by(chars_label.id));
r.cell(|ui| ui.add_enabled(grid.0 >= 2, egui::DragValue::new(&mut grid.2).range(1..=1000)).labelled_by(chars_label.id));
let lines_label = r.cell(|ui| ui.add_enabled(grid.0 >= 1, egui::Label::new(tl!("Lines per page:"))));
r.cell(|ui| ui.add_enabled(grid.0 >= 1, egui::DragValue::new(&mut grid.1).range(1..=1000).clip_text(true)).labelled_by(lines_label.id));
r.cell(|ui| ui.add_enabled(grid.0 >= 1, egui::DragValue::new(&mut grid.1).range(1..=1000)).labelled_by(lines_label.id));
});
});
ui.horizontal(|ui| {
@@ -1067,15 +1055,6 @@ fn commands_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
if !r.has_focus() {
r.request_focus();
}
let enter = ui.input(|i| i.key_pressed(egui::Key::Enter));
search_results(app, ui, query, enter, 380.0) || ui.input(|i| i.key_pressed(egui::Key::Escape))
}
/// Search's results under its box (the Search Commands dialog, or the title bar's drop-down,
/// #5398): suggestions for an empty box, else the matching commands and "Find … in the
/// document". A click, or `enter` (the first command, else the find), does it: `true` when one
/// was done, so the search closes.
pub(crate) fn search_results(app: &mut WordApp, ui: &mut Ui, query: &str, enter: bool, width: f32) -> bool {
let reg = app.session.registry.clone();
// An empty box suggests a few common commands, as Word's Search does.
let mut hits: Vec<&wordcraft_engine::CommandSpec> = if query.trim().is_empty() {
@@ -1085,11 +1064,12 @@ pub(crate) fn search_results(app: &mut WordApp, ui: &mut Ui, query: &str, enter:
reg.all().iter().filter(|c| !NOT_IN_SEARCH.contains(&c.id) && command_matches(c, query)).collect()
};
hits.truncate(14);
let enter = ui.input(|i| i.key_pressed(egui::Key::Enter));
let mut run: Option<&str> = enter.then(|| hits.first().map(|c| c.id)).flatten();
for c in &hits {
let sc = crate::widgets::shortcut_text(app, c.id);
let label = format!("{} — {}", tl!(c.label), crate::i18n::location(c.location));
if ui.add(egui::Button::new(label).shortcut_text(sc).min_size(vec2(width, 0.0))).clicked() {
if ui.add(egui::Button::new(label).shortcut_text(sc).min_size(vec2(380.0, 0.0))).clicked() {
run = Some(c.id);
}
}
@@ -1099,14 +1079,14 @@ pub(crate) fn search_results(app: &mut WordApp, ui: &mut Ui, query: &str, enter:
if !text.is_empty() {
ui.separator();
let label = crate::i18n::fmt(tl!("Find “{text}” in the document"), &[("text", &text)]);
find = ui.add(egui::Button::new(label).min_size(vec2(width, 0.0))).clicked() || (enter && hits.is_empty());
find = ui.add(egui::Button::new(label).min_size(vec2(380.0, 0.0))).clicked() || (enter && hits.is_empty());
}
if let Some(id) = run {
let _ = app.run(id, json!({}));
} else if find {
find_in_navigation_pane(app, ui.ctx(), &text);
}
run.is_some() || find
run.is_some() || find || ui.input(|i| i.key_pressed(egui::Key::Escape))
}
fn paste_special_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
@@ -1290,7 +1270,7 @@ fn insert_merge_field_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) ->
}
fn password_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
let Dialog::Password { name, then, params, password, message } = d else {
let Dialog::Password { name, params, password, message } = d else {
return false;
};
ui.label(crate::i18n::fmt(tl!("{name} is protected with a password."), &[("name", name)]));
@@ -1311,9 +1291,8 @@ fn password_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
if let Some(o) = p.as_object_mut() {
o.insert("password".into(), json!(password.as_str()));
}
// `execute`, not `run`: Save Changes was asked when the open began. The command that
// asked runs again, so a protected template still starts a new document (#3433).
match app.execute(then, p) {
// `execute`, not `run`: Save Changes was asked when the open began.
match app.execute("file.open", p) {
Ok(_) => {
app.ui.backstage = false;
return true;
+1 -1
View File
@@ -137,7 +137,7 @@ pub(super) fn find_in_navigation_pane(app: &mut WordApp, ctx: &egui::Context, te
/// Whether `e` is the text of the shortcut that opened a box (see `Dialog::Commands::swallow`):
/// a plain key press first means the user is typing, and one swallowed text is all there is.
pub(crate) fn swallow_shortcut_text(swallow: &mut bool, e: &egui::Event) -> bool {
pub(super) fn swallow_shortcut_text(swallow: &mut bool, e: &egui::Event) -> bool {
match e {
egui::Event::Key { pressed: true, modifiers, .. } if !modifiers.alt => {
*swallow = false;
+2 -3
View File
@@ -112,13 +112,12 @@ pub(super) fn page_paper_group(ui: &mut Ui, paper: &mut (f32, f32), landscape: &
t.row(|r| {
let width_label = r.cell(|ui| ui.label(tl!(l.width)));
let w = r.cell(|ui| {
ui.add(egui::DragValue::new(&mut paper.0).speed(3.6 / ppu).range(range.clone()).measure(unit).max_decimals(2).clip_text(true))
ui.add(egui::DragValue::new(&mut paper.0).speed(3.6 / ppu).range(range.clone()).measure(unit).max_decimals(2))
.labelled_by(width_label.id)
});
let height_label = r.cell(|ui| ui.label(tl!(l.height)));
let h = r.cell(|ui| {
ui.add(egui::DragValue::new(&mut paper.1).speed(3.6 / ppu).range(range).measure(unit).max_decimals(2).clip_text(true))
.labelled_by(height_label.id)
ui.add(egui::DragValue::new(&mut paper.1).speed(3.6 / ppu).range(range).measure(unit).max_decimals(2)).labelled_by(height_label.id)
});
if (w.changed() || h.changed()) && paper.0 != paper.1 {
*landscape = paper.0 > paper.1;
-139
View File
@@ -985,145 +985,6 @@ fn opened_paper(app: &mut WordApp) -> ((f32, f32), Option<&'static str>) {
((paper.0 * ppu, paper.1 * ppu), paper_entry(paper))
}
/// #5650: a long paste scrolls inside Page Setup's number boxes instead of enlarging the
/// dialog, including after leaving the field and reopening the dialog.
#[test]
fn page_setup_number_fields_do_not_expand_after_paste() {
use egui::accesskit::Role;
use egui_kittest::kittest::Queryable;
for label in ["Bottom:", "Top:", "Left:", "Right:", "Width:", "Height:", "Characters per line:", "Lines per page:"] {
let mut h = app_harness(app_with("Page"));
h.state_mut().run("ui.dialog", json!({"name": "pageSetup"})).unwrap();
if let Some(Dialog::PageSetup { grid, .. }) = h.state_mut().dialog.as_mut() {
grid.0 = 2; // Enable both document-grid number boxes.
}
h.run_steps(3);
let width =
|h: &egui_kittest::Harness<'_, WordApp>| h.ctx.memory(|m| m.area_rect(egui::Id::unique(("dialog", "Page Setup")))).unwrap().width();
let before = width(&h);
h.get_by_role_and_label(Role::SpinButton, label).click();
h.run_steps(2);
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A);
h.event(egui::Event::Paste("a".repeat(400)));
h.run_steps(3);
assert!(width(&h) <= before + 2.0, "{label}: pasted text grew the dialog from {before} to {}", width(&h));
h.key_press(egui::Key::Tab);
h.run_steps(3);
assert!(width(&h) <= before + 2.0, "{label}: leaving the field kept a larger dialog");
h.get_by_role_and_label(Role::Button, "Cancel").click();
h.run_steps(3);
assert!(h.state().dialog.is_none());
h.state_mut().run("ui.dialog", json!({"name": "pageSetup"})).unwrap();
h.run_steps(3);
assert!(width(&h) <= before + 2.0, "{label}: reopening kept a larger dialog");
}
}
/// A finite value can outgrow DragValue's unfocused button even when its editor clips.
#[test]
fn page_setup_large_numeric_margins_do_not_expand_after_leaving_field() {
use egui::accesskit::Role;
use egui_kittest::kittest::Queryable;
for label in ["Bottom:", "Top:", "Left:", "Right:"] {
let mut h = app_harness(app_with("Page"));
h.state_mut().run("ui.dialog", json!({"name": "pageSetup"})).unwrap();
h.run_steps(3);
let width =
|h: &egui_kittest::Harness<'_, WordApp>| h.ctx.memory(|m| m.area_rect(egui::Id::unique(("dialog", "Page Setup")))).unwrap().width();
let before = width(&h);
h.get_by_role_and_label(Role::SpinButton, label).click();
h.run_steps(2);
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A);
h.event(egui::Event::Paste(format!("1{}", "0".repeat(36))));
h.run_steps(3);
let Some(Dialog::PageSetup { top, bottom, left, right, .. }) = h.state().dialog.as_ref() else { panic!("no page setup dialog") };
let value = match label {
"Bottom:" => *bottom,
"Top:" => *top,
"Left:" => *left,
_ => *right,
};
assert!(value.is_finite() && value > 1e35, "{label}: the large finite paste was accepted: {value}");
assert!(width(&h) <= before + 2.0, "{label}: editing grew the dialog from {before} to {}", width(&h));
h.key_press(egui::Key::Tab);
h.run_steps(3);
let Some(Dialog::PageSetup { top, bottom, left, right, .. }) = h.state().dialog.as_ref() else { panic!("no page setup dialog") };
let after = match label {
"Bottom:" => *bottom,
"Top:" => *top,
"Left:" => *left,
_ => *right,
};
assert_eq!(after, value, "{label}: leaving the field keeps the accepted value");
assert!(width(&h) <= before + 2.0, "{label}: leaving the large numeric field grew the dialog from {before} to {}", width(&h));
h.get_by_role_and_label(Role::Button, "Cancel").click();
h.run_steps(3);
assert!(h.state().dialog.is_none());
}
}
/// A clipped wide number in a mirrored form can still be clicked where it is visible.
#[test]
fn page_setup_rtl_large_margin_can_be_edited_again_after_blur() {
use egui::accesskit::Role;
use egui_kittest::kittest::Queryable;
let mut app = app_with("Page");
app.run("ui.dialog", json!({"name": "pageSetup"})).unwrap();
let ar = crate::i18n::fixture_lang("ar", "\tMargins\tMargins\n");
let mut h = egui_kittest::Harness::builder().with_size(vec2(1440.0, 900.0)).build_ui_state(
move |ui, app: &mut WordApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
// Draw the actual dialog in a fixture language even without embedded catalogs.
crate::i18n::set_current(ar);
crate::rtl::sync(&ctx);
assert!(crate::rtl::on() && ctx.global_style().right_to_left);
show(app, &ctx);
crate::i18n::set_current(crate::i18n::Lang::EN);
},
app,
);
h.run_steps(4);
let initial = h.get_by_role_and_label(Role::SpinButton, "Bottom:").rect();
assert!(h.get_by_role_and_label(Role::SpinButton, "Top:").rect().center().x > initial.center().x, "the form is mirrored");
let window = |h: &egui_kittest::Harness<'_, WordApp>| h.ctx.memory(|m| m.area_rect(egui::Id::unique(("dialog", "Page Setup")))).unwrap();
let before = window(&h).width();
h.get_by_role_and_label(Role::SpinButton, "Bottom:").click();
h.run_steps(2);
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A);
h.event(egui::Event::Paste(format!("1{}", "0".repeat(36))));
h.run_steps(3);
let value = match h.state().dialog.as_ref() {
Some(Dialog::PageSetup { bottom, .. }) => *bottom,
_ => panic!("no page setup dialog"),
};
assert!(value.is_finite() && value > 1e35);
h.key_press(egui::Key::Tab);
h.run_steps(3);
assert!(matches!(h.state().dialog.as_ref(), Some(Dialog::PageSetup { bottom, .. }) if *bottom == value));
let field = h.get_by_role_and_label(Role::SpinButton, "Bottom:");
assert!(!field.is_focused());
let wide = field.rect();
let visible = wide.intersect(initial).intersect(window(&h));
assert!(wide.width() > 80.0 && visible.is_positive() && !visible.contains(wide.center()));
assert!(window(&h).width() <= before + 2.0);
// The AccessKit bounds include clipped text: click the visible part, not their centre.
let pos = visible.center();
h.event(egui::Event::PointerMoved(pos));
for pressed in [true, false] {
h.event(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE });
}
h.run_steps(3);
assert!(h.get_by_role_and_label(Role::SpinButton, "Bottom:").is_focused(), "the visible field enters editing");
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A);
h.event(egui::Event::Paste("2.5".into()));
h.run_steps(3);
h.key_press(egui::Key::Tab);
h.run_steps(3);
assert!(matches!(h.state().dialog.as_ref(), Some(Dialog::PageSetup { bottom, .. }) if *bottom == 2.5), "the field accepts a normal value again");
assert!(window(&h).width() <= before + 2.0);
}
/// #1433: Page Setup's Paper size shows the named size the section has, in either
/// orientation, and Custom size (None) for anything else.
#[test]
-31
View File
@@ -130,28 +130,6 @@ impl WordApp {
self.run("file.open", json!({"path": path}))
}
/// Open a file the platform hands the app: one named on the command line (a double-click in
/// the file manager, the shell's Open verb) or sent by the Finder. A Word template (`.dotx`,
/// `.dotm`, `.dot`) starts a new, untitled document based on it, as Word's default verb for a
/// template does (#3433): saving it asks where to write, and the template is never written
/// over. File › Open still opens a template itself, to edit it, as in Word. Anything else
/// opens as [`WordApp::open_file`] opens it. `data` holds the file's bytes (base64) when its
/// name can't be passed as text (#1440); `path` then only names it.
pub fn open_from_platform(&mut self, path: &str, data: Option<&str>) -> Result<Value, String> {
let mut params = json!({"path": path});
if let Some(data) = data {
params["data"] = json!(data);
}
if is_word_template(path) {
params["replaceBlank"] = json!(true);
return self.run("file.newFromTemplate", params);
}
match data {
Some(_) => self.run("file.open", params),
None => self.open_file(path, None),
}
}
/// Show open document `id` in the window (View › Window › Switch Windows, quitting).
pub(crate) fn show_document(&mut self, id: u64) -> Result<(), String> {
self.session.activate(id).map_err(|e| e.to_string())?;
@@ -219,15 +197,6 @@ impl WordApp {
}
}
/// Is `path` a Word template by its extension (`.dotx`, `.dotm`, `.dot`, in any case)? These are
/// the template types WordCraft registers with the platform.
pub fn is_word_template(path: &str) -> bool {
std::path::Path::new(path)
.extension()
.and_then(std::ffi::OsStr::to_str)
.is_some_and(|e| ["dotx", "dotm", "dot"].iter().any(|t| e.eq_ignore_ascii_case(t)))
}
/// The name an open document goes by in the window: the engine's title, with an untitled
/// document's `Document2` in the interface language.
pub fn display_title(d: &DocInfo) -> String {
+1 -2
View File
@@ -315,8 +315,7 @@ fn save_as_answer_arriving_later_is_saved() {
typed(&mut a);
a.save_as_dialog();
assert!(a.file_dialog_open());
// A new document is offered its first words as its name, as in Word (#5793).
assert_eq!(shown.borrow()[0].0, FileDialogRequest::Save { name: format!("{UNSAVED}.docx"), dir: None });
assert_eq!(shown.borrow()[0].0, FileDialogRequest::Save { name: "Document1.docx".into(), dir: None });
let ctx = egui::Context::default();
let frame = |a: &mut WordApp| ctx.run_ui(egui::RawInput::default(), |ui| a.logic(ui.ctx())).drop_without_applying_deltas();
frame(&mut a);
-3
View File
@@ -72,9 +72,6 @@ pub(super) fn styles(app: &mut WordApp, ui: &mut Ui) {
if ui.selectable_label(app.session.view.style_inspector, tl!("Style Inspector")).clicked() {
let _ = app.run("styles.inspector", json!({}));
}
// A solid scroll bar takes its own room beside the list instead of floating over the a / ¶ markers.
// Its handle keeps the text colour: the solid default fills it like a button, white on white here.
ui.spacing_mut().scroll = egui::style::ScrollStyle { foreground_color: true, ..egui::style::ScrollStyle::solid() };
ui.separator();
let mut list: Vec<(String, String, bool, bool)> = app
.session
-128
View File
@@ -309,78 +309,6 @@ fn pane_bodies_fit_the_narrowest_pane() {
assert!(wide.is_empty(), "wider than the pane: {wide:?}");
}
/// #4482: at the 200 pt minimum width every Navigation tab is whole, in the longest labels. The
/// fixture puts the longest translation of each tab in craft-i18n (at `craft-i18n.rev`) side by
/// side; with the catalogs built in, every interface language is checked too, and none may have
/// a wider tab than the fixture (else the fixture needs the new longest label).
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn navigation_tabs_fit_the_narrowest_pane_in_every_language() {
use egui_kittest::kittest::Queryable;
// The language is thread-local: put English back even when an assertion fails.
struct English;
impl Drop for English {
fn drop(&mut self) {
crate::i18n::set_current(crate::i18n::Lang::EN);
}
}
let _english = English;
// 200 pt less the 10 pt body margins and the frame's 1 pt strokes.
const BODY: f32 = 178.0;
let longest = crate::i18n::fixture_lang("x-fixture", "\tHeadings\tÜberschriften\n\tPages\tСтраницы\n\tResults\tEredmények\n");
let mut widest = Vec::new();
let mut langs = vec![longest];
if crate::i18n::catalogs_embedded() {
// Chinese, Japanese and Korean need a craft-fonts build (their labels are short anyway).
let cjk = !wordcraft_fonts::ui_cjk_fonts(false).is_empty();
langs.extend(crate::i18n::Lang::all().filter(|l| cjk || !l.uses_cjk()));
}
for (n, lang) in langs.into_iter().enumerate() {
let app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("Hello")), crate::Services::default());
let body = std::rc::Rc::new(std::cell::Cell::new(egui::Rect::NOTHING));
let seen = body.clone();
let mut fonts = false;
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(400.0, 600.0)).build_ui_state(
move |ui, app: &mut crate::WordApp| {
crate::i18n::set_current(lang);
// Fonts take effect the frame after they are installed.
if !fonts {
crate::theme::apply(ui.ctx(), crate::theme::Appearance::Light);
crate::theme::install_fonts_language(ui.ctx(), lang.prefers_hans(), lang.prefers_korean());
fonts = true;
return;
}
let r = ui.allocate_ui_with_layout(vec2(BODY, 600.0), egui::Layout::top_down(egui::Align::Min), |ui| {
ui.set_max_width(BODY);
Content { app: &mut *app }.body(ui, &Pane::Navigation);
});
seen.set(egui::Rect::from_min_size(r.response.rect.min, vec2(BODY, 600.0)));
},
app,
);
h.run_steps(3);
for (i, tab) in ["Headings", "Pages", "Results"].into_iter().enumerate() {
let label = crate::i18n::tr(lang, tab);
let r = h.get_by_label(label).rect();
assert!(body.get().expand(0.5).contains_rect(r), "{}: {label} at {r:?} is cut off by the {BODY} pt body", lang.code());
let font = egui::TextStyle::Button.resolve(&h.ctx.global_style());
let text = h.ctx.fonts_mut(|f| f.layout_no_wrap(label.to_string(), font, egui::Color32::WHITE).size().x);
// A tab that shrank to fit would have elided or wrapped its label inside a rect that
// still fits the body.
assert!(r.width() + 0.5 >= text, "{}: {label} is {:.1} pt wide, narrower than its {text:.1} pt label", lang.code(), r.width());
if n == 0 {
widest.push(text);
} else {
assert!(
text <= widest[i] + 0.5,
"{}: {label} is wider than the fixture's {tab} label; put {label:?} in the fixture as the longest {tab}",
lang.code()
);
}
}
}
}
/// A pane other than Styles floated from its menu comes back floating, at the same rect, after
/// a restart; junk in `panePlaces` reads as the defaults (#1756).
#[test]
@@ -540,59 +468,3 @@ fn a_long_comment_card_keeps_its_buttons_and_the_next_card_in_view() {
assert_eq!(go_to.len(), 2, "one Go to button per card");
assert!(go_to.iter().all(|r| r.max.y < 900.0), "both cards' buttons are in view: {go_to:?}");
}
/// #4753: the Styles pane's scroll bar has a column of its own, so with the pointer over it the
/// "a" and "¶" markers at the right of each row stay clear of it; its handle still shows, light
/// or dark.
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn the_styles_pane_scroll_bar_leaves_the_style_markers_clear() {
use egui_kittest::kittest::Queryable;
for appearance in [crate::theme::Appearance::Light, crate::theme::Appearance::Dark] {
let app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("Hello")), crate::Services::default());
let size = egui::vec2(240.0, 400.0);
let right = std::rc::Rc::new(std::cell::Cell::new(0.0));
let edge = right.clone();
let mut fonts = false;
let mut h = egui_kittest::Harness::builder().with_size(size).build_ui_state(
move |ui, app: &mut crate::WordApp| {
if !fonts {
crate::theme::install_fonts(ui.ctx());
crate::theme::apply(ui.ctx(), appearance);
fonts = true;
return;
}
edge.set(ui.max_rect().right());
super::styles(app, ui);
},
app,
);
h.run_steps(4);
// On the bar, along the pane's right edge, where it is at its widest.
h.hover_at(egui::pos2(right.get() - 3.0, size.y - 60.0));
h.run_steps(20);
let markers: Vec<egui::Rect> = h.get_all_by_label("").chain(h.get_all_by_label("a")).map(|n| n.rect()).collect();
assert!(markers.len() > 5, "{appearance:?}: the list shows its markers: {markers:?}");
// Only a scroll bar is a narrow, tall filled rectangle along the list's right edge.
let bars: Vec<(egui::Rect, egui::Color32)> = h
.output()
.shapes
.iter()
.filter_map(|c| match &c.shape {
egui::Shape::Rect(r) if r.fill.a() > 0 && r.rect.width() < 20.0 && r.rect.height() > 3.0 * r.rect.width() => {
Some((r.rect.intersect(c.clip_rect), r.fill))
}
_ => None,
})
.collect();
assert!(bars.iter().any(|(b, _)| b.x_range().contains(right.get() - 3.0)), "{appearance:?}: the pointer is on the bar: {bars:?}");
let covered: Vec<_> = markers.iter().filter(|m| bars.iter().any(|(b, _)| b.intersects(**m))).collect();
assert!(covered.is_empty(), "{appearance:?}: the scroll bar {bars:?} covers markers {covered:?}");
// The track is the pane's field colour; the handle stands out from it.
let track = h.ctx.global_style().visuals.extreme_bg_color;
assert!(
bars.iter().any(|(_, fill)| fill.r().abs_diff(track.r()) > 60),
"{appearance:?}: the handle shows against the track {track:?}: {bars:?}"
);
}
}
+2 -8
View File
@@ -147,14 +147,8 @@ pub(crate) fn paste(app: &mut WordApp) -> Option<Result<Value, String>> {
}
let mut r = Err(String::new());
for path in files {
// A clipboard path can name a pipe or device that reports no length and never ends.
let md = std::fs::metadata(&path).ok();
let size = md.as_ref().map_or(0, |m| m.len());
r = if md.as_ref().is_some_and(|m| !m.is_file()) {
let e = format!("{} is not a regular file", path.display());
app.status(e.clone());
Err(e)
} else if size > MAX_FILE_BYTES {
let size = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
r = if size > MAX_FILE_BYTES {
let e = format!("{} is larger than 200 MB", path.display());
app.status(e.clone());
Err(e)
+2 -10
View File
@@ -582,13 +582,10 @@ pub fn style_gallery(app: &mut WordApp, ui: &mut Ui, state: &Value) {
// A compact ribbon group (#662, #1285) trims the gallery to fewer tiles before it folds.
let visible = if crate::ribbon_fit::compact(ui.ctx()) { STYLE_TILES_COMPACT } else { STYLE_TILES };
crate::ribbon_fit::add_saving(ui.ctx(), (STYLE_TILES - STYLE_TILES_COMPACT) as f32 * 76.0);
// A mirrored interface (#5566) starts the tiles at the right and puts the arrows at the left.
let rtl = crate::rtl::flows(ui);
let (area, _) = ui.allocate_exact_size(vec2(visible as f32 * 76.0 + 18.0, crate::widgets::CONTENT_H), Sense::hover());
ui.painter().rect(area.shrink(1.0), 4.0, t.input, Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
for (k, (id, name)) in styles.iter().skip(start).take(visible).enumerate() {
let x = crate::rtl::lead_left(rtl, area, 3.0 + k as f32 * 76.0, 74.0);
let r = Rect::from_min_size(pos2(x, area.min.y + 3.0), vec2(74.0, area.height() - 6.0));
let r = Rect::from_min_size(pos2(area.min.x + 3.0 + k as f32 * 76.0, area.min.y + 3.0), vec2(74.0, area.height() - 6.0));
let resp = ui.interact(r, ui.scope_id().with(("style_tile", id)), Sense::click());
let active = *id == current;
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, active, name));
@@ -640,16 +637,11 @@ pub fn style_gallery(app: &mut WordApp, ui: &mut Ui, state: &Value) {
}
}
// Scroll arrows and "more".
let ax = crate::rtl::lead_left(!rtl, area, 2.0, 14.0);
let ar = Rect::from_min_max(pos2(ax, area.min.y + 2.0), pos2(ax + 14.0, area.max.y - 2.0));
let ar = Rect::from_min_max(pos2(area.max.x - 16.0, area.min.y + 2.0), pos2(area.max.x - 2.0, area.max.y - 2.0));
let thirds = ar.height() / 3.0;
for (i, (icon, delta)) in [("chevronUp", -1i32), ("chevronDown", 1), ("more", 0)].iter().enumerate() {
let br = Rect::from_min_size(pos2(ar.min.x, ar.min.y + thirds * i as f32), vec2(ar.width(), thirds));
let resp = ui.interact(br, ui.scope_id().with(("gal_arrow", i)), Sense::click());
if *delta == 0 {
// "More" opens the Styles pane: named for it, for screen readers.
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Styles Pane")));
}
if resp.hovered() {
ui.painter().rect_filled(br, 2.0, t.hover);
}
-66
View File
@@ -382,72 +382,6 @@ fn font_menu_entries_take_the_theme_text_colour() {
}
}
/// Where the Home tab's Styles group, laid out as the ribbon lays it out in the current interface
/// language, puts its parts, by their accessible rectangles: the gallery's first tiles in gallery
/// order, the column of scroll arrows and "more" from the top (the unnamed buttons beside the
/// tiles; only "more" has a name), and the button named for the Styles pane.
fn styles_group_rects() -> (Vec<(String, Rect)>, Vec<Rect>, Option<Rect>) {
use egui_kittest::kittest::{Queryable, by};
let app = WordApp::new(wordcraft_engine::Session::new(Document::new()), Default::default());
let names: Vec<String> = app.session.doc.styles.gallery().iter().take(super::STYLE_TILES).map(|s| crate::i18n::style_name(s)).collect();
let mut h = egui_kittest::Harness::builder().with_size(vec2(900.0, 200.0)).build_ui_state(
|ui, app: &mut WordApp| {
crate::rtl::root(ui, |ui| {
crate::rtl::horizontal_top(ui, |ui| {
crate::widgets::group(ui, "Styles", Some("styles.pane"), app, |ui, app| style_gallery(app, ui, &json!({})));
});
});
},
app,
);
h.run_steps(3);
let tiles: Vec<(String, Rect)> = names.iter().map(|n| (n.clone(), h.get_by_label(n).rect())).collect();
let row = tiles.first().map_or(Rect::NOTHING, |(_, r)| *r);
// The arrows are 14 points wide and stacked beside the tiles; the group's launcher is below them.
let mut arrows: Vec<Rect> = h
.query_all(by().predicate(|n| n.bounding_box().is_some()))
.map(|n| n.rect())
.filter(|r| r.width() <= 16.0 && (row.min.y..=row.max.y).contains(&r.center().y))
.collect();
arrows.sort_by(|a, b| a.min.y.total_cmp(&b.min.y));
let more = h.query_by_label(crate::i18n::t("Styles Pane")).map(|n| n.rect());
(tiles, arrows, more)
}
/// #5566: in a right-to-left interface the Styles gallery starts at the right, as the rest of
/// the ribbon does: the first style (Normal) is the rightmost tile, and the scroll arrows and
/// "more" sit at the gallery's left end. Left to right is unchanged.
#[test]
fn the_styles_gallery_reads_from_the_right_in_a_right_to_left_interface() {
crate::i18n::set_current(crate::i18n::Lang::EN);
let (tiles, arrows, more) = styles_group_rects();
assert!(tiles.len() >= 2, "the gallery shows its tiles: {tiles:?}");
assert_eq!(arrows.len(), 3, "up, down and more: {arrows:?}");
assert!(tiles.windows(2).all(|w| w[0].1.center().x < w[1].1.center().x), "left to right, tiles run rightwards: {tiles:?}");
assert!(
arrows.iter().all(|a| tiles.iter().all(|(_, r)| a.min.x >= r.max.x)),
"left to right, the arrows are right of every tile: {arrows:?} {tiles:?}"
);
let ltr_more = (more, arrows.last().copied());
// An Arabic interface; its catalog is not needed (the harness has no Arabic font to draw it).
crate::i18n::set_current(crate::i18n::fixture_lang("ar", "\tHome\tالرئيسية\n"));
let (tiles, arrows, more) = styles_group_rects();
crate::i18n::set_current(crate::i18n::Lang::EN);
assert_eq!(tiles.first().map(|(n, _)| n.as_str()), Some("Normal"), "the first tile is Normal");
assert_eq!(arrows.len(), 3, "up, down and more: {arrows:?}");
assert!(tiles.windows(2).all(|w| w[0].1.center().x > w[1].1.center().x), "right to left, tiles run leftwards: {tiles:?}");
assert!(
arrows.iter().all(|a| tiles.iter().all(|(_, r)| a.max.x <= r.min.x)),
"right to left, the arrows are left of every tile: {arrows:?} {tiles:?}"
);
// Screen readers find "more", the bottom arrow, by the pane it opens, in either direction.
for (more, bottom) in [ltr_more, (more, arrows.last().copied())] {
assert!(more.is_some() && more == bottom, "the bottom arrow ({bottom:?}) is named for the Styles pane ({more:?})");
}
}
#[test]
fn font_sample_shows_small_caps_and_the_script_mark() {
let white = wordcraft_doc::Rgb(255, 255, 255);
+6 -339
View File
@@ -96,55 +96,6 @@ fn title_stem_omits_nul_from_each_title_source() {
}
}
/// #5793: a name from a paragraph's first words, as Word suggests one: up to the first sentence or
/// clause end, without what a file name can't hold, at most 50 characters at a word's end.
#[test]
fn a_suggested_name_is_the_first_words_of_the_text() {
use crate::app::suggested_stem as stem;
assert_eq!(stem("Quarterly report for the board. More follows here.").as_deref(), Some("Quarterly report for the board"));
assert_eq!(stem("Minutes of the meeting, 6 November").as_deref(), Some("Minutes of the meeting, 6 November"));
assert_eq!(stem("What happened? Nobody knows").as_deref(), Some("What happened"));
assert_eq!(stem("Plan: phase one").as_deref(), Some("Plan"));
assert_eq!(stem("Q3 / Q4 \"draft\" <final> *v2* | a\\b").as_deref(), Some("Q3 Q4 draft final v2 a b"));
assert_eq!(stem(" Leading and inner spaces ").as_deref(), Some("Leading and inner spaces"));
assert_eq!(stem("日本語の文書。続きの文").as_deref(), Some("日本語の文書"));
let long = "Annual report of the regional committee on transport and infrastructure planning";
let got = stem(long).unwrap();
assert!(got.chars().count() <= 50 && long.starts_with(&got) && long[got.len()..].starts_with(' '), "{got:?}");
assert_eq!(stem(&"x".repeat(80)).map(|s| s.chars().count()), Some(50), "one long word is cut at 50");
for nothing in ["", " ", "...", "?", "/ * |", "\u{1}"] {
assert_eq!(stem(nothing), None, "{nothing:?}");
}
}
/// #5793: Save As suggests the first words for a document that was never saved and has no title
/// of its own (the first paragraph with text, past empty ones); a title, a file name or a path
/// still name it.
#[test]
fn save_as_suggests_the_first_words_of_a_new_document() {
let mut app = app();
assert_eq!(app.default_save_name(), format!("{}.docx", app.title_stem()), "an empty document");
for c in [
json!(["text.newParagraph", {}]),
json!(["text.insert", {"text": "Minutes of the board meeting. Present: all"}]),
json!(["text.newParagraph", {}]),
json!(["text.insert", {"text": "Second paragraph"}]),
] {
app.session.run(c[0].as_str().unwrap(), &c[1]).unwrap();
}
assert_eq!(app.default_save_name(), "Minutes of the board meeting.docx");
assert_eq!(
app.title_stem(),
crate::documents::untitled_title(&app.session.resolved_filenames().0).unwrap_or_else(|| "Document1".into()),
"the window title stays Document1"
);
app.session.doc.core.title = "Board minutes".into();
assert_eq!(app.default_save_name(), "Board minutes.docx", "a title wins");
app.session.doc.core.title.clear();
app.session.path = Some(std::path::PathBuf::from("/tmp/kept.docm"));
assert_eq!(app.default_save_name(), "kept.docm", "a saved file keeps its name and format");
}
/// #1678: Focus and Full Screen View put the window in full screen, asked of the system once
/// per change (switching between the two keeps it), and the usual window comes back after.
#[test]
@@ -590,25 +541,6 @@ fn title_and_account_buttons_work() {
assert!(h.query_by_label("User name:").is_some());
}
/// The Options toggle hides the community pill in the title bar; the backstage menu's
/// Discord link hides with it — one setting, everywhere the link shows.
#[test]
fn the_community_button_off_hides_the_backstage_discord_link_too() {
use egui_kittest::kittest::Queryable;
let mut a = app();
a.run("ui.backstage", json!({"value": true})).unwrap();
let mut h = harness(a);
h.run_steps(2);
assert!(h.query_by_label("Discord community").is_some(), "the community link shows by default");
let mut a = app();
a.ui.show_discord = false;
a.run("ui.backstage", json!({"value": true})).unwrap();
let mut h = harness(a);
h.run_steps(2);
assert!(h.query_by_label("Discord community").is_none(), "the backstage link honors the same setting");
}
/// File › Options › Advanced › Editing: alignment guides are a UI command, on by default and
/// kept between runs.
#[test]
@@ -2917,88 +2849,6 @@ fn a_command_line_file_still_asks_for_its_password_after_the_startup_document_is
assert!(body_text(&a).contains("Locked away"), "{}", body_text(&a));
}
/// A template WordCraft can be handed, saved by the engine with `text` (and `password`).
fn template_file(path: &std::path::Path, text: &str, password: Option<&str>) -> Vec<u8> {
let mut s = Session::new(wordcraft_doc::Document::from_text(text));
s.run("file.save", &json!({"path": path.to_string_lossy(), "password": password})).unwrap();
std::fs::read(path).unwrap()
}
/// #3433: a Word template the platform hands the app (the command line, a double-click in the
/// file manager, the Finder) starts a new, untitled document based on it, in place of the
/// untouched blank document, as Word does; saving can't write over the template. A document is
/// still opened as itself, and File › Open still opens a template itself, to edit it.
#[test]
fn a_template_the_platform_hands_the_app_starts_a_new_document() {
let dir = scratch("platform-template");
let path = dir.join("Letterhead.DOTX");
let bytes = template_file(&path, "Letterhead text", None);
let name = path.to_string_lossy().to_string();
let mut a = app();
let r = a.open_from_platform(&name, None).unwrap();
assert_eq!(r.get("pending"), None, "{r}");
assert_eq!(a.session.documents().len(), 1, "the blank startup document gave way");
assert_eq!(a.session.path, None);
assert_eq!(a.session.doc.settings.attached_template.as_deref(), Some(name.as_str()));
assert!(body_text(&a).contains("Letterhead text"));
assert_eq!(a.title_stem(), "Document1");
a.run("text.insert", json!({"text": "Dear reader, "})).unwrap();
let _ = a.run("file.save", json!({}));
assert_eq!(std::fs::read(&path).unwrap(), bytes, "Save never writes over the template");
assert_eq!(a.session.path, None);
// A document opens as itself, beside the new one.
let doc = dir.join("Report.docx");
template_file(&doc, "Report text", None);
a.open_from_platform(&doc.to_string_lossy(), None).unwrap();
assert_eq!(a.session.path.as_deref(), Some(doc.as_path()));
// File › Open edits the template itself.
let mut b = app();
b.open_file(&name, None).unwrap();
assert_eq!(b.session.path.as_deref(), Some(path.as_path()));
let _ = std::fs::remove_dir_all(&dir);
}
/// #3433: a protected template from the platform asks for its password, and the password box
/// retries the command that asked, so the template still starts a new document instead of
/// opening itself.
#[test]
fn a_protected_template_from_the_platform_asks_for_its_password_and_stays_a_new_document() {
let dir = scratch("platform-template-password");
let path = dir.join("Locked.dotm");
let bytes = template_file(&path, "Locked template", Some("sesame"));
let name = path.to_string_lossy().to_string();
let mut a = app();
a.open_from_platform(&name, None).unwrap();
assert_eq!(prompt(&a), Some("password"));
let (then, params) = match &a.dialog {
Some(crate::dialogs::Dialog::Password { then, params, .. }) => (then.clone(), params.clone()),
other => panic!("{:?}", other.as_ref().map(|d| d.name())),
};
assert_eq!(then, "file.newFromTemplate");
let mut with = params;
with["password"] = json!("sesame");
a.execute(&then, with).unwrap();
assert!(body_text(&a).contains("Locked template"), "{}", body_text(&a));
assert_eq!((a.session.path.as_ref(), a.session.password.is_some()), (None, false));
assert_eq!(a.session.documents().len(), 1);
assert_eq!(std::fs::read(&path).unwrap(), bytes);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn word_templates_are_told_by_their_extension() {
use crate::documents::is_word_template;
for t in ["a.dotx", "a.dotm", "a.dot", "A.DOTX", "/x/y.Dotm", "C:\\x\\y.DOT"] {
assert!(is_word_template(t), "{t}");
}
for t in ["a.docx", "a.doc", "dotx", "a.dotx.docx", "a.ott", "", "a."] {
assert!(!is_word_template(t), "{t}");
}
}
/// #55: a password-protected document asks for its password (after Save Changes) instead of
/// opening blank; scripts get the error instead of a dialog; Encrypt with Password asks for one.
#[test]
@@ -4473,9 +4323,10 @@ fn alt_q_opens_search_and_typing_goes_there() {
h.event(key(egui::Key::Q, egui::Modifiers::ALT));
h.event(egui::Event::Text("q".into()));
h.step();
// The title bar shows the Search box, so Search opens there, in place (#5398).
let query = |h: &egui_kittest::Harness<WordApp>| crate::chrome::title_search_query(h.state(), &h.ctx);
assert!(h.state().dialog.is_none(), "no Search Commands dialog over the page");
let query = |h: &egui_kittest::Harness<WordApp>| match &h.state().dialog {
Some(dialogs::Dialog::Commands { query, .. }) => Some(query.clone()),
_ => None,
};
assert_eq!(query(&h).as_deref(), Some(""), "Search is open and empty");
h.step();
{
@@ -4489,8 +4340,7 @@ fn alt_q_opens_search_and_typing_goes_there() {
h.event(key(egui::Key::Escape, egui::Modifiers::NONE));
h.step();
h.step();
assert!(query(&h).is_none(), "Escape closes Search");
assert!(!h.state().session.view.full_screen_view);
assert!(h.state().dialog.is_none(), "Escape closes Search");
// Words that name no command: Enter finds them in the document, in the Navigation pane.
h.event(key(egui::Key::Q, egui::Modifiers::ALT));
h.step();
@@ -4502,194 +4352,11 @@ fn alt_q_opens_search_and_typing_goes_there() {
h.event(key(egui::Key::Enter, egui::Modifiers::NONE));
h.step();
h.step();
assert!(query(&h).is_none(), "Enter ran the search");
assert!(h.state().dialog.is_none(), "Enter ran the search");
assert!(h.state().session.view.nav_pane, "the Navigation pane shows the results");
assert_eq!(h.state().session.find.results.len(), 1);
}
/// #5398: a click on the title bar's Search box types into that box, as in Word, with the
/// results dropping down under it, instead of opening a second box in a dialog; a result's click
/// runs it, and a click elsewhere closes Search.
#[test]
fn the_title_bar_search_box_is_typed_in_place() {
use egui_kittest::kittest::{NodeT, Queryable};
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.run_steps(6);
let query = |h: &egui_kittest::Harness<WordApp>| crate::chrome::title_search_query(h.state(), &h.ctx);
let open = |h: &mut egui_kittest::Harness<WordApp>| {
h.get_by_label("Search commands and help").click();
h.run_steps(3);
};
open(&mut h);
assert!(h.state().dialog.is_none(), "no second box in a dialog");
assert_eq!(query(&h).as_deref(), Some(""));
assert!(h.ctx.text_edit_focused(), "the box itself takes the typing");
h.event(egui::Event::Text("word count".into()));
h.run_steps(2);
assert_eq!(query(&h).as_deref(), Some("word count"));
let body = |h: &egui_kittest::Harness<WordApp>| h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body);
assert_eq!(body(&h).trim(), "", "nothing typed into the document");
// The result is a row of the drop-down (the ribbon's own Word Count button has no "—").
let row = h
.query_all_by_label_contains("Word Count")
.find(|n| n.accesskit_node().label().is_some_and(|l| l.contains('—')))
.expect("Word Count among the results");
row.click();
h.run_steps(3);
assert!(query(&h).is_none(), "running a result closes Search");
assert!(h.state().dialog.is_some(), "and Word Count ran (its dialog is open)");
h.state_mut().dialog = None;
h.run_steps(2);
// A click elsewhere closes it.
open(&mut h);
assert!(query(&h).is_some());
for pressed in [true, false] {
let at = egui::pos2(720.0, 600.0);
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() });
h.step();
}
h.run_steps(2);
assert!(query(&h).is_none(), "a click on the page closes Search");
}
/// #5398 (review): Focus hides the Search box, so Search there is the dialog, even on the frame
/// Focus comes on; and Search left open when Focus comes on doesn't keep Escape from leaving it.
#[test]
fn focus_mode_search_never_opens_in_the_hidden_box() {
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.run_steps(6);
let query = |h: &egui_kittest::Harness<WordApp>| crate::chrome::title_search_query(h.state(), &h.ctx);
let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers };
// Search, as Alt+Q opens it (Windows and Linux; macOS has no Alt+Q, Option+Q types a character).
let search = |h: &mut egui_kittest::Harness<WordApp>| {
let _ = h.state_mut().run("ui.dialog", json!({"name": "commands"}));
};
// The last frame drew the box; Focus comes on just before Search.
h.state_mut().run("view.focus", json!({"value": true})).unwrap();
search(&mut h);
h.step();
assert!(query(&h).is_none(), "no Search in a box Focus hides");
assert!(matches!(h.state().dialog, Some(dialogs::Dialog::Commands { .. })), "Search opens as the dialog");
h.state_mut().dialog = None;
h.state_mut().run("view.focus", json!({"value": false})).unwrap();
h.run_steps(3);
// Search open in the title bar, then Focus comes on: Escape leaves Focus.
search(&mut h);
h.run_steps(2);
assert!(query(&h).is_some());
h.state_mut().run("view.focus", json!({"value": true})).unwrap();
h.run_steps(3);
assert!(query(&h).is_none(), "Search whose box is gone is dropped");
h.event(key(egui::Key::Escape, egui::Modifiers::NONE));
h.run_steps(2);
assert!(!h.state().session.view.focus_mode, "Escape leaves Focus");
}
/// #5398 (review): what is typed on the very first frame after Search opens goes into the
/// Search box, not into the document; and Enter on a result that opens a dialog (Word Count)
/// leaves that dialog open: the same Enter doesn't also press its OK.
#[test]
fn title_bar_search_takes_the_first_keys_and_enter_opens_the_result() {
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.run_steps(6);
let query = |h: &egui_kittest::Harness<WordApp>| crate::chrome::title_search_query(h.state(), &h.ctx);
let body = |h: &egui_kittest::Harness<WordApp>| h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body);
// Search as the ribbon's box and Alt+Q open it, then typing straight away.
let _ = h.state_mut().run("ui.dialog", json!({"name": "commands"}));
h.step();
h.event(egui::Event::Text("word count".into()));
h.step();
h.step();
assert_eq!(body(&h).trim(), "", "nothing typed into the document");
assert_eq!(query(&h).as_deref(), Some("word count"), "the first keys go into the Search box");
let enter = egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE };
h.event(enter);
h.run_steps(4);
assert!(query(&h).is_none(), "Enter ran the first result");
assert!(matches!(h.state().dialog, Some(dialogs::Dialog::WordCount { .. })), "Word Count is open, not closed by the same Enter");
}
/// #5398 (review): Tab moves from the Search box to its results, and the box doesn't take the
/// focus back, so a keyboard user can pick a result; Enter on it runs it.
#[test]
fn tab_moves_to_a_search_result_and_enter_runs_it() {
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.run_steps(6);
let query = |h: &egui_kittest::Harness<WordApp>| crate::chrome::title_search_query(h.state(), &h.ctx);
let key = |key| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE };
let _ = h.state_mut().run("ui.dialog", json!({"name": "commands"}));
h.step();
h.event(egui::Event::Text("word count".into()));
h.run_steps(2);
let edit = h.state().window_id("title_search_edit");
assert!(h.ctx.memory(|m| m.has_focus(edit)), "the box has the keys");
h.event(key(egui::Key::Tab));
h.run_steps(3);
assert!(!h.ctx.memory(|m| m.has_focus(edit)), "the box didn't take the focus back");
assert!(h.ctx.memory(|m| m.focused()).is_some(), "a result has it");
assert_eq!(query(&h).as_deref(), Some("word count"), "Search is still open");
h.event(key(egui::Key::Enter));
h.run_steps(4);
assert!(query(&h).is_none(), "Enter ran the result that has the focus");
assert!(h.state().dialog.is_some(), "its dialog is open, not closed by the same Enter");
}
/// #5398 (review): Alt+Q pressed on the page opens Search while the page has the keys; what is
/// typed on the very next frame goes into the Search box, not into the document.
#[cfg(not(target_os = "macos"))]
#[test]
fn keys_typed_right_after_alt_q_go_into_search() {
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.run_steps(6);
h.event(egui::Event::Text("Kilns".into()));
h.step();
let query = |h: &egui_kittest::Harness<WordApp>| crate::chrome::title_search_query(h.state(), &h.ctx);
h.event(egui::Event::Key { key: egui::Key::Q, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::ALT });
h.step();
// Typing sends each key's press before its text.
h.event(egui::Event::Key { key: egui::Key::B, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE });
h.event(egui::Event::Text("bold".into()));
h.step();
h.step();
assert_eq!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body), "Kilns", "nothing typed into the document");
assert_eq!(query(&h).as_deref(), Some("bold"), "the keys went into the Search box");
}
/// #819: `tools.search` with a query lists matching commands for scripts and agents; every
/// word has to match, so "insert table" finds the command that inserts a table.
#[test]
-45
View File
@@ -51,25 +51,6 @@ impl WordApp {
pub fn set_startup_update_check(&mut self, on: bool) {
self.updates_rt.startup_check = on;
}
/// Turns the self-update off for the rest of this process: no update is offered, and Help ▸
/// Check for Updates says why. For a process that still carries another program's AppImage
/// variables, which the update would take for this copy's file and replace (#5653).
#[cfg(not(target_arch = "wasm32"))]
pub fn disable_self_update(&mut self) {
let rt = &mut self.updates_rt;
rt.startup_check = false;
rt.updater = Updater::new(&crate::UPDATE_APP, env!("CARGO_PKG_VERSION")).with_source(refused_check());
}
}
/// A check for updates that never finds one and gives the reason as its error, which Help ▸ Check
/// for Updates shows.
#[cfg(not(target_arch = "wasm32"))]
fn refused_check() -> craft_update::updater::CheckSource {
let why = tl!("Updates are off in this window: WordCraft was started with another program's AppImage settings. Start WordCraft from its own menu entry to update it.")
.to_string();
std::sync::Arc::new(move || Err(why.clone()))
}
pub(crate) fn check(app: &mut WordApp) {
@@ -153,29 +134,3 @@ pub(crate) fn show(app: &mut WordApp, ctx: &egui::Context) {
}
}
}
#[cfg(all(test, not(target_arch = "wasm32")))]
mod tests {
use craft_update::updater::Status;
/// With the self-update off, a startup check is never made, and Help ▸ Check for Updates (the
/// command's own path) ends in the reason instead of a release to install (#5653).
#[test]
fn with_the_self_update_off_a_check_finds_nothing_and_says_why() {
let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default());
app.set_startup_update_check(true);
app.disable_self_update();
assert!(!app.updates_rt.startup_check, "no check at start");
let ctx = egui::Context::default();
super::check(&mut app);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
while !matches!(app.updates_rt.updater.status(), Status::Failed(_)) {
assert!(std::time::Instant::now() < deadline, "the check never ended: {:?}", app.updates_rt.updater.status());
super::tick(&mut app, &ctx);
std::thread::sleep(std::time::Duration::from_millis(5));
}
let status = app.updates_rt.updater.status();
assert!(matches!(status, Status::Failed(why) if why.contains("another program's AppImage")), "{status:?}");
assert!(!app.updates_rt.updater.prompt_open(), "no update offered");
}
}
+3 -5
View File
@@ -1,6 +1,6 @@
# WordCraft architecture
> **Last reviewed:** 2026-10-28 · **Last updated:** 2026-11-05 · **Change:** trivial (the hostile-parameter test now has a sibling for `file.*`, `insert.picture` and `insert.textFromFile`; previously trivial: craft-proof's row says what a build without the Language data keeps: English spelling from an installed Hunspell English or the built-in list, rule hyphenation for Bulgarian, Turkish and Russian; previously minor: proofing moved out: `wordcraft-proof` is craft-libs' `craft-proof`, its Language data craft-i18n's `language-data/` embedded from `CRAFT_I18N_DIR`; previously major: crate and layer inventory, source counts, proofing languages and module boundaries re-measured from this checkout; previously minor: `proof` is per language: English and Polish; previously major: first committed version; `plan/architecture.md` is local-only) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
> **Last reviewed:** 2026-10-28 · **Last updated:** 2026-11-02 · **Change:** trivial (craft-proof's row says what a build without the Language data keeps: English spelling from an installed Hunspell English or the built-in list, rule hyphenation for Bulgarian, Turkish and Russian; previously minor: proofing moved out: `wordcraft-proof` is craft-libs' `craft-proof`, its Language data craft-i18n's `language-data/` embedded from `CRAFT_I18N_DIR`; previously major: crate and layer inventory, source counts, proofing languages and module boundaries re-measured from this checkout; previously minor: `proof` is per language: English and Polish; previously major: first committed version; `plan/architecture.md` is local-only) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
How WordCraft is built today. The longer design notes in
`plan/architecture.md` are gitignored and local to the original author's machine; this file is the
@@ -57,9 +57,8 @@ crate name links to its source tree.
- 821 `#[test]` functions and 10 property-test blocks: model, layout, DOCX round-trip and hostile
input, `.doc`, formats, typing parity (`engine/src/tests/typing.rs`), bidi, i18n catalogs, UI
interaction (egui_kittest), MCP acceptance; `hostile_file_params_never_panic` runs `file.*`,
`insert.picture` and `insert.textFromFile` with junk params and unusable paths, held to a
temporary folder, and `hostile_params_never_panic` every other command with junk params.
interaction (egui_kittest), MCP acceptance; `hostile_params_never_panic` runs every command with
junk params.
- `cargo xtask ci`: fmt, clippy `-D warnings`, tests, asset attribution, layering, wasm build.
- `cargo xtask parity` regenerates [`parity-checklist.md`](parity-checklist.md); a test keeps the
catalog percentage above a floor that only rises.
@@ -70,7 +69,6 @@ crate name links to its source tree.
| Date | Change | Summary |
|---|---|---|
| 2026-11-05 | trivial | `hostile_file_params_never_panic` runs `file.*`, `insert.picture` and `insert.textFromFile` with junk params and unusable paths (#3719) |
| 2026-11-02 | trivial | craft-proof's row: without the Language data English spelling falls back to an installed Hunspell English or the built-in Moby list, and only pattern hyphenation is off (#5202 review) |
| 2026-11-01 | minor | `proof` left the workspace: it is `craft-proof` in storytold/craft-libs (with its history), and the spelling and hyphenation data is craft-i18n's `language-data/` (`CRAFT_I18N_DIR`); `assets/spelling` and `assets/hyphenation` are gone |
| 2026-10-28 | major | Recounted workspace crates, layers and Rust source lines; refreshed proofing languages and documented current engine/layout module boundaries and source links |
+1 -2
View File
@@ -8,8 +8,7 @@ the AI models named in `Co-Authored-By` trailers.
- Two files, both **compiled into the binary** by the UI crate's `build.rs` (nothing is read from
disk or the network at run time; the web build has them too):
- [`contributors/contributors.json`](../contributors/contributors.json): commit stats per GitHub
username. Release builds regenerate it for the release commit
(`.github/workflows/release.yml`, `credits` job); the copy in the repository is not updated.
username.
- [`contributors/people.toml`](../contributors/people.toml): the names people chose for
themselves. **Each contributor adds only their own entry**, in their own PR.
- Each contributor gets one line: GitHub username (always), display name (opt-in, or their synced

Some files were not shown because too many files have changed in this diff Show More