mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-08 03:09:51 -06:00
Compare commits
38
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49b3adb858 | ||
|
|
852e67d587 | ||
|
|
9b6e9564a2 | ||
|
|
eb100edfd6 | ||
|
|
c38dc17ba9 | ||
|
|
5997aef2f9 | ||
|
|
73356ed82a | ||
|
|
6d3b51fab1 | ||
|
|
750e26657e | ||
|
|
f7e060ac3f | ||
|
|
b61aee61ba | ||
|
|
bc0f691c13 | ||
|
|
cac121fd89 | ||
|
|
ea8c45a4ca | ||
|
|
e53cae3482 | ||
|
|
7855a2d814 | ||
|
|
871401509c | ||
|
|
4ce92e8afd | ||
|
|
746577406c | ||
|
|
0131d5c3aa | ||
|
|
fa59ed0e8a | ||
|
|
b29f3a8a2e | ||
|
|
e7668ae53e | ||
|
|
7aea5f8cab | ||
|
|
3b6547de8f | ||
|
|
e0dd821d4c | ||
|
|
7c3a5ccd34 | ||
|
|
fa153b932c | ||
|
|
595cddaaef | ||
|
|
da914ce674 | ||
|
|
729cd0a5fc | ||
|
|
8c46f4fc1b | ||
|
|
2a0a20aec5 | ||
|
|
247c7feaab | ||
|
|
8833c604e0 | ||
|
|
cad8ede1e8 | ||
|
|
beca3f3485 | ||
|
|
6ba25ee491 |
@@ -18,7 +18,14 @@ 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
|
||||
@@ -31,6 +38,7 @@ 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 }}
|
||||
|
||||
+50
-2
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 of their arguments not starting with `--` it is (as
|
||||
/// `pos` counts). `run` opens the value of `--file`.
|
||||
/// The commands that open a file, and which positional argument it is, excluding option values.
|
||||
/// `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,8 +108,14 @@ 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)) => args.iter().enumerate().skip(1).filter(|(_, a)| !a.starts_with("--")).nth(*n).map(|(i, _)| i),
|
||||
Some((_, n)) => positional_indices(args.get(1..).unwrap_or_default(), specs).get(*n).map(|i| i + 1),
|
||||
None if cmd == "run" => args.iter().position(|a| a == "--file").map(|i| i + 1),
|
||||
None => None,
|
||||
};
|
||||
@@ -145,8 +151,13 @@ 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. Mirrors [`validate_options`], which has already checked the options are known.
|
||||
/// path. Uses the same option specifications as [`validate_options`].
|
||||
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) {
|
||||
@@ -156,7 +167,7 @@ fn positionals<'a>(args: &'a [String], specs: &[OptionSpec]) -> Vec<&'a String>
|
||||
i += 2;
|
||||
} else {
|
||||
if !arg.starts_with("--") {
|
||||
out.push(arg);
|
||||
out.push(i);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
@@ -211,6 +211,10 @@ 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
|
||||
@@ -228,6 +232,7 @@ 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,6 +53,15 @@ 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");
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
//! 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,14 +36,15 @@ 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. Quitting closes the window, which asks about every document
|
||||
/// 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
|
||||
/// 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_file(p, None) {
|
||||
match app.open_from_platform(p, None) {
|
||||
// Leave Backstage, as opening from the Open dialog does.
|
||||
Ok(_) => app.ui.backstage = false,
|
||||
Err(e) => {
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
#![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;
|
||||
@@ -421,14 +423,15 @@ 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.
|
||||
/// 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.
|
||||
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.run("file.open", serde_json::json!({"path": name, "data": wordcraft_engine::cmd::insert::base64_encode(&bytes)})));
|
||||
.and_then(|bytes| app.open_from_platform(&name, Some(&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() => app.session.path = Some(path.into()),
|
||||
Ok(r) if r.get("pending").is_none() && !wordcraft_ui_egui::documents::is_word_template(&name) => app.session.path = Some(path.into()),
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
app.report_open_failure(&name, &e);
|
||||
@@ -449,9 +452,22 @@ 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;
|
||||
@@ -566,6 +582,10 @@ 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);
|
||||
@@ -605,8 +625,9 @@ 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.run("file.open", serde_json::json!({"path": f})) {
|
||||
if let Err(e) = app.open_from_platform(&f, None) {
|
||||
app.report_open_failure(&f, &e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
//! 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,3 +1,5 @@
|
||||
#[cfg(target_os = "linux")]
|
||||
mod appimage_env;
|
||||
mod control_arg;
|
||||
mod help;
|
||||
#[cfg(unix)]
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
9d6d54d6d9ff603cc41e12e5f6ba51af3554ab66
|
||||
1e515422afdda2f317af430c1f9d68a5f83935b4
|
||||
|
||||
+19
-10
@@ -7,8 +7,9 @@
|
||||
/// 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 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
|
||||
/// 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
|
||||
/// `cfb` to report.
|
||||
pub fn check(bytes: &[u8]) -> Result<(), String> {
|
||||
const MAGIC: [u8; 8] = [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1];
|
||||
@@ -52,12 +53,14 @@ 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 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.
|
||||
// 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).
|
||||
let listed: Vec<u32> = difat.into_iter().filter(|&s| s != FREE).collect();
|
||||
let needed = num_sectors.div_ceil(sector_len / 4).saturating_add(1);
|
||||
if listed.len() > needed {
|
||||
if listed.len() > num_sectors {
|
||||
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());
|
||||
@@ -123,10 +126,16 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
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.
|
||||
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.
|
||||
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])).is_err_and(|e| e.contains("3 FAT sectors")));
|
||||
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])).is_ok());
|
||||
assert!(super::check(&header_difat(200, &[0, 1])).is_ok());
|
||||
}
|
||||
|
||||
@@ -478,8 +478,9 @@ 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, the page colour and
|
||||
/// picture, and the notes' numbering.
|
||||
/// 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.
|
||||
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);
|
||||
@@ -490,8 +491,10 @@ 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),
|
||||
)) {
|
||||
|
||||
@@ -125,6 +125,7 @@ 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_and(|d| d.format != NumFormat::None)
|
||||
self.abstract_of(num).is_some() && self.level(num, level.min(8)).is_some()
|
||||
}
|
||||
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,12 +325,9 @@ 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
|
||||
@@ -382,7 +379,14 @@ 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() };
|
||||
self.labels.insert((abs.id, lv as u8), CountedLabel { label: numeric.then(|| label.clone()), levels: template_levels(&def.text) });
|
||||
// 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);
|
||||
Some((label, def))
|
||||
}
|
||||
/// Which levels of `num`'s list have counted a paragraph yet (a level never used has no
|
||||
@@ -455,9 +459,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").
|
||||
// format (二 of a Chinese outline becomes 2 in "2.1"); a "none" level stays empty.
|
||||
let fmt = match level_def(k).map(|l| l.format).unwrap_or(NumFormat::Decimal) {
|
||||
NumFormat::DecimalZero => NumFormat::DecimalZero,
|
||||
f @ (NumFormat::DecimalZero | NumFormat::None) => f,
|
||||
_ if def.legal => NumFormat::Decimal,
|
||||
f => f,
|
||||
};
|
||||
@@ -556,6 +560,27 @@ 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
|
||||
|
||||
@@ -316,20 +316,34 @@ impl InlineObject {
|
||||
/// A group's members placed in a `w` × `h` box at (`x`, `y`): each member's rectangle
|
||||
/// (x, y, w, h) and the member. Empty for anything else.
|
||||
pub fn group_rects(&self, x: f32, y: f32, w: f32, h: f32) -> Vec<([f32; 4], &InlineObject)> {
|
||||
let InlineObject::Group { ch_w, ch_h, children, .. } = self else { return Vec::new() };
|
||||
let InlineObject::Group { children, .. } = self else { return Vec::new() };
|
||||
self.member_rects(x, y, w, h).into_iter().filter_map(|(i, r)| Some((r, &children.get(i)?.obj))).collect()
|
||||
}
|
||||
/// [`InlineObject::group_rects`] with each member's index in `children` instead of the member.
|
||||
pub fn member_rects(&self, x: f32, y: f32, w: f32, h: f32) -> Vec<(usize, [f32; 4])> {
|
||||
let InlineObject::Group { children, .. } = self else { return Vec::new() };
|
||||
let (sx, sy) = self.group_scale(w, h);
|
||||
let fin = |v: f32| if v.is_finite() { v } else { 0.0 };
|
||||
let sx = if fin(*ch_w) > 0.0 { fin(w) / ch_w } else { 1.0 };
|
||||
let sy = if fin(*ch_h) > 0.0 { fin(h) / ch_h } else { 1.0 };
|
||||
children
|
||||
.iter()
|
||||
.take(MAX_GROUP_CHILDREN)
|
||||
.filter_map(|c| {
|
||||
.enumerate()
|
||||
.filter_map(|(i, c)| {
|
||||
let (cw, chh, _) = c.obj.frame()?;
|
||||
let r = [x + fin(c.x) * sx, y + fin(c.y) * sy, (fin(cw) * sx).clamp(0.0, 4000.0), (fin(chh) * sy).clamp(0.0, 4000.0)];
|
||||
(!matches!(c.obj, InlineObject::Group { .. })).then_some((r, &c.obj))
|
||||
(!matches!(c.obj, InlineObject::Group { .. })).then_some((i, r))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// How much a group drawn `w` × `h` scales its members' space (x, y): 1 for a canvas, whose
|
||||
/// members keep their size.
|
||||
pub fn group_scale(&self, w: f32, h: f32) -> (f32, f32) {
|
||||
let InlineObject::Group { ch_w, ch_h, .. } = self else { return (1.0, 1.0) };
|
||||
let fin = |v: f32| if v.is_finite() { v } else { 0.0 };
|
||||
let sx = if fin(*ch_w) > 0.0 { fin(w) / ch_w } else { 1.0 };
|
||||
let sy = if fin(*ch_h) > 0.0 { fin(h) / ch_h } else { 1.0 };
|
||||
(sx, sy)
|
||||
}
|
||||
/// A legacy form field's definition (`FORMCHECKBOX`, `FORMTEXT`, `FORMDROPDOWN`), read from
|
||||
/// the `w:ffData` it keeps; `None` for anything else.
|
||||
pub fn legacy_form(&self) -> Option<crate::legacy_form::LegacyForm> {
|
||||
|
||||
@@ -289,13 +289,14 @@ 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,
|
||||
/// Vietnamese, text amounts) show decimal, as do values past a sequence's end
|
||||
/// (the 21st item of ①…⑳).
|
||||
/// 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 (๑, ๒ … ๑๐).
|
||||
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(),
|
||||
@@ -349,7 +350,6 @@ impl NumFormat {
|
||||
| NumFormat::HindiNumbers
|
||||
| NumFormat::HindiCounting
|
||||
| NumFormat::ThaiLetters
|
||||
| NumFormat::ThaiNumbers
|
||||
| NumFormat::ThaiCounting
|
||||
| NumFormat::BahtText
|
||||
| NumFormat::DollarText => n.to_string(),
|
||||
@@ -357,6 +357,11 @@ 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}'];
|
||||
@@ -999,6 +1004,18 @@ 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.
|
||||
|
||||
+52
-13
@@ -7,9 +7,26 @@ 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>,
|
||||
}
|
||||
@@ -20,7 +37,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(), pns: Vec::new() });
|
||||
return Ok(Bins { fcs: Vec::new(), sorted: true, 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()))?;
|
||||
@@ -38,36 +55,58 @@ 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);
|
||||
}
|
||||
Ok(Bins { fcs, pns })
|
||||
let sorted = fcs.windows(2).all(|w| w.first() <= w.get(1));
|
||||
Ok(Bins { fcs, sorted, pns })
|
||||
}
|
||||
|
||||
/// The page containing `fc`, as a slice of the WordDocument stream.
|
||||
fn page<'w>(&self, word: &'w [u8], fc: u32) -> Option<&'w [u8]> {
|
||||
/// 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)> {
|
||||
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;
|
||||
word.get(at..at.checked_add(512)?)
|
||||
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))
|
||||
}
|
||||
|
||||
/// 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] {
|
||||
let Some(page) = self.page(word, fc) else { return &[] };
|
||||
let Some(&crun) = page.get(511) else { return &[] };
|
||||
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 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 Some(j) = j else { return &[] };
|
||||
let Some(&off) = page.get(rgb_at + j) else { return &[] };
|
||||
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(&[]) };
|
||||
if off == 0 {
|
||||
return &[];
|
||||
return run(&[]);
|
||||
}
|
||||
let at = off as usize * 2;
|
||||
let Some(&cb) = page.get(at) else { return &[] };
|
||||
page.get(at + 1..at + 1 + cb as usize).unwrap_or(&[])
|
||||
let Some(&cb) = page.get(at) else { return run(&[]) };
|
||||
run(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`
|
||||
@@ -75,7 +114,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;
|
||||
|
||||
+81
-42
@@ -502,29 +502,37 @@ struct ParaBuild {
|
||||
runs: Vec<Run>,
|
||||
props: wordcraft_doc::props::ParaProps,
|
||||
mark: CharProps,
|
||||
/// The current run's properties, so runs only break when formatting changes.
|
||||
cur: Option<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>,
|
||||
/// 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.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);
|
||||
}
|
||||
match self.runs.last_mut() {
|
||||
Some(r) if r.props == props => r.len += len,
|
||||
_ => self.runs.push(Run { len, 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() {
|
||||
@@ -552,7 +560,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 memo: Option<FmtMemo<'a>> = None;
|
||||
let mut fmt = FmtState::default();
|
||||
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).
|
||||
@@ -715,16 +723,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 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),
|
||||
'\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),
|
||||
// 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 memo),
|
||||
c => push_formatted(&mut pb, cp, c, ctx, field.as_mut(), &mut fmt),
|
||||
}
|
||||
cp = cp.saturating_add(units);
|
||||
}
|
||||
@@ -854,34 +862,53 @@ 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>, 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 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(),
|
||||
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 => {
|
||||
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
|
||||
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());
|
||||
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 };
|
||||
// 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 c == '('
|
||||
&& let Some((ftc, xchar)) = symbol_operand(grpprl)
|
||||
{
|
||||
if let Some((ftc, xchar)) = symbol {
|
||||
if let Some(font) = ctx.fonts.get(usize::from(ftc)) {
|
||||
props.font = Some(font.clone());
|
||||
}
|
||||
@@ -900,6 +927,18 @@ 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,12 +117,6 @@ 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() {
|
||||
@@ -143,6 +137,17 @@ 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)
|
||||
@@ -187,6 +192,31 @@ 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,3 +2880,153 @@ 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,6 +2462,31 @@ 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,6 +228,7 @@ 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")),
|
||||
|
||||
@@ -169,6 +169,9 @@ pub(crate) fn delete_selection_effect(s: &mut Session) -> Result<SelectionDelete
|
||||
if a == b {
|
||||
return Ok(SelectionDelete { at: a, content_changed: false, state_changed: false });
|
||||
}
|
||||
if canvas::selected_member(s).is_some() {
|
||||
return Err(CmdError::Disabled("a shape inside a drawing canvas is selected".into()));
|
||||
}
|
||||
controls::check_delete(s, &a, &b)?;
|
||||
let effect = if s.doc.settings.track_changes {
|
||||
if !track_delete_would_change(s, &a, &b) {
|
||||
@@ -186,7 +189,9 @@ pub(crate) fn delete_selection_effect(s: &mut Session) -> Result<SelectionDelete
|
||||
Ok(effect)
|
||||
}
|
||||
|
||||
/// Delete the selection (respecting track changes); returns the collapsed position.
|
||||
/// Delete the selection (respecting track changes); returns the collapsed position. With a
|
||||
/// Drawing Canvas member picked (#2637) the selection is the whole canvas, which a command that
|
||||
/// replaces the selection (Insert › Table, Equation, Symbol, Field…) would take away: refused.
|
||||
pub fn delete_selection(s: &mut Session) -> Result<Pos, CmdError> {
|
||||
delete_selection_effect(s).map(|e| e.at)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
//! top-left corner, unscaled). While a canvas is selected, Insert › Pictures, Shapes and Text Box
|
||||
//! put the new object inside it ([`add_member`]). Fit, Expand and Scale Drawing change its frame;
|
||||
//! Shape Fill and Shape Outline colour its background and border.
|
||||
//!
|
||||
//! A member can be picked on its own (#2637, `select.member`; a click on it does this): Shape
|
||||
//! Format, Size, Rotate, Alt Text, Delete, Cut and Copy then act on that member, and Move or
|
||||
//! Resize and Nudge move it inside the canvas. The canvas stays the selection in the text.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use wordcraft_doc::para::{CanvasStyle, Float, GroupChild, InlineObject, MAX_GROUP_CHILDREN};
|
||||
@@ -14,6 +18,29 @@ use super::objects::object_selection;
|
||||
use super::{delete_selection, sel_result};
|
||||
use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
|
||||
|
||||
/// A member of the selected canvas picked on its own: its index, and the selection it was picked
|
||||
/// in (it counts only while that selection stands).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct CanvasMember {
|
||||
sel: Selection,
|
||||
pub index: usize,
|
||||
}
|
||||
|
||||
/// The canvas member picked on its own: the canvas's position, the member's index and the member.
|
||||
pub fn selected_member(s: &Session) -> Option<(Pos, usize, &InlineObject)> {
|
||||
let m = s.member.as_ref().filter(|m| m.sel == s.sel)?;
|
||||
let (pos, canvas) = selected_canvas(s)?;
|
||||
let InlineObject::Group { children, .. } = canvas else { return None };
|
||||
let c = children.get(m.index).filter(|c| pickable(c))?;
|
||||
Some((pos, m.index, &c.obj))
|
||||
}
|
||||
|
||||
/// Whether a member can be picked on its own: a picture, shape or text box that is drawn (a
|
||||
/// group inside a canvas isn't, nor a hidden member).
|
||||
pub fn pickable(c: &GroupChild) -> bool {
|
||||
c.obj.is_drawing() && !matches!(c.obj, InlineObject::Group { .. }) && !c.obj.frame().is_some_and(|(_, _, f)| f.hidden)
|
||||
}
|
||||
|
||||
/// Largest canvas side and member offset, points (well past any page).
|
||||
const MAX_SIDE: f32 = 4000.0;
|
||||
/// Smallest canvas side, points.
|
||||
@@ -47,6 +74,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("canvas.scale", "Scale Drawing", "Shape Format › Drawing Canvas", scale)
|
||||
.params(r#"{"factor": number (0.1–10)} (scales the frame and every member together)"#)
|
||||
.when(has_canvas),
|
||||
CommandSpec::new("select.member", "Select Canvas Member", "Shape Format › Drawing Canvas", select_member)
|
||||
.params(r#"{"index": n | null} (picks member `index` of the selected drawing canvas on its own: Shape Format, Size, Rotate, Alt Text, Delete, Cut and Copy then act on it, and Move or Resize and Nudge move it inside the canvas; null: the canvas itself again)"#)
|
||||
.when(has_canvas)
|
||||
.pure(),
|
||||
CommandSpec::new("canvas.member", "Canvas Member", "Shape Format › Drawing Canvas", member)
|
||||
.params(r#"{"index": n, "x"?: pt, "y"?: pt, "width"?: pt, "height"?: pt, "delete"?: bool} (moves, resizes or removes member `index` of the selected canvas; x/y from the canvas's top-left; with no change, returns the members)"#)
|
||||
.when(has_canvas),
|
||||
@@ -224,9 +255,171 @@ fn member(s: &mut Session, v: &Value) -> CmdResult {
|
||||
c.obj.set_size(nw.unwrap_or(w0).clamp(1.0, MAX_SIDE), nh.unwrap_or(h0).clamp(1.0, MAX_SIDE));
|
||||
}
|
||||
})?;
|
||||
// The indices after a removed member shift down, so a picked member would name another one.
|
||||
if delete {
|
||||
s.member = None;
|
||||
}
|
||||
result(&o)
|
||||
}
|
||||
|
||||
/// `select.member`: pick a member of the selected canvas on its own, or (`null`) the canvas again.
|
||||
fn select_member(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let Some((_, InlineObject::Group { children, .. })) = selected_canvas(s) else {
|
||||
return Err(CmdError::Disabled("select a drawing canvas first".into()));
|
||||
};
|
||||
let index = match v.get("index") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(_) => {
|
||||
Some(p::u64(v, "index").and_then(|i| usize::try_from(i).ok()).ok_or_else(|| CmdError::Params("`index` is a member number".into()))?)
|
||||
}
|
||||
};
|
||||
let Some(i) = index else {
|
||||
s.member = None;
|
||||
return sel_result(s);
|
||||
};
|
||||
let count = children.len();
|
||||
let c = children.get(i).ok_or_else(|| CmdError::Params(format!("no member {i} (the canvas has {count})")))?;
|
||||
if !pickable(c) {
|
||||
return Err(CmdError::Params(format!("member {i} can't be selected on its own")));
|
||||
}
|
||||
let (w, h) = c.obj.frame().map(|(w, h, _)| (w, h)).unwrap_or((0.0, 0.0));
|
||||
let out = json!({"index": i, "x": c.x, "y": c.y, "width": w, "height": h, "object": serde_json::to_value(&c.obj).unwrap_or(Value::Null)});
|
||||
s.member = Some(CanvasMember { sel: s.sel.clone(), index: i });
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Delete, Backspace (and their word forms), Cut and Copy with a member picked act on the member
|
||||
/// alone; the canvas stays selected. Typing and pasting, which would replace the whole canvas,
|
||||
/// are disabled. `None`: another command, or no member picked.
|
||||
pub(crate) fn member_command(s: &mut Session, id: &str, _: &Value) -> Option<CmdResult> {
|
||||
let replaces = (id.starts_with("text.") && id != "text.overtype")
|
||||
|| matches!(
|
||||
id,
|
||||
"edit.paste" | "edit.pasteText" | "edit.pasteMerge" | "edit.pasteSpecial" | "edit.pasteClipboardItem" | "edit.pasteAllClipboard"
|
||||
);
|
||||
if !replaces && !matches!(id, "edit.cut" | "edit.copy") {
|
||||
return None;
|
||||
}
|
||||
let (pos, index, _) = selected_member(s)?;
|
||||
Some(match id {
|
||||
"edit.copy" => copy_member(s, &pos, index),
|
||||
"edit.cut" => copy_member(s, &pos, index).and_then(|r| remove_member(s, &pos, index).map(|_| r)),
|
||||
"text.delete" | "text.backspace" | "text.deleteWordBack" | "text.deleteWordForward" => {
|
||||
remove_member(s, &pos, index).and_then(|_| sel_result(s))
|
||||
}
|
||||
_ => Err(CmdError::Disabled("a shape inside a drawing canvas is selected".into())),
|
||||
})
|
||||
}
|
||||
|
||||
/// Copy member `index` of the canvas at `pos` alone: pasted, it is a picture or shape of its own.
|
||||
fn copy_member(s: &mut Session, pos: &Pos, index: usize) -> CmdResult {
|
||||
let end = Pos { off: pos.off + wordcraft_doc::para::OBJ.len_utf8(), ..pos.clone() };
|
||||
let mut f = s.doc.copy_range(pos, &end);
|
||||
let mut copied = false;
|
||||
for b in &mut f.blocks {
|
||||
let wordcraft_doc::Block::Para(p) = b else { continue };
|
||||
for o in &mut p.objects {
|
||||
if let InlineObject::Group { children, canvas: Some(_), .. } = o
|
||||
&& let Some(c) = children.get(index)
|
||||
{
|
||||
*o = c.obj.clone();
|
||||
copied = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !copied {
|
||||
return Err(CmdError::Failed("the member vanished".into()));
|
||||
}
|
||||
s.clipboard_text = f.clipboard_text();
|
||||
s.clip_history.push(&f, &s.clipboard_text);
|
||||
s.clipboard = Some(f);
|
||||
Ok(super::edit::copy_result(s))
|
||||
}
|
||||
|
||||
/// Take member `index` out of the canvas at `pos`; the canvas stays selected.
|
||||
fn remove_member(s: &mut Session, pos: &Pos, index: usize) -> Result<(), CmdError> {
|
||||
edit_canvas(s, pos, |_, _, children| {
|
||||
if index < children.len() {
|
||||
children.remove(index);
|
||||
}
|
||||
})?;
|
||||
s.member = None;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Move or Resize with a member picked: `x`/`y` (the page point of its top-left, as dragging its
|
||||
/// frame gives it) and the size become its place and size in the canvas. `None`: no member.
|
||||
pub(crate) fn member_bounds(s: &mut Session, v: &Value) -> Option<CmdResult> {
|
||||
let (pos, index, obj) = selected_member(s)?;
|
||||
let (w0, h0, _) = obj.frame()?;
|
||||
let min = super::objects::min_size(obj);
|
||||
let to = match (p::f32(v, "x"), p::f32(v, "y")) {
|
||||
(Some(x), Some(y)) => Some((x, y)),
|
||||
(None, None) => None,
|
||||
_ => return Some(Err(CmdError::Params("`x` and `y` go together".into()))),
|
||||
};
|
||||
// `x`/`y` are on the canvas's page: a drop on another page would put the member outside it.
|
||||
if to.is_some()
|
||||
&& let Some(page) = p::u64(v, "page").and_then(|n| usize::try_from(n).ok())
|
||||
&& s.layout().object(&pos, s.page_hint).is_some_and(|o| o.page != page)
|
||||
{
|
||||
return Some(Err(CmdError::Disabled("a shape can't leave its drawing canvas".into())));
|
||||
}
|
||||
let (nw, nh) = (p::f32(v, "width"), p::f32(v, "height"));
|
||||
Some(edit_member(s, &pos, index, |frame, (sx, sy), c| {
|
||||
if let Some((x, y)) = to {
|
||||
c.x = ((x - frame.x) / sx).clamp(-MAX_SIDE, MAX_SIDE);
|
||||
c.y = ((y - frame.y) / sy).clamp(-MAX_SIDE, MAX_SIDE);
|
||||
}
|
||||
let w = nw.map_or(w0, |w| w / sx).clamp(min, MAX_SIDE);
|
||||
let h = nh.map_or(h0, |h| h / sy).clamp(min, MAX_SIDE);
|
||||
if (w, h) != (w0, h0) {
|
||||
c.obj.set_size(w, h);
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
/// Nudge with a member picked: it moves inside the canvas. `None`: no member.
|
||||
pub(crate) fn member_nudge(s: &mut Session, dx: f32, dy: f32) -> Option<CmdResult> {
|
||||
let (pos, index, _) = selected_member(s)?;
|
||||
Some(edit_member(s, &pos, index, |_, (sx, sy), c| {
|
||||
c.x = (c.x + dx / sx).clamp(-MAX_SIDE, MAX_SIDE);
|
||||
c.y = (c.y + dy / sy).clamp(-MAX_SIDE, MAX_SIDE);
|
||||
}))
|
||||
}
|
||||
|
||||
/// Change member `index` of the canvas at `pos`: `f` gets the canvas's frame on the page, the
|
||||
/// scale of its members' space and the member.
|
||||
fn edit_member(s: &mut Session, pos: &Pos, index: usize, f: impl FnOnce(wordcraft_geom::Rect, (f32, f32), &mut GroupChild)) -> CmdResult {
|
||||
let frame = s.layout().object(pos, s.page_hint).ok_or_else(|| CmdError::Failed("the drawing canvas isn't laid out".into()))?.rect;
|
||||
let canvas =
|
||||
s.doc.para_at(pos).and_then(|p| p.object_at(pos.off)).cloned().ok_or_else(|| CmdError::Failed("the drawing canvas vanished".into()))?;
|
||||
let scale = canvas.group_scale(frame.w, frame.h);
|
||||
let mut f = Some(f);
|
||||
let canvas = edit_canvas(s, pos, |_, _, children| {
|
||||
if let (Some(c), Some(f)) = (children.get_mut(index), f.take()) {
|
||||
f(frame, scale, c);
|
||||
}
|
||||
})?;
|
||||
let InlineObject::Group { children, .. } = &canvas else { return result(&canvas) };
|
||||
let member = children.get(index).map(|c| serde_json::to_value(&c.obj).unwrap_or(Value::Null)).unwrap_or(Value::Null);
|
||||
Ok(json!({"object": member, "pos": super::pos_json(pos), "member": index}))
|
||||
}
|
||||
|
||||
/// Change the picked member's own object (Shape Format, Size, Rotate, Alt Text) when `pos` is its
|
||||
/// canvas. `None`: no member picked there.
|
||||
pub(crate) fn edit_member_object(s: &mut Session, pos: &Pos, f: impl Fn(&mut InlineObject)) -> Option<Result<InlineObject, CmdError>> {
|
||||
let (at, index, _) = selected_member(s).filter(|(at, ..)| at == pos)?;
|
||||
let mut out = None;
|
||||
let r = edit_canvas(s, &at, |_, _, children| {
|
||||
if let Some(c) = children.get_mut(index) {
|
||||
f(&mut c.obj);
|
||||
out = Some(c.obj.clone());
|
||||
}
|
||||
});
|
||||
Some(r.and_then(|_| out.ok_or_else(|| CmdError::Failed("the member vanished".into()))))
|
||||
}
|
||||
|
||||
/// Shape Fill and Shape Outline on a selected canvas colour its background and border.
|
||||
pub fn style_mut(o: &mut InlineObject) -> Option<&mut CanvasStyle> {
|
||||
match o {
|
||||
@@ -327,4 +520,131 @@ mod tests {
|
||||
fn canvas_in_text(s: &Session) -> InlineObject {
|
||||
s.doc.body.first().and_then(|b| b.as_para()).and_then(|p| p.objects.first()).cloned().unwrap()
|
||||
}
|
||||
|
||||
fn member_fill(o: &InlineObject, i: usize) -> Option<wordcraft_doc::fill::Fill> {
|
||||
let InlineObject::Group { children, .. } = o else { panic!("{o:?}") };
|
||||
match &children[i].obj {
|
||||
InlineObject::Shape { fill, .. } => fill.clone(),
|
||||
o => panic!("{o:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// #2637: a shape inside a drawing canvas can be picked on its own, as in Word. Shape Format,
|
||||
/// Move or Resize, Nudge, Delete and Copy then act on it rather than on the whole canvas,
|
||||
/// which stays the selection in the text; the commands that place an object in the text
|
||||
/// (wrapping, position) don't apply to it.
|
||||
#[test]
|
||||
fn a_canvas_member_is_selected_and_changed_on_its_own() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.run("insert.canvas", &json!({"width": 400, "height": 300})).unwrap();
|
||||
s.run("insert.shape", &json!({"kind": "rectangle", "width": 100, "height": 40})).unwrap();
|
||||
s.run("insert.shape", &json!({"kind": "ellipse", "width": 50, "height": 50})).unwrap();
|
||||
s.run("canvas.member", &json!({"index": 0, "x": 40, "y": 30})).unwrap();
|
||||
s.run("canvas.member", &json!({"index": 1, "x": 200, "y": 100})).unwrap();
|
||||
let canvas_sel = s.sel.clone();
|
||||
assert!(s.run("select.member", &json!({"index": 5})).is_err(), "no such member");
|
||||
s.run("select.member", &json!({"index": 1})).unwrap();
|
||||
assert_eq!(selected_member(&s).map(|(_, i, _)| i), Some(1));
|
||||
assert_eq!(s.sel, canvas_sel, "the canvas stays the selection in the text");
|
||||
|
||||
// Shape Fill colours the member, not the canvas or the other member.
|
||||
let other = member_fill(&canvas(&s), 0);
|
||||
s.run("shape.fill", &json!({"color": "FF0000"})).unwrap();
|
||||
let c = canvas(&s);
|
||||
assert_eq!(member_fill(&c, 1), wordcraft_doc::props::Rgb::parse("FF0000").map(wordcraft_doc::fill::Fill::solid));
|
||||
assert_eq!(member_fill(&c, 0), other);
|
||||
assert_eq!(c.canvas_style().and_then(|st| st.fill.clone()), None);
|
||||
assert!(selected_member(&s).is_some(), "still picked after a change");
|
||||
|
||||
// Dragging its frame: the page point becomes its place in the canvas.
|
||||
let frame = s.layout().object(&selected_canvas(&s).unwrap().0, 0).unwrap().rect;
|
||||
s.run("arrange.bounds", &json!({"page": 0, "x": frame.x + 10.0, "y": frame.y + 20.0, "width": 60, "height": 30})).unwrap();
|
||||
assert_eq!(members(&canvas(&s))[1], (10.0, 20.0, 60.0, 30.0));
|
||||
assert_eq!(canvas(&s).frame().map(|(w, h, _)| (w, h)), Some((400.0, 300.0)), "the canvas keeps its frame");
|
||||
s.run("arrange.nudge", &json!({"dx": 6, "dy": -6})).unwrap();
|
||||
assert_eq!(members(&canvas(&s))[1], (16.0, 14.0, 60.0, 30.0));
|
||||
assert!(s.run("arrange.wrap", &json!({"wrap": "square"})).is_err(), "the canvas places its members");
|
||||
|
||||
// Copy puts the member alone on the clipboard; Delete takes it out of the canvas.
|
||||
s.run("edit.copy", &json!({})).unwrap();
|
||||
let clip = s.clipboard.clone().unwrap();
|
||||
let copied: Vec<&InlineObject> = clip.blocks.iter().filter_map(|b| b.as_para()).flat_map(|p| p.objects.iter()).collect();
|
||||
assert!(matches!(copied.as_slice(), [InlineObject::Shape { kind: wordcraft_doc::para::ShapeKind::Ellipse, .. }]), "{copied:?}");
|
||||
s.run("text.delete", &json!({})).unwrap();
|
||||
assert_eq!(members(&canvas(&s)), [(40.0, 30.0, 100.0, 40.0)]);
|
||||
assert!(selected_member(&s).is_none());
|
||||
assert_eq!(s.sel, canvas_sel, "the canvas is still selected");
|
||||
// Undo puts the member back.
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(members(&canvas_in_text(&s)).len(), 2);
|
||||
|
||||
// Picking the canvas again (or anything else) ends the member's selection.
|
||||
s.run("select.objects", &json!({"index": 0})).unwrap();
|
||||
s.run("select.member", &json!({"index": 0})).unwrap();
|
||||
s.run("select.member", &json!({"index": null})).unwrap();
|
||||
assert!(selected_member(&s).is_none());
|
||||
s.run("select.member", &json!({"index": 0})).unwrap();
|
||||
s.run("caret.docEnd", &json!({})).unwrap();
|
||||
assert!(selected_member(&s).is_none());
|
||||
// Pasted outside the canvas, the copied member is a shape of its own in the text.
|
||||
s.run("edit.paste", &json!({})).unwrap();
|
||||
let para = s.doc.body.first().and_then(|b| b.as_para()).unwrap();
|
||||
assert!(matches!(para.objects.last(), Some(InlineObject::Shape { kind: wordcraft_doc::para::ShapeKind::Ellipse, .. })), "{:?}", para.objects);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_or_removing_a_member_never_hits_another_shape_or_the_canvas() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.run("insert.canvas", &json!({"width": 400, "height": 300})).unwrap();
|
||||
s.run("insert.shape", &json!({"kind": "rectangle", "width": 100, "height": 40})).unwrap();
|
||||
s.run("insert.shape", &json!({"kind": "ellipse", "width": 50, "height": 50})).unwrap();
|
||||
s.run("select.member", &json!({"index": 1})).unwrap();
|
||||
|
||||
// Typing or pasting would replace the whole canvas: refused, and the member stays picked.
|
||||
for (id, v) in [
|
||||
("text.insert", json!({"text": "x"})),
|
||||
("text.newParagraph", json!({})),
|
||||
("edit.paste", json!({"text": "x"})),
|
||||
("edit.pasteText", json!({"text": "x"})),
|
||||
] {
|
||||
assert!(matches!(s.run(id, &v), Err(CmdError::Disabled(_))), "{id}");
|
||||
assert_eq!(members(&canvas(&s)).len(), 2, "{id}");
|
||||
}
|
||||
assert_eq!(selected_member(&s).map(|(_, i, _)| i), Some(1));
|
||||
// Ctrl+Backspace removes the member alone, like Backspace.
|
||||
s.run("text.deleteWordBack", &json!({})).unwrap();
|
||||
let sizes: Vec<(f32, f32)> = members(&canvas(&s)).iter().map(|m| (m.2, m.3)).collect();
|
||||
assert_eq!(sizes, [(100.0, 40.0)], "the ellipse went, the rectangle stays");
|
||||
assert!(selected_member(&s).is_none());
|
||||
|
||||
// Removing an earlier member through `canvas.member` drops the pick: its index would now
|
||||
// name the next member.
|
||||
s.run("insert.shape", &json!({"kind": "ellipse", "width": 50, "height": 50})).unwrap();
|
||||
s.run("insert.shape", &json!({"kind": "rectangle", "width": 20, "height": 20})).unwrap();
|
||||
s.run("select.member", &json!({"index": 1})).unwrap();
|
||||
s.run("canvas.member", &json!({"index": 0, "delete": true})).unwrap();
|
||||
assert!(selected_member(&s).is_none(), "the pick doesn't slide onto the member that moved up");
|
||||
|
||||
// Commands that replace the selection (the whole canvas) are refused too.
|
||||
s.run("select.member", &json!({"index": 0})).unwrap();
|
||||
for (id, v) in [
|
||||
("insert.table", json!({"rows": 1, "cols": 1})),
|
||||
("table.quick", json!({})),
|
||||
("insert.equation", json!({})),
|
||||
("insert.symbol", json!({"char": "§"})),
|
||||
("insert.field", json!({"instr": "AUTHOR"})),
|
||||
("insert.pageBreak", json!({})),
|
||||
] {
|
||||
assert!(s.run(id, &v).is_err(), "{id}");
|
||||
assert_eq!(members(&canvas(&s)).len(), 2, "{id}: the canvas and its members stay");
|
||||
}
|
||||
// Moving the member: on the canvas's page it moves; a drop on another page is refused.
|
||||
let frame = s.layout().object(&selected_canvas(&s).unwrap().0, 0).unwrap();
|
||||
let before = members(&canvas(&s));
|
||||
let other = frame.page + 1;
|
||||
assert!(matches!(s.run("arrange.bounds", &json!({"page": other, "x": 100, "y": 100})), Err(CmdError::Disabled(_))));
|
||||
assert_eq!(members(&canvas(&s)), before);
|
||||
s.run("arrange.bounds", &json!({"page": frame.page, "x": frame.rect.x + 5.0, "y": frame.rect.y + 5.0})).unwrap();
|
||||
assert_ne!(members(&canvas(&s)), before);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,6 +323,7 @@ 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)
|
||||
@@ -332,6 +333,7 @@ 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)
|
||||
}
|
||||
|
||||
@@ -392,6 +394,7 @@ 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)
|
||||
}
|
||||
@@ -486,6 +489,9 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -361,7 +361,7 @@ impl ClipHistory {
|
||||
self.items.is_empty()
|
||||
}
|
||||
|
||||
fn push(&mut self, f: &Fragment, text: &str) {
|
||||
pub(crate) fn push(&mut self, f: &Fragment, text: &str) {
|
||||
// Text plus per-paragraph overhead; pictures are references into the document.
|
||||
let paras = text.matches('\n').count().saturating_add(f.blocks.len()).saturating_add(f.parts.len()).saturating_add(1);
|
||||
let bytes = text.len().saturating_mul(2).saturating_add(paras.saturating_mul(256));
|
||||
|
||||
@@ -253,27 +253,8 @@ fn open(s: &mut Session, v: &Value) -> CmdResult {
|
||||
);
|
||||
}
|
||||
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()))?;
|
||||
// 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());
|
||||
}
|
||||
// An empty file opens as a new blank document that saves back to it (#1384).
|
||||
let (doc, encrypted) = read_document(s, path, v)?;
|
||||
match s.used_because() {
|
||||
None => s.set_document(doc),
|
||||
Some(why) => {
|
||||
@@ -294,6 +275,53 @@ 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,6 +63,17 @@ 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| {
|
||||
@@ -73,6 +84,33 @@ 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,6 +452,36 @@ 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() {
|
||||
@@ -459,7 +489,8 @@ 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.")]),
|
||||
("chapter", [Some("Chapter 1"), None, None]),
|
||||
// The lower levels are "none" levels with no text: an empty label, as in Word (#4985).
|
||||
("chapter", [Some("Chapter 1"), Some(""), Some("")]),
|
||||
] {
|
||||
let mut s = headings("Heading3");
|
||||
s.run("list.multilevelPreset", &json!({"preset": preset})).unwrap();
|
||||
|
||||
@@ -6,6 +6,9 @@ pub(super) const MAX_OFFSET: f32 = 4000.0;
|
||||
|
||||
/// Resize and/or move the selected object (dragging its frame or handles).
|
||||
pub(super) fn bounds(s: &mut Session, v: &Value) -> CmdResult {
|
||||
if let Some(r) = crate::cmd::canvas::member_bounds(s, v) {
|
||||
return r;
|
||||
}
|
||||
let (pos, obj) = selected(s).ok_or_else(|| CmdError::Disabled("no picture or shape selected".into()))?;
|
||||
let to = match (p::f32(v, "x"), p::f32(v, "y")) {
|
||||
(Some(x), Some(y)) => Some((x, y)),
|
||||
@@ -58,7 +61,7 @@ fn landing(s: &Session, pos: &Pos, to: &Pos) -> Result<Pos, CmdError> {
|
||||
|
||||
/// Move the In Line with Text object at `pos` to the text position `to`, where it stays In Line
|
||||
/// with Text, keeping its character formatting: Word's drag of an inline picture, which lands
|
||||
/// where its dashed insertion cursor was (#2233, #2234). Dropped on itself, nothing changes.
|
||||
/// where its insertion cursor was (#2233, #2234). Dropped on itself, nothing changes.
|
||||
/// Returns the object's position afterwards.
|
||||
fn move_in_text(s: &mut Session, pos: Pos, to: &Pos) -> Result<Pos, CmdError> {
|
||||
let to = landing(s, &pos, to)?;
|
||||
@@ -161,8 +164,9 @@ fn move_object_once(s: &mut Session, pos: &Pos, page: usize, x: f32, y: f32, (w,
|
||||
Some(path) => (Pos { story, path, off: 0 }, false),
|
||||
None => (nearest_line(&layout, page, y, story).ok_or_else(|| CmdError::Params("no text on that page to anchor to".into()))?, true),
|
||||
};
|
||||
// Move its character to the anchor (unless it's already in that paragraph).
|
||||
let at = if anchor.story == pos.story && anchor.path == pos.path && !on_page {
|
||||
// Move its character to the anchor, unless it's already in that paragraph and drawn on this
|
||||
// page (one anchored on a later page of it is drawn there, #4250).
|
||||
let at = if anchor.story == pos.story && anchor.path == pos.path && !on_page && layout.object(pos, page).is_some_and(|o| o.page == page) {
|
||||
pos.clone()
|
||||
} else {
|
||||
let obj = s.doc.para_at(pos).and_then(|p| p.object_at(pos.off)).cloned().ok_or_else(|| CmdError::Failed("object vanished".into()))?;
|
||||
|
||||
@@ -12,6 +12,10 @@ pub(super) fn has_object(s: &Session) -> Option<&'static str> {
|
||||
}
|
||||
|
||||
pub(super) fn has_floating(s: &Session) -> Option<&'static str> {
|
||||
// A canvas member moves inside its canvas.
|
||||
if crate::cmd::canvas::selected_member(s).is_some() {
|
||||
return None;
|
||||
}
|
||||
match selected(s) {
|
||||
Some((_, InlineObject::Graphic { .. })) => Some(FROZEN),
|
||||
Some((_, o)) if o.is_floating() => None,
|
||||
@@ -51,6 +55,15 @@ pub(super) fn has_movable(s: &Session) -> Option<&'static str> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `has_movable` for what places an object in the text (wrapping, position, alignment, Move with
|
||||
/// Text): not for a member picked inside a drawing canvas, which the canvas places.
|
||||
pub(super) fn has_placeable(s: &Session) -> Option<&'static str> {
|
||||
if crate::cmd::canvas::selected_member(s).is_some() {
|
||||
return Some("a shape inside a drawing canvas is placed by the canvas");
|
||||
}
|
||||
has_movable(s)
|
||||
}
|
||||
|
||||
/// Word's own ink in a group is kept in the form a group member takes; it can't stand on its own
|
||||
/// yet, so such a group stays whole.
|
||||
pub(super) const INK_GROUP: &str = "a group holding Word ink can't be ungrouped yet";
|
||||
@@ -99,7 +112,9 @@ pub fn object_selection(s: &Session) -> Option<(Pos, &InlineObject)> {
|
||||
o.is_drawing().then_some((a, o))
|
||||
}
|
||||
|
||||
/// The first picture/shape in the selection, or just before a collapsed caret.
|
||||
/// The first picture/shape in the selection, or just before a collapsed caret. With a canvas
|
||||
/// member picked ([`crate::cmd::canvas::selected_member`]), that member, at its canvas's position:
|
||||
/// the commands that change it go through [`edit_obj`], which changes the member.
|
||||
pub fn selected(s: &Session) -> Option<(Pos, InlineObject)> {
|
||||
selected_all(s, 1).into_iter().next()
|
||||
}
|
||||
@@ -107,6 +122,9 @@ pub fn selected(s: &Session) -> Option<(Pos, InlineObject)> {
|
||||
/// The pictures and shapes in the selection (at most `max`), or the one just before a collapsed
|
||||
/// caret.
|
||||
pub fn selected_all(s: &Session, max: usize) -> Vec<(Pos, InlineObject)> {
|
||||
if let Some((pos, _, member)) = crate::cmd::canvas::selected_member(s) {
|
||||
return vec![(pos, member.clone())];
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
let (a, b) = s.sel.ordered();
|
||||
let max = if a == b { 1 } else { max };
|
||||
@@ -175,8 +193,12 @@ pub(super) fn with_obj(s: &mut Session, f: impl Fn(&mut InlineObject)) -> CmdRes
|
||||
Ok(json!({"object": serde_json::to_value(o).unwrap_or(Value::Null)}))
|
||||
}
|
||||
|
||||
/// Change the object at `pos`; returns it afterwards.
|
||||
/// Change the object at `pos`; returns it afterwards. With a member of the canvas at `pos`
|
||||
/// picked, the member changes.
|
||||
pub(crate) fn edit_obj(s: &mut Session, pos: &Pos, f: impl Fn(&mut InlineObject)) -> Result<InlineObject, CmdError> {
|
||||
if let Some(r) = crate::cmd::canvas::edit_member_object(s, pos, &f) {
|
||||
return r;
|
||||
}
|
||||
let para = s.doc.para_mut(pos.story, &pos.path)?;
|
||||
let o = para.object_at_mut(pos.off).ok_or_else(|| CmdError::Failed("object vanished".into()))?;
|
||||
f(o);
|
||||
|
||||
@@ -2,8 +2,8 @@ use super::arrange::{MAX_OFFSET, bounds, copy_to, move_with_text, unalign};
|
||||
use super::group::{add_object, group, ungroup};
|
||||
use super::picture::{adjust, adjust_media, clear_lum, crop, original_media, recolor, relook, remove_background, reset, size};
|
||||
use super::select::{
|
||||
all_objects, alt_text, can_group, edit_obj, has_floating, has_group, has_movable, has_object, has_picture, has_shape, has_shape_or_canvas,
|
||||
has_sizable, obj_size, objects_list, selected, with_float, with_obj,
|
||||
all_objects, alt_text, can_group, edit_obj, has_floating, has_group, has_movable, has_object, has_picture, has_placeable, has_shape,
|
||||
has_shape_or_canvas, has_sizable, obj_size, objects_list, selected, with_float, with_obj,
|
||||
};
|
||||
use super::shape::{NO_TEXT_BOX, can_link, has_text_box, link_text_box, set_text_body, shape_effects, text_box_target};
|
||||
use super::{CmdError, CommandSpec, InlineObject, Pos, Rgb, Selection, Spin, TextVert, VAlign, Wrap, WrapText, json, p, sel_result};
|
||||
@@ -193,17 +193,17 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
with_float(s, |f| f.wrap = wrap)
|
||||
})
|
||||
.params(r#"{"wrap": "inline|square|tight|through|topAndBottom|behindText|inFrontOfText"}"#)
|
||||
.when(has_movable),
|
||||
.when(has_placeable),
|
||||
CommandSpec::new("arrange.wrapText", "Wrap Text Sides", "Layout › Arrange", |s, v| {
|
||||
let side = p::str(v, "side").unwrap_or("bothSides");
|
||||
let side = WrapText::from_ooxml(side).ok_or_else(|| CmdError::Params(format!("unknown side {side:?}")))?;
|
||||
with_float(s, |f| f.wrap_text = side)
|
||||
})
|
||||
.params(r#"{"side": "bothSides|left|right|largest"} (the sides of the selected Square, Tight or Through object text flows on; largest: the wider side, line by line)"#)
|
||||
.when(has_movable),
|
||||
.when(has_placeable),
|
||||
CommandSpec::new("arrange.moveWithText", "Move with Text", "Layout › Arrange › Wrap Text", |s, v| move_with_text(s, p::bool(v, "value").unwrap_or(true)))
|
||||
.params(r#"{"value": bool} (true: Move with Text, the floating object's vertical position follows its paragraph; false: Fix Position on Page. It stays where it is)"#)
|
||||
.when(has_floating),
|
||||
.when(|s| has_placeable(s).or_else(|| has_floating(s))),
|
||||
CommandSpec::new("arrange.position", "Position", "Layout › Arrange", |s, v| {
|
||||
let preset = p::str(v, "preset").unwrap_or("middleCenter").to_string();
|
||||
let (x, y) = (p::f32(v, "x"), p::f32(v, "y"));
|
||||
@@ -224,9 +224,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
})
|
||||
})
|
||||
.params(r#"{"preset"?: "topLeft|topCenter|topRight|middleLeft|middleCenter|middleRight|bottomLeft|bottomCenter|bottomRight", "x"?: pt, "y"?: pt}"#)
|
||||
.when(has_movable),
|
||||
CommandSpec::new("arrange.bringForward", "Bring Forward", "Layout › Arrange", |s, _| with_float(s, |f| f.wrap = Wrap::InFrontOfText)).when(has_movable),
|
||||
CommandSpec::new("arrange.sendBackward", "Send Backward", "Layout › Arrange", |s, _| with_float(s, |f| f.wrap = Wrap::BehindText)).when(has_movable),
|
||||
.when(has_placeable),
|
||||
CommandSpec::new("arrange.bringForward", "Bring Forward", "Layout › Arrange", |s, _| with_float(s, |f| f.wrap = Wrap::InFrontOfText)).when(has_placeable),
|
||||
CommandSpec::new("arrange.sendBackward", "Send Backward", "Layout › Arrange", |s, _| with_float(s, |f| f.wrap = Wrap::BehindText)).when(has_placeable),
|
||||
CommandSpec::new("arrange.align", "Align", "Layout › Arrange", |s, v| {
|
||||
let h = p::str(v, "value").unwrap_or("center").to_string();
|
||||
let tw = crate::cmd::page::sect(s).text_width();
|
||||
@@ -245,17 +245,20 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
})
|
||||
})
|
||||
.params(r#"{"value": "left|center|right"}"#)
|
||||
.when(has_movable),
|
||||
.when(has_placeable),
|
||||
CommandSpec::new("arrange.selectionPane", "Selection Pane", "Layout › Arrange", objects_list).pure(),
|
||||
CommandSpec::new("arrange.bounds", "Move or Resize", "Layout › Arrange", bounds)
|
||||
.params(r#"{"width"?: pt, "height"?: pt, "x"?: pt, "y"?: pt, "page"?: n, "to"?: Pos} (x/y: the top-left on page `page` (0-based, default its page); moving an inline object by x/y floats it. to: an In Line with Text object moves to that text position and stays inline, as a drag does)"#)
|
||||
.when(has_movable),
|
||||
CommandSpec::new("arrange.copyTo", "Copy to", "Layout › Arrange", copy_to)
|
||||
.params(r#"{"x": pt, "y": pt, "page"?: n} | {"to": Pos} (Ctrl+drag: a copy of the selected object lands with its top-left there, or an In Line with Text one at the text position `to`, and is selected; the original and the clipboard stay as they were)"#)
|
||||
.when(has_movable),
|
||||
.when(has_placeable),
|
||||
CommandSpec::new("arrange.nudge", "Nudge", "Layout › Arrange", |s, v| {
|
||||
let d = |k| p::f32(v, k).unwrap_or(0.0).clamp(-MAX_OFFSET, MAX_OFFSET);
|
||||
let (dx, dy) = (d("dx"), d("dy"));
|
||||
if let Some(r) = crate::cmd::canvas::member_nudge(s, dx, dy) {
|
||||
return r;
|
||||
}
|
||||
unalign(s)?;
|
||||
with_float(s, |f| {
|
||||
f.x = (f.x + dx).clamp(-MAX_OFFSET, MAX_OFFSET);
|
||||
|
||||
@@ -14,6 +14,69 @@ fn object_area(s: &mut Session) -> wordcraft_layout::hit::ObjectHit {
|
||||
s.layout().find_object(0, |o| o.text_box.is_none()).unwrap()
|
||||
}
|
||||
|
||||
/// #4250: a shape dragged into the middle of a page that only continues a long paragraph
|
||||
/// stays there, as in Word: anchored at the line beside it, it's drawn on that page, not
|
||||
/// thrown back to the page the paragraph starts on. One undo step puts it back.
|
||||
#[test]
|
||||
fn a_shape_dropped_on_a_page_a_paragraph_continues_onto_stays_there() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.run("text.insert", &json!({"text": "The quick brown fox jumps over the lazy dog. ".repeat(400)})).unwrap();
|
||||
s.run("caret.docStart", &json!({})).unwrap();
|
||||
s.run("insert.shape", &json!({"kind": "rectangle"})).unwrap();
|
||||
s.run("arrange.wrap", &json!({"wrap": "square"})).unwrap();
|
||||
s.run("arrange.bounds", &json!({"page": 0, "x": 100, "y": 250, "width": 100, "height": 50})).unwrap();
|
||||
assert!(s.layout().pages.len() > 2);
|
||||
let before = (object_area(&mut s), s.undo_depth());
|
||||
s.run("arrange.bounds", &json!({"page": 1, "x": 100, "y": 275})).unwrap();
|
||||
let o = object_area(&mut s);
|
||||
assert_eq!(o.page, 1, "on the page it was dropped on: {o:?}");
|
||||
assert!((o.rect.x - 100.0).abs() < 0.5 && (o.rect.y - 275.0).abs() < 0.5, "where it was dropped: {:?}", o.rect);
|
||||
assert_eq!(s.undo_depth(), before.1 + 1);
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
let back = object_area(&mut s);
|
||||
assert_eq!((back.page, back.rect), (before.0.page, before.0.rect));
|
||||
}
|
||||
|
||||
/// #4250: and dragged back onto the page the paragraph starts on, it stays there: its anchor
|
||||
/// goes back to the paragraph's start rather than staying on the later page's line.
|
||||
#[test]
|
||||
fn a_shape_dragged_back_to_the_page_its_paragraph_starts_on_stays_there() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.run("text.insert", &json!({"text": "The quick brown fox jumps over the lazy dog. ".repeat(400)})).unwrap();
|
||||
s.run("caret.docStart", &json!({})).unwrap();
|
||||
s.run("insert.shape", &json!({"kind": "rectangle"})).unwrap();
|
||||
s.run("arrange.wrap", &json!({"wrap": "square"})).unwrap();
|
||||
s.run("arrange.bounds", &json!({"page": 1, "x": 100, "y": 275, "width": 100, "height": 50})).unwrap();
|
||||
let before = (object_area(&mut s), s.undo_depth());
|
||||
assert!(before.0.page == 1 && before.0.off > 0, "anchored on page 2's line: {:?}", before.0);
|
||||
s.run("arrange.bounds", &json!({"page": 0, "x": 120, "y": 300})).unwrap();
|
||||
let o = object_area(&mut s);
|
||||
assert_eq!((o.page, o.off), (0, 0), "on the page it was dropped on, anchored at the paragraph's start: {o:?}");
|
||||
assert!((o.rect.x - 120.0).abs() < 0.5 && (o.rect.y - 300.0).abs() < 0.5, "where it was dropped: {:?}", o.rect);
|
||||
assert_eq!(s.undo_depth(), before.1 + 1);
|
||||
}
|
||||
|
||||
/// #4262: dropped beside the first line after a page break inside a paragraph, a shape stays
|
||||
/// on the page it was dropped on, anchored in that line.
|
||||
#[test]
|
||||
fn a_shape_dropped_just_after_a_page_break_in_a_paragraph_stays_there() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
let text = "The quick brown fox jumps over the lazy dog. ";
|
||||
s.run("text.insert", &json!({"text": text.repeat(20)})).unwrap();
|
||||
s.run("insert.pageBreak", &json!({})).unwrap();
|
||||
s.run("text.insert", &json!({"text": text.repeat(40)})).unwrap();
|
||||
assert_eq!(s.doc.body.len(), 1, "one paragraph");
|
||||
s.run("caret.docStart", &json!({})).unwrap();
|
||||
s.run("insert.shape", &json!({"kind": "rectangle"})).unwrap();
|
||||
s.run("arrange.wrap", &json!({"wrap": "square"})).unwrap();
|
||||
s.run("arrange.bounds", &json!({"page": 0, "x": 100, "y": 250, "width": 100, "height": 50})).unwrap();
|
||||
s.run("arrange.bounds", &json!({"page": 1, "x": 100, "y": 80})).unwrap();
|
||||
let o = object_area(&mut s);
|
||||
let after_break = text.len() * 20 + wordcraft_doc::para::PAGE_BREAK.len_utf8();
|
||||
assert_eq!((o.page, o.off), (1, after_break), "on the page it was dropped on, anchored right after the break: {o:?}");
|
||||
assert!((o.rect.x - 100.0).abs() < 0.5 && (o.rect.y - 80.0).abs() < 0.5, "where it was dropped: {:?}", o.rect);
|
||||
}
|
||||
|
||||
/// What a click and a handle or body drag do in the editor (#142, #82): hit-test the object,
|
||||
/// select it, then resize and move it with `arrange.bounds`.
|
||||
fn click_resize_and_drag(insert: &str, params: Value) {
|
||||
|
||||
@@ -183,9 +183,17 @@ 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}"#),
|
||||
.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)"#,
|
||||
),
|
||||
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`"#,
|
||||
),
|
||||
@@ -264,3 +272,21 @@ 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())
|
||||
}
|
||||
|
||||
@@ -652,19 +652,33 @@ pub(crate) fn typing_pos(s: &Session, p: &Pos) -> Pos {
|
||||
if para.objects.is_empty() {
|
||||
return p.clone();
|
||||
}
|
||||
let editable: Vec<u32> = s.doc.perm_ranges().iter().filter(|r| editable_by(r, &s.author)).map(|r| r.id).collect();
|
||||
let is_start = |off: usize| matches!(para.object_at(off), Some(InlineObject::PermStart { id, .. }) if editable.contains(id));
|
||||
let is_end = |off: usize| matches!(para.object_at(off), Some(InlineObject::PermEnd { id }) if editable.contains(id));
|
||||
let Some(before) = para.text.get(..p.off) else { return p.clone() };
|
||||
let Some(after) = para.text.get(p.off..) else { return p.clone() };
|
||||
if !before.ends_with(OBJ) && !after.starts_with(OBJ) {
|
||||
return p.clone();
|
||||
}
|
||||
let editable: HashSet<u32> = s.doc.perm_ranges().iter().filter(|r| editable_by(r, &s.author)).map(|r| r.id).collect();
|
||||
// Count the preceding objects once. Each adjacent marker can then use the matching
|
||||
// object directly, forwards for starts and backwards for ends, even with short storage.
|
||||
let index = before.chars().filter(|c| *c == OBJ).count();
|
||||
let w = OBJ.len_utf8();
|
||||
let mut off = p.off;
|
||||
if is_start(off) {
|
||||
while is_start(off) {
|
||||
off += w;
|
||||
for (ch, object) in after.chars().zip(para.objects.iter().skip(index)) {
|
||||
if ch != OBJ || !matches!(object, InlineObject::PermStart { id, .. } if editable.contains(id)) {
|
||||
break;
|
||||
}
|
||||
off += w;
|
||||
}
|
||||
if off > p.off {
|
||||
return Pos { off, ..p.clone() };
|
||||
}
|
||||
while off >= w && is_end(off - w) {
|
||||
off -= w;
|
||||
if let Some(objects) = para.objects.get(..index) {
|
||||
for (ch, object) in before.chars().rev().zip(objects.iter().rev()) {
|
||||
if ch != OBJ || !matches!(object, InlineObject::PermEnd { id } if editable.contains(id)) {
|
||||
break;
|
||||
}
|
||||
off -= w;
|
||||
}
|
||||
}
|
||||
Pos { off, ..p.clone() }
|
||||
}
|
||||
|
||||
@@ -922,3 +922,159 @@ fn legacy_password_is_verified_and_unknown_forms_refused() {
|
||||
assert!(e.to_string().contains(super::UNSUPPORTED_PASSWORD));
|
||||
assert!(super::enforced(&s.doc));
|
||||
}
|
||||
|
||||
/// Consecutive permission boundaries must not recount the paragraph prefix or search all
|
||||
/// editable IDs once per marker. Both ends are used by typing and Enter.
|
||||
#[test]
|
||||
fn typing_boundary_many_permission_markers_is_not_quadratic() {
|
||||
use wordcraft_doc::para::Run;
|
||||
let cpu = || std::fs::read_to_string("/proc/thread-self/schedstat").ok()?.split_whitespace().next()?.parse::<u64>().ok();
|
||||
let measure = |n: usize, at_end: bool| {
|
||||
let mut p = wordcraft_doc::Paragraph::new();
|
||||
p.text.push_str("λ prefix ");
|
||||
let start = p.text.len();
|
||||
for i in 0..n {
|
||||
p.text.push(OBJ);
|
||||
p.objects.push(InlineObject::PermStart { id: i as u32, group: "everyone".into(), editor: String::new() });
|
||||
}
|
||||
p.text.push_str("body");
|
||||
let end = p.text.len();
|
||||
for i in 0..n {
|
||||
p.text.push(OBJ);
|
||||
p.objects.push(InlineObject::PermEnd { id: i as u32 });
|
||||
}
|
||||
let after = p.text.len();
|
||||
p.runs.push(Run { len: p.text.len(), props: CharProps::default() });
|
||||
let mut doc = Document::new();
|
||||
doc.body = vec![Block::Para(p).into()];
|
||||
doc.settings.protection = Some("readOnly".into());
|
||||
let s = Session::new(doc);
|
||||
let at = Pos::body(0, if at_end { after } else { start });
|
||||
let expected = Pos::body(0, if at_end { end } else { start + n * OBJ.len_utf8() });
|
||||
let before = cpu();
|
||||
for _ in 0..3 {
|
||||
assert_eq!(typing_pos(std::hint::black_box(&s), &at), expected);
|
||||
}
|
||||
match (before, cpu()) {
|
||||
(Some(a), Some(b)) if b > a => Some((b - a) as f64 * 1e-9),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
for at_end in [false, true] {
|
||||
let (small, large) = (measure(2_000, at_end), measure(16_000, at_end));
|
||||
let (Some(a), Some(b)) = (small, large) else {
|
||||
eprintln!("Typing boundary: CPU counter unavailable; semantic checks passed, timing assertion skipped");
|
||||
continue;
|
||||
};
|
||||
eprintln!("Typing boundary end={at_end}: small={a:.9}s large={b:.9}s ratio={:.2}", b / a);
|
||||
assert!(b < a * 24.0 + 0.005, "8x permission markers must not take quadratic boundary work: {a:.9}s -> {b:.9}s");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_boundary_keeps_author_precedence_and_malformed_pairing() {
|
||||
use wordcraft_doc::para::Run;
|
||||
// Frozen boundary semantics from before the one-pass lookup. This oracle deliberately
|
||||
// uses object_at independently of the new traversal, including malformed storage.
|
||||
let original = |s: &Session, p: &Pos| {
|
||||
if !matches!(s.doc.settings.protection.as_deref(), Some("readOnly" | "comments")) {
|
||||
return p.clone();
|
||||
}
|
||||
let Some(para) = s.doc.para_at(p) else { return p.clone() };
|
||||
if para.objects.is_empty() {
|
||||
return p.clone();
|
||||
}
|
||||
let editable: Vec<u32> = s.doc.perm_ranges().iter().filter(|r| editable_by(r, &s.author)).map(|r| r.id).collect();
|
||||
let start = |off| matches!(para.object_at(off), Some(InlineObject::PermStart { id, .. }) if editable.contains(id));
|
||||
let end = |off| matches!(para.object_at(off), Some(InlineObject::PermEnd { id }) if editable.contains(id));
|
||||
let mut off = p.off;
|
||||
if start(off) {
|
||||
while start(off) {
|
||||
off += OBJ.len_utf8();
|
||||
}
|
||||
} else {
|
||||
while off >= OBJ.len_utf8() && end(off - OBJ.len_utf8()) {
|
||||
off -= OBJ.len_utf8();
|
||||
}
|
||||
}
|
||||
Pos { off, ..p.clone() }
|
||||
};
|
||||
let objects = [
|
||||
InlineObject::PermStart { id: 1, group: "everyone".into(), editor: String::new() },
|
||||
InlineObject::PermStart { id: 2, group: String::new(), editor: " ALICE ".into() },
|
||||
InlineObject::PermStart { id: 3, group: String::new(), editor: "alice".into() },
|
||||
InlineObject::PermEnd { id: 1 },
|
||||
InlineObject::PermEnd { id: 3 },
|
||||
InlineObject::BookmarkStart { name: "Original".into() },
|
||||
InlineObject::PermEnd { id: 7 },
|
||||
InlineObject::PermStart { id: 7, group: String::new(), editor: "Bob".into() },
|
||||
];
|
||||
for text in
|
||||
[format!("λ{}x{}終", OBJ.to_string().repeat(4), OBJ.to_string().repeat(4)), OBJ.to_string().repeat(9), format!("λ{OBJ}{OBJ}x{OBJ}{OBJ}{OBJ}")]
|
||||
{
|
||||
for count in 0..=objects.len() {
|
||||
for mode in [None, Some("readOnly"), Some("comments"), Some("forms"), Some("trackedChanges")] {
|
||||
let mut p = wordcraft_doc::Paragraph::new();
|
||||
p.text = text.clone();
|
||||
p.objects = objects[..count].to_vec();
|
||||
p.runs.push(Run { len: p.text.len(), props: CharProps::default() });
|
||||
let mut doc = Document::new();
|
||||
doc.body = vec![Block::Para(p).into()];
|
||||
doc.settings.protection = mode.map(str::to_string);
|
||||
let mut s = Session::new(doc);
|
||||
s.author = " Alice ".into();
|
||||
for off in 0..=text.len() + 1 {
|
||||
let at = Pos::body(0, off);
|
||||
assert_eq!(typing_pos(&s, &at), original(&s, &at), "mode={mode:?} count={count} off={off} text={text:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut s = Session::new(Document::from_text("outside"));
|
||||
s.doc.settings.protection = Some("readOnly".into());
|
||||
assert_eq!(typing_pos(&s, &Pos::body(99, 0)), Pos::body(99, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_boundary_keeps_duplicate_ids_across_stories_and_start_precedence() {
|
||||
use wordcraft_doc::para::Run;
|
||||
let paragraph = |text: String, objects| wordcraft_doc::Paragraph {
|
||||
runs: vec![Run { len: text.len(), props: CharProps::default() }],
|
||||
text,
|
||||
objects,
|
||||
..Default::default()
|
||||
};
|
||||
let mut doc = Document::new();
|
||||
doc.body = vec![
|
||||
Block::Para(paragraph(
|
||||
format!("{OBJ}x{OBJ}"),
|
||||
vec![InlineObject::PermStart { id: 1, group: String::new(), editor: "Bob".into() }, InlineObject::PermEnd { id: 1 }],
|
||||
))
|
||||
.into(),
|
||||
];
|
||||
doc.add_part(
|
||||
wordcraft_doc::PartKind::Header,
|
||||
vec![
|
||||
Block::Para(paragraph(
|
||||
format!("{OBJ}y{OBJ}"),
|
||||
vec![InlineObject::PermStart { id: 1, group: "everyone".into(), editor: String::new() }, InlineObject::PermEnd { id: 1 }],
|
||||
))
|
||||
.into(),
|
||||
],
|
||||
);
|
||||
doc.settings.protection = Some("comments".into());
|
||||
let mut s = Session::new(doc);
|
||||
s.author = "Alice".into();
|
||||
// Preserve the existing document-wide ID membership, even for a duplicate in another story.
|
||||
assert_eq!(typing_pos(&s, &Pos::body(0, 0)), Pos::body(0, OBJ.len_utf8()));
|
||||
assert_eq!(typing_pos(&s, &Pos::body(0, 7)), Pos::body(0, 4));
|
||||
s.doc.body = vec![
|
||||
Block::Para(paragraph(
|
||||
format!("{OBJ}{OBJ}x"),
|
||||
vec![InlineObject::PermEnd { id: 1 }, InlineObject::PermStart { id: 1, group: "everyone".into(), editor: String::new() }],
|
||||
))
|
||||
.into(),
|
||||
];
|
||||
// A start to the right wins over an end to the left.
|
||||
assert_eq!(typing_pos(&s, &Pos::body(0, 3)), Pos::body(0, 6));
|
||||
}
|
||||
|
||||
@@ -103,8 +103,9 @@ 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; `\"` 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.
|
||||
/// 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.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct FieldCode {
|
||||
pub name: String,
|
||||
@@ -126,7 +127,7 @@ pub fn field_words(instr: &str) -> Vec<(String, bool)> {
|
||||
while let Some(c) = chars.next() {
|
||||
match c {
|
||||
'"' => break,
|
||||
'\\' if matches!(chars.peek(), Some('"' | '\\')) => w.extend(chars.next()),
|
||||
'\\' if chars.peek().is_some() => w.extend(chars.next()),
|
||||
c => w.push(c),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use super::properties::{new_property_value, text_result};
|
||||
use super::*;
|
||||
|
||||
fn fields(s: &Session) -> Vec<(String, String)> {
|
||||
@@ -446,7 +447,7 @@ fn update_fields_apply_the_capitalization_switches() {
|
||||
"seventh",
|
||||
"Seventh",
|
||||
"BUDGET",
|
||||
"new \\* Upper"
|
||||
"new * Upper"
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -548,6 +549,18 @@ 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
|
||||
|
||||
@@ -121,17 +121,11 @@ 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", |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.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.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.
|
||||
|
||||
@@ -337,6 +337,73 @@ 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");
|
||||
|
||||
@@ -219,6 +219,10 @@ pub struct Session {
|
||||
/// More pictures and shapes selected along with the one the selection holds (Shift+click),
|
||||
/// for Group. Cleared by any edit or selection change other than adding to it.
|
||||
pub also_selected: Vec<Pos>,
|
||||
/// The member of the selected Drawing Canvas picked on its own (#2637), which Shape Format,
|
||||
/// Delete, Copy and the frame act on; see [`crate::cmd::canvas::selected_member`]. Cleared by
|
||||
/// any selection change, caret movement or undo.
|
||||
pub member: Option<crate::cmd::canvas::CanvasMember>,
|
||||
/// Column (block) selection, if one was made (see [`Session::column_segments`]).
|
||||
pub column: Option<ColumnBlock>,
|
||||
/// Column selection mode (Ctrl+Shift+F8): caret movement extends the block.
|
||||
|
||||
@@ -276,6 +276,8 @@ impl Session {
|
||||
ime_state_exhausted: _,
|
||||
pending_ime_application_change: _,
|
||||
ime_origin: _,
|
||||
// A picked Drawing Canvas member is cleared by a switch, not kept per document.
|
||||
member: _,
|
||||
others: _,
|
||||
order: _,
|
||||
untitled_count: _,
|
||||
@@ -376,6 +378,8 @@ impl Session {
|
||||
swap(&mut self.widened, widened);
|
||||
swap(&mut self.edit_marks, edit_marks);
|
||||
swap(&mut self.go_back_step, go_back_step);
|
||||
// A picked Drawing Canvas member (#2637) belongs to the document left behind.
|
||||
self.member = None;
|
||||
}
|
||||
|
||||
/// Make document `id` active without anything else a switch by the user does.
|
||||
|
||||
@@ -150,6 +150,7 @@ impl Session {
|
||||
math_latex: false,
|
||||
math_normal_text: false,
|
||||
also_selected,
|
||||
member: None,
|
||||
prefs: Prefs::default(),
|
||||
system_region: Default::default(),
|
||||
keymap: crate::KeyMap::default(),
|
||||
|
||||
@@ -185,7 +185,10 @@ impl Session {
|
||||
let mutates = spec.mutates;
|
||||
let ime_caret_before = self.current_ime_caret_state();
|
||||
let rev_before = self.rev;
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| crate::cmd::column::dispatch(self, id, mutates, run, params)));
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| match crate::cmd::canvas::member_command(self, id, params) {
|
||||
Some(r) => r,
|
||||
None => crate::cmd::column::dispatch(self, id, mutates, run, params),
|
||||
}));
|
||||
let mut result = match result {
|
||||
Ok(r) => r,
|
||||
Err(p) => {
|
||||
@@ -391,6 +394,15 @@ impl Session {
|
||||
if !matches!(id, "select.addObject" | "arrange.ungroup" | "shape.fill" | "shape.effects") && (spec.mutates || self.sel != *sel_before) {
|
||||
self.also_selected.clear();
|
||||
}
|
||||
// A canvas member stays picked while its canvas does and nothing else is selected.
|
||||
if id != "select.member"
|
||||
&& (self.sel != *sel_before
|
||||
|| id.starts_with("select.")
|
||||
|| id.starts_with("caret.")
|
||||
|| matches!(id, "edit.undo" | "edit.undoTo" | "edit.redo"))
|
||||
{
|
||||
self.member = None;
|
||||
}
|
||||
self.note_typing(id, before_doc.as_ref().map(|b| &b.0), sel_before);
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ impl Session {
|
||||
self.pending = None;
|
||||
self.typing = None;
|
||||
self.also_selected.clear();
|
||||
self.member = None;
|
||||
self.column = None;
|
||||
self.column_mode = false;
|
||||
self.widened = None;
|
||||
@@ -155,6 +156,7 @@ impl Session {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
recovery_slot: _,
|
||||
also_selected: _,
|
||||
member: _,
|
||||
// Column selection and a widened selection's ends, like `sel`'s shape: valid only while
|
||||
// `sel` matches them.
|
||||
column: _,
|
||||
@@ -211,6 +213,7 @@ impl Session {
|
||||
self.doc = doc;
|
||||
self.sel = sel;
|
||||
self.also_selected.clear();
|
||||
self.member = None;
|
||||
self.history.truncate(history_len);
|
||||
self.redo = redo;
|
||||
self.typing_open = typing_open;
|
||||
|
||||
@@ -141,6 +141,8 @@ impl Session {
|
||||
self.page_hint = page_hint;
|
||||
self.column_mode = column_mode;
|
||||
self.math = math;
|
||||
// A picked Drawing Canvas member (#2637) is not part of a view's saved selection.
|
||||
self.member = None;
|
||||
if current {
|
||||
self.hf_return = hf_return;
|
||||
self.typing_open = typing_open;
|
||||
|
||||
+230
-8
@@ -4681,10 +4681,8 @@ fn typed_paragraphs_are_capped_for_every_caller() {
|
||||
assert!(!body_texts(&s).iter().any(|p| p.contains('y')));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_params_never_panic() {
|
||||
let reg = cmd::registry();
|
||||
let junk = [
|
||||
fn hostile_junk() -> [serde_json::Value; 8] {
|
||||
[
|
||||
json!(null),
|
||||
json!({}),
|
||||
json!({"text": 5, "size": "x", "path": [], "pos": {"block": 9999, "off": 99999}}),
|
||||
@@ -4693,11 +4691,23 @@ fn hostile_params_never_panic() {
|
||||
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() {
|
||||
// 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" {
|
||||
// Read Aloud (`review.readAloud`, `readAloud.*`) is fuzzed too: under `cfg(test)` its
|
||||
// backend is the silent `Hold`, asserted below.
|
||||
if reaches_files(spec.id) {
|
||||
continue;
|
||||
}
|
||||
for j in &junk {
|
||||
@@ -4711,6 +4721,127 @@ 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.
|
||||
@@ -7812,6 +7943,97 @@ 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,6 +1539,110 @@ 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,6 +3,10 @@
|
||||
|
||||
/// 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),
|
||||
@@ -17,7 +21,7 @@ pub fn decode_entity(name: &str) -> Option<String> {
|
||||
0x80..=0x9F => crate::txt::cp1252(v as u8),
|
||||
_ => char::from_u32(v).unwrap_or('\u{FFFD}'),
|
||||
};
|
||||
return Some(c.to_string());
|
||||
return Some(c);
|
||||
}
|
||||
let c = match name {
|
||||
"amp" | "AMP" => '&',
|
||||
@@ -229,11 +233,54 @@ pub fn decode_entity(name: &str) -> Option<String> {
|
||||
"zwnj" => '\u{200C}',
|
||||
_ => return None,
|
||||
};
|
||||
Some(c.to_string())
|
||||
Some(c)
|
||||
}
|
||||
|
||||
/// Replace entity references in `s`.
|
||||
/// 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`.
|
||||
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();
|
||||
}
|
||||
@@ -248,10 +295,10 @@ pub fn unescape(s: &str) -> String {
|
||||
cut -= 1;
|
||||
}
|
||||
let end = after.get(..cut).and_then(|w| w.find(';'));
|
||||
match end.and_then(|e| decode_entity(&after[..e]).map(|t| (t, e))) {
|
||||
Some((t, e)) => {
|
||||
out.push_str(&t);
|
||||
rest = &after[e + 1..];
|
||||
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..];
|
||||
}
|
||||
None => {
|
||||
out.push('&');
|
||||
@@ -310,3 +357,14 @@ 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;
|
||||
use super::entity::{unescape, unescape_attr};
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// 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(rest.get(v0..e).unwrap_or(""));
|
||||
val = unescape_attr(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(rest.get(v0..i).unwrap_or(""));
|
||||
val = unescape_attr(rest.get(v0..i).unwrap_or(""));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,6 +232,21 @@ 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,6 +330,18 @@ 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];
|
||||
@@ -410,8 +422,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), body.replace("\\\n", " ")),
|
||||
Kind::Title => format!("# {}", body.replace("\\\n", " ")),
|
||||
Kind::Heading(n) => format!("{} {}", "#".repeat(n.clamp(1, 6) as usize), heading_text(&body)),
|
||||
Kind::Title => format!("# {}", heading_text(&body)),
|
||||
Kind::Rule => "---".to_string(),
|
||||
Kind::Quote => {
|
||||
let b = escape_lines(body);
|
||||
|
||||
@@ -775,3 +775,25 @@ 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,6 +1122,7 @@ 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,6 +844,7 @@ 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,6 +943,8 @@ 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,6 +79,10 @@ 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,
|
||||
@@ -128,6 +132,9 @@ 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,
|
||||
@@ -237,6 +244,8 @@ 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(),
|
||||
@@ -259,6 +268,7 @@ impl<'a> Body<'a> {
|
||||
pending_master: None,
|
||||
paras_done: 0,
|
||||
frame: None,
|
||||
frame_unread: None,
|
||||
in_desc: false,
|
||||
pages,
|
||||
sec: page.clone(),
|
||||
@@ -952,6 +962,8 @@ 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).
|
||||
@@ -970,6 +982,7 @@ 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()
|
||||
@@ -1020,13 +1033,18 @@ 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;
|
||||
}
|
||||
f.size = None;
|
||||
if f.size == style_size {
|
||||
f.size = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1323,6 +1341,7 @@ 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);
|
||||
@@ -1372,19 +1391,23 @@ 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(), fixed: true, space, header_rows: 0 };
|
||||
let extra =
|
||||
model::TableExtra { align, width: None, width_pct, heights: Vec::new(), keep_rows: 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1429,12 +1452,25 @@ 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("./");
|
||||
fr.2 = (self.zip)(path);
|
||||
if let Some(o) = &mut fr.6 {
|
||||
o.replacement = fr.2.clone().map(std::sync::Arc::new);
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1478,9 +1514,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.
|
||||
// A replacement picture read before the object: the first the frame offered.
|
||||
if let Some(o) = &mut fr.6 {
|
||||
o.replacement = fr.2.clone().map(std::sync::Arc::new);
|
||||
o.replacement = self.frame_unread.take().or_else(|| fr.2.clone()).map(std::sync::Arc::new);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1526,7 +1562,20 @@ impl Body<'_> {
|
||||
match local(q) {
|
||||
"span" => {
|
||||
let base = self.cur_fmt();
|
||||
let fmt = get(a, "text:style-name").map_or_else(|| base.clone(), |s| self.styles.span_fmt(s, &base));
|
||||
// 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
|
||||
},
|
||||
);
|
||||
self.fmt.push(fmt);
|
||||
}
|
||||
"a" => {
|
||||
|
||||
@@ -385,6 +385,11 @@ 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,7 +87,11 @@ 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);
|
||||
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);
|
||||
FTable { rows, widths, borderless, extra: kept.then(|| Box::new(extra)), kept: None, wrap: None }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ 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());
|
||||
@@ -39,11 +40,23 @@ 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
|
||||
@@ -183,20 +196,41 @@ 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| {
|
||||
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 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 open = format!("<{el} ");
|
||||
xml.match_indices(&open).any(|(i, _)| xml.get(i..).and_then(|t| t.split('>').next()).is_some_and(|tag| tag.contains(name)))
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// 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).
|
||||
fn children(xml: &str, container: &str, group: &str) -> (Vec<(String, (usize, usize))>, Option<(usize, usize)>) {
|
||||
pub(super) 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,6 +900,11 @@ 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()
|
||||
@@ -913,6 +918,54 @@ 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,6 +252,8 @@ 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>,
|
||||
@@ -623,6 +625,9 @@ 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) {
|
||||
@@ -1043,6 +1048,8 @@ 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.
|
||||
@@ -1055,6 +1062,7 @@ 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.
|
||||
@@ -1083,8 +1091,12 @@ 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) {
|
||||
@@ -1151,7 +1163,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) = (false, false),
|
||||
"default-style" if q.starts_with("style:") => (in_default, in_graphic_default, in_row_default) = (false, false, false),
|
||||
"style" if q.starts_with("style:") => {
|
||||
if let Some((n, s)) = cur.take()
|
||||
&& self.map.len() < 100_000
|
||||
@@ -1263,6 +1275,24 @@ 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) };
|
||||
@@ -1337,6 +1367,12 @@ 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)> {
|
||||
@@ -1462,6 +1498,10 @@ 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,3 +284,126 @@ 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,3 +367,33 @@ 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,3 +159,75 @@ 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,6 +287,97 @@ 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,3 +159,161 @@ 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,3 +167,84 @@ 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,11 +244,19 @@ 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" };
|
||||
format!(
|
||||
let mut inner = 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,19 +161,33 @@ 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`).
|
||||
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 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));
|
||||
}
|
||||
let mut g = 0usize;
|
||||
for c in row {
|
||||
|
||||
@@ -105,22 +105,27 @@ 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)
|
||||
&& !matches!(word, "par" | "pard" | "cell" | "row")
|
||||
&& matches!(r.st.dest, Dest::Body | Dest::Info(_) | Dest::FldInst | Dest::Bookmark | Dest::FontTbl | Dest::RevTbl)
|
||||
{
|
||||
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,6 +37,62 @@ 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.clamp(0, 8) as usize,
|
||||
"uc" => self.st.uc = pv.max(0) as usize,
|
||||
"plain" => {
|
||||
self.st.fmt = Fmt { link: self.st.fmt.link.clone(), ..Default::default() };
|
||||
self.st.rev = RevSt::default();
|
||||
|
||||
@@ -332,7 +332,14 @@ pub(crate) fn layout_box(ctx: &mut Ctx, input: BoxInput<'_>) -> BoxLayout {
|
||||
prev_end = push_para(&mut items, (story, &path), &pl, (0, pl.lines.len()), (0.0, y + lead), width, edges);
|
||||
// Pictures, shapes and text boxes: drawn, their areas and text boxes' text.
|
||||
if !p.objects.is_empty() {
|
||||
let at = ObjFrame { page: frame, col: (0.0, width), para_y: para_top, table_chr: table.map(|t| &t.chr), pinned: Some(&pinned) };
|
||||
let at = ObjFrame {
|
||||
page: frame,
|
||||
col: (0.0, width),
|
||||
para_y: para_top,
|
||||
table_chr: table.map(|t| &t.chr),
|
||||
pinned: Some(&pinned),
|
||||
late: None,
|
||||
};
|
||||
let (back, front) = place_objects(
|
||||
ctx,
|
||||
&ObjectPlacement {
|
||||
|
||||
@@ -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), worked out once
|
||||
/// per layout ([`textbox::fitted_height`]).
|
||||
pub(crate) fitted: HashMap<(u32, u32), Option<f32>>,
|
||||
/// 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>>,
|
||||
/// 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());
|
||||
let key = (id, w.to_bits(), self.fields.page_key());
|
||||
if let Some(h) = self.fitted.get(&key) {
|
||||
return *h;
|
||||
}
|
||||
@@ -455,3 +455,85 @@ 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, c.adv, c.obj_h + c.obj_d);
|
||||
let frame = Rect::new(cx, base - c.obj_h.max(0.0), c.adv, c.obj_h + c.obj_d);
|
||||
check_box(frame, checked, color, alpha, out);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -491,7 +491,8 @@ 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());
|
||||
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));
|
||||
// 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));
|
||||
// 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`).
|
||||
|
||||
+264
-11
@@ -3,8 +3,8 @@ use super::page::{PageBuilder, PageFrame};
|
||||
use super::placed::Placed;
|
||||
use super::table_flow::footer_table_wraps;
|
||||
use super::{
|
||||
Anchor, Arc, CharProps, Document, Float, FloatAlign, HashMap, HideDeleted, InlineObject, Level, ParaLayout, Paragraph, Path, Point, Rect, Spin,
|
||||
StoryRef, Wrap, WrapText, contour, display, para, textbox,
|
||||
Anchor, Arc, CharProps, Document, Float, FloatAlign, HashMap, HideDeleted, InlineObject, Level, LineEnd, ParaLayout, Paragraph, Path, Point,
|
||||
Rect, Spin, StoryRef, Wrap, WrapText, contour, display, para, textbox,
|
||||
};
|
||||
|
||||
/// Which of `p`'s objects (by index) the layout leaves out ([`para::left_out`]): anchored in hidden
|
||||
@@ -336,14 +336,135 @@ fn group_members(o: &InlineObject, rect: Rect) -> Vec<(Rect, &InlineObject)> {
|
||||
/// page's wrap areas (its area as placed now). Without `place` (a later piece of the paragraph
|
||||
/// broken again for another column) the floats stay where its first piece placed and drew them:
|
||||
/// they aren't placed again in the new column, and their record for the page they are drawn on
|
||||
/// ([`EarlyFloats`]) stands.
|
||||
/// ([`EarlyFloats`]) stands. Floats anchored on a later page ([`late_floats`]), and those in
|
||||
/// `force`, aren't placed here: the paragraph is laid out without them, and they're returned.
|
||||
pub(crate) fn anchor_floats(
|
||||
ctx: &mut Ctx,
|
||||
pb: &mut PageBuilder,
|
||||
anchor: FloatAnchor,
|
||||
force: &[usize],
|
||||
) -> (HashMap<usize, Rect>, Arc<ParaLayout>, Vec<para::Exclusion>, Vec<usize>) {
|
||||
let FloatAnchor { p, block, y0, place, .. } = anchor;
|
||||
ctx.fields.page = pb.number;
|
||||
// Each layout of it here counts its automatic equation numbers from the same place.
|
||||
let eq0 = ctx.eq_count;
|
||||
let (rects, pl, rel) = anchor_floats_but(ctx, pb, anchor, force);
|
||||
if !place || pb.web || p.objects.is_empty() {
|
||||
return (rects, pl, rel, force.to_vec());
|
||||
}
|
||||
// Not those left out, nor those the page started with (#2048 settled them here).
|
||||
let page = pb.pages.len().saturating_sub(1);
|
||||
let seeded: Vec<usize> =
|
||||
pb.early.get(&page).map(|v| v.iter().take(pb.seeded).filter(|(b, ..)| *b == block).map(|(_, k, _)| *k).collect()).unwrap_or_default();
|
||||
let (bottom, more, col_top) = (pb.bottom, pb.cols.len().saturating_sub(pb.col + 1), pb.top);
|
||||
let fit = |pl: &ParaLayout| fitting(pl, 0, y0 + pl.lines.first().map_or(0.0, |l| l.top.max(0.0)), bottom, (more, col_top));
|
||||
let left_out = left_out_objects(ctx.doc, p, None, ctx.opts.show_hidden, ctx.hide_deleted);
|
||||
let mut late: Vec<usize> =
|
||||
late_floats(p, &pl, fit(&pl)).into_iter().chain(force.iter().copied()).filter(|oi| !left_out(*oi) && !seeded.contains(oi)).collect();
|
||||
late.sort_unstable();
|
||||
late.dedup();
|
||||
if late.as_slice() == force {
|
||||
return (rects, pl, rel, late);
|
||||
}
|
||||
ctx.eq_count = eq0;
|
||||
let (rects, pl, rel) = anchor_floats_but(ctx, pb, anchor, &late);
|
||||
// Without them in the way more of the paragraph may fit, and some of their anchors with it.
|
||||
let still = late_floats(p, &pl, fit(&pl));
|
||||
let fewer: Vec<usize> = late.iter().copied().filter(|oi| force.binary_search(oi).is_ok() || still.binary_search(oi).is_ok()).collect();
|
||||
if fewer.len() == late.len() {
|
||||
return (rects, pl, rel, late);
|
||||
}
|
||||
ctx.eq_count = eq0;
|
||||
let (rects, pl, rel) = anchor_floats_but(ctx, pb, anchor, &fewer);
|
||||
(rects, pl, rel, fewer)
|
||||
}
|
||||
|
||||
/// The floating objects of `p` (laid out as `pl`) whose anchor line ([`anchor_lines`]) is at or
|
||||
/// after `fit`, in order: Word draws them on the page their anchor line is on (#4250).
|
||||
pub(crate) fn late_floats(p: &Paragraph, pl: &ParaLayout, fit: usize) -> Vec<usize> {
|
||||
let lines = anchor_lines(p, pl);
|
||||
let late =
|
||||
|(oi, o): (usize, &InlineObject)| (floating(o).is_some() && lines.get(oi).copied().flatten().is_some_and(|li| li >= fit)).then_some(oi);
|
||||
p.objects.iter().enumerate().filter_map(late).collect()
|
||||
}
|
||||
|
||||
/// The end of the lines of `pl` from `l0` that fit on the page: line `l0`'s top at `top` in the
|
||||
/// column, whose bottom is at `bottom`, then `more` columns from `col_top` (at least one line; a
|
||||
/// page break ends them, a column break its column).
|
||||
pub(crate) fn fitting(pl: &ParaLayout, l0: usize, top: f32, bottom: f32, (mut more, col_top): (usize, f32)) -> usize {
|
||||
let Some(first) = pl.lines.get(l0) else { return l0 };
|
||||
// A line's place in its column: `shift` plus its top.
|
||||
let (mut shift, mut start, mut fit) = (top - first.top, l0, l0 + 1);
|
||||
for (li, l) in pl.lines.iter().enumerate().skip(l0) {
|
||||
if li > start && shift + l.top + l.height > bottom + 0.01 {
|
||||
if more == 0 {
|
||||
break;
|
||||
}
|
||||
(more, start, shift) = (more - 1, li, col_top - l.top);
|
||||
}
|
||||
fit = li + 1;
|
||||
match l.end {
|
||||
LineEnd::PageBreak => break,
|
||||
LineEnd::ColumnBreak if more == 0 => break,
|
||||
LineEnd::ColumnBreak => {
|
||||
more -= 1;
|
||||
start = li + 1;
|
||||
shift = pl.lines.get(start).map_or(shift, |n| col_top - n.top);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
fit
|
||||
}
|
||||
|
||||
/// Place late float `oi` of body paragraph `p` ([`LateFloats`]) in the current column,
|
||||
/// positioned from `top`, where the paragraph continues; its wrap area joins the page's.
|
||||
pub(crate) fn place_late(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, oi: usize, top: f32, late: &mut LateFloats) {
|
||||
let Some(o) = p.objects.get(oi) else { return };
|
||||
let Some((w, h, float)) = floating(o) else { return };
|
||||
let h = ctx.float_height(o, w, h);
|
||||
let frame = PageFrame { sect: pb.sect, margin_top: None, origin: (0.0, 0.0), number: pb.number, mirror: ctx.mirror };
|
||||
let rect = float_rect(Some(frame), (pb.col_x(), pb.col_w()), top, w, h, float);
|
||||
let outline = ctx.wrap_outline(o);
|
||||
let area = wrap_area(rect, float, outline.as_deref().map(Vec::as_slice)).map(|a| {
|
||||
let bounds = a.0;
|
||||
pb.excl.push(a);
|
||||
bounds
|
||||
});
|
||||
late.placed.insert(oi, Some(LatePlace { rect, top, spot: (pb.pages.len(), pb.col), drawn: false, area }));
|
||||
}
|
||||
|
||||
/// The line of `pl` each object of `p` goes with (by object index; None if it isn't laid out):
|
||||
/// the one holding its anchor, or for anchors on a line after a page or column break that holds
|
||||
/// nothing taking room ([`para::takes_room`]: only anchors and markers such as bookmarks), the
|
||||
/// break's: Word keeps them on the page before (a cover page's title box, #1632). With text after
|
||||
/// them they are on the new page with it (#4262).
|
||||
pub(crate) fn anchor_lines(p: &Paragraph, pl: &ParaLayout) -> Vec<Option<usize>> {
|
||||
let mut out = vec![None; p.objects.len()];
|
||||
for (li, l) in pl.lines.iter().enumerate() {
|
||||
let clusters = pl.clusters.get(l.c0..l.c1).unwrap_or_default();
|
||||
let lead = li.checked_sub(1).and_then(|k| pl.lines.get(k)).is_some_and(|l| matches!(l.end, LineEnd::PageBreak | LineEnd::ColumnBreak))
|
||||
&& !clusters.iter().any(para::takes_room);
|
||||
for c in clusters {
|
||||
let para::ClKind::Object(oi) = c.kind else { continue };
|
||||
if let Some(slot) = out.get_mut(oi).filter(|s| s.is_none()) {
|
||||
*slot = Some(if lead { li.saturating_sub(1) } else { li });
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// [`anchor_floats`], leaving out the floats in `late` (in order).
|
||||
fn anchor_floats_but(
|
||||
ctx: &mut Ctx,
|
||||
pb: &mut PageBuilder,
|
||||
anchor: FloatAnchor,
|
||||
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 {
|
||||
@@ -363,6 +484,10 @@ pub(crate) fn anchor_floats(
|
||||
if left_out(oi) {
|
||||
continue;
|
||||
}
|
||||
if late.binary_search(&oi).is_ok() {
|
||||
ctx.floats_above.remove(&(block, oi));
|
||||
continue;
|
||||
}
|
||||
let h = ctx.float_height(o, w, h);
|
||||
// Positions relative to the paragraph start above its space before (Word for Windows).
|
||||
let mut r = float_rect(Some(frame), (col_x, width), y0 - before, w, h, float);
|
||||
@@ -414,6 +539,123 @@ pub(crate) struct ObjFrame<'a> {
|
||||
/// Floats placed on the page from a header or footer cell (by object index, in page
|
||||
/// coordinates): their items go to [`Ctx::pin`].
|
||||
pub(crate) pinned: Option<&'a HashMap<usize, Rect>>,
|
||||
/// A body paragraph's floats that go on a later page or column than its first piece.
|
||||
pub(crate) late: Option<&'a LateFloats>,
|
||||
}
|
||||
|
||||
/// Where a late float ([`LateFloats`]) was placed: its rect, the top it's positioned from, the
|
||||
/// page and column, `(pages, col)`, whether it's drawn, and the bounds of its wrap area.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct LatePlace {
|
||||
pub(crate) rect: Rect,
|
||||
pub(crate) top: f32,
|
||||
pub(crate) spot: (usize, usize),
|
||||
pub(crate) drawn: bool,
|
||||
pub(crate) area: Option<Rect>,
|
||||
}
|
||||
|
||||
/// A body paragraph's floating objects anchored on a later page than its first line
|
||||
/// ([`late_floats`]): Word draws them there, positioned from where the paragraph continues
|
||||
/// (#4250). Each is placed when the paragraph gets there, and drawn with that piece.
|
||||
pub(crate) struct LateFloats {
|
||||
/// By object index, where it was placed, once it is.
|
||||
pub(crate) placed: HashMap<usize, Option<LatePlace>>,
|
||||
/// The page and column of the piece being placed.
|
||||
pub(crate) piece: (usize, usize),
|
||||
/// [`anchor_lines`], and the paragraph layout they're for.
|
||||
pub(crate) lines: Option<(Arc<ParaLayout>, Vec<Option<usize>>)>,
|
||||
/// Those a piece let go ([`LateFloats::release`]), and those it keeps all the same
|
||||
/// ([`LateFloats::keep_back`]): (piece, object index).
|
||||
pub(crate) held: Vec<((usize, usize), usize)>,
|
||||
pub(crate) kept: Vec<((usize, usize), usize)>,
|
||||
}
|
||||
|
||||
impl LateFloats {
|
||||
pub(crate) fn new(late: &[usize]) -> Self {
|
||||
LateFloats { placed: late.iter().map(|oi| (*oi, None)).collect(), piece: (0, 0), lines: None, held: Vec::new(), kept: Vec::new() }
|
||||
}
|
||||
|
||||
/// [`anchor_lines`] for `pl`.
|
||||
pub(crate) fn refresh(&mut self, p: &Paragraph, pl: &Arc<ParaLayout>) {
|
||||
if !self.lines.as_ref().is_some_and(|(of, _)| Arc::ptr_eq(of, pl)) {
|
||||
self.lines = Some((pl.clone(), anchor_lines(p, pl)));
|
||||
}
|
||||
}
|
||||
|
||||
/// The line object `oi` goes with ([`anchor_lines`]).
|
||||
pub(crate) fn anchor(&self, oi: usize) -> Option<usize> {
|
||||
self.lines.as_ref().and_then(|(_, v)| v.get(oi).copied().flatten())
|
||||
}
|
||||
|
||||
/// Those placed with this piece whose anchor line is past `end` after all (notes, widow
|
||||
/// control or a row kept together took lines [`fitting`] counted) wait for the next piece,
|
||||
/// their wrap areas out of `excl`. Whether there were any.
|
||||
pub(crate) fn release(&mut self, p: &Paragraph, pl: &Arc<ParaLayout>, end: usize, excl: &mut Vec<WrapArea>) -> bool {
|
||||
self.refresh(p, pl);
|
||||
let piece = self.piece;
|
||||
let out: Vec<usize> = self
|
||||
.placed
|
||||
.iter()
|
||||
.filter(|(oi, at)| at.is_some_and(|a| a.spot == piece && !a.drawn) && !self.kept.contains(&(piece, **oi)))
|
||||
.filter(|(oi, _)| self.anchor(**oi).is_some_and(|li| li >= end))
|
||||
.map(|(oi, _)| *oi)
|
||||
.collect();
|
||||
for oi in &out {
|
||||
if let Some(Some(a)) = self.placed.insert(*oi, None)
|
||||
&& let Some(k) = a.area.and_then(|r| excl.iter().rposition(|w| w.0 == r))
|
||||
{
|
||||
excl.remove(k);
|
||||
}
|
||||
self.held.push((piece, *oi));
|
||||
}
|
||||
!out.is_empty()
|
||||
}
|
||||
|
||||
/// Those this piece let go whose anchor line came back into `lines` without them (their own
|
||||
/// wrap pushed it on): kept with this piece after all, as before. Whether there were any.
|
||||
pub(crate) fn keep_back(&mut self, p: &Paragraph, pl: &Arc<ParaLayout>, lines: std::ops::Range<usize>) -> bool {
|
||||
self.refresh(p, pl);
|
||||
let piece = self.piece;
|
||||
let back: Vec<_> =
|
||||
self.held.iter().filter(|(pc, oi)| *pc == piece && self.anchor(*oi).is_some_and(|li| lines.contains(&li))).copied().collect();
|
||||
self.held.retain(|h| !back.contains(h));
|
||||
self.kept.extend(&back);
|
||||
!back.is_empty()
|
||||
}
|
||||
|
||||
/// Those to place with this piece: not placed yet (nor let go by it), or placed for a column
|
||||
/// the paragraph then didn't go into, with their anchor line in `lines`, in order.
|
||||
pub(crate) fn due(&mut self, p: &Paragraph, pl: &Arc<ParaLayout>, lines: std::ops::Range<usize>) -> Vec<usize> {
|
||||
self.refresh(p, pl);
|
||||
let mut due: Vec<usize> = self
|
||||
.placed
|
||||
.iter()
|
||||
.filter(|(_, at)| at.is_none_or(|a| a.spot != self.piece && !a.drawn))
|
||||
.filter(|(oi, _)| !self.held.contains(&(self.piece, **oi)) && self.anchor(**oi).is_some_and(|li| lines.contains(&li)))
|
||||
.map(|(oi, _)| *oi)
|
||||
.collect();
|
||||
due.sort_unstable();
|
||||
due
|
||||
}
|
||||
|
||||
/// Late float `oi` if it's drawn with this piece.
|
||||
pub(crate) fn here(&self, oi: usize) -> Option<LatePlace> {
|
||||
self.placed.get(&oi).copied().flatten().filter(|a| a.spot == self.piece && !a.drawn)
|
||||
}
|
||||
|
||||
/// The lines holding the anchors of those drawn with this piece (an anchor right after a
|
||||
/// break is on the line after its [`anchor_lines`] one).
|
||||
pub(crate) fn lines_here(&self) -> Vec<usize> {
|
||||
self.placed.keys().filter(|oi| self.here(**oi).is_some()).filter_map(|oi| self.anchor(*oi)).flat_map(|li| [li, li + 1]).collect()
|
||||
}
|
||||
|
||||
/// Those drawn with this piece are drawn.
|
||||
pub(crate) fn drew(&mut self) {
|
||||
let piece = self.piece;
|
||||
for a in self.placed.values_mut().flatten().filter(|a| a.spot == piece) {
|
||||
a.drawn = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What [`anchor_floats`] anchors: paragraph `p` (body block `block`) starting at `y0` with
|
||||
@@ -461,15 +703,15 @@ pub(crate) fn place_objects(ctx: &mut Ctx, req: &ObjectPlacement) -> (Vec<Placed
|
||||
let Some(fl) = pl.lines.get(l0) else { return (Vec::new(), Vec::new()) };
|
||||
// Objects left out of the layout (hidden, or deleted in the final text) aren't placed.
|
||||
let left_out = left_out_objects(ctx.doc, p, at.table_chr, ctx.opts.show_hidden, ctx.hide_deleted);
|
||||
// A floating object belongs to its paragraph, not to its line: Word puts it on the page the
|
||||
// paragraph starts on (a cover page is a paragraph holding a page break and then the text
|
||||
// box with the title; the box stays on the cover). So the paragraph's first piece places
|
||||
// every floating object, whichever of its lines holds the anchor, and later pieces none;
|
||||
// inline objects go with their own line.
|
||||
// The paragraph's first piece draws its floating objects, whichever of its lines holds the
|
||||
// anchor, except those anchored on a later page (`at.late`): the piece there draws them
|
||||
// (#4250). Inline objects go with their own line.
|
||||
let first = l0 == 0;
|
||||
let late = at.late.filter(|l| !l.placed.is_empty());
|
||||
let late_lines = late.map(LateFloats::lines_here).unwrap_or_default();
|
||||
for li in 0..pl.lines.len() {
|
||||
let in_piece = (l0..l1).contains(&li);
|
||||
if !in_piece && !first {
|
||||
if !in_piece && !first && !late_lines.contains(&li) {
|
||||
continue;
|
||||
}
|
||||
let Some(line) = pl.lines.get(li) else { continue };
|
||||
@@ -482,12 +724,23 @@ pub(crate) fn place_objects(ctx: &mut Ctx, req: &ObjectPlacement) -> (Vec<Placed
|
||||
let Some(obj) = p.objects.get(oi) else { continue };
|
||||
let Some((w, h, float)) = obj.frame() else { continue };
|
||||
let floating = float.wrap != Wrap::Inline;
|
||||
if if floating { !first } else { !in_piece } {
|
||||
let is_late = floating && late.is_some_and(|l| l.placed.contains_key(&oi));
|
||||
let late_here = late.and_then(|l| l.here(oi)).filter(|_| floating);
|
||||
let skip = if is_late {
|
||||
late_here.is_none()
|
||||
} else if floating {
|
||||
!first
|
||||
} else {
|
||||
!in_piece
|
||||
};
|
||||
if skip {
|
||||
continue;
|
||||
}
|
||||
let pinned = at.pinned.and_then(|m| m.get(&oi)).copied().filter(|_| floating);
|
||||
let rect = if let Some(r) = pinned {
|
||||
r
|
||||
} else if let Some(l) = late_here {
|
||||
l.rect
|
||||
} else if floating {
|
||||
match floats.get(&oi) {
|
||||
Some(r) => *r,
|
||||
@@ -522,7 +775,7 @@ pub(crate) fn place_objects(ctx: &mut Ctx, req: &ObjectPlacement) -> (Vec<Placed
|
||||
off: c.start,
|
||||
text_box: shown,
|
||||
wrap: float.wrap,
|
||||
origin: Point::new(at.col.0, at.para_y),
|
||||
origin: Point::new(at.col.0, late_here.map_or(at.para_y, |l| l.top)),
|
||||
});
|
||||
// Its text (a group: its text boxes', each where the group's turn carries it; the
|
||||
// text itself stays upright), unless the box budget says no (a box inside itself,
|
||||
|
||||
+116
-11
@@ -1,7 +1,10 @@
|
||||
use super::boxes::{BoxInput, BoxLayout, Edges, joined, layout_box, push_para};
|
||||
use super::cache::has_page_number_fields;
|
||||
use super::ctx::Ctx;
|
||||
use super::floats::{FloatAnchor, ObjFrame, ObjectPlacement, anchor_floats, body_wraps, continued_exclusions, place_objects};
|
||||
use super::floats::{
|
||||
FloatAnchor, LateFloats, ObjFrame, ObjectPlacement, anchor_floats, body_wraps, continued_exclusions, fitting, late_floats, place_late,
|
||||
place_objects,
|
||||
};
|
||||
use super::notes::{DECOR_STORY, NoteBox, para_notes};
|
||||
use super::page::{PageBuilder, PageFrame, item_bottom};
|
||||
use super::placed::Placed;
|
||||
@@ -172,6 +175,15 @@ struct Flow<'a> {
|
||||
wrap_relays: usize,
|
||||
/// The first line not placed yet.
|
||||
l0: usize,
|
||||
/// Its floats anchored on a later page or column than its first piece (#4250).
|
||||
late: LateFloats,
|
||||
/// Whether the floats past the first piece were checked ([`Flow::recheck_late`]), and those
|
||||
/// it held back.
|
||||
rechecked: bool,
|
||||
forced: Vec<usize>,
|
||||
/// The space before it at the top of the piece being placed (a paragraph starting with a page
|
||||
/// break has it on the new page): late floats there are positioned from above it.
|
||||
space_here: f32,
|
||||
}
|
||||
|
||||
impl<'a> Flow<'a> {
|
||||
@@ -185,8 +197,8 @@ impl<'a> Flow<'a> {
|
||||
let guess = pb.y + space_before;
|
||||
let label = ctx.next_label(p);
|
||||
let eq_start = ctx.eq_count;
|
||||
let (float_rects, pl, rel) =
|
||||
anchor_floats(ctx, pb, FloatAnchor { p, block, y0: guess, before: space_before, excl_mark, label: &label, place: true });
|
||||
let (float_rects, pl, rel, late) =
|
||||
anchor_floats(ctx, pb, FloatAnchor { p, block, y0: guess, before: space_before, excl_mark, label: &label, place: true }, &[]);
|
||||
Flow {
|
||||
p,
|
||||
block,
|
||||
@@ -206,6 +218,10 @@ impl<'a> Flow<'a> {
|
||||
page_relays: 0,
|
||||
wrap_relays: 0,
|
||||
l0: 0,
|
||||
late: LateFloats::new(&late),
|
||||
rechecked: false,
|
||||
forced: Vec::new(),
|
||||
space_here: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,16 +229,19 @@ impl<'a> Flow<'a> {
|
||||
self.pl.lines.len()
|
||||
}
|
||||
|
||||
/// Place the floats and lay the paragraph out again from `y0`, `before` the space before it.
|
||||
fn reanchor(&mut self, ctx: &mut Ctx, pb: &mut PageBuilder, (y0, before): (f32, f32)) {
|
||||
let (rects, pl, rel) = again(ctx, self.eq_start, |ctx| {
|
||||
/// Place the floats and lay the paragraph out again from `y0`, `before` the space before it,
|
||||
/// leaving those in `force` for a later piece.
|
||||
fn reanchor(&mut self, ctx: &mut Ctx, pb: &mut PageBuilder, (y0, before): (f32, f32), force: &[usize]) {
|
||||
let (rects, pl, rel, late) = again(ctx, self.eq_start, |ctx| {
|
||||
anchor_floats(
|
||||
ctx,
|
||||
pb,
|
||||
FloatAnchor { p: self.p, block: self.block, y0, before, excl_mark: self.excl_mark, label: &self.label, place: true },
|
||||
force,
|
||||
)
|
||||
});
|
||||
(self.float_rects, self.pl, self.rel) = (rects, pl, rel);
|
||||
self.late = LateFloats::new(&late);
|
||||
(self.laid_page, self.laid_width) = (pb.number, pb.col_w());
|
||||
}
|
||||
|
||||
@@ -473,8 +492,10 @@ impl<'a> Flow<'a> {
|
||||
pb.advance(self.block, self.body_top);
|
||||
if self.l0 == 0 {
|
||||
let y0 = pb.y;
|
||||
self.reanchor(ctx, pb, (y0, 0.0));
|
||||
self.reanchor(ctx, pb, (y0, 0.0), &[]);
|
||||
self.anchor_y = y0;
|
||||
// Its floats are checked again on the new page.
|
||||
(self.rechecked, self.forced) = (false, Vec::new());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -507,6 +528,13 @@ impl<'a> Flow<'a> {
|
||||
}
|
||||
let end = push_para(&mut items, (StoryRef::Body, &[self.block as u32]), &self.pl, (l0, l1), (x, y), self.width, edges);
|
||||
pb.para_end = Some((pb.pages.len(), pb.col, end));
|
||||
// Late floats whose anchor line came here after all are placed with these lines.
|
||||
self.late.piece = (pb.pages.len(), pb.col);
|
||||
if !pb.web && !self.late.placed.is_empty() {
|
||||
let top = if l0 == 0 { self.anchor_y } else { y - self.space_here };
|
||||
self.place_due(ctx, pb, 0..l1, top);
|
||||
}
|
||||
self.space_here = 0.0;
|
||||
// Pictures, shapes and text boxes in these lines (web view: floats placed by this piece).
|
||||
let at = ObjFrame {
|
||||
page: Some(PageFrame { sect: pb.sect, margin_top: None, origin: (0.0, 0.0), number: pb.number, mirror: ctx.mirror }),
|
||||
@@ -514,6 +542,7 @@ impl<'a> Flow<'a> {
|
||||
para_y: if pb.web { y } else { self.anchor_y },
|
||||
table_chr: None,
|
||||
pinned: None,
|
||||
late: Some(&self.late),
|
||||
};
|
||||
let (behind, front) = place_objects(
|
||||
ctx,
|
||||
@@ -529,6 +558,7 @@ impl<'a> Flow<'a> {
|
||||
depth: 0,
|
||||
},
|
||||
);
|
||||
self.late.drew();
|
||||
items.extend(front);
|
||||
items.extend(line_numbers(ctx, pb, &self.pl, (l0, l1), y));
|
||||
add_items(pb, behind, items);
|
||||
@@ -555,7 +585,8 @@ impl<'a> Flow<'a> {
|
||||
}
|
||||
Some(LineEnd::PageBreak) if starts_with_break && !pb.web => {
|
||||
pb.new_page(block, body_top);
|
||||
pb.y += self.pl.rp.space_before.max(0.0);
|
||||
self.space_here = self.pl.rp.space_before.max(0.0);
|
||||
pb.y += self.space_here;
|
||||
}
|
||||
Some(LineEnd::PageBreak) => pb.new_page(block, body_top),
|
||||
Some(LineEnd::ColumnBreak) => {
|
||||
@@ -578,8 +609,8 @@ impl<'a> Flow<'a> {
|
||||
let y0 = pb.y;
|
||||
// The floats were placed and drawn with the first piece: they stay there.
|
||||
let (p, block, excl_mark, eq_start, label) = (self.p, self.block, self.excl_mark, self.eq_start, &self.label);
|
||||
let (rects, relaid, relaid_rel) = with_page_map(ctx, &mut self.page_map, |ctx| {
|
||||
again(ctx, eq_start, |ctx| anchor_floats(ctx, pb, FloatAnchor { p, block, y0, before: 0.0, excl_mark, label, place: false }))
|
||||
let (rects, relaid, relaid_rel, _) = with_page_map(ctx, &mut self.page_map, |ctx| {
|
||||
again(ctx, eq_start, |ctx| anchor_floats(ctx, pb, FloatAnchor { p, block, y0, before: 0.0, excl_mark, label, place: false }, &[]))
|
||||
});
|
||||
if let Some(k) = relaid.lines.iter().position(|l| l.start == resume) {
|
||||
(self.float_rects, self.pl, self.rel) = (rects, relaid, relaid_rel);
|
||||
@@ -589,6 +620,72 @@ impl<'a> Flow<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Late floats anchored in the lines that fit in this column are placed here, so these lines
|
||||
/// are broken again around them ([`Flow::relay_wrap`]).
|
||||
fn place_due_late(&mut self, ctx: &mut Ctx, pb: &mut PageBuilder) {
|
||||
if self.l0 == 0 || pb.web || self.late.placed.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.late.piece = (pb.pages.len(), pb.col);
|
||||
let lines = self.l0..fitting(&self.pl, self.l0, pb.y, pb.bottom, (0, pb.top));
|
||||
self.place_due(ctx, pb, lines, pb.y - self.space_here);
|
||||
}
|
||||
|
||||
/// Place the late floats due with this piece whose anchor line is in `lines`, positioned from
|
||||
/// `top`.
|
||||
fn place_due(&mut self, ctx: &mut Ctx, pb: &mut PageBuilder, lines: std::ops::Range<usize>, top: f32) {
|
||||
for oi in self.late.due(self.p, &self.pl, lines) {
|
||||
place_late(ctx, pb, self.p, oi, top, &mut self.late);
|
||||
}
|
||||
}
|
||||
|
||||
/// Late floats placed for lines that went on after all (past `l1`) wait for the next piece;
|
||||
/// one whose own wrap pushed its line on stays, as before. Whether the lines from `l0` are to
|
||||
/// be broken again without them. Does nothing once the wrap relays are used up
|
||||
/// ([`MAX_WRAP_RELAYS`]).
|
||||
fn settle_late(&mut self, pb: &mut PageBuilder, l1: usize) -> bool {
|
||||
let l0 = self.l0;
|
||||
if l0 == 0 || pb.web || self.late.placed.is_empty() || self.wrap_relays >= MAX_WRAP_RELAYS {
|
||||
return false;
|
||||
}
|
||||
let end = if l1 == l0 && pb.fresh() { l0 + 1 } else { l1 }; // what the overflow keeps
|
||||
let moved = self.late.release(self.p, &self.pl, end, &mut pb.excl) || self.late.keep_back(self.p, &self.pl, l0..end);
|
||||
moved && l1 > l0
|
||||
}
|
||||
|
||||
/// The first piece's lines end at `l1` on this page. Floats anchored past them, on the next
|
||||
/// page, though [`anchor_floats`] thought they would fit here (footnotes, widow control…),
|
||||
/// are held back after all, once; and those held back whose anchor came back once they were
|
||||
/// out of the way are placed here as before, with the text wrapping around them rather than
|
||||
/// over it (before the notes of these lines are kept: the wrap can push their references on).
|
||||
/// Whether the paragraph was laid out again.
|
||||
fn recheck_late(&mut self, ctx: &mut Ctx, pb: &mut PageBuilder, l1: usize) -> bool {
|
||||
if self.l0 != 0 || pb.web {
|
||||
return false;
|
||||
}
|
||||
if l1 < self.n() && !self.rechecked && pb.col + 1 >= pb.cols.len() {
|
||||
self.rechecked = true;
|
||||
self.forced = late_floats(self.p, &self.pl, l1).into_iter().filter(|oi| !self.late.placed.contains_key(oi)).collect();
|
||||
if !self.forced.is_empty() {
|
||||
let mut force = self.forced.clone();
|
||||
force.extend(self.late.placed.keys());
|
||||
force.sort_unstable();
|
||||
let top = (pb.y, pb.y - self.anchor_y);
|
||||
self.reanchor(ctx, pb, top, &force);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
let back = if self.forced.is_empty() { Vec::new() } else { self.late.due(self.p, &self.pl, 0..l1) };
|
||||
if !back.iter().any(|oi| self.forced.contains(oi)) {
|
||||
return false;
|
||||
}
|
||||
// The others are still held back.
|
||||
self.forced.retain(|oi| !back.contains(oi));
|
||||
let (keep, top) = (std::mem::take(&mut self.forced), (pb.y, pb.y - self.anchor_y));
|
||||
self.reanchor(ctx, pb, top, &keep);
|
||||
true
|
||||
}
|
||||
|
||||
/// Leave the space after the paragraph, for the next one to merge with.
|
||||
fn finish(&self, pb: &mut PageBuilder, rooms: Rooms) {
|
||||
// A mark that stayed with its page break keeps its space after on that page too, and its
|
||||
@@ -730,10 +827,11 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block:
|
||||
// A different top only matters when there is something to wrap around.
|
||||
if spot(pb) != placed_at || ((pb.y - f.guess).abs() > 0.01 && !pb.excl.is_empty()) {
|
||||
let y0 = pb.y;
|
||||
f.reanchor(ctx, pb, (y0, applied_before));
|
||||
f.reanchor(ctx, pb, (y0, applied_before), &[]);
|
||||
f.anchor_y = y0 - applied_before;
|
||||
}
|
||||
while f.l0 < f.n() {
|
||||
f.place_due_late(ctx, pb);
|
||||
f.relay_wrap(ctx, pb);
|
||||
f.relay_pages(ctx, pb);
|
||||
// What is laid out for these lines from here (their footnotes) is on this page.
|
||||
@@ -744,6 +842,9 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block:
|
||||
let notes = para_notes(ctx.doc, p, &f.pl);
|
||||
let mut fit = f.fit_lines(ctx, pb, ¬es, lead);
|
||||
fit.l1 = f.settled_end(pb, &fit);
|
||||
if f.settle_late(pb, fit.l1) {
|
||||
continue;
|
||||
}
|
||||
if fit.l1 == f.l0 {
|
||||
if !pb.fresh() {
|
||||
f.move_on(ctx, pb);
|
||||
@@ -751,6 +852,9 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block:
|
||||
}
|
||||
fit.l1 = f.l0 + 1; // can't fit even one line on an empty page: overflow
|
||||
}
|
||||
if f.recheck_late(ctx, pb, fit.l1) {
|
||||
continue;
|
||||
}
|
||||
let (forced, mark_with_break) = (fit.forced, fit.mark_with_break);
|
||||
f.emit(ctx, pb, rooms, (lead, fit, ¬es));
|
||||
f.after_lines(ctx, pb, forced, mark_with_break);
|
||||
@@ -811,6 +915,7 @@ pub(crate) fn place_outline_para(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragr
|
||||
para_y: y,
|
||||
table_chr: None,
|
||||
pinned: None,
|
||||
late: None,
|
||||
};
|
||||
let (behind, front) = place_objects(
|
||||
ctx,
|
||||
|
||||
@@ -204,6 +204,7 @@ 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
|
||||
|
||||
@@ -72,6 +72,9 @@ pub struct ObjectHit {
|
||||
pub wrap: Wrap,
|
||||
/// Where column- and paragraph-relative offsets start (see [`Placed::Object`]).
|
||||
pub origin: Point,
|
||||
/// A member of a Drawing Canvas, by its index in the canvas, when the hit is that member
|
||||
/// (its frame inside the canvas at `pos`) rather than the canvas. Layout gives `None`.
|
||||
pub member: Option<usize>,
|
||||
}
|
||||
|
||||
/// Object positions in one layout, retaining each page occurrence for page-hinted lookups.
|
||||
@@ -149,6 +152,7 @@ fn objects_in(items: &[Placed], index: usize) -> impl Iterator<Item = ObjectHit>
|
||||
text_box: *text_box,
|
||||
wrap: *wrap,
|
||||
origin: *origin,
|
||||
member: None,
|
||||
}),
|
||||
_ => None,
|
||||
})
|
||||
|
||||
@@ -9,6 +9,8 @@ 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)>,
|
||||
|
||||
@@ -38,6 +38,7 @@ pub(crate) use chars::{ligature_features, remap_range, thai_run_face};
|
||||
pub use grid::{Exclusion, Grid, is_east_asian};
|
||||
#[cfg(test)]
|
||||
use lines::break_lines;
|
||||
pub(crate) use lines::takes_room;
|
||||
pub use model::{CellText, ClKind, Cluster, Enclosed, Glyph, Label, LabelRun, Line, LineEnd, PARA_MARK_WIDTH, ParaEnv, RubyLayout, StyleRun, VisCl};
|
||||
pub use paragraph::layout_para;
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -109,7 +109,7 @@ fn has_content(pl: &ParaLayout, c0: usize, j: usize) -> bool {
|
||||
/// control's start, a bookmark) and floating objects' anchors take no room and don't count (Word:
|
||||
/// a picture a little wider than the line, after an anchor, stays on the first line rather than
|
||||
/// leaving the anchor a line of its own).
|
||||
fn takes_room(c: &Cluster) -> bool {
|
||||
pub(crate) fn takes_room(c: &Cluster) -> bool {
|
||||
c.kind != ClKind::Marker && !(matches!(c.kind, ClKind::Object(_)) && c.adv == 0.0 && c.obj_h == 0.0)
|
||||
}
|
||||
|
||||
@@ -297,6 +297,9 @@ 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.
|
||||
@@ -438,8 +441,10 @@ 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.
|
||||
ex.obj_desc = ex.obj_desc.max(c.obj_d);
|
||||
// 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);
|
||||
(st.ascent, st.descent)
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -338,7 +338,8 @@ 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` 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).
|
||||
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.
|
||||
@@ -368,10 +369,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 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 };
|
||||
// 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 };
|
||||
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,6 +43,7 @@ struct Tops {
|
||||
struct Sec<'p> {
|
||||
si: usize,
|
||||
sect: &'p SectionProps,
|
||||
page_format: wordcraft_doc::section::NumFormat,
|
||||
start: SectionStart,
|
||||
tops: Tops,
|
||||
}
|
||||
@@ -158,10 +159,17 @@ fn reflow_sect(first: &SectionProps, opts: &LayoutOptions) -> SectionProps {
|
||||
s
|
||||
}
|
||||
|
||||
fn new_page_builder<'p>(sect: &'p SectionProps, early: &'p EarlyFloats, web: bool, settle: bool) -> PageBuilder<'p> {
|
||||
fn new_page_builder<'p>(
|
||||
sect: &'p SectionProps,
|
||||
page_format: wordcraft_doc::section::NumFormat,
|
||||
early: &'p EarlyFloats,
|
||||
web: bool,
|
||||
settle: bool,
|
||||
) -> PageBuilder<'p> {
|
||||
PageBuilder {
|
||||
pages: Vec::new(),
|
||||
sect,
|
||||
page_format,
|
||||
sect_idx: 0,
|
||||
col: 0,
|
||||
cols: Vec::new(),
|
||||
@@ -221,6 +229,7 @@ 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)
|
||||
})
|
||||
@@ -232,6 +241,7 @@ 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);
|
||||
@@ -246,6 +256,8 @@ 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() {
|
||||
@@ -263,6 +275,8 @@ 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);
|
||||
@@ -281,6 +295,8 @@ 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() {
|
||||
@@ -492,7 +508,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, early, mode.web, !may_rewrap && !mode.reflow);
|
||||
let mut pb = new_page_builder(sect_ref, first_sect.page_num_format, 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;
|
||||
@@ -500,15 +516,17 @@ 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, start, tops };
|
||||
let sec = Sec { si, sect, page_format, start, tops };
|
||||
if !mode.web && !pb.pages.is_empty() && (start != SectionStart::Continuous || turn != prev_turn) {
|
||||
// The page the section before ends on is finished with its own margins and footnote
|
||||
// separators, before this section's replace them: the footnotes pending on it
|
||||
|
||||
@@ -125,9 +125,11 @@ struct Entry {
|
||||
runs: Vec<(String, String)>,
|
||||
/// The list label as shown (`Chapter 2.`).
|
||||
label: Option<String>,
|
||||
/// Each level's value up to the paragraph's, formatted; and whether the label's own
|
||||
/// template shows that level.
|
||||
levels: Vec<(String, bool)>,
|
||||
/// 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,
|
||||
/// How many paragraphs of its style there are up to this one.
|
||||
ordinal: u32,
|
||||
}
|
||||
@@ -180,7 +182,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) = (None, Vec::new());
|
||||
let (mut label, mut levels, mut level) = (None, Vec::new(), 0u8);
|
||||
if let Some(n) = num
|
||||
&& let Some((l, def)) = counters.next_label(&doc.numbering, n.num, n.level)
|
||||
&& def.format != NumFormat::Bullet
|
||||
@@ -193,9 +195,10 @@ 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((fmt.format(v), def.text.contains(&format!("%{}", k + 1))));
|
||||
levels.push((k as u8, 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;
|
||||
@@ -221,7 +224,7 @@ impl StyleIndex {
|
||||
list.push(i);
|
||||
}
|
||||
}
|
||||
entries.push(Entry { path, style, text: clean(&p.plain_text()), runs, label, levels, ordinal });
|
||||
entries.push(Entry { path, style, text: clean(&p.plain_text()), runs, label, levels, level, ordinal });
|
||||
}
|
||||
StyleIndex { entries, by_path, styles, para_entries, char_entries }
|
||||
}
|
||||
@@ -323,18 +326,32 @@ impl StyleIndex {
|
||||
let mut out = match code.number {
|
||||
Number::Text if code.position => String::new(),
|
||||
Number::Text => text,
|
||||
Number::Chapter => e.label.as_deref().map(chapter_number).filter(|s| !s.is_empty()).unwrap_or_else(|| e.ordinal.to_string()),
|
||||
// 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::Plain | Number::Full => {
|
||||
let own = match &e.label {
|
||||
Some(l) if code.suppress => suppress(l, &e.levels),
|
||||
Some(l) if code.suppress => suppress(l, &e.levels, e.level),
|
||||
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 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(".")) }
|
||||
// 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(".")
|
||||
} else {
|
||||
own
|
||||
}
|
||||
@@ -356,13 +373,46 @@ 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`).
|
||||
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(" ") };
|
||||
/// `\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(" "),
|
||||
};
|
||||
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)
|
||||
@@ -441,7 +491,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn suppress_keeps_the_number() {
|
||||
assert_eq!(suppress("Chapter 2.", &[("2".into(), true)]), "2");
|
||||
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!(chapter_number("Chapter IV."), "IV");
|
||||
}
|
||||
}
|
||||
|
||||
+401
-6
@@ -1820,6 +1820,30 @@ 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;
|
||||
@@ -5598,8 +5622,13 @@ 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: still only to its top.
|
||||
assert_eq!(line_of(-500.0).0.obj_d, 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));
|
||||
// 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));
|
||||
@@ -6036,9 +6065,8 @@ fn float_follows_its_paragraph_to_the_next_page() {
|
||||
assert!(para.lines[0].left >= 144.0, "text wraps beside it: {}", para.lines[0].left);
|
||||
}
|
||||
|
||||
/// A floating object belongs to its paragraph, not to the line holding its anchor: Word draws
|
||||
/// it on the page the paragraph starts on. A Word cover page is one paragraph holding a page
|
||||
/// break and then the page-anchored text box with the title; the box stays on the cover.
|
||||
/// A Word cover page is one paragraph holding a page break and then the page-anchored text box
|
||||
/// with the title: with nothing after its anchor, the box stays on the cover.
|
||||
#[test]
|
||||
fn a_float_anchored_after_a_page_break_stays_on_its_paragraph_s_first_page() {
|
||||
let mut d = Document::from_text(&format!("{}\nAfter the cover", wordcraft_doc::para::PAGE_BREAK));
|
||||
@@ -6051,6 +6079,347 @@ fn a_float_anchored_after_a_page_break_stays_on_its_paragraph_s_first_page() {
|
||||
assert!(shapes(&l.pages[1].items).is_empty(), "and not again on page two");
|
||||
}
|
||||
|
||||
/// #4262: a bookmark after the box's anchor (or a comment's end, a field's) takes no room: the box
|
||||
/// still stays on the cover. An inline picture after it does take room: it goes on to page two,
|
||||
/// and the box with it.
|
||||
#[test]
|
||||
fn a_cover_box_stays_on_the_cover_with_a_bookmark_after_it_but_not_with_a_picture() {
|
||||
let cover = |after: InlineObject| {
|
||||
let mut d = Document::from_text(&format!("{}\nAfter the cover", wordcraft_doc::para::PAGE_BREAK));
|
||||
d.insert_object(&Pos::body(0, 1), after, &Default::default()).unwrap();
|
||||
d.insert_object(&Pos::body(0, 1), rect_shape(200.0, 100.0, page_float(100.0, 300.0)), &Default::default()).unwrap();
|
||||
let l = lay(&d);
|
||||
// The floating box; the inline picture is 20 pt wide.
|
||||
let boxes = |pi: usize| shapes(&l.pages[pi].items).iter().filter(|r| r.w > 100.0).count();
|
||||
(boxes(0), boxes(1))
|
||||
};
|
||||
assert_eq!(cover(InlineObject::BookmarkEnd { name: "_GoBack".into() }), (1, 0), "with a bookmark: on the cover");
|
||||
assert_eq!(cover(rect_shape(20.0, 20.0, Default::default())), (0, 1), "with an inline picture: on page two");
|
||||
}
|
||||
|
||||
/// #4262: with text after the anchor, though, the line after the break is on the next page, and
|
||||
/// so is the float anchored at its start. Observed in Word for Mac: a shape dragged beside the
|
||||
/// first line after a page break inside a paragraph is anchored right after the break and stays
|
||||
/// on the new page, positioned from the paragraph's top there.
|
||||
#[test]
|
||||
fn a_float_anchored_before_text_after_a_page_break_is_drawn_on_the_next_page() {
|
||||
use wordcraft_doc::para::{Anchor, Float, Wrap};
|
||||
let text = "The quick brown fox jumps over the lazy dog. ";
|
||||
let mut d = Document::from_text(&format!("{}{}{}", text.repeat(5), wordcraft_doc::para::PAGE_BREAK, text.repeat(5)));
|
||||
let at = text.len() * 5 + wordcraft_doc::para::PAGE_BREAK.len_utf8();
|
||||
let f = Float { wrap: Wrap::Square, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, x: 60.0, y: 1.0, ..Default::default() };
|
||||
d.insert_object(&Pos::body(0, at), rect_shape(100.0, 50.0, f), &Default::default()).unwrap();
|
||||
let l = lay(&d);
|
||||
assert_eq!(l.pages.len(), 2);
|
||||
assert!(shapes(&l.pages[0].items).is_empty(), "not on page one");
|
||||
let (top, ..) = lines_on(&l, 1)[0];
|
||||
assert_eq!(shapes(&l.pages[1].items).len(), 1, "drawn once");
|
||||
let r = shapes(&l.pages[1].items)[0];
|
||||
assert!((r.y - (top + 1.0)).abs() < 0.5, "1 pt below the paragraph's top on page two ({top}): {r:?}");
|
||||
}
|
||||
|
||||
/// The lines of body paragraph 0 on page `pi`: each one's (top on the page, left, start).
|
||||
fn lines_on(l: &DocLayout, pi: usize) -> Vec<(f32, f32, usize)> {
|
||||
let mut out = Vec::new();
|
||||
for it in &l.pages[pi].items {
|
||||
if let Placed::Lines { para, l0, l1, y, story: StoryRef::Body, path, .. } = it
|
||||
&& path.0 == [0]
|
||||
{
|
||||
let first = para.lines[*l0].top;
|
||||
out.extend(para.lines[*l0..*l1].iter().map(|li| (y + li.top - first, li.left, li.start)));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// #4250: a floating object anchored in a line of a long paragraph on a later page is drawn on
|
||||
/// that page, as Word does (its anchor line's page), not on the page the paragraph starts on.
|
||||
/// That page's text wraps around it; the page before is laid out as without it.
|
||||
#[test]
|
||||
fn a_float_anchored_on_a_later_page_of_its_paragraph_is_drawn_on_that_page() {
|
||||
use wordcraft_doc::para::Wrap;
|
||||
let mut d = Document::from_text(&"The quick brown fox jumps over the lazy dog. ".repeat(400));
|
||||
let plain = lay(&d);
|
||||
assert!(plain.pages.len() >= 3, "pages {}", plain.pages.len());
|
||||
let anchor = lines_on(&plain, 1)[5].2;
|
||||
let f = wordcraft_doc::para::Float { wrap: Wrap::Square, ..page_float(72.0, 300.0) };
|
||||
d.insert_object(&Pos::body(0, anchor), rect_shape(100.0, 50.0, f), &Default::default()).unwrap();
|
||||
let l = lay(&d);
|
||||
assert!(shapes(&l.pages[0].items).is_empty(), "not on the page the paragraph starts on");
|
||||
assert_eq!(l.pages.iter().map(|p| shapes(&p.items).len()).sum::<usize>(), 1, "drawn once");
|
||||
let on_two = shapes(&l.pages[1].items);
|
||||
assert_eq!(on_two.len(), 1, "{on_two:?}");
|
||||
assert!((on_two[0].x - 72.0).abs() < 0.5 && (on_two[0].y - 300.0).abs() < 0.5, "{:?}", on_two[0]);
|
||||
let beside: Vec<_> = lines_on(&l, 1).into_iter().filter(|(y, ..)| *y > 300.0 && *y < 350.0).collect();
|
||||
assert!(!beside.is_empty() && beside.iter().all(|(_, left, _)| *left >= 100.0), "the text beside it wraps: {beside:?}");
|
||||
let starts = |l: &DocLayout| lines_on(l, 0).iter().map(|(_, left, start)| (*left, *start)).collect::<Vec<_>>();
|
||||
assert_eq!(starts(&l), starts(&plain), "page one is as without it");
|
||||
// Its hit area is there too, for selecting and dragging it.
|
||||
let hit = l.object(&Pos::body(0, anchor), 0).expect("laid out");
|
||||
assert_eq!(hit.page, 1);
|
||||
}
|
||||
|
||||
/// #4250: positioned from its paragraph, a float anchored on a later page of the paragraph is
|
||||
/// measured from where the paragraph continues on that page (Word saves such a picture dragged
|
||||
/// onto page two as `relativeFrom="paragraph"` with its offset from that page's top line).
|
||||
#[test]
|
||||
fn a_float_from_its_paragraph_on_a_later_page_is_measured_from_the_paragraph_s_top_there() {
|
||||
use wordcraft_doc::para::{Anchor, Float, Wrap};
|
||||
let mut d = Document::from_text(&"The quick brown fox jumps over the lazy dog. ".repeat(400));
|
||||
let plain = lay(&d);
|
||||
let (top, _, _) = lines_on(&plain, 1)[0];
|
||||
let anchor = lines_on(&plain, 1)[8].2;
|
||||
let f = Float { wrap: Wrap::Square, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, x: 120.0, y: 150.0, dist: 9.0, ..Default::default() };
|
||||
d.insert_object(&Pos::body(0, anchor), rect_shape(130.0, 100.0, f), &Default::default()).unwrap();
|
||||
let l = lay(&d);
|
||||
assert!(shapes(&l.pages[0].items).is_empty());
|
||||
let r = shapes(&l.pages[1].items)[0];
|
||||
assert!((r.y - (top + 150.0)).abs() < 0.5, "150 pt below the paragraph's top on page two ({top}): {r:?}");
|
||||
assert!((r.x - 192.0).abs() < 0.5, "{r:?}");
|
||||
let hit = l.object(&Pos::body(0, anchor), 1).expect("laid out");
|
||||
assert!((hit.origin.y - top).abs() < 0.5, "dragging measures from there too: {:?}", hit.origin);
|
||||
}
|
||||
|
||||
/// #4250: a paragraph starting with a page break has its space before on the new page; a float
|
||||
/// from its paragraph anchored there is still measured from the paragraph's top, above that
|
||||
/// space, as on its first page.
|
||||
#[test]
|
||||
fn a_float_after_a_leading_page_break_is_measured_from_above_the_space_before() {
|
||||
use wordcraft_doc::para::{Anchor, Float, Wrap};
|
||||
let text = format!("Page one.\n{}{}", wordcraft_doc::para::PAGE_BREAK, "The quick brown fox jumps over the lazy dog. ".repeat(20));
|
||||
let mut d = Document::from_text(&text);
|
||||
if let Some(wordcraft_doc::Block::Para(p)) = d.body.get_mut(1).map(std::sync::Arc::make_mut) {
|
||||
p.props.space_before = Some(24.0);
|
||||
}
|
||||
let f = Float { wrap: Wrap::Square, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, y: 150.0, dist: 9.0, ..Default::default() };
|
||||
d.insert_object(&Pos::body(1, 200), rect_shape(130.0, 100.0, f), &Default::default()).unwrap();
|
||||
let l = lay(&d);
|
||||
assert!(shapes(&l.pages[0].items).is_empty());
|
||||
let r = shapes(&l.pages[1].items)[0];
|
||||
assert!((r.y - (72.0 + 150.0)).abs() < 0.5, "150 pt below the top margin: {r:?}");
|
||||
}
|
||||
|
||||
/// #4250, as observed in Word for Mac: lines added at the top of page one push the text down,
|
||||
/// but a picture positioned from its paragraph on page two stays where it is there.
|
||||
#[test]
|
||||
fn text_added_on_page_one_leaves_a_float_on_page_two_where_it_is() {
|
||||
use wordcraft_doc::para::{Anchor, Float, Wrap};
|
||||
let mut d = Document::from_text(&"The quick brown fox jumps over the lazy dog. ".repeat(400));
|
||||
let anchor = lines_on(&lay(&d), 1)[8].2;
|
||||
let f = Float { wrap: Wrap::Square, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, y: 150.0, ..Default::default() };
|
||||
d.insert_object(&Pos::body(0, anchor), rect_shape(130.0, 100.0, f), &Default::default()).unwrap();
|
||||
let before = shapes(&lay(&d).pages[1].items);
|
||||
for _ in 0..3 {
|
||||
d.body.insert(0, wordcraft_doc::para_block(wordcraft_doc::Paragraph::new()));
|
||||
}
|
||||
let l = lay(&d);
|
||||
assert!(shapes(&l.pages[0].items).is_empty());
|
||||
assert_eq!(shapes(&l.pages[1].items), before);
|
||||
}
|
||||
|
||||
/// #4250: anchored on the third page of a paragraph, a float is drawn on the third page.
|
||||
#[test]
|
||||
fn a_float_anchored_two_pages_into_its_paragraph_is_drawn_on_that_page() {
|
||||
let mut d = Document::from_text(&"The quick brown fox jumps over the lazy dog. ".repeat(600));
|
||||
let plain = lay(&d);
|
||||
assert!(plain.pages.len() >= 4, "pages {}", plain.pages.len());
|
||||
let anchor = lines_on(&plain, 2)[3].2;
|
||||
let f = wordcraft_doc::para::Float { wrap: wordcraft_doc::para::Wrap::Square, ..page_float(72.0, 300.0) };
|
||||
d.insert_object(&Pos::body(0, anchor), rect_shape(100.0, 50.0, f), &Default::default()).unwrap();
|
||||
let l = lay(&d);
|
||||
let per_page: Vec<usize> = l.pages.iter().map(|p| shapes(&p.items).len()).collect();
|
||||
assert_eq!(&per_page[..3], [0, 0, 1], "{per_page:?}");
|
||||
assert_eq!(per_page.iter().sum::<usize>(), 1);
|
||||
}
|
||||
|
||||
/// A long paragraph with a long footnote on page one, and a page-placed square float anchored at
|
||||
/// line `k` of page two as laid out without it.
|
||||
fn footnoted_float(k: usize) -> DocLayout {
|
||||
let mut d = Document::from_text(&"The quick brown fox jumps over the lazy dog. ".repeat(400));
|
||||
let mut note = wordcraft_doc::Paragraph::with_text(&"The note text runs on for a while. ".repeat(30), Default::default()).styled("FootnoteText");
|
||||
note.props.space_after = Some(0.0);
|
||||
let id = d.add_part(wordcraft_doc::PartKind::Footnote, vec![wordcraft_doc::para_block(note)]);
|
||||
d.insert_object(
|
||||
&Pos::body(0, 4),
|
||||
InlineObject::NoteRef { kind: wordcraft_doc::para::NoteKind::Footnote, id, custom: String::new() },
|
||||
&Default::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let anchor = lines_on(&lay(&d), 1)[k].2;
|
||||
let f = wordcraft_doc::para::Float { wrap: wordcraft_doc::para::Wrap::Square, ..page_float(72.0, 300.0) };
|
||||
d.insert_object(&Pos::body(0, anchor), rect_shape(100.0, 50.0, f), &Default::default()).unwrap();
|
||||
lay(&d)
|
||||
}
|
||||
|
||||
/// #4250: a footnote on page one leaves less room there than its lines alone suggest; a float
|
||||
/// anchored a few lines into page two is drawn on page two all the same.
|
||||
#[test]
|
||||
fn a_float_anchored_just_past_a_page_of_footnotes_is_drawn_on_the_next_page() {
|
||||
let l = footnoted_float(3);
|
||||
assert!(shapes(&l.pages[0].items).is_empty(), "not on page one");
|
||||
assert_eq!(shapes(&l.pages[1].items).len(), 1);
|
||||
}
|
||||
|
||||
/// #4250: anchored in page two's first line, which goes back to page one once the float no longer
|
||||
/// wraps the text there, a float stays on page one with the text wrapping around it, not over it.
|
||||
#[test]
|
||||
fn a_float_whose_anchor_comes_back_to_page_one_stays_there_with_the_text_around_it() {
|
||||
let l = footnoted_float(0);
|
||||
assert_eq!(l.pages.iter().map(|p| shapes(&p.items).len()).collect::<Vec<_>>()[..2], [1, 0]);
|
||||
let beside: Vec<_> = lines_on(&l, 0).into_iter().filter(|(y, ..)| *y > 300.0 && *y < 350.0).collect();
|
||||
assert!(!beside.is_empty() && beside.iter().all(|(_, left, _)| *left >= 100.0), "the text wraps around it: {beside:?}");
|
||||
}
|
||||
|
||||
/// #4250: the text wrapping around that float again pushes a footnote reference beside its anchor
|
||||
/// back to page two; the note goes with it, once, not left on page one as well.
|
||||
#[test]
|
||||
fn a_footnote_pushed_on_by_a_float_coming_back_is_on_its_reference_s_page_only() {
|
||||
let mut d = Document::from_text(&"The quick brown fox jumps over the lazy dog. ".repeat(400));
|
||||
let note = |text: &str| {
|
||||
let mut p = wordcraft_doc::Paragraph::with_text(text, Default::default()).styled("FootnoteText");
|
||||
p.props.space_after = Some(0.0);
|
||||
vec![wordcraft_doc::para_block(p)]
|
||||
};
|
||||
let noteref = |id| InlineObject::NoteRef { kind: wordcraft_doc::para::NoteKind::Footnote, id, custom: String::new() };
|
||||
let long = d.add_part(wordcraft_doc::PartKind::Footnote, note(&"The note text runs on for a while. ".repeat(30)));
|
||||
d.insert_object(&Pos::body(0, 4), noteref(long), &Default::default()).unwrap();
|
||||
let anchor = lines_on(&lay(&d), 1)[0].2;
|
||||
let short = d.add_part(wordcraft_doc::PartKind::Footnote, note("A short note."));
|
||||
d.insert_object(&Pos::body(0, anchor), noteref(short), &Default::default()).unwrap();
|
||||
let f = wordcraft_doc::para::Float { wrap: wordcraft_doc::para::Wrap::Square, ..page_float(72.0, 300.0) };
|
||||
d.insert_object(&Pos::body(0, anchor), rect_shape(100.0, 50.0, f), &Default::default()).unwrap();
|
||||
let l = lay(&d);
|
||||
assert_eq!(shapes(&l.pages[0].items).len(), 1, "the float stays on page one");
|
||||
let at = anchor + wordcraft_doc::para::OBJ.len_utf8();
|
||||
let ref_page = (0..l.pages.len()).rfind(|&pi| lines_on(&l, pi).first().is_some_and(|f| f.2 <= at));
|
||||
assert_eq!(ref_page, Some(1), "the reference is pushed on to page two");
|
||||
let notes: Vec<usize> = l
|
||||
.pages
|
||||
.iter()
|
||||
.map(|pg| pg.items.iter().filter(|i| matches!(i, Placed::Lines { story: StoryRef::Part(p), .. } if *p == short)).count())
|
||||
.collect();
|
||||
assert_eq!(notes[..2], [0, 1], "the note is on its reference's page, once");
|
||||
}
|
||||
|
||||
/// #4250: of two floats held back for page two, the one whose anchor comes back to page one stays
|
||||
/// there; the other is still drawn on page two.
|
||||
#[test]
|
||||
fn of_two_floats_held_back_only_the_one_whose_anchor_comes_back_stays_on_page_one() {
|
||||
let mut d = Document::from_text(&"The quick brown fox jumps over the lazy dog. ".repeat(400));
|
||||
let mut note = wordcraft_doc::Paragraph::with_text(&"The note text runs on for a while. ".repeat(30), Default::default()).styled("FootnoteText");
|
||||
note.props.space_after = Some(0.0);
|
||||
let id = d.add_part(wordcraft_doc::PartKind::Footnote, vec![wordcraft_doc::para_block(note)]);
|
||||
let noteref = InlineObject::NoteRef { kind: wordcraft_doc::para::NoteKind::Footnote, id, custom: String::new() };
|
||||
d.insert_object(&Pos::body(0, 4), noteref, &Default::default()).unwrap();
|
||||
let lines = lines_on(&lay(&d), 1);
|
||||
let f = |y| wordcraft_doc::para::Float { wrap: wordcraft_doc::para::Wrap::Square, ..page_float(72.0, y) };
|
||||
d.insert_object(&Pos::body(0, lines[3].2), rect_shape(100.0, 50.0, f(400.0)), &Default::default()).unwrap();
|
||||
d.insert_object(&Pos::body(0, lines[0].2), rect_shape(100.0, 50.0, f(300.0)), &Default::default()).unwrap();
|
||||
let l = lay(&d);
|
||||
let ys = |pi: usize| shapes(&l.pages[pi].items).iter().map(|r| r.y).collect::<Vec<_>>();
|
||||
assert_eq!((ys(0).len(), ys(1).len()), (1, 1), "one on each page: {:?} {:?}", ys(0), ys(1));
|
||||
}
|
||||
|
||||
/// #4250: a footnote referenced on page two leaves less room there than its lines alone suggest;
|
||||
/// a float anchored on the first line it pushes on to page three is drawn on page three.
|
||||
#[test]
|
||||
fn a_float_anchored_on_a_line_a_footnote_pushes_on_is_drawn_on_the_next_page() {
|
||||
let mut d = Document::from_text(&"The quick brown fox jumps over the lazy dog. ".repeat(600));
|
||||
let mut note = wordcraft_doc::Paragraph::with_text(&"The note text runs on for a while. ".repeat(30), Default::default()).styled("FootnoteText");
|
||||
note.props.space_after = Some(0.0);
|
||||
let id = d.add_part(wordcraft_doc::PartKind::Footnote, vec![wordcraft_doc::para_block(note)]);
|
||||
let noteref = InlineObject::NoteRef { kind: wordcraft_doc::para::NoteKind::Footnote, id, custom: String::new() };
|
||||
let at = lines_on(&lay(&d), 1)[1].2;
|
||||
d.insert_object(&Pos::body(0, at), noteref, &Default::default()).unwrap();
|
||||
// A word into the line: an anchor at its very start goes with the line before.
|
||||
let anchor = lines_on(&lay(&d), 2)[0].2 + "The ".len();
|
||||
let f = wordcraft_doc::para::Float { wrap: wordcraft_doc::para::Wrap::Square, ..page_float(72.0, 300.0) };
|
||||
d.insert_object(&Pos::body(0, anchor), rect_shape(100.0, 50.0, f), &Default::default()).unwrap();
|
||||
let l = lay(&d);
|
||||
let anchor_page = (0..l.pages.len()).rfind(|&pi| lines_on(&l, pi).first().is_some_and(|f| f.2 <= anchor));
|
||||
let per_page: Vec<usize> = l.pages.iter().map(|p| shapes(&p.items).len()).collect();
|
||||
assert_eq!(anchor_page, Some(2), "the anchor is on page three");
|
||||
assert_eq!(&per_page[..3], [0, 0, 1], "{per_page:?}");
|
||||
let beside: Vec<_> = lines_on(&l, 1).into_iter().filter(|(y, ..)| *y > 300.0 && *y < 350.0).collect();
|
||||
assert!(!beside.is_empty() && beside.iter().all(|(_, left, _)| *left < 1.0), "page two doesn't wrap around it: {beside:?}");
|
||||
}
|
||||
|
||||
/// #4250: widow control takes the line before a paragraph's last on to the next page with it; a
|
||||
/// float anchored there is drawn on that page.
|
||||
#[test]
|
||||
fn a_float_anchored_on_a_line_widow_control_takes_on_is_drawn_on_the_next_page() {
|
||||
// A paragraph of three pages whose last two lines are on the third only by widow control.
|
||||
let (mut d, l) = (150..300)
|
||||
.map(|k| Document::from_text(&"The quick brown fox jumps over the lazy dog. ".repeat(k)))
|
||||
.map(|d| {
|
||||
let l = lay(&d);
|
||||
(d, l)
|
||||
})
|
||||
.find(|(_, l)| l.pages.len() == 3 && lines_on(l, 2).len() == 2 && lines_on(l, 1).len() < lines_on(l, 0).len())
|
||||
.expect("a paragraph ending in a widow");
|
||||
let anchor = lines_on(&l, 2)[0].2 + "The ".len();
|
||||
d.insert_object(&Pos::body(0, anchor), rect_shape(100.0, 50.0, page_float(72.0, 300.0)), &Default::default()).unwrap();
|
||||
let per_page: Vec<usize> = lay(&d).pages.iter().map(|p| shapes(&p.items).len()).collect();
|
||||
assert_eq!(per_page, [0, 0, 1]);
|
||||
}
|
||||
|
||||
/// #4250: a float whose own wrap pushes its anchor line on to the next page stays on the page
|
||||
/// whose lines it was placed with, as before, with the text there wrapping around it.
|
||||
#[test]
|
||||
fn a_float_whose_wrap_pushes_its_own_line_on_stays_with_the_text_around_it() {
|
||||
let mut d = Document::from_text(&"The quick brown fox jumps over the lazy dog. ".repeat(600));
|
||||
let last = *lines_on(&lay(&d), 1).last().unwrap();
|
||||
let anchor = last.2 + "The ".len();
|
||||
let f = wordcraft_doc::para::Float { wrap: wordcraft_doc::para::Wrap::Square, ..page_float(72.0, 300.0) };
|
||||
d.insert_object(&Pos::body(0, anchor), rect_shape(200.0, 100.0, f), &Default::default()).unwrap();
|
||||
let l = lay(&d);
|
||||
let anchor_page = (0..l.pages.len()).rfind(|&pi| lines_on(&l, pi).first().is_some_and(|f| f.2 <= anchor));
|
||||
assert_eq!(anchor_page, Some(2), "its wrap pushes the anchor on to page three");
|
||||
let per_page: Vec<usize> = l.pages.iter().map(|p| shapes(&p.items).len()).collect();
|
||||
assert_eq!(&per_page[..3], [0, 1, 0], "{per_page:?}");
|
||||
let beside: Vec<_> = lines_on(&l, 1).into_iter().filter(|(y, ..)| *y > 300.0 && *y < 400.0).collect();
|
||||
assert!(!beside.is_empty() && beside.iter().all(|(_, left, _)| *left >= 200.0), "the text wraps around it: {beside:?}");
|
||||
}
|
||||
|
||||
/// #4250: holding back a float for a later page lays its paragraph out again; its automatic
|
||||
/// equation numbers aren't counted twice: they read (1), (2).
|
||||
#[test]
|
||||
fn a_paragraph_with_a_float_for_a_later_page_keeps_its_equation_numbers() {
|
||||
let eq = |s: &str| InlineObject::Equation {
|
||||
linear: s.into(),
|
||||
display: true,
|
||||
math: wordcraft_doc::math::Math { nodes: wordcraft_doc::math::parse_linear(s), ..Default::default() },
|
||||
};
|
||||
let mut d = Document::from_text(&format!("{}\n", "The quick brown fox jumps over the lazy dog. ".repeat(400)));
|
||||
let anchor = lines_on(&lay(&d), 1)[5].2;
|
||||
let f = wordcraft_doc::para::Float { wrap: wordcraft_doc::para::Wrap::Square, ..page_float(72.0, 300.0) };
|
||||
d.insert_object(&Pos::body(0, anchor), rect_shape(100.0, 50.0, f), &Default::default()).unwrap();
|
||||
let n = d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().len();
|
||||
d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().insert_object(n, eq("a=b#"), &Default::default()).unwrap();
|
||||
d.para_mut(StoryRef::Body, &Path::top(1)).unwrap().insert_object(0, eq("c=d#"), &Default::default()).unwrap();
|
||||
let l = lay(&d);
|
||||
assert_eq!(shapes(&l.pages[1].items).len(), 1, "held back for page two");
|
||||
assert_eq!(equation_numbers(&l), ["(1)", "(2)"]);
|
||||
}
|
||||
|
||||
/// #4250: in two columns, a float anchored where the paragraph continues in the second column of
|
||||
/// the same page is placed with its first piece, as before, so the first column wraps around it.
|
||||
#[test]
|
||||
fn a_float_anchored_in_the_next_column_of_the_page_stays_with_its_first_piece() {
|
||||
let mut d = Document::from_text(&"The quick brown fox jumps over the lazy dog. ".repeat(120));
|
||||
d.last_section.columns.count = 2;
|
||||
let plain = lay(&d);
|
||||
let lines = lines_on(&plain, 0);
|
||||
let second = lines.iter().position(|w| w.0 < lines[0].0 + 0.5 && w.2 > 0).expect("a second column on page one");
|
||||
let anchor = lines[second + 2].2;
|
||||
let f = wordcraft_doc::para::Float { wrap: wordcraft_doc::para::Wrap::Square, ..page_float(72.0, 200.0) };
|
||||
d.insert_object(&Pos::body(0, anchor), rect_shape(100.0, 50.0, f), &Default::default()).unwrap();
|
||||
let l = lay(&d);
|
||||
assert_eq!(shapes(&l.pages[0].items).len(), 1, "on page one");
|
||||
let beside: Vec<_> = lines_on(&l, 0).into_iter().filter(|(y, left, start)| *y > 200.0 && *y < 250.0 && *start < anchor && *left >= 0.0).collect();
|
||||
assert!(!beside.is_empty() && beside.iter().all(|(_, left, _)| *left >= 100.0), "the first column wraps around it: {beside:?}");
|
||||
}
|
||||
|
||||
/// Floating objects are drawn in their stacking order (`relativeHeight`), not in the order of
|
||||
/// their anchors: a panel anchored after the text box it frames, but lower in the stack, is
|
||||
/// drawn first so the box stays visible over it. Objects without an order keep theirs.
|
||||
@@ -13354,7 +13723,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 - line.top, st.ascent + st.descent)
|
||||
(line.height, line.baseline - c.obj_h.max(0.0) - line.top, st.ascent + st.descent)
|
||||
};
|
||||
for m in [1.0, 1.15, 1.5] {
|
||||
let share = |font: f32| (m - 1.0) * font;
|
||||
@@ -13371,10 +13740,36 @@ 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,3 +612,93 @@ 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,6 +97,49 @@ 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() {
|
||||
|
||||
@@ -324,6 +324,20 @@ fn floating_box_that_resizes_to_fit_takes_its_texts_height() {
|
||||
assert!((shrunk.h - grown.h).abs() < 0.5, "{shrunk:?} {grown:?}");
|
||||
}
|
||||
|
||||
/// #4250: on a later page of its paragraph, a floating box that resizes to fit its text takes its
|
||||
/// text's height too.
|
||||
#[test]
|
||||
fn a_floating_box_on_a_later_page_of_its_paragraph_fits_its_text() {
|
||||
let mut d = Document::from_text(&"The quick brown fox jumps over the lazy dog. ".repeat(400));
|
||||
let float = wordcraft_doc::para::Float { wrap: wordcraft_doc::para::Wrap::Square, ..Default::default() };
|
||||
let id = text_box_with(&mut d, 0, "Inside", 144.0, 1.0, TextBody { auto_fit: true, ..Default::default() }, float);
|
||||
let l = lay(&d);
|
||||
assert!(l.pages.len() >= 3, "pages {}", l.pages.len());
|
||||
let hit = l.find_object(0, |o| o.text_box == Some(id)).unwrap();
|
||||
assert!(hit.page > 0, "drawn on the page of its anchor");
|
||||
assert!(hit.rect.h > 15.0 && hit.rect.h < 30.0, "one line and the margins: {:?}", hit.rect);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_turned_linked_box_turns_only_its_own_lines() {
|
||||
// Linked boxes show the first box's story; turning the second box turns the lines it shows,
|
||||
|
||||
@@ -13,6 +13,8 @@ use super::{
|
||||
mod commands;
|
||||
mod control;
|
||||
mod files;
|
||||
#[cfg(test)]
|
||||
pub(crate) use files::suggested_stem;
|
||||
mod fonts;
|
||||
mod frame;
|
||||
mod input;
|
||||
@@ -439,7 +441,11 @@ 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);
|
||||
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);
|
||||
// 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,7 +318,8 @@ impl WordApp {
|
||||
file_dialogs::Asked::Busy => Ok(json!({"saved": false})),
|
||||
};
|
||||
}
|
||||
let retry = (id == "file.open").then(|| params.clone());
|
||||
// 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_path = retry.clone();
|
||||
let r = self.execute(id, params);
|
||||
if let (Err(e), Some(params)) = (&r, retry)
|
||||
@@ -326,7 +327,8 @@ 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, params, password: Default::default(), message: String::new() });
|
||||
self.dialog =
|
||||
Some(dialogs::Dialog::Password { name, then: id.to_string(), 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,12 +298,16 @@ impl WordApp {
|
||||
}
|
||||
|
||||
/// The name Save suggests: the open file's own Word format (so a .docm keeps its macros),
|
||||
/// otherwise .docx.
|
||||
/// 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`.
|
||||
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!("{}.{e}", self.title_stem()),
|
||||
_ => format!("{}.docx", self.title_stem()),
|
||||
Some(e) if wordcraft_engine::io::is_word_package(&e) => format!("{stem}.{e}"),
|
||||
_ => format!("{stem}.docx"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -463,3 +467,33 @@ 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,6 +55,17 @@ 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) {
|
||||
@@ -155,7 +166,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 [("About", "about"), ("Discord community", "discord")] {
|
||||
for (label, id) in bottom_links(app) {
|
||||
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,6 +90,24 @@ 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.
|
||||
@@ -1390,9 +1408,14 @@ 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,6 +755,86 @@ 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,12 +446,148 @@ 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).
|
||||
/// of Full Screen (#1678). Clicked (or Alt+Q), it takes the typing in place (#5398).
|
||||
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(260.0, control_h), Sense::click());
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(SEARCH_BOX_W, 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,9 +35,11 @@ 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, swallow_shortcut_text};
|
||||
use find::{command_matches, goto_page};
|
||||
#[cfg(test)]
|
||||
use font::{apply_font, preview_size};
|
||||
use font::{east_asian_char, font_props};
|
||||
@@ -414,10 +416,12 @@ pub enum Dialog {
|
||||
#[serde(skip)]
|
||||
basis: Box<CellMarginsForm>,
|
||||
},
|
||||
/// Opening a password-protected document (#55): the password, then `file.open` with
|
||||
/// `params` again. The password is never serialised or printed.
|
||||
/// 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.
|
||||
Password {
|
||||
name: String,
|
||||
then: String,
|
||||
#[serde(skip)]
|
||||
params: Value,
|
||||
#[serde(skip)]
|
||||
@@ -575,6 +579,10 @@ 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)));
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user