mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-09 03:39:51 -06:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ebfc3c2a7 | ||
|
|
c09da43475 |
@@ -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
File diff suppressed because one or more lines are too long
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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())));
|
||||
}
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
#[cfg(target_os = "linux")]
|
||||
mod appimage_env;
|
||||
mod control_arg;
|
||||
mod help;
|
||||
#[cfg(unix)]
|
||||
|
||||
+10
-19
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
)) {
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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")),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 "Sec" \m"#), Some("#Sec")),
|
||||
(simple("HYPERLINK https://example.org/b"), Some("https://example.org/b")),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}."#)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 ¶ms {
|
||||
let mut s = Session::new(crate::sample::sample_document());
|
||||
let r = confined(&mut s, spec.id, v);
|
||||
assert!(!caught_panic(®, &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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(""));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,21 +232,6 @@ fn entities() {
|
||||
assert_eq!(unescape("θ λ Δ ∇ ∑ √ ≠ ≤ ≥ ♥"), "θ λ Δ ∇ ∑ √ ≠ ≤ ≥ ♥");
|
||||
}
|
||||
|
||||
/// 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("© 2024 Acme"), "© 2024 Acme");
|
||||
assert_eq!(unescape("© 2024 Acme"), "© 2024 Acme");
|
||||
assert_eq!(unescape("© 2024 & <"), "© 2024 & <");
|
||||
assert_eq!(unescape("&bogus &# &#x &"), "&bogus &# &#x &");
|
||||
assert_eq!(unescape(" x"), "\u{00A0}x");
|
||||
let f = parse("<p><a href=\"/q?a=1©=2®2=3&b=<\">l</a> © 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©=2®2=3&b=<"))));
|
||||
assert!(p.text().ends_with("© 2024"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wbr_word_break_opportunity() {
|
||||
let f = parse("<p>Super<wbr>cali<wbr>fragilistic</p>");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" => {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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="'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">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'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'Brien"] {
|
||||
let face = format!(r#"<style:font-face style:name="{spelled}" svg:font-family="O'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'Brien" || n == "O'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="'Mystery'" 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="'Mystery'" 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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)>,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) = (¶.lines[0], ¶.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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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})) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user