mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-11 16:31:54 -06:00
Merge upstream/main into ui-text-hinting
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 }}
|
||||
|
||||
+54
-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)]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
|
||||
@@ -118,9 +118,33 @@ impl Reader<'_> {
|
||||
Some(obj)
|
||||
}
|
||||
|
||||
/// The hyperlink Word keeps on a drawing itself: its `wp:docPr`'s `a:hlinkClick` (ECMA-376
|
||||
/// Part 1 §20.4.2.5, §21.1.2.3.5) naming an external hyperlink relationship. Word stores a
|
||||
/// picture's link there rather than in a `w:hyperlink` around its run (#5267). Its `#name`
|
||||
/// part, when the address has one, stays part of the address.
|
||||
pub(super) fn drawing_link(d: &El, rels: &Rels) -> Option<String> {
|
||||
let frame = d.child("wp:inline").or_else(|| d.child("wp:anchor"))?;
|
||||
let id = frame.child("wp:docPr")?.child("a:hlinkClick")?.attr("r:id")?;
|
||||
let rel = rels.by_id(id).filter(|r| r.external && crate::package::rel_is(&r.kind, rt::HYPERLINK))?;
|
||||
Some(rel.target.trim().to_string()).filter(|u| !u.is_empty())
|
||||
}
|
||||
|
||||
/// Native ink or an Office 2016 chart kept with its fallback (`mc:AlternateContent`), and the
|
||||
/// [`Self::drawing_link`] of the choice it was read from, else of its fallback: another
|
||||
/// choice's link is not this object's.
|
||||
pub(super) fn read_alternate(&mut self, sc: &mut StoryCtx, ac: &El, rels: &Rels) -> Option<(InlineObject, Option<String>)> {
|
||||
let (object, choice) = match self.read_ink_alternate(sc, ac, rels) {
|
||||
Some(o) => (o, children_of_choice(ac).filter(|e| e.name == "mc:Choice")),
|
||||
None => (self.read_chartex_alternate(sc, ac, rels)?, chartex_choice(ac)),
|
||||
};
|
||||
let link =
|
||||
[choice, ac.child("mc:Fallback")].into_iter().flatten().filter_map(|e| e.child("w:drawing")).find_map(|d| Self::drawing_link(d, rels));
|
||||
Some((object, link))
|
||||
}
|
||||
|
||||
/// Keep the selected native-ink branch and its picture fallback as one object. Reading only
|
||||
/// the fallback flattens the ink; reading only the choice loses its preview on the next save.
|
||||
pub(super) fn read_ink_alternate(&mut self, sc: &mut StoryCtx, ac: &El, rels: &Rels) -> Option<InlineObject> {
|
||||
fn read_ink_alternate(&mut self, sc: &mut StoryCtx, ac: &El, rels: &Rels) -> Option<InlineObject> {
|
||||
let choice = children_of_choice(ac).filter(|e| e.name == "mc:Choice")?;
|
||||
let drawing = choice.child("w:drawing")?;
|
||||
if drawing.find("a:graphicData")?.attr("uri") != Some(INK_URI) {
|
||||
@@ -149,10 +173,8 @@ impl Reader<'_> {
|
||||
/// An Office 2016 chart (`cx:chart`: waterfall, sunburst, box & whisker…) drawn from its
|
||||
/// fallback picture, keeping both branches so saving writes the chart back rather than only
|
||||
/// the picture. `None` (read the fallback alone) when it can't be kept whole.
|
||||
pub(super) fn read_chartex_alternate(&mut self, sc: &mut StoryCtx, ac: &El, rels: &Rels) -> Option<InlineObject> {
|
||||
let choice = ac
|
||||
.children("mc:Choice")
|
||||
.find(|c| c.child("w:drawing").and_then(|d| d.find("a:graphicData")).and_then(|g| g.attr("uri")) == Some(CHARTEX_URI))?;
|
||||
fn read_chartex_alternate(&mut self, sc: &mut StoryCtx, ac: &El, rels: &Rels) -> Option<InlineObject> {
|
||||
let choice = chartex_choice(ac)?;
|
||||
// A requirement in a namespace we can't name couldn't be written back.
|
||||
let requires = choice.attr("Requires").unwrap_or("");
|
||||
if requires.split_whitespace().next().is_none() || requires.split_whitespace().any(|p| p == "?") {
|
||||
@@ -563,6 +585,11 @@ impl Reader<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The `mc:Choice` holding an Office 2016 chart (`cx:chart`) in an `mc:AlternateContent`.
|
||||
fn chartex_choice(ac: &El) -> Option<&El> {
|
||||
ac.children("mc:Choice").find(|c| c.child("w:drawing").and_then(|d| d.find("a:graphicData")).and_then(|g| g.attr("uri")) == Some(CHARTEX_URI))
|
||||
}
|
||||
|
||||
/// The alternative text of a `wp:docPr`, `pic:cNvPr` or `wps:cNvPr` (ECMA-376
|
||||
/// `CT_NonVisualDrawingProps`): the description (the title when there is none, as before), and
|
||||
/// the title and decorative mark (the `adec:decorative` extension of [MS-ODRAWXML]).
|
||||
|
||||
@@ -98,9 +98,9 @@ impl Reader<'_> {
|
||||
}
|
||||
}
|
||||
"mc:AlternateContent" => {
|
||||
if let Some(o) = self.read_ink_alternate(sc, k, rels).or_else(|| self.read_chartex_alternate(sc, k, rels)) {
|
||||
if let Some((o, link)) = self.read_alternate(sc, k, rels) {
|
||||
let props = self.run_props_none(ctx);
|
||||
self.emit_obj(sc, pb, o, &props);
|
||||
self.emit_linked_obj(sc, pb, o, &props, link);
|
||||
} else if let Some(c) = children_of_choice(k) {
|
||||
self.read_inline_children(sc, pb, c, rels, ctx, depth + 1);
|
||||
}
|
||||
@@ -237,7 +237,7 @@ impl Reader<'_> {
|
||||
},
|
||||
"w:drawing" => {
|
||||
if let Some(o) = self.read_drawing(sc, k, rels) {
|
||||
self.emit_obj(sc, pb, o, props);
|
||||
self.emit_linked_obj(sc, pb, o, props, Self::drawing_link(k, rels));
|
||||
}
|
||||
}
|
||||
"w:pict" => {
|
||||
@@ -298,8 +298,8 @@ impl Reader<'_> {
|
||||
),
|
||||
"w:ruby" => self.read_ruby(sc, pb, k, props),
|
||||
"mc:AlternateContent" => {
|
||||
if let Some(o) = self.read_ink_alternate(sc, k, rels).or_else(|| self.read_chartex_alternate(sc, k, rels)) {
|
||||
self.emit_obj(sc, pb, o, props);
|
||||
if let Some((o, link)) = self.read_alternate(sc, k, rels) {
|
||||
self.emit_linked_obj(sc, pb, o, props, link);
|
||||
} else if let Some(c) = children_of_choice(k) {
|
||||
for x in c.els() {
|
||||
self.read_run_child(sc, pb, x, (props, rels), note, depth + 1);
|
||||
@@ -376,6 +376,14 @@ impl Reader<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Self::emit_obj`] for a drawing that carries its own `link`: a link around the run wins.
|
||||
fn emit_linked_obj(&mut self, sc: &mut StoryCtx, pb: &mut PB, o: InlineObject, props: &CharProps, link: Option<String>) {
|
||||
match link.filter(|_| props.link.is_none()) {
|
||||
Some(link) => self.emit_obj(sc, pb, o, &CharProps { link: Some(link), ..props.clone() }),
|
||||
None => self.emit_obj(sc, pb, o, props),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn emit_obj(&mut self, sc: &mut StoryCtx, pb: &mut PB, o: InlineObject, props: &CharProps) {
|
||||
match sink(sc) {
|
||||
Some(f) => {
|
||||
|
||||
@@ -10,9 +10,19 @@ pub(super) struct DrawingInfo<'a> {
|
||||
pub docpr: &'a str,
|
||||
pub name: &'a str,
|
||||
pub alt: &'a str,
|
||||
/// The relationship id of the drawing's hyperlink (`a:hlinkClick`), see [`Writer::drawing_link`].
|
||||
pub link: Option<&'a str>,
|
||||
}
|
||||
|
||||
impl Writer<'_> {
|
||||
/// The relationship of the hyperlink a drawing in a run with `props` carries on itself, as
|
||||
/// Word writes a picture's link (`wp:docPr/a:hlinkClick`, #5267): a web or file address. A
|
||||
/// link to a place in the document (`#name`) is only the `w:hyperlink` around the run.
|
||||
pub(super) fn drawing_link(props: &wordcraft_doc::CharProps, rels: &mut PartRels) -> Option<String> {
|
||||
let url = props.link.as_deref().filter(|l| !l.is_empty() && !l.starts_with('#'))?;
|
||||
Some(rels.add(rt::HYPERLINK, url, true))
|
||||
}
|
||||
|
||||
/// A picture's `pic:pic` (the image in `media/{file}`), at `off` in its group (or 0, 0).
|
||||
pub(super) fn pic(&mut self, w: &mut W, o: &InlineObject, file: &str, off: (f32, f32), rels: &mut PartRels) {
|
||||
let InlineObject::Image { media, w: iw, h: ih, alt, crop, float, fx, .. } = o else { return };
|
||||
@@ -20,7 +30,7 @@ impl Writer<'_> {
|
||||
self.used_media.insert(media.clone());
|
||||
w.open("pic:pic", &[]);
|
||||
w.open("pic:nvPicPr", &[]);
|
||||
nv_props(w, "pic:cNvPr", "0", file, alt, &float.alt, float.hidden);
|
||||
nv_props(w, "pic:cNvPr", ("0", file), alt, &float.alt, float.hidden, None);
|
||||
w.open("pic:cNvPicPr", &[]);
|
||||
w.empty("a:picLocks", &[("noChangeAspect", "1"), ("noChangeArrowheads", "1")]);
|
||||
w.close("pic:cNvPicPr");
|
||||
@@ -66,7 +76,7 @@ impl Writer<'_> {
|
||||
w.open("wps:wsp", &[]);
|
||||
if let Some(id) = id {
|
||||
let name = shape_name(*kind, freeform.as_deref(), id);
|
||||
nv_props(w, "wps:cNvPr", id, &name, &float.alt.text, &float.alt, float.hidden);
|
||||
nv_props(w, "wps:cNvPr", (id, &name), &float.alt.text, &float.alt, float.hidden, None);
|
||||
}
|
||||
if kind.is_connector() {
|
||||
// A connector's glued ends (ECMA-376 §20.1.2.2.8 `cNvCxnSpPr`).
|
||||
@@ -235,7 +245,7 @@ impl Writer<'_> {
|
||||
}
|
||||
|
||||
pub(super) fn drawing_open(&mut self, w: &mut W, info: DrawingInfo<'_>) {
|
||||
let DrawingInfo { float, size: (iw, ih), docpr, name, alt } = info;
|
||||
let DrawingInfo { float, size: (iw, ih), docpr, name, alt, link } = info;
|
||||
let (cx, cy) = (emu(iw.max(0.0)), emu(ih.max(0.0)));
|
||||
if float.wrap == Wrap::Inline {
|
||||
w.open("wp:inline", &[("distT", "0"), ("distB", "0"), ("distL", "0"), ("distR", "0")]);
|
||||
@@ -316,7 +326,7 @@ impl Writer<'_> {
|
||||
Wrap::TopAndBottom => w.empty("wp:wrapTopAndBottom", &[]),
|
||||
Wrap::BehindText | Wrap::InFrontOfText => w.empty("wp:wrapNone", &[]),
|
||||
}
|
||||
nv_props(w, "wp:docPr", docpr, name, alt, &float.alt, float.hidden);
|
||||
nv_props(w, "wp:docPr", (docpr, name), alt, &float.alt, float.hidden, link);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -502,8 +512,9 @@ pub(in super::super) fn spin_attrs(spin: wordcraft_geom::Spin) -> Vec<(&'static
|
||||
/// A drawing's `wp:docPr` (or a group member's `pic:cNvPr`/`wps:cNvPr`, ECMA-376
|
||||
/// `CT_NonVisualDrawingProps`) with its alt text `descr`, its title, and the decorative mark as
|
||||
/// the `adec:decorative` extension ([MS-ODRAWXML]). A description that is only the title (read
|
||||
/// from a file with no description) is not written twice.
|
||||
pub(in super::super) fn nv_props(w: &mut W, tag: &str, id: &str, name: &str, descr: &str, alt: &AltText, hidden: bool) {
|
||||
/// from a file with no description) is not written twice. `link` is the relationship id of the
|
||||
/// drawing's hyperlink (`a:hlinkClick`, the first child the schema allows).
|
||||
pub(in super::super) fn nv_props(w: &mut W, tag: &str, (id, name): (&str, &str), descr: &str, alt: &AltText, hidden: bool, link: Option<&str>) {
|
||||
let mut attrs = vec![("id", id), ("name", name)];
|
||||
if descr != alt.title || descr.is_empty() {
|
||||
attrs.push(("descr", descr));
|
||||
@@ -514,16 +525,21 @@ pub(in super::super) fn nv_props(w: &mut W, tag: &str, id: &str, name: &str, des
|
||||
if hidden {
|
||||
attrs.push(("hidden", "1"));
|
||||
}
|
||||
if !alt.decorative {
|
||||
if !alt.decorative && link.is_none() {
|
||||
w.empty(tag, &attrs);
|
||||
return;
|
||||
}
|
||||
w.open(tag, &attrs);
|
||||
w.open("a:extLst", &[]);
|
||||
w.open("a:ext", &[("uri", crate::xml::DECORATIVE_EXT)]);
|
||||
w.empty("adec:decorative", &[("xmlns:adec", crate::xml::DECORATIVE_NS), ("val", "1")]);
|
||||
w.close("a:ext");
|
||||
w.close("a:extLst");
|
||||
if let Some(rid) = link {
|
||||
w.empty("a:hlinkClick", &[("r:id", rid)]);
|
||||
}
|
||||
if alt.decorative {
|
||||
w.open("a:extLst", &[]);
|
||||
w.open("a:ext", &[("uri", crate::xml::DECORATIVE_EXT)]);
|
||||
w.empty("adec:decorative", &[("xmlns:adec", crate::xml::DECORATIVE_NS), ("val", "1")]);
|
||||
w.close("a:ext");
|
||||
w.close("a:extLst");
|
||||
}
|
||||
w.close(tag);
|
||||
}
|
||||
|
||||
|
||||
@@ -220,13 +220,18 @@ impl Writer<'_> {
|
||||
w.open("mc:Choice", &[("Requires", requires)]);
|
||||
}
|
||||
let docpr = self.next_docpr();
|
||||
let link = Self::drawing_link(props, rels);
|
||||
let name = match graphic.kind {
|
||||
wordcraft_doc::graphic::GraphicKind::Chart => format!("Chart {docpr}"),
|
||||
wordcraft_doc::graphic::GraphicKind::Diagram => format!("Diagram {docpr}"),
|
||||
wordcraft_doc::graphic::GraphicKind::Ink => format!("Ink {docpr}"),
|
||||
};
|
||||
let z = self.z;
|
||||
self.graphic_frame(w, DrawingInfo { float, size: (*gw, *gh), docpr: &docpr, name: &name, alt }, native.unwrap_or(&root));
|
||||
self.graphic_frame(
|
||||
w,
|
||||
DrawingInfo { float, size: (*gw, *gh), docpr: &docpr, name: &name, alt, link: link.as_deref() },
|
||||
native.unwrap_or(&root),
|
||||
);
|
||||
if let Some(fallback) = fallback {
|
||||
w.close("mc:Choice");
|
||||
match picture {
|
||||
@@ -234,7 +239,11 @@ impl Writer<'_> {
|
||||
// Both branches are one object: same id and stacking order.
|
||||
self.z = z;
|
||||
w.open("mc:Fallback", &[]);
|
||||
self.graphic_frame(w, DrawingInfo { float, size: (*gw, *gh), docpr: &docpr, name: &name, alt }, picture);
|
||||
self.graphic_frame(
|
||||
w,
|
||||
DrawingInfo { float, size: (*gw, *gh), docpr: &docpr, name: &name, alt, link: link.as_deref() },
|
||||
picture,
|
||||
);
|
||||
w.close("mc:Fallback");
|
||||
}
|
||||
None => w.raw(&fallback.to_xml()),
|
||||
@@ -266,8 +275,9 @@ impl Writer<'_> {
|
||||
self.rpr(w, props);
|
||||
w.open("w:drawing", &[]);
|
||||
let docpr = self.next_docpr();
|
||||
let link = Self::drawing_link(props, rels);
|
||||
let name = format!("Picture {docpr}");
|
||||
self.drawing_open(w, DrawingInfo { float, size: (*iw, *ih), docpr: &docpr, name: &name, alt });
|
||||
self.drawing_open(w, DrawingInfo { float, size: (*iw, *ih), docpr: &docpr, name: &name, alt, link: link.as_deref() });
|
||||
w.empty("wp:cNvGraphicFramePr", &[]);
|
||||
w.open("a:graphic", &[]);
|
||||
w.open("a:graphicData", &[("uri", "http://schemas.openxmlformats.org/drawingml/2006/picture")]);
|
||||
@@ -355,6 +365,7 @@ impl Writer<'_> {
|
||||
self.rpr(w, props);
|
||||
w.open("w:drawing", &[]);
|
||||
let docpr = self.shape_docpr(extra.id);
|
||||
let link = Self::drawing_link(props, rels);
|
||||
// Ink is told apart by its name (and its pen), which reading looks for.
|
||||
let name = shape_name(*kind, freeform.as_deref(), &docpr);
|
||||
// The effect extent leaves room for the shadow and glow.
|
||||
@@ -369,7 +380,7 @@ impl Writer<'_> {
|
||||
.map(std::sync::Arc::new);
|
||||
}
|
||||
let float = &float;
|
||||
self.drawing_open(w, DrawingInfo { float, size: (*sw, *sh), docpr: &docpr, name: &name, alt: o.alt_text() });
|
||||
self.drawing_open(w, DrawingInfo { float, size: (*sw, *sh), docpr: &docpr, name: &name, alt: o.alt_text(), link: link.as_deref() });
|
||||
w.empty("wp:cNvGraphicFramePr", &[]);
|
||||
w.open("a:graphic", &[]);
|
||||
w.open("a:graphicData", &[("uri", "http://schemas.microsoft.com/office/word/2010/wordprocessingShape")]);
|
||||
@@ -389,8 +400,9 @@ impl Writer<'_> {
|
||||
self.rpr(w, props);
|
||||
w.open("w:drawing", &[]);
|
||||
let docpr = self.next_docpr();
|
||||
let link = Self::drawing_link(props, rels);
|
||||
let name = format!("{} {docpr}", if canvas.is_some() { "Canvas" } else { "Group" });
|
||||
self.drawing_open(w, DrawingInfo { float, size: (*gw, *gh), docpr: &docpr, name: &name, alt: o.alt_text() });
|
||||
self.drawing_open(w, DrawingInfo { float, size: (*gw, *gh), docpr: &docpr, name: &name, alt: o.alt_text(), link: link.as_deref() });
|
||||
w.empty("wp:cNvGraphicFramePr", &[]);
|
||||
w.open("a:graphic", &[]);
|
||||
let outer = match canvas {
|
||||
|
||||
+83
-4
@@ -94,6 +94,49 @@ fn prefix_for(uri: &[u8], extra: &[(&'static str, &str)]) -> &'static str {
|
||||
"?"
|
||||
}
|
||||
|
||||
/// Word's 2006 namespace (`wne`). A Strict document from Word keeps a text box's content in
|
||||
/// `wne:txbxContent`; a Transitional one uses `w:txbxContent`.
|
||||
const WNE: &[u8] = b"http://schemas.microsoft.com/office/word/2006/wordml";
|
||||
|
||||
/// The canonical prefix of an element named `local` in namespace `uri`, inside an element
|
||||
/// named `parent` (canonical, `None` at the root). This is [`prefix_for`] with two exceptions
|
||||
/// for Strict documents (#3548):
|
||||
///
|
||||
/// - Shapes, groups and canvases (`wsp`, `wgp`, `wpc`) and their parts are elements of the
|
||||
/// WordprocessingDrawing namespace in ECMA-376 Part 1, 5th edition (`dml-wordprocessingDrawing.xsd`,
|
||||
/// Strict and Transitional alike). A Strict document from Word puts them there; a Transitional
|
||||
/// one uses Word 2010's own namespaces, whose names the reader knows. So they read as `wps:`,
|
||||
/// `wpg:` and `wpc:` (see [`drawing_prefix`]).
|
||||
/// - A shape's text box content reads as `w:txbxContent` from the drawing namespace (the
|
||||
/// schema's name) and from Word's `wne:txbxContent`.
|
||||
fn element_prefix(uri: &[u8], local: &str, parent: Option<&str>, extra: &[(&'static str, &str)]) -> &'static str {
|
||||
match prefix_for(uri, extra) {
|
||||
"wp" => drawing_prefix(local, parent),
|
||||
"?" if uri == WNE && local == "txbxContent" && parent == Some("wps:txbx") => "w",
|
||||
p => p,
|
||||
}
|
||||
}
|
||||
|
||||
/// The prefix for `local`, an element of the WordprocessingDrawing namespace inside `parent`.
|
||||
/// Word 2010's namespaces split what this one holds: a shape and its parts are `wps:`, a group
|
||||
/// (`wgp`, a nested `grpSp`) and a graphic frame in it are `wpg:`, and a canvas is `wpc:`. Its
|
||||
/// members are named for what they are. A text box's content is `w:txbxContent`, and anything
|
||||
/// else (`inline`, `anchor`, `docPr`…) stays `wp:`.
|
||||
fn drawing_prefix(local: &str, parent: Option<&str>) -> &'static str {
|
||||
match (parent, local) {
|
||||
(_, "wsp") => "wps",
|
||||
(_, "wgp" | "grpSp" | "graphicFrame") => "wpg",
|
||||
(_, "wpc") => "wpc",
|
||||
(Some("wps:wsp"), "cNvPr" | "cNvSpPr" | "cNvCnPr" | "spPr" | "style" | "extLst" | "txbx" | "linkedTxbx" | "bodyPr") => "wps",
|
||||
(Some("wps:txbx"), "txbxContent") => "w",
|
||||
(Some("wps:txbx" | "wps:linkedTxbx"), "extLst") => "wps",
|
||||
(Some("wpg:wgp" | "wpg:grpSp"), "cNvPr" | "cNvGrpSpPr" | "grpSpPr" | "extLst") => "wpg",
|
||||
(Some("wpg:graphicFrame"), "cNvPr" | "cNvFrPr" | "xfrm" | "extLst") => "wpg",
|
||||
(Some("wpc:wpc"), "bg" | "whole" | "extLst") => "wpc",
|
||||
_ => "wp",
|
||||
}
|
||||
}
|
||||
|
||||
/// Compatibility feature namespaces whose elements are not otherwise handled by this tree.
|
||||
/// Keep the reader's existing feature set when canonicalizing `mc:Choice/@Requires` aliases.
|
||||
fn requirement_prefix(uri: &[u8]) -> &'static str {
|
||||
@@ -342,8 +385,8 @@ fn parse_with(bytes: &[u8], extra: &[(&'static str, &str)], strict: bool) -> Res
|
||||
loop {
|
||||
buf.clear();
|
||||
let (res, ev) = r.read_resolved_event_into(&mut buf).map_err(|e| DocxError::Xml(e.to_string()))?;
|
||||
let el_prefix = match &res {
|
||||
ResolveResult::Bound(ns) => Some(prefix_for(ns.as_ref(), extra)),
|
||||
let el_ns = match &res {
|
||||
ResolveResult::Bound(ns) => Some(ns.as_ref()),
|
||||
_ => None,
|
||||
};
|
||||
match ev {
|
||||
@@ -356,8 +399,8 @@ fn parse_with(bytes: &[u8], extra: &[(&'static str, &str)], strict: bool) -> Res
|
||||
return Err(DocxError::Limit("too many XML elements".into()));
|
||||
}
|
||||
let local = String::from_utf8_lossy(e.local_name().as_ref()).into_owned();
|
||||
let name = match el_prefix {
|
||||
Some(p) => format!("{p}:{local}"),
|
||||
let name = match el_ns {
|
||||
Some(ns) => format!("{}:{local}", element_prefix(ns, &local, stack.last().map(|p| p.name.as_str()), extra)),
|
||||
None => local,
|
||||
};
|
||||
let mut el = El { name, attrs: Vec::new(), kids: Vec::new() };
|
||||
@@ -593,6 +636,42 @@ mod tests {
|
||||
assert!(parse(b"<a></a><b></b>").is_ok(), "lenient parsing still stops at the root");
|
||||
}
|
||||
|
||||
/// Every element name in `e`, depth first.
|
||||
fn names(e: &El, out: &mut Vec<String>) {
|
||||
out.push(e.name.clone());
|
||||
for c in e.els() {
|
||||
names(c, out);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_drawing_names_read_as_word_2010_names() {
|
||||
// A Strict shape with a text box, a group with a nested group and a graphic frame, and
|
||||
// a canvas, each in the one Strict WordprocessingDrawing namespace (#3548).
|
||||
let xml = br#"<w:body xmlns:w="http://purl.oclc.org/ooxml/wordprocessingml/main" xmlns:a="http://purl.oclc.org/ooxml/drawingml/main" xmlns:d="http://purl.oclc.org/ooxml/drawingml/wordprocessingDrawing" xmlns:wne="http://schemas.microsoft.com/office/word/2006/wordml">
|
||||
<d:anchor><d:extent/><d:docPr/><d:cNvGraphicFramePr/><a:graphic><a:graphicData>
|
||||
<d:wsp><d:cNvPr/><d:cNvSpPr/><d:spPr/><d:style/><d:extLst/><d:txbx><wne:txbxContent><w:p/></wne:txbxContent></d:txbx><d:bodyPr/></d:wsp>
|
||||
<d:wgp><d:cNvPr/><d:cNvGrpSpPr/><d:grpSpPr/><d:grpSp><d:cNvGrpSpPr/><d:grpSpPr/><d:wsp><d:cNvCnPr/><d:spPr/><d:linkedTxbx/><d:bodyPr/></d:wsp></d:grpSp>
|
||||
<d:graphicFrame><d:cNvPr/><d:cNvFrPr/><d:xfrm/><a:graphic/><d:extLst/></d:graphicFrame><d:extLst/></d:wgp>
|
||||
<d:wpc><d:bg/><d:whole/><d:wsp><d:spPr/><d:txbx><d:txbxContent/><d:extLst/></d:txbx></d:wsp><d:wgp><d:grpSpPr/></d:wgp><d:extLst/></d:wpc>
|
||||
</a:graphicData></a:graphic></d:anchor>
|
||||
<wne:txbxContent/><d:inline><d:docPr/><d:bodyPr/><d:bg/></d:inline></w:body>"#;
|
||||
let mut got = Vec::new();
|
||||
names(&parse(xml).unwrap(), &mut got);
|
||||
let want = "w:body wp:anchor wp:extent wp:docPr wp:cNvGraphicFramePr a:graphic a:graphicData \
|
||||
wps:wsp wps:cNvPr wps:cNvSpPr wps:spPr wps:style wps:extLst wps:txbx w:txbxContent w:p wps:bodyPr \
|
||||
wpg:wgp wpg:cNvPr wpg:cNvGrpSpPr wpg:grpSpPr wpg:grpSp wpg:cNvGrpSpPr wpg:grpSpPr wps:wsp wps:cNvCnPr wps:spPr wps:linkedTxbx wps:bodyPr \
|
||||
wpg:graphicFrame wpg:cNvPr wpg:cNvFrPr wpg:xfrm a:graphic wpg:extLst wpg:extLst \
|
||||
wpc:wpc wpc:bg wpc:whole wps:wsp wps:spPr wps:txbx w:txbxContent wps:extLst wpg:wgp wpg:grpSpPr wpc:extLst \
|
||||
?:txbxContent wp:inline wp:docPr wp:bodyPr wp:bg";
|
||||
assert_eq!(got.join(" "), want);
|
||||
// The Transitional namespaces read as before.
|
||||
let xml = br#"<w:body xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape" xmlns:wne="http://schemas.microsoft.com/office/word/2006/wordml"><wp:inline><wp:docPr/></wp:inline><wps:wsp><wps:txbx><w:txbxContent/></wps:txbx></wps:wsp><wne:toolbars/></w:body>"#;
|
||||
let mut got = Vec::new();
|
||||
names(&parse(xml).unwrap(), &mut got);
|
||||
assert_eq!(got.join(" "), "w:body wp:inline wp:docPr wps:wsp wps:txbx w:txbxContent ?:toolbars");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_relationship_attribute_is_the_namespaced_one_or_the_alias_of_one() {
|
||||
// The relationship namespace, and VML's `o:relid`.
|
||||
|
||||
@@ -1766,12 +1766,27 @@ const CT_CHARTEX: &str = "application/vnd.ms-office.chartex+xml";
|
||||
/// A package holding one box & whisker chart the way Word writes it: the `cx:chart` in an
|
||||
/// `mc:Choice` requiring `requires` (bound to `requires_ns`), a picture of it in the `mc:Fallback`.
|
||||
fn chartex_docx(requires: &str, requires_ns: &str) -> Vec<u8> {
|
||||
let frame = r#"<wp:inline distT="0" distB="0" distL="0" distR="0"><wp:extent cx="2743200" cy="1371600"/><wp:docPr id="1" name="Chart 1"/><wp:cNvGraphicFramePr/>"#;
|
||||
chartex_docx_linked(requires, requires_ns, false)
|
||||
}
|
||||
|
||||
/// [`chartex_docx`], its drawing linked to a web address (`wp:docPr/a:hlinkClick`) when `link`,
|
||||
/// after a first choice linked elsewhere, which needs a namespace no reader knows, so a reader
|
||||
/// takes the chart's choice.
|
||||
fn chartex_docx_linked(requires: &str, requires_ns: &str, link: bool) -> Vec<u8> {
|
||||
let click = if link { r#"<a:hlinkClick r:id="rIdWeb"/>"# } else { "" };
|
||||
let other = if link {
|
||||
r#"<mc:Choice xmlns:w99="urn:example:unsupported" Requires="w99"><w:drawing><wp:inline distT="0" distB="0" distL="0" distR="0"><wp:extent cx="254000" cy="254000"/><wp:docPr id="9" name="Other"><a:hlinkClick r:id="rIdOther"/></wp:docPr><a:graphic><a:graphicData uri="http://schemas.microsoft.com/office/word/2010/wordprocessingShape"><wps:wsp><wps:spPr><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></wps:spPr><wps:bodyPr/></wps:wsp></a:graphicData></a:graphic></wp:inline></w:drawing></mc:Choice>"#
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let frame = format!(
|
||||
r#"<wp:inline distT="0" distB="0" distL="0" distR="0"><wp:extent cx="2743200" cy="1371600"/><wp:docPr id="1" name="Chart 1">{click}</wp:docPr><wp:cNvGraphicFramePr/>"#
|
||||
);
|
||||
let body = format!(
|
||||
r#"<w:p><w:r><w:t>Spread</w:t></w:r><w:r><mc:AlternateContent><mc:Choice xmlns:{requires}="{requires_ns}" Requires="{requires}"><w:drawing>{frame}<a:graphic><a:graphicData uri="{CHARTEX_NS}"><cx:chart xmlns:cx="{CHARTEX_NS}" r:id="rIdCx"/></a:graphicData></a:graphic></wp:inline></w:drawing></mc:Choice><mc:Fallback><w:drawing>{frame}<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:nvPicPr><pic:cNvPr id="1" name="Chart 1"/><pic:cNvPicPr/></pic:nvPicPr><pic:blipFill><a:blip r:embed="rIdPic"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill><pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="2743200" cy="1371600"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></mc:Fallback></mc:AlternateContent></w:r></w:p>"#
|
||||
r#"<w:p><w:r><w:t>Spread</w:t></w:r><w:r><mc:AlternateContent>{other}<mc:Choice xmlns:{requires}="{requires_ns}" Requires="{requires}"><w:drawing>{frame}<a:graphic><a:graphicData uri="{CHARTEX_NS}"><cx:chart xmlns:cx="{CHARTEX_NS}" r:id="rIdCx"/></a:graphicData></a:graphic></wp:inline></w:drawing></mc:Choice><mc:Fallback><w:drawing>{frame}<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:nvPicPr><pic:cNvPr id="1" name="Chart 1"/><pic:cNvPicPr/></pic:nvPicPr><pic:blipFill><a:blip r:embed="rIdPic"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill><pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="2743200" cy="1371600"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></mc:Fallback></mc:AlternateContent></w:r></w:p>"#
|
||||
);
|
||||
let doc = format!(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document {W_NS}><w:body>{body}</w:body></w:document>"#);
|
||||
let doc_rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdCx" Type="http://schemas.microsoft.com/office/2014/relationships/chartEx" Target="charts/chartEx1.xml"/><Relationship Id="rIdPic" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/image1.png"/></Relationships>"#;
|
||||
let doc_rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdCx" Type="http://schemas.microsoft.com/office/2014/relationships/chartEx" Target="charts/chartEx1.xml"/><Relationship Id="rIdPic" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/image1.png"/><Relationship Id="rIdWeb" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" Target="https://example.com/chart" TargetMode="External"/><Relationship Id="rIdOther" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" Target="https://example.com/other" TargetMode="External"/></Relationships>"#;
|
||||
let chart_rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/package" Target="../embeddings/Microsoft_Excel_Worksheet.xlsx"/><Relationship Id="rId2" Type="http://schemas.microsoft.com/office/2011/relationships/chartColorStyle" Target="colors1.xml"/></Relationships>"#;
|
||||
let types = format!(
|
||||
r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Default Extension="png" ContentType="image/png"/><Default Extension="xlsx" ContentType="{CT_XLSX}"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/charts/chartEx1.xml" ContentType="{CT_CHARTEX}"/><Override PartName="/word/charts/colors1.xml" ContentType="application/vnd.ms-office.chartcolorstyle+xml"/></Types>"#
|
||||
@@ -1827,6 +1842,25 @@ fn office_2016_chart_keeps_its_chart_part_and_fallback_through_saves() {
|
||||
}
|
||||
}
|
||||
|
||||
/// #5267: a link on an Office 2016 chart's drawing is the chart's link (not an earlier choice's),
|
||||
/// and saving keeps it on both branches (the writer rebuilds their frames).
|
||||
#[test]
|
||||
fn a_link_on_an_office_2016_charts_drawing_is_kept() {
|
||||
let mut d = wordcraft_docx::read(&chartex_docx_linked("cx1", "http://schemas.microsoft.com/office/drawing/2015/9/8/chartex", true)).unwrap();
|
||||
for _ in 0..2 {
|
||||
let p = d.body[0].as_para().unwrap();
|
||||
let off = p.object_offsets().into_iter().find(|o| matches!(p.object_at(*o), Some(InlineObject::Graphic { .. }))).unwrap();
|
||||
assert_eq!(p.props_of_char(off).link.as_deref(), Some("https://example.com/chart"));
|
||||
let files = unzip(&wordcraft_docx::write(&d).unwrap());
|
||||
let body = text(&files, "word/document.xml");
|
||||
let ids = attr_values(&body, "a:hlinkClick", "r:id");
|
||||
assert_eq!(ids.len(), 2, "on the chart and its fallback picture: {body}");
|
||||
assert_eq!(rel(&text(&files, "word/_rels/document.xml.rels"), &ids[0]).1, "https://example.com/chart");
|
||||
let entries: Vec<_> = files.iter().map(|(name, bytes)| (name.as_str(), bytes.as_slice())).collect();
|
||||
d = wordcraft_docx::read(&zip(&entries)).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resized_office_2016_chart_resizes_its_fallback_too() {
|
||||
let mut d = wordcraft_docx::read(&chartex_docx("cx1", "http://schemas.microsoft.com/office/drawing/2015/9/8/chartex")).unwrap();
|
||||
@@ -2462,6 +2496,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]
|
||||
|
||||
@@ -23,6 +23,7 @@ mod ligatures;
|
||||
mod malformed;
|
||||
mod note_comments;
|
||||
mod passthrough;
|
||||
mod picture_links;
|
||||
mod positional_tabs;
|
||||
mod privacy;
|
||||
mod roundtrip;
|
||||
@@ -34,6 +35,7 @@ mod section_page_number_type;
|
||||
mod section_text_direction;
|
||||
mod section_valign;
|
||||
mod settings_merge;
|
||||
mod strict_drawings;
|
||||
mod text_alignment;
|
||||
mod text_boxes;
|
||||
mod toc_ranges;
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
//! A picture's hyperlink (#5267): Word keeps it on the drawing (`wp:docPr/a:hlinkClick`,
|
||||
//! ECMA-376 Part 1 §20.4.2.5) rather than in a `w:hyperlink` around the run. Original
|
||||
//! synthetic packages; the 2×2 picture is generated in code.
|
||||
use std::io::{Read, Write};
|
||||
use std::sync::Arc;
|
||||
use wordcraft_doc::{CharProps, Document, InlineObject, Pos};
|
||||
|
||||
fn png() -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
image::RgbaImage::from_pixel(2, 2, image::Rgba([20, 90, 200, 255]))
|
||||
.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
/// An inline picture's run; `link` goes into its `wp:docPr`.
|
||||
fn picture_run(id: u32, link: &str) -> String {
|
||||
format!(
|
||||
r#"<w:r><w:drawing><wp:inline distT="0" distB="0" distL="0" distR="0"><wp:extent cx="254000" cy="254000"/><wp:docPr id="{id}" name="Picture {id}">{link}</wp:docPr><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:nvPicPr><pic:cNvPr id="0" name="square.png"/><pic:cNvPicPr/></pic:nvPicPr><pic:blipFill><a:blip r:embed="rIdPic"/></pic:blipFill><pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="254000" cy="254000"/></a:xfrm><a:prstGeom prst="rect"/></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r>"#
|
||||
)
|
||||
}
|
||||
|
||||
fn package(body: &str) -> Vec<u8> {
|
||||
let xml = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture"><w:body>{body}<w:sectPr/></w:body></w:document>"#
|
||||
);
|
||||
let rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
|
||||
let doc_rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdPic" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/square.png"/><Relationship Id="rIdWeb" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" Target="https://example.com/picture" TargetMode="External"/><Relationship Id="rIdRun" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" Target="https://example.com/run" TargetMode="External"/></Relationships>"#;
|
||||
let types = r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="png" ContentType="image/png"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>"#;
|
||||
let png = png();
|
||||
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (name, bytes) in [
|
||||
("[Content_Types].xml", types.as_bytes()),
|
||||
("_rels/.rels", rels.as_bytes()),
|
||||
("word/document.xml", xml.as_bytes()),
|
||||
("word/_rels/document.xml.rels", doc_rels.as_bytes()),
|
||||
("word/media/square.png", png.as_slice()),
|
||||
] {
|
||||
zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zip.write_all(bytes).unwrap();
|
||||
}
|
||||
zip.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
/// The link on each picture of the first paragraph, in order.
|
||||
fn picture_links(doc: &Document) -> Vec<Option<String>> {
|
||||
let p = doc.body[0].as_para().unwrap();
|
||||
p.object_offsets()
|
||||
.into_iter()
|
||||
.filter(|off| matches!(p.object_at(*off), Some(InlineObject::Image { .. })))
|
||||
.map(|off| p.props_of_char(off).link.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn part(bytes: &[u8], name: &str) -> String {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut s = String::new();
|
||||
z.by_name(name).unwrap().read_to_string(&mut s).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_link_on_a_pictures_drawing_is_read() {
|
||||
let body = format!(
|
||||
"<w:p>{}{}{}<w:hyperlink r:id=\"rIdRun\">{}</w:hyperlink></w:p>",
|
||||
// Word's way: the link on the drawing.
|
||||
picture_run(1, r#"<a:hlinkClick r:id="rIdWeb"/>"#),
|
||||
// A click naming a relationship that is no hyperlink, and one naming none.
|
||||
picture_run(2, r#"<a:hlinkClick r:id="rIdPic"/>"#),
|
||||
picture_run(3, r#"<a:hlinkClick r:id="rIdMissing"/>"#),
|
||||
// A link around the run wins over the drawing's.
|
||||
picture_run(4, r#"<a:hlinkClick r:id="rIdWeb"/>"#),
|
||||
);
|
||||
let doc = wordcraft_docx::read(&package(&body)).unwrap();
|
||||
assert_eq!(picture_links(&doc), [Some("https://example.com/picture".to_string()), None, None, Some("https://example.com/run".to_string())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_linked_picture_is_saved_with_its_link_on_the_drawing_too() {
|
||||
let mut doc = Document::new();
|
||||
doc.media.insert("square.png".into(), Arc::new(png()));
|
||||
let picture = || InlineObject::Image {
|
||||
media: "square.png".into(),
|
||||
w: 20.0,
|
||||
h: 20.0,
|
||||
alt: String::new(),
|
||||
float: Default::default(),
|
||||
crop: [0.0; 4],
|
||||
ole: None,
|
||||
fx: Default::default(),
|
||||
};
|
||||
let linked = |l: &str| CharProps { link: Some(l.to_string()), ..Default::default() };
|
||||
// A place in this document stays a `w:hyperlink w:anchor` only: `a:hlinkClick` names a relationship.
|
||||
doc.insert_object(&Pos::body(0, 0), picture(), &linked("#Spot")).unwrap();
|
||||
doc.insert_object(&Pos::body(0, 0), picture(), &linked("https://example.com/saved")).unwrap();
|
||||
let bytes = wordcraft_docx::write(&doc).unwrap();
|
||||
let xml = part(&bytes, "word/document.xml");
|
||||
let clicks: Vec<&str> = xml.match_indices("<a:hlinkClick ").map(|(i, _)| &xml[i..]).collect();
|
||||
assert_eq!(clicks.len(), 1, "{xml}");
|
||||
let rid = clicks[0].split('"').nth(1).unwrap();
|
||||
let docpr = xml.find("<wp:docPr ").unwrap();
|
||||
assert!(docpr < xml.find("<a:hlinkClick ").unwrap() && xml.find("<a:hlinkClick ").unwrap() < xml.find("</wp:docPr>").unwrap(), "{xml}");
|
||||
let rels = part(&bytes, "word/_rels/document.xml.rels");
|
||||
assert!(rels.contains(&format!(r#"Id="{rid}""#)) && rels.contains(r#"Target="https://example.com/saved""#), "{rels}");
|
||||
// The run's own hyperlink names the same relationship, so the address is stored once.
|
||||
assert_eq!(rels.matches("https://example.com/saved").count(), 1, "{rels}");
|
||||
let back = wordcraft_docx::read(&bytes).unwrap();
|
||||
assert_eq!(picture_links(&back), [Some("https://example.com/saved".to_string()), Some("#Spot".to_string())]);
|
||||
// The drawing's link alone (as Word writes it) reads back the same.
|
||||
let without_run_link = xml.replace(&format!(r#"<w:hyperlink r:id="{rid}" w:history="1">"#), "").replacen("</w:hyperlink>", "", 1);
|
||||
assert_ne!(without_run_link, xml);
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes.as_slice())).unwrap();
|
||||
let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for i in 0..z.len() {
|
||||
let mut f = z.by_index(i).unwrap();
|
||||
let name = f.name().to_string();
|
||||
let mut data = Vec::new();
|
||||
f.read_to_end(&mut data).unwrap();
|
||||
if name == "word/document.xml" {
|
||||
data = without_run_link.clone().into_bytes();
|
||||
}
|
||||
out.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
out.write_all(&data).unwrap();
|
||||
}
|
||||
let edited = out.finish().unwrap().into_inner();
|
||||
assert_eq!(picture_links(&wordcraft_docx::read(&edited).unwrap())[0], Some("https://example.com/saved".to_string()));
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
//! Shapes, text boxes, groups and canvases in a Strict document (#3548). ISO/IEC 29500 Strict
|
||||
//! puts them in the one WordprocessingDrawing namespace, where Transitional documents use Word
|
||||
//! 2010's `wps`, `wpg` and `wpc` namespaces. Fixtures are original and hand-written from
|
||||
//! ECMA-376 Part 1, 5th edition (`dml-wordprocessingDrawing.xsd`, Strict and Transitional): each
|
||||
//! drawing is written once and filled in with either set of namespaces.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
|
||||
use wordcraft_doc::{Document, InlineObject, PartKind};
|
||||
|
||||
const STRICT_NS: &str = r#"xmlns:w="http://purl.oclc.org/ooxml/wordprocessingml/main" xmlns:r="http://purl.oclc.org/ooxml/officeDocument/relationships" xmlns:wp="http://purl.oclc.org/ooxml/drawingml/wordprocessingDrawing" xmlns:a="http://purl.oclc.org/ooxml/drawingml/main" xmlns:wne="http://schemas.microsoft.com/office/word/2006/wordml" w:conformance="strict""#;
|
||||
const TRANSITIONAL_NS: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape" xmlns:wpg="http://schemas.microsoft.com/office/word/2010/wordprocessingGroup" xmlns:wpc="http://schemas.microsoft.com/office/word/2010/wordprocessingCanvas""#;
|
||||
const STRICT_RELS: &str = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://purl.oclc.org/ooxml/officeDocument/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
|
||||
const TRANSITIONAL_RELS: &str = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
|
||||
|
||||
/// How a fixture names its drawing elements: the shape, group and canvas prefixes, and the
|
||||
/// text box content element.
|
||||
struct Names {
|
||||
ns: &'static str,
|
||||
rels: &'static str,
|
||||
shape: &'static str,
|
||||
group: &'static str,
|
||||
canvas: &'static str,
|
||||
content: &'static str,
|
||||
}
|
||||
|
||||
/// Word's Strict output: everything in `wp:`, text box content in `wne:txbxContent`.
|
||||
const STRICT_WORD: Names = Names { ns: STRICT_NS, rels: STRICT_RELS, shape: "wp", group: "wp", canvas: "wp", content: "wne:txbxContent" };
|
||||
/// The Strict schema's own names: the text box content in `wp:` too.
|
||||
const STRICT_SCHEMA: Names = Names { ns: STRICT_NS, rels: STRICT_RELS, shape: "wp", group: "wp", canvas: "wp", content: "wp:txbxContent" };
|
||||
/// The same drawings in a Transitional document.
|
||||
const TRANSITIONAL: Names =
|
||||
Names { ns: TRANSITIONAL_NS, rels: TRANSITIONAL_RELS, shape: "wps", group: "wpg", canvas: "wpc", content: "w:txbxContent" };
|
||||
|
||||
fn zip(entries: &[(&str, &[u8])]) -> Vec<u8> {
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (name, bytes) in entries {
|
||||
zw.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zw.write_all(bytes).unwrap();
|
||||
}
|
||||
zw.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
fn document_xml(bytes: &[u8]) -> String {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut s = String::new();
|
||||
z.by_name("word/document.xml").unwrap().read_to_string(&mut s).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn solid(rgb: &str) -> String {
|
||||
format!(r#"<a:solidFill><a:srgbClr val="{rgb}"/></a:solidFill>"#)
|
||||
}
|
||||
|
||||
/// A `wsp` at (`x`, `y`) EMUs in its group, `cx` × `cy`. `text` gives it a text box.
|
||||
fn shape(n: &Names, prst: &str, (x, y, cx, cy): (u32, u32, u32, u32), fill: &str, text: Option<&str>) -> String {
|
||||
let s = n.shape;
|
||||
let connector = prst.contains("Connector");
|
||||
let nv = if connector {
|
||||
format!("<{s}:cNvCnPr/>")
|
||||
} else if text.is_some() {
|
||||
format!(r#"<{s}:cNvSpPr txBox="1"/>"#)
|
||||
} else {
|
||||
format!("<{s}:cNvSpPr/>")
|
||||
};
|
||||
let fill = if connector { String::new() } else { solid(fill) };
|
||||
let line =
|
||||
if connector { r#"<a:ln w="25400"><a:solidFill><a:srgbClr val="117A65"/></a:solidFill><a:tailEnd type="triangle"/></a:ln>"# } else { "" };
|
||||
let txbx = match text {
|
||||
Some(t) => {
|
||||
let c = n.content;
|
||||
format!(r#"<{s}:txbx><{c}><w:p><w:r><w:t>{t}</w:t></w:r></w:p></{c}></{s}:txbx>"#)
|
||||
}
|
||||
None => String::new(),
|
||||
};
|
||||
format!(
|
||||
r#"<{s}:wsp><{s}:cNvPr id="{cx}" name="{prst}"/>{nv}<{s}:spPr><a:xfrm><a:off x="{x}" y="{y}"/><a:ext cx="{cx}" cy="{cy}"/></a:xfrm><a:prstGeom prst="{prst}"><a:avLst/></a:prstGeom>{fill}{line}</{s}:spPr>{txbx}<{s}:bodyPr anchor="ctr"/></{s}:wsp>"#
|
||||
)
|
||||
}
|
||||
|
||||
/// Drawing `id`, anchored `x` EMUs from the page's left edge, 1 in × ½ in.
|
||||
fn anchor(id: u32, x: u32, uri: &str, payload: &str) -> String {
|
||||
format!(
|
||||
r#"<w:r><w:drawing><wp:anchor distT="0" distB="0" distL="0" distR="0" simplePos="0" relativeHeight="{id}" behindDoc="0" locked="0" layoutInCell="1" allowOverlap="1"><wp:simplePos x="0" y="0"/><wp:positionH relativeFrom="page"><wp:posOffset>{x}</wp:posOffset></wp:positionH><wp:positionV relativeFrom="page"><wp:posOffset>914400</wp:posOffset></wp:positionV><wp:extent cx="914400" cy="457200"/><wp:effectExtent l="0" t="0" r="0" b="0"/><wp:wrapNone/><wp:docPr id="{id}" name="Drawing {id}"/><wp:cNvGraphicFramePr/><a:graphic><a:graphicData uri="{uri}">{payload}</a:graphicData></a:graphic></wp:anchor></w:drawing></w:r>"#
|
||||
)
|
||||
}
|
||||
|
||||
/// A rectangle, a text box, an arrow, a group holding a nested group, and a canvas holding a
|
||||
/// shape and a group, each 1 in × ½ in.
|
||||
fn package(n: &Names) -> Vec<u8> {
|
||||
const WPS: &str = "http://schemas.microsoft.com/office/word/2010/wordprocessingShape";
|
||||
const WPG: &str = "http://schemas.microsoft.com/office/word/2010/wordprocessingGroup";
|
||||
const WPC: &str = "http://schemas.microsoft.com/office/word/2010/wordprocessingCanvas";
|
||||
let full = (0, 0, 914_400, 457_200);
|
||||
let half = (0, 0, 457_200, 457_200);
|
||||
let g = n.group;
|
||||
let group_xfrm = |(x, y, cx, cy): (u32, u32, u32, u32)| {
|
||||
format!(
|
||||
r#"<{g}:cNvGrpSpPr/><{g}:grpSpPr><a:xfrm><a:off x="{x}" y="{y}"/><a:ext cx="{cx}" cy="{cy}"/><a:chOff x="0" y="0"/><a:chExt cx="{cx}" cy="{cy}"/></a:xfrm>{}</{g}:grpSpPr>"#,
|
||||
solid("F39C12")
|
||||
)
|
||||
};
|
||||
let group = format!(
|
||||
"<{g}:wgp>{}{}<{g}:grpSp>{}{}</{g}:grpSp></{g}:wgp>",
|
||||
group_xfrm(full),
|
||||
shape(n, "ellipse", half, "28B463", None),
|
||||
group_xfrm((457_200, 0, 457_200, 457_200)),
|
||||
shape(n, "triangle", half, "8E44AD", None),
|
||||
);
|
||||
let c = n.canvas;
|
||||
let canvas = format!(
|
||||
r#"<{c}:wpc><{c}:bg>{}</{c}:bg><{c}:whole><a:ln w="12700">{}</a:ln></{c}:whole>{}<{g}:wgp>{}{}</{g}:wgp></{c}:wpc>"#,
|
||||
solid("FDF2E9"),
|
||||
solid("A04000"),
|
||||
shape(n, "roundRect", (45_720, 45_720, 365_760, 365_760), "16A085", None),
|
||||
group_xfrm((457_200, 45_720, 365_760, 365_760)),
|
||||
shape(n, "diamond", (0, 0, 365_760, 365_760), "2E86C1", None),
|
||||
);
|
||||
let drawings = [
|
||||
anchor(1, 914_400, WPS, &shape(n, "rect", full, "C0392B", None)),
|
||||
anchor(2, 2_286_000, WPS, &shape(n, "rect", full, "EAF2F8", Some("Strict text box"))),
|
||||
anchor(3, 3_657_600, WPS, &shape(n, "straightConnector1", full, "", None)),
|
||||
anchor(4, 5_029_200, WPG, &group),
|
||||
anchor(5, 6_400_800, WPC, &canvas),
|
||||
];
|
||||
let doc = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document {}><w:body><w:p><w:r><w:t>Shapes</w:t></w:r>{}</w:p><w:sectPr><w:pgSz w:w="12240" w:h="15840"/></w:sectPr></w:body></w:document>"#,
|
||||
n.ns,
|
||||
drawings.concat()
|
||||
);
|
||||
zip(&[("_rels/.rels", n.rels.as_bytes()), ("word/document.xml", doc.as_bytes())])
|
||||
}
|
||||
|
||||
fn objects(d: &Document) -> Vec<InlineObject> {
|
||||
d.body[0].as_para().unwrap().objects.clone()
|
||||
}
|
||||
|
||||
/// The text of each text box story, in reading order.
|
||||
fn box_texts(d: &Document) -> Vec<String> {
|
||||
let mut ids = Vec::new();
|
||||
d.objects_in_reading_order(&mut |o| ids.extend(o.text_boxes()));
|
||||
ids.iter()
|
||||
.filter_map(|id| d.parts.get(id).filter(|p| p.kind == PartKind::TextBox))
|
||||
.map(|p| p.blocks.iter().filter_map(|b| b.as_para()).map(|p| p.text.clone()).collect::<Vec<_>>().join("\n"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_shapes_read_like_their_transitional_twins() {
|
||||
let transitional = wordcraft_docx::read(&package(&TRANSITIONAL)).unwrap();
|
||||
let want = objects(&transitional);
|
||||
// The twin itself reads every drawing: a rectangle, a text box, an arrow, a group whose
|
||||
// nested group is flattened into it, and a canvas with its background.
|
||||
assert_eq!(want.len(), 5, "{want:#?}");
|
||||
assert!(matches!(&want[0], InlineObject::Shape { story: None, freeform: None, .. }), "{:?}", want[0]);
|
||||
assert!(matches!(&want[1], InlineObject::Shape { story: Some(_), .. }), "{:?}", want[1]);
|
||||
assert!(matches!(&want[3], InlineObject::Group { children, canvas: None, .. } if children.len() == 2), "{:?}", want[3]);
|
||||
assert!(
|
||||
matches!(&want[4], InlineObject::Group { children, canvas: Some(c), .. } if children.len() == 2 && c.fill.is_some() && c.stroke.is_some()),
|
||||
"{:?}",
|
||||
want[4]
|
||||
);
|
||||
assert_eq!(box_texts(&transitional), ["Strict text box"]);
|
||||
for (name, names) in [("Word's Strict names", &STRICT_WORD), ("the Strict schema's names", &STRICT_SCHEMA)] {
|
||||
let strict = wordcraft_docx::read(&package(names)).unwrap();
|
||||
assert_eq!(objects(&strict), want, "{name}");
|
||||
assert_eq!(box_texts(&strict), ["Strict text box"], "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_shapes_are_saved_and_reopened() {
|
||||
let strict = wordcraft_docx::read(&package(&STRICT_WORD)).unwrap();
|
||||
let saved = wordcraft_docx::write(&strict).unwrap();
|
||||
let xml = document_xml(&saved);
|
||||
for name in ["<wps:wsp>", "<wpg:wgp>", "<wpc:wpc>", "<wpc:bg>", "<w:txbxContent>", "<wps:cNvCnPr"] {
|
||||
assert!(xml.contains(name), "{name} missing from the saved document");
|
||||
}
|
||||
let reopened = wordcraft_docx::read(&saved).unwrap();
|
||||
assert_eq!(objects(&reopened), objects(&strict));
|
||||
assert_eq!(box_texts(&reopened), ["Strict text box"]);
|
||||
}
|
||||
@@ -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")),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -198,7 +198,9 @@ fn special(query: &str, o: &Opts) -> (String, bool) {
|
||||
if (o.ignore_punct && is_punct(c)) || (o.ignore_space && is_space(c)) {
|
||||
continue;
|
||||
}
|
||||
atoms.push(lit(c));
|
||||
// A keyboard apostrophe also finds text produced by smart quotes. Explicit character
|
||||
// codes above, and literal/regex/wildcard searches, keep exact character matching.
|
||||
atoms.push(if c == '\'' { "['‘’]".into() } else { lit(c) });
|
||||
}
|
||||
let filler = match (o.ignore_punct, o.ignore_space) {
|
||||
(true, true) => r"[\p{P} \t\x{A0}]*",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -76,9 +76,11 @@ pub fn cropped_original_size(s: &Session, o: &InlineObject) -> Option<(f32, f32)
|
||||
pub(super) fn size(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let (_, o) = selected(s).ok_or_else(|| CmdError::Disabled("no object".into()))?;
|
||||
let lock = p::bool(v, "lockAspect").unwrap_or(true);
|
||||
let (mut w, mut h) = obj_size(&o);
|
||||
let (w0, h0) = obj_size(&o);
|
||||
let (mut w, mut h) = (w0, h0);
|
||||
let scale = p::f32(v, "scale");
|
||||
if p::bool(v, "original").unwrap_or(false) {
|
||||
let original = p::bool(v, "original").unwrap_or(false);
|
||||
if original {
|
||||
let (ow, oh) =
|
||||
cropped_original_size(s, &o).ok_or_else(|| CmdError::Disabled("only a picture whose image can be read has an original size".into()))?;
|
||||
let k = scale.unwrap_or(100.0) / 100.0;
|
||||
@@ -113,7 +115,16 @@ pub(super) fn size(s: &mut Session, v: &Value) -> CmdResult {
|
||||
(None, None) => true,
|
||||
_ => lock,
|
||||
};
|
||||
let (w, h) = fit_size(w, h, keep);
|
||||
// Sides set in proportion (from the original, or from a size whose sides both have a length)
|
||||
// fit the limits together. Otherwise each side the call sets fits on its own, and a side it
|
||||
// leaves as it was keeps its size, a straight line's 0 too.
|
||||
let (w, h) = if keep && (original || (w0 > 0.0 && h0 > 0.0 && (w, h) != (w0, h0))) {
|
||||
fit_size(w, h, true)
|
||||
} else {
|
||||
let (fw, fh) = fit_size(w, h, false);
|
||||
let left = |new: f32, old: f32| !original && new == old;
|
||||
(if left(w, w0) { w } else { fw }, if left(h, h0) { h } else { fh })
|
||||
};
|
||||
// Any drawing: a group's members scale with its frame (its child space is kept); a chart or
|
||||
// SmartArt graphic lays itself out again at the new size.
|
||||
let theme = s.doc.settings.theme_colors.clone();
|
||||
|
||||
@@ -13,7 +13,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("picture.size", "Size", "Picture Format › Size", size)
|
||||
.params(
|
||||
r#"{"width"?: pt, "height"?: pt, "lockAspect"?: bool, "scale"?: percent, "original"?: bool} (scale: of the current size, or with original of the picture's original size, its pixels at 96 ppi less what is cropped; original alone restores that size; one side alone keeps the proportions unless lockAspect is false; 1–4000 pt: when the proportions are kept, a side past 4000 pt shrinks the other with it)"#,
|
||||
r#"{"width"?: pt, "height"?: pt, "lockAspect"?: bool, "scale"?: percent, "original"?: bool} (scale: of the current size, or with original of the picture's original size, its pixels at 96 ppi less what is cropped; original alone restores that size; one side alone keeps the proportions unless lockAspect is false; 1–4000 pt: when the proportions are kept (from the original, or with both sides having a length), a side past 4000 pt shrinks the other with it; a side the call leaves as it was keeps its size, a straight line's 0 too)"#,
|
||||
)
|
||||
.when(has_sizable),
|
||||
CommandSpec::new("picture.crop", "Crop", "Picture Format › Size", crop)
|
||||
|
||||
@@ -1034,6 +1034,66 @@ fn picture_size_past_the_limit_keeps_the_proportions() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A side the call leaves as it was keeps its size, even outside 1–4000 pt: a straight line's 0
|
||||
/// stays 0 when only its length is typed. Sides set in proportion (the original, or a size whose
|
||||
/// sides both have a length) fit the limits together; any other side the call sets, on its own.
|
||||
#[test]
|
||||
fn picture_size_keeps_the_side_it_leaves() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.run("insert.shape", &json!({"kind": "straightConnector", "width": 100.0, "height": 0.0})).unwrap();
|
||||
let size = |s: &Session| obj_size(&selected(s).unwrap().1);
|
||||
// A side a scale sets on its own: 0 has no length to scale.
|
||||
let scaled = |x: f32, k: f32| if x == 0.0 { 0.0 } else { (x * k).clamp(1.0, 4000.0) };
|
||||
// Sizes with both sides follow each other: they fit 1–4000 pt by the one factor nearest 1, worked out by hand
|
||||
// for the calls `width` locked, `height` locked, `scale` 50 and `scale` 200 with a width (in that order).
|
||||
let states = [
|
||||
((100.0, 0.0), None),
|
||||
((0.0, 100.0), None),
|
||||
((5000.0, 0.0), None),
|
||||
((0.0, 5000.0), None),
|
||||
((100.0, 0.5), Some([(200.0, 1.0), (4000.0, 20.0), (200.0, 1.0), (200.0, 1.0)])),
|
||||
((5000.0, 50.0), Some([(200.0, 2.0), (4000.0, 40.0), (2500.0, 25.0), (200.0, 2.0)])),
|
||||
((100.0, 50.0), Some([(200.0, 100.0), (400.0, 200.0), (50.0, 25.0), (200.0, 100.0)])),
|
||||
];
|
||||
for ((w0, h0), together) in states {
|
||||
let follow = |i: usize, alone: (f32, f32)| together.map_or(alone, |t: [(f32, f32); 4]| t[i]);
|
||||
let cases = [
|
||||
(json!({"width": 200.0, "lockAspect": false}), (200.0, h0)),
|
||||
(json!({"height": 200.0, "lockAspect": false}), (w0, 200.0)),
|
||||
(json!({"width": 200.0}), follow(0, (200.0, h0))),
|
||||
(json!({"height": 200.0}), follow(1, (w0, 200.0))),
|
||||
(json!({"scale": 50}), follow(2, (scaled(w0, 0.5), scaled(h0, 0.5)))),
|
||||
(json!({"scale": 200, "width": 200.0}), follow(3, (200.0, scaled(h0, 2.0)))),
|
||||
(json!({"scale": 100}), (w0, h0)),
|
||||
(json!({}), (w0, h0)),
|
||||
// The Size dialog: both sides sent, the height as it was.
|
||||
(json!({"width": 200.0, "height": h0, "lockAspect": false}), (200.0, h0)),
|
||||
(json!({"width": 200.0, "height": 300.0}), (200.0, 300.0)),
|
||||
];
|
||||
for (v, (ew, eh)) in cases {
|
||||
with_obj(&mut s, |o| o.set_size(w0, h0)).unwrap();
|
||||
s.run("picture.size", &v).unwrap();
|
||||
let (w, h) = size(&s);
|
||||
assert!((w - ew).abs() < 1e-3 && (h - eh).abs() < 1e-3, "{w0}×{h0} {v}: {w}×{h}, not {ew}×{eh}");
|
||||
}
|
||||
}
|
||||
// Both sides of a picture's original size fit together, whatever size it had, even when the
|
||||
// height comes out as it was: 4050×1012.5 pt is 4000×1000, not 4000×1012.5.
|
||||
let data = super::super::insert::base64_encode(&png(400, 100));
|
||||
s.run("insert.picture", &json!({"data": data})).unwrap();
|
||||
for (w0, h0) in [(3000.0, 1012.5), (4050.0, 0.0), (100.0, 100.0)] {
|
||||
with_obj(&mut s, |o| o.set_size(w0, h0)).unwrap();
|
||||
s.run("picture.size", &json!({"original": true, "scale": 1350})).unwrap();
|
||||
let (w, h) = size(&s);
|
||||
assert!((w - 4000.0).abs() < 0.01 && (h - 1000.0).abs() < 0.01, "{w0}×{h0}: {w}×{h}");
|
||||
}
|
||||
// A height the original sets is set, though it is the one the picture had: 300×75 pt at
|
||||
// 5400 % is 16200×4050, so with a width of 200 the height fits to 4000.
|
||||
with_obj(&mut s, |o| o.set_size(100.0, 4050.0)).unwrap();
|
||||
s.run("picture.size", &json!({"original": true, "scale": 5400, "width": 200.0, "lockAspect": false})).unwrap();
|
||||
assert_eq!(size(&s), (200.0, 4000.0));
|
||||
}
|
||||
|
||||
/// A crop handle drop: `picture.crop` with the new size is one edit, one undo step.
|
||||
#[test]
|
||||
fn picture_crop_with_size_is_one_undo_step() {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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,85 @@ 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 fresh scratch directory for the file-name tests below.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn filename_scratch_dir(tag: &str) -> std::path::PathBuf {
|
||||
let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||||
let dir = std::env::temp_dir().join(format!("wordcraft-filename-field-{tag}-{}-{nanos}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
/// #3796: after Save As to a new name, Update Field with the caret in a header shows the new
|
||||
/// name for FILENAME and the new full path for FILENAME \p.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn filename_fields_in_a_header_follow_save_as() {
|
||||
let dir = filename_scratch_dir("save-as");
|
||||
let (mut s, hdr) = header_fields_session(wordcraft_doc::Document::from_text("body"), &["FILENAME", r"FILENAME \p"]);
|
||||
let first = dir.join("report_v1.docx");
|
||||
let second = dir.join("report_v2.docx");
|
||||
s.run("file.save", &json!({"path": first.to_string_lossy()})).unwrap();
|
||||
s.run("references.updateFields", &json!({})).unwrap();
|
||||
let got = story_fields(&s, StoryRef::Part(hdr));
|
||||
assert_eq!(got[0].1, "report_v1.docx");
|
||||
assert_eq!(got[1].1, first.to_string_lossy());
|
||||
|
||||
s.run("file.saveAs", &json!({"path": second.to_string_lossy()})).unwrap();
|
||||
s.run("references.updateFields", &json!({})).unwrap();
|
||||
let got = story_fields(&s, StoryRef::Part(hdr));
|
||||
assert_eq!(got[0].1, "report_v2.docx");
|
||||
assert_eq!(got[1].1, second.to_string_lossy());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// #3796: a Word-style `FILENAME \* MERGEFORMAT` field whose cached text names the old file
|
||||
/// shows the new name after Save As and Update Field.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn filename_mergeformat_field_replaces_its_cached_name_after_save_as() {
|
||||
let dir = filename_scratch_dir("mergeformat");
|
||||
let (mut s, hdr) = header_fields_session(wordcraft_doc::Document::from_text("body"), &[r"FILENAME \* MERGEFORMAT"]);
|
||||
let first = dir.join("report_v1.docx");
|
||||
s.run("file.save", &json!({"path": first.to_string_lossy()})).unwrap();
|
||||
// The cached text a file written earlier would carry.
|
||||
let path = s.doc.para_paths(StoryRef::Part(hdr)).remove(0);
|
||||
if let Some(InlineObject::Field { result, .. }) = s.doc.para_mut(StoryRef::Part(hdr), &path).unwrap().objects.first_mut() {
|
||||
*result = "report_v1.docx".into();
|
||||
}
|
||||
s.run("file.saveAs", &json!({"path": dir.join("report_v2.docx").to_string_lossy()})).unwrap();
|
||||
s.run("references.updateFields", &json!({})).unwrap();
|
||||
assert_eq!(story_fields(&s, StoryRef::Part(hdr))[0].1, "report_v2.docx");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// #3796: renaming the document (File > Info) changes what FILENAME shows on the next Update
|
||||
/// Field; the extension stays.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn filename_field_in_a_header_follows_rename() {
|
||||
let dir = filename_scratch_dir("rename");
|
||||
let (mut s, hdr) = header_fields_session(wordcraft_doc::Document::from_text("body"), &["FILENAME"]);
|
||||
s.run("file.save", &json!({"path": dir.join("report_v1.docx").to_string_lossy()})).unwrap();
|
||||
s.run("file.rename", &json!({"name": "report_v3"})).unwrap();
|
||||
s.run("references.updateFields", &json!({})).unwrap();
|
||||
assert_eq!(story_fields(&s, StoryRef::Part(hdr))[0].1, "report_v3.docx");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
||||
@@ -195,7 +195,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
})
|
||||
.params(r#"{"text"?: string, "title"?: string} (empty clears)"#)),
|
||||
t(CommandSpec::new("table.sort", "Sort", "Table Layout › Data", sort_table)
|
||||
.params(r#"{"column"?: n, "descending"?: bool, "header"?: bool}"#)),
|
||||
.params(r#"{"column"?: non-negative JSON integer (0-based, default 0; must name a column in the table), "descending"?: bool, "header"?: bool}"#)),
|
||||
t(CommandSpec::new("table.toText", "Convert to Text", "Table Layout › Data", to_text)
|
||||
.params(r#"{"separator"?: "tab|comma|paragraph|other", "char"?: string (with other)} (the selected rows, or the whole table)"#)),
|
||||
t(CommandSpec::new("table.formula", "Formula", "Table Layout › Data", formula).params(r#"{"formula"?: "=SUM(ABOVE)"}"#)),
|
||||
|
||||
@@ -8,11 +8,18 @@ use super::{
|
||||
/// Sort the table's rows by a column. Numbers are read in each cell's language (`1,5` is one and
|
||||
/// a half in Polish) and sort first, numerically; other text sorts in the column's language.
|
||||
pub(super) fn sort_table(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let col = p::u64(v, "column").unwrap_or(0) as usize;
|
||||
let column = match v.get("column") {
|
||||
None => 0,
|
||||
Some(value) => value.as_u64().ok_or_else(|| CmdError::Params("`column` must be a non-negative JSON integer".into()))?,
|
||||
};
|
||||
let desc = p::bool(v, "descending").unwrap_or(false);
|
||||
let header = p::bool(v, "header");
|
||||
let (tp, _, _) = cell(s)?;
|
||||
let t = s.doc.table(s.sel.focus.story, &tp).ok_or_else(|| CmdError::Failed("no table".into()))?;
|
||||
let col = usize::try_from(column)
|
||||
.ok()
|
||||
.filter(|&col| t.rows.iter().any(|row| col < row.cells.len()))
|
||||
.ok_or_else(|| CmdError::Params("`column` is outside the table".into()))?;
|
||||
let skip = usize::from(header.unwrap_or(t.rows.first().is_some_and(|r| r.props.header))).min(t.rows.len());
|
||||
let column = || t.rows.iter().skip(skip).map(|r| r.cells.get(col));
|
||||
let lang = column().flatten().find_map(|c| cell_lang(&s.doc, c)).or_else(|| crate::cmd::default_lang(s));
|
||||
|
||||
@@ -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");
|
||||
|
||||
+298
-8
@@ -12,6 +12,7 @@ use crate::{Session, cmd};
|
||||
|
||||
mod bidi;
|
||||
mod documents;
|
||||
mod find_apostrophes;
|
||||
mod font_sizes;
|
||||
mod fonts;
|
||||
mod ime_noop;
|
||||
@@ -4681,10 +4682,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 +4692,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 +4722,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 +7944,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.
|
||||
@@ -10167,6 +10390,73 @@ fn column_texts(s: &Session) -> Vec<String> {
|
||||
t.rows.iter().map(|r| r.cells[0].blocks.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).collect::<String>()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_sort_rejects_invalid_columns_without_changing_rows_or_history() {
|
||||
for column in [
|
||||
json!(-1),
|
||||
json!(0.5),
|
||||
json!(0.0),
|
||||
json!("0"),
|
||||
json!(null),
|
||||
json!(true),
|
||||
json!([]),
|
||||
json!({}),
|
||||
json!(1),
|
||||
json!(u32::MAX as u64 + 1),
|
||||
json!(u64::MAX),
|
||||
] {
|
||||
let mut s = column_table("en-US", &["Charlie", "Alpha", "Bravo"]);
|
||||
s.close_typing();
|
||||
run(&mut s, "text.insert", json!({"text": "!"}));
|
||||
let before = s.doc.clone();
|
||||
let selection = s.sel.clone();
|
||||
let dirty = s.dirty;
|
||||
assert!(s.run("table.sort", &json!({"column": column, "header": false})).is_err(), "column {column}");
|
||||
assert_eq!(s.doc, before, "column {column}");
|
||||
assert_eq!(s.sel, selection, "column {column}");
|
||||
assert_eq!(s.dirty, dirty, "column {column}");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(column_texts(&s), ["Charlie", "Alpha", "Bravo"], "column {column}: refusal must not consume Undo");
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(s.doc, before, "column {column}: Redo must restore the preceding edit");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_sort_keeps_default_and_valid_columns() {
|
||||
let mut s = s();
|
||||
run(&mut s, "insert.table", json!({"rows": 3, "cols": 2}));
|
||||
for (k, value) in ["Charlie", "1", "Alpha", "3", "Bravo", "2"].into_iter().enumerate() {
|
||||
if k > 0 {
|
||||
run(&mut s, "text.tab", json!({}));
|
||||
}
|
||||
run(&mut s, "text.insert", json!({"text": value}));
|
||||
}
|
||||
let original = s.doc.clone();
|
||||
for params in [json!({"header": false}), json!({"column": 0, "header": false})] {
|
||||
run(&mut s, "table.sort", params);
|
||||
assert_eq!(column_texts(&s), ["Alpha", "Bravo", "Charlie"]);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc, original);
|
||||
}
|
||||
run(&mut s, "table.sort", json!({"column": 1, "header": false}));
|
||||
assert_eq!(column_texts(&s), ["Charlie", "Bravo", "Alpha"]);
|
||||
run(&mut s, "table.sort", json!({"column": 1, "header": false, "descending": true}));
|
||||
assert_eq!(column_texts(&s), ["Alpha", "Bravo", "Charlie"]);
|
||||
// A column may be missing in some rows. Its present cells still sort normally.
|
||||
let at = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32;
|
||||
let table = s.doc.table_mut(StoryRef::Body, &wordcraft_doc::Path(vec![at])).unwrap();
|
||||
table.rows[0].cells.remove(1);
|
||||
run(&mut s, "table.sort", json!({"column": 1, "header": false}));
|
||||
assert_eq!(column_texts(&s), ["Charlie", "Bravo", "Alpha"]);
|
||||
|
||||
let mut s = column_table("en-US", &["Title", "3", "1", "2"]);
|
||||
let at = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32;
|
||||
s.doc.table_mut(StoryRef::Body, &wordcraft_doc::Path(vec![at])).unwrap().rows[0].props.header = true;
|
||||
run(&mut s, "table.sort", json!({"column": 0}));
|
||||
assert_eq!(column_texts(&s), ["Title", "1", "2", "3"]);
|
||||
}
|
||||
|
||||
/// #836: table sort reads numbers in the text's language: `1,5` is one and a half in Polish.
|
||||
#[test]
|
||||
fn table_sort_reads_decimal_comma_in_polish() {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
use super::*;
|
||||
|
||||
/// Keyboard smart quotes stay searchable, and replacement keeps their original spelling.
|
||||
#[test]
|
||||
fn keyboard_apostrophe_finds_smart_quoted_typing() {
|
||||
let mut s = s();
|
||||
for ch in "Tau'va".chars() {
|
||||
run(&mut s, "text.insert", json!({"text": ch.to_string()}));
|
||||
}
|
||||
assert_eq!(text(&s), "Tau’va");
|
||||
for query in ["Tau'", "Tau'v", "Tau'va", "tau'VA"] {
|
||||
assert_eq!(run(&mut s, "edit.find", json!({"text": query}))["count"], 1, "{query}");
|
||||
}
|
||||
assert_eq!(run(&mut s, "edit.find", json!({"text": "tau'va", "matchCase": true}))["count"], 0);
|
||||
assert_eq!(run(&mut s, "edit.replaceAll", json!({"text": "Tau'va", "with": "[^&]", "matchCase": false}))["replaced"], 1);
|
||||
assert_eq!(text(&s), "[Tau’va]");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(text(&s), "Tau’va");
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(text(&s), "[Tau’va]");
|
||||
}
|
||||
|
||||
/// Normal search uses raw UTF-8 ranges while explicit syntax and modifier letters stay exact.
|
||||
#[test]
|
||||
fn normal_apostrophe_search_keeps_exact_modes_and_original_byte_ranges() {
|
||||
let mut s = s();
|
||||
let original = "🙂 Tau'va Tau’va Tau‘va Tauʼva";
|
||||
run(&mut s, "document.setText", json!({"text": original}));
|
||||
let found = run(&mut s, "edit.find", json!({"text": "Tau'va", "wholeWord": true}));
|
||||
assert_eq!(found["count"], 3);
|
||||
let ranges: Vec<_> =
|
||||
found["matches"].as_array().unwrap().iter().map(|m| (m["start"]["off"].as_u64().unwrap(), m["end"]["off"].as_u64().unwrap())).collect();
|
||||
assert_eq!(ranges, [(5, 11), (12, 20), (21, 29)]);
|
||||
for options in [json!({"special": false}), json!({"wildcards": true}), json!({"regex": true})] {
|
||||
let mut exact = super::s();
|
||||
run(&mut exact, "document.setText", json!({"text": original}));
|
||||
let mut params = options;
|
||||
params["text"] = json!("Tau'va");
|
||||
assert_eq!(run(&mut exact, "edit.find", params)["count"], 1);
|
||||
}
|
||||
for query in ["Tau^u39va", "Tau^u8217va", "Tau’va", "Tau‘va", "Tauʼva"] {
|
||||
assert_eq!(run(&mut s, "edit.find", json!({"text": query}))["count"], 1, "{query}");
|
||||
}
|
||||
assert_eq!(run(&mut s, "edit.replaceAll", json!({"text": "Tau'va", "with": "<^&>"}))["replaced"], 3);
|
||||
assert_eq!(text(&s), "🙂 <Tau'va> <Tau’va> <Tau‘va> Tauʼva");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(text(&s), original);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
+55
-7
@@ -21,7 +21,7 @@ pub use fontdb::{
|
||||
pub use harfrust::Feature;
|
||||
use harfrust::{Direction, ShapeOptions, ShapePlan, ShapePlanKey, Shaper, Tag, UnicodeBuffer};
|
||||
pub use kurbo::BezPath;
|
||||
pub use script::{fallback_rank, is_emoji, needs_layout_tables, script_fallbacks};
|
||||
pub use script::{fallback_rank, is_emoji, joins_cursively, needs_layout_tables, script_fallbacks};
|
||||
use skrifa::MetadataProvider;
|
||||
use skrifa::instance::Size;
|
||||
pub use ui_fallback::{
|
||||
@@ -185,13 +185,34 @@ fn direction_runs(text: &str) -> Vec<(std::ops::Range<usize>, bool)> {
|
||||
/// order: right-to-left runs are shaped right to left, then their clusters put back in text
|
||||
/// order (each cluster's glyphs keep the shaper's order); line layout reorders them visually.
|
||||
pub fn shape(face: &FontFace, text: &str, features: &[Feature], map: impl Fn(char) -> char) -> Vec<ShapedGlyph> {
|
||||
shape_in(face, text, features, map, ShapeContext::default())
|
||||
}
|
||||
|
||||
/// The text shown on either side of what is being shaped. Only a letter's form depends on it: the
|
||||
/// letters of an Arabic word join across a change of formatting, so each piece has to know what
|
||||
/// it continues (#4093). Nothing outside the shaped text is drawn or measured.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct ShapeContext<'a> {
|
||||
pub before: &'a str,
|
||||
pub after: &'a str,
|
||||
}
|
||||
|
||||
impl<'a> ShapeContext<'a> {
|
||||
/// This context for the part `r` of `text`: what's beyond `text` where `r` reaches its edge.
|
||||
fn at_edges_of(self, text: &str, r: &std::ops::Range<usize>) -> ShapeContext<'a> {
|
||||
ShapeContext { before: if r.start == 0 { self.before } else { "" }, after: if r.end == text.len() { self.after } else { "" } }
|
||||
}
|
||||
}
|
||||
|
||||
/// [`shape`] of a piece of longer text (see [`ShapeContext`]).
|
||||
pub fn shape_in(face: &FontFace, text: &str, features: &[Feature], map: impl Fn(char) -> char, around: ShapeContext<'_>) -> Vec<ShapedGlyph> {
|
||||
if !text.chars().any(is_rtl) {
|
||||
return shape_dir(face, text, features, &map, false);
|
||||
return shape_dir(face, text, features, &map, false, around);
|
||||
}
|
||||
let mut out = Vec::with_capacity(text.len());
|
||||
for (r, rtl) in direction_runs(text) {
|
||||
let Some(sub) = text.get(r.clone()) else { continue };
|
||||
let mut g = shape_run(face, sub, features, &map, rtl);
|
||||
let mut g = shape_run_in(face, sub, features, &map, rtl, around.at_edges_of(text, &r));
|
||||
for x in &mut g {
|
||||
x.cluster += r.start;
|
||||
}
|
||||
@@ -205,7 +226,19 @@ pub fn shape(face: &FontFace, text: &str, features: &[Feature], map: impl Fn(cha
|
||||
/// mark positioning. Glyphs come back in logical order like [`shape`]: clusters in text order,
|
||||
/// each cluster's glyphs in the shaper's (visual) order.
|
||||
pub fn shape_run(face: &FontFace, text: &str, features: &[Feature], map: impl Fn(char) -> char, rtl: bool) -> Vec<ShapedGlyph> {
|
||||
let g = shape_dir(face, text, features, &map, rtl);
|
||||
shape_run_in(face, text, features, map, rtl, ShapeContext::default())
|
||||
}
|
||||
|
||||
/// [`shape_run`] of a piece of longer text (see [`ShapeContext`]).
|
||||
pub fn shape_run_in(
|
||||
face: &FontFace,
|
||||
text: &str,
|
||||
features: &[Feature],
|
||||
map: impl Fn(char) -> char,
|
||||
rtl: bool,
|
||||
around: ShapeContext<'_>,
|
||||
) -> Vec<ShapedGlyph> {
|
||||
let g = shape_dir(face, text, features, &map, rtl, around);
|
||||
if !rtl {
|
||||
return g;
|
||||
}
|
||||
@@ -221,7 +254,14 @@ pub fn shape_run(face: &FontFace, text: &str, features: &[Feature], map: impl Fn
|
||||
groups.into_iter().flatten().collect()
|
||||
}
|
||||
|
||||
fn shape_dir(face: &FontFace, text: &str, features: &[Feature], map: &impl Fn(char) -> char, rtl: bool) -> Vec<ShapedGlyph> {
|
||||
fn shape_dir(
|
||||
face: &FontFace,
|
||||
text: &str,
|
||||
features: &[Feature],
|
||||
map: &impl Fn(char) -> char,
|
||||
rtl: bool,
|
||||
around: ShapeContext<'_>,
|
||||
) -> Vec<ShapedGlyph> {
|
||||
let mut out = Vec::with_capacity(text.len());
|
||||
let shaped = face.hb().map(|hb| {
|
||||
let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build();
|
||||
@@ -234,6 +274,10 @@ fn shape_dir(face: &FontFace, text: &str, features: &[Feature], map: &impl Fn(ch
|
||||
for (i, c) in sub.char_indices() {
|
||||
buf.add(map(c), (r.start + i) as u32);
|
||||
}
|
||||
// After the text: adding to a buffer clears what follows it.
|
||||
let around = around.at_edges_of(text, &r);
|
||||
buf.set_pre_context(around.before);
|
||||
buf.set_post_context(around.after);
|
||||
buf.guess_segment_properties();
|
||||
buf.set_direction(if rtl { Direction::RightToLeft } else { Direction::LeftToRight });
|
||||
let plan = shape_plan(face, &shaper, &buf, features);
|
||||
@@ -386,7 +430,10 @@ mod tests {
|
||||
gb.glyph_infos().iter().zip(gb.glyph_positions()).map(|(i, p)| (i.glyph_id, i.cluster, p.x_advance, p.x_offset, p.y_offset)).collect()
|
||||
};
|
||||
let kept = |face: &FontFace, text: &str, features: &[Feature], rtl: bool| -> Vec<(u32, u32, i32, i32, i32)> {
|
||||
shape_dir(face, text, features, &|c| c, rtl).iter().map(|g| (g.gid, g.cluster as u32, g.x_advance, g.x_offset, g.y_offset)).collect()
|
||||
shape_dir(face, text, features, &|c| c, rtl, ShapeContext::default())
|
||||
.iter()
|
||||
.map(|g| (g.gid, g.cluster as u32, g.x_advance, g.x_offset, g.y_offset))
|
||||
.collect()
|
||||
};
|
||||
let db = FontDb::global();
|
||||
let faces = [db.face(DEFAULT_FAMILY, "Regular"), db.face(DEFAULT_FAMILY, "Italic"), db.face("Source Sans 3", "Regular")];
|
||||
@@ -421,7 +468,8 @@ mod tests {
|
||||
for n in 0..3 * MAX_SHAPE_PLANS as u32 {
|
||||
let features = [feature_value(b"kern", n % 2), feature_value(b"liga", n)];
|
||||
let rtl = n % 3 == 0;
|
||||
let kept: Vec<(u32, i32)> = shape_dir(&face, "office", &features, &|c| c, rtl).iter().map(|g| (g.gid, g.x_advance)).collect();
|
||||
let kept: Vec<(u32, i32)> =
|
||||
shape_dir(&face, "office", &features, &|c| c, rtl, ShapeContext::default()).iter().map(|g| (g.gid, g.x_advance)).collect();
|
||||
assert_eq!(kept, fresh(&features, rtl), "{n}");
|
||||
assert!(kept_shape_plans() <= MAX_SHAPE_PLANS, "{}", kept_shape_plans());
|
||||
}
|
||||
|
||||
@@ -18,6 +18,28 @@ fn real_script(c: char) -> Option<Script> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `c` belongs to a script whose letters join their neighbours (Arabic, Syriac, Mongolian…),
|
||||
/// so a letter's form depends on the text on either side of it.
|
||||
pub fn joins_cursively(c: char) -> bool {
|
||||
c >= '\u{0600}'
|
||||
&& matches!(
|
||||
c.script(),
|
||||
Script::Adlam
|
||||
| Script::Arabic
|
||||
| Script::Chorasmian
|
||||
| Script::Hanifi_Rohingya
|
||||
| Script::Mandaic
|
||||
| Script::Manichaean
|
||||
| Script::Mongolian
|
||||
| Script::Nko
|
||||
| Script::Old_Uyghur
|
||||
| Script::Phags_Pa
|
||||
| Script::Psalter_Pahlavi
|
||||
| Script::Sogdian
|
||||
| Script::Syriac
|
||||
)
|
||||
}
|
||||
|
||||
/// Maximal single-script byte ranges of `text`. Characters of no particular script join the run
|
||||
/// before them (or, at the start, the first run), so the ranges cover `text` exactly.
|
||||
pub(crate) fn script_runs(text: &str) -> Vec<std::ops::Range<usize>> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1391,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -1355,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)> {
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -463,6 +463,8 @@ fn anchor_floats_but(
|
||||
late: &[usize],
|
||||
) -> (HashMap<usize, Rect>, Arc<ParaLayout>, Vec<para::Exclusion>) {
|
||||
let FloatAnchor { p, block, y0, before, excl_mark, label, place } = anchor;
|
||||
ctx.fields.page = pb.number;
|
||||
ctx.fields.page_format = pb.page_format;
|
||||
let (col_x, width) = (pb.col_x(), pb.col_w());
|
||||
let mut rects = HashMap::new();
|
||||
if !pb.web && place {
|
||||
|
||||
@@ -816,7 +816,8 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block:
|
||||
let mut f = Flow::new(ctx, pb, p, (block, body_top));
|
||||
let spot = |pb: &PageBuilder| (pb.pages.len(), pb.col);
|
||||
let placed_at = spot(pb);
|
||||
let broke_before = f.pl.rp.page_break_before && !pb.at_top() && !pb.web;
|
||||
// A continuous section's top mid-page is not a page top, so the break still applies.
|
||||
let broke_before = f.pl.rp.page_break_before && !pb.fresh() && !pb.web;
|
||||
if broke_before {
|
||||
pb.new_page(block, body_top);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)>,
|
||||
|
||||
@@ -6,6 +6,7 @@ use super::chars::{
|
||||
use super::grid::east_asian;
|
||||
use super::model::{ClKind, Cluster, Enclosed, Glyph, ParaEnv, RubyLayout, StyleRun, SymbolRun};
|
||||
use super::{Arc, Document, FaceRef, NumeralMode, ResolvedChar, RubyAlign, SOFT_HYPHEN};
|
||||
use wordcraft_fonts::ShapeContext;
|
||||
|
||||
pub(super) struct Builder<'a> {
|
||||
pub(super) env: &'a ParaEnv<'a>,
|
||||
@@ -226,6 +227,19 @@ impl<'a> Builder<'a> {
|
||||
|
||||
/// `dir`: the bidi levels `text` is shaped by (see [`Dir`]).
|
||||
pub(super) fn shape_with(&mut self, text: &str, base: usize, rc: &Arc<ResolvedChar>, kind_override: Option<ClKind>, dir: Dir<'_>) {
|
||||
self.shape_around(text, base, rc, kind_override, dir, ShapeContext::default());
|
||||
}
|
||||
|
||||
/// `around`: the text shown beside `text`, which its first and last letters may join.
|
||||
pub(super) fn shape_around(
|
||||
&mut self,
|
||||
text: &str,
|
||||
base: usize,
|
||||
rc: &Arc<ResolvedChar>,
|
||||
kind_override: Option<ClKind>,
|
||||
dir: Dir<'_>,
|
||||
around: ShapeContext<'_>,
|
||||
) {
|
||||
if text.is_empty() {
|
||||
return;
|
||||
}
|
||||
@@ -374,10 +388,15 @@ impl<'a> Builder<'a> {
|
||||
let from = usize::from(rc.kerned());
|
||||
let to = if ligatures.is_some() { all.len() } else { 1 };
|
||||
let features: &[wordcraft_fonts::Feature] = all.get(from..to).unwrap_or_default();
|
||||
// Letters join across a change of font or direction inside the run too.
|
||||
let around = ShapeContext {
|
||||
before: if a == 0 { around.before } else { text.get(..a).unwrap_or("") },
|
||||
after: if b == text.len() { around.after } else { text.get(b..).unwrap_or("") },
|
||||
};
|
||||
let shaped = if use_levels {
|
||||
wordcraft_fonts::shape_run(&st.face, shape_text, features, map, key.rtl)
|
||||
wordcraft_fonts::shape_run_in(&st.face, shape_text, features, map, key.rtl, around)
|
||||
} else {
|
||||
wordcraft_fonts::shape(&st.face, shape_text, features, map)
|
||||
wordcraft_fonts::shape_in(&st.face, shape_text, features, map, around)
|
||||
};
|
||||
let k = st.size / st.face.upem.max(1.0) as f32;
|
||||
let hscale = rc.scale / 100.0;
|
||||
|
||||
@@ -92,6 +92,8 @@ struct Scan {
|
||||
j: usize,
|
||||
/// Tab cluster, stop, and x where the tab started.
|
||||
pending_tab: Option<(usize, TabStop, f32)>,
|
||||
/// The text after a pending decimal tab looked at so far for its '.'.
|
||||
decimal: Option<DecimalScan>,
|
||||
/// The width of the spaces so far on this line.
|
||||
space_w: f32,
|
||||
has_tab: bool,
|
||||
@@ -113,17 +115,15 @@ 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)
|
||||
}
|
||||
|
||||
/// The width up to the first '.' of the text after the tab at `tj`, up to cluster `j`.
|
||||
fn width_to_decimal(pl: &ParaLayout, tj: usize, j: usize) -> Option<f32> {
|
||||
let mut w = 0.0;
|
||||
for k in tj + 1..=j {
|
||||
let cc = pl.clusters.get(k)?;
|
||||
if pl_text_at(cc) {
|
||||
return Some(w);
|
||||
}
|
||||
w += cc.adv;
|
||||
}
|
||||
None
|
||||
/// How far the text after a decimal tab has been looked at for its first '.' (see
|
||||
/// [`Scan::width_to_decimal`]): the tab, the next cluster to look at, the width before it, and
|
||||
/// whether that cluster is the '.'.
|
||||
#[derive(Clone, Copy, Default)]
|
||||
struct DecimalScan {
|
||||
tab: usize,
|
||||
next: usize,
|
||||
width: f32,
|
||||
found: bool,
|
||||
}
|
||||
|
||||
/// The objects' exclusions mirrored across a line of `width`, for a right-to-left paragraph.
|
||||
@@ -279,6 +279,7 @@ impl<'e> Breaker<'e> {
|
||||
end: LineEnd::Para,
|
||||
j: self.i,
|
||||
pending_tab: None,
|
||||
decimal: None,
|
||||
space_w: 0.0,
|
||||
has_tab: false,
|
||||
skip: false,
|
||||
@@ -297,6 +298,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 +442,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 {
|
||||
@@ -912,6 +918,29 @@ impl Scan {
|
||||
false
|
||||
}
|
||||
|
||||
/// The width up to the first '.' of the text after the tab at `tj`, up to cluster `j`. The
|
||||
/// text is shifted after each of its clusters, so the clusters already looked at are not
|
||||
/// walked again: a long run without a '.' after a decimal tab is looked at once, not once
|
||||
/// per cluster.
|
||||
fn width_to_decimal(&mut self, pl: &ParaLayout, tj: usize, j: usize) -> Option<f32> {
|
||||
let start = DecimalScan { tab: tj, next: tj + 1, width: 0.0, found: false };
|
||||
let d = self.decimal.get_or_insert(start);
|
||||
if d.tab != tj || d.next > j.saturating_add(1) {
|
||||
*d = start;
|
||||
}
|
||||
while !d.found && d.next <= j {
|
||||
crate::work::steps(1);
|
||||
let cc = pl.clusters.get(d.next)?;
|
||||
if pl_text_at(cc) {
|
||||
d.found = true;
|
||||
} else {
|
||||
d.width += cc.adv;
|
||||
d.next += 1;
|
||||
}
|
||||
}
|
||||
d.found.then_some(d.width)
|
||||
}
|
||||
|
||||
/// Text after a decimal/centre/right tab shifts as it grows.
|
||||
fn shift_pending_tab(&mut self, pl: &mut ParaLayout) {
|
||||
let Some((tj, stop, tx)) = self.pending_tab else { return };
|
||||
@@ -920,7 +949,7 @@ impl Scan {
|
||||
TabAlign::Right => stop.pos - tx - seg_w,
|
||||
TabAlign::Center => stop.pos - tx - seg_w / 2.0,
|
||||
// Width up to the first '.' in the segment.
|
||||
TabAlign::Decimal => stop.pos - tx - width_to_decimal(pl, tj, self.j).unwrap_or(seg_w),
|
||||
TabAlign::Decimal => stop.pos - tx - self.width_to_decimal(pl, tj, self.j).unwrap_or(seg_w),
|
||||
_ => 0.0,
|
||||
};
|
||||
let shift = shift.max(0.0);
|
||||
|
||||
@@ -6,6 +6,8 @@ use super::justify::hyphenation_points;
|
||||
use super::lines::break_lines;
|
||||
use super::model::{ClKind, Cluster, Enclosed, Glyph, Label, LabelRun, ParaEnv, RubyLayout, undrawable_bullet};
|
||||
use super::proof::{ProofRun, is_list_item, proof_issues};
|
||||
use wordcraft_fonts::ShapeContext;
|
||||
|
||||
use super::{
|
||||
Arc, COLUMN_BREAK, CharProps, Document, FIELD_SHADING, InlineObject, LINE_BREAK, NoteKind, OBJ, PAGE_BREAK, PTabAlign, PTabBase, ParaLayout,
|
||||
Paragraph, ResolvedChar, ResolvedPara, TabLeader, field_code_text, field_text_on, reference_link,
|
||||
@@ -33,6 +35,8 @@ struct Cx<'a> {
|
||||
math_props: wordcraft_doc::math::MathProps,
|
||||
/// The first character, if it is to be a drop cap.
|
||||
drop_first: Option<char>,
|
||||
/// The text on the page, when its letters can join across runs (see [`shown_text`]).
|
||||
shown: Option<String>,
|
||||
}
|
||||
|
||||
/// Where a cluster for an object or a break goes in the paragraph: its text, style and first glyph.
|
||||
@@ -83,6 +87,10 @@ struct Shaped<'a> {
|
||||
bare: Vec<std::ops::Range<usize>>,
|
||||
/// Each run's proofing language and whether it is checked (spelling, grammar, hyphenation).
|
||||
runs: Vec<ProofRun>,
|
||||
/// How far into the shown text ([`Cx::shown`]) the runs so far have come.
|
||||
shown_at: usize,
|
||||
/// Where in the shown text a letter's context may begin: past the drop cap, once one is drawn.
|
||||
shown_floor: usize,
|
||||
}
|
||||
|
||||
fn is_special(c: char) -> bool {
|
||||
@@ -186,6 +194,8 @@ impl<'a> Shaped<'a> {
|
||||
left: Vec::new(),
|
||||
bare: Vec::new(),
|
||||
runs: Vec::new(),
|
||||
shown_at: 0,
|
||||
shown_floor: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,24 +246,44 @@ impl<'a> Shaped<'a> {
|
||||
|
||||
/// A run shown: its text, with the breaks and objects in it as clusters of their own.
|
||||
fn visible_run(&mut self, cx: &Cx, (rc, props): (&Arc<ResolvedChar>, &CharProps), range: &std::ops::Range<usize>, text: &str) {
|
||||
let shown_start = self.shown_at;
|
||||
let around = self.next_shown(cx, text.len());
|
||||
let mut seg = self.drop_cap_letter(cx, rc, text, range.start);
|
||||
if seg > 0 {
|
||||
// A drop cap is drawn apart from its word: nothing after it joins it, in this run or
|
||||
// in the runs after it (the letter can be formatted on its own).
|
||||
self.shown_floor = shown_start.saturating_add(seg);
|
||||
}
|
||||
// The first piece continues what came before the run. After a drop cap or a break nothing
|
||||
// joins: that letter is drawn apart, and a break is no letter. The same goes for the end.
|
||||
let mut before = if seg == 0 { around.before } else { "" };
|
||||
for (i, c) in text.char_indices() {
|
||||
if i < seg || !is_special(c) {
|
||||
continue;
|
||||
}
|
||||
if let Some(s) = text.get(seg..i) {
|
||||
self.b.shape(s, range.start + seg, rc, None);
|
||||
self.b.shape_around(s, range.start + seg, rc, None, Dir::Para, ShapeContext { before, after: "" });
|
||||
}
|
||||
before = "";
|
||||
seg = i + c.len_utf8();
|
||||
let style = self.b.style(rc, None, false);
|
||||
let slot = Slot { start: range.start + i, end: range.start + i + c.len_utf8(), style, g: self.b.glyphs.len() as u32 };
|
||||
self.special(cx, c, slot, (rc, props));
|
||||
}
|
||||
if let Some(s) = text.get(seg..) {
|
||||
self.b.shape(s, range.start + seg, rc, None);
|
||||
self.b.shape_around(s, range.start + seg, rc, None, Dir::Para, ShapeContext { before, after: around.after });
|
||||
}
|
||||
}
|
||||
|
||||
/// The text shown on either side of the next `len` bytes shown, and moves past them. What
|
||||
/// comes before starts after a drop cap ([`Shaped::shown_floor`]).
|
||||
fn next_shown<'c>(&mut self, cx: &'c Cx, len: usize) -> ShapeContext<'c> {
|
||||
let Some(shown) = &cx.shown else { return ShapeContext::default() };
|
||||
let start = self.shown_at;
|
||||
self.shown_at = start.saturating_add(len);
|
||||
ShapeContext { before: shown.get(self.shown_floor.min(start)..start).unwrap_or(""), after: shown.get(self.shown_at..).unwrap_or("") }
|
||||
}
|
||||
|
||||
/// Drop cap: the first character, sized so its capital spans `lines` lines. Returns how much
|
||||
/// of the run's text it took.
|
||||
fn drop_cap_letter(&mut self, cx: &Cx, rc: &Arc<ResolvedChar>, text: &str, start: usize) -> usize {
|
||||
@@ -338,7 +368,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 +399,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);
|
||||
@@ -592,6 +623,26 @@ fn joined_cluster_starts(p: &Paragraph, clusters: &[Cluster]) -> Vec<usize> {
|
||||
joined
|
||||
}
|
||||
|
||||
/// The paragraph's text without the runs left out of the layout, if it has letters that join their
|
||||
/// neighbours: a letter joins the next one on the page, across a change of formatting and past
|
||||
/// text that isn't shown (#4093). `None` for every other paragraph, which don't need it.
|
||||
fn shown_text(cx: &Cx) -> Option<String> {
|
||||
let text = &cx.p.text;
|
||||
if text.is_ascii() || !text.chars().any(wordcraft_fonts::joins_cursively) {
|
||||
return None;
|
||||
}
|
||||
let mut shown = String::with_capacity(text.len());
|
||||
for (range, props) in cx.p.run_ranges() {
|
||||
let rc = cx.res.char(props);
|
||||
if !left_out(cx.env.doc, &rc, cx.env.show_hidden, cx.env.hide_deleted)
|
||||
&& let Some(run) = text.get(range)
|
||||
{
|
||||
shown.push_str(run);
|
||||
}
|
||||
}
|
||||
Some(shown)
|
||||
}
|
||||
|
||||
/// Lay out one paragraph.
|
||||
pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout {
|
||||
#[cfg(test)]
|
||||
@@ -610,7 +661,9 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout {
|
||||
shown: Vec::new(),
|
||||
substituted: Vec::new(),
|
||||
};
|
||||
let cx = Cx { p, env, rp: &rp, res, math_props: doc.settings.math.clone().unwrap_or_default(), drop_first: drop_cap_char(p, &rp) };
|
||||
let mut cx =
|
||||
Cx { p, env, rp: &rp, res, math_props: doc.settings.math.clone().unwrap_or_default(), drop_first: drop_cap_char(p, &rp), shown: None };
|
||||
cx.shown = shown_text(&cx);
|
||||
let mut sh = Shaped::new(b, env.eq_number);
|
||||
let mark_rc = cx.res.char(&p.mark);
|
||||
let mark_style = sh.b.style(&mark_rc, None, false);
|
||||
|
||||
@@ -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)
|
||||
@@ -443,7 +493,14 @@ mod tests {
|
||||
#[test]
|
||||
fn suppress_keeps_the_number() {
|
||||
crate::test_fonts();
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1860,6 +1860,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() {
|
||||
crate::test_fonts();
|
||||
@@ -5728,8 +5752,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));
|
||||
@@ -10442,6 +10471,20 @@ fn empty_page_section_keeps_line() {
|
||||
/// side by side). A section break on a paragraph with text keeps its line, and so does any break
|
||||
/// before a section on a new page. The SARP form's fields drifted a line lower at each of its
|
||||
/// many continuous breaks.
|
||||
/// A continuous section starting mid-page leaves its first paragraph's page break before in
|
||||
/// force, measured in Word for Mac: "Cover", an empty mark closing a continuous section, then
|
||||
/// "Heading" with page break before puts "Heading" on page 2.
|
||||
#[test]
|
||||
fn a_page_break_before_after_a_continuous_mark_starts_a_page() {
|
||||
use wordcraft_doc::section::{SectionProps, SectionStart};
|
||||
let mut d = Document::from_text("Cover\n\nHeading");
|
||||
d.para_mut(StoryRef::Body, &Path::top(1)).unwrap().section =
|
||||
Some(Box::new(SectionProps { start: SectionStart::Continuous, ..Default::default() }));
|
||||
d.para_mut(StoryRef::Body, &Path::top(2)).unwrap().props.page_break_before = Some(true);
|
||||
d.last_section.start = SectionStart::Continuous;
|
||||
assert_eq!(lay(&d).caret(&Pos::body(2, 0)).unwrap().page, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_continuous_section_mark_takes_no_room() {
|
||||
crate::test_fonts();
|
||||
@@ -14002,7 +14045,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;
|
||||
@@ -14019,10 +14062,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);
|
||||
|
||||
@@ -661,3 +661,149 @@ fn list_label_after_leading_page_break_uses_its_text_lines_alignment() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The glyph ids of each letter of paragraph 0 as laid out (marks of runs left out have none).
|
||||
fn letters(l: &DocLayout) -> Vec<Vec<u32>> {
|
||||
let (pl, _) = first_line(l, 0);
|
||||
pl.clusters.iter().filter(|c| c.g1 > c.g0).map(|c| pl.glyphs[c.g0 as usize..c.g1 as usize].iter().map(|g| g.gid).collect()).collect()
|
||||
}
|
||||
|
||||
/// `text` as one right-to-left paragraph with `format` applied to each `(start, end)` byte range.
|
||||
fn formatted(text: &str, splits: &[(usize, usize)], format: &dyn Fn(&mut wordcraft_doc::props::CharProps)) -> Document {
|
||||
let mut d = doc_of(&[(text, true)]);
|
||||
for &(start, end) in splits {
|
||||
d.format_range(&Pos::body(0, start), &Pos::body(0, end), format).unwrap();
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
const BEH: usize = 'ب'.len_utf8();
|
||||
|
||||
fn red(c: &mut wordcraft_doc::props::CharProps) {
|
||||
c.color = Some(wordcraft_doc::props::TextColor::Rgb(wordcraft_doc::props::Rgb(0xC0, 0, 0)));
|
||||
}
|
||||
|
||||
/// The reporter's symptom: after a change of formatting inside a word, the letters on either side
|
||||
/// took the forms of the end and the start of a word, which reads as a space in it (#4093).
|
||||
#[test]
|
||||
fn letters_join_across_a_change_of_formatting() {
|
||||
if arabic_face().is_none() {
|
||||
eprintln!("skipped: no font with Persian letters installed");
|
||||
return;
|
||||
}
|
||||
let word = "بببب";
|
||||
let whole = letters(&lay(&doc_of(&[(word, true)])));
|
||||
assert_eq!(whole.len(), 4, "one cluster per letter: {whole:?}");
|
||||
assert!(whole.first() != whole.get(1) && whole.get(1) != whole.last(), "initial, medial and final forms: {whole:?}");
|
||||
// A letter of its own at each place, then the two middle letters, then every other letter.
|
||||
let pieces: [&[(usize, usize)]; 6] =
|
||||
[&[(0, BEH)], &[(BEH, 2 * BEH)], &[(2 * BEH, 3 * BEH)], &[(3 * BEH, 4 * BEH)], &[(BEH, 3 * BEH)], &[(0, BEH), (2 * BEH, 3 * BEH)]];
|
||||
for runs in pieces {
|
||||
let d = formatted(word, runs, &red);
|
||||
assert!(d.para_at(&Pos::body(0, 0)).unwrap().runs.len() > 1, "{runs:?} split the run");
|
||||
assert_eq!(letters(&lay(&d)), whole, "split at {runs:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A letter joins what is on the page: with a tracked deletion shown, the deleted letter; with it
|
||||
/// left out, the letter past it. The same goes for hidden text.
|
||||
#[test]
|
||||
fn letters_join_past_text_left_out_of_the_layout() {
|
||||
if arabic_face().is_none() {
|
||||
eprintln!("skipped: no font with Persian letters installed");
|
||||
return;
|
||||
}
|
||||
let (three, two) = (letters(&lay(&doc_of(&[("ببب", true)]))), letters(&lay(&doc_of(&[("بب", true)]))));
|
||||
assert_eq!((three.len(), two.len()), (3, 2));
|
||||
let middle = [(BEH, 2 * BEH)];
|
||||
|
||||
let deleted = formatted("ببب", &middle, &|c| c.del = Some(0));
|
||||
assert_eq!(letters(&lay(&deleted)), three, "deletion shown: it joins both neighbours");
|
||||
let mut c = LayoutCache::new();
|
||||
let final_text = layout(&deleted, &mut c, &LayoutOptions { hide_deleted: true, ..LayoutOptions::default() });
|
||||
assert_eq!(letters(&final_text), two, "deletion left out: its neighbours join each other");
|
||||
|
||||
let hidden = formatted("ببب", &middle, &|c| c.hidden = Some(true));
|
||||
assert_eq!(letters(&lay(&hidden)), two, "hidden text left out: its neighbours join each other");
|
||||
let mut c = LayoutCache::new();
|
||||
let shown = layout(&hidden, &mut c, &LayoutOptions { show_hidden: true, ..LayoutOptions::default() });
|
||||
assert_eq!(letters(&shown), three, "hidden text shown: it joins both neighbours");
|
||||
}
|
||||
|
||||
/// A mark in a run of its own (a coloured vowel sign) doesn't stop the letters around it joining.
|
||||
#[test]
|
||||
fn letters_join_past_a_mark_in_a_run_of_its_own() {
|
||||
if arabic_face().is_none() {
|
||||
eprintln!("skipped: no font with Persian letters installed");
|
||||
return;
|
||||
}
|
||||
let word = "بَب";
|
||||
let whole = letters(&lay(&doc_of(&[(word, true)])));
|
||||
let split = letters(&lay(&formatted(word, &[(BEH, BEH + 'َ'.len_utf8())], &red)));
|
||||
assert_eq!(split.last(), whole.last(), "the letter after the mark stays joined");
|
||||
// Together with the mark, the letter before it has more glyphs, in the shaper's order.
|
||||
let (before, with_mark) = (split.first().unwrap(), whole.first().unwrap());
|
||||
assert!(before.iter().all(|g| with_mark.contains(g)), "and the letter before it: {before:?} {with_mark:?}");
|
||||
}
|
||||
|
||||
/// What can't join still doesn't: a tab between the letters, in one run or at the edge of a run, a
|
||||
/// Latin neighbour, and a letter beside a drop cap, which is drawn apart from the word.
|
||||
#[test]
|
||||
fn letters_stay_apart_where_nothing_joins_them() {
|
||||
if arabic_face().is_none() {
|
||||
eprintln!("skipped: no font with Persian letters installed");
|
||||
return;
|
||||
}
|
||||
let alone = letters(&lay(&doc_of(&[("ب", true)])));
|
||||
assert_eq!(alone.len(), 1);
|
||||
let beh = alone.first();
|
||||
let tabbed = "ب\tب";
|
||||
for d in [doc_of(&[(tabbed, true)]), formatted(tabbed, &[(0, BEH)], &red), formatted(tabbed, &[(BEH + 1, tabbed.len())], &red)] {
|
||||
let got = letters(&lay(&d));
|
||||
assert_eq!((got.first(), got.last()), (beh, beh), "across a tab: {got:?}");
|
||||
}
|
||||
let got = letters(&lay(&formatted("xب", &[(0, 1)], &red)));
|
||||
assert_eq!(got.last(), beh, "after a Latin letter: {got:?}");
|
||||
|
||||
// The drop cap in the word's run, and formatted on its own (a run of its own).
|
||||
let two = letters(&lay(&doc_of(&[("بب", true)])));
|
||||
for mut d in [doc_of(&[("ببب", true)]), formatted("ببب", &[(0, BEH)], &red)] {
|
||||
let at = Pos::body(0, 0);
|
||||
d.format_paragraphs(&at, &at, &|p: &mut ParaProps| p.drop_cap = Some(2)).unwrap();
|
||||
let runs = d.para_at(&at).unwrap().runs.len();
|
||||
let got = letters(&lay(&d));
|
||||
assert_eq!(got.get(got.len().saturating_sub(2)..), two.get(..), "{runs} run(s): the letters after a drop cap start a word: {got:?} {two:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_shaper_takes_a_letters_form_from_the_text_around_it() {
|
||||
use wordcraft_fonts::{ShapeContext, shape_run_in};
|
||||
let Some(face) = arabic_face() else {
|
||||
eprintln!("skipped: no font with Persian letters installed");
|
||||
return;
|
||||
};
|
||||
let form = |around| cluster_glyphs(&shape_run_in(&face, "ب", &[], |c| c, true, around));
|
||||
let isolated = form(ShapeContext::default());
|
||||
let joined = cluster_glyphs(&wordcraft_fonts::shape_run(&face, "ببب", &[], |c| c, true));
|
||||
assert_eq!(form(ShapeContext { before: "", after: "ب" }).first(), joined.first(), "initial");
|
||||
assert_eq!(form(ShapeContext { before: "ب", after: "ب" }).first(), joined.get(1), "medial");
|
||||
assert_eq!(form(ShapeContext { before: "ب", after: "" }).first(), joined.get(2), "final");
|
||||
// A vowel sign is transparent, and ZWNJ and a Latin letter stop the join.
|
||||
assert_eq!(form(ShapeContext { before: "بَ", after: "" }).first(), joined.get(2), "past a mark");
|
||||
assert_eq!(form(ShapeContext { before: "ب\u{200C}", after: "" }), isolated, "after ZWNJ");
|
||||
assert_eq!(form(ShapeContext { before: "a", after: "b" }), isolated, "between Latin letters");
|
||||
// Only the letter's form depends on the context: nothing of it is drawn.
|
||||
assert_eq!(form(ShapeContext { before: "ببب", after: "ببب" }).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scripts_that_join_their_letters_are_told_apart() {
|
||||
use wordcraft_fonts::joins_cursively;
|
||||
for c in ['ب', 'پ', 'ܐ', 'ᠠ', 'ߊ', '𞤀'] {
|
||||
assert!(joins_cursively(c), "{c}");
|
||||
}
|
||||
for c in ['a', 'é', '’', 'я', 'א', '中', ' ', '\t', '\u{FFFC}', '\u{200C}', 'َ'] {
|
||||
assert!(!joins_cursively(c), "{c:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -482,3 +482,25 @@ fn selecting_a_header_on_the_hinted_page_does_not_walk_every_page() {
|
||||
eprintln!("hinted header: {small} steps for 20 pages, {big} for 160");
|
||||
assert!(small > 0 && big <= small + 8, "the pages aren't walked: {small} → {big}");
|
||||
}
|
||||
|
||||
/// A paragraph of a decimal tab at 200 pt followed by `text`.
|
||||
fn after_decimal_tab(text: &str) -> Document {
|
||||
use wordcraft_doc::props::{TabAlign, TabStop};
|
||||
let mut d = Document::from_text(&format!("\t{text}"));
|
||||
let at = Pos::body(0, 0);
|
||||
d.format_paragraphs(&at, &at, &|p| p.tabs = Some(vec![TabStop { pos: 200.0, align: TabAlign::Decimal, leader: Default::default() }])).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// The text after a decimal tab was looked at again from the tab for its '.' after each of its
|
||||
/// clusters, so a long run of clusters without a '.' (soft hyphens take no room, so they stay on
|
||||
/// one line) took quadratic time.
|
||||
#[test]
|
||||
fn text_after_a_decimal_tab_is_looked_at_once() {
|
||||
let opts = LayoutOptions::default();
|
||||
// The text after the tab still ends at the stop, where it ended before.
|
||||
let l = lay(&after_decimal_tab("12.5"), &opts);
|
||||
let (Some(end), Some(start)) = (l.caret(&Pos::body(0, 5)), l.caret(&Pos::body(0, 0))) else { panic!("no carets") };
|
||||
assert!((end.x - start.x - 200.0).abs() < 1.0, "the text ends at the stop: {} from {}", end.x, start.x);
|
||||
assert_layout_linear("decimal tab", 2000, |n| after_decimal_tab(&format!("1{}", "\u{AD}".repeat(n))), &opts);
|
||||
}
|
||||
|
||||
@@ -626,3 +626,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");
|
||||
}
|
||||
|
||||
@@ -102,6 +102,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() {
|
||||
|
||||
@@ -8,7 +8,10 @@ pub(crate) fn metadata(doc: &Document, opts: &PdfOptions) -> Metadata {
|
||||
if !title.trim().is_empty() {
|
||||
m.title = Some(title);
|
||||
}
|
||||
let author = opts.author.clone().unwrap_or_else(|| c.creator.clone());
|
||||
// "Remove personal information from file properties on save" covers the PDF's author too:
|
||||
// the document's creator is left out (an author the caller names is still written).
|
||||
let kept = if doc.settings.remove_personal_info { String::new() } else { c.creator.clone() };
|
||||
let author = opts.author.clone().unwrap_or(kept);
|
||||
if !author.trim().is_empty() {
|
||||
m.authors = vec![author];
|
||||
}
|
||||
|
||||
@@ -2862,3 +2862,14 @@ fn baseline_is_the_most_shared_y() {
|
||||
assert_eq!(baseline(&[(1, 0.0, 9.0), (2, 3.0, 4.0)]), Some(4.0));
|
||||
assert_eq!(baseline(&[(1, 0.0, -0.0), (2, 3.0, 0.0)]), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_author_is_left_out_when_personal_information_is_removed() {
|
||||
let mut d = Document::from_text("Private");
|
||||
d.core.creator = "Ada Writer".into();
|
||||
let opts = PdfOptions { tagged: false, compress: false, ..Default::default() };
|
||||
let raw = |d: &Document| String::from_utf8_lossy(&export(d, &opts).unwrap()).to_string();
|
||||
assert!(raw(&d).contains("Ada Writer"));
|
||||
d.settings.remove_personal_info = true;
|
||||
assert!(!raw(&d).contains("Ada Writer"), "the creator stays out of the PDF");
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ impl WordApp {
|
||||
}
|
||||
self.font_generation = generation;
|
||||
self.session.relayout();
|
||||
self.previews.fonts_changed(ctx, db.user_fonts().iter().flat_map(|f| f.families()));
|
||||
self.previews.fonts_changed();
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -48,6 +48,7 @@ use geometry::{floating_comments, on_page_gap, on_visible_page, white_space_hidd
|
||||
use input::{EDGE_MARGIN, EDGE_MAX_DT, EDGE_SPEED_MAX};
|
||||
use input::{UnitDrag, clamped_scroll, edge_scroll, mouse, wheel_hovered};
|
||||
pub(crate) use input::{hold_document_selection, press_count};
|
||||
pub(crate) use links::follow_link;
|
||||
#[cfg(test)]
|
||||
use links::{field_link, is_followable_link, is_generated_bookmark_link};
|
||||
#[cfg(test)]
|
||||
@@ -90,6 +91,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.
|
||||
@@ -1415,9 +1434,14 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
let CanvasView { area, hruler, vruler, paper, hinted, 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);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::balloons::over_balloon;
|
||||
use super::links::{field_showing_code, is_followable_link, is_generated_bookmark_link, link_under};
|
||||
use super::links::{field_showing_code, follow_link, is_generated_bookmark_link, link_under};
|
||||
use super::navigation::page_at;
|
||||
use super::story::{enters_header_footer, in_text_box, part_kind, text_box_host};
|
||||
use super::{CanvasState, DocLayout, PartKind, Pos, Pos2, Rect, StoryRef, Ui, WordApp, json};
|
||||
@@ -110,7 +110,7 @@ pub(super) fn mouse(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[
|
||||
};
|
||||
let Some((page, x, y)) = page_at(rects, layout, scale, p) else { return };
|
||||
let story = app.session.sel.focus.story;
|
||||
handle_page_pointer(app, PagePointer { ui, resp, layout, p, page, x, y, story });
|
||||
handle_page_pointer(app, PagePointer { ui, resp, layout, rects, scale, p, page, x, y, story });
|
||||
}
|
||||
|
||||
fn route_pointer_targets(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect], layout: &DocLayout, scale: f32) -> Option<Pos2> {
|
||||
@@ -143,6 +143,8 @@ struct PagePointer<'a> {
|
||||
ui: &'a Ui,
|
||||
resp: &'a egui::Response,
|
||||
layout: &'a DocLayout,
|
||||
rects: &'a [Rect],
|
||||
scale: f32,
|
||||
p: Pos2,
|
||||
page: usize,
|
||||
x: f32,
|
||||
@@ -329,16 +331,7 @@ fn handle_primary_press(
|
||||
if mods.command
|
||||
&& let Some(link) = link_under(app, layout, page, x, y, &pos)
|
||||
{
|
||||
if let Some(name) = link.strip_prefix('#') {
|
||||
// The caret goes to the bookmark's start, as in Word (Go To selects its text).
|
||||
if app.run("edit.goto", json!({"bookmark": name})).is_ok() {
|
||||
let _ = app.run("select.collapse", json!({}));
|
||||
}
|
||||
} else if is_followable_link(&link) {
|
||||
app.canvas.open_url = Some(link);
|
||||
} else {
|
||||
app.status(tl!("Only web (http, https) and email (mailto) links can be followed"));
|
||||
}
|
||||
follow_link(app, link);
|
||||
return;
|
||||
}
|
||||
let pj = serde_json::to_value(&pos).unwrap_or_default();
|
||||
@@ -479,16 +472,17 @@ fn skip_equation_or_outline(app: &mut WordApp, pointer: &PagePointer<'_>) -> boo
|
||||
}
|
||||
|
||||
fn show_hover_link(app: &WordApp, pointer: &PagePointer<'_>) {
|
||||
let PagePointer { ui, resp, layout, page, x, y, story, .. } = pointer;
|
||||
let PagePointer { ui, resp, layout, rects, scale, p, page, x, y, story } = pointer;
|
||||
// Hover a link: show its target, unless the context menu is opening or open: a tooltip draws
|
||||
// above the menu and would cover its first items (#3368).
|
||||
// above the menu and would cover its first items (#3368). A linked picture or shape under the
|
||||
// pointer comes first: it lies over the text (#5267).
|
||||
if resp.hovered()
|
||||
&& !app.canvas.dragging
|
||||
&& !app.canvas.context_menu_open
|
||||
&& !resp.secondary_clicked()
|
||||
&& !ui.input(|i| i.pointer.secondary_down())
|
||||
&& let Some(pos) = layout.hit(*page, *x, *y, *story)
|
||||
&& let Some(link) = link_under(app, layout, *page, *x, *y, &pos)
|
||||
&& let Some(link) = crate::objects::link_at(app, layout, rects, *scale, *p)
|
||||
.or_else(|| layout.hit(*page, *x, *y, *story).and_then(|pos| link_under(app, layout, *page, *x, *y, &pos)))
|
||||
{
|
||||
let key = if cfg!(target_os = "macos") { "⌘" } else { "Ctrl" };
|
||||
let target = if is_generated_bookmark_link(&link) { tl!("Current Document").to_string() } else { link };
|
||||
|
||||
@@ -43,6 +43,21 @@ pub(super) fn is_generated_bookmark_link(link: &str) -> bool {
|
||||
link.strip_prefix("#_").is_some()
|
||||
}
|
||||
|
||||
/// Ctrl/⌘+click on `link` (in the text or on a picture): a place in this document takes the caret
|
||||
/// to its bookmark; a web or email address opens in the browser; any other is not followed.
|
||||
pub(crate) fn follow_link(app: &mut WordApp, link: String) {
|
||||
if let Some(name) = link.strip_prefix('#') {
|
||||
// The caret goes to the bookmark's start, as in Word (Go To selects its text).
|
||||
if app.run("edit.goto", serde_json::json!({"bookmark": name})).is_ok() {
|
||||
let _ = app.run("select.collapse", serde_json::json!({}));
|
||||
}
|
||||
} else if is_followable_link(&link) {
|
||||
app.canvas.open_url = Some(link);
|
||||
} else {
|
||||
app.status(tl!("Only web (http, https) and email (mailto) links can be followed"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether Ctrl/⌘+click may hand a document's hyperlink to the system: web and email links only,
|
||||
/// so a document can't launch `file:` paths, programs or custom-scheme handlers.
|
||||
pub(super) fn is_followable_link(url: &str) -> bool {
|
||||
|
||||
@@ -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]
|
||||
@@ -5011,3 +5091,116 @@ fn a_pictures_corrections_and_crop_change_its_page_key() {
|
||||
assert_ne!(plain, pages::page_content_key(&at([0.1, 0.0, 0.0, 0.0], PicFx::default())), "crop");
|
||||
assert_ne!(plain, pages::page_content_key(&at([0.0; 4], PicFx::new(10_000, 0, 0))), "brightness");
|
||||
}
|
||||
|
||||
/// #5267: a picture linked from its context menu (Link…) keeps the link, shows it on hover and
|
||||
/// follows it on Ctrl/⌘+click, in line with the text and floating over it.
|
||||
#[test]
|
||||
fn a_picture_linked_from_its_context_menu_shows_and_follows_its_link() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
for floating in [false, true] {
|
||||
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default());
|
||||
a.run("text.insert", json!({"text": "Text before the picture "})).unwrap();
|
||||
a.run("select.text", json!({"text": "before"})).unwrap();
|
||||
a.run("insert.bookmark", json!({"name": "Spot"})).unwrap();
|
||||
a.run("caret.docEnd", json!({})).unwrap();
|
||||
let mut png = Vec::new();
|
||||
let img = image::RgbaImage::from_pixel(40, 20, image::Rgba([200, 30, 30, 255]));
|
||||
image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
a.run("insert.picture", json!({"data": wordcraft_engine::cmd::insert::base64_encode(&png), "width": 160})).unwrap();
|
||||
if floating {
|
||||
a.run("arrange.wrap", json!({"wrap": "inFrontOfText"})).unwrap();
|
||||
}
|
||||
let pos = wordcraft_engine::cmd::objects::selected(&a.session).unwrap().0;
|
||||
a.session.sel = wordcraft_engine::Selection::caret(Pos::body(0, 0));
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(
|
||||
|ui, app: &mut WordApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
a,
|
||||
);
|
||||
steps(&mut h, 6);
|
||||
let rect = h.state_mut().session.layout().object(&pos, 0).expect("the picture is laid out").rect;
|
||||
let at = crate::frame::to_screen(h.state().canvas.page_rects[0], h.state().canvas.scale, rect).center();
|
||||
h.event(egui::Event::PointerMoved(at));
|
||||
for pressed in [true, false] {
|
||||
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Secondary, pressed, modifiers: Default::default() });
|
||||
h.step();
|
||||
}
|
||||
h.step();
|
||||
h.get_by_label_contains("Link…").click();
|
||||
steps(&mut h, 4);
|
||||
h.event(egui::Event::Text("https://example.com/picture".into()));
|
||||
steps(&mut h, 3);
|
||||
h.get_by_label_contains("OK").click();
|
||||
steps(&mut h, 4);
|
||||
let s = &h.state().session;
|
||||
let link = s.doc.para_at(&pos).map(|p| p.props_of_char(pos.off).link.clone());
|
||||
assert_eq!(link, Some(Some("https://example.com/picture".to_string())), "floating {floating}: the link is on the picture");
|
||||
// Hovering the picture shows where it goes.
|
||||
h.event(egui::Event::PointerMoved(at + egui::vec2(1.0, 0.0)));
|
||||
steps(&mut h, 3);
|
||||
assert!(h.query_by_label_contains("https://example.com/picture").is_some(), "floating {floating}: the hover tip names the link");
|
||||
// Ctrl/⌘+click follows it, instead of picking the picture for a copy: here a link to a
|
||||
// place in the document, which moves the caret (a web address goes to the browser).
|
||||
h.state_mut().run("insert.link", json!({"bookmark": "Spot"})).unwrap();
|
||||
steps(&mut h, 2);
|
||||
let press = egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::COMMAND };
|
||||
h.event_modifiers(press, egui::Modifiers::COMMAND);
|
||||
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
|
||||
steps(&mut h, 3);
|
||||
let s = &h.state().session;
|
||||
let (start, _) = wordcraft_engine::cmd::insert::bookmark_range(&s.doc, "Spot").unwrap();
|
||||
assert!(s.sel.is_collapsed() && s.sel.focus == start, "floating {floating}: the caret went to the bookmark: {:?}", s.sel);
|
||||
assert!(h.state().canvas.obj_drag.is_none(), "floating {floating}: no copy-drag started");
|
||||
}
|
||||
}
|
||||
|
||||
/// #5267: inside a linked text box, hovering shows the box's link and Ctrl/⌘+click follows it,
|
||||
/// although a click there reaches the box's text rather than the box.
|
||||
#[test]
|
||||
fn a_linked_text_box_shows_and_follows_its_link_from_inside() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default());
|
||||
a.run("text.insert", json!({"text": "Text before the box "})).unwrap();
|
||||
a.run("select.text", json!({"text": "before"})).unwrap();
|
||||
a.run("insert.bookmark", json!({"name": "Spot"})).unwrap();
|
||||
a.run("caret.docEnd", json!({})).unwrap();
|
||||
a.run("insert.textBox", json!({"text": "Boxed words"})).unwrap();
|
||||
let link_box = |a: &mut WordApp, link: serde_json::Value| {
|
||||
let bx = a.session.layout().find_object(0, |o| o.text_box.is_some()).expect("the box");
|
||||
let end = Pos { off: bx.off + wordcraft_doc::para::OBJ.len_utf8(), ..bx.pos() };
|
||||
a.run("select.range", json!({"anchor": bx.pos(), "focus": end})).unwrap();
|
||||
a.run("insert.link", link).unwrap();
|
||||
a.session.sel = wordcraft_engine::Selection::caret(Pos::body(0, 0));
|
||||
bx.pos()
|
||||
};
|
||||
let pos = link_box(&mut a, json!({"url": "https://example.com/box"}));
|
||||
let link = a.session.doc.para_at(&pos).map(|p| p.props_of_char(pos.off).link.clone());
|
||||
assert_eq!(link, Some(Some("https://example.com/box".to_string())), "the link is on the text box");
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(
|
||||
|ui, app: &mut WordApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
a,
|
||||
);
|
||||
steps(&mut h, 6);
|
||||
let rect = h.state_mut().session.layout().find_object(0, |o| o.text_box.is_some()).expect("the box is laid out").rect;
|
||||
let at = crate::frame::to_screen(h.state().canvas.page_rects[0], h.state().canvas.scale, rect).center();
|
||||
h.event(egui::Event::PointerMoved(at));
|
||||
steps(&mut h, 3);
|
||||
assert!(h.query_by_label_contains("https://example.com/box").is_some(), "the hover tip names the box's link");
|
||||
// Ctrl/⌘+click inside the box follows it: here a place in the document, which moves the caret.
|
||||
link_box(h.state_mut(), json!({"bookmark": "Spot"}));
|
||||
steps(&mut h, 2);
|
||||
let press = egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::COMMAND };
|
||||
h.event_modifiers(press, egui::Modifiers::COMMAND);
|
||||
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
|
||||
steps(&mut h, 3);
|
||||
let s = &h.state().session;
|
||||
let (start, _) = wordcraft_engine::cmd::insert::bookmark_range(&s.doc, "Spot").unwrap();
|
||||
assert!(s.sel.is_collapsed() && s.sel.focus == start, "the caret went to the bookmark: {:?}", s.sel);
|
||||
}
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
@@ -534,7 +534,12 @@ impl BordersForm {
|
||||
}
|
||||
if self.fill != b.fill || self.fill_to_text != b.fill_to_text {
|
||||
let color = Rgb::parse(&self.fill).map(|c| json!(c.hex())).unwrap_or(Value::Null);
|
||||
out.push((if self.fill_to_text { "format.shading" } else { "para.shading" }, json!({"color": color})));
|
||||
// Apply to: Paragraph shades the paragraphs even for text selected inside one.
|
||||
out.push(if self.fill_to_text {
|
||||
("format.shading", json!({"color": color}))
|
||||
} else {
|
||||
("para.shading", json!({"color": color, "paragraph": true}))
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -581,8 +581,20 @@ fn insert_table_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
}
|
||||
|
||||
/// A margin's number box, in the unit the interface measures in.
|
||||
fn margin(value: &mut f32) -> egui::DragValue<'_> {
|
||||
egui::DragValue::new(value).speed(3.6 / crate::options::unit().pt_per_unit()).measure(crate::options::unit()).max_decimals(2)
|
||||
fn margin(value: &mut f32) -> impl egui::Widget + '_ {
|
||||
move |ui: &mut Ui| {
|
||||
// DragValue's unfocused button extends to fit its value; keep it inside the field too.
|
||||
let (_, rect) = ui.allocate_space(egui::vec2(80.0, ui.spacing().interact_size.y));
|
||||
let mut field = ui.new_child(egui::UiBuilder::new().max_rect(rect).layout(crate::rtl::row_layout(ui, egui::Align::Center)));
|
||||
field.shrink_clip_rect(rect);
|
||||
field.add(
|
||||
egui::DragValue::new(value)
|
||||
.speed(3.6 / crate::options::unit().pt_per_unit())
|
||||
.measure(crate::options::unit())
|
||||
.max_decimals(2)
|
||||
.clip_text(true),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn page_setup_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
@@ -640,9 +652,9 @@ fn page_setup_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
crate::rtl::Table::new("ps_grid").columns(4).show(ui, |t| {
|
||||
t.row(|r| {
|
||||
let chars_label = r.cell(|ui| ui.add_enabled(grid.0 >= 2, egui::Label::new(tl!("Characters per line:"))));
|
||||
r.cell(|ui| ui.add_enabled(grid.0 >= 2, egui::DragValue::new(&mut grid.2).range(1..=1000)).labelled_by(chars_label.id));
|
||||
r.cell(|ui| ui.add_enabled(grid.0 >= 2, egui::DragValue::new(&mut grid.2).range(1..=1000).clip_text(true)).labelled_by(chars_label.id));
|
||||
let lines_label = r.cell(|ui| ui.add_enabled(grid.0 >= 1, egui::Label::new(tl!("Lines per page:"))));
|
||||
r.cell(|ui| ui.add_enabled(grid.0 >= 1, egui::DragValue::new(&mut grid.1).range(1..=1000)).labelled_by(lines_label.id));
|
||||
r.cell(|ui| ui.add_enabled(grid.0 >= 1, egui::DragValue::new(&mut grid.1).range(1..=1000).clip_text(true)).labelled_by(lines_label.id));
|
||||
});
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
@@ -1055,6 +1067,15 @@ fn commands_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
if !r.has_focus() {
|
||||
r.request_focus();
|
||||
}
|
||||
let enter = ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||||
search_results(app, ui, query, enter, 380.0) || ui.input(|i| i.key_pressed(egui::Key::Escape))
|
||||
}
|
||||
|
||||
/// Search's results under its box (the Search Commands dialog, or the title bar's drop-down,
|
||||
/// #5398): suggestions for an empty box, else the matching commands and "Find … in the
|
||||
/// document". A click, or `enter` (the first command, else the find), does it: `true` when one
|
||||
/// was done, so the search closes.
|
||||
pub(crate) fn search_results(app: &mut WordApp, ui: &mut Ui, query: &str, enter: bool, width: f32) -> bool {
|
||||
let reg = app.session.registry.clone();
|
||||
// An empty box suggests a few common commands, as Word's Search does.
|
||||
let mut hits: Vec<&wordcraft_engine::CommandSpec> = if query.trim().is_empty() {
|
||||
@@ -1064,12 +1085,11 @@ fn commands_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
reg.all().iter().filter(|c| !NOT_IN_SEARCH.contains(&c.id) && command_matches(c, query)).collect()
|
||||
};
|
||||
hits.truncate(14);
|
||||
let enter = ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||||
let mut run: Option<&str> = enter.then(|| hits.first().map(|c| c.id)).flatten();
|
||||
for c in &hits {
|
||||
let sc = crate::widgets::shortcut_text(app, c.id);
|
||||
let label = format!("{} — {}", tl!(c.label), crate::i18n::location(c.location));
|
||||
if ui.add(egui::Button::new(label).shortcut_text(sc).min_size(vec2(380.0, 0.0))).clicked() {
|
||||
if ui.add(egui::Button::new(label).shortcut_text(sc).min_size(vec2(width, 0.0))).clicked() {
|
||||
run = Some(c.id);
|
||||
}
|
||||
}
|
||||
@@ -1079,14 +1099,14 @@ fn commands_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
if !text.is_empty() {
|
||||
ui.separator();
|
||||
let label = crate::i18n::fmt(tl!("Find “{text}” in the document"), &[("text", &text)]);
|
||||
find = ui.add(egui::Button::new(label).min_size(vec2(380.0, 0.0))).clicked() || (enter && hits.is_empty());
|
||||
find = ui.add(egui::Button::new(label).min_size(vec2(width, 0.0))).clicked() || (enter && hits.is_empty());
|
||||
}
|
||||
if let Some(id) = run {
|
||||
let _ = app.run(id, json!({}));
|
||||
} else if find {
|
||||
find_in_navigation_pane(app, ui.ctx(), &text);
|
||||
}
|
||||
run.is_some() || find || ui.input(|i| i.key_pressed(egui::Key::Escape))
|
||||
run.is_some() || find
|
||||
}
|
||||
|
||||
fn paste_special_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
@@ -1270,7 +1290,7 @@ fn insert_merge_field_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) ->
|
||||
}
|
||||
|
||||
fn password_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
let Dialog::Password { name, params, password, message } = d else {
|
||||
let Dialog::Password { name, then, params, password, message } = d else {
|
||||
return false;
|
||||
};
|
||||
ui.label(crate::i18n::fmt(tl!("{name} is protected with a password."), &[("name", name)]));
|
||||
@@ -1291,8 +1311,9 @@ fn password_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
if let Some(o) = p.as_object_mut() {
|
||||
o.insert("password".into(), json!(password.as_str()));
|
||||
}
|
||||
// `execute`, not `run`: Save Changes was asked when the open began.
|
||||
match app.execute("file.open", p) {
|
||||
// `execute`, not `run`: Save Changes was asked when the open began. The command that
|
||||
// asked runs again, so a protected template still starts a new document (#3433).
|
||||
match app.execute(then, p) {
|
||||
Ok(_) => {
|
||||
app.ui.backstage = false;
|
||||
return true;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user