mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-08 11:19:54 -06:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b0d399599 | ||
|
|
fda8758ec9 | ||
|
|
c5fe6c6816 | ||
|
|
438f82977a | ||
|
|
72a7afd9b5 | ||
|
|
36ed94a09e | ||
|
|
0655e651fb | ||
|
|
0e3f4dd315 | ||
|
|
e01a88b0be | ||
|
|
ae257ba784 | ||
|
|
0495fbb789 | ||
|
|
e7e9bc2759 | ||
|
|
be75ee90e3 | ||
|
|
480511eaa5 | ||
|
|
aa262cf7d8 | ||
|
|
2d97c8443e | ||
|
|
d5d2b22dac | ||
|
|
2794ae44e9 | ||
|
|
5125de6f1f | ||
|
|
59030b1bb5 | ||
|
|
4050f1bf8b | ||
|
|
02e74a5d9c | ||
|
|
d5a1c0e181 | ||
|
|
69d2a2e131 | ||
|
|
661a790540 | ||
|
|
04217419b6 | ||
|
|
4afff61f10 | ||
|
|
e76293a564 | ||
|
|
e0d8312fbe | ||
|
|
523e319424 | ||
|
|
735c2a5382 | ||
|
|
7592d1440e | ||
|
|
fde39ab061 | ||
|
|
d34e341d75 | ||
|
|
535b675e8a | ||
|
|
c8618d3e05 | ||
|
|
a891d861b7 | ||
|
|
85434d2c20 | ||
|
|
833c0154aa | ||
|
|
0a3fb5828e | ||
|
|
7634553900 | ||
|
|
cd4f755dcf | ||
|
|
c4d5f70c1d | ||
|
|
de4ca29309 | ||
|
|
b8a7e2acdb | ||
|
|
f0fb31c0c9 | ||
|
|
4cd6ffa564 | ||
|
|
52934ef110 | ||
|
|
3291be0fbf | ||
|
|
5dfb3acf15 | ||
|
|
4259dd1b2e | ||
|
|
e344bb4243 | ||
|
|
4a507de3d6 | ||
|
|
d07f50dd8b | ||
|
|
87e08051bf | ||
|
|
5c0a62a736 | ||
|
|
855a0fe9bd | ||
|
|
5f1db14954 | ||
|
|
b016ecc26c | ||
|
|
625f598b64 | ||
|
|
fa791d29b2 | ||
|
|
796b39dde6 | ||
|
|
33fa77a892 | ||
|
|
efd476cbc7 | ||
|
|
d3fcf10bcc | ||
|
|
b9936609dc | ||
|
|
7152143e45 | ||
|
|
c40719f431 | ||
|
|
6d0bd73127 | ||
|
|
cd81d5da49 | ||
|
|
b7c2fe8dff | ||
|
|
586f85df96 | ||
|
|
6367400215 | ||
|
|
9655b2da50 | ||
|
|
66e169c343 | ||
|
|
1c6c7c732e | ||
|
|
bd8bc96cb5 | ||
|
|
7830333c34 | ||
|
|
e349439bb6 | ||
|
|
4c8c1f1268 | ||
|
|
caeb87c754 | ||
|
|
0f5a701171 | ||
|
|
677e610886 | ||
|
|
d2a2709e14 | ||
|
|
050b6b0da3 | ||
|
|
6b163b33dc | ||
|
|
4ba444a342 | ||
|
|
4821a13ee0 | ||
|
|
dba2b3b5e9 | ||
|
|
beab4e8e9c | ||
|
|
852e67d587 | ||
|
|
9b6e9564a2 | ||
|
|
c9090ba95a | ||
|
|
c606533853 | ||
|
|
eb100edfd6 | ||
|
|
c38dc17ba9 | ||
|
|
5997aef2f9 | ||
|
|
73356ed82a | ||
|
|
6d3b51fab1 | ||
|
|
750e26657e | ||
|
|
f7e060ac3f | ||
|
|
b61aee61ba | ||
|
|
bc0f691c13 | ||
|
|
cac121fd89 | ||
|
|
ea8c45a4ca | ||
|
|
4774749a05 | ||
|
|
20a10a5f33 | ||
|
|
e53cae3482 | ||
|
|
7855a2d814 | ||
|
|
871401509c | ||
|
|
4ce92e8afd | ||
|
|
746577406c | ||
|
|
0131d5c3aa | ||
|
|
fa59ed0e8a | ||
|
|
b29f3a8a2e | ||
|
|
e7668ae53e | ||
|
|
7aea5f8cab | ||
|
|
3b6547de8f | ||
|
|
e0dd821d4c | ||
|
|
e4310ec5c7 | ||
|
|
7c3a5ccd34 | ||
|
|
ecbabc327c | ||
|
|
34b4caf317 | ||
|
|
8ab285e61d | ||
|
|
490437fa87 | ||
|
|
fa153b932c | ||
|
|
4925e06391 | ||
|
|
5c2e5ce0d0 | ||
|
|
274d40fc4d | ||
|
|
570c39e287 | ||
|
|
595cddaaef | ||
|
|
f1b5f0b4de | ||
|
|
ddb1d37de3 | ||
|
|
d98a335ae8 | ||
|
|
bbc6d94dbe | ||
|
|
d0498254a2 | ||
|
|
9fd9ce54b7 | ||
|
|
d94bbc8fe0 | ||
|
|
da914ce674 | ||
|
|
18a26b41ce | ||
|
|
9f9190f0d7 | ||
|
|
1c7c4e66e0 | ||
|
|
2d6da7a326 | ||
|
|
30a439899b | ||
|
|
875a62043a | ||
|
|
7ca1dd1802 | ||
|
|
729cd0a5fc | ||
|
|
a5b1518a10 | ||
|
|
4e334166e5 | ||
|
|
8df01e481f | ||
|
|
4ac7292d62 | ||
|
|
67c9b8ac31 | ||
|
|
401e7c9169 | ||
|
|
3daa6b1e78 | ||
|
|
e4ba85346d | ||
|
|
ebdca4feb8 | ||
|
|
44eb54eef9 | ||
|
|
75a905802c | ||
|
|
2c17b04f9a | ||
|
|
951b255b80 | ||
|
|
37a627c61e | ||
|
|
0ff06b9d86 | ||
|
|
d5495ea4f8 | ||
|
|
afc01f8328 | ||
|
|
9e391e97f4 | ||
|
|
2113f52db4 | ||
|
|
098cf86dc8 | ||
|
|
e99e28aebd | ||
|
|
4abae255e0 | ||
|
|
0b6e052d73 | ||
|
|
45cfc55116 | ||
|
|
a3b50342f9 | ||
|
|
ce4c401c4a | ||
|
|
b8ae10e453 | ||
|
|
f4f9d28d99 | ||
|
|
e17ad54b15 | ||
|
|
9e2f6ad330 | ||
|
|
6f125461dd | ||
|
|
31a37bcf00 | ||
|
|
2025c48e9b | ||
|
|
83b319e7c3 | ||
|
|
ca1f584ae0 | ||
|
|
7378ac5063 | ||
|
|
7e967d4a3a | ||
|
|
f5038a12e3 | ||
|
|
1bbced51a4 | ||
|
|
9de2227af2 | ||
|
|
7d0ba2be63 | ||
|
|
f69b003fe1 | ||
|
|
c9046233a0 | ||
|
|
0e7dc8da53 | ||
|
|
837d2b0748 | ||
|
|
220bcf947a | ||
|
|
7d27d8299a | ||
|
|
71dba44a0a | ||
|
|
3a22b72132 | ||
|
|
f342b6ff1f | ||
|
|
95669b9eb9 | ||
|
|
88ac0d7174 | ||
|
|
3b20ea46de | ||
|
|
4524bc6ba9 | ||
|
|
49a3cd223b | ||
|
|
061d40d5eb | ||
|
|
05a37f6fe2 | ||
|
|
d95cb46da5 | ||
|
|
8c44eb1b98 | ||
|
|
933bccf052 | ||
|
|
9aa3d93ecf | ||
|
|
bffb6e6705 | ||
|
|
a449c59232 | ||
|
|
a82d509793 | ||
|
|
4bfd84514c | ||
|
|
c40d5159b7 | ||
|
|
841dd538dc | ||
|
|
364495deb5 | ||
|
|
c85d454e09 | ||
|
|
1c1ee15cff | ||
|
|
1a950a3631 | ||
|
|
0c859e334d | ||
|
|
f4ff1f842d | ||
|
|
d8899ed345 | ||
|
|
c10df4e7bc | ||
|
|
2a82cc4a22 | ||
|
|
5ce611091e | ||
|
|
878e5e3457 | ||
|
|
3cfa9ca721 | ||
|
|
ff4377bf7e | ||
|
|
71aa12c076 | ||
|
|
cfa99b45ce | ||
|
|
e11943f818 | ||
|
|
33070c575c | ||
|
|
86a1b000ef | ||
|
|
b6d89388c8 | ||
|
|
914e1d5872 | ||
|
|
ba62c2255e | ||
|
|
0ea610d0ff | ||
|
|
a4e9b07701 | ||
|
|
2fb626e548 | ||
|
|
827fc6e07a | ||
|
|
e2b92cde84 | ||
|
|
ed3c3ea038 | ||
|
|
4940d07dad | ||
|
|
dc2b990602 | ||
|
|
92683b6796 | ||
|
|
d858066cf7 | ||
|
|
006b6480ed | ||
|
|
402ee4893c | ||
|
|
21bba8e7de | ||
|
|
d5de454641 | ||
|
|
11fce726e3 | ||
|
|
ed97087907 | ||
|
|
2b2398ae55 | ||
|
|
d7c7889c82 | ||
|
|
0b1704677d | ||
|
|
ec1cb78a20 | ||
|
|
8b06dd8666 | ||
|
|
28b5cba00c | ||
|
|
14526d0d81 | ||
|
|
97eea6e12f | ||
|
|
8b679452eb | ||
|
|
c488558a1c | ||
|
|
addf03751d | ||
|
|
8578d99ccd | ||
|
|
77bfbd6f11 | ||
|
|
38253611d2 | ||
|
|
876ce9ab48 | ||
|
|
0a23c4febe | ||
|
|
65f36f9b8a | ||
|
|
f50a5cc5e9 | ||
|
|
12582bb529 | ||
|
|
badd0c5ef9 | ||
|
|
2f46f194a1 | ||
|
|
6bc7810c8c | ||
|
|
f9c6ddba3a | ||
|
|
00d4e7a291 | ||
|
|
bfbb3d05a4 | ||
|
|
9b6d326730 | ||
|
|
212df61bc7 | ||
|
|
0abe06e236 | ||
|
|
843bf26ae8 | ||
|
|
44055678c5 | ||
|
|
9ea1d93a8d | ||
|
|
ab3ad718a0 | ||
|
|
ad7eea67c1 | ||
|
|
be492ddc98 | ||
|
|
e8c852d490 | ||
|
|
7a3c3b40af | ||
|
|
5288fed0ea | ||
|
|
1e306ed5ab | ||
|
|
9e25e978e5 | ||
|
|
3248047b76 | ||
|
|
5edcd6765b | ||
|
|
80bf6ed96b | ||
|
|
665af07277 | ||
|
|
2e7852fa4d | ||
|
|
3ee5840d6d | ||
|
|
ee649d03da | ||
|
|
19094d4f3a | ||
|
|
fb3d600428 | ||
|
|
7b1c0402b0 | ||
|
|
365b8c003e | ||
|
|
1bec25c02d | ||
|
|
c3703ebc73 | ||
|
|
ede933dcc2 | ||
|
|
d955ff2874 | ||
|
|
bd320e65a5 | ||
|
|
3445b7226d | ||
|
|
f2d7a4fe7d | ||
|
|
25e4986998 | ||
|
|
a912aacf73 | ||
|
|
256d5d084d | ||
|
|
f0c1b64bc8 | ||
|
|
0164ac86fa | ||
|
|
12a1316f10 |
@@ -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 }}
|
||||
|
||||
Generated
+13
-1
@@ -1158,10 +1158,21 @@ dependencies = [
|
||||
"zip 9.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-web-api"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"serde_json",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-web-host"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=2e2312ddaf21bc3d85cd3395d0955c7d239306f6#2e2312ddaf21bc3d85cd3395d0955c7d239306f6"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505"
|
||||
dependencies = [
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
@@ -7098,6 +7109,7 @@ dependencies = [
|
||||
name = "wordcraft-web"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"craft-web-api",
|
||||
"craft-web-host",
|
||||
"eframe",
|
||||
"egui",
|
||||
|
||||
+131
-4
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");
|
||||
|
||||
@@ -17,7 +17,9 @@ path = "src/main.rs"
|
||||
wordcraft-engine = { workspace = true }
|
||||
wordcraft-ui-egui = { workspace = true }
|
||||
# The host messages (storytold/craft-libs): one contract and one bridge for the web builds.
|
||||
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "2e2312ddaf21bc3d85cd3395d0955c7d239306f6", version = "0.1.0" }
|
||||
# The page command API (storytold/craft-libs#413): window.wordcraft over the control channel.
|
||||
craft-web-api = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" }
|
||||
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" }
|
||||
egui = { workspace = true }
|
||||
log = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
//! `window.wordcraft`: the page command API (`craft-web-api`, storytold/craft-libs#413) over the
|
||||
//! app's own control channel, the one the desktop control server drives (`docs/control-protocol.md`).
|
||||
//!
|
||||
//! The crate owns the JavaScript side (`request`, `execute`, `commands`, `docInspect`, `uiInspect`,
|
||||
//! `screenshot`, as promises with a timeout). This module is WordCraft's part: a [`Request`] that
|
||||
//! queues a [`ControlRequest`] for the app, which answers it on its next frame, and waits for the
|
||||
//! reply. WordCraft's control request has no abandon state, so a call the page gave up on still runs
|
||||
//! when the app reaches it, as it does for a desktop client that timed out.
|
||||
|
||||
use std::rc::Rc;
|
||||
use std::sync::mpsc::{Receiver, TryRecvError, channel};
|
||||
|
||||
use craft_web_api::methods::Methods;
|
||||
use craft_web_api::page::{PageApi, Request};
|
||||
use serde_json::{Value, json};
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
use wordcraft_ui_egui::ControlRequest;
|
||||
|
||||
/// The app id in `window.<app>`.
|
||||
const APP: &str = "wordcraft";
|
||||
|
||||
/// How often a waiting call looks for the app's reply, in milliseconds (about a frame).
|
||||
const POLL_MS: i32 = 16;
|
||||
|
||||
/// Put `window.wordcraft` on the page and return the queue the app drains each frame (hand it to
|
||||
/// `WordApp::with_control`). An existing `window.wordcraft` keeps the functions it already has.
|
||||
pub fn install(ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
let (queue, requests) = channel::<ControlRequest>();
|
||||
let request: Request = Rc::new(move |method, params| {
|
||||
let (control, reply) = ControlRequest::new(method, params);
|
||||
if queue.send(control).is_err() {
|
||||
return Box::pin(async { json!({"ok": false, "error": "the app is not running"}) });
|
||||
}
|
||||
ctx.request_repaint();
|
||||
Box::pin(wait(reply))
|
||||
});
|
||||
if let Err(e) = Rc::new(PageApi::new(APP, Methods::default(), request)).install() {
|
||||
log::warn!("couldn't install window.{APP}: {e:?}");
|
||||
}
|
||||
requests
|
||||
}
|
||||
|
||||
/// The app's reply, polled about once a frame.
|
||||
async fn wait(reply: Receiver<Value>) -> Value {
|
||||
loop {
|
||||
match reply.try_recv() {
|
||||
Ok(reply) => return reply,
|
||||
Err(TryRecvError::Empty) => sleep(POLL_MS).await,
|
||||
Err(TryRecvError::Disconnected) => return json!({"ok": false, "error": "the app dropped the request"}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn sleep(ms: i32) {
|
||||
let promise = js_sys::Promise::new(&mut |resolve, _| {
|
||||
if let Some(window) = web_sys::window() {
|
||||
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, ms);
|
||||
}
|
||||
});
|
||||
let _ = JsFuture::from(promise).await;
|
||||
}
|
||||
@@ -20,6 +20,8 @@ mod prefs;
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
mod save_fallback;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod api;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod host;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
|
||||
@@ -76,7 +76,8 @@ pub fn start() {
|
||||
if let Some(bridge) = &app_bridge {
|
||||
bridge.connect(&mut services, inbox.clone(), cc.egui_ctx.clone());
|
||||
}
|
||||
let mut app = WordApp::new(session, services);
|
||||
let control = crate::api::install(cc.egui_ctx.clone());
|
||||
let mut app = WordApp::new(session, services).with_control(control);
|
||||
// Pasted pictures wait apart from dropped files: the app inserts one only when the page is the edit target (#1349).
|
||||
app.services.pasted = Some(pasted);
|
||||
app.autosave = false;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -9,6 +9,7 @@ use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
@@ -21,6 +22,38 @@ const MAX_LINE: usize = 1 << 20;
|
||||
/// A connection that sends no authorised request in this time is closed.
|
||||
const FIRST_REQUEST: Duration = Duration::from_secs(30);
|
||||
|
||||
/// How long a caller waits for the UI to answer a request. A request still queued after this
|
||||
/// is cancelled and never runs.
|
||||
const REPLY_WAIT: Duration = Duration::from_secs(60);
|
||||
|
||||
/// Connections that have not yet sent an authorised request are limited to this many; more are
|
||||
/// closed at once. Each one holds a thread until its first request or [`FIRST_REQUEST`].
|
||||
const MAX_UNAUTHENTICATED: usize = 64;
|
||||
|
||||
/// Counts one unauthenticated connection until it authenticates or ends.
|
||||
struct Admission(Option<Arc<AtomicUsize>>);
|
||||
|
||||
impl Admission {
|
||||
fn enter(count: &Arc<AtomicUsize>, max: usize) -> Option<Self> {
|
||||
if count.fetch_add(1, Ordering::SeqCst) >= max {
|
||||
count.fetch_sub(1, Ordering::SeqCst);
|
||||
return None;
|
||||
}
|
||||
Some(Self(Some(count.clone())))
|
||||
}
|
||||
fn release(&mut self) {
|
||||
if let Some(c) = self.0.take() {
|
||||
c.fetch_sub(1, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Admission {
|
||||
fn drop(&mut self) {
|
||||
self.release();
|
||||
}
|
||||
}
|
||||
|
||||
/// The key file of a running control server; [`KeyFile::remove`] it when the window closes.
|
||||
pub struct KeyFile(PathBuf);
|
||||
|
||||
@@ -37,6 +70,15 @@ impl KeyFile {
|
||||
/// cannot be bound or there is no random key (the control channel is off); the key file is
|
||||
/// `None` when it could not be written (requests are then refused).
|
||||
pub fn start(port: u16, ctx: egui::Context, settings: Option<&Path>) -> Option<(Receiver<ControlRequest>, Option<KeyFile>)> {
|
||||
start_with(port, ctx, settings, |listener, ctx, key| serve_on(listener, ctx, key, FIRST_REQUEST))
|
||||
}
|
||||
|
||||
fn start_with(
|
||||
port: u16,
|
||||
ctx: egui::Context,
|
||||
settings: Option<&Path>,
|
||||
serve: impl FnOnce(TcpListener, egui::Context, String) -> std::io::Result<Receiver<ControlRequest>>,
|
||||
) -> Option<(Receiver<ControlRequest>, Option<KeyFile>)> {
|
||||
let listener = match TcpListener::bind(("127.0.0.1", port)) {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
@@ -67,11 +109,21 @@ pub fn start(port: u16, ctx: egui::Context, settings: Option<&Path>) -> Option<(
|
||||
None
|
||||
}
|
||||
};
|
||||
let rx = match serve(listener, ctx, key) {
|
||||
Ok(rx) => rx,
|
||||
Err(e) => {
|
||||
if let Some(f) = &key_file {
|
||||
f.remove();
|
||||
}
|
||||
log::error!("control server off: cannot start the accept thread ({e})");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
match &key_file {
|
||||
Some(f) => log::info!("control server listening on 127.0.0.1:{port}, key in {}", f.path().display()),
|
||||
None => log::info!("control server listening on 127.0.0.1:{port}"),
|
||||
}
|
||||
Some((serve_on(listener, ctx, key, FIRST_REQUEST), key_file))
|
||||
Some((rx, key_file))
|
||||
}
|
||||
|
||||
/// A new key: 32 random bytes from the operating system, as hex.
|
||||
@@ -103,16 +155,47 @@ fn keys_match(given: &str, key: &str) -> bool {
|
||||
!b.is_empty() && a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
|
||||
}
|
||||
|
||||
fn serve_on(listener: TcpListener, ctx: egui::Context, key: String, first: Duration) -> Receiver<ControlRequest> {
|
||||
fn serve_on(listener: TcpListener, ctx: egui::Context, key: String, first: Duration) -> std::io::Result<Receiver<ControlRequest>> {
|
||||
serve_with(listener, ctx, key, Limits { first, wait: REPLY_WAIT, max_unauthenticated: MAX_UNAUTHENTICATED })
|
||||
}
|
||||
|
||||
/// The time and count budgets of a server (small ones in tests).
|
||||
#[derive(Clone, Copy)]
|
||||
struct Limits {
|
||||
first: Duration,
|
||||
wait: Duration,
|
||||
max_unauthenticated: usize,
|
||||
}
|
||||
|
||||
fn serve_with(listener: TcpListener, ctx: egui::Context, key: String, limits: Limits) -> std::io::Result<Receiver<ControlRequest>> {
|
||||
serve_with_spawner(listener, ctx, key, limits, |accept| std::thread::Builder::new().name("control-accept".into()).spawn(accept).map(|_| ()))
|
||||
}
|
||||
|
||||
fn serve_with_spawner(
|
||||
listener: TcpListener,
|
||||
ctx: egui::Context,
|
||||
key: String,
|
||||
limits: Limits,
|
||||
spawn: impl FnOnce(Box<dyn FnOnce() + Send>) -> std::io::Result<()>,
|
||||
) -> std::io::Result<Receiver<ControlRequest>> {
|
||||
let (tx, rx) = channel::<ControlRequest>();
|
||||
let key: Arc<str> = key.into();
|
||||
std::thread::spawn(move || {
|
||||
let unauthenticated = Arc::new(AtomicUsize::new(0));
|
||||
let accept = move || {
|
||||
for stream in listener.incoming().flatten() {
|
||||
let Some(admission) = Admission::enter(&unauthenticated, limits.max_unauthenticated) else {
|
||||
log::warn!("control server: too many unauthenticated connections, one closed");
|
||||
continue;
|
||||
};
|
||||
let (tx, ctx, key) = (tx.clone(), ctx.clone(), key.clone());
|
||||
std::thread::spawn(move || serve(stream, tx, ctx, &key, first));
|
||||
// A thread that cannot start closes this connection only (the guard releases its slot).
|
||||
if let Err(e) = std::thread::Builder::new().name("control-conn".into()).spawn(move || serve(stream, tx, ctx, &key, limits, admission)) {
|
||||
log::warn!("control server: no thread for a connection: {e}");
|
||||
}
|
||||
}
|
||||
});
|
||||
rx
|
||||
};
|
||||
spawn(Box::new(accept))?;
|
||||
Ok(rx)
|
||||
}
|
||||
|
||||
/// The read side of a connection with an optional overall deadline: each read waits only for
|
||||
@@ -175,11 +258,11 @@ fn discard_rest_of_line(r: &mut BufReader<Timed>) {
|
||||
}
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context, key: &str, first: Duration) {
|
||||
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context, key: &str, limits: Limits, mut admission: Admission) {
|
||||
let Ok(read) = stream.try_clone() else { return };
|
||||
let mut out = stream;
|
||||
// One overall deadline from accept to the first authorised request; after it, none.
|
||||
let mut reader = BufReader::new(Timed { s: read, deadline: Some(Instant::now() + first) });
|
||||
let mut reader = BufReader::new(Timed { s: read, deadline: Some(Instant::now() + limits.first) });
|
||||
let mut authorised = false;
|
||||
// The key an `auth` request gave this connection.
|
||||
let mut conn_key: Option<String> = None;
|
||||
@@ -228,6 +311,7 @@ fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context, key:
|
||||
}
|
||||
if !authorised {
|
||||
authorised = true;
|
||||
admission.release();
|
||||
reader.get_mut().set_deadline(None);
|
||||
}
|
||||
if method == "auth" {
|
||||
@@ -237,12 +321,18 @@ fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context, key:
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let (req, rrx) = ControlRequest::new(method, params);
|
||||
let (req, rrx, waiter) = ControlRequest::with_waiter(method, params);
|
||||
if tx.send(req).is_err() {
|
||||
break;
|
||||
}
|
||||
ctx.request_repaint();
|
||||
let r = rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"}));
|
||||
let r = rrx.recv_timeout(limits.wait).unwrap_or_else(|_| {
|
||||
if waiter.cancel() {
|
||||
json!({"ok": false, "error": "timeout before execution: the request was not run and never will be"})
|
||||
} else {
|
||||
json!({"ok": false, "error": "timeout: outcome unknown, the request had started when the wait ended; inspect the document to check"})
|
||||
}
|
||||
});
|
||||
if !reply(r) {
|
||||
break;
|
||||
}
|
||||
@@ -260,7 +350,7 @@ mod tests {
|
||||
fn boot_with(first: Duration) -> (u16, Receiver<ControlRequest>) {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
(port, serve_on(listener, egui::Context::default(), KEY.to_string(), first))
|
||||
(port, serve_on(listener, egui::Context::default(), KEY.to_string(), first).unwrap())
|
||||
}
|
||||
|
||||
fn boot() -> (u16, Receiver<ControlRequest>) {
|
||||
@@ -512,6 +602,40 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_accept_thread_spawn_failure_aborts_start_and_removes_the_key() {
|
||||
let dir = temp_dir("spawn-failure");
|
||||
let path = wordcraft_control_key::key_path(&dir, 0);
|
||||
let mut bound = None;
|
||||
let result = start_with(0, egui::Context::default(), Some(&dir), |listener, ctx, key| {
|
||||
bound = Some(listener.local_addr().unwrap());
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), key, "startup wrote the key before spawning");
|
||||
serve_with_spawner(listener, ctx, key, small(150, 8), |accept| {
|
||||
drop(accept);
|
||||
Err(std::io::Error::other("injected accept thread spawn failure"))
|
||||
})
|
||||
});
|
||||
assert!(result.is_none(), "a server without its accept thread must not report success");
|
||||
assert!(!path.exists(), "failed startup must remove its published key");
|
||||
let _listener = TcpListener::bind(bound.unwrap()).unwrap();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_complete_json_request_at_eof_can_reach_the_ui() {
|
||||
// This is why a bridge write failure before the newline is outcome-unknown.
|
||||
let (port, rx) = boot();
|
||||
let mut c = Conn::open(port);
|
||||
let body = json!({"id": 1, "key": KEY, "method": "engine.execute", "params": {"command": "text.insert", "params": {"text": "once"}}});
|
||||
c.1.write_all(body.to_string().as_bytes()).unwrap();
|
||||
c.1.shutdown(std::net::Shutdown::Write).unwrap();
|
||||
let req = answer(&rx);
|
||||
assert_eq!(req.params["params"]["text"], "once");
|
||||
assert_eq!(c.recv()["ok"], true);
|
||||
assert!(c.closed());
|
||||
assert!(rx.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stale_key_file_is_replaced() {
|
||||
let dir = temp_dir("stale");
|
||||
@@ -532,4 +656,52 @@ mod tests {
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
fn boot_limits(limits: Limits) -> (u16, Receiver<ControlRequest>) {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
(port, serve_with(listener, egui::Context::default(), KEY.to_string(), limits).unwrap())
|
||||
}
|
||||
|
||||
fn small(wait_ms: u64, max_unauthenticated: usize) -> Limits {
|
||||
Limits { first: FIRST_REQUEST, wait: Duration::from_millis(wait_ms), max_unauthenticated }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_request_that_timed_out_in_the_queue_never_runs() {
|
||||
let (port, rx) = boot_limits(small(150, 8));
|
||||
let mut c = Conn::open(port);
|
||||
let r = c.call(&format!(r#"{{"id":1,"key":"{KEY}","method":"engine.execute","params":{{"command":"text.insert"}}}}"#));
|
||||
assert_eq!(r["ok"], false);
|
||||
assert!(r["error"].as_str().unwrap_or("").starts_with("timeout before execution"), "{r}");
|
||||
// The UI gets to it late: it must not run it.
|
||||
let req = rx.recv_timeout(Duration::from_secs(5)).unwrap();
|
||||
assert!(!req.start(), "a cancelled request was started");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_request_that_started_before_the_timeout_reports_an_unknown_outcome() {
|
||||
let (port, rx) = boot_limits(small(150, 8));
|
||||
let mut c = Conn::open(port);
|
||||
c.send(&format!(r#"{{"id":1,"key":"{KEY}","method":"engine.execute","params":{{"command":"text.insert"}}}}"#));
|
||||
let req = rx.recv_timeout(Duration::from_secs(5)).unwrap();
|
||||
assert!(req.start());
|
||||
let r = c.recv();
|
||||
assert!(r["error"].as_str().unwrap_or("").contains("outcome unknown"), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unauthenticated_connections_are_limited() {
|
||||
let (port, rx) = boot_limits(small(5_000, 2));
|
||||
let mut idle = [Conn::open(port), Conn::open(port)];
|
||||
// Give the server time to count both.
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
assert!(Conn::open(port).closed(), "the third idle connection was admitted");
|
||||
// One authenticates: its slot is free again.
|
||||
assert_eq!(idle[0].call(&format!(r#"{{"id":1,"method":"auth","key":"{KEY}"}}"#))["ok"], true);
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
let mut again = Conn::open(port);
|
||||
assert_eq!(again.call(&format!(r#"{{"id":1,"method":"auth","key":"{KEY}"}}"#))["ok"], true);
|
||||
assert!(rx.try_recv().is_err());
|
||||
}
|
||||
}
|
||||
|
||||
+43
-20
@@ -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;
|
||||
@@ -67,6 +69,8 @@ impl eframe::App for App {
|
||||
if let Some((pos, size)) = self.1.and_then(|g| g.fit_on_monitor(&window)) {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(size));
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(pos));
|
||||
let b = self.0.window_builder().clone().with_inner_size(size);
|
||||
self.0.set_window_builder(b);
|
||||
}
|
||||
// The geometry saved for the next start is the active window's.
|
||||
let prev = self.0.ui.window;
|
||||
@@ -295,25 +299,24 @@ fn uses_x11(setting: wordcraft_ui_egui::DisplayServer, env: impl Fn(&str) -> Opt
|
||||
}
|
||||
|
||||
fn save_prefs(app: &WordApp) {
|
||||
if let Err(e) = try_save_prefs(app) {
|
||||
log::warn!("preferences: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves the preferences and says why when it could not. `Ok(false)`: nothing was written
|
||||
/// because preferences are switched off or there is no settings folder.
|
||||
fn try_save_prefs(app: &WordApp) -> Result<bool, String> {
|
||||
if !prefs_enabled() {
|
||||
return;
|
||||
return Ok(false);
|
||||
}
|
||||
if let Some(p) = prefs_path() {
|
||||
if let Some(dir) = p.parent()
|
||||
&& let Err(e) = std::fs::create_dir_all(dir)
|
||||
{
|
||||
log::warn!("preferences: cannot create {}: {e}", dir.display());
|
||||
return;
|
||||
}
|
||||
match serde_json::to_vec_pretty(&app.prefs()) {
|
||||
Ok(bytes) => {
|
||||
if let Err(e) = write_prefs(&p, &bytes) {
|
||||
log::warn!("preferences: cannot save {}: {e}", p.display());
|
||||
}
|
||||
}
|
||||
Err(e) => log::warn!("preferences: cannot serialize: {e}"),
|
||||
}
|
||||
let Some(p) = prefs_path() else { return Ok(false) };
|
||||
if let Some(dir) = p.parent() {
|
||||
std::fs::create_dir_all(dir).map_err(|e| format!("cannot create {}: {e}", dir.display()))?;
|
||||
}
|
||||
let bytes = serde_json::to_vec_pretty(&app.prefs()).map_err(|e| format!("cannot serialize: {e}"))?;
|
||||
write_prefs(&p, &bytes).map_err(|e| format!("cannot save {}: {e}", p.display()))?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Writes `bytes` to a sibling temporary file and renames it over `path`, so a failed or
|
||||
@@ -348,6 +351,7 @@ fn services(file_dialog: file_dialogs::Hook, ctx: &egui::Context) -> Services {
|
||||
set_clipboard_rich: Some(Box::new(clipboard::set_rich)),
|
||||
clipboard_rtf: Some(Box::new(clipboard::rtf)),
|
||||
clipboard_formats: Some(Box::new(clipboard::formats)),
|
||||
save_prefs: Some(Box::new(try_save_prefs)),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
@@ -421,14 +425,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 +454,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 +584,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 +627,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)]
|
||||
|
||||
@@ -14,6 +14,10 @@
|
||||
# To remove your names, delete your entry (or set fields empty) in a PR. The next
|
||||
# contributors.py run, and the next build, drop them.
|
||||
|
||||
[people.SenuraMalaka]
|
||||
real_name = "Senura Dissanayake"
|
||||
sync_github_name = true
|
||||
|
||||
[people.divyanshu2074]
|
||||
real_name = "Divyanshu Gupta"
|
||||
sync_github_name = true
|
||||
@@ -197,3 +201,6 @@ display_name = "entropybeater"
|
||||
|
||||
[people.helenkwok]
|
||||
real_name = "Helen Kwok"
|
||||
|
||||
[people.ashokram-si]
|
||||
real_name = "Ashok Ram"
|
||||
|
||||
@@ -16,7 +16,7 @@ workspace = true
|
||||
test-support = []
|
||||
|
||||
[dependencies]
|
||||
wordcraft-geom = { workspace = true, features = ["image"] }
|
||||
wordcraft-geom = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
log = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
|
||||
+19
-10
@@ -7,8 +7,9 @@
|
||||
/// makes it allocate gigabytes. Here the DIFAT is walked the way `cfb` walks it (header entries,
|
||||
/// then the DIFAT chain, at most one step per sector; trailing zero and free entries trimmed
|
||||
/// the same way) and the file is refused when it lists one FAT sector twice, or more FAT sectors
|
||||
/// than its sectors need (one FAT sector covers `sector_len / 4` of them, plus one spare), so the
|
||||
/// FAT `cfb` builds is about the size the file needs. Anything else that is wrong is left for
|
||||
/// than the file has sectors — every FAT sector is a sector of the file, so a longer list is
|
||||
/// duplicates or names `cfb` refuses anyway, and the FAT `cfb` builds (one sector of entries per
|
||||
/// listed name) stays within about the file's own size. Anything else that is wrong is left for
|
||||
/// `cfb` to report.
|
||||
pub fn check(bytes: &[u8]) -> Result<(), String> {
|
||||
const MAGIC: [u8; 8] = [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1];
|
||||
@@ -52,12 +53,14 @@ pub fn check(bytes: &[u8]) -> Result<(), String> {
|
||||
while difat.last() == Some(&FREE) {
|
||||
difat.pop();
|
||||
}
|
||||
// Each listed FAT sector adds a whole sector of entries. A FAT sector holds the entries of
|
||||
// `sector_len / 4` sectors, so a file needs no more FAT sectors than that covers (one spare),
|
||||
// and none of them twice. A free entry in the middle is left for `cfb` to refuse.
|
||||
// Each listed FAT sector adds a whole sector of entries. A real file lists no more FAT
|
||||
// sectors than the file has — every FAT sector is a sector of the file, and none of them
|
||||
// twice (checked below) — so the FAT `cfb` builds stays within about the file's own size.
|
||||
// The #3642 blowup listed 2048 × 127 names in a ~1 MB file. A tighter bound (one FAT sector
|
||||
// per `sector_len / 4` sectors plus a spare) refused real files, whose writers reserve FAT
|
||||
// for growth: an encrypted package of 78 sectors lists 4 and didn't open (#5109).
|
||||
let listed: Vec<u32> = difat.into_iter().filter(|&s| s != FREE).collect();
|
||||
let needed = num_sectors.div_ceil(sector_len / 4).saturating_add(1);
|
||||
if listed.len() > needed {
|
||||
if listed.len() > num_sectors {
|
||||
return Err(format!("the DIFAT lists {} FAT sectors in a file of {num_sectors} sectors", listed.len()));
|
||||
}
|
||||
let mut seen = std::collections::HashSet::with_capacity(listed.len());
|
||||
@@ -123,10 +126,16 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fat_sector_listed_twice_or_more_than_the_file_needs_is_refused() {
|
||||
// Each list is shorter than the file has sectors, which the guard used to accept.
|
||||
fn a_fat_sector_listed_twice_or_more_than_the_file_has_is_refused() {
|
||||
// A duplicate, and a list longer than the file has sectors: every FAT sector is a
|
||||
// sector of the file, so a longer list is one of the two.
|
||||
assert!(super::check(&header_difat(10, &[0, 0])).is_err_and(|e| e.contains("FAT sector 0 twice")));
|
||||
assert!(super::check(&header_difat(10, &[0, 1, 2])).is_err_and(|e| e.contains("3 FAT sectors")));
|
||||
assert!(
|
||||
super::check(&header_difat(10, &[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10])).is_err_and(|e| e.contains("11 FAT sectors in a file of 10 sectors"))
|
||||
);
|
||||
// A writer may reserve FAT for growth: a real encrypted package of 78 sectors lists
|
||||
// 4 FAT sectors and didn't open since the #3642 guard landed (#5109).
|
||||
assert!(super::check(&header_difat(78, &[0, 1, 2, 3])).is_ok());
|
||||
assert!(super::check(&header_difat(10, &[0])).is_ok());
|
||||
assert!(super::check(&header_difat(200, &[0, 1])).is_ok());
|
||||
}
|
||||
|
||||
@@ -772,6 +772,10 @@ impl Document {
|
||||
if a.path.parent() == b.path.parent() {
|
||||
// Same container: trim the ends, drop the blocks between, join.
|
||||
let (ia, ib) = (a.path.last(), b.path.last());
|
||||
// Removing an already-empty paragraph must not impose its style or mark formatting
|
||||
// on the following content. Test before trimming: deleting actual heading text is
|
||||
// still an ordinary merge that keeps the first paragraph's properties.
|
||||
let empty_start = self.para(a.story, &a.path).is_some_and(Paragraph::is_empty);
|
||||
// Kept markers of the paragraphs in between move to the join.
|
||||
let mut moved: Vec<(InlineObject, CharProps)> = Vec::new();
|
||||
// `kept` is sorted: one walk of each paragraph's objects and runs (#3614).
|
||||
@@ -819,15 +823,18 @@ impl Document {
|
||||
if ib < bl.len() && ia < ib {
|
||||
bl.drain(ia + 1..=ib);
|
||||
}
|
||||
tail.props = ParaProps::default();
|
||||
// The deleted marks include `a`'s, so a section break there goes too: the joined
|
||||
// paragraph ends where `b`'s mark did, and its text takes the following section's
|
||||
// setup, as in Word (#2127).
|
||||
let sect = tail.section.take();
|
||||
let pa = self.para_mut(a.story, &a.path)?;
|
||||
let keep_props = pa.props.clone();
|
||||
let keep_props = if empty_start { tail.props.clone() } else { pa.props.clone() };
|
||||
let keep_mark = empty_start.then(|| tail.mark.clone());
|
||||
pa.append(tail);
|
||||
pa.props = keep_props;
|
||||
if let Some(mark) = keep_mark {
|
||||
pa.mark = mark;
|
||||
}
|
||||
let before = std::mem::replace(&mut pa.section, sect);
|
||||
if a.story == crate::StoryRef::Body && a.path.0.len() == 1 {
|
||||
// Nearest first, as "link to previous" resolved them before the delete.
|
||||
|
||||
@@ -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, the watermark, 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,9 +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,
|
||||
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),
|
||||
)) {
|
||||
|
||||
@@ -48,7 +48,6 @@ pub mod smart_art;
|
||||
pub mod styles;
|
||||
pub mod symbol_fonts;
|
||||
pub mod table;
|
||||
pub mod watermark;
|
||||
pub mod wordart;
|
||||
pub mod work;
|
||||
pub mod wrap;
|
||||
@@ -93,7 +92,6 @@ pub use settings::{
|
||||
};
|
||||
pub(crate) use text_walk::{PARA_WALK_DEPTH, walk_between};
|
||||
pub use text_walk::{count_chars, count_chars_no_spaces, count_words};
|
||||
pub use watermark::WatermarkPicture;
|
||||
|
||||
/// A document.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
|
||||
@@ -19,6 +19,12 @@ pub fn is_web_or_mail_link(url: &str) -> bool {
|
||||
/// names another machine — following it on Windows leaks the user's NTLM credentials — so it is
|
||||
/// refused too.
|
||||
pub fn is_safe_href(url: &str) -> bool {
|
||||
// Exporters drop other control characters and noncharacters when they write the link
|
||||
// (`file\u{85}:` comes out `file:`), so a link holding one is refused, not judged as it
|
||||
// stands and written as something else.
|
||||
if url.chars().any(|c| (c.is_control() && !c.is_ascii_control()) || matches!(c, '\u{FFFE}' | '\u{FFFF}')) {
|
||||
return false;
|
||||
}
|
||||
let cleaned: String = url.chars().filter(|c| !c.is_ascii_control() && *c != ' ').collect();
|
||||
// `//host` (protocol-relative) and `\\host` (UNC), with `\` read as `/` the way Windows does.
|
||||
if cleaned.replace('\\', "/").starts_with("//") {
|
||||
@@ -125,6 +131,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")),
|
||||
@@ -199,6 +206,10 @@ mod tests {
|
||||
"\\\\attacker.example\\share\\x",
|
||||
"/\\attacker.example\\x",
|
||||
" //attacker.example/x",
|
||||
// Characters the exporters drop on writing don't hide a scheme.
|
||||
"file\u{85}:///etc/passwd",
|
||||
"java\u{9F}script:alert(1)",
|
||||
"/\u{FFFE}/attacker.example/x",
|
||||
"",
|
||||
] {
|
||||
assert!(!is_safe_href(bad), "{bad:?}");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -186,6 +186,11 @@ pub struct Float {
|
||||
/// top. 0 = unknown, drawn in document order (objects inserted here, or from other formats).
|
||||
#[serde(skip_serializing_if = "is_zero_u32")]
|
||||
pub z: u32,
|
||||
/// Turned, but the file keeps no room for the turn (`wp:effectExtent` missing or zero, as
|
||||
/// files from other tools have): Word then keeps an inline one's line to its frame and lets
|
||||
/// the turned object overhang it (see [`Float::line_pad`]). Cleared by [`Float::set_spin`].
|
||||
#[serde(skip_serializing_if = "is_false")]
|
||||
pub bare_turn: bool,
|
||||
}
|
||||
|
||||
/// Longest alternative text kept (characters): hostile files and commands are capped.
|
||||
@@ -241,6 +246,7 @@ impl Float {
|
||||
self.rot = wordcraft_geom::normalize_degrees(s.deg);
|
||||
self.flip_h = s.flip_h;
|
||||
self.flip_v = s.flip_v;
|
||||
self.bare_turn = false;
|
||||
}
|
||||
/// How far a `w` × `h` frame's rotated bounds reach past it on each side (x, y): the room a
|
||||
/// rotated object takes beyond its frame (zero unrotated; negative where the turned object
|
||||
@@ -250,6 +256,23 @@ impl Float {
|
||||
let (bw, bh) = self.spin().extent(w, h);
|
||||
((bw - w) / 2.0, (bh - h) / 2.0)
|
||||
}
|
||||
/// A [`Float::bare_turn`] still inline and without effects room: made floating, or given room
|
||||
/// for a shadow or glow since, it saves, and so lays out, with the turn's room again.
|
||||
pub fn is_bare_turn(&self) -> bool {
|
||||
self.bare_turn && self.wrap == Wrap::Inline && self.effect_extent() == [0.0; 4]
|
||||
}
|
||||
/// The room an inline `w` × `h` object's line keeps past its frame on each side (x, y) for its
|
||||
/// turn: its rotated bounds ([`Float::spin_pad`]) — or, for a [`Float::is_bare_turn`], none, except
|
||||
/// that Word 2013+ layout (`modern`) exchanges the frame's width and height from 45° up to 135°
|
||||
/// and from 225° up to 315°.
|
||||
pub fn line_pad(&self, w: f32, h: f32, modern: bool) -> (f32, f32) {
|
||||
if !self.is_bare_turn() {
|
||||
return self.spin_pad(w, h);
|
||||
}
|
||||
let (w, h) = (wordcraft_geom::finite(w).clamp(0.0, 4000.0), wordcraft_geom::finite(h).clamp(0.0, 4000.0));
|
||||
let deg = wordcraft_geom::normalize_degrees(self.rot);
|
||||
if modern && ((45.0..135.0).contains(°) || (225.0..315.0).contains(°)) { ((h - w) / 2.0, (w - h) / 2.0) } else { (0.0, 0.0) }
|
||||
}
|
||||
/// The effect extents, finite and clamped (left, top, right, bottom).
|
||||
pub fn effect_extent(&self) -> [f32; 4] {
|
||||
self.effect.map(|v| if v.is_finite() { v.clamp(0.0, 1584.0) } else { 0.0 })
|
||||
|
||||
@@ -27,7 +27,8 @@ pub use char_props::{
|
||||
};
|
||||
pub use color::{Rgb, TextColor};
|
||||
pub use para_props::{
|
||||
Align, Border, BorderStyle, Borders, LineSpacing, NumRef, ParaProps, TAB_POS_EPS, TabAlign, TabLeader, TabStop, TextAlignment, TextFrame,
|
||||
Align, Border, BorderStyle, Borders, LineSpacing, NumRef, ParaProps, ParagraphTextDirection, TAB_POS_EPS, TabAlign, TabLeader, TabStop,
|
||||
TextAlignment, TextFrame,
|
||||
};
|
||||
pub use table_props::{
|
||||
CellPicture, CellProps, HeightRule, PictureRepeat, RowProps, TableFloat, TableLook, TableProps, TextDirection, TextVert, VAlign, VMerge,
|
||||
|
||||
@@ -26,6 +26,67 @@ impl Align {
|
||||
}
|
||||
}
|
||||
|
||||
/// Paragraph-level text flow (`w:pPr/w:textDirection`, ECMA-376 §17.3.1.41).
|
||||
///
|
||||
/// Kept separate from [`super::TextDirection`], whose three values model the table-cell direction
|
||||
/// control. These values preserve the exact OOXML direction through JSON and DOCX saves.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ParagraphTextDirection {
|
||||
LrTb,
|
||||
LrTbV,
|
||||
Tb,
|
||||
TbRl,
|
||||
TbRlV,
|
||||
TbLr,
|
||||
TbLrV,
|
||||
BtLr,
|
||||
TbV,
|
||||
Rl,
|
||||
RlV,
|
||||
Lr,
|
||||
LrV,
|
||||
}
|
||||
|
||||
impl ParagraphTextDirection {
|
||||
pub fn from_ooxml(value: &str) -> Option<Self> {
|
||||
match value {
|
||||
"lrTb" => Some(Self::LrTb),
|
||||
"lrTbV" => Some(Self::LrTbV),
|
||||
"tb" => Some(Self::Tb),
|
||||
"tbRl" => Some(Self::TbRl),
|
||||
"tbRlV" => Some(Self::TbRlV),
|
||||
"tbLr" => Some(Self::TbLr),
|
||||
"tbLrV" => Some(Self::TbLrV),
|
||||
"btLr" => Some(Self::BtLr),
|
||||
"tbV" => Some(Self::TbV),
|
||||
"rl" => Some(Self::Rl),
|
||||
"rlV" => Some(Self::RlV),
|
||||
"lr" => Some(Self::Lr),
|
||||
"lrV" => Some(Self::LrV),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ooxml(self) -> &'static str {
|
||||
match self {
|
||||
Self::LrTb => "lrTb",
|
||||
Self::LrTbV => "lrTbV",
|
||||
Self::Tb => "tb",
|
||||
Self::TbRl => "tbRl",
|
||||
Self::TbRlV => "tbRlV",
|
||||
Self::TbLr => "tbLr",
|
||||
Self::TbLrV => "tbLrV",
|
||||
Self::BtLr => "btLr",
|
||||
Self::TbV => "tbV",
|
||||
Self::Rl => "rl",
|
||||
Self::RlV => "rlV",
|
||||
Self::Lr => "lr",
|
||||
Self::LrV => "lrV",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Vertical alignment of characters of different sizes on a line (`w:textAlignment`,
|
||||
/// ECMA-376 §17.3.1.39).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
@@ -391,6 +452,9 @@ pub struct ParaProps {
|
||||
pub suppress_line_numbers: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub bidi: Option<bool>,
|
||||
/// Paragraph text direction (`w:textDirection`); `None` inherits from the paragraph style.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub text_direction: Option<ParagraphTextDirection>,
|
||||
/// Drop cap: lines to drop (0 = none) — applies to the first character(s).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub drop_cap: Option<u8>,
|
||||
@@ -462,6 +526,7 @@ impl ParaProps {
|
||||
}
|
||||
|
||||
fn overlay_script_layout(&mut self, patch: &ParaProps) {
|
||||
overlay_fields!(self, patch; bidi, text_direction, kinsoku, word_wrap, overflow_punct, top_line_punct, auto_space_de, auto_space_dn);
|
||||
overlay_fields!(self, patch; bidi, kinsoku, word_wrap, overflow_punct, top_line_punct, auto_space_de, auto_space_dn, text_alignment);
|
||||
}
|
||||
pub fn overlaid(mut self, patch: &ParaProps) -> ParaProps {
|
||||
|
||||
@@ -137,9 +137,12 @@ impl ResolvedChar {
|
||||
pub fn uses_complex(&self, c: char) -> bool {
|
||||
self.rtl || self.cs || crate::bidi::is_complex_script(c)
|
||||
}
|
||||
/// The size glyphs are drawn at (super/subscript shrink to ~2/3).
|
||||
/// The size glyphs are drawn and measured at. Word sets superscript and subscript text at
|
||||
/// 0.65 of the run's size rounded to the half point, a font size the format can store
|
||||
/// (measured in Word for Mac from digit advances: 8 pt → 5, 9 → 6, 11 → 7, 12 → 8,
|
||||
/// 16 → 10.5, 24 → 15.5, 36 → 23.5).
|
||||
pub fn draw_size(&self) -> f32 {
|
||||
if self.vert_align == VertAlign::Baseline { self.size } else { self.size * 0.65 }
|
||||
if self.vert_align == VertAlign::Baseline { self.size } else { (self.size * 0.65 * 2.0).round() / 2.0 }
|
||||
}
|
||||
/// Baseline shift, points (positive = up).
|
||||
pub fn baseline_shift(&self) -> f32 {
|
||||
@@ -453,6 +456,32 @@ fn diff_patch<T: Serialize + serde::de::DeserializeOwned + Default>(with: &T, wi
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Superscript and subscript sizes as Word for Mac sets them (measured from digit advances in
|
||||
/// its PDF export): 0.65 of the size, rounded to the half point.
|
||||
#[test]
|
||||
fn raised_and_lowered_text_is_sized_to_the_half_point() {
|
||||
for (size, want) in [
|
||||
(8.0, 5.0),
|
||||
(9.0, 6.0),
|
||||
(10.0, 6.5),
|
||||
(11.0, 7.0),
|
||||
(12.0, 8.0),
|
||||
(13.0, 8.5),
|
||||
(14.0, 9.0),
|
||||
(16.0, 10.5),
|
||||
(20.0, 13.0),
|
||||
(24.0, 15.5),
|
||||
(36.0, 23.5),
|
||||
] {
|
||||
for vert_align in [VertAlign::Superscript, VertAlign::Subscript] {
|
||||
let rc = ResolvedChar { size, vert_align, ..StyleSheet::builtin().resolve_char(None, &CharProps::default()) };
|
||||
assert_eq!(rc.draw_size(), want, "{size} pt {vert_align:?}");
|
||||
}
|
||||
}
|
||||
let rc = StyleSheet::builtin().resolve_char(None, &CharProps { size: Some(11.0), ..Default::default() });
|
||||
assert_eq!(rc.draw_size(), 11.0, "baseline text keeps its size");
|
||||
}
|
||||
|
||||
/// #2036: only a character itself (direct formatting) has a symbol font of its own; a
|
||||
/// style never gives one to its text.
|
||||
#[test]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -234,20 +234,12 @@ pub struct Watermark {
|
||||
pub font: String,
|
||||
pub color: Rgb,
|
||||
pub diagonal: bool,
|
||||
/// Word's Semitransparent check box (the text 50 % transparent).
|
||||
pub semitransparent: bool,
|
||||
/// The text's transparency in percent when it is neither 0 nor 50 (see
|
||||
/// [`Watermark::transparency`]); `None` follows `semitransparent`, as older documents do.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub transparency: Option<u8>,
|
||||
/// The shape's box (its `style` width and height, points), which the text is stretched to
|
||||
/// fill (`v:textpath fitshape`); `None`: the box Word's Design › Watermark gives
|
||||
/// ([`Watermark::frame`]).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub size: Option<[f32; 2]>,
|
||||
/// A picture watermark, drawn instead of the text (which is kept for the dialog).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub picture: Option<crate::watermark::WatermarkPicture>,
|
||||
}
|
||||
|
||||
impl Default for Watermark {
|
||||
@@ -258,35 +250,12 @@ impl Default for Watermark {
|
||||
color: Rgb(0xC0, 0xC0, 0xC0),
|
||||
diagonal: true,
|
||||
semitransparent: true,
|
||||
transparency: None,
|
||||
size: None,
|
||||
picture: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Watermark {
|
||||
/// The text's transparency in percent: 0 is opaque, 100 invisible, Semitransparent is 50.
|
||||
pub fn transparency(&self) -> u8 {
|
||||
match self.transparency {
|
||||
Some(t) => t.min(100),
|
||||
None if self.semitransparent => 50,
|
||||
None => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the text's transparency in percent (at most 100). Semitransparent follows it.
|
||||
pub fn set_transparency(&mut self, percent: u8) {
|
||||
let t = percent.min(100);
|
||||
self.semitransparent = t > 0;
|
||||
self.transparency = (t != 0 && t != 50).then_some(t);
|
||||
}
|
||||
|
||||
/// The text's opacity, 0 to 1.
|
||||
pub fn opacity(&self) -> f32 {
|
||||
1.0 - f32::from(self.transparency()) / 100.0
|
||||
}
|
||||
|
||||
/// The box the text fills on a page `page_w` × `page_h` (points): its own size, else half the
|
||||
/// page diagonal across (half the page width when not diagonal), as high as the text's length
|
||||
/// makes it. Centred on the margins and, when diagonal, turned 45° counterclockwise (Word's
|
||||
@@ -363,30 +332,3 @@ impl Default for Settings {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod watermark_tests {
|
||||
use super::Watermark;
|
||||
|
||||
#[test]
|
||||
fn transparency_follows_semitransparent() {
|
||||
let mut wm = Watermark::default();
|
||||
assert_eq!((wm.transparency(), wm.opacity()), (50, 0.5));
|
||||
wm.semitransparent = false;
|
||||
assert_eq!(wm.transparency(), 0);
|
||||
wm.set_transparency(80);
|
||||
assert_eq!((wm.semitransparent, wm.transparency, wm.transparency()), (true, Some(80), 80));
|
||||
// Word's two values are stored as the check box alone.
|
||||
wm.set_transparency(50);
|
||||
assert_eq!((wm.semitransparent, wm.transparency), (true, None));
|
||||
wm.set_transparency(0);
|
||||
assert_eq!((wm.semitransparent, wm.transparency), (false, None));
|
||||
wm.set_transparency(255);
|
||||
assert_eq!((wm.transparency(), wm.opacity()), (100, 0.0));
|
||||
// Older documents have no transparency field.
|
||||
let old: Watermark = serde_json::from_str(r#"{"text":"X","semitransparent":false}"#).unwrap();
|
||||
assert_eq!(old.transparency(), 0);
|
||||
let hostile: Watermark = serde_json::from_str(r#"{"transparency":200}"#).unwrap();
|
||||
assert_eq!(hostile.transparency(), 100);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,8 +19,8 @@
|
||||
//!
|
||||
//! Tables are indexed by code − 0x20; 0 = no Unicode equivalent.
|
||||
|
||||
/// The symbol fonts this module knows, by lowercase family name.
|
||||
fn table(family: &str) -> Option<&'static [u32; 224]> {
|
||||
/// The symbol table for a single family name, by lowercase family name.
|
||||
fn single_table(family: &str) -> Option<&'static [u32; 224]> {
|
||||
let f = family.trim();
|
||||
if f.eq_ignore_ascii_case("symbol") || f.eq_ignore_ascii_case("symbol (pcl6)") {
|
||||
Some(&SYMBOL)
|
||||
@@ -37,6 +37,11 @@ fn table(family: &str) -> Option<&'static [u32; 224]> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The symbol fonts this module knows, by lowercase family name.
|
||||
fn table(family: &str) -> Option<&'static [u32; 224]> {
|
||||
if family.contains(';') { family.split(';').map(str::trim).filter(|s| !s.is_empty()).find_map(single_table) } else { single_table(family) }
|
||||
}
|
||||
|
||||
/// Whether `family` is one of Word's symbol fonts (Symbol, Wingdings, Wingdings 2, Wingdings 3,
|
||||
/// Webdings), whose characters [`symbol_char`] maps.
|
||||
pub fn is_symbol_font(family: &str) -> bool {
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
//! Design › Watermark › Picture watermark: a picture drawn behind the text of every page.
|
||||
//!
|
||||
//! The picture's size is shared by everything that draws or saves it. A scale is a percentage of
|
||||
//! the picture's own size (its pixels at 96 ppi). Auto is as large as fits inside the margins of
|
||||
//! the first section that names the page's header ([`Document::watermark_fit`]), the same
|
||||
//! on screen, in a PDF and in the saved file, whatever pages follow. The picture is centred in
|
||||
//! the page's own text area, so mirrored margins move it as they move the text.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use wordcraft_geom::Rect;
|
||||
|
||||
use crate::Document;
|
||||
use crate::picfx::PicFx;
|
||||
use crate::section::SectionProps;
|
||||
|
||||
/// Largest picture watermark scale, percent (the top of Word's Scale list).
|
||||
pub const MAX_SCALE: f32 = 500.0;
|
||||
|
||||
/// Smallest picture watermark scale, percent.
|
||||
pub const MIN_SCALE: f32 = 1.0;
|
||||
|
||||
/// Longest side of a placed picture watermark, points.
|
||||
const MAX_SIDE: f32 = 31_680.0;
|
||||
|
||||
/// Word's Washout as `v:imagedata` values: `gain` 0.3 and `blacklevel` 0.35, in 65536ths.
|
||||
pub const WASHOUT_GAIN: f64 = 19_661.0 / 65_536.0;
|
||||
pub const WASHOUT_BLACK_LEVEL: f64 = 22_938.0 / 65_536.0;
|
||||
|
||||
/// A picture's own size in points (its pixels at 96 ppi), from its header: nothing is decoded.
|
||||
/// None when it isn't a picture WordCraft reads.
|
||||
pub fn own_size(bytes: &[u8]) -> Option<(f32, f32)> {
|
||||
let p = wordcraft_geom::picture::probe(bytes).ok()?;
|
||||
(p.width > 0 && p.height > 0).then_some((p.width as f32 * 0.75, p.height as f32 * 0.75))
|
||||
}
|
||||
|
||||
/// A picture watermark.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct WatermarkPicture {
|
||||
/// Key into [`crate::Document::media`].
|
||||
pub media: String,
|
||||
/// Percent of the picture's own size (its pixels at 96 ppi); None for Auto, as large as fits
|
||||
/// inside the margins.
|
||||
pub scale: Option<f32>,
|
||||
/// Word's Washout: the picture faded so the text over it can be read.
|
||||
pub washout: bool,
|
||||
}
|
||||
|
||||
impl Default for WatermarkPicture {
|
||||
fn default() -> Self {
|
||||
WatermarkPicture { media: String::new(), scale: None, washout: true }
|
||||
}
|
||||
}
|
||||
|
||||
impl WatermarkPicture {
|
||||
/// The corrections the picture is drawn with: Word's Washout, else none. The renderers draw
|
||||
/// it through the same picture path as any picture with corrections, so Washout costs no
|
||||
/// more memory than the picture itself.
|
||||
pub fn fx(&self) -> PicFx {
|
||||
if self.washout { PicFx::from_vml(WASHOUT_GAIN, WASHOUT_BLACK_LEVEL) } else { PicFx::default() }
|
||||
}
|
||||
|
||||
/// Whether `fx` (read from a file's `gain` and `blacklevel`) is Word's Washout, to within
|
||||
/// rounding of the values a file writes.
|
||||
pub fn is_washout(fx: PicFx) -> bool {
|
||||
let w = PicFx::from_vml(WASHOUT_GAIN, WASHOUT_BLACK_LEVEL);
|
||||
fx.transparency == 0 && fx.bright.abs_diff(w.bright) <= 100 && fx.contrast.abs_diff(w.contrast) <= 100
|
||||
}
|
||||
|
||||
/// The picture's size in points, for a picture `own_w` × `own_h` points: a scale's percentage
|
||||
/// of it, or at Auto as large as fits `fit` (width, height). None for an empty or unusable
|
||||
/// size.
|
||||
pub fn size(&self, own_w: f32, own_h: f32, fit: (f32, f32)) -> Option<(f32, f32)> {
|
||||
let pos = |v: f32| v.is_finite() && v > 0.0;
|
||||
if !(pos(own_w) && pos(own_h)) {
|
||||
return None;
|
||||
}
|
||||
let k = match self.scale {
|
||||
Some(s) if s.is_finite() => s.clamp(MIN_SCALE, MAX_SCALE) / 100.0,
|
||||
_ => {
|
||||
if !(pos(fit.0) && pos(fit.1)) {
|
||||
return None;
|
||||
}
|
||||
(fit.0 / own_w).min(fit.1 / own_h)
|
||||
}
|
||||
};
|
||||
let k = k.min(MAX_SIDE / own_w.max(own_h));
|
||||
let (w, h) = (own_w * k, own_h * k);
|
||||
(pos(w) && pos(h)).then_some((w, h))
|
||||
}
|
||||
|
||||
/// Where the picture goes: [`WatermarkPicture::size`], centred in `body`, the page's text
|
||||
/// area (a scale may spill past it).
|
||||
pub fn place(&self, own_w: f32, own_h: f32, fit: (f32, f32), body: Rect) -> Option<Rect> {
|
||||
let (w, h) = self.size(own_w, own_h, fit)?;
|
||||
let ok = |v: f32| v.is_finite();
|
||||
if !(ok(body.x) && ok(body.y) && ok(body.w) && ok(body.h)) {
|
||||
return None;
|
||||
}
|
||||
Some(Rect::new(body.x + (body.w - w) / 2.0, body.y + (body.h - h) / 2.0, w, h))
|
||||
}
|
||||
}
|
||||
|
||||
/// The room Auto has in a section: its text area's width and height.
|
||||
fn fit_of(s: &SectionProps) -> (f32, f32) {
|
||||
(s.text_width(), s.text_height())
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// The room (width, height; points) a picture watermark at Auto fills on a page showing
|
||||
/// header `header` (None: no header, which the saved file gives a header of its own to hold
|
||||
/// the watermark): that of the first section naming the header, in document order, as the
|
||||
/// DOCX writer and reader do. A header no section names is sized by the last section.
|
||||
pub fn watermark_fit(&self, header: Option<u32>) -> (f32, f32) {
|
||||
let even = self.settings.even_odd_headers;
|
||||
// Which slots an earlier section has filled (named, or given the watermark's own header),
|
||||
// that later ones inherit.
|
||||
let mut filled = [false; 3];
|
||||
for (_, s) in self.sections() {
|
||||
let own = [s.headers.default, s.headers.first, s.headers.even];
|
||||
let wants = [true, s.title_page, even];
|
||||
let mut hit = false;
|
||||
for ((o, w), f) in own.into_iter().zip(wants).zip(filled.iter_mut()) {
|
||||
match (header, o) {
|
||||
(Some(h), Some(o)) => hit |= h == o,
|
||||
// A slot the pages show with no header of its own, nor an earlier one's.
|
||||
(None, None) => hit |= w && !*f,
|
||||
_ => {}
|
||||
}
|
||||
*f |= o.is_some() || w;
|
||||
}
|
||||
if hit {
|
||||
return fit_of(s);
|
||||
}
|
||||
}
|
||||
fit_of(&self.last_section)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Rounded to a hundredth of a point: (x, y, w, h).
|
||||
fn r(r: Option<Rect>) -> Option<(f32, f32, f32, f32)> {
|
||||
let q = |v: f32| (v * 100.0).round() / 100.0;
|
||||
r.map(|r| (q(r.x), q(r.y), q(r.w), q(r.h)))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picture_is_placed_in_the_text_area() {
|
||||
let body = Rect::new(72.0, 72.0, 468.0, 648.0);
|
||||
let fit = (468.0, 648.0);
|
||||
// Auto: as large as fits, centred.
|
||||
let auto = WatermarkPicture::default();
|
||||
assert_eq!(r(auto.place(200.0, 100.0, fit, body)), Some((72.0, 279.0, 468.0, 234.0)));
|
||||
assert_eq!(r(auto.place(100.0, 400.0, fit, body)), Some((225.0, 72.0, 162.0, 648.0)));
|
||||
// A scale is a percentage of the picture's own size, and may spill past the margins.
|
||||
let half = WatermarkPicture { scale: Some(50.0), ..Default::default() };
|
||||
assert_eq!(r(half.place(200.0, 100.0, fit, body)), Some((256.0, 371.0, 100.0, 50.0)));
|
||||
let big = WatermarkPicture { scale: Some(500.0), ..Default::default() };
|
||||
assert_eq!(r(big.place(200.0, 100.0, fit, body)), Some((-194.0, 146.0, 1000.0, 500.0)));
|
||||
// Mirrored margins move the page's text area, and the picture with it; Auto's size is
|
||||
// still the section's.
|
||||
let mirrored = Rect::new(108.0, 72.0, 468.0, 648.0);
|
||||
assert_eq!(r(auto.place(200.0, 100.0, fit, mirrored)), Some((108.0, 279.0, 468.0, 234.0)));
|
||||
// Hostile sizes and scales.
|
||||
let wild = WatermarkPicture { scale: Some(1e30), ..Default::default() };
|
||||
assert_eq!(r(wild.place(200.0, 100.0, fit, body)).map(|r| r.2), Some(1000.0));
|
||||
let tiny = WatermarkPicture { scale: Some(-5.0), ..Default::default() };
|
||||
assert_eq!(r(tiny.place(200.0, 100.0, fit, body)).map(|r| r.2), Some(2.0));
|
||||
let nan = WatermarkPicture { scale: Some(f32::NAN), ..Default::default() };
|
||||
assert_eq!(r(nan.place(200.0, 100.0, fit, body)).map(|r| r.2), Some(468.0));
|
||||
assert!(auto.place(0.0, 100.0, fit, body).is_none());
|
||||
assert!(auto.place(f32::INFINITY, 100.0, fit, body).is_none());
|
||||
assert!(auto.place(200.0, 100.0, (-1.0, 10.0), body).is_none());
|
||||
let huge = auto.size(1e30, 1e30, (1e38, 1e38)).unwrap();
|
||||
assert!(huge.0 <= MAX_SIDE + 1.0 && huge.1 <= MAX_SIDE + 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn washout_is_the_vml_values() {
|
||||
let on = WatermarkPicture::default();
|
||||
assert_eq!(on.fx(), PicFx::from_vml(WASHOUT_GAIN, WASHOUT_BLACK_LEVEL));
|
||||
assert!(WatermarkPicture::is_washout(on.fx()) && WatermarkPicture::is_washout(PicFx::from_vml(0.3, 0.35)));
|
||||
assert!(!WatermarkPicture::is_washout(PicFx::default()));
|
||||
assert!(!WatermarkPicture::is_washout(PicFx::from_vml(0.5, 0.1)));
|
||||
assert!(WatermarkPicture { washout: false, ..on }.fx().is_identity());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
use std::sync::Arc;
|
||||
use wordcraft_doc::props::{Align, CharProps, ParaProps};
|
||||
use wordcraft_doc::{Block, Document, Paragraph, Path, Pos, StoryRef, Table, para_block};
|
||||
|
||||
/// Build an empty heading followed by explicitly formatted, mixed-run body text.
|
||||
fn fixture(first: &str, style: Option<&str>) -> Document {
|
||||
let mut d = Document::from_text(&format!("{first}\nBody paragraph."));
|
||||
let p = d.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
p.props.style = Some("Heading1".into());
|
||||
p.props.outline_level = Some(0);
|
||||
p.mark = CharProps { size: Some(40.0), bold: Some(true), ..Default::default() };
|
||||
let p = d.para_mut(StoryRef::Body, &Path::top(1)).unwrap();
|
||||
p.props = ParaProps {
|
||||
style: style.map(str::to_string),
|
||||
align: Some(Align::Right),
|
||||
indent_left: Some(18.0),
|
||||
space_after: Some(7.0),
|
||||
keep_next: Some(false),
|
||||
..Default::default()
|
||||
};
|
||||
p.mark = CharProps { size: Some(11.0), bold: Some(false), lang: Some("en-GB".into()), ..Default::default() };
|
||||
p.format(0, 4, &|c| c.italic = Some(true)).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// Compare paragraph, mark and inline-run formatting independently of text.
|
||||
fn assert_format(p: &Paragraph, expected: &Paragraph) {
|
||||
assert_eq!(p.props, expected.props, "the following paragraph keeps all its properties");
|
||||
assert_eq!(p.mark, expected.mark, "the empty heading's mark must not replace the following mark");
|
||||
assert_eq!(serde_json::to_value(&p.runs).unwrap(), serde_json::to_value(&expected.runs).unwrap());
|
||||
}
|
||||
|
||||
/// Deleting an empty paragraph preserves the following style, including implicit Normal.
|
||||
#[test]
|
||||
fn deleting_an_empty_paragraph_keeps_the_following_format() {
|
||||
for first_style in ["Heading1", "Heading3", "Normal"] {
|
||||
for style in [None, Some("Normal"), Some("Heading2")] {
|
||||
let mut d = fixture("", style);
|
||||
d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().props.style = Some(first_style.into());
|
||||
let expected = d.para_at(&Pos::body(1, 0)).unwrap().clone();
|
||||
assert_eq!(d.delete_range(&Pos::body(0, 0), &Pos::body(1, 0)).unwrap(), Pos::body(0, 0));
|
||||
let p = d.para_at(&Pos::body(0, 0)).unwrap();
|
||||
assert_eq!(p.text, expected.text);
|
||||
assert_format(p, &expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A selection spanning several paragraphs keeps the surviving tail format in both directions.
|
||||
#[test]
|
||||
fn an_empty_start_preserves_the_last_paragraph_over_a_multi_paragraph_selection() {
|
||||
for backwards in [false, true] {
|
||||
let mut d = fixture("", None);
|
||||
d.body.insert(1, para_block(Paragraph::with_text("Removed middle paragraph", Default::default()).styled("Heading2")));
|
||||
let mut expected = d.para_at(&Pos::body(2, 0)).unwrap().clone();
|
||||
expected.delete(0, 5).unwrap();
|
||||
let (a, b) = (Pos::body(0, 0), Pos::body(2, 5));
|
||||
d.delete_range(if backwards { &b } else { &a }, if backwards { &a } else { &b }).unwrap();
|
||||
assert_eq!(d.body.len(), 1);
|
||||
let p = d.para_at(&Pos::body(0, 0)).unwrap();
|
||||
assert_eq!(p.text, "paragraph.");
|
||||
assert_format(p, &expected);
|
||||
}
|
||||
}
|
||||
|
||||
/// A control whose closing marker survives must move across the join without replacing tail formatting.
|
||||
#[test]
|
||||
fn an_empty_start_keeps_a_control_from_a_deleted_middle_paragraph() {
|
||||
use wordcraft_doc::control::ContentControl;
|
||||
use wordcraft_doc::{InlineObject, Revision, RevisionKind};
|
||||
|
||||
let mut d = fixture("", Some("Normal"));
|
||||
d.body.insert(1, para_block(Paragraph::with_text("Removed", Default::default())));
|
||||
d.body.push(para_block(Paragraph::with_text("After", Default::default())));
|
||||
d.revisions.push(Revision { kind: RevisionKind::Insert, ..Default::default() });
|
||||
d.revisions.push(Revision { kind: RevisionKind::Delete, ..Default::default() });
|
||||
let p = d.para_mut(StoryRef::Body, &Path::top(2)).unwrap();
|
||||
p.mark.ins = Some(0);
|
||||
p.mark.del = Some(1);
|
||||
let expected = p.clone();
|
||||
let marker = CharProps { bold: Some(true), ins: Some(0), ..Default::default() };
|
||||
d.insert_object(&Pos::body(1, 0), InlineObject::ControlStart { control: Box::new(ContentControl::default()) }, &marker).unwrap();
|
||||
d.insert_object(&Pos::body(3, 5), InlineObject::ControlEnd, &Default::default()).unwrap();
|
||||
assert_eq!(d.control_ranges(StoryRef::Body).len(), 1);
|
||||
|
||||
d.delete_range(&Pos::body(0, 0), &Pos::body(2, 0)).unwrap();
|
||||
let p = d.para_at(&Pos::body(0, 0)).unwrap();
|
||||
assert_eq!(p.props, expected.props);
|
||||
assert_eq!(p.mark, expected.mark, "tail revision IDs and formatting survive the moved marker");
|
||||
assert_eq!(p.props_of_char(0), &marker);
|
||||
assert!(matches!(p.objects.as_slice(), [InlineObject::ControlStart { .. }]));
|
||||
assert_eq!(p.text, format!("\u{fffc}{}", expected.text));
|
||||
let ranges = d.control_ranges(StoryRef::Body);
|
||||
assert_eq!(ranges.len(), 1);
|
||||
assert_eq!(ranges[0].start, Pos::body(0, 0));
|
||||
assert_eq!(ranges[0].end, Pos::body(1, 5));
|
||||
assert!(!d.has_unbalanced_controls());
|
||||
}
|
||||
|
||||
/// The empty-paragraph rule applies in header stories and table-cell containers.
|
||||
#[test]
|
||||
fn empty_paragraph_merges_work_in_a_header_and_a_table_cell() {
|
||||
for cell in [false, true] {
|
||||
let mut d = fixture("", Some("Normal"));
|
||||
let expected = d.para_at(&Pos::body(1, 0)).unwrap().clone();
|
||||
let (story, a, b) = if cell {
|
||||
let mut table = Table::new(1, 1, 300.0);
|
||||
table.rows[0].cells[0].blocks = d.body.clone();
|
||||
d.body = vec![Arc::new(Block::Table(table)), para_block(Paragraph::new())];
|
||||
(StoryRef::Body, Path(vec![0, 0, 0, 0]), Path(vec![0, 0, 0, 1]))
|
||||
} else {
|
||||
let id = d.add_part(wordcraft_doc::PartKind::Header, d.body.clone());
|
||||
(StoryRef::Part(id), Path::top(0), Path::top(1))
|
||||
};
|
||||
d.delete_range(&Pos { story, path: a.clone(), off: 0 }, &Pos { story, path: b, off: 0 }).unwrap();
|
||||
assert_format(d.para(story, &a).unwrap(), &expected);
|
||||
}
|
||||
}
|
||||
|
||||
/// Absorbing an empty section keeps the following format and inherited header.
|
||||
#[test]
|
||||
fn deleting_an_empty_section_paragraph_keeps_format_and_linked_headers() {
|
||||
let mut d = fixture("", None);
|
||||
let expected = d.para_at(&Pos::body(1, 0)).unwrap().clone();
|
||||
let header = d.add_part(wordcraft_doc::PartKind::Header, vec![para_block(Paragraph::with_text("Header", Default::default()))]);
|
||||
let mut section = d.last_section.clone();
|
||||
section.headers.default = Some(header);
|
||||
d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().section = Some(Box::new(section));
|
||||
d.last_section.margin_left = 96.0;
|
||||
d.delete_range(&Pos::body(0, 0), &Pos::body(1, 0)).unwrap();
|
||||
assert_format(d.para_at(&Pos::body(0, 0)).unwrap(), &expected);
|
||||
assert!(d.para_at(&Pos::body(0, 0)).unwrap().section.is_none());
|
||||
assert_eq!(d.last_section.headers.default, Some(header));
|
||||
assert_eq!(d.last_section.margin_left, 96.0);
|
||||
}
|
||||
|
||||
/// Deleting actual text continues to use the first paragraph merge rule.
|
||||
#[test]
|
||||
fn joining_nonempty_paragraphs_keeps_the_existing_first_paragraph_rule() {
|
||||
for off in [0, 7] {
|
||||
let mut d = fixture("Heading", None);
|
||||
let expected = d.para_at(&Pos::body(0, 0)).unwrap().props.clone();
|
||||
d.delete_range(&Pos::body(0, off), &Pos::body(1, 0)).unwrap();
|
||||
assert_eq!(d.para_at(&Pos::body(0, 0)).unwrap().props, expected);
|
||||
}
|
||||
}
|
||||
|
||||
/// Removing a soft line break must not change paragraph formatting.
|
||||
#[test]
|
||||
fn a_soft_break_is_not_an_empty_paragraph_merge() {
|
||||
let mut d = fixture("", None);
|
||||
let p = d.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
p.insert_text(0, "\nHeading", &Default::default()).unwrap();
|
||||
let expected = p.props.clone();
|
||||
d.delete_range(&Pos::body(0, 0), &Pos::body(0, 1)).unwrap();
|
||||
assert_eq!(d.body.len(), 2);
|
||||
assert_eq!(d.para_at(&Pos::body(0, 0)).unwrap().props, expected);
|
||||
}
|
||||
@@ -211,7 +211,11 @@ fn leaf(sp: &Sp, w: f32, h: f32, float: Float, b: &Build, pics: &mut Pictures) -
|
||||
kind = ShapeKind::TextBox;
|
||||
}
|
||||
let line = kind == ShapeKind::Line;
|
||||
let fill = if line || p.filled == Some(false) { None } else { Some(Fill::solid(p.fill_color.unwrap_or(Rgb::WHITE))) };
|
||||
// Brackets and braces (85 to 88, bracket and brace pairs 185, 186) are unfilled unless
|
||||
// `fFilled` says otherwise, as Word's VML shapetypes for them say (`filled="f"`). They are
|
||||
// drawn as their box here, which a default fill would make an opaque block over the text.
|
||||
let unfilled = p.filled == Some(false) || (matches!(sp.spt, 85..=88 | 185 | 186) && p.filled != Some(true));
|
||||
let fill = if line || unfilled { None } else { Some(Fill::solid(p.fill_color.unwrap_or(Rgb::WHITE))) };
|
||||
let stroke = if p.line == Some(false) { None } else { Some(p.line_color.unwrap_or(Rgb::BLACK)) };
|
||||
// Line widths are EMUs; Word's default is 0.75 pt.
|
||||
let stroke_width = p.line_width.map_or(0.75, |e| (e as f32 / 12_700.0).clamp(0.0, 100.0));
|
||||
|
||||
+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;
|
||||
|
||||
+84
-42
@@ -260,6 +260,9 @@ fn read_limited(bytes: &[u8], password: Option<&str>, max_paragraphs: usize) ->
|
||||
};
|
||||
|
||||
let mut doc = Document::new();
|
||||
// Word opens Word 97-2003 files in compatibility mode 11 (Word 2003); the model's default
|
||||
// 15 would apply Word 2013+ justification and line-breaking rules to them.
|
||||
doc.settings.compat_mode = 11;
|
||||
if let Some(core) = metadata::read(&mut comp) {
|
||||
doc.core = core;
|
||||
}
|
||||
@@ -502,29 +505,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 +563,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 +726,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 +865,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 +930,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,
|
||||
|
||||
@@ -337,6 +337,15 @@ fn empty_document_gets_a_paragraph() {
|
||||
assert_eq!(doc.body.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_doc_opens_in_word_97_2003_compatibility_mode() {
|
||||
let f = text_doc(&[Piece { compressed: false, text: "body\r".into() }], &[], 0);
|
||||
let doc = read(&f).expect("opens");
|
||||
// Word 97-2003 files are mode 11 in Word 2013+; mode 15 would change justification and
|
||||
// line breaking.
|
||||
assert_eq!(doc.settings.compat_mode, 11);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_text_stays_out_of_body() {
|
||||
let f = text_doc(
|
||||
@@ -2880,3 +2889,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:?}");
|
||||
}
|
||||
|
||||
@@ -183,6 +183,41 @@ fn shapes_keep_kind_colours_wrap_and_text_box_text() {
|
||||
assert_eq!(part.blocks.first().and_then(|b| b.as_para()).map(|p| p.text.as_str()), Some("Inside"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn braces_and_brackets_are_unfilled_unless_filled_is_set() {
|
||||
// A right brace (88) in front of the text with no fill properties, and a left bracket (85)
|
||||
// whose fFilled is set: only the bracket is filled. A plain rectangle (1) keeps its default
|
||||
// white fill.
|
||||
let brace = sp(&[fsp(88, 1026, 0xA00)]);
|
||||
let bracket = sp(&[fsp(85, 1027, 0xA00), fopt(&[(0x01BF, (1 << 20) | (1 << 4))], &[])]);
|
||||
let rect = sp(&[fsp(1, 1028, 0xA00)]);
|
||||
let art = art_content(&[], &[brace, bracket, rect]);
|
||||
let flags = spa_flags(0, 0, 3, false);
|
||||
let spas =
|
||||
plc(&[1, 2, 3, 5], &[spa(1026, [0, 0, 240, 1140], flags), spa(1027, [0, 0, 240, 1140], flags), spa(1028, [0, 0, 240, 1140], flags)].concat());
|
||||
let text = "A\u{8}\u{8}\u{8}B\r";
|
||||
let ccp = text.chars().count() as u32;
|
||||
let spec = StructSpec { text, extra_pairs: vec![(40, spas), (50, art)], ..structured_spec(text, ccp) };
|
||||
let doc = read(&struct_doc(&spec)).expect("opens");
|
||||
let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para");
|
||||
let fills: Vec<_> = p0
|
||||
.objects
|
||||
.iter()
|
||||
.map(|o| match o {
|
||||
InlineObject::Shape { fill, stroke, w, h, .. } => (fill.clone(), *stroke, *w, *h),
|
||||
other => panic!("{other:?}"),
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
fills,
|
||||
[
|
||||
(None, Some(Rgb::BLACK), 12.0, 57.0),
|
||||
(Some(Fill::solid(Rgb::WHITE)), Some(Rgb::BLACK), 12.0, 57.0),
|
||||
(Some(Fill::solid(Rgb::WHITE)), Some(Rgb::BLACK), 12.0, 57.0),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_boxes_pointing_at_the_same_story_text_read_it_once() {
|
||||
// Two text boxes whose PlcftxbxTxt stories are both [0, 7) (the table steps back to CP 0
|
||||
|
||||
@@ -10,7 +10,7 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
wordcraft-doc = { workspace = true }
|
||||
wordcraft-geom = { workspace = true, features = ["image"] }
|
||||
wordcraft-geom = { workspace = true }
|
||||
quick-xml = { workspace = true }
|
||||
zip = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
@@ -350,6 +350,7 @@ impl Package {
|
||||
let path = resolve(dir, target);
|
||||
self.canonical(&path).map_or(path, str::to_string)
|
||||
};
|
||||
rels.ids.entry(id.to_string()).or_insert(rels.list.len());
|
||||
rels.list.push(Rel { id: id.to_string(), kind: r.attr("Type").unwrap_or("").to_string(), target: resolved, external });
|
||||
}
|
||||
rels
|
||||
@@ -405,11 +406,13 @@ pub struct Rel {
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Rels {
|
||||
pub list: Vec<Rel>,
|
||||
/// Each id's first relationship in `list`, so a lookup doesn't scan every relationship.
|
||||
ids: HashMap<String, usize>,
|
||||
}
|
||||
|
||||
impl Rels {
|
||||
pub fn by_id(&self, id: &str) -> Option<&Rel> {
|
||||
self.list.iter().find(|r| r.id == id)
|
||||
self.ids.get(id).and_then(|&i| self.list.get(i))
|
||||
}
|
||||
pub fn by_type(&self, full: &str) -> Option<&Rel> {
|
||||
self.list.iter().find(|r| rel_is(&r.kind, full))
|
||||
@@ -547,6 +550,22 @@ mod tests {
|
||||
assert_eq!(crate::package_title(std::io::Cursor::new(z), 1 << 20).as_deref(), Some("Plan"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relationships_are_looked_up_by_id_the_first_one_winning() {
|
||||
let mut rels = String::from(r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">"#);
|
||||
for i in 0..20_000 {
|
||||
rels.push_str(&format!(r#"<Relationship Id="rId{i}" Type="hyperlink" Target="https://example.com/{i}" TargetMode="External"/>"#));
|
||||
}
|
||||
rels.push_str(r#"<Relationship Id="rId7" Type="hyperlink" Target="https://example.com/dup" TargetMode="External"/></Relationships>"#);
|
||||
let z = zip_of(&[("word/_rels/document.xml.rels", rels.into_bytes())]);
|
||||
let r = Package::open(&z).unwrap().rels("word/document.xml");
|
||||
assert_eq!(r.by_id("rId7").map(|r| r.target.as_str()), Some("https://example.com/7"), "the first rId7 wins");
|
||||
assert_eq!(r.by_id("rId19999").map(|r| r.target.as_str()), Some("https://example.com/19999"));
|
||||
assert!(r.by_id("rId20000").is_none());
|
||||
// Hyperlink-style lookups, one per relationship, are each a map lookup.
|
||||
assert_eq!((0..20_000).filter(|i| r.by_id(&format!("rId{i}")).is_some()).count(), 20_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decompression_bomb_is_rejected() {
|
||||
let z = zip_of(&[("word/document.xml", vec![b' '; 2_000_000])]);
|
||||
|
||||
@@ -12,7 +12,6 @@ mod props;
|
||||
mod sdt;
|
||||
mod shape_style;
|
||||
mod story;
|
||||
mod watermark;
|
||||
mod wordart;
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
@@ -117,8 +116,6 @@ pub(crate) struct Reader<'p> {
|
||||
pub theme_fills: shape_style::ThemeFills,
|
||||
/// The fill of the group being read (what its members' `a:grpFill` stands for).
|
||||
pub group_fill: Option<wordcraft_doc::fill::Fill>,
|
||||
/// The document's picture watermark from the headers, if any (`watermark::plan`).
|
||||
picture_wm: watermark::Plan,
|
||||
}
|
||||
|
||||
/// The package path that internal relationship `id` points at, if it has type `kind` (an `rt`
|
||||
@@ -191,7 +188,6 @@ fn read_opened_in(pkg: &Package, chunks: ChunkState) -> Result<(Document, usize,
|
||||
txbx_links: Vec::new(),
|
||||
theme_fills: Default::default(),
|
||||
group_fill: None,
|
||||
picture_wm: watermark::Plan::default(),
|
||||
chunks,
|
||||
};
|
||||
r.pc.major_font = r.doc.settings.major_font.clone();
|
||||
@@ -283,7 +279,6 @@ fn read_opened_in(pkg: &Package, chunks: ChunkState) -> Result<(Document, usize,
|
||||
}
|
||||
}
|
||||
if let Some(body) = root.child("w:body") {
|
||||
r.picture_wm = watermark::plan(&r, body, &rels);
|
||||
let mut sc = StoryCtx::default();
|
||||
let mut blocks = Blocks::new();
|
||||
r.read_blocks(&mut sc, body, &rels, &mut blocks, r.chunks.depth);
|
||||
|
||||
@@ -66,6 +66,9 @@ pub(super) struct Axis {
|
||||
|
||||
pub(super) struct Series {
|
||||
pub(super) name: String,
|
||||
/// The series' `c:idx` (an `xsd:unsignedInt`), which picks its automatic colour; `None`
|
||||
/// when missing or not a valid number.
|
||||
pub(super) idx: Option<usize>,
|
||||
pub(super) color: Option<Rgb>,
|
||||
pub(super) line_w: f32,
|
||||
/// Whether the series is drawn as a line (scatter series without one get markers).
|
||||
|
||||
@@ -91,8 +91,11 @@ impl Chart {
|
||||
self.accents.get(i % 6).copied().unwrap_or(Rgb::BLACK)
|
||||
}
|
||||
|
||||
/// Colour of series `j`: its own, else the accent its `c:idx` picks (as Word does; `c:order`
|
||||
/// doesn't matter), else the one its place in the file picks.
|
||||
pub(super) fn series_colour(&self, j: usize) -> Rgb {
|
||||
self.series.get(j).and_then(|s| s.color).unwrap_or_else(|| self.accent(j))
|
||||
let s = self.series.get(j);
|
||||
s.and_then(|s| s.color).unwrap_or_else(|| self.accent(s.and_then(|s| s.idx).unwrap_or(j)))
|
||||
}
|
||||
|
||||
/// Colour of point `i` of series `j`: its `c:dPt` override, else the varied accent (pie) or
|
||||
@@ -199,6 +202,7 @@ pub(super) fn text_size(space: &El) -> f32 {
|
||||
impl Series {
|
||||
fn parse(ser: &El, scatter: bool, theme: &[Rgb], cap: usize) -> Series {
|
||||
let name = ser.child("c:tx").and_then(|t| t.find("c:v")).map(|v| label(v.text().trim())).unwrap_or_default();
|
||||
let idx = ser.child("c:idx").and_then(|e| e.attr("val")).and_then(|v| v.trim().parse::<u32>().ok()).and_then(|v| usize::try_from(v).ok());
|
||||
let sp = ser.child("c:spPr");
|
||||
let ln = sp.and_then(|p| p.child("a:ln"));
|
||||
let marker = ser.child("c:marker").and_then(|m| m.child("c:spPr")).and_then(|p| fill_colour(p, theme));
|
||||
@@ -219,7 +223,7 @@ impl Series {
|
||||
}
|
||||
}
|
||||
let labels = show_values(ser.child("c:dLbls"));
|
||||
Series { name, color, line_w, line, values, xs, points, fmt, x_fmt, labels, raw: Vec::new() }
|
||||
Series { name, idx, color, line_w, line, values, xs, points, fmt, x_fmt, labels, raw: Vec::new() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -528,3 +528,65 @@ fn negative_sections_and_scientific_numbers() {
|
||||
assert_eq!(format_value(0.0, "0.00E+00", 1.0), "0.00E+00");
|
||||
assert_eq!(format_value(-12345.0, "0.0E-00", 1.0), "-1.2E04");
|
||||
}
|
||||
|
||||
// An automatic series colour follows the series' `c:idx`, not its place in the file (#5963), as
|
||||
// measured in Word: `idx` 3 then 0 are accent4 then accent1, a lone `idx` 2 is accent3, and
|
||||
// `c:order` changes nothing.
|
||||
|
||||
/// The fill colours of the bars of a column chart of `sers`, and of its legend swatches.
|
||||
fn column_colours(sers: &[String]) -> (Vec<Rgb>, Vec<Rgb>) {
|
||||
let plot = format!(
|
||||
r#"<c:barChart><c:barDir val="col"/><c:grouping val="clustered"/><c:varyColors val="0"/>{}<c:axId val="1"/><c:axId val="2"/></c:barChart><c:catAx><c:axId val="1"/></c:catAx><c:valAx><c:axId val="2"/></c:valAx>"#,
|
||||
sers.concat()
|
||||
);
|
||||
let items = with_legend(&plot, "r", 300.0, 200.0);
|
||||
let bars = filled(&items).iter().map(|b| b.0).collect();
|
||||
let keys = items
|
||||
.iter()
|
||||
.filter_map(|it| if let GraphicItem::Shape { rect, fill, .. } = it { (rect[2] == SWATCH).then_some(*fill).flatten() } else { None })
|
||||
.collect();
|
||||
(bars, keys)
|
||||
}
|
||||
|
||||
/// `ser` with its `c:idx` / `c:order` replaced by `idx_order` (an empty string drops them).
|
||||
fn renumbered(ser: &str, idx_order: &str) -> String {
|
||||
let start = ser.find("<c:idx").expect("ser writes c:idx");
|
||||
let end = ser.find("<c:tx>").expect("ser writes c:tx");
|
||||
format!("{}{idx_order}{}", &ser[..start], &ser[end..])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_series_colours_follow_c_idx() {
|
||||
let accent = |k: usize| theme()[4 + k];
|
||||
let a = ser(0, "A", None, &["Q1"], &["1"]);
|
||||
let b = ser(1, "B", None, &["Q1"], &["2"]);
|
||||
// idx 3 first, idx 0 second: accent4, accent1, in the bars and the legend.
|
||||
let sers = [renumbered(&a, r#"<c:idx val="3"/><c:order val="0"/>"#), renumbered(&b, r#"<c:idx val="0"/><c:order val="1"/>"#)];
|
||||
assert_eq!(column_colours(&sers), (vec![accent(3), accent(0)], vec![accent(3), accent(0)]));
|
||||
// A lone series with idx 2 is accent3.
|
||||
let lone = [renumbered(&a, r#"<c:idx val="2"/><c:order val="0"/>"#)];
|
||||
assert_eq!(column_colours(&lone).0, [accent(2)]);
|
||||
// c:order doesn't matter: idx 0 and 1 stay accent1 and accent2 with their orders swapped.
|
||||
let swapped = [renumbered(&a, r#"<c:idx val="0"/><c:order val="1"/>"#), renumbered(&b, r#"<c:idx val="1"/><c:order val="0"/>"#)];
|
||||
assert_eq!(column_colours(&swapped).0, [accent(0), accent(1)]);
|
||||
// A series' own colour still wins.
|
||||
let own = [renumbered(&ser(0, "A", Some("FF0000"), &["Q1"], &["1"]), r#"<c:idx val="3"/><c:order val="0"/>"#)];
|
||||
assert_eq!(column_colours(&own).0, [Rgb(0xFF, 0, 0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn series_without_a_usable_c_idx_take_their_place() {
|
||||
let accent = |k: usize| theme()[4 + k];
|
||||
let a = ser(0, "A", None, &["Q1"], &["1"]);
|
||||
let b = ser(1, "B", None, &["Q1"], &["2"]);
|
||||
// No c:idx at all: the place in the file.
|
||||
assert_eq!(column_colours(&[renumbered(&a, ""), renumbered(&b, "")]).0, [accent(0), accent(1)]);
|
||||
// Junk, negative and out-of-range values: no panic, the place in the file.
|
||||
for junk in ["x", "-1", "1e3", "99999999999999999999999"] {
|
||||
let sers = [renumbered(&a, &format!(r#"<c:idx val="{junk}"/>"#)), renumbered(&b, &format!(r#"<c:idx val="{junk}"/>"#))];
|
||||
assert_eq!(column_colours(&sers).0, [accent(0), accent(1)], "{junk:?}");
|
||||
}
|
||||
// The largest unsignedInt `c:idx` allows draws, without a panic.
|
||||
let big = [renumbered(&a, &format!(r#"<c:idx val="{}"/>"#, u32::MAX))];
|
||||
assert_eq!(column_colours(&big).0.len(), 1);
|
||||
}
|
||||
|
||||
@@ -239,7 +239,13 @@ impl Reader<'_> {
|
||||
/// Keep one part's bytes and content type (not its relationships).
|
||||
fn keep_part(&mut self, part: &str) -> bool {
|
||||
let lower = part.to_ascii_lowercase();
|
||||
if !manifest_field_ok(part) || lower.ends_with(".rels") || lower == "[content_types].xml" || part.eq_ignore_ascii_case(&self.main) {
|
||||
// `wordcraft:` names are WordCraft's own bookkeeping in `passthrough`, never a file's part.
|
||||
if !manifest_field_ok(part)
|
||||
|| lower.ends_with(".rels")
|
||||
|| lower == "[content_types].xml"
|
||||
|| lower.starts_with("wordcraft:")
|
||||
|| part.eq_ignore_ascii_case(&self.main)
|
||||
{
|
||||
log::warn!("docx: not carrying part {part:?} for an embedded object");
|
||||
return false;
|
||||
}
|
||||
@@ -388,6 +394,23 @@ mod tests {
|
||||
assert!(roots.contains("keep.bin"), "the part is dropped: {roots}");
|
||||
}
|
||||
|
||||
/// A part named like WordCraft's own bookkeeping (`wordcraft:unmodelled.roots`) is not carried:
|
||||
/// kept, it would be read back as that bookkeeping and add relationships the file chose.
|
||||
#[test]
|
||||
fn a_part_named_like_bookkeeping_is_not_carried() {
|
||||
let forged = b"D\thttp://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument\tword/evil.xml\n";
|
||||
let bytes = package(
|
||||
r#"<w:p><w:r><w:t>text</w:t></w:r></w:p>"#,
|
||||
&[("rIdDoc1", "/wordcraft:unmodelled.roots"), ("rIdDoc2", "/wordcraft:embedded.parts")],
|
||||
&[("wordcraft:unmodelled.roots", forged), ("wordcraft:embedded.parts", b"P\tword/evil.xml\tapplication/xml\n")],
|
||||
);
|
||||
let doc = crate::read(&bytes).unwrap();
|
||||
let roots = unmodelled_roots(&doc);
|
||||
assert!(!roots.contains("wordcraft:") && !roots.contains("evil"), "{roots}");
|
||||
let manifest = doc.passthrough.get(crate::package::EMBEDDED_PARTS).map(|v| String::from_utf8_lossy(v).to_string()).unwrap_or_default();
|
||||
assert!(!manifest.contains("evil"), "{manifest}");
|
||||
}
|
||||
|
||||
/// An unqualified attribute is a relationship only where the markup names a relationship
|
||||
/// that way: a hyperlink's `id` names the part, a VML shape's `id` is its own.
|
||||
#[test]
|
||||
|
||||
@@ -55,6 +55,8 @@ impl Reader<'_> {
|
||||
.children("w:font")
|
||||
.filter(|f| f.child("w:family").and_then(|v| v.attr("w:val")) == Some("roman"))
|
||||
.filter_map(|f| f.attr("w:name"))
|
||||
.flat_map(|n| n.split(';'))
|
||||
.map(str::trim)
|
||||
.filter(|n| !n.is_empty() && n.len() <= MAX_FONT_NAME)
|
||||
.take(MAX_TABLE_FONTS)
|
||||
.map(str::to_string)
|
||||
@@ -142,7 +144,6 @@ impl Reader<'_> {
|
||||
let rels = self.pkg.rels(path);
|
||||
let mut sc = StoryCtx::default();
|
||||
sc.in_hf = true;
|
||||
sc.picture_wm = !footer && self.picture_wm_header(path);
|
||||
let mut blocks = Blocks::new();
|
||||
self.hf_depth += 1;
|
||||
self.read_blocks(&mut sc, &root, &rels, &mut blocks, self.chunks.depth);
|
||||
|
||||
@@ -5,16 +5,17 @@ use std::collections::HashMap;
|
||||
use wordcraft_doc::para::cap_alt;
|
||||
use wordcraft_doc::props::{
|
||||
Align, Border, BorderStyle, Borders, CellProps, CharProps, CombineBrackets, EastAsianLayout, HeightRule, Highlight, LineSpacing, NumRef,
|
||||
ParaProps, Rgb, RowProps, TabAlign, TabLeader, TabStop, TableFloat, TableLook, TableProps, TextAlignment, TextColor, TextDirection, Underline,
|
||||
VAlign, VMerge, VertAlign,
|
||||
ParaProps, ParagraphTextDirection, Rgb, RowProps, TabAlign, TabLeader, TabStop, TableFloat, TableLook, TableProps, TextAlignment, TextColor,
|
||||
TextDirection, Underline, VAlign, VMerge, VertAlign,
|
||||
};
|
||||
use wordcraft_doc::section::{
|
||||
Columns, DocGrid, DocGridType, LineNumberRestart, LineNumbering, MAX_CHAR_SPACE, MAX_GRID_PITCH, NotePos, NoteProps, NoteRestart, NumFormat,
|
||||
PageVAlign, SectionProps, SectionStart,
|
||||
};
|
||||
|
||||
use super::MAX_FONT_NAME;
|
||||
use super::drawing_color::adjust_lum;
|
||||
use crate::units::{int, line_multiple, measure, on_off, tw, u32_of};
|
||||
use crate::units::{int, line_multiple, measure, on_off, tw, tw_i16, tw_u16, u32_of};
|
||||
|
||||
/// A text frame (`w:framePr` without a drop cap, ECMA-376 §17.3.1.11), sizes and offsets in
|
||||
/// points (clamped), names kept as OOXML writes them.
|
||||
@@ -145,6 +146,29 @@ pub(crate) fn resolve_level_only(sheet: &wordcraft_doc::styles::StyleSheet, p: &
|
||||
}
|
||||
}
|
||||
|
||||
/// A `w:rFonts` value as the document keeps it: `None` when it names no family, else its leading
|
||||
/// families up to [`MAX_FONT_NAME`] bytes, cut at a `;` so no name is split. A LibreOffice file
|
||||
/// names its Latin font as a semicolon-separated fallback list, and a real list names a few
|
||||
/// families; a longer value is corrupt or hostile, and every piece of text drawn in it would pay
|
||||
/// for its whole length as the list is classified and resolved. Keeping the leading families keeps
|
||||
/// the family such a list picks, and the rest of the value is dropped like `w:cs`/`w:eastAsia`
|
||||
/// drop an over-long name.
|
||||
fn bounded_font_list(v: &str) -> Option<String> {
|
||||
if v.len() <= MAX_FONT_NAME {
|
||||
return (!v.is_empty()).then(|| v.to_string());
|
||||
}
|
||||
let mut end = 0usize;
|
||||
for part in v.split_inclusive(';') {
|
||||
let next = end + part.len();
|
||||
if next > MAX_FONT_NAME {
|
||||
break;
|
||||
}
|
||||
end = next;
|
||||
}
|
||||
let kept = v.get(..end)?.trim_end_matches(';');
|
||||
(!kept.is_empty()).then(|| kept.to_string())
|
||||
}
|
||||
|
||||
impl PropCtx {
|
||||
pub fn style_id(&self, id: &str) -> String {
|
||||
self.alias.get(id).cloned().unwrap_or_else(|| id.to_string())
|
||||
@@ -226,6 +250,8 @@ impl PropCtx {
|
||||
shd_fill(e)
|
||||
}
|
||||
|
||||
/// The character properties `e` (a `w:rPr`) sets: direct formatting, plus the fonts, size,
|
||||
/// weight and posture of the complex-script and East Asian text beside them.
|
||||
pub fn rpr(&self, e: &El) -> CharProps {
|
||||
let mut c = CharProps::default();
|
||||
for k in e.els() {
|
||||
@@ -234,7 +260,11 @@ impl PropCtx {
|
||||
"w:rFonts" => {
|
||||
// A theme font supersedes the fixed font beside it (`w:asciiTheme` over
|
||||
// `w:ascii`, `w:hAnsiTheme` over `w:hAnsi`).
|
||||
let named = |a: &str| k.attr(a).filter(|f| !f.is_empty()).map(str::to_string);
|
||||
// A LibreOffice file names the Latin font as a semicolon-separated fallback
|
||||
// list. Names are bounded like `w:cs`/`w:eastAsia` below: a longer one is
|
||||
// corrupt or hostile, and every piece of text drawn in it would pay for its
|
||||
// whole length as the list is classified (see `bounded_font_list`).
|
||||
let named = |a: &str| k.attr(a).and_then(bounded_font_list);
|
||||
let themed = |a: &str| k.attr(a).and_then(|t| self.theme_font(t));
|
||||
// `w:eastAsia` and `w:cs` name fonts for other scripts and stay in `font_ea` /
|
||||
// `font_cs`: `w:hint` only picks between them for ambiguous characters, never for
|
||||
@@ -338,6 +368,7 @@ impl PropCtx {
|
||||
"w:suppressAutoHyphens" => p.suppress_hyphens = Some(on_off(k)),
|
||||
"w:suppressLineNumbers" => p.suppress_line_numbers = Some(on_off(k)),
|
||||
"w:bidi" => p.bidi = Some(on_off(k)),
|
||||
"w:textDirection" => p.text_direction = k.attr("w:val").and_then(ParagraphTextDirection::from_ooxml),
|
||||
"w:kinsoku" => p.kinsoku = Some(on_off(k)),
|
||||
"w:wordWrap" => p.word_wrap = Some(on_off(k)),
|
||||
"w:overflowPunct" => p.overflow_punct = Some(on_off(k)),
|
||||
@@ -376,10 +407,10 @@ impl PropCtx {
|
||||
p.tabs = Some(tabs);
|
||||
}
|
||||
"w:spacing" => {
|
||||
if let Some(v) = tw(k, "w:before") {
|
||||
if let Some(v) = tw_u16(k, "w:before") {
|
||||
p.space_before = Some(v.max(0.0));
|
||||
}
|
||||
if let Some(v) = tw(k, "w:after") {
|
||||
if let Some(v) = tw_u16(k, "w:after") {
|
||||
p.space_after = Some(v.max(0.0));
|
||||
}
|
||||
// HTML-style automatic spacing; "0" switches an inherited one off.
|
||||
@@ -402,15 +433,15 @@ impl PropCtx {
|
||||
}
|
||||
}
|
||||
"w:ind" => {
|
||||
if let Some(v) = tw(k, "w:left").or_else(|| tw(k, "w:start")) {
|
||||
if let Some(v) = tw_i16(k, "w:left").or_else(|| tw_i16(k, "w:start")) {
|
||||
p.indent_left = Some(v);
|
||||
}
|
||||
if let Some(v) = tw(k, "w:right").or_else(|| tw(k, "w:end")) {
|
||||
if let Some(v) = tw_i16(k, "w:right").or_else(|| tw_i16(k, "w:end")) {
|
||||
p.indent_right = Some(v);
|
||||
}
|
||||
if let Some(v) = tw(k, "w:hanging") {
|
||||
if let Some(v) = tw_i16(k, "w:hanging") {
|
||||
p.indent_first = Some(-v);
|
||||
} else if let Some(v) = tw(k, "w:firstLine") {
|
||||
} else if let Some(v) = tw_i16(k, "w:firstLine") {
|
||||
p.indent_first = Some(v);
|
||||
}
|
||||
// Character units (hundredths of a character), which win when non-zero.
|
||||
|
||||
@@ -25,8 +25,6 @@ mod fields;
|
||||
mod runs;
|
||||
mod vml;
|
||||
|
||||
pub(super) use vml::{vml_spin, vml_style, vml_watermark};
|
||||
|
||||
const INK_URI: &str = "http://schemas.microsoft.com/office/word/2010/wordprocessingInk";
|
||||
const INK_REL: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/customXml";
|
||||
/// The `a:graphicData` URI of an Office 2016 chart (`cx:chart`).
|
||||
@@ -115,10 +113,6 @@ pub struct StoryCtx {
|
||||
pub story_depth: usize,
|
||||
/// Reading a header or footer (not a text box inside one): where Word keeps its watermark.
|
||||
pub in_hf: bool,
|
||||
/// This header's Word picture watermark is the document's watermark (see `watermark::plan`).
|
||||
pub picture_wm: bool,
|
||||
/// Reading the content of a tracked deletion: a watermark shape in it is not the document's.
|
||||
pub in_deletion: bool,
|
||||
}
|
||||
|
||||
/// Paragraph content under construction.
|
||||
@@ -164,7 +158,7 @@ fn clean_text(s: &str) -> String {
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn children_of_choice(ac: &El) -> Option<&El> {
|
||||
fn children_of_choice(ac: &El) -> Option<&El> {
|
||||
// Prefer a Choice whose requirements we understand; else the Fallback; else any Choice.
|
||||
// `m`: Office Math (a producer may guard an equation with a picture fallback, #1273).
|
||||
const KNOWN: &[&str] = &["wps", "wpg", "wpc", "wpi", "w14", "w15", "wp14", "a14", "w16se", "w16cid", "w16", "w16cex", "w16sdtdh", "v", "m"];
|
||||
|
||||
@@ -41,6 +41,7 @@ impl Reader<'_> {
|
||||
}
|
||||
let source = self.keep_embedded(c.child("a:graphic")?, rels, &[])?;
|
||||
float.set_spin(xfrm_spin(content.child("w14:xfrm")));
|
||||
float.bare_turn = !anchored && float.rot != 0.0 && float.effect == [0.0; 4];
|
||||
let (px, py) = float.spin_pad(w, h);
|
||||
for (e, p) in float.effect.iter_mut().zip([px, py, px, py]) {
|
||||
if *e != 0.0 {
|
||||
@@ -106,7 +107,11 @@ impl Reader<'_> {
|
||||
self.set_shape_id(&mut obj, c.child("wp:docPr").and_then(|p| p.attr("id")));
|
||||
// Word's effect extent also covers a rotated object's overhang; the model keeps that
|
||||
// apart (it follows from the angle), so take it back out.
|
||||
// Shadow or glow room the file didn't record: saving records it (and the turn's room).
|
||||
let fx_room = matches!(&obj, InlineObject::Shape { effects, .. } if effects.extent() != [0.0; 4]);
|
||||
if let Some(f) = obj.float_mut() {
|
||||
// None recorded: Word keeps an inline one's line to the frame (see `Float::bare_turn`).
|
||||
f.bare_turn = !anchored && !fx_room && f.rot != 0.0 && f.effect == [0.0; 4];
|
||||
let (px, py) = f.spin_pad(w, h);
|
||||
for (e, p) in f.effect.iter_mut().zip([px, py, px, py]) {
|
||||
// Zero adds no effects room when rotation contracts this axis.
|
||||
@@ -118,9 +123,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 +178,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 +590,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,18 +237,14 @@ 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" => {
|
||||
if let Some(o) = self.read_signature_line(sc, k, rels) {
|
||||
self.emit_obj(sc, pb, o, props);
|
||||
} else {
|
||||
// A watermark in a tracked deletion is not the document's: it stays a shape.
|
||||
sc.in_deletion = props.del.is_some();
|
||||
let objects = self.read_vml_all(sc, k, rels);
|
||||
sc.in_deletion = false;
|
||||
for o in objects {
|
||||
for o in self.read_vml_all(sc, k, rels) {
|
||||
self.emit_obj(sc, pb, o, props);
|
||||
}
|
||||
}
|
||||
@@ -302,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);
|
||||
@@ -380,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) => {
|
||||
|
||||
@@ -21,8 +21,7 @@ impl Reader<'_> {
|
||||
break;
|
||||
}
|
||||
if sc.in_hf
|
||||
&& !sc.in_deletion
|
||||
&& let Some(wm) = vml_watermark(e).or_else(|| self.picture_watermark(sc, e, rels))
|
||||
&& let Some(wm) = vml_watermark(e)
|
||||
{
|
||||
// The document's watermark (the first one found); drawn on every page, not as
|
||||
// a shape, so it is not one of the objects this picture contributes.
|
||||
@@ -225,6 +224,15 @@ impl Reader<'_> {
|
||||
{
|
||||
let id = self.read_textbox(sc, t, rels);
|
||||
if let Some(part) = self.doc.parts.get_mut(&id) {
|
||||
// Text direction (#5193), as Word reads it: `mso-layout-flow-alt:bottom-to-top` is
|
||||
// `vert270` whatever `layout-flow` says; otherwise `layout-flow:vertical…` (also
|
||||
// `vertical-ideographic`, our `eaVert`) is `vert`, and anything else horizontal.
|
||||
let tb_style = tb.attr("style").unwrap_or("");
|
||||
if vml_style(tb_style, "mso-layout-flow-alt").is_some_and(|v| v.eq_ignore_ascii_case("bottom-to-top")) {
|
||||
part.body.vert = wordcraft_doc::props::TextVert::Vert270;
|
||||
} else if vml_style(tb_style, "layout-flow").is_some_and(|v| v.to_ascii_lowercase().starts_with("vertical")) {
|
||||
part.body.vert = wordcraft_doc::props::TextVert::Vert;
|
||||
}
|
||||
// Internal margins `inset="l,t,r,b"` (ECMA-376 Part 4 §19.1.2.22), each one left
|
||||
// out keeping Word's default; a bare number is in EMUs, VML's base unit.
|
||||
// `o:insetmode="auto"` leaves them to the application.
|
||||
@@ -483,7 +491,7 @@ fn vml_textbox(e: &El, depth: usize) -> Option<(&El, &El)> {
|
||||
})
|
||||
}
|
||||
|
||||
pub(in crate::read) fn vml_style<'a>(style: &'a str, key: &str) -> Option<&'a str> {
|
||||
fn vml_style<'a>(style: &'a str, key: &str) -> Option<&'a str> {
|
||||
style.rsplit(';').filter_map(|d| d.split_once(':')).find(|(k, _)| k.trim().eq_ignore_ascii_case(key)).map(|(_, v)| v.trim())
|
||||
}
|
||||
|
||||
@@ -606,10 +614,10 @@ fn vml_float(shape: &El, style: &str, in_hf: bool) -> Float {
|
||||
|
||||
/// Word's text watermark (Design › Watermark): a VML shape in a header whose `v:textpath` draws
|
||||
/// its `string` as text (ECMA-376 Part 4, VML `textpath`), in the textpath's `font-family`, filled
|
||||
/// with the shape's `fillcolor`, as transparent as its `v:fill` `opacity` says (any value), and
|
||||
/// with the shape's `fillcolor`, semitransparent when its `v:fill` has an `opacity` below 1, and
|
||||
/// diagonal when the shape is turned. Only Word's watermark shape counts (its `id`), or text
|
||||
/// placed absolutely and centred both ways; other header WordArt (a logo or a title) is None.
|
||||
pub(in crate::read) fn vml_watermark(shape: &El) -> Option<wordcraft_doc::Watermark> {
|
||||
fn vml_watermark(shape: &El) -> Option<wordcraft_doc::Watermark> {
|
||||
let tp = shape.child("v:textpath")?;
|
||||
if tp.attr("on").is_some_and(|v| matches!(v, "f" | "false" | "0")) {
|
||||
return None;
|
||||
@@ -639,9 +647,7 @@ pub(in crate::read) fn vml_watermark(shape: &El) -> Option<wordcraft_doc::Waterm
|
||||
// The box the text is stretched to (`fitshape`).
|
||||
let len = |k: &str| vml_style(style, k).and_then(|v| measure(v, 1.0)).filter(|v| v.is_finite() && *v > 0.0).map(|v| v.min(4000.0));
|
||||
let size = len("width").zip(len("height")).map(|(w, h)| [w, h]);
|
||||
let mut wm = wordcraft_doc::Watermark { text, font, color, diagonal: vml_spin(style).deg != 0.0, size, ..default };
|
||||
wm.set_transparency(((1.0 - opacity) * 100.0).round().clamp(0.0, 100.0) as u8);
|
||||
Some(wm)
|
||||
Some(wordcraft_doc::Watermark { text, font, color, diagonal: vml_spin(style).deg != 0.0, semitransparent: opacity < 1.0, size })
|
||||
}
|
||||
|
||||
/// A VML `opacity`, 0..=1.
|
||||
@@ -651,7 +657,7 @@ fn vml_opacity(v: &str) -> Option<f32> {
|
||||
|
||||
/// A VML shape's turn: `rotation` in degrees, or 65536ths of one with an `fd` suffix; `flip`: `x`
|
||||
/// and/or `y`.
|
||||
pub(in crate::read) fn vml_spin(style: &str) -> wordcraft_geom::Spin {
|
||||
fn vml_spin(style: &str) -> wordcraft_geom::Spin {
|
||||
let get = |k: &str| vml_style(style, k);
|
||||
let rot = get("rotation").and_then(|v| match v.strip_suffix("fd") {
|
||||
Some(fd) => fd.trim().parse::<f32>().ok().map(|f| f / 65_536.0),
|
||||
|
||||
@@ -2,6 +2,7 @@ use super::{
|
||||
AbstractNum, DocxError, El, Level, LevelSuffix, Num, NumFormat, PartFormat, Reader, Style, StyleKind, StyleSheet, TablePart, TableStyleParts,
|
||||
VAlign, flag, int, props, tw, u32_of,
|
||||
};
|
||||
use crate::units::tw_i16;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Built-in style names Word stores in lower case, and how the UI shows them.
|
||||
@@ -317,12 +318,12 @@ impl Reader<'_> {
|
||||
lv.align = props::align(j);
|
||||
}
|
||||
if let Some(ind) = l.child("w:pPr").and_then(|p| p.child("w:ind")) {
|
||||
if let Some(v) = tw(ind, "w:left").or_else(|| tw(ind, "w:start")) {
|
||||
if let Some(v) = tw_i16(ind, "w:left").or_else(|| tw_i16(ind, "w:start")) {
|
||||
lv.indent = v;
|
||||
}
|
||||
if let Some(h) = tw(ind, "w:hanging") {
|
||||
if let Some(h) = tw_i16(ind, "w:hanging") {
|
||||
lv.hanging = h;
|
||||
} else if let Some(f) = tw(ind, "w:firstLine") {
|
||||
} else if let Some(f) = tw_i16(ind, "w:firstLine") {
|
||||
lv.hanging = -f;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,13 +83,14 @@ pub(super) fn toc_control_to_toc(body: &mut Blocks) {
|
||||
}
|
||||
// An empty paragraph opening the control takes the field. It may end a section, as above
|
||||
// entries in columns (#3889).
|
||||
if engine.get(s).and_then(|b| b.as_para()).is_some_and(|p| p.is_empty()) {
|
||||
let empty_heading = engine.get(s).and_then(|b| b.as_para()).is_some_and(|p| p.is_empty());
|
||||
if empty_heading {
|
||||
field_to_heading(&mut engine, s + 1);
|
||||
} else {
|
||||
toc_field_to_heading(&mut engine);
|
||||
}
|
||||
if crate::write::written_toc_span(&engine) == Some((s, last)) {
|
||||
*body = toc;
|
||||
*body = if empty_heading { toc } else { engine };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,7 +98,7 @@ pub(super) fn toc_control_to_toc(body: &mut Blocks) {
|
||||
/// reproduce exactly. Other imported TOCs keep their explicit boundaries, independent of styles.
|
||||
pub(super) fn toc_field_to_heading(body: &mut Blocks) {
|
||||
for i in 1..body.len() {
|
||||
if body[i - 1].as_para().is_some_and(|p| p.props.style.as_deref() == Some("TOCHeading")) {
|
||||
if body.get(i - 1).and_then(|b| b.as_para()).is_some_and(|p| p.props.style.as_deref() == Some("TOCHeading")) {
|
||||
field_to_heading(body, i);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,334 +0,0 @@
|
||||
//! Word's picture watermark (Design › Watermark › Picture watermark): a VML picture with Word's
|
||||
//! `WordPictureWatermark` id in the headers. It becomes the document's watermark only when every
|
||||
//! page shows the same one, placed as Word places it; otherwise each stays a picture in its header,
|
||||
//! so a cover page without it, a section with another picture, or one moved by hand keep theirs.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use wordcraft_doc::picfx::PicFx;
|
||||
use wordcraft_doc::watermark::{MAX_SCALE, MIN_SCALE, own_size};
|
||||
use wordcraft_doc::{SectionProps, Watermark, WatermarkPicture};
|
||||
|
||||
use super::Reader;
|
||||
use super::props::{HfRef, sectpr};
|
||||
use super::story::{StoryCtx, children_of_choice, vml_style, vml_watermark};
|
||||
use crate::package::Rels;
|
||||
use crate::units::{measure, vml_number};
|
||||
use crate::xml::{El, MAX_DEPTH};
|
||||
|
||||
/// Word's watermark picture shape id starts with this.
|
||||
const WORD_ID: &str = "WordPictureWatermark";
|
||||
|
||||
/// Most sections, and header or footer parts, looked at; past them the pictures stay put.
|
||||
const MAX_SECTIONS: usize = 10_000;
|
||||
const MAX_PARTS: usize = 64;
|
||||
|
||||
/// The document's picture watermark as the plan found it: the header parts whose Word picture
|
||||
/// watermark it is (empty: none), and the scale and Washout every one of them agrees on.
|
||||
#[derive(Default)]
|
||||
pub(super) struct Plan {
|
||||
headers: HashSet<String>,
|
||||
scale: Option<f32>,
|
||||
washout: bool,
|
||||
}
|
||||
|
||||
/// A Word picture watermark shape as Word places it: absolutely, centred on the margins both
|
||||
/// ways, behind the text, shown, not turned or flipped, its picture carrying Word's Washout or
|
||||
/// nothing.
|
||||
struct Shape<'a> {
|
||||
rid: &'a str,
|
||||
size: Option<(f32, f32)>,
|
||||
washout: bool,
|
||||
}
|
||||
|
||||
/// `e` as Word's picture watermark shape, if it is one placed as Word places it.
|
||||
fn shape(e: &El) -> Option<Shape<'_>> {
|
||||
if e.name != "v:shape" || !e.attr("id").is_some_and(|id| id.starts_with(WORD_ID)) {
|
||||
return None;
|
||||
}
|
||||
let style = e.attr("style").unwrap_or("");
|
||||
let is = |k: &str, v: &str| vml_style(style, k).is_some_and(|x| x.eq_ignore_ascii_case(v));
|
||||
let placed = is("position", "absolute")
|
||||
&& is("mso-position-horizontal", "center")
|
||||
&& is("mso-position-horizontal-relative", "margin")
|
||||
&& is("mso-position-vertical", "center")
|
||||
&& is("mso-position-vertical-relative", "margin");
|
||||
let behind = vml_style(style, "z-index").and_then(|z| z.trim().parse::<i64>().ok()).is_some_and(|z| z < 0);
|
||||
let spin = super::story::vml_spin(style);
|
||||
if !placed || !behind || is("visibility", "hidden") || spin.deg != 0.0 || spin.flip_h || spin.flip_v {
|
||||
return None;
|
||||
}
|
||||
let data = e.child("v:imagedata")?;
|
||||
if data.attrs.iter().any(|(k, _)| !matches!(k.as_str(), "r:id" | "r:pict" | "src" | "o:title" | "gain" | "blacklevel")) {
|
||||
return None;
|
||||
}
|
||||
// Word's Washout is gain 0.3 and black level 0.35; other values stay on a header picture.
|
||||
let num = |k: &str| data.attr(k).and_then(vml_number);
|
||||
let fx = PicFx::from_vml(num("gain").unwrap_or(1.0), num("blacklevel").unwrap_or(0.0));
|
||||
let washout = if data.attr("gain").is_none() && data.attr("blacklevel").is_none() {
|
||||
false
|
||||
} else if WatermarkPicture::is_washout(fx) {
|
||||
true
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
let len = |k: &str| vml_style(style, k).and_then(|v| measure(v, 1.0)).filter(|v| v.is_finite() && *v > 0.0);
|
||||
let size = len("width").zip(len("height"));
|
||||
Some(Shape { rid: data.attr("r:id").or_else(|| data.attr("r:pict"))?, size, washout })
|
||||
}
|
||||
|
||||
/// One header's Word picture watermark: its picture, its width as a percentage of the picture's
|
||||
/// own size (pixels at 96 ppi), whether it fills its section's margins (Auto), and Washout.
|
||||
struct Seen {
|
||||
bytes: Arc<Vec<u8>>,
|
||||
percent: f32,
|
||||
fits: bool,
|
||||
washout: bool,
|
||||
}
|
||||
|
||||
/// What shape `s` shows, its picture `bytes`, in a header whose first section has room `fit`.
|
||||
/// None when the picture can't be read, or the shape has no size or is stretched out of proportion.
|
||||
fn seen(bytes: &Arc<Vec<u8>>, s: &Shape, fit: (f32, f32)) -> Option<Seen> {
|
||||
let (own_w, own_h) = own_size(bytes)?;
|
||||
let (w, h) = s.size?;
|
||||
if (h - w * own_h / own_w).abs() > 1.0 {
|
||||
return None;
|
||||
}
|
||||
let (auto_w, _) = WatermarkPicture::default().size(own_w, own_h, fit)?;
|
||||
Some(Seen { bytes: bytes.clone(), percent: w / own_w * 100.0, fits: (auto_w - w).abs() <= 1.0, washout: s.washout })
|
||||
}
|
||||
|
||||
impl Reader<'_> {
|
||||
/// The document's watermark from a header's Word picture watermark shape `e`, when the plan
|
||||
/// made that header's one the document's watermark.
|
||||
pub(super) fn picture_watermark(&mut self, sc: &StoryCtx, e: &El, rels: &Rels) -> Option<Watermark> {
|
||||
if !sc.picture_wm {
|
||||
return None;
|
||||
}
|
||||
let s = shape(e)?;
|
||||
let media = self.media_for(rels, s.rid)?;
|
||||
let pic = WatermarkPicture { media, scale: self.picture_wm.scale, washout: self.picture_wm.washout };
|
||||
Some(Watermark { picture: Some(pic), ..Default::default() })
|
||||
}
|
||||
|
||||
/// Whether header part `path`'s Word picture watermark is the document's (see [`plan`]).
|
||||
pub(super) fn picture_wm_header(&self, path: &str) -> bool {
|
||||
self.picture_wm.headers.contains(path)
|
||||
}
|
||||
}
|
||||
|
||||
/// The document's picture watermark, worked out from the section properties and header and
|
||||
/// footer parts before the body is read. Every page shows a header (default; first page with a
|
||||
/// title page; even pages with different odd and even pages, each linked to the section before
|
||||
/// when it has none), each header holds at most one Word picture watermark placed as Word places
|
||||
/// it and the shown ones hold one, all have the same picture and Washout, and all fill their
|
||||
/// section's margins (Auto) or all have the same scale. No header or footer has a text
|
||||
/// watermark, and no footer a Word picture watermark. Otherwise the plan is empty.
|
||||
pub(super) fn plan(r: &Reader, body: &El, rels: &Rels) -> Plan {
|
||||
let mut sects = Vec::new();
|
||||
sect_prs(body, 0, true, &mut sects);
|
||||
if let Some(s) = body.child("w:sectPr") {
|
||||
sects.push((s, true));
|
||||
}
|
||||
if sects.len() > MAX_SECTIONS {
|
||||
return Plan::default();
|
||||
}
|
||||
let parts = collect_parts(r, rels, §s);
|
||||
if !parts_viable(r, &parts) || !footers_clean(r, &parts.footers) {
|
||||
return Plan::default();
|
||||
}
|
||||
let Some(found) = read_headers(r, &parts.headers) else { return Plan::default() };
|
||||
let Some((scale, washout)) = agreed(&parts.shown, &found) else { return Plan::default() };
|
||||
Plan { headers: found.into_iter().filter(|(_, w)| w.is_some()).map(|(p, _)| p.to_string()).collect(), scale, washout }
|
||||
}
|
||||
|
||||
/// The header and footer parts the sections name, and the header each page kind shows.
|
||||
#[derive(Default)]
|
||||
struct Parts {
|
||||
/// Each header part, with the room Auto fills in the first section naming it
|
||||
/// (`Document::watermark_fit`).
|
||||
headers: HashMap<String, (f32, f32)>,
|
||||
footers: HashSet<String>,
|
||||
/// The header part (None: linked to none) of every page kind each section ending shows.
|
||||
shown: Vec<Option<String>>,
|
||||
}
|
||||
|
||||
/// Walks the sections in order, collecting the header and footer parts they name and the headers
|
||||
/// the pages show (each kind linked to the section before when a section has none).
|
||||
fn collect_parts(r: &Reader, rels: &Rels, sects: &[(&El, bool)]) -> Parts {
|
||||
let even = r.doc.settings.even_odd_headers;
|
||||
let mut parts = Parts::default();
|
||||
let mut linked: [Option<String>; 3] = Default::default();
|
||||
for (s, top) in sects {
|
||||
let (sp, refs) = sectpr(&r.pc, s);
|
||||
for h in &refs {
|
||||
note_ref(&mut parts, &mut linked, h, rels, &sp, *top);
|
||||
}
|
||||
if !*top {
|
||||
continue;
|
||||
}
|
||||
let [default, first, even_page] = &linked;
|
||||
parts.shown.push(default.clone());
|
||||
if sp.title_page {
|
||||
parts.shown.push(first.clone());
|
||||
}
|
||||
if even {
|
||||
parts.shown.push(even_page.clone());
|
||||
}
|
||||
}
|
||||
parts
|
||||
}
|
||||
|
||||
/// Records header or footer reference `h` of a section with properties `sp`: the part it names,
|
||||
/// and, in a section that ends one of the document's, the header kind it now links.
|
||||
fn note_ref(parts: &mut Parts, linked: &mut [Option<String>; 3], h: &HfRef, rels: &Rels, sp: &SectionProps, top: bool) {
|
||||
let Some(rel) = rels.by_id(&h.rid).filter(|x| !x.external) else { return };
|
||||
if h.footer {
|
||||
parts.footers.insert(rel.target.clone());
|
||||
return;
|
||||
}
|
||||
parts.headers.entry(rel.target.clone()).or_insert_with(|| (sp.text_width(), sp.text_height()));
|
||||
if !top {
|
||||
return;
|
||||
}
|
||||
let slot = match h.kind.as_str() {
|
||||
"first" => 1,
|
||||
"even" => 2,
|
||||
_ => 0,
|
||||
};
|
||||
if let Some(l) = linked.get_mut(slot) {
|
||||
*l = Some(rel.target.clone());
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `parts` can hold the document's watermark: not too many, every page shows a header
|
||||
/// and one of the headers is Word's.
|
||||
fn parts_viable(r: &Reader, parts: &Parts) -> bool {
|
||||
let has_word_id = |path: &str| r.pkg.get(path).is_some_and(|b| b.windows(WORD_ID.len()).any(|w| w == WORD_ID.as_bytes()));
|
||||
parts.headers.len() <= MAX_PARTS
|
||||
&& parts.footers.len() <= MAX_PARTS
|
||||
&& !parts.shown.is_empty()
|
||||
&& parts.shown.iter().all(Option::is_some)
|
||||
&& parts.headers.keys().any(|p| has_word_id(p))
|
||||
}
|
||||
|
||||
/// Whether no footer holds a watermark: a footer's text watermark is the document's watermark,
|
||||
/// and a footer's picture watermark stays where it is, so either way the headers' pictures stay.
|
||||
fn footers_clean(r: &Reader, footers: &HashSet<String>) -> bool {
|
||||
footers.iter().all(|path| {
|
||||
let Ok(Some(root)) = r.pkg.xml(path) else { return true };
|
||||
let mut shapes = Vec::new();
|
||||
word_shapes(&root, 0, "", false, false, &mut shapes) && shapes.is_empty()
|
||||
})
|
||||
}
|
||||
|
||||
/// What each header part's Word picture watermark shows (None: it has none). None when a part
|
||||
/// can't be read, has a text watermark or several pictures, or its picture isn't placed as Word
|
||||
/// places it.
|
||||
fn read_headers<'a>(r: &Reader, headers: &'a HashMap<String, (f32, f32)>) -> Option<HashMap<&'a str, Option<Seen>>> {
|
||||
let mut found = HashMap::new();
|
||||
for (path, fit) in headers {
|
||||
let root = r.pkg.xml(path).ok()??;
|
||||
let mut shapes = Vec::new();
|
||||
if !word_shapes(&root, 0, "", false, false, &mut shapes) || shapes.len() > 1 {
|
||||
return None;
|
||||
}
|
||||
let wm = match shapes.first() {
|
||||
None => None,
|
||||
Some(e) => Some(header_seen(r, path, e, *fit)?),
|
||||
};
|
||||
found.insert(path.as_str(), wm);
|
||||
}
|
||||
Some(found)
|
||||
}
|
||||
|
||||
/// What header part `path`'s watermark shape `e` shows, when it is placed as Word places it and
|
||||
/// its picture is in the package.
|
||||
fn header_seen(r: &Reader, path: &str, e: &El, fit: (f32, f32)) -> Option<Seen> {
|
||||
let hrels = r.pkg.rels(path);
|
||||
let s = shape(e)?;
|
||||
let bytes = hrels.by_id(s.rid).filter(|x| !x.external).and_then(|x| r.pkg.get_shared(&x.target))?;
|
||||
seen(bytes, &s, fit)
|
||||
}
|
||||
|
||||
/// The Scale (None: Auto) and Washout every shown header agrees on. None when a shown header has
|
||||
/// no picture, or they differ in picture, Washout, or scale.
|
||||
fn agreed(shown: &[Option<String>], found: &HashMap<&str, Option<Seen>>) -> Option<(Option<f32>, bool)> {
|
||||
if shown.iter().flatten().any(|p| found.get(p.as_str()).is_none_or(Option::is_none)) {
|
||||
return None;
|
||||
}
|
||||
let all: Vec<&Seen> = found.values().flatten().collect();
|
||||
// The first shown header's, whatever order the map holds them in.
|
||||
let first = shown.iter().flatten().find_map(|p| found.get(p.as_str())?.as_ref())?;
|
||||
if !all.iter().all(|s| s.bytes == first.bytes && s.washout == first.washout) {
|
||||
return None;
|
||||
}
|
||||
if all.iter().all(|s| s.fits) {
|
||||
return Some((None, first.washout));
|
||||
}
|
||||
let percent = (first.percent * 10.0).round() / 10.0;
|
||||
if !(MIN_SCALE..=MAX_SCALE).contains(&percent) || all.iter().any(|s| (s.percent - first.percent).abs() > 0.1) {
|
||||
return None;
|
||||
}
|
||||
Some((Some(percent), first.washout))
|
||||
}
|
||||
|
||||
/// The `w:sectPr` elements the reader reads, in order, as (element, whether it ends a section of
|
||||
/// the document): in paragraph properties through the containers `read_blocks` follows, and in
|
||||
/// table cells (which load headers but make no section).
|
||||
fn sect_prs<'a>(e: &'a El, depth: usize, top: bool, out: &mut Vec<(&'a El, bool)>) {
|
||||
if depth > MAX_DEPTH || out.len() > MAX_SECTIONS {
|
||||
return;
|
||||
}
|
||||
for k in e.els() {
|
||||
match k.name.as_str() {
|
||||
"w:p" => {
|
||||
if let Some(s) = k.child("w:pPr").and_then(|p| p.child("w:sectPr")) {
|
||||
out.push((s, top));
|
||||
}
|
||||
}
|
||||
"w:sdt" => {
|
||||
if let Some(c) = k.child("w:sdtContent") {
|
||||
sect_prs(c, depth + 1, top, out);
|
||||
}
|
||||
}
|
||||
"w:customXml" | "w:smartTag" | "w:ins" | "w:moveTo" => sect_prs(k, depth + 1, top, out),
|
||||
"mc:AlternateContent" => {
|
||||
if let Some(c) = children_of_choice(k) {
|
||||
sect_prs(c, depth + 1, top, out);
|
||||
}
|
||||
}
|
||||
"w:tbl" | "w:tr" | "w:tc" => sect_prs(k, depth + 1, false, out),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Collects the Word picture watermark shapes a header or footer would read as such (straight
|
||||
/// in a `w:pict` or `w:object`, not in a tracked deletion) into `out`. False when the part has a
|
||||
/// text watermark, or a Word picture watermark anywhere else (a group, a text box, alternate
|
||||
/// content, a deleted run).
|
||||
fn word_shapes<'a>(e: &'a El, depth: usize, parent: &str, nested: bool, deleted: bool, out: &mut Vec<&'a El>) -> bool {
|
||||
if depth > MAX_DEPTH {
|
||||
return true;
|
||||
}
|
||||
for k in e.els() {
|
||||
if k.name == "v:shape" && vml_watermark(k).is_some() {
|
||||
return false;
|
||||
}
|
||||
if k.name == "v:shape" && k.attr("id").is_some_and(|id| id.starts_with(WORD_ID)) {
|
||||
if nested || deleted || !matches!(parent, "w:pict" | "w:object") {
|
||||
return false;
|
||||
}
|
||||
out.push(k);
|
||||
continue;
|
||||
}
|
||||
let inner = nested || matches!(k.name.as_str(), "w:txbxContent" | "mc:AlternateContent" | "v:group");
|
||||
let gone = deleted || matches!(k.name.as_str(), "w:del" | "w:moveFrom");
|
||||
if !word_shapes(k, depth + 1, &k.name, inner, gone, out) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
@@ -116,6 +116,27 @@ pub fn tw(e: &El, attr: &str) -> Option<f32> {
|
||||
e.attr(attr).and_then(|v| measure(v, 20.0))
|
||||
}
|
||||
|
||||
/// Signed twips Word keeps in 16 bits (paragraph and list indents, ECMA-376 Part 1 §17.3.1.12):
|
||||
/// a plain integer outside `i16` keeps its low 16 bits, two's complement, as Word reads it
|
||||
/// (66256 → 720). Values with a unit or a fraction read as [`tw`].
|
||||
pub fn tw_i16(e: &El, attr: &str) -> Option<f32> {
|
||||
let v = e.attr(attr)?;
|
||||
match v.trim().parse::<i64>() {
|
||||
Ok(i) => Some(f32::from(i as i16) / 20.0),
|
||||
Err(_) => measure(v, 20.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Unsigned twips Word keeps in 16 bits (paragraph spacing, §17.3.1.33): a plain integer above
|
||||
/// 65535 keeps its low 16 bits (65537 → 1). Other values read as [`tw`].
|
||||
pub fn tw_u16(e: &El, attr: &str) -> Option<f32> {
|
||||
let v = e.attr(attr)?;
|
||||
match v.trim().parse::<i64>() {
|
||||
Ok(i) if i > 0xFFFF => Some(f32::from((i & 0xFFFF) as u16) / 20.0),
|
||||
_ => measure(v, 20.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// ST_OnOff: absent `w:val` = on; `0`, `false`, `off` = off.
|
||||
pub fn on_off(e: &El) -> bool {
|
||||
match e.attr("w:val") {
|
||||
@@ -148,6 +169,24 @@ pub fn emu(pt: f32) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn twips_beyond_16_bits_wrap() {
|
||||
let el = |a: &str| El { attrs: vec![("w:v".into(), a.into())], ..El::default() };
|
||||
let s = |a: &str| tw_i16(&el(a), "w:v");
|
||||
let u = |a: &str| tw_u16(&el(a), "w:v");
|
||||
assert_eq!(s("66256"), Some(36.0));
|
||||
assert_eq!(s("-65516"), Some(1.0));
|
||||
assert_eq!(s("98304"), Some(-1638.4));
|
||||
assert_eq!(s("32767"), Some(1638.35));
|
||||
assert_eq!(s("-32768"), Some(-1638.4));
|
||||
assert_eq!(s("1in"), Some(72.0));
|
||||
assert_eq!(s("x"), None);
|
||||
assert_eq!(u("65537"), Some(0.05));
|
||||
assert_eq!(u("65535"), Some(3276.75));
|
||||
assert_eq!(u("12pt"), Some(12.0));
|
||||
assert_eq!(u("-20"), Some(-1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn measures() {
|
||||
assert_eq!(measure("1440", 20.0), Some(72.0));
|
||||
|
||||
@@ -353,11 +353,11 @@ impl super::Writer<'_> {
|
||||
};
|
||||
self.embeds.markup_bytes = left;
|
||||
let mut root = xml::parse(src.xml.as_bytes()).ok()?;
|
||||
let mut used = Vec::new();
|
||||
let mut used = HashSet::new();
|
||||
rel_attr_values(&root, &mut used, 0);
|
||||
// Every relationship the markup uses must be writable, or the object would be broken.
|
||||
let writable = |id: &String| src.refs.iter().any(|r| r.id == *id && (r.external || self.embeds.available(&r.target)));
|
||||
if !used.iter().all(writable) {
|
||||
let writable: HashSet<&str> = src.refs.iter().filter(|r| r.external || self.embeds.available(&r.target)).map(|r| r.id.as_str()).collect();
|
||||
if !used.iter().all(|id| writable.contains(id.as_str())) {
|
||||
log::warn!("docx: an embedded object's parts are missing; it is left out");
|
||||
return None;
|
||||
}
|
||||
@@ -499,8 +499,9 @@ impl super::Writer<'_> {
|
||||
}
|
||||
}
|
||||
"wp:effectExtent" => {
|
||||
// Word's effect extent also covers a rotated object's overhang (its rotated bounds).
|
||||
let (px, py) = float.spin_pad(w, h);
|
||||
// Word's effect extent also covers a rotated object's overhang (its rotated bounds),
|
||||
// unless the file it came from kept none.
|
||||
let (px, py) = if float.is_bare_turn() { (0.0, 0.0) } else { float.spin_pad(w, h) };
|
||||
let [el, et, er, eb] = float.effect_extent();
|
||||
let ext = [el + px, et + py, er + px, eb + py].map(emu);
|
||||
e.attrs.retain(|(k, _)| !matches!(k.as_str(), "l" | "t" | "r" | "b"));
|
||||
@@ -637,13 +638,15 @@ fn set_attr(e: &mut El, k: &str, v: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
fn rel_attr_values(e: &El, out: &mut Vec<String>, depth: usize) {
|
||||
/// The distinct relationship ids `e` uses: a set, so a large tree (a native document's markup
|
||||
/// never went through the reader's id limit) costs no scan per attribute.
|
||||
fn rel_attr_values(e: &El, out: &mut HashSet<String>, depth: usize) {
|
||||
if depth > MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
for (k, v) in &e.attrs {
|
||||
if xml::is_rel_attr(&e.name, k) && !out.contains(v) {
|
||||
out.push(v.clone());
|
||||
out.insert(v.clone());
|
||||
}
|
||||
}
|
||||
for c in e.els() {
|
||||
@@ -851,6 +854,24 @@ fn valid_part_name(name: &str) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn relationship_ids_are_collected_once_each() {
|
||||
// A native document's embedded markup skips the reader's id limit: 40,000 distinct ids
|
||||
// (each named twice) were a scan of every id collected so far, per attribute.
|
||||
let mut xml = String::from(
|
||||
r#"<w:p xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">"#,
|
||||
);
|
||||
for i in 0..40_000 {
|
||||
xml.push_str(&format!(r#"<w:r r:id="rId{i}"/><w:r r:id="rId{i}"/>"#));
|
||||
}
|
||||
xml.push_str("</w:p>");
|
||||
let root = xml::parse(xml.as_bytes()).unwrap();
|
||||
let mut used = HashSet::new();
|
||||
rel_attr_values(&root, &mut used, 0);
|
||||
assert_eq!(used.len(), 40_000);
|
||||
assert!(used.contains("rId39999"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn charts_cut_to_the_limits_on_save_say_so_once() {
|
||||
// A model deserialised as is can hold more than a chart part keeps; the save left the rest
|
||||
|
||||
@@ -91,17 +91,6 @@ pub(crate) fn write_parts(doc: &Document, flavor: Flavor) -> Result<Vec<(String,
|
||||
let blocks: Blocks = doc.parts.get(&id).map(|p| p.blocks.clone()).unwrap_or_default();
|
||||
// The watermark goes in every header, at the start of its first paragraph.
|
||||
wr.pending_watermark = match &doc.settings.watermark {
|
||||
// A picture watermark is sized for the first section naming this header (or, for the
|
||||
// header written for the watermark alone, the first needing it): the same size the
|
||||
// screen and a PDF draw.
|
||||
Some(wm) if !footer && wm.picture.is_some() => watermark::picture(doc).and_then(|(pic, own)| {
|
||||
let fit = doc.watermark_fit((id != wr.wm_header).then_some(id));
|
||||
pic.size(own.0, own.1, fit)?;
|
||||
let file = wr.media_files.get(&pic.media)?;
|
||||
let rid = prels.add(rt::IMAGE, &format!("media/{file}"), false);
|
||||
wr.used_media.insert(pic.media.clone());
|
||||
watermark::picture_run(pic, own, done, fit, &rid)
|
||||
}),
|
||||
Some(wm) if !footer => watermark::run(wm, done, &doc.last_section),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
@@ -287,6 +287,9 @@ pub fn ppr_inner(w: &mut W, p: &ParaProps, framed: bool, num_change: Option<&Cha
|
||||
if let Some(a) = p.align {
|
||||
w.val("w:jc", align_val(a));
|
||||
}
|
||||
if let Some(direction) = p.text_direction {
|
||||
w.val("w:textDirection", direction.ooxml());
|
||||
}
|
||||
if let Some(t) = p.text_alignment {
|
||||
w.val("w:textAlignment", t.ooxml());
|
||||
}
|
||||
|
||||
@@ -86,11 +86,28 @@ pub fn plan(bl: &Blocks) -> Plan<'_> {
|
||||
&& (0..off).step_by(OBJ.len_utf8()).all(|o| matches!(p.object_at(o), Some(wordcraft_doc::InlineObject::ControlStart { .. })))
|
||||
})
|
||||
};
|
||||
// Bookmark, comment and permission markers after the end may stay there: a body-level
|
||||
// bookmark after a block control (Word's `_GoBack`) is read onto the end of its paragraph,
|
||||
// and the control still covers the whole paragraph.
|
||||
let at_end = |b: usize, off: usize| {
|
||||
use wordcraft_doc::InlineObject as O;
|
||||
para(b).is_some_and(|p| {
|
||||
let from = off + OBJ.len_utf8();
|
||||
p.text.get(from..).is_some_and(|t| t.chars().all(|c| c == OBJ))
|
||||
&& (from..p.len()).step_by(OBJ.len_utf8()).all(|o| matches!(p.object_at(o), Some(wordcraft_doc::InlineObject::ControlEnd)))
|
||||
&& (from..p.len()).step_by(OBJ.len_utf8()).all(|o| {
|
||||
matches!(
|
||||
p.object_at(o),
|
||||
Some(
|
||||
O::ControlEnd
|
||||
| O::BookmarkStart { .. }
|
||||
| O::BookmarkEnd { .. }
|
||||
| O::CommentStart { .. }
|
||||
| O::CommentEnd { .. }
|
||||
| O::PermStart { .. }
|
||||
| O::PermEnd { .. }
|
||||
)
|
||||
)
|
||||
})
|
||||
})
|
||||
};
|
||||
let mut mode = vec![Mode::Inline; ranges.len()];
|
||||
|
||||
@@ -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")]);
|
||||
@@ -288,8 +298,9 @@ impl Writer<'_> {
|
||||
}
|
||||
}
|
||||
w.empty("wp:extent", &[("cx", &cx), ("cy", &cy)]);
|
||||
// Word's effect extent also covers a rotated object's overhang (its rotated bounds).
|
||||
let (px, py) = float.spin_pad(iw, ih);
|
||||
// Word's effect extent also covers a rotated object's overhang (its rotated bounds), unless
|
||||
// the file it came from kept none.
|
||||
let (px, py) = if float.is_bare_turn() { (0.0, 0.0) } else { float.spin_pad(iw, ih) };
|
||||
let [el, et, er, eb] = float.effect_extent();
|
||||
let [el, et, er, eb] = [el + px, et + py, er + px, eb + py].map(emu);
|
||||
w.empty("wp:effectExtent", &[("l", &el), ("t", &et), ("r", &er), ("b", &eb)]);
|
||||
@@ -316,7 +327,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 +513,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 +526,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 {
|
||||
|
||||
@@ -7,7 +7,7 @@ impl Writer<'_> {
|
||||
fn watermark_headers(&mut self, s: &SectionProps) -> wordcraft_doc::section::HeaderSet {
|
||||
let mut set = s.headers;
|
||||
let linked = &mut self.linked_headers;
|
||||
if crate::write::watermark::shown(self.doc) {
|
||||
if self.doc.settings.watermark.as_ref().and_then(crate::write::watermark::text).is_some() {
|
||||
let id = self.wm_header;
|
||||
if set.default.or(linked.default).is_none() {
|
||||
set.default = Some(id);
|
||||
|
||||
@@ -1,32 +1,11 @@
|
||||
//! The document's watermark in the headers: a text watermark as a VML text shape (ECMA-376
|
||||
//! Part 4, VML: `shape`, `shapetype`, `path`, `textpath`, `fill`), a picture watermark as a VML
|
||||
//! picture (`imagedata`), which is how a WordprocessingML consumer draws them behind every page.
|
||||
//! The reader (`read::story::vml_watermark`, `read::watermark`) maps them back.
|
||||
//! The document's text watermark as a VML text shape in the headers (ECMA-376 Part 4, VML:
|
||||
//! `shape`, `shapetype`, `path`, `textpath`, `fill`), which is how a WordprocessingML consumer
|
||||
//! draws text behind every page. The reader (`read::story::vml_watermark`) maps it back.
|
||||
|
||||
use wordcraft_doc::watermark::own_size;
|
||||
use wordcraft_doc::{Document, SectionProps, Watermark, WatermarkPicture};
|
||||
use wordcraft_doc::{SectionProps, Watermark};
|
||||
|
||||
use crate::xml::esc;
|
||||
|
||||
/// Word's Washout on `v:imagedata`: `gain` 0.3 and `blacklevel` 0.35, in 65536ths.
|
||||
const WASHOUT: &str = r#" gain="19661f" blacklevel="22938f""#;
|
||||
|
||||
/// The document's picture watermark and its picture's own size, when its picture can be read.
|
||||
pub(super) fn picture(doc: &Document) -> Option<(&WatermarkPicture, (f32, f32))> {
|
||||
let pic = doc.settings.watermark.as_ref()?.picture.as_ref()?;
|
||||
Some((pic, doc.media.get(&pic.media).and_then(|b| own_size(b))?))
|
||||
}
|
||||
|
||||
/// Whether the document's watermark is written: text that isn't blank, or a picture that can be
|
||||
/// read.
|
||||
pub(super) fn shown(doc: &Document) -> bool {
|
||||
match &doc.settings.watermark {
|
||||
Some(wm) if wm.picture.is_some() => picture(doc).is_some(),
|
||||
Some(wm) => text(wm).is_some(),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The shape type the watermark shape uses: two straight subpaths, the box's top and bottom
|
||||
/// edges, between which the `v:textpath` text is stretched to fit the shape (`fitshape`). A single
|
||||
/// path gives the text no height, and Word draws nothing.
|
||||
@@ -39,18 +18,6 @@ pub(super) fn text(wm: &Watermark) -> Option<String> {
|
||||
(!t.trim().is_empty()).then_some(t)
|
||||
}
|
||||
|
||||
/// The `v:fill` `opacity` for the text's transparency: Word's `.5` for Semitransparent. None when
|
||||
/// the text is opaque.
|
||||
fn opacity(wm: &Watermark) -> Option<String> {
|
||||
let t = wm.transparency();
|
||||
if t == 0 {
|
||||
return None;
|
||||
}
|
||||
let o = format!("{:.2}", f32::from(100 - t) / 100.0);
|
||||
let o = o.trim_end_matches('0').trim_end_matches('.');
|
||||
Some(o.strip_prefix("0.").map_or_else(|| o.to_string(), |f| format!(".{f}")))
|
||||
}
|
||||
|
||||
/// A `w:r` holding the watermark shape (and its shape type), for the first paragraph of header
|
||||
/// part number `n`, in its box ([`Watermark::frame`]: its own, or Word's default for the page).
|
||||
pub(super) fn run(wm: &Watermark, n: usize, page: &SectionProps) -> Option<String> {
|
||||
@@ -78,7 +45,7 @@ pub(super) fn run(wm: &Watermark, n: usize, page: &SectionProps) -> Option<Strin
|
||||
mso-position-horizontal:center;mso-position-horizontal-relative:margin;\
|
||||
mso-position-vertical:center;mso-position-vertical-relative:margin"
|
||||
);
|
||||
let fill = opacity(wm).map(|o| format!(r#"<v:fill opacity="{o}"/>"#)).unwrap_or_default();
|
||||
let fill = if wm.semitransparent { r#"<v:fill opacity=".5"/>"# } else { "" };
|
||||
Some(format!(
|
||||
concat!(
|
||||
r#"<w:r><w:rPr><w:noProof/></w:rPr><w:pict>"#,
|
||||
@@ -97,43 +64,3 @@ pub(super) fn run(wm: &Watermark, n: usize, page: &SectionProps) -> Option<Strin
|
||||
text = esc(&text),
|
||||
))
|
||||
}
|
||||
|
||||
/// A `w:r` holding the picture watermark for header part number `n`, its picture relationship
|
||||
/// `rid`: Word's picture shape, centred on the margins, behind the text, with Word's Washout
|
||||
/// values when washed out. `fit` is the room Auto fills (see [`Document::watermark_fit`]).
|
||||
pub(super) fn picture_run(pic: &WatermarkPicture, own: (f32, f32), n: usize, fit: (f32, f32), rid: &str) -> Option<String> {
|
||||
let (w, h) = pic.size(own.0, own.1, fit)?;
|
||||
let style = format!(
|
||||
"position:absolute;margin-left:0;margin-top:0;width:{w:.3}pt;height:{h:.3}pt;z-index:-251657216;\
|
||||
mso-position-horizontal:center;mso-position-horizontal-relative:margin;\
|
||||
mso-position-vertical:center;mso-position-vertical-relative:margin"
|
||||
);
|
||||
Some(format!(
|
||||
concat!(
|
||||
r#"<w:r><w:rPr><w:noProof/></w:rPr><w:pict>"#,
|
||||
r#"<v:shapetype id="_x0000_t75" coordsize="21600,21600" o:spt="75" o:preferrelative="t" path="m0,0l0,21600,21600,21600,21600,0xe" filled="f" stroked="f">"#,
|
||||
r#"<v:path o:extrusionok="f" gradientshapeok="t" o:connecttype="rect"/><o:lock v:ext="edit" aspectratio="t"/></v:shapetype>"#,
|
||||
r##"<v:shape id="WordPictureWatermark{n}" type="#_x0000_t75" style="{style}" o:allowincell="f">"##,
|
||||
r#"<v:imagedata r:id="{rid}"{wash}/></v:shape></w:pict></w:r>"#
|
||||
),
|
||||
n = n,
|
||||
style = esc(&style),
|
||||
rid = esc(rid),
|
||||
wash = if pic.washout { WASHOUT } else { "" },
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn opacity_is_written_like_word() {
|
||||
let mut wm = Watermark::default();
|
||||
assert_eq!(opacity(&wm).as_deref(), Some(".5"));
|
||||
for (t, want) in [(0, None), (25, Some(".75")), (80, Some(".2")), (99, Some(".01")), (100, Some("0"))] {
|
||||
wm.set_transparency(t);
|
||||
assert_eq!(opacity(&wm).as_deref(), want, "{t}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+114
-6
@@ -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() };
|
||||
@@ -419,15 +462,22 @@ fn parse_with(bytes: &[u8], extra: &[(&'static str, &str)], strict: bool) -> Res
|
||||
attach(&mut stack, &mut root, el);
|
||||
}
|
||||
}
|
||||
// Line ends are normalised as XML 1.0 says (§2.11): CR LF and a lone CR are read as
|
||||
// LF, so a file saved with Windows line ends has no CR in its text. A CR written as
|
||||
// ` ` is a character reference and stays.
|
||||
Event::Text(t) => {
|
||||
if let Some(top) = stack.last_mut() {
|
||||
let s = t.decode().map_err(|e| DocxError::Xml(e.to_string()))?;
|
||||
let s = t.xml_content(XmlVersion::Explicit1_0).map_err(|e| DocxError::Xml(e.to_string()))?;
|
||||
push_text(top, &s);
|
||||
}
|
||||
}
|
||||
Event::CData(t) => {
|
||||
if let Some(top) = stack.last_mut() {
|
||||
let s = String::from_utf8_lossy(&t).into_owned();
|
||||
// CDATA that isn't UTF-8 is decoded lossily, with its line ends normalised the same way.
|
||||
let s = t
|
||||
.xml_content(XmlVersion::Explicit1_0)
|
||||
.map(|s| s.into_owned())
|
||||
.unwrap_or_else(|_| String::from_utf8_lossy(&t).replace("\r\n", "\n").replace('\r', "\n"));
|
||||
push_text(top, &s);
|
||||
}
|
||||
}
|
||||
@@ -593,6 +643,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`.
|
||||
@@ -645,6 +731,28 @@ mod tests {
|
||||
assert_eq!(t.attr("xml:space"), Some("preserve"));
|
||||
}
|
||||
|
||||
/// CDATA that isn't UTF-8 is decoded lossily, and its line ends are still normalised.
|
||||
#[test]
|
||||
fn line_ends_in_cdata_that_isnt_utf8_are_normalised() {
|
||||
let x =
|
||||
b"<x:document xmlns:x=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><x:t><![CDATA[a\xff\r\nb\rc]]></x:t></x:document>";
|
||||
assert_eq!(parse(x).unwrap().child("w:t").unwrap().text(), "a\u{FFFD}\nb\nc");
|
||||
}
|
||||
|
||||
/// XML 1.0 §2.11: CR LF and a lone CR in text and CDATA are read as LF; ` ` stays a CR.
|
||||
#[test]
|
||||
fn line_ends_in_text_are_normalised_but_a_cr_reference_stays() {
|
||||
let x = b"<x:document xmlns:x=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><x:t>a\r\nb\rc d<![CDATA[e\r\nf]]></x:t></x:document>";
|
||||
let e = parse(x).unwrap();
|
||||
assert_eq!(e.child("w:t").unwrap().text(), "a\nb\nc\rde\nf");
|
||||
// The writer escapes a CR, so a CR in the text survives a save.
|
||||
let x = format!(
|
||||
"<x:document xmlns:x=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><x:t>{}</x:t></x:document>",
|
||||
esc("a\r\nb\rc")
|
||||
);
|
||||
assert_eq!(parse(x.as_bytes()).unwrap().child("w:t").unwrap().text(), "a\r\nb\rc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn choice_requirements_resolve_namespace_aliases_and_rebindings() {
|
||||
let x = br#"<mc:AlternateContent xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:pen="http://schemas.microsoft.com/office/word/2010/wordprocessingInk"><mc:Choice Requires="pen"/><mc:Choice xmlns:pen="urn:future" Requires="pen"/><mc:Choice Requires="undeclared"/></mc:AlternateContent>"#;
|
||||
|
||||
@@ -342,3 +342,57 @@ fn toc_gallery_around_a_toc_whose_heading_is_in_a_block_control_round_trips() {
|
||||
assert!(xml.rfind("</w:sdt>").unwrap() < xml.rfind(">Chapter One<").unwrap(), "pass {pass}: {xml}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_toc_gallery_with_field_end_after_entries_round_trips_without_update() {
|
||||
let style = |s: &str, inner: &str| format!(r#"<w:p><w:pPr><w:pStyle w:val="{s}"/></w:pPr>{inner}</w:p>"#);
|
||||
let begin = r#"<w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> TOC \o "1-3" \h \z \u </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r>"#;
|
||||
let content = format!(
|
||||
"{}{}{}<w:p><w:r><w:fldChar w:fldCharType=\"end\"/></w:r></w:p>",
|
||||
style("TOCHeading", &t("Contents")),
|
||||
style("TOC1", &format!("{begin}{}", t("Chapter One\t1"))),
|
||||
style("TOC2", &t("Section\t2"))
|
||||
);
|
||||
let pr = r#"<w:id w:val="-1234567"/><w:docPartObj><w:docPartGallery w:val="Table of Contents"/><w:docPartUnique/></w:docPartObj>"#;
|
||||
let mut bytes = package(&format!("{}{}", sdt(pr, &content), p(&t("Chapter One"))));
|
||||
for pass in 0..2 {
|
||||
let d = read(&bytes).unwrap();
|
||||
bytes = write(&d).unwrap();
|
||||
let xml = document_xml(&bytes);
|
||||
assert_eq!(xml.matches("<w:sdt>").count(), 1, "pass {pass}: {xml}");
|
||||
assert!(xml.contains(r#"<w:docPartGallery w:val="Table of Contents"/>"#), "pass {pass}: {xml}");
|
||||
assert!(xml.find("</w:sdt>").unwrap() < xml.rfind(">Chapter One<").unwrap(), "pass {pass}: {xml}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn range_toc_without_control_stays_unwrapped_and_section_break_toc_control_hands_over() {
|
||||
let style = |s: &str, inner: &str| format!(r#"<w:p><w:pPr><w:pStyle w:val="{s}"/></w:pPr>{inner}</w:p>"#);
|
||||
let begin = r#"<w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> TOC \o "1-3" \h \z \u </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r>"#;
|
||||
// A range TOC that was never in a content control (with FieldEnd in its own paragraph) must
|
||||
// not gain a Table of Contents w:sdt on save.
|
||||
let bare = format!(
|
||||
"{}{}{}<w:p><w:r><w:fldChar w:fldCharType=\"end\"/></w:r></w:p>{}",
|
||||
style("TOCHeading", &t("Contents")),
|
||||
style("TOC1", &format!("{begin}{}", t("Chapter One\t1"))),
|
||||
style("TOC2", &t("Section\t2")),
|
||||
p(&t("Chapter One")),
|
||||
);
|
||||
let d_bare = read(&package(&bare)).unwrap();
|
||||
let xml_bare = document_xml(&write(&d_bare).unwrap());
|
||||
assert_eq!(xml_bare.matches("<w:sdt>").count(), 0, "{xml_bare}");
|
||||
|
||||
// #3889: a Table of Contents control opening on an empty paragraph that ends a section above
|
||||
// the entries hands its control over on read so Update Table reuses the section-break paragraph.
|
||||
let sect_toc = format!(
|
||||
r#"<w:p><w:pPr><w:sectPr><w:pgSz w:w="11906" w:h="16838"/></w:sectPr></w:pPr></w:p>{}{}<w:p><w:r><w:fldChar w:fldCharType="end"/></w:r></w:p>"#,
|
||||
style("TOC1", &format!("{begin}{}", t("Chapter One\t1"))),
|
||||
style("TOC2", &t("Section\t2")),
|
||||
);
|
||||
let pr = r#"<w:id w:val="-1234567"/><w:docPartObj><w:docPartGallery w:val="Table of Contents"/><w:docPartUnique/></w:docPartObj>"#;
|
||||
let d_sect = read(&package(&format!("{}{}", sdt(pr, §_toc), p(&t("Chapter One"))))).unwrap();
|
||||
assert!(controls(&d_sect).is_empty());
|
||||
assert!(d_sect.body[0].as_para().unwrap().section.is_some());
|
||||
let xml_sect = document_xml(&write(&d_sect).unwrap());
|
||||
assert!(xml_sect.contains("Chapter One") && xml_sect.contains("Section"), "{xml_sect}");
|
||||
}
|
||||
|
||||
+147
-286
@@ -1381,14 +1381,7 @@ fn vml_textpath_in_header_is_the_watermark() {
|
||||
// Horizontal, centred both ways without Word's id, opacity in 65536ths.
|
||||
let flat = r##"<v:shape type="#_x0000_t136" style="position:absolute;width:300pt;height:60pt;mso-position-horizontal:center;mso-position-vertical:center" fillcolor="#336699"><v:fill opacity="32768f"/><v:textpath string="ASAP"/></v:shape>"##;
|
||||
let wm = read(flat, true).0.expect("watermark");
|
||||
assert_eq!((wm.color, wm.diagonal, wm.semitransparent, wm.transparency()), (Rgb(0x33, 0x66, 0x99), false, true, 50));
|
||||
|
||||
// Any opacity is read as a transparency percentage, not only Word's `.5`.
|
||||
for (opacity, want) in [(".25", 75), ("0", 100), ("1", 0), ("2", 0), ("-1", 100)] {
|
||||
let shape = flat.replace(r#"opacity="32768f""#, &format!(r#"opacity="{opacity}""#));
|
||||
let wm = read(&shape, true).0.expect("watermark");
|
||||
assert_eq!(wm.transparency(), want, "{opacity}");
|
||||
}
|
||||
assert_eq!((wm.color, wm.diagonal, wm.semitransparent), (Rgb(0x33, 0x66, 0x99), false, true));
|
||||
|
||||
// Not watermarks: the same shape in the body (WordArt); header WordArt that isn't centred
|
||||
// and isn't Word's watermark (a logo); a textpath turned off; a watermark-like shape inside a
|
||||
@@ -1505,281 +1498,6 @@ fn word_watermark_survives_save() {
|
||||
assert_eq!(d2.settings.watermark, None);
|
||||
}
|
||||
|
||||
/// #2954: Word's picture watermark (a VML picture with Word's `WordPictureWatermark` id in the
|
||||
/// headers) becomes the document's picture watermark: its picture, the scale from the shape's
|
||||
/// width (Auto when it fills the margins) and Washout from `gain` and `blacklevel`. The header
|
||||
/// keeps its text. Another header picture, even centred, stays a picture.
|
||||
#[test]
|
||||
fn word_picture_watermark_is_the_documents_watermark() {
|
||||
let mut png = Vec::new();
|
||||
image::RgbaImage::from_pixel(400, 200, image::Rgba([10, 20, 30, 255]))
|
||||
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
let read = |id: &str, size: &str, wash: &str| {
|
||||
let hdr = format!(
|
||||
r##"<w:hdr {W_NS}><w:p><w:r><w:t>Head</w:t></w:r><w:r><w:pict><v:shapetype id="_x0000_t75" coordsize="21600,21600" o:spt="75" path="m@4@5l@4@11@9@11@9@5xe" filled="f" stroked="f"/><v:shape id="{id}" o:spid="_x0000_s2049" type="#_x0000_t75" style="position:absolute;margin-left:0;margin-top:0;{size};z-index:-251657216;mso-position-horizontal:center;mso-position-horizontal-relative:margin;mso-position-vertical:center;mso-position-vertical-relative:margin" o:allowincell="f"><v:imagedata r:id="rIdP" o:title="logo"{wash}/></v:shape></w:pict></w:r></w:p></w:hdr>"##
|
||||
);
|
||||
let doc = format!(
|
||||
r#"<w:document {W_NS}><w:body><w:p><w:r><w:t>Body</w:t></w:r></w:p><w:sectPr><w:headerReference w:type="default" r:id="rIdH"/></w:sectPr></w:body></w:document>"#
|
||||
);
|
||||
let (drels, hrels) = (rels(&[("rIdH", "header", "header1.xml")]), rels(&[("rIdP", "image", "media/logo.png")]));
|
||||
let parts: [(&str, &[u8]); 6] = [
|
||||
("_rels/.rels", ROOT_RELS.as_bytes()),
|
||||
("word/document.xml", doc.as_bytes()),
|
||||
("word/_rels/document.xml.rels", drels.as_bytes()),
|
||||
("word/header1.xml", hdr.as_bytes()),
|
||||
("word/_rels/header1.xml.rels", hrels.as_bytes()),
|
||||
("word/media/logo.png", &png),
|
||||
];
|
||||
wordcraft_docx::read(&zip(&parts)).unwrap()
|
||||
};
|
||||
let header_objects = |d: &Document| -> usize {
|
||||
let hid = d.last_section.headers.default.unwrap();
|
||||
d.parts[&hid].blocks.iter().filter_map(|b| b.as_para()).map(|p| p.objects.len()).sum()
|
||||
};
|
||||
// 400 × 200 px is 300 × 150 pt at 96 ppi: 150 pt wide is 50 %.
|
||||
let d = read("WordPictureWatermark77", "width:150pt;height:75pt", r#" gain="19661f" blacklevel="22938f""#);
|
||||
let pic = d.settings.watermark.as_ref().and_then(|w| w.picture.clone()).expect("picture watermark");
|
||||
assert_eq!((pic.scale, pic.washout), (Some(50.0), true));
|
||||
assert_eq!(d.media.get(&pic.media).map(|b| b.as_slice()), Some(png.as_slice()));
|
||||
assert_eq!(header_objects(&d), 0);
|
||||
let hid = d.last_section.headers.default.unwrap();
|
||||
assert_eq!(d.parts[&hid].blocks[0].as_para().unwrap().plain_text(), "Head");
|
||||
// As wide as the margins of a Letter page with 1 in margins: Auto. No Washout.
|
||||
let d = read("WordPictureWatermark1", "width:468pt;height:234pt", "");
|
||||
let pic = d.settings.watermark.as_ref().and_then(|w| w.picture.clone()).expect("picture watermark");
|
||||
assert_eq!((pic.scale, pic.washout), (None, false));
|
||||
// Another centred header picture is not a watermark.
|
||||
let d = read("Logo1", "width:150pt;height:75pt", "");
|
||||
assert_eq!(d.settings.watermark, None);
|
||||
assert_eq!(header_objects(&d), 1);
|
||||
}
|
||||
|
||||
/// A 400 × 200 px PNG of one colour (300 × 150 pt at 96 ppi).
|
||||
fn solid_png(c: [u8; 4]) -> Vec<u8> {
|
||||
let mut png = Vec::new();
|
||||
image::RgbaImage::from_pixel(400, 200, image::Rgba(c)).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
png
|
||||
}
|
||||
|
||||
/// Word's picture watermark shape: `extra` added to its style, `wash` to its `v:imagedata`.
|
||||
fn wm_shape(size: &str, extra: &str, wash: &str) -> String {
|
||||
format!(
|
||||
r##"<w:r><w:pict><v:shape id="WordPictureWatermark1" type="#_x0000_t75" style="position:absolute;margin-left:0;margin-top:0;{size};z-index:-251657216;mso-position-horizontal:center;mso-position-horizontal-relative:margin;mso-position-vertical:center;mso-position-vertical-relative:margin{extra}" o:allowincell="f"><v:imagedata r:id="rIdP" o:title="wm"{wash}/></v:shape></w:pict></w:r>"##
|
||||
)
|
||||
}
|
||||
|
||||
/// A document of `body` (its `w:sectPr`s refer to `rH1`, `rH2`…) whose headers are `headers`:
|
||||
/// (label, runs after the label, picture file under `word/media`).
|
||||
fn headers_doc(body: &str, headers: &[(&str, String, &str)], pics: &[(&str, Vec<u8>)]) -> Document {
|
||||
parts_doc(body, headers, &[], pics)
|
||||
}
|
||||
|
||||
/// [`headers_doc`] with footers too, referred to as `rF1`, `rF2`….
|
||||
fn parts_doc(body: &str, headers: &[(&str, String, &str)], footers: &[(&str, String, &str)], pics: &[(&str, Vec<u8>)]) -> Document {
|
||||
let mut list: Vec<(String, String, &str, String, &str)> = Vec::new();
|
||||
for (kind, tag, prefix, set) in [("header", "w:hdr", "rH", headers), ("footer", "w:ftr", "rF", footers)] {
|
||||
for (i, (label, runs, pic)) in set.iter().enumerate() {
|
||||
let xml = format!(r#"<{tag} {W_NS}><w:p><w:r><w:t>{label}</w:t></w:r>{runs}</w:p></{tag}>"#);
|
||||
list.push((format!("{prefix}{}", i + 1), format!("{kind}{}.xml", i + 1), kind, xml, pic));
|
||||
}
|
||||
}
|
||||
let doc_rels = rels(&list.iter().map(|(id, name, kind, ..)| (id.as_str(), *kind, name.as_str())).collect::<Vec<_>>());
|
||||
let doc = format!(r#"<w:document {W_NS}><w:body>{body}</w:body></w:document>"#);
|
||||
let mut parts: Vec<(String, Vec<u8>)> = vec![
|
||||
("_rels/.rels".into(), ROOT_RELS.as_bytes().to_vec()),
|
||||
("word/document.xml".into(), doc.into_bytes()),
|
||||
("word/_rels/document.xml.rels".into(), doc_rels.into_bytes()),
|
||||
];
|
||||
for (_, name, _, xml, pic) in &list {
|
||||
parts.push((format!("word/{name}"), xml.clone().into_bytes()));
|
||||
parts.push((format!("word/_rels/{name}.rels"), rels(&[("rIdP", "image", &format!("media/{pic}"))]).into_bytes()));
|
||||
}
|
||||
for (name, bytes) in pics {
|
||||
parts.push((format!("word/media/{name}"), bytes.clone()));
|
||||
}
|
||||
let entries: Vec<(&str, &[u8])> = parts.iter().map(|(n, b)| (n.as_str(), b.as_slice())).collect();
|
||||
wordcraft_docx::read(&zip(&entries)).unwrap()
|
||||
}
|
||||
|
||||
/// Pictures in each header part, by its text.
|
||||
fn header_pictures(d: &Document) -> Vec<(String, usize)> {
|
||||
let mut out: Vec<(String, usize)> = d
|
||||
.parts
|
||||
.values()
|
||||
.filter(|p| p.kind == wordcraft_doc::PartKind::Header)
|
||||
.map(|p| {
|
||||
let paras: Vec<&Paragraph> = p.blocks.iter().filter_map(|b| b.as_para()).collect();
|
||||
let text = paras.iter().map(|p| p.plain_text()).collect::<Vec<_>>().join("").replace('\u{FFFC}', "");
|
||||
let pics = paras.iter().flat_map(|p| &p.objects).filter(|o| matches!(o, InlineObject::Image { .. })).count();
|
||||
(text, pics)
|
||||
})
|
||||
.collect();
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
|
||||
const PORTRAIT: &str = r#"<w:pgSz w:w="12240" w:h="15840"/><w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440" w:header="720" w:footer="720" w:gutter="0"/>"#;
|
||||
const LANDSCAPE: &str = r#"<w:pgSz w:w="15840" w:h="12240" w:orient="landscape"/><w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440" w:header="720" w:footer="720" w:gutter="0"/>"#;
|
||||
|
||||
/// #2954 review: Word's picture watermark becomes the document's watermark only when every page
|
||||
/// shows the same one, placed as Word places it. A cover page whose header lacks it, a section
|
||||
/// with another picture, a watermark moved, turned or with other picture settings: each picture
|
||||
/// stays in its header, as before, and still does after saving and reopening.
|
||||
#[test]
|
||||
fn picture_watermark_stays_in_headers_unless_every_page_shows_it() {
|
||||
let (a, b) = (solid_png([20, 60, 140, 255]), solid_png([140, 20, 20, 255]));
|
||||
let portrait_auto = wm_shape("width:468pt;height:234pt", "", r#" gain="19661f" blacklevel="22938f""#);
|
||||
let landscape_auto = wm_shape("width:648pt;height:324pt", "", r#" gain="19661f" blacklevel="22938f""#);
|
||||
let p = |label: &str, run: &str, pic: &'static str| (label.to_string(), run.to_string(), pic);
|
||||
let read = |body: &str, hs: Vec<(String, String, &str)>| {
|
||||
let hs: Vec<(&str, String, &str)> = hs.iter().map(|(l, r, p)| (l.as_str(), r.clone(), *p)).collect();
|
||||
headers_doc(body, &hs, &[("a.png", a.clone()), ("b.png", b.clone())])
|
||||
};
|
||||
let saved = |d: &Document| wordcraft_docx::read(&wordcraft_docx::write(d).unwrap()).unwrap();
|
||||
let two_sections = format!(
|
||||
r#"<w:p><w:pPr><w:sectPr><w:headerReference w:type="default" r:id="rH1"/>{PORTRAIT}</w:sectPr></w:pPr><w:r><w:t>One</w:t></w:r></w:p><w:p><w:r><w:t>Two</w:t></w:r></w:p><w:sectPr><w:headerReference w:type="default" r:id="rH2"/>{LANDSCAPE}</w:sectPr>"#
|
||||
);
|
||||
|
||||
// Every page: two sections, each Auto for its own page, and a title page with both headers.
|
||||
let d = read(&two_sections, vec![p("One", &portrait_auto, "a.png"), p("Two", &landscape_auto, "a.png")]);
|
||||
let pic = d.settings.watermark.as_ref().and_then(|w| w.picture.clone()).expect("the document's watermark");
|
||||
assert_eq!((pic.scale, pic.washout), (None, true));
|
||||
assert_eq!(header_pictures(&d), vec![("One".into(), 0), ("Two".into(), 0)]);
|
||||
let title = format!(
|
||||
r#"<w:p><w:r><w:t>Body</w:t></w:r></w:p><w:sectPr><w:headerReference w:type="default" r:id="rH1"/><w:headerReference w:type="first" r:id="rH2"/>{PORTRAIT}<w:titlePg/></w:sectPr>"#
|
||||
);
|
||||
let d = read(&title, vec![p("Main", &portrait_auto, "a.png"), p("Cover", &portrait_auto, "a.png")]);
|
||||
assert!(d.settings.watermark.as_ref().is_some_and(|w| w.picture.is_some()));
|
||||
|
||||
// A: the cover page's header has none.
|
||||
let d = read(&title, vec![p("Main", &portrait_auto, "a.png"), p("Cover", "", "a.png")]);
|
||||
assert_eq!(d.settings.watermark, None);
|
||||
assert_eq!(header_pictures(&d), vec![("Cover".into(), 0), ("Main".into(), 1)]);
|
||||
let d = saved(&d);
|
||||
assert_eq!(d.settings.watermark, None);
|
||||
assert_eq!(header_pictures(&d), vec![("Cover".into(), 0), ("Main".into(), 1)], "saved");
|
||||
|
||||
// B: section 2 has another picture.
|
||||
let d = read(&two_sections, vec![p("One", &portrait_auto, "a.png"), p("Two", &landscape_auto, "b.png")]);
|
||||
assert_eq!(d.settings.watermark, None);
|
||||
assert_eq!(header_pictures(&d), vec![("One".into(), 1), ("Two".into(), 1)]);
|
||||
let d2 = saved(&d);
|
||||
assert_eq!(d2.settings.watermark, None);
|
||||
assert_eq!(header_pictures(&d2), vec![("One".into(), 1), ("Two".into(), 1)], "saved");
|
||||
let media = |d: &Document| {
|
||||
let mut m: Vec<Vec<u8>> = d.media.values().map(|b| b.to_vec()).collect();
|
||||
m.sort();
|
||||
m
|
||||
};
|
||||
assert_eq!(media(&d2), media(&d), "both pictures kept");
|
||||
|
||||
// C: moved, turned, or with picture settings other than Washout; and a header without one.
|
||||
let one = format!(r#"<w:p><w:r><w:t>Body</w:t></w:r></w:p><w:sectPr><w:headerReference w:type="default" r:id="rH1"/>{PORTRAIT}</w:sectPr>"#);
|
||||
for run in [
|
||||
wm_shape("width:468pt;height:234pt", ";mso-position-horizontal:left", ""),
|
||||
wm_shape("width:468pt;height:234pt", ";rotation:30", ""),
|
||||
wm_shape("width:468pt;height:234pt", ";flip:x", ""),
|
||||
wm_shape("width:468pt;height:234pt", "", r#" gain="0.5" blacklevel="0.1""#),
|
||||
wm_shape("width:468pt;height:234pt", "", r#" grayscale="t""#),
|
||||
] {
|
||||
let d = read(&one, vec![p("Main", &run, "a.png")]);
|
||||
assert_eq!(d.settings.watermark, None, "{run}");
|
||||
assert_eq!(header_pictures(&d), vec![("Main".into(), 1)], "{run}");
|
||||
}
|
||||
let no_header = format!(
|
||||
r#"<w:p><w:pPr><w:sectPr>{PORTRAIT}</w:sectPr></w:pPr><w:r><w:t>One</w:t></w:r></w:p><w:p><w:r><w:t>Two</w:t></w:r></w:p><w:sectPr><w:headerReference w:type="default" r:id="rH1"/>{PORTRAIT}</w:sectPr>"#
|
||||
);
|
||||
let d = read(&no_header, vec![p("Two", &portrait_auto, "a.png")]);
|
||||
assert_eq!(d.settings.watermark, None, "the first section's pages have no header");
|
||||
}
|
||||
|
||||
/// #2954 second review: watermarks the plan must leave alone, as main did. A text watermark in
|
||||
/// a footer stays the document's watermark (the headers' pictures stay pictures), and a Word
|
||||
/// picture watermark in a footer keeps every picture where it is. A stretched shape, a scale
|
||||
/// outside 1 to 500 %, other picture corrections, in front of the text, hidden, in a group,
|
||||
/// or a section in tracked insertion with no header: all stay header pictures.
|
||||
#[test]
|
||||
fn picture_watermark_plan_leaves_other_watermarks_alone() {
|
||||
let a = solid_png([20, 60, 140, 255]);
|
||||
let pics = [("a.png", a.clone())];
|
||||
let one = format!(
|
||||
r#"<w:p><w:r><w:t>Body</w:t></w:r></w:p><w:sectPr><w:headerReference w:type="default" r:id="rH1"/><w:footerReference w:type="default" r:id="rF1"/>{PORTRAIT}</w:sectPr>"#
|
||||
);
|
||||
let auto = wm_shape("width:468pt;height:234pt", "", r#" gain="19661f" blacklevel="22938f""#);
|
||||
let text = r##"<w:r><w:pict><v:shape id="PowerPlusWaterMarkObject9" type="#_x0000_t136" style="position:absolute;width:400pt;height:100pt;rotation:315;mso-position-horizontal:center;mso-position-horizontal-relative:margin;mso-position-vertical:center;mso-position-vertical-relative:margin" fillcolor="silver"><v:fill opacity=".5"/><v:textpath style="font-family:"Calibri"" string="DRAFT"/></v:shape></w:pict></w:r>"##;
|
||||
|
||||
// A footer's text watermark: the document's watermark, as before; the header picture stays,
|
||||
// and both survive saving.
|
||||
let d = parts_doc(&one, &[("Head", auto.clone(), "a.png")], &[("Foot", text.to_string(), "a.png")], &pics);
|
||||
let wm = d.settings.watermark.clone().expect("the footer's text watermark");
|
||||
assert_eq!((wm.text.as_str(), wm.picture.is_none()), ("DRAFT", true));
|
||||
assert_eq!(header_pictures(&d), vec![("Head".into(), 1)]);
|
||||
let d = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||||
assert_eq!(d.settings.watermark.as_ref().map(|w| w.text.as_str()), Some("DRAFT"), "saved");
|
||||
assert_eq!(header_pictures(&d), vec![("Head".into(), 1)], "saved");
|
||||
|
||||
// A footer's picture watermark: nothing is promoted, nothing is drawn twice.
|
||||
let d = parts_doc(&one, &[("Head", auto.clone(), "a.png")], &[("Foot", auto.clone(), "a.png")], &pics);
|
||||
assert_eq!(d.settings.watermark, None);
|
||||
assert_eq!(header_pictures(&d), vec![("Head".into(), 1)]);
|
||||
|
||||
let only = format!(r#"<w:p><w:r><w:t>Body</w:t></w:r></w:p><w:sectPr><w:headerReference w:type="default" r:id="rH1"/>{PORTRAIT}</w:sectPr>"#);
|
||||
let group = r##"<w:r><w:pict><v:group style="position:absolute;width:468pt;height:234pt;z-index:-1" coordsize="468,234"><v:shape id="WordPictureWatermark1" type="#_x0000_t75" style="position:absolute;width:468;height:234"><v:imagedata r:id="rIdP"/></v:shape></v:group></w:pict></w:r>"##;
|
||||
for (run, why) in [
|
||||
(wm_shape("width:468pt;height:100pt", "", ""), "stretched"),
|
||||
(wm_shape("width:1800pt;height:900pt", "", ""), "600 %"),
|
||||
(wm_shape("width:2pt;height:1pt", "", ""), "under 1 %"),
|
||||
(wm_shape("width:468pt;height:234pt", "", r#" gain="79922f" blacklevel="-1966f""#), "other corrections"),
|
||||
(wm_shape("width:468pt;height:234pt", "", r#" gain="1" blacklevel="0""#), "identity values"),
|
||||
(wm_shape("width:468pt;height:234pt", ";z-index:5", ""), "in front of the text"),
|
||||
(wm_shape("width:468pt;height:234pt", ";visibility:hidden", ""), "hidden"),
|
||||
(group.to_string(), "in a group"),
|
||||
] {
|
||||
let d = headers_doc(&only, &[("Head", run, "a.png")], &pics);
|
||||
assert_eq!(d.settings.watermark, None, "{why}");
|
||||
}
|
||||
// The first section, inside a tracked insertion, has no header: its pages show none.
|
||||
let tracked = format!(
|
||||
r#"<w:ins w:id="1" w:author="A"><w:p><w:pPr><w:sectPr>{PORTRAIT}</w:sectPr></w:pPr><w:r><w:t>One</w:t></w:r></w:p></w:ins><w:p><w:r><w:t>Two</w:t></w:r></w:p><w:sectPr><w:headerReference w:type="default" r:id="rH1"/>{PORTRAIT}</w:sectPr>"#
|
||||
);
|
||||
let d = headers_doc(&tracked, &[("Head", auto.clone(), "a.png")], &pics);
|
||||
assert_eq!(d.settings.watermark, None, "tracked section");
|
||||
// A header that only a table cell's section property loads, with another picture.
|
||||
let cell = format!(
|
||||
r#"<w:tbl><w:tr><w:tc><w:p><w:pPr><w:sectPr><w:headerReference w:type="default" r:id="rH2"/>{PORTRAIT}</w:sectPr></w:pPr></w:p></w:tc></w:tr></w:tbl><w:p/><w:sectPr><w:headerReference w:type="default" r:id="rH1"/>{PORTRAIT}</w:sectPr>"#
|
||||
);
|
||||
let b = solid_png([140, 20, 20, 255]);
|
||||
let both = [("a.png", a.clone()), ("b.png", b)];
|
||||
let d = headers_doc(&cell, &[("Head", auto.clone(), "a.png"), ("Cell", auto.clone(), "b.png")], &both);
|
||||
assert_eq!(d.settings.watermark, None, "a header with another picture");
|
||||
// The same document with the plain watermark is promoted.
|
||||
let d = headers_doc(&only, &[("Head", auto, "a.png")], &pics);
|
||||
assert!(d.settings.watermark.is_some_and(|w| w.picture.is_some()));
|
||||
}
|
||||
|
||||
/// A watermark in a tracked deletion is not the document's: a text one stays the WordArt text
|
||||
/// box it is read as (not drawn on every page), and a picture one keeps the plan from making any
|
||||
/// picture the document's watermark. The same watermarks outside a deletion are the document's.
|
||||
#[test]
|
||||
fn a_watermark_in_a_tracked_deletion_is_not_the_documents() {
|
||||
let a = solid_png([20, 60, 140, 255]);
|
||||
let pics = [("a.png", a)];
|
||||
let one = format!(r#"<w:p><w:r><w:t>Body</w:t></w:r></w:p><w:sectPr><w:headerReference w:type="default" r:id="rH1"/>{PORTRAIT}</w:sectPr>"#);
|
||||
let pic = wm_shape("width:468pt;height:234pt", "", r#" gain="19661f" blacklevel="22938f""#);
|
||||
let text = r##"<w:r><w:pict><v:shape id="PowerPlusWaterMarkObject9" type="#_x0000_t136" style="position:absolute;width:400pt;height:100pt;rotation:315;mso-position-horizontal:center;mso-position-horizontal-relative:margin;mso-position-vertical:center;mso-position-vertical-relative:margin" fillcolor="silver"><v:textpath style="font-family:"Calibri"" string="DRAFT"/></v:shape></w:pict></w:r>"##;
|
||||
let deleted = |runs: &str| format!(r#"<w:del w:id="9" w:author="A" w:date="2024-01-01T00:00:00Z">{}</w:del>"#, runs);
|
||||
for (runs, kind) in [(&pic, "picture"), (&text.to_string(), "text")] {
|
||||
let live = headers_doc(&one, &[("Head", runs.to_string(), "a.png")], &pics);
|
||||
assert!(live.settings.watermark.is_some(), "{kind}: outside a deletion it is the document's");
|
||||
let gone = headers_doc(&one, &[("Head", deleted(runs), "a.png")], &pics);
|
||||
assert_eq!(gone.settings.watermark, None, "{kind}: in a deletion it isn't");
|
||||
}
|
||||
// The picture stays in the header, marked deleted.
|
||||
let gone = headers_doc(&one, &[("Head", deleted(&pic), "a.png")], &pics);
|
||||
assert_eq!(header_pictures(&gone), vec![("Head".into(), 1)]);
|
||||
}
|
||||
|
||||
/// Issue #1775: a picture watermark in a header that starts with a table is anchored in a cell
|
||||
/// but placed on the page: any VML shape in a header (Word's picture watermark, `o:allowincell="f"`; user-checked
|
||||
/// in Word for Mac, the attribute makes no difference) or DrawingML `layoutInCell="0"`. That survives save and reopen; a shape laid out in its cell stays so.
|
||||
@@ -2048,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>"#
|
||||
@@ -2109,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();
|
||||
@@ -2744,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]
|
||||
@@ -4170,6 +3947,33 @@ fn bidi_theme_slots_follow_the_theme_font_language() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A `w:rFonts` fallback list longer than the reader keeps is cut at the last complete family
|
||||
/// within the bound, so the family a real list would pick survives while a hostile list cannot
|
||||
/// cost its whole length in every piece of text drawn in it.
|
||||
#[test]
|
||||
fn a_hostile_fallback_list_keeps_its_leading_families() {
|
||||
let candidates: Vec<String> = (0..500).map(|i| format!("MissingCandidateFont{i}")).collect();
|
||||
let list = format!("{};Source Serif 4", candidates.join(";"));
|
||||
let body = format!(r#"<w:p><w:r><w:rPr><w:rFonts w:ascii="{list}" w:hAnsi="{list}"/></w:rPr><w:t>x</w:t></w:r></w:p>"#);
|
||||
let d = read_body(&body);
|
||||
let c = &d.body[0].as_para().unwrap().runs[0].props;
|
||||
let kept = c.font.as_deref().expect("the leading families are kept");
|
||||
assert!(kept.len() <= 256, "{} bytes kept", kept.len());
|
||||
assert!(kept.starts_with("MissingCandidateFont0"), "{kept}");
|
||||
// Every kept entry is a whole family name: the cut lands on a `;`, never inside a name.
|
||||
assert!(kept.split(';').all(|f| f.starts_with("MissingCandidateFont") || f.contains("Source Serif")), "{kept}");
|
||||
assert!(!kept.ends_with(';'), "no dangling separator: {kept}");
|
||||
}
|
||||
|
||||
/// A real LibreOffice fallback list is read whole: a few families, well under any bound.
|
||||
#[test]
|
||||
fn a_real_fallback_list_is_read_whole() {
|
||||
let body = r#"<w:p><w:r><w:rPr><w:rFonts w:ascii="Liberation Sans;Arial" w:hAnsi="Liberation Sans;Arial"/></w:rPr><w:t>x</w:t></w:r></w:p>"#;
|
||||
let d = read_body(body);
|
||||
let c = &d.body[0].as_para().unwrap().runs[0].props;
|
||||
assert_eq!(c.font.as_deref(), Some("Liberation Sans;Arial"));
|
||||
}
|
||||
|
||||
/// The font table's family classes: Word draws a missing roman font in a serif face.
|
||||
#[test]
|
||||
fn roman_fonts_are_read_from_the_font_table() {
|
||||
@@ -4179,6 +3983,15 @@ fn roman_fonts_are_read_from_the_font_table() {
|
||||
assert_eq!(d.settings.roman_fonts, ["Brand Serif"]);
|
||||
}
|
||||
|
||||
/// Semicolon-separated roman font fallback lists in `fontTable.xml` register each candidate family.
|
||||
#[test]
|
||||
fn roman_fonts_split_fallback_lists() {
|
||||
let fonts = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:fonts xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:font w:name="Liberation Serif;Times New Roman"><w:family w:val="roman"/></w:font></w:fonts>"#;
|
||||
let rels = [("rId9", "http://schemas.openxmlformats.org/officeDocument/2006/relationships/fontTable", "fontTable.xml")];
|
||||
let d = wordcraft_docx::read(&docx("<w:p/>", &rels, &[("word/fontTable.xml", fonts)])).unwrap();
|
||||
assert_eq!(d.settings.roman_fonts, ["Liberation Serif", "Times New Roman"]);
|
||||
}
|
||||
|
||||
/// `w:doNotUseHTMLParagraphAutoSpacing` in the settings' `w:compat`: paragraph spacing adds up.
|
||||
#[test]
|
||||
fn html_auto_spacing_off_is_read_and_kept() {
|
||||
@@ -4577,6 +4390,54 @@ fn zero_effects_stay_zero_rotated() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A turned picture whose file keeps no room for the turn (no or a zero `wp:effectExtent`) is
|
||||
/// marked so its line keeps to its frame as in Word, and saves that way; one with the room keeps
|
||||
/// its rotated bounds.
|
||||
#[test]
|
||||
fn bare_turns_are_kept_through_a_save() {
|
||||
let pic = |effects: &str| {
|
||||
format!(
|
||||
r#"<w:p><w:r><w:drawing><wp:inline><wp:extent cx="1524000" cy="762000"/>{effects}<wp:docPr id="1" name="Synthetic picture"/><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:blipFill><a:blip r:embed="rId1"/></pic:blipFill><pic:spPr><a:xfrm rot="2700000"><a:off x="0" y="0"/><a:ext cx="1524000" cy="762000"/></a:xfrm></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r></w:p>"#
|
||||
)
|
||||
};
|
||||
let read =
|
||||
|body: &str| wordcraft_docx::read(&docx(body, &[("rId1", "image", "media/marker.png")], &[("word/media/marker.png", "synthetic")])).unwrap();
|
||||
let bare = |d: &Document| paras(d)[0].objects[0].frame().unwrap().2.bare_turn;
|
||||
for effects in ["", r#"<wp:effectExtent l="0" t="0" r="0" b="0"/>"#] {
|
||||
let d = read(&pic(effects));
|
||||
assert!(bare(&d), "{effects:?}");
|
||||
let out = wordcraft_docx::write(&d).unwrap();
|
||||
let body = text(&unzip(&out), "word/document.xml");
|
||||
assert_eq!(attr_values(&body, "wp:effectExtent", "t"), ["0"]);
|
||||
assert!(bare(&wordcraft_docx::read(&out).unwrap()));
|
||||
}
|
||||
// 120 × 60 pt at 45°: Word's rotated-bounds room, 3.64 pt wider and 33.64 pt taller each side.
|
||||
let d = read(&pic(r#"<wp:effectExtent l="46210" t="427210" r="46210" b="427210"/>"#));
|
||||
assert!(!bare(&d));
|
||||
let mut d = read(&pic(""));
|
||||
let f = first_object(&mut d).float_mut().unwrap();
|
||||
f.set_spin(wordcraft_geom::Spin::new(30.0, false, false));
|
||||
assert!(!f.bare_turn, "turned here: the rotated bounds again");
|
||||
// A shadow's room added later: saved, and so laid out, with the turn's room again.
|
||||
let mut d = read(&pic(""));
|
||||
let f = first_object(&mut d).float_mut().unwrap();
|
||||
f.effect = [2.0; 4];
|
||||
assert!(!f.is_bare_turn());
|
||||
let out = wordcraft_docx::write(&d).unwrap();
|
||||
assert_ne!(attr_values(&text(&unzip(&out), "word/document.xml"), "wp:effectExtent", "t"), ["0"]);
|
||||
// Made floating: saved with the turn's room, so it opens again as an ordinary turn.
|
||||
let mut d = read(&pic(""));
|
||||
let f = first_object(&mut d).float_mut().unwrap();
|
||||
f.wrap = wordcraft_doc::para::Wrap::Square;
|
||||
assert!(!f.is_bare_turn());
|
||||
let out = wordcraft_docx::write(&d).unwrap();
|
||||
assert_ne!(attr_values(&text(&unzip(&out), "word/document.xml"), "wp:effectExtent", "t"), ["0"]);
|
||||
assert!(!bare(&wordcraft_docx::read(&out).unwrap()));
|
||||
// Only inline ones: a floating one keeps text clear of its rotated bounds as before.
|
||||
let anchored = pic("").replace("<wp:inline>", "<wp:anchor>").replace("</wp:inline>", "</wp:anchor>");
|
||||
assert!(!bare(&read(&anchored)));
|
||||
}
|
||||
|
||||
/// The math namespace, declared on the math element itself (the fixture root doesn't).
|
||||
const M_NS: &str = r#"xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math""#;
|
||||
|
||||
|
||||
@@ -21,8 +21,10 @@ mod heading_levels;
|
||||
mod legacy_forms;
|
||||
mod ligatures;
|
||||
mod malformed;
|
||||
mod nested_block_controls;
|
||||
mod note_comments;
|
||||
mod passthrough;
|
||||
mod picture_links;
|
||||
mod positional_tabs;
|
||||
mod privacy;
|
||||
mod roundtrip;
|
||||
@@ -34,9 +36,11 @@ 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;
|
||||
mod twips_wrap;
|
||||
mod undefined_style;
|
||||
mod vml_shapes;
|
||||
mod word_xml;
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
//! A body-level content control nested directly in another body-level one stays body-level when
|
||||
//! saved, even when it holds a single paragraph (ECMA-376 §17.5.2.29-30; [MS-DOCX] `w15`
|
||||
//! repeating sections, which must wrap whole paragraphs or rows). All markup here is synthetic.
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
use wordcraft_doc::{ContentControl, Document, StoryRef};
|
||||
use wordcraft_docx::{read, write};
|
||||
|
||||
const NS: &str =
|
||||
r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:w15="http://schemas.microsoft.com/office/word/2012/wordml""#;
|
||||
const ROOT_RELS: &str = 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>"#;
|
||||
|
||||
fn package(body: &str) -> Vec<u8> {
|
||||
let doc = format!(r#"<w:document {NS}><w:body>{body}</w:body></w:document>"#);
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (name, bytes) in [("_rels/.rels", ROOT_RELS.as_bytes()), ("word/document.xml", doc.as_bytes())] {
|
||||
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zw.write_all(bytes).unwrap();
|
||||
}
|
||||
zw.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
fn document_xml(docx: &[u8]) -> String {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(docx)).unwrap();
|
||||
let mut s = String::new();
|
||||
std::io::Read::read_to_string(&mut z.by_name("word/document.xml").unwrap(), &mut s).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn t(s: &str) -> String {
|
||||
format!(r#"<w:r><w:t xml:space="preserve">{s}</w:t></w:r>"#)
|
||||
}
|
||||
fn p(inner: &str) -> String {
|
||||
format!("<w:p>{inner}</w:p>")
|
||||
}
|
||||
fn sdt(pr: &str, content: &str) -> String {
|
||||
format!("<w:sdt><w:sdtPr>{pr}</w:sdtPr><w:sdtContent>{content}</w:sdtContent></w:sdt>")
|
||||
}
|
||||
|
||||
fn controls(d: &Document) -> Vec<ContentControl> {
|
||||
let mut out = Vec::new();
|
||||
wordcraft_doc::control::walk_controls(&d.body, &mut |c| out.push(c.clone()));
|
||||
out
|
||||
}
|
||||
|
||||
/// How many `w:p` elements are open at byte `at` of `xml`.
|
||||
fn open_paragraphs(xml: &str, at: usize) -> usize {
|
||||
let head = &xml[..at];
|
||||
let opened = head.matches("<w:p>").count() + head.matches("<w:p ").count();
|
||||
opened - head.matches("</w:p>").count()
|
||||
}
|
||||
|
||||
/// Paragraph depth at the start of the `w:sdt` whose `w:sdtPr` holds `marker`.
|
||||
fn depth_of_control(xml: &str, marker: &str) -> usize {
|
||||
let at = xml.find(marker).unwrap_or_else(|| panic!("{marker} not written:\n{xml}"));
|
||||
let start = xml[..at].rfind("<w:sdt>").expect("control start");
|
||||
open_paragraphs(xml, start)
|
||||
}
|
||||
|
||||
fn round_trip(body: &str) -> (Document, Document, String) {
|
||||
let d = read(&package(body)).unwrap();
|
||||
let bytes = write(&d).unwrap();
|
||||
let back = read(&bytes).unwrap();
|
||||
(d, back, document_xml(&bytes))
|
||||
}
|
||||
|
||||
fn repeating_section() -> String {
|
||||
let field = sdt(r#"<w:alias w:val="note"/><w:id w:val="3"/><w:text/>"#, &t("note 1"));
|
||||
let item = sdt(r#"<w:alias w:val="item"/><w:id w:val="2"/><w15:repeatingSectionItem/>"#, &p(&format!("{field}{}", t(" "))));
|
||||
let section = sdt(r#"<w:alias w:val="notes"/><w:id w:val="1"/><w15:repeatingSection/>"#, &item);
|
||||
format!("{}{section}{}", p(&t("Notes")), p(&t("End")))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_repeating_section_around_one_paragraph_stays_around_the_paragraph() {
|
||||
let (_, _, xml) = round_trip(&repeating_section());
|
||||
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="notes"/>"#), 0, "section written inside a paragraph:\n{xml}");
|
||||
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="item"/>"#), 0, "item written inside a paragraph:\n{xml}");
|
||||
// The text control stays inside the paragraph, and the item holds that paragraph.
|
||||
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="note"/>"#), 1);
|
||||
assert!(xml.contains("<w15:repeatingSectionItem/></w:sdtPr><w:sdtContent><w:p"), "{xml}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_repeating_section_round_trips_its_controls_and_text() {
|
||||
let (d, back, _) = round_trip(&repeating_section());
|
||||
assert_eq!(controls(&back), controls(&d));
|
||||
assert_eq!(back.plain_text(StoryRef::Body), d.plain_text(StoryRef::Body));
|
||||
assert_eq!(back.body.len(), d.body.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_nested_block_controls_around_one_paragraph_stay_block_level() {
|
||||
let inner = sdt(r#"<w:alias w:val="inner"/><w:id w:val="11"/>"#, &p(&t("Only paragraph")));
|
||||
let outer = sdt(r#"<w:alias w:val="outer"/><w:id w:val="10"/>"#, &inner);
|
||||
let (d, back, xml) = round_trip(&format!("{outer}{}", p(&t("After"))));
|
||||
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="outer"/>"#), 0, "{xml}");
|
||||
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="inner"/>"#), 0, "{xml}");
|
||||
assert_eq!(controls(&back), controls(&d));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_block_controls_in_a_table_cell_stay_block_level() {
|
||||
let inner = sdt(r#"<w:alias w:val="cellInner"/><w:id w:val="21"/>"#, &p(&t("In cell")));
|
||||
let outer = sdt(r#"<w:alias w:val="cellOuter"/><w:id w:val="20"/>"#, &inner);
|
||||
let table = format!(r#"<w:tbl><w:tblGrid><w:gridCol w:w="4000"/></w:tblGrid><w:tr><w:tc>{outer}</w:tc></w:tr></w:tbl>"#);
|
||||
let (_, _, xml) = round_trip(&format!("{table}{}", p(&t("After"))));
|
||||
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="cellOuter"/>"#), 0, "{xml}");
|
||||
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="cellInner"/>"#), 0, "{xml}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_run_level_control_stays_inside_its_paragraph() {
|
||||
let field = sdt(r#"<w:alias w:val="inline"/><w:id w:val="30"/>"#, &t("value"));
|
||||
let (_, _, xml) = round_trip(&p(&format!("{}{field}", t("Name: "))));
|
||||
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="inline"/>"#), 1, "{xml}");
|
||||
}
|
||||
|
||||
/// A bound repeating section as Word writes one at the end of a form, optionally followed by
|
||||
/// the body-level `_GoBack` bookmark Word leaves after the last control.
|
||||
fn bound_section_body(bound: bool, bookmark: bool) -> String {
|
||||
let section_binding = if bound {
|
||||
r#"<w15:dataBinding w:prefixMappings="" w:xpath="/a[1]/n[1]" w:storeItemID="{00000000-0000-0000-0000-0000000000AA}"/>"#
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let trailing = if bookmark { r#"<w:bookmarkStart w:id="0" w:name="_GoBack"/><w:bookmarkEnd w:id="0"/>"# } else { "" };
|
||||
let bind = |x: &str| format!(r#"<w:dataBinding w:prefixMappings="" w:xpath="{x}" w:storeItemID="{{00000000-0000-0000-0000-0000000000AA}}"/>"#);
|
||||
let field = format!(
|
||||
r#"<w:sdt><w:sdtPr><w:alias w:val="note"/><w:placeholder><w:docPart w:val="DefaultPlaceholder_1"/></w:placeholder>{}<w:text/></w:sdtPr><w:sdtEndPr/><w:sdtContent>{}</w:sdtContent></w:sdt>"#,
|
||||
if bound { bind("/a[1]/n[1]") } else { String::new() },
|
||||
t("note 1")
|
||||
);
|
||||
let item = format!(
|
||||
r#"<w:sdt><w:sdtPr><w:alias w:val="item"/><w:placeholder><w:docPart w:val="DefaultPlaceholder_2"/></w:placeholder><w15:repeatingSectionItem/></w:sdtPr><w:sdtEndPr/><w:sdtContent>{}</w:sdtContent></w:sdt>"#,
|
||||
p(&format!("{field}{}", t(" ")))
|
||||
);
|
||||
let section = format!(
|
||||
r#"<w:sdt><w:sdtPr><w:alias w:val="notes"/>{section_binding}<w15:repeatingSection/></w:sdtPr><w:sdtContent>{item}</w:sdtContent></w:sdt>"#
|
||||
);
|
||||
format!(r#"{}{section}{trailing}<w:sectPr><w:pgSz w:w="12240" w:h="15840"/></w:sectPr>"#, p(&t("Notes")))
|
||||
}
|
||||
|
||||
fn assert_section_block_level(body: &str) {
|
||||
let (d, back, xml) = round_trip(body);
|
||||
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="notes"/>"#), 0, "section written inside a paragraph:\n{xml}");
|
||||
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="item"/>"#), 0, "item written inside a paragraph:\n{xml}");
|
||||
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="note"/>"#), 1, "{xml}");
|
||||
assert_eq!(controls(&back), controls(&d));
|
||||
assert_eq!(back.plain_text(StoryRef::Body), d.plain_text(StoryRef::Body));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bound_repeating_section_followed_by_a_body_bookmark_stays_block_level() {
|
||||
assert_section_block_level(&bound_section_body(true, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bound_repeating_section_at_the_end_of_the_body_stays_block_level() {
|
||||
assert_section_block_level(&bound_section_body(true, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_repeating_section_followed_by_a_body_bookmark_stays_block_level() {
|
||||
assert_section_block_level(&bound_section_body(false, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_block_control_followed_by_a_body_bookmark_stays_around_its_paragraph() {
|
||||
let inner = sdt(r#"<w:alias w:val="inner"/><w:id w:val="41"/>"#, &p(&t("Last paragraph")));
|
||||
let outer = sdt(r#"<w:alias w:val="outer"/><w:id w:val="40"/>"#, &inner);
|
||||
let body = format!(r#"{}{outer}<w:bookmarkStart w:id="0" w:name="_GoBack"/><w:bookmarkEnd w:id="0"/>"#, p(&t("First")));
|
||||
let (d, back, xml) = round_trip(&body);
|
||||
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="outer"/>"#), 0, "{xml}");
|
||||
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="inner"/>"#), 0, "{xml}");
|
||||
assert_eq!(controls(&back), controls(&d));
|
||||
// The bookmark is kept.
|
||||
assert!(xml.contains(r#"w:name="_GoBack""#), "{xml}");
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
@@ -6,15 +6,13 @@ use wordcraft_doc::fill::Fill;
|
||||
use wordcraft_doc::numbering::ListKind;
|
||||
use wordcraft_doc::para::{Anchor, Float, FloatAlign, NoteKind, ShapeKind, Wrap};
|
||||
use wordcraft_doc::props::{
|
||||
Align, Border, BorderStyle, Borders, CharProps, HeightRule, Highlight, LineSpacing, NumRef, ParaProps, Rgb, RowProps, TabAlign, TabLeader,
|
||||
TabStop, TableLook, TextAlignment, TextColor, TextDirection, Underline, VAlign, VMerge, VertAlign,
|
||||
Align, Border, BorderStyle, Borders, CharProps, HeightRule, Highlight, LineSpacing, NumRef, ParaProps, ParagraphTextDirection, Rgb, RowProps,
|
||||
TabAlign, TabLeader, TabStop, TableLook, TextAlignment, TextColor, TextDirection, Underline, VAlign, VMerge, VertAlign,
|
||||
};
|
||||
use wordcraft_doc::section::{Columns, DocGrid, DocGridType, LineNumberRestart, LineNumbering, NumFormat, PageVAlign, SectionProps, SectionStart};
|
||||
use wordcraft_doc::styles::{Style, StyleKind};
|
||||
use wordcraft_doc::table::{Cell, Table};
|
||||
use wordcraft_doc::{
|
||||
Block, Blocks, Comment, Document, InlineObject, Paragraph, PartKind, Revision, RevisionKind, Source, Watermark, WatermarkPicture, para_block,
|
||||
};
|
||||
use wordcraft_doc::{Block, Blocks, Comment, Document, InlineObject, Paragraph, PartKind, Revision, RevisionKind, Source, Watermark, para_block};
|
||||
|
||||
fn rt(doc: &Document) -> Document {
|
||||
let bytes = wordcraft_docx::write(doc).expect("write");
|
||||
@@ -197,6 +195,7 @@ fn every_para_prop_round_trips() {
|
||||
suppress_hyphens: Some(true),
|
||||
suppress_line_numbers: Some(true),
|
||||
bidi: Some(false),
|
||||
text_direction: Some(ParagraphTextDirection::TbRl),
|
||||
drop_cap: None,
|
||||
drop_cap_margin: None,
|
||||
drop_cap_distance: None,
|
||||
@@ -1563,10 +1562,7 @@ fn watermark_roundtrips() {
|
||||
diagonal: false,
|
||||
semitransparent: false,
|
||||
size: None,
|
||||
..Default::default()
|
||||
},
|
||||
// Any transparency, not only Word's two.
|
||||
Watermark { transparency: Some(80), ..Default::default() },
|
||||
// A box of its own (not Word's default for the page) is kept.
|
||||
Watermark { text: "COPY".into(), size: Some([300.0, 80.0]), ..Default::default() },
|
||||
] {
|
||||
@@ -1578,147 +1574,6 @@ fn watermark_roundtrips() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A `w`×`h` px PNG of one colour.
|
||||
fn wm_png(w: u32, h: u32) -> Vec<u8> {
|
||||
let mut png = Vec::new();
|
||||
image::RgbaImage::from_pixel(w, h, image::Rgba([20, 60, 140, 255]))
|
||||
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
png
|
||||
}
|
||||
|
||||
/// #2954: a picture watermark round-trips through Word's VML picture shape in every header:
|
||||
/// the picture, Auto or a scale, and Washout (`gain` and `blacklevel`) on or off. The header
|
||||
/// keeps its own text and gains no picture of its own.
|
||||
#[test]
|
||||
fn picture_watermark_roundtrips() {
|
||||
let png = wm_png(40, 20);
|
||||
let mut d = doc_with(vec![Paragraph::with_text("Body", CharProps::default())]);
|
||||
let h = d.add_part(PartKind::Header, vec![para_block(Paragraph::with_text("Head", CharProps::default()))]);
|
||||
d.last_section.headers.default = Some(h);
|
||||
let key = d.add_media(png.clone(), "png");
|
||||
for (scale, washout) in [(None, true), (Some(50.0), false), (Some(200.0), true)] {
|
||||
let wm = Watermark { picture: Some(WatermarkPicture { media: key.clone(), scale, washout }), ..Watermark::default() };
|
||||
d.settings.watermark = Some(wm.clone());
|
||||
let bytes = wordcraft_docx::write(&d).unwrap();
|
||||
let hdr = part_xml(&bytes, "word/header1.xml");
|
||||
assert_eq!(hdr.matches("<v:shape ").count(), 1);
|
||||
assert!(hdr.contains(r#"id="WordPictureWatermark1""#) && hdr.contains("mso-position-vertical-relative:margin"), "{hdr}");
|
||||
assert_eq!(hdr.contains(r#"gain="19661f" blacklevel="22938f""#), washout);
|
||||
assert!(part_xml(&bytes, "word/_rels/header1.xml.rels").contains("media/"));
|
||||
let r = wordcraft_docx::read(&bytes).unwrap();
|
||||
let back = r.settings.watermark.clone().expect("watermark");
|
||||
let pic = back.picture.clone().expect("picture");
|
||||
assert_eq!((pic.scale, pic.washout), (scale, washout));
|
||||
assert_eq!(r.media.get(&pic.media).map(|b| b.as_slice()), Some(png.as_slice()));
|
||||
let hid = r.last_section.headers.default.unwrap();
|
||||
assert_eq!(part_text(&r, Some(hid)), "Head");
|
||||
assert!(r.parts[&hid].blocks.iter().filter_map(|b| b.as_para()).all(|p| p.objects.is_empty()), "no picture left in the header");
|
||||
assert_eq!(rt(&r).settings.watermark, r.settings.watermark, "second generation");
|
||||
}
|
||||
// A watermark whose picture can't be read is not written.
|
||||
d.media.insert(key, Arc::new(b"junk".to_vec()));
|
||||
let r = rt(&d);
|
||||
assert_eq!(r.settings.watermark, None);
|
||||
assert!(!part_xml(&wordcraft_docx::write(&d).unwrap(), "word/header1.xml").contains("v:shape"));
|
||||
}
|
||||
|
||||
/// A portrait first section whose header the landscape last section links to ("link to
|
||||
/// previous"), with a wide picture watermark at Auto.
|
||||
fn portrait_then_landscape_linked(header: bool, scale: Option<f32>) -> Document {
|
||||
let mut d = Document::new();
|
||||
let mut s1 = SectionProps::default();
|
||||
if header {
|
||||
s1.headers.default = Some(d.add_part(PartKind::Header, vec![para_block(Paragraph::with_text("Head", CharProps::default()))]));
|
||||
}
|
||||
let mut p1 = Paragraph::with_text("Portrait", CharProps::default());
|
||||
p1.section = Some(Box::new(s1));
|
||||
d.body = vec![para_block(p1), para_block(Paragraph::with_text("Landscape", CharProps::default()))];
|
||||
d.last_section.set_landscape(true);
|
||||
let key = d.add_media(wm_png(400, 200), "png");
|
||||
d.settings.watermark = Some(Watermark { picture: Some(WatermarkPicture { media: key, scale, washout: true }), ..Watermark::default() });
|
||||
d
|
||||
}
|
||||
|
||||
/// #2954 review: Auto is sized once per header, for the first section that names it. A portrait
|
||||
/// section before a landscape one that links to its header: the header holds the portrait size
|
||||
/// (468 pt wide), and reads back as Auto.
|
||||
#[test]
|
||||
fn auto_picture_watermark_is_sized_for_the_first_section_naming_its_header() {
|
||||
let d = portrait_then_landscape_linked(true, None);
|
||||
// The header the landscape section links to is the portrait section's.
|
||||
assert_eq!(d.watermark_fit(d.sections()[0].1.headers.default), (468.0, 648.0));
|
||||
let bytes = wordcraft_docx::write(&d).unwrap();
|
||||
assert!(part_xml(&bytes, "word/header1.xml").contains("width:468.000pt;height:234.000pt"));
|
||||
let r = wordcraft_docx::read(&bytes).unwrap();
|
||||
assert_eq!(r.settings.watermark.and_then(|w| w.picture).map(|p| p.scale), Some(None), "still Auto");
|
||||
}
|
||||
|
||||
/// A document of two sections, the first with no header of its own and the last landscape: the
|
||||
/// watermark's own header serves both, sized for the first.
|
||||
#[test]
|
||||
fn auto_picture_watermark_header_written_for_the_watermark_alone() {
|
||||
let d = portrait_then_landscape_linked(false, None);
|
||||
assert_eq!(d.watermark_fit(None), (468.0, 648.0));
|
||||
let bytes = wordcraft_docx::write(&d).unwrap();
|
||||
assert!(part_xml(&bytes, "word/header1.xml").contains("width:468.000pt;height:234.000pt"));
|
||||
let r = wordcraft_docx::read(&bytes).unwrap();
|
||||
assert_eq!(r.settings.watermark.and_then(|w| w.picture).map(|p| p.scale), Some(None));
|
||||
}
|
||||
|
||||
/// #2954 second review: a watermark at a fixed scale, its header shapes the same size in a
|
||||
/// portrait and a landscape section, fills one section's margins but not the other's. It is
|
||||
/// still the document's watermark at that scale after saving and reopening, washed out, and
|
||||
/// the headers gain no pictures.
|
||||
#[test]
|
||||
fn fixed_scale_watermark_across_differing_sections() {
|
||||
let mut d = Document::new();
|
||||
let h1 = d.add_part(PartKind::Header, vec![para_block(Paragraph::with_text("Portrait", CharProps::default()))]);
|
||||
let h2 = d.add_part(PartKind::Header, vec![para_block(Paragraph::with_text("Landscape", CharProps::default()))]);
|
||||
let mut s1 = SectionProps::default();
|
||||
s1.headers.default = Some(h1);
|
||||
let mut p1 = Paragraph::with_text("One", CharProps::default());
|
||||
p1.section = Some(Box::new(s1));
|
||||
d.body = vec![para_block(p1), para_block(Paragraph::with_text("Two", CharProps::default()))];
|
||||
d.last_section.set_landscape(true);
|
||||
d.last_section.headers.default = Some(h2);
|
||||
let key = d.add_media(wm_png(624, 312), "png");
|
||||
let wm = Watermark { picture: Some(WatermarkPicture { media: key, scale: Some(100.0), washout: true }), ..Watermark::default() };
|
||||
d.settings.watermark = Some(wm);
|
||||
let bytes = wordcraft_docx::write(&d).unwrap();
|
||||
for name in ["word/header1.xml", "word/header2.xml"] {
|
||||
assert!(part_xml(&bytes, name).contains("width:468.000pt;height:234.000pt"), "{name}");
|
||||
}
|
||||
let r = wordcraft_docx::read(&bytes).unwrap();
|
||||
let pic = r.settings.watermark.clone().and_then(|w| w.picture).expect("still the document's watermark");
|
||||
assert_eq!((pic.scale, pic.washout), (Some(100.0), true));
|
||||
for id in header_ids(&r) {
|
||||
assert!(r.parts[&id].blocks.iter().filter_map(|b| b.as_para()).all(|p| p.objects.is_empty()), "no header picture");
|
||||
}
|
||||
let r2 = rt(&r);
|
||||
assert_eq!(r2.settings.watermark, r.settings.watermark, "second generation");
|
||||
}
|
||||
|
||||
/// #2954: the text watermark's transparency round-trips as `v:fill` `opacity`; Word's
|
||||
/// Semitransparent is `.5`, and an opaque watermark has no `v:fill`.
|
||||
#[test]
|
||||
fn text_watermark_transparency_roundtrips() {
|
||||
for (t, fill) in [(0, None), (25, Some(".75")), (50, Some(".5")), (80, Some(".2")), (100, Some("0"))] {
|
||||
let mut d = doc_with(vec![Paragraph::with_text("Body", CharProps::default())]);
|
||||
let mut wm = Watermark::default();
|
||||
wm.set_transparency(t);
|
||||
d.settings.watermark = Some(wm.clone());
|
||||
let bytes = wordcraft_docx::write(&d).unwrap();
|
||||
let hdr = part_xml(&bytes, "word/header1.xml");
|
||||
match fill {
|
||||
Some(o) => assert!(hdr.contains(&format!(r#"<v:fill opacity="{o}"/>"#)), "{t}: {hdr}"),
|
||||
None => assert!(!hdr.contains("v:fill"), "{t}"),
|
||||
}
|
||||
let r = wordcraft_docx::read(&bytes).unwrap();
|
||||
assert_eq!(r.settings.watermark, Some(wm), "{t}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #3994: a watermark font name round-trips through the textpath's CSS-like style whatever it
|
||||
/// carries — apostrophes, semicolons, colons, quotes, backslashes. Before, those characters
|
||||
/// were deleted (the font was saved under a different name) and a trailing backslash escaped
|
||||
|
||||
@@ -18,6 +18,25 @@ fn docx(body: &str) -> Vec<u8> {
|
||||
zw.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
fn docx_with_styles(body: &str, styles: &str) -> Vec<u8> {
|
||||
let mut bytes = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let root = 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 document_rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdStyles" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>"#;
|
||||
let doc = format!(
|
||||
r#"<?xml version="1.0"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>{body}</w:body></w:document>"#
|
||||
);
|
||||
for (name, value) in [
|
||||
("_rels/.rels", root.as_bytes()),
|
||||
("word/_rels/document.xml.rels", document_rels.as_bytes()),
|
||||
("word/document.xml", doc.as_bytes()),
|
||||
("word/styles.xml", styles.as_bytes()),
|
||||
] {
|
||||
bytes.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
bytes.write_all(value).unwrap();
|
||||
}
|
||||
bytes.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();
|
||||
@@ -59,3 +78,52 @@ fn explicit_horizontal_direction_is_kept_in_sections_and_cells() {
|
||||
let plain = wordcraft_docx::read(&docx(r#"<w:p><w:r><w:t>Body</w:t></w:r></w:p><w:sectPr/>"#)).unwrap();
|
||||
assert!(!document_xml(&wordcraft_docx::write(&plain).unwrap()).contains("textDirection"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_text_direction_round_trips_through_two_saves_and_json() {
|
||||
use wordcraft_doc::props::ParaProps;
|
||||
use wordcraft_doc::props::ParagraphTextDirection;
|
||||
|
||||
let values = ["lrTb", "lrTbV", "tb", "tbRl", "tbRlV", "tbLr", "tbLrV", "btLr", "tbV", "rl", "rlV", "lr", "lrV"];
|
||||
let mut body: String =
|
||||
values.iter().map(|value| format!(r#"<w:p><w:pPr><w:textDirection w:val="{value}"/></w:pPr><w:r><w:t>{value}</w:t></w:r></w:p>"#)).collect();
|
||||
body.push_str(r#"<w:p><w:r><w:t>Inherited</w:t></w:r></w:p>"#);
|
||||
let d = wordcraft_docx::read(&docx(&body)).unwrap();
|
||||
let paras: Vec<_> = d.body.iter().filter_map(|b| b.as_para()).collect();
|
||||
for (para, value) in paras.iter().zip(values) {
|
||||
assert_eq!(para.props.text_direction, ParagraphTextDirection::from_ooxml(value), "{value}");
|
||||
}
|
||||
assert_eq!(paras[values.len()].props.text_direction, None);
|
||||
let json = serde_json::to_value(¶s[0].props).unwrap();
|
||||
assert_eq!(json["textDirection"], "lrTb");
|
||||
let decoded: ParaProps = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(decoded.text_direction, Some(ParagraphTextDirection::LrTb));
|
||||
|
||||
let first = wordcraft_docx::write(&d).unwrap();
|
||||
let xml = document_xml(&first);
|
||||
for value in values {
|
||||
assert!(xml.contains(&format!(r#"<w:textDirection w:val="{value}"/>"#)), "{xml}");
|
||||
}
|
||||
assert_eq!(xml.matches("w:textDirection").count(), values.len(), "inherited direction stays absent: {xml}");
|
||||
let second = wordcraft_docx::write(&wordcraft_docx::read(&first).unwrap()).unwrap();
|
||||
let twice = document_xml(&second);
|
||||
assert_eq!(twice.matches("w:textDirection").count(), values.len(), "{twice}");
|
||||
for value in values {
|
||||
assert!(twice.contains(&format!(r#"<w:textDirection w:val="{value}"/>"#)), "{twice}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_style_text_direction_is_preserved_and_direct_value_overrides_it() {
|
||||
let styles = r#"<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:style w:type="paragraph" w:styleId="Vertical"><w:name w:val="Vertical"/><w:pPr><w:textDirection w:val="tbRl"/></w:pPr></w:style></w:styles>"#;
|
||||
let body = r#"<w:p><w:pPr><w:pStyle w:val="Vertical"/></w:pPr><w:r><w:t>Style</w:t></w:r></w:p><w:p><w:pPr><w:pStyle w:val="Vertical"/><w:textDirection w:val="lrTb"/></w:pPr><w:r><w:t>Override</w:t></w:r></w:p>"#;
|
||||
let d = wordcraft_docx::read(&docx_with_styles(body, styles)).unwrap();
|
||||
let xml = document_xml(&wordcraft_docx::write(&d).unwrap());
|
||||
assert_eq!(xml.matches(r#"<w:pStyle w:val="Vertical"/>"#).count(), 2, "both paragraphs keep their style: {xml}");
|
||||
assert!(xml.contains(r#"<w:pStyle w:val="Vertical"/><w:textDirection w:val="lrTb"/>"#), "direct horizontal is explicit: {xml}");
|
||||
assert_eq!(xml.matches("w:textDirection").count(), 1, "only the direct paragraph writes a direction: {xml}");
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(wordcraft_docx::write(&d).unwrap())).unwrap();
|
||||
let mut styles_xml = String::new();
|
||||
z.by_name("word/styles.xml").unwrap().read_to_string(&mut styles_xml).unwrap();
|
||||
assert!(styles_xml.contains(r#"<w:textDirection w:val="tbRl"/>"#), "style value survives: {styles_xml}");
|
||||
}
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
@@ -132,6 +132,15 @@ fn vml_text_boxes_keep_their_inset_fit_and_anchor() {
|
||||
shape("", r#"o:insetmode="auto" inset="0,0,0,0""#),
|
||||
// In a group, a member's margins go with that member's text, not an empty box's before it.
|
||||
format!(r#"<w:p><w:r><w:pict><v:group style="width:100pt;height:40pt"><v:shape><v:textbox inset="9pt,9pt,9pt,9pt"/></v:shape><v:shape><v:textbox inset="0,0,0,0">{content}</v:textbox></v:shape></v:group></w:pict></w:r></w:p>"#),
|
||||
// Text direction (#5193), as Word for Windows reads it: `vertical` and
|
||||
// `vertical-ideographic` are `vert`; a `bottom-to-top` alt is `vert270` with any
|
||||
// `layout-flow` or none; `horizontal-ideographic` stays horizontal.
|
||||
shape("", r#"style="layout-flow:vertical""#),
|
||||
shape("", r#"style="layout-flow:vertical;mso-layout-flow-alt:bottom-to-top;mso-next-textbox:#Text Box 4""#),
|
||||
shape("", r#"style="layout-flow:vertical-ideographic""#),
|
||||
shape("", r#"style="mso-layout-flow-alt:bottom-to-top""#),
|
||||
shape("", r#"style="layout-flow:horizontal;mso-layout-flow-alt:bottom-to-top""#),
|
||||
shape("", r#"style="layout-flow:horizontal-ideographic""#),
|
||||
]
|
||||
.concat();
|
||||
let doc = format!(
|
||||
@@ -140,13 +149,15 @@ fn vml_text_boxes_keep_their_inset_fit_and_anchor() {
|
||||
let rels = br#"<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 d = wordcraft_docx::read(&zip(&[("_rels/.rels", rels), ("word/document.xml", doc.as_bytes())])).unwrap();
|
||||
let bodies: Vec<TextBody> = boxes(&d).iter().map(|id| d.parts[id].body).collect();
|
||||
assert_eq!(bodies.len(), 6);
|
||||
assert_eq!(bodies.len(), 12);
|
||||
assert_eq!(bodies[0].inset, Some([0, 0, 0, 0]));
|
||||
assert_eq!((bodies[1].inset, bodies[1].auto_fit, bodies[1].anchor), (Some([36_000, 45_720, 25_400, 45_720]), true, VAlign::Center));
|
||||
assert_eq!(bodies[2], TextBody::default());
|
||||
assert_eq!((bodies[3].inset, bodies[3].anchor), (Some([12_700, 45_720, 0, wordcraft_doc::MAX_TEXT_BOX_INSET]), VAlign::Bottom));
|
||||
assert_eq!(bodies[4].inset, None);
|
||||
assert_eq!(bodies[5].inset, Some([0, 0, 0, 0]));
|
||||
let verts: Vec<TextVert> = bodies[6..].iter().map(|b| b.vert).collect();
|
||||
assert_eq!(verts, [TextVert::Vert, TextVert::Vert270, TextVert::Vert, TextVert::Vert270, TextVert::Vert270, TextVert::Horz]);
|
||||
// Saved and read again, they stay.
|
||||
let back = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||||
let again: Vec<TextBody> = boxes(&back).iter().map(|id| back.parts[id].body).collect();
|
||||
|
||||
@@ -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")),
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
//! Paragraph indents and spacing written as plain twips beyond 16 bits: Word keeps only the low
|
||||
//! 16 bits (ECMA-376 Part 1 §17.3.1.12 `w:ind`, §17.3.1.33 `w:spacing`). Measured in Word: a
|
||||
//! `w:left` of 66256 starts the text 36 pt in, a `w:right` of 66976 leaves a 72 pt right indent.
|
||||
//! Fixtures are hand-written.
|
||||
|
||||
use wordcraft_doc::Document;
|
||||
use wordcraft_doc::props::ParaProps;
|
||||
|
||||
use crate::fixtures::zip;
|
||||
|
||||
/// A package whose body is `body`.
|
||||
fn docx(body: &str) -> Vec<u8> {
|
||||
let root = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
|
||||
let doc = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>{body}</w:body></w:document>"#
|
||||
);
|
||||
zip(&[("_rels/.rels", root.as_bytes()), ("word/document.xml", doc.as_bytes())])
|
||||
}
|
||||
|
||||
fn props(body: &str) -> Vec<ParaProps> {
|
||||
let d: Document = wordcraft_docx::read(&docx(body)).unwrap();
|
||||
d.body.iter().filter_map(|b| b.as_para()).map(|p| p.props.clone()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indents_beyond_16_bits_wrap() {
|
||||
let p = props(
|
||||
r#"<w:p><w:pPr><w:ind w:left="66256"/></w:pPr><w:r><w:t>a</w:t></w:r></w:p>
|
||||
<w:p><w:pPr><w:ind w:right="66976"/></w:pPr><w:r><w:t>b</w:t></w:r></w:p>
|
||||
<w:p><w:pPr><w:ind w:start="65556" w:end="-65516"/></w:pPr><w:r><w:t>c</w:t></w:r></w:p>
|
||||
<w:p><w:pPr><w:ind w:left="98304" w:hanging="65896"/></w:pPr><w:r><w:t>d</w:t></w:r></w:p>"#,
|
||||
);
|
||||
assert_eq!(p[0].indent_left, Some(36.0));
|
||||
assert_eq!(p[1].indent_right, Some(72.0));
|
||||
// 65556 -> 20 twips; -65516 -> 20 twips.
|
||||
assert_eq!((p[2].indent_left, p[2].indent_right), (Some(1.0), Some(1.0)));
|
||||
// 98304 = 0x18000 -> 0x8000 = -32768 as a signed 16-bit value; hanging 65896 -> 360.
|
||||
assert_eq!((p[3].indent_left, p[3].indent_first), (Some(-1638.4), Some(-18.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spacing_beyond_16_bits_wraps() {
|
||||
let p = props(r#"<w:p><w:pPr><w:spacing w:before="65537" w:after="65776"/></w:pPr><w:r><w:t>a</w:t></w:r></w:p>"#);
|
||||
assert_eq!((p[0].space_before, p[0].space_after), (Some(0.05), Some(12.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn in_range_and_unit_values_are_unchanged() {
|
||||
let p = props(
|
||||
r#"<w:p><w:pPr><w:ind w:left="32767" w:right="-32768"/><w:spacing w:before="65535"/></w:pPr><w:r><w:t>a</w:t></w:r></w:p>
|
||||
<w:p><w:pPr><w:ind w:left="1in" w:right="720"/><w:spacing w:before="12pt"/></w:pPr><w:r><w:t>b</w:t></w:r></w:p>"#,
|
||||
);
|
||||
assert_eq!((p[0].indent_left, p[0].indent_right, p[0].space_before), (Some(1638.35), Some(-1638.4), Some(3276.75)));
|
||||
assert_eq!((p[1].indent_left, p[1].indent_right, p[1].space_before), (Some(72.0), Some(36.0), Some(12.0)));
|
||||
}
|
||||
@@ -270,3 +270,12 @@ fn flat_opc_limits_count_what_is_not_kept() {
|
||||
// A part nothing reads, one element over what the XML tree reader takes in a part (8 000 000).
|
||||
assert!(limited(&format!(r#"<pkg:part pkg:name="/word/unused.xml"><pkg:xmlData><r>{}</r></pkg:xmlData></pkg:part>"#, "<y/>".repeat(8_000_000))));
|
||||
}
|
||||
|
||||
/// A Word 2003 XML Document saved with Windows line ends: XML reads CR LF as one line feed (XML
|
||||
/// 1.0 §2.11), so a line break inside `w:t` is one space, not two.
|
||||
#[test]
|
||||
fn word_2003_xml_with_windows_line_ends_reads_one_space_per_line_break() {
|
||||
let xml = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\r\n<w:wordDocument xmlns:w=\"http://schemas.microsoft.com/office/word/2003/wordml\">\r\n<w:body><w:p><w:r><w:t>a\r\nb</w:t></w:r></w:p></w:body></w:wordDocument>\r\n";
|
||||
let d = read_xml(xml.as_bytes()).unwrap();
|
||||
assert_eq!(paras(&d)[0].plain_text(), "a b");
|
||||
}
|
||||
|
||||
@@ -17,27 +17,6 @@ fn tiny_png() -> Vec<u8> {
|
||||
out.into_inner()
|
||||
}
|
||||
|
||||
/// #2954: a picture watermark and a text watermark's transparency survive Word 2003 XML (the
|
||||
/// picture as `w:binData`, the same VML shapes as in DOCX).
|
||||
#[test]
|
||||
fn watermarks_round_trip_through_2003() {
|
||||
let mut d = Document::new();
|
||||
d.body = vec![para_block(Paragraph::with_text("Body", CharProps::default()))];
|
||||
let key = d.add_media(tiny_png(), "png");
|
||||
let pic = wordcraft_doc::WatermarkPicture { media: key, scale: Some(150.0), washout: true };
|
||||
d.settings.watermark = Some(wordcraft_doc::Watermark { picture: Some(pic), ..Default::default() });
|
||||
let back = read_xml(&write_wordml2003(&d).expect("write")).expect("read");
|
||||
let got = back.settings.watermark.and_then(|w| w.picture).expect("picture watermark");
|
||||
assert_eq!((got.scale, got.washout), (Some(150.0), true));
|
||||
assert_eq!(back.media.get(&got.media).map(|b| b.as_slice()), Some(tiny_png().as_slice()));
|
||||
|
||||
let mut wm = wordcraft_doc::Watermark::default();
|
||||
wm.set_transparency(70);
|
||||
d.settings.watermark = Some(wm.clone());
|
||||
let back = read_xml(&write_wordml2003(&d).expect("write")).expect("read");
|
||||
assert_eq!(back.settings.watermark, Some(wm));
|
||||
}
|
||||
|
||||
fn paras(doc: &Document) -> Vec<&Paragraph> {
|
||||
doc.body.iter().filter_map(|b| b.as_para()).collect()
|
||||
}
|
||||
|
||||
@@ -56,5 +56,3 @@ wordcraft-doc = { workspace = true, features = ["test-support"] }
|
||||
encoding_rs = { workspace = true }
|
||||
zip = { workspace = true }
|
||||
cfb = { workspace = true }
|
||||
# The PDF side of the watermark size check (what the export draws, read back).
|
||||
wordcraft-pdf = { workspace = true, features = ["test-support"] }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::Arc;
|
||||
use wordcraft_doc::props::{Border, BorderStyle, Borders, CharProps, LineSpacing, Rgb, TextColor};
|
||||
use wordcraft_doc::watermark::{MAX_SCALE, MIN_SCALE};
|
||||
use wordcraft_doc::{Watermark, WatermarkPicture, styles};
|
||||
use wordcraft_doc::{Watermark, styles};
|
||||
|
||||
use super::sel_result;
|
||||
use crate::{CmdError, CmdResult, CommandSpec, Session, p};
|
||||
@@ -72,7 +71,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("design.scaleFontSizes", "Scale Font Sizes", "Design › Document Formatting", scale_font_sizes)
|
||||
.params(r#"{"delta": pt (-1638..1638, not 0) | "percent": number (-99..1000, not 0; 10 = +10%)} (exactly one)"#),
|
||||
CommandSpec::new("design.watermark", "Watermark", "Design › Page Background", watermark)
|
||||
.params(r#"{"text"?: string (a text watermark), "remove"?: bool, "diagonal"?: bool, "color"?: "RRGGBB", "transparency"?: 0-100 (percent; Word's Semitransparent is 50), "semitransparent"?: bool (50 or 0 percent), "picture"?: {"path": string} | {"data": base64} | {"media": key} (a picture watermark; at most 64 MB) | false (back to text), "scale"?: "auto" | 1-500 (percent of the picture's own size; auto fits the margins), "washout"?: bool (faded, default on)}"#),
|
||||
.params(r#"{"text"?: string, "remove"?: bool, "diagonal"?: bool, "color"?: "RRGGBB"}"#),
|
||||
CommandSpec::new("design.pageColor", "Page Color", "Design › Page Background", |s, v| {
|
||||
s.doc.settings.page_color = p::str(v, "color").and_then(Rgb::parse);
|
||||
s.doc.settings.page_picture = None;
|
||||
@@ -248,142 +247,23 @@ fn scale_font_sizes(s: &mut Session, v: &Value) -> CmdResult {
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// Design › Watermark: a text watermark (`text` switches to one), or a picture watermark
|
||||
/// (`picture`) with its scale and Washout; the text keeps its settings meanwhile, as in Word's
|
||||
/// dialog. Settings for the other kind are refused rather than silently kept.
|
||||
fn watermark(s: &mut Session, v: &Value) -> CmdResult {
|
||||
if p::bool(v, "remove").unwrap_or(false) {
|
||||
s.doc.settings.watermark = None;
|
||||
return sel_result(s);
|
||||
}
|
||||
let mut wm = s.doc.settings.watermark.clone().unwrap_or_default();
|
||||
// Everything is checked before the document is touched (a picture added to the media).
|
||||
let scale = watermark_scale(v)?;
|
||||
let picked = match v.get("picture") {
|
||||
Some(Value::Bool(false)) => {
|
||||
wm.picture = None;
|
||||
None
|
||||
}
|
||||
Some(pic) if !pic.is_null() => Some(watermark_picture(s, pic)?),
|
||||
_ => {
|
||||
if p::str(v, "text").is_some() {
|
||||
wm.picture = None;
|
||||
}
|
||||
None
|
||||
}
|
||||
};
|
||||
// The picture's bytes wait: it joins the document's media only after every check below.
|
||||
let pending = pick_watermark_picture(&mut wm, picked);
|
||||
if let Some(t) = p::str(v, "text") {
|
||||
wm.text = t.chars().take(200).collect();
|
||||
}
|
||||
match wm.picture.as_mut() {
|
||||
Some(pic) => apply_picture_settings(v, pic, scale)?,
|
||||
None => apply_text_settings(v, &mut wm)?,
|
||||
}
|
||||
if let (Some(bytes), Some(pic)) = (pending, wm.picture.as_mut()) {
|
||||
let ext = super::insert::image_ext(&bytes);
|
||||
pic.media = s.doc.add_media(bytes, ext);
|
||||
}
|
||||
s.doc.settings.watermark = Some(wm);
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// The watermark's `scale` param: None when absent, `Some(None)` for Auto, else the percentage in
|
||||
/// tenths, clamped.
|
||||
fn watermark_scale(v: &Value) -> Result<Option<Option<f32>>, CmdError> {
|
||||
match v.get("scale") {
|
||||
None => Ok(None),
|
||||
Some(Value::String(a)) if a.eq_ignore_ascii_case("auto") => Ok(Some(None)),
|
||||
Some(_) => {
|
||||
let percent =
|
||||
p::f32(v, "scale").filter(|p| p.is_finite()).ok_or_else(|| CmdError::Params("`scale` is \"auto\" or a percentage".into()))?;
|
||||
// Tenths of a percent, as a saved file gives it back.
|
||||
Ok(Some(Some((percent.clamp(MIN_SCALE, MAX_SCALE) * 10.0).round() / 10.0)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Makes `picked` (bytes and an existing media key) the watermark's picture, keeping its other
|
||||
/// settings; returns the bytes still to join the document's media, if any.
|
||||
fn pick_watermark_picture(wm: &mut Watermark, picked: Option<(Vec<u8>, Option<String>)>) -> Option<Vec<u8>> {
|
||||
let (bytes, key) = picked?;
|
||||
let (media, pending) = match key {
|
||||
Some(key) => (key, None),
|
||||
None => (String::new(), Some(bytes)),
|
||||
};
|
||||
wm.picture = Some(WatermarkPicture { media, ..wm.picture.take().unwrap_or_default() });
|
||||
pending
|
||||
}
|
||||
|
||||
/// Applies a picture watermark's `scale` and `washout`; refuses a text watermark's settings.
|
||||
fn apply_picture_settings(v: &Value, pic: &mut WatermarkPicture, scale: Option<Option<f32>>) -> Result<(), CmdError> {
|
||||
if let Some(k) = ["diagonal", "color", "transparency", "semitransparent"].into_iter().find(|k| v.get(*k).is_some()) {
|
||||
return Err(CmdError::Params(format!("`{k}` is for a text watermark: give `text` or `\"picture\": false` too")));
|
||||
}
|
||||
if let Some(scale) = scale {
|
||||
pic.scale = scale;
|
||||
}
|
||||
if let Some(w) = p::bool(v, "washout") {
|
||||
pic.washout = w;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Applies a text watermark's layout, colour and transparency; refuses a picture's settings.
|
||||
fn apply_text_settings(v: &Value, wm: &mut Watermark) -> Result<(), CmdError> {
|
||||
if let Some(k) = ["scale", "washout"].into_iter().find(|k| v.get(*k).is_some()) {
|
||||
return Err(CmdError::Params(format!("`{k}` is for a picture watermark: give `picture` too")));
|
||||
}
|
||||
if let Some(d) = p::bool(v, "diagonal") {
|
||||
wm.diagonal = d;
|
||||
}
|
||||
if let Some(c) = p::str(v, "color").and_then(Rgb::parse) {
|
||||
wm.color = c;
|
||||
}
|
||||
if let Some(on) = p::bool(v, "semitransparent") {
|
||||
wm.set_transparency(if on { 50 } else { 0 });
|
||||
}
|
||||
// Applied after `semitransparent`, so a percentage wins when both are given.
|
||||
if let Some(t) = p::f32(v, "transparency") {
|
||||
wm.set_transparency(if t.is_finite() { t.clamp(0.0, 100.0).round() as u8 } else { 0 });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Largest watermark picture taken, bytes. A watermark is a faded backdrop, not a photograph
|
||||
/// library: far below Insert › Pictures' 200 MB, so refusing a bigger one never costs a large
|
||||
/// allocation (the base64 text is measured before it is decoded).
|
||||
const MAX_PICTURE_BYTES: usize = 64 << 20;
|
||||
|
||||
/// The picture of a watermark: a file (`path`), base64 `data`, or a picture already in the
|
||||
/// document (`media`, returned as its key). Only pictures the renderers draw are taken.
|
||||
fn watermark_picture(s: &Session, v: &Value) -> Result<(Vec<u8>, Option<String>), CmdError> {
|
||||
let unsupported = || CmdError::Failed("not a supported picture (PNG, JPEG, GIF, WebP, BMP, TIFF)".into());
|
||||
let readable = |b: &[u8]| wordcraft_doc::watermark::own_size(b).is_some();
|
||||
let too_large = || CmdError::Failed(format!("the picture is larger than {} MB", MAX_PICTURE_BYTES >> 20));
|
||||
if let Some(key) = p::str(v, "media") {
|
||||
let bytes = s.doc.media.get(key).ok_or_else(|| CmdError::Params(format!("no picture `{key}` in the document")))?;
|
||||
return if readable(bytes) { Ok((Vec::new(), Some(key.to_string()))) } else { Err(unsupported()) };
|
||||
}
|
||||
let bytes = if let Some(d) = p::str(v, "data") {
|
||||
// base64 is 4 characters for 3 bytes: measured before anything is decoded.
|
||||
if d.len() > MAX_PICTURE_BYTES / 3 * 4 + 4 {
|
||||
return Err(too_large());
|
||||
}
|
||||
super::insert::base64_decode(d).ok_or_else(|| CmdError::Params("bad base64 data".into()))?
|
||||
} else if let Some(path) = p::str(v, "path") {
|
||||
crate::io::read_file_capped(path, MAX_PICTURE_BYTES as u64, "picture").map_err(CmdError::Failed)?
|
||||
} else {
|
||||
return Err(CmdError::Params("`picture` needs `path`, `data` or `media`".into()));
|
||||
};
|
||||
if bytes.len() > MAX_PICTURE_BYTES {
|
||||
return Err(too_large());
|
||||
}
|
||||
if !readable(&bytes) {
|
||||
return Err(unsupported());
|
||||
}
|
||||
Ok((bytes, None))
|
||||
s.doc.settings.watermark = Some(Watermark { ..wm });
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// Page borders of the caret's section (or every section with `applyTo: "document"`).
|
||||
@@ -408,125 +288,3 @@ fn page_borders(s: &mut Session, v: &Value) -> CmdResult {
|
||||
}
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
use wordcraft_doc::{Document, Watermark, WatermarkPicture};
|
||||
|
||||
use super::MAX_PICTURE_BYTES;
|
||||
use crate::Session;
|
||||
|
||||
fn png(w: u32, h: u32) -> Vec<u8> {
|
||||
let mut b = Vec::new();
|
||||
image::RgbaImage::from_pixel(w, h, image::Rgba([30, 60, 200, 255]))
|
||||
.write_to(&mut std::io::Cursor::new(&mut b), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
b
|
||||
}
|
||||
|
||||
fn wm(s: &Session) -> Watermark {
|
||||
s.doc.settings.watermark.clone().unwrap()
|
||||
}
|
||||
|
||||
/// #2954: a picture watermark from a file's bytes, its scale and Washout, then back to text;
|
||||
/// each step undoes.
|
||||
#[test]
|
||||
fn picture_watermark_and_back_to_text() {
|
||||
let mut s = Session::new(Document::new());
|
||||
let data = super::super::insert::base64_encode(&png(40, 20));
|
||||
s.run("design.watermark", &json!({"picture": {"data": data}})).unwrap();
|
||||
let pic = wm(&s).picture.unwrap();
|
||||
assert_eq!((pic.scale, pic.washout), (None, true), "Word's defaults: Auto, washed out");
|
||||
assert!(s.doc.media.contains_key(&pic.media));
|
||||
s.run("design.watermark", &json!({"scale": 150, "washout": false})).unwrap();
|
||||
assert_eq!(wm(&s).picture, Some(WatermarkPicture { media: pic.media.clone(), scale: Some(150.0), washout: false }));
|
||||
s.run("design.watermark", &json!({"scale": "AUTO"})).unwrap();
|
||||
assert_eq!(wm(&s).picture.unwrap().scale, None);
|
||||
// Clamped to Word's range.
|
||||
s.run("design.watermark", &json!({"scale": 9000})).unwrap();
|
||||
assert_eq!(wm(&s).picture.unwrap().scale, Some(500.0));
|
||||
s.run("design.watermark", &json!({"scale": 0})).unwrap();
|
||||
assert_eq!(wm(&s).picture.unwrap().scale, Some(1.0));
|
||||
s.run("design.watermark", &json!({"scale": 33.333})).unwrap();
|
||||
assert_eq!(wm(&s).picture.unwrap().scale, Some(33.3));
|
||||
// A picture already in the document; text settings are refused while it is a picture.
|
||||
s.run("design.watermark", &json!({"picture": {"media": pic.media}, "scale": 50})).unwrap();
|
||||
assert!(s.run("design.watermark", &json!({"transparency": 30})).is_err());
|
||||
assert!(s.run("design.watermark", &json!({"diagonal": false})).is_err());
|
||||
// Text (or `picture: false`) makes it a text watermark again, with its text settings.
|
||||
s.run("design.watermark", &json!({"text": "SAMPLE", "transparency": 30})).unwrap();
|
||||
let text = wm(&s);
|
||||
assert_eq!((text.picture.is_none(), text.text.as_str(), text.transparency()), (true, "SAMPLE", 30));
|
||||
assert!(s.run("design.watermark", &json!({"washout": true})).is_err());
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(wm(&s).picture.unwrap().scale, Some(50.0));
|
||||
s.run("design.watermark", &json!({"picture": false})).unwrap();
|
||||
assert_eq!(wm(&s).picture, None);
|
||||
s.run("design.watermark", &json!({"remove": true})).unwrap();
|
||||
assert_eq!(s.doc.settings.watermark, None);
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
assert!(s.doc.settings.watermark.is_some());
|
||||
}
|
||||
|
||||
/// #2954: the text's transparency in percent, kept in step with Semitransparent.
|
||||
#[test]
|
||||
fn text_watermark_transparency() {
|
||||
let mut s = Session::new(Document::new());
|
||||
s.run("design.watermark", &json!({"text": "DRAFT"})).unwrap();
|
||||
assert_eq!((wm(&s).transparency(), wm(&s).semitransparent), (50, true));
|
||||
s.run("design.watermark", &json!({"transparency": 80})).unwrap();
|
||||
assert_eq!((wm(&s).transparency(), wm(&s).semitransparent), (80, true));
|
||||
s.run("design.watermark", &json!({"semitransparent": false})).unwrap();
|
||||
assert_eq!((wm(&s).transparency(), wm(&s).semitransparent), (0, false));
|
||||
s.run("design.watermark", &json!({"transparency": 250.7})).unwrap();
|
||||
assert_eq!(wm(&s).transparency(), 100);
|
||||
s.run("design.watermark", &json!({"transparency": -3})).unwrap();
|
||||
assert_eq!(wm(&s).transparency(), 0);
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(wm(&s).transparency(), 100);
|
||||
}
|
||||
|
||||
/// #2954: pictures that can't be drawn, and missing ones, are refused and change nothing.
|
||||
#[test]
|
||||
fn bad_watermark_pictures_are_refused() {
|
||||
let mut s = Session::new(Document::new());
|
||||
let text = s.doc.add_media(b"not a picture".to_vec(), "png");
|
||||
for bad in [
|
||||
json!({"picture": {}}),
|
||||
json!({"picture": {"data": "%%%"}}),
|
||||
json!({"picture": {"data": super::super::insert::base64_encode(b"plain text")}}),
|
||||
json!({"picture": {"media": "nope.png"}}),
|
||||
json!({"picture": {"media": text}}),
|
||||
json!({"picture": {"path": "/nonexistent/watermark.png"}}),
|
||||
json!({"picture": 7}),
|
||||
json!({"picture": {"data": super::super::insert::base64_encode(&png(4, 4))}, "scale": "big"}),
|
||||
] {
|
||||
assert!(s.run("design.watermark", &bad).is_err(), "{bad}");
|
||||
assert_eq!(s.doc.settings.watermark, None, "{bad}");
|
||||
assert_eq!(s.doc.media.len(), 1, "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #2954 review: a picture too large is refused before anything is decoded or read: the
|
||||
/// base64 text is measured first, and a file by its size.
|
||||
#[test]
|
||||
fn oversized_watermark_pictures_are_refused_without_a_large_allocation() {
|
||||
let mut s = Session::new(Document::new());
|
||||
// Longer than the largest picture's base64 text: refused by length, never decoded (the
|
||||
// decoded bytes would be as large as the cap again).
|
||||
let big = "A".repeat(MAX_PICTURE_BYTES / 3 * 4 + 8);
|
||||
let e = s.run("design.watermark", &json!({"picture": {"data": big}})).unwrap_err();
|
||||
let e = format!("{e:?}");
|
||||
assert!(e.contains("larger than"), "{e}");
|
||||
assert_eq!((s.doc.settings.watermark.clone(), s.doc.media.len()), (None, 0));
|
||||
// A file past the cap is refused by its size (a sparse file, so nothing is written).
|
||||
let path = std::env::temp_dir().join(format!("wordcraft-wm-big-{}.png", std::process::id()));
|
||||
std::fs::File::create(&path).unwrap().set_len(MAX_PICTURE_BYTES as u64 + 1).unwrap();
|
||||
let e = s.run("design.watermark", &json!({"picture": {"path": path.to_string_lossy()}})).unwrap_err();
|
||||
let e = format!("{e:?}");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
assert!(e.contains("larger than"), "{e}");
|
||||
assert_eq!((s.doc.settings.watermark.clone(), s.doc.media.len()), (None, 0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}]*",
|
||||
|
||||
@@ -212,6 +212,32 @@ pub(crate) fn record(s: &mut Session, id: &str, before: &Document) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the tracked paragraph-property change for a command that also changes the paragraph's
|
||||
/// text. The ordinary after-command recorder deliberately compares only paragraphs with identical
|
||||
/// text, so mixed text/paragraph-format transformations use this before assigning their props.
|
||||
/// Made once per command, before it creates revisions: it keeps the revisions as they were then
|
||||
/// (all `Recorder::own` needs), instead of copying the list for every paragraph.
|
||||
pub(crate) struct ParaPropTracker {
|
||||
before: Vec<Revision>,
|
||||
}
|
||||
|
||||
impl ParaPropTracker {
|
||||
pub(crate) fn new(s: &Session) -> Self {
|
||||
let tracking = s.doc.settings.track_changes && s.prefs.markup.track_formatting;
|
||||
ParaPropTracker { before: if tracking { s.doc.revisions.clone() } else { Vec::new() } }
|
||||
}
|
||||
|
||||
pub(crate) fn changed(&self, s: &mut Session, before: &ParaProps, after: &ParaProps) -> Option<Box<PropChange<ParaProps>>> {
|
||||
if !s.doc.settings.track_changes || !s.prefs.markup.track_formatting {
|
||||
return after.fmt_change.clone();
|
||||
}
|
||||
let mut r = Recorder { author: s.author.clone(), date: super::now_iso(), before: &self.before, revisions: &mut s.doc.revisions, rid: None };
|
||||
r.change(before, after, ParaProps::formatting, |p| &p.fmt_change)
|
||||
.map(|Replacement(change)| change)
|
||||
.unwrap_or_else(|| after.fmt_change.clone())
|
||||
}
|
||||
}
|
||||
|
||||
/// Record the paragraph formatting a command changed on the paragraph at `path` whose text it also
|
||||
/// changed, which [`record`] doesn't compare: `before` is the paragraph's properties before the
|
||||
/// command. As in [`record`], the author's own earlier change keeps the formatting before it and
|
||||
|
||||
@@ -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)
|
||||
@@ -132,14 +132,26 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
} else {
|
||||
return Err(CmdError::Params("`path` or `data` required".into()));
|
||||
};
|
||||
wordcraft_geom::picture::probe(&bytes).map_err(|e| match e {
|
||||
wordcraft_geom::picture::Refusal::Unsupported(why) => CmdError::Failed(why.into()),
|
||||
_ => CmdError::Failed("not a supported image".into()),
|
||||
})?;
|
||||
let (px_w, px_h) = wordcraft_geom::picture::probe(&bytes)
|
||||
.map(|p| (p.width, p.height))
|
||||
.map_err(|e| match e {
|
||||
wordcraft_geom::picture::Refusal::Unsupported(why) => CmdError::Failed(why.into()),
|
||||
_ => CmdError::Failed("not a supported image".into()),
|
||||
})?;
|
||||
let key = s.doc.add_media(bytes.clone(), crate::cmd::insert::image_ext(&bytes));
|
||||
with_obj(s, |o| {
|
||||
if let InlineObject::Image { media, .. } = o {
|
||||
if let InlineObject::Image { media, w, h, crop, .. } = o {
|
||||
*media = key.clone();
|
||||
// The replacement keeps the picture's width and takes its own aspect ratio, so a
|
||||
// picture of another shape isn't stretched into the old frame. The old crop
|
||||
// described the old picture, so it is dropped.
|
||||
if px_w > 0 && px_h > 0 && w.is_finite() && *w > 0.0 {
|
||||
let nh = *w * px_h as f32 / px_w as f32;
|
||||
if nh.is_finite() && nh > 0.0 {
|
||||
*h = nh;
|
||||
}
|
||||
}
|
||||
*crop = [0.0; 4];
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -77,6 +77,43 @@ fn a_shape_dropped_just_after_a_page_break_in_a_paragraph_stays_there() {
|
||||
assert!((o.rect.x - 100.0).abs() < 0.5 && (o.rect.y - 80.0).abs() < 0.5, "where it was dropped: {:?}", o.rect);
|
||||
}
|
||||
|
||||
/// Leaf #107: wrapping can leave the anchor alone to the left and put the text on the right
|
||||
/// of the same physical row. A drop after the break must still stay on the destination page.
|
||||
#[test]
|
||||
fn a_picture_with_wrap_spacing_dropped_after_a_leading_break_stays_there() {
|
||||
for gap in [0.0, 6.0, 12.0] {
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("Source\nDestination"));
|
||||
s.run("caret.set", &json!({"pos": Pos::body(1, 0)})).unwrap();
|
||||
s.run("layout.break", &json!({"kind": "page"})).unwrap();
|
||||
s.run("caret.set", &json!({"pos": Pos::body(0, 0)})).unwrap();
|
||||
let data = super::super::insert::base64_encode(&png(20, 10));
|
||||
s.run("insert.picture", &json!({"data": data, "width": 120})).unwrap();
|
||||
s.run("arrange.wrap", &json!({"wrap": "square"})).unwrap();
|
||||
super::select::with_float(&mut s, |f| {
|
||||
f.dist = gap;
|
||||
f.dist_top = gap;
|
||||
f.dist_bottom = gap;
|
||||
})
|
||||
.unwrap();
|
||||
let before = serde_json::to_value(&s.doc.body).unwrap();
|
||||
let depth = s.undo_depth();
|
||||
s.run("arrange.bounds", &json!({"page": 1, "x": 100, "y": 110})).unwrap();
|
||||
let hit = object_area(&mut s);
|
||||
assert_eq!(hit.page, 1, "gap={gap}: {hit:?}");
|
||||
assert!((hit.rect.x - 100.0).abs() < 0.5 && (hit.rect.y - 110.0).abs() < 0.5, "gap={gap}: {:?}", hit.rect);
|
||||
assert_eq!(s.undo_depth(), depth + 1);
|
||||
let after = serde_json::to_value(&s.doc.body).unwrap();
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(serde_json::to_value(&s.doc.body).unwrap(), before);
|
||||
s.run("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(serde_json::to_value(&s.doc.body).unwrap(), after);
|
||||
let mut copy = Session::new(wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap());
|
||||
let saved = object_area(&mut copy);
|
||||
assert_eq!(saved.page, 1, "DOCX gap={gap}: {saved:?}");
|
||||
assert!((saved.rect.x - hit.rect.x).abs() < 0.5 && (saved.rect.y - hit.rect.y).abs() < 0.5, "saved at the drop position");
|
||||
}
|
||||
}
|
||||
|
||||
/// What a click and a handle or body drag do in the editor (#142, #82): hit-test the object,
|
||||
/// select it, then resize and move it with `arrange.bounds`.
|
||||
fn click_resize_and_drag(insert: &str, params: Value) {
|
||||
@@ -1034,6 +1071,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() {
|
||||
|
||||
@@ -16,6 +16,7 @@ mod lists;
|
||||
mod outline;
|
||||
mod styles;
|
||||
mod tabs_borders;
|
||||
mod tools;
|
||||
|
||||
use format::{align, asian_typography, cur, direction, line_spacing, ls, set_align, set_props, tog};
|
||||
pub use format::{cur as selection_para, fmt, indent, outdent};
|
||||
@@ -26,6 +27,10 @@ use outline::{move_paras, outline_step, sort};
|
||||
use styles::{apply_style, apply_styles, create_style, delete_style, modify_style, set_visibility, style_id, update_to_match};
|
||||
pub use tabs_borders::border_sides;
|
||||
use tabs_borders::{borders, tabs};
|
||||
use tools::{
|
||||
blank_between, convert_breaks, indent_to_spaces, remove_blank, remove_extra_spaces, remove_line_breaks, separate_paragraphs, spaces_to_indent,
|
||||
trim_leading,
|
||||
};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
@@ -183,9 +188,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`"#,
|
||||
),
|
||||
@@ -262,5 +275,43 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
sel_result(s)
|
||||
}),
|
||||
// Advanced paragraph tools (#5043, as in WPS Office): conversions and cleanup over the
|
||||
// selection, or the whole document with `scope: "all"`. One undo step per command.
|
||||
CommandSpec::new("para.convertBreaks", "Convert Line Breaks to Paragraphs", "Home › Paragraph › Paragraph Tools", convert_breaks)
|
||||
.params(r#"{"scope"?: "selection|all"}"#),
|
||||
CommandSpec::new("para.blankBetween", "Insert Blank Paragraphs Between", "Home › Paragraph › Paragraph Tools", blank_between)
|
||||
.params(r#"{"scope"?: "selection|all", "after"?: bool (also after the last paragraph)}"#),
|
||||
CommandSpec::new("para.separateParagraphs", "Separate Paragraphs", "Home › Paragraph › Paragraph Tools", separate_paragraphs)
|
||||
.params(r#"{"scope"?: "selection|all"}"#),
|
||||
CommandSpec::new("para.removeBlank", "Delete Blank Paragraphs", "Home › Paragraph › Paragraph Tools", remove_blank)
|
||||
.params(r#"{"scope"?: "selection|all"}"#),
|
||||
CommandSpec::new("para.trimLeadingSpaces", "Remove Leading Spaces", "Home › Paragraph › Paragraph Tools", trim_leading)
|
||||
.params(r#"{"scope"?: "selection|all"}"#),
|
||||
CommandSpec::new("para.removeExtraSpaces", "Remove Extra Spaces", "Home › Paragraph › Paragraph Tools", remove_extra_spaces)
|
||||
.params(r#"{"scope"?: "selection|all"}"#),
|
||||
CommandSpec::new("para.spacesToIndent", "Convert Spaces to Indentation", "Home › Paragraph › Paragraph Tools", spaces_to_indent)
|
||||
.params(r#"{"scope"?: "selection|all"}"#),
|
||||
CommandSpec::new("para.indentToSpaces", "Convert Indents to Spaces", "Home › Paragraph › Paragraph Tools", indent_to_spaces)
|
||||
.params(r#"{"scope"?: "selection|all"}"#),
|
||||
CommandSpec::new("para.removeLineBreaks", "Remove Line Breaks", "Home › Paragraph › Paragraph Tools", remove_line_breaks)
|
||||
.params(r#"{"scope"?: "selection|all"}"#),
|
||||
]
|
||||
}
|
||||
|
||||
/// 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())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
//! Advanced paragraph tools (#5043): converting and cleaning up paragraphs, like WPS Office's
|
||||
//! paragraph tools. Each command works on the paragraphs the selection covers, or on the whole
|
||||
//! document with `scope: "all"`, and runs as one undo step. Deletions respect Track Changes.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use wordcraft_doc::para::LINE_BREAK;
|
||||
use wordcraft_doc::{Path, Pos, RevisionKind, StoryRef};
|
||||
|
||||
use super::{CmdError, CmdResult, Session, p, sel_result};
|
||||
use crate::cmd::{has_next_para, new_revision, split_para, track_delete};
|
||||
|
||||
/// The maximum number of paragraphs one call may touch: the work, and the blank paragraphs a
|
||||
/// command can add, stay bounded whatever the document says.
|
||||
const MAX_TOOL_PARAS: usize = 10_000;
|
||||
|
||||
/// Leading spaces one paragraph may gain from an indent, whatever the file's indent says.
|
||||
const MAX_INDENT_SPACES: usize = 1_000;
|
||||
|
||||
/// The scope of a tools command: `{"scope": "selection|all"}`, `selection` by default.
|
||||
/// `all` selects the whole document first, so the command, its undo step and the result all
|
||||
/// follow the full selection like any other command.
|
||||
fn scope_all(s: &mut Session, v: &Value) -> Result<(), CmdError> {
|
||||
match p::str(v, "scope").unwrap_or("selection") {
|
||||
"selection" => Ok(()),
|
||||
"all" => {
|
||||
s.run("select.all", &json!({"whole": true}))?;
|
||||
Ok(())
|
||||
}
|
||||
x => Err(CmdError::Params(format!("unknown scope `{x}` (selection|all)"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Refuse a paragraph rewrite that would touch content locked against editing. Commands operate
|
||||
/// on whole paragraphs, so checking each full paragraph also covers controls outside the exact
|
||||
/// selection offsets in a selected paragraph.
|
||||
fn check_target_edits(s: &mut Session, story: StoryRef, paths: &[Path]) -> Result<(), CmdError> {
|
||||
let ranges: Vec<(Pos, Pos)> = paths
|
||||
.iter()
|
||||
.filter_map(|path| {
|
||||
let len = s.doc.para(story, path)?.len();
|
||||
Some((Pos { story, path: path.clone(), off: 0 }, Pos { story, path: path.clone(), off: len }))
|
||||
})
|
||||
.collect();
|
||||
for (a, b) in ranges {
|
||||
crate::cmd::controls::check_edit(s, &a, &b)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The paragraphs a command runs over, in document order, and the story they are in.
|
||||
fn targets(s: &Session) -> Result<(StoryRef, Vec<Path>), CmdError> {
|
||||
let (a, b) = s.sel.ordered();
|
||||
let paths = s.doc.para_paths_in(&a, &b);
|
||||
if paths.is_empty() {
|
||||
return Err(CmdError::Failed("no paragraphs selected".into()));
|
||||
}
|
||||
if paths.len() > MAX_TOOL_PARAS {
|
||||
return Err(CmdError::Params(format!("select at most {MAX_TOOL_PARAS} paragraphs to process them in one command")));
|
||||
}
|
||||
Ok((a.story, paths))
|
||||
}
|
||||
|
||||
/// Deletes `a..b` inside one paragraph, recording a tracked deletion when Track Changes is on.
|
||||
/// Offsets past the paragraph's end are clamped, so the document is never half-edited.
|
||||
fn delete_in_para(s: &mut Session, story: StoryRef, path: &Path, a: usize, b: usize) -> Result<(), CmdError> {
|
||||
let len = s.doc.para(story, path).ok_or_else(|| CmdError::Failed("no paragraph here".into()))?.len();
|
||||
let (a, b) = (a.min(b).min(len), b.min(len));
|
||||
if a >= b {
|
||||
return Ok(());
|
||||
}
|
||||
let at = |off: usize| Pos { story, path: path.clone(), off };
|
||||
crate::cmd::controls::check_delete(s, &at(a), &at(b))?;
|
||||
if s.doc.settings.track_changes {
|
||||
track_delete(s, &at(a), &at(b))?;
|
||||
} else {
|
||||
s.doc.delete_range(&at(a), &at(b))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The deletions to make in one paragraph, as byte ranges, merged where they touch and applied
|
||||
/// last first so the earlier offsets stay valid.
|
||||
fn apply_deletions(s: &mut Session, story: StoryRef, path: &Path, mut ranges: Vec<(usize, usize)>) -> Result<(), CmdError> {
|
||||
ranges.sort();
|
||||
let mut merged: Vec<(usize, usize)> = Vec::new();
|
||||
for (a, b) in ranges {
|
||||
match merged.last_mut() {
|
||||
Some(last) if a <= last.1 => last.1 = last.1.max(b),
|
||||
_ => merged.push((a, b)),
|
||||
}
|
||||
}
|
||||
for (a, b) in merged.into_iter().rev() {
|
||||
delete_in_para(s, story, path, a, b)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The characters of the paragraph that are not tracked-deleted, as (byte offset, char). Under
|
||||
/// Track Changes deleted text stays in `para.text`, so every scan that decides what to delete or
|
||||
/// count goes through this.
|
||||
fn live_chars(para: &wordcraft_doc::Paragraph) -> impl Iterator<Item = (usize, char)> + '_ {
|
||||
para.run_ranges().filter(|(_, c)| c.del.is_none()).flat_map(|(r, _)| para.text[r.clone()].char_indices().map(move |(i, c)| (r.start + i, c)))
|
||||
}
|
||||
|
||||
/// The byte ranges of every manual line break in the paragraph that is not already deleted.
|
||||
fn break_ranges(para: &wordcraft_doc::Paragraph) -> Vec<(usize, usize)> {
|
||||
live_chars(para).filter(|(_, c)| *c == LINE_BREAK).map(|(i, _)| (i, i + 1)).collect()
|
||||
}
|
||||
|
||||
/// `para.convertBreaks`: every manual line break (Shift+Enter) in the selection becomes a
|
||||
/// paragraph break. Paragraphs are handled last first: a split only shifts the paths after it.
|
||||
pub(super) fn convert_breaks(s: &mut Session, v: &Value) -> CmdResult {
|
||||
scope_all(s, v)?;
|
||||
let (story, paths) = targets(s)?;
|
||||
check_target_edits(s, story, &paths)?;
|
||||
let breaks: usize = paths.iter().filter_map(|path| s.doc.para(story, path)).map(|p| break_ranges(p).len()).sum();
|
||||
if breaks > MAX_TOOL_PARAS {
|
||||
return Err(CmdError::Params(format!("convert at most {MAX_TOOL_PARAS} line breaks at once")));
|
||||
}
|
||||
for path in paths.iter().rev() {
|
||||
let Some(para) = s.doc.para(story, path) else { continue };
|
||||
for (i, _) in break_ranges(para).into_iter().rev() {
|
||||
let new = split_para(s, &Pos { story, path: path.clone(), off: i })?;
|
||||
// The break stays at the head of the new paragraph; the paragraph break replaces it.
|
||||
delete_in_para(s, story, &new.path, 0, 1)?;
|
||||
}
|
||||
}
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// `para.blankBetween`: insert one empty paragraph between the selected paragraphs; `after`
|
||||
/// adds one blank paragraph after the last selected paragraph too.
|
||||
pub(super) fn blank_between(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let after = p::bool(v, "after").unwrap_or(false);
|
||||
scope_all(s, v)?;
|
||||
let (story, paths) = targets(s)?;
|
||||
check_target_edits(s, story, &paths)?;
|
||||
let count = paths.len().saturating_sub(1) + usize::from(after);
|
||||
if count > MAX_TOOL_PARAS {
|
||||
return Err(CmdError::Params(format!("insert at most {MAX_TOOL_PARAS} blank paragraphs at once")));
|
||||
}
|
||||
// Last first, each split at a paragraph's end: a split only shifts the paths after it.
|
||||
if after {
|
||||
let last = paths.last().ok_or_else(|| CmdError::Failed("no paragraph selected".into()))?;
|
||||
let len = s.doc.para(story, last).map(|p| p.len()).unwrap_or(0);
|
||||
split_para(s, &Pos { story, path: last.clone(), off: len })?;
|
||||
}
|
||||
for path in paths[..paths.len().saturating_sub(1)].iter().rev() {
|
||||
let len = s.doc.para(story, path).map(|p| p.len()).unwrap_or(0);
|
||||
split_para(s, &Pos { story, path: path.clone(), off: len })?;
|
||||
}
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// `para.separateParagraphs`: every selected paragraph becomes a paragraph of its own with a
|
||||
/// blank line between them.
|
||||
pub(super) fn separate_paragraphs(s: &mut Session, v: &Value) -> CmdResult {
|
||||
blank_between(s, &json!({"scope": p::str(v, "scope").unwrap_or("selection"), "after": true}))
|
||||
}
|
||||
|
||||
/// `para.removeBlank`: delete every empty paragraph in the selection.
|
||||
pub(super) fn remove_blank(s: &mut Session, v: &Value) -> CmdResult {
|
||||
scope_all(s, v)?;
|
||||
let (story, paths) = targets(s)?;
|
||||
check_target_edits(s, story, &paths)?;
|
||||
// Last first: removing a block leaves the paths before it as they are.
|
||||
for path in paths.iter().rev() {
|
||||
let Some(para) = s.doc.para(story, path) else { continue };
|
||||
// A paragraph must be empty, carry no section break and have a paragraph after it in
|
||||
// its container: a story's, part's or cell's last mark can't be removed, and a paragraph
|
||||
// before a table can't be joined across it.
|
||||
if !para.is_empty() || para.section.is_some() || !has_next_para(s, story, path) {
|
||||
continue;
|
||||
}
|
||||
let next = path.with_last(path.last() + 1);
|
||||
let (a, b) = (Pos { story, path: path.clone(), off: 0 }, Pos { story, path: next, off: 0 });
|
||||
crate::cmd::controls::check_delete(s, &a, &b)?;
|
||||
if s.doc.settings.track_changes {
|
||||
track_delete(s, &a, &b)?;
|
||||
} else {
|
||||
s.doc.remove_block(story, path)?;
|
||||
}
|
||||
}
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// The number of leading spaces in the paragraph's text, deleted ones included (the range
|
||||
/// `trimLeadingSpaces` deletes).
|
||||
fn leading_spaces(para: &wordcraft_doc::Paragraph) -> usize {
|
||||
para.text.chars().take_while(|c| *c == ' ').count()
|
||||
}
|
||||
|
||||
/// The byte offsets of the paragraph's leading spaces that are not tracked-deleted.
|
||||
fn live_leading_spaces(para: &wordcraft_doc::Paragraph) -> Vec<usize> {
|
||||
live_chars(para).take_while(|(_, c)| *c == ' ').map(|(i, _)| i).collect()
|
||||
}
|
||||
|
||||
/// `para.trimLeadingSpaces`: delete the spaces at the start of each selected paragraph.
|
||||
pub(super) fn trim_leading(s: &mut Session, v: &Value) -> CmdResult {
|
||||
scope_all(s, v)?;
|
||||
let (story, paths) = targets(s)?;
|
||||
check_target_edits(s, story, &paths)?;
|
||||
for path in paths {
|
||||
let Some(para) = s.doc.para(story, &path) else { continue };
|
||||
let n = leading_spaces(para);
|
||||
apply_deletions(s, story, &path, vec![(0, n)])?;
|
||||
}
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// `para.removeExtraSpaces`: every run of spaces outside the paragraph's start becomes one
|
||||
/// space. Tabs and page breaks are document structure, not redundant spaces, and stay in place.
|
||||
/// Leading spaces stay (that is `para.trimLeadingSpaces`).
|
||||
pub(super) fn remove_extra_spaces(s: &mut Session, v: &Value) -> CmdResult {
|
||||
scope_all(s, v)?;
|
||||
let (story, paths) = targets(s)?;
|
||||
check_target_edits(s, story, &paths)?;
|
||||
for path in paths {
|
||||
let Some(para) = s.doc.para(story, &path) else { continue };
|
||||
// Runs of live spaces (deleted characters are skipped, as if absent). A run at the
|
||||
// paragraph's start stays; a run inside the text collapses to its last space, and one
|
||||
// that reaches the paragraph's end to its first.
|
||||
let mut runs = Vec::new();
|
||||
let mut run: Vec<usize> = Vec::new();
|
||||
let mut leading = true;
|
||||
for (i, c) in live_chars(para) {
|
||||
if c == ' ' {
|
||||
run.push(i);
|
||||
continue;
|
||||
}
|
||||
if !leading && run.len() > 1 {
|
||||
runs.extend(run[..run.len() - 1].iter().map(|&a| (a, a + 1)));
|
||||
}
|
||||
run.clear();
|
||||
leading = false;
|
||||
}
|
||||
if !leading && run.len() > 1 {
|
||||
runs.extend(run[1..].iter().map(|&a| (a, a + 1)));
|
||||
}
|
||||
apply_deletions(s, story, &path, runs)?;
|
||||
}
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// `para.spacesToIndent`: each paragraph's leading spaces become a first-line indent of 72 pt
|
||||
/// per space (Word's point-based indent from typing spaces), and are deleted.
|
||||
pub(super) fn spaces_to_indent(s: &mut Session, v: &Value) -> CmdResult {
|
||||
scope_all(s, v)?;
|
||||
let (story, paths) = targets(s)?;
|
||||
check_target_edits(s, story, &paths)?;
|
||||
let tracker = crate::cmd::fmt_revisions::ParaPropTracker::new(s);
|
||||
for path in paths {
|
||||
let Some(para) = s.doc.para(story, &path) else { continue };
|
||||
let live = live_leading_spaces(para);
|
||||
let spaces = live.len();
|
||||
if spaces == 0 {
|
||||
continue;
|
||||
}
|
||||
let current = para.props.indent_first.unwrap_or(0.0).clamp(-1584.0, 1584.0);
|
||||
let represented = spaces.min(((1584.0 - current).max(0.0) / 72.0).floor() as usize);
|
||||
if represented == 0 {
|
||||
continue;
|
||||
}
|
||||
let before = para.props.clone();
|
||||
let mut after = before.clone();
|
||||
after.indent_first = Some(current + represented as f32 * 72.0);
|
||||
after.cancel_char_indents(false, false, true);
|
||||
after.fmt_change = tracker.changed(s, &before, &after);
|
||||
{
|
||||
let p = s.doc.para_mut(story, &path)?;
|
||||
p.props = after;
|
||||
p.touch();
|
||||
}
|
||||
// A space is one byte; deleted spaces in between stay as they are.
|
||||
apply_deletions(s, story, &path, live[..represented].iter().map(|&a| (a, a + 1)).collect())?;
|
||||
}
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// `para.indentToSpaces`: each paragraph's first-line and left indents become leading spaces
|
||||
/// (72 pt per space, as typing spaces indents), and the indents go to zero.
|
||||
pub(super) fn indent_to_spaces(s: &mut Session, v: &Value) -> CmdResult {
|
||||
scope_all(s, v)?;
|
||||
let (story, paths) = targets(s)?;
|
||||
check_target_edits(s, story, &paths)?;
|
||||
let tracker = crate::cmd::fmt_revisions::ParaPropTracker::new(s);
|
||||
for path in paths {
|
||||
let Some(para) = s.doc.para(story, &path) else { continue };
|
||||
let pts = para.props.indent_left.unwrap_or(0.0) + para.props.indent_first.unwrap_or(0.0);
|
||||
let n = (pts / 72.0).round().clamp(0.0, MAX_INDENT_SPACES as f32) as usize;
|
||||
if n == 0 && para.props.indent_left.unwrap_or(0.0) == 0.0 && para.props.indent_first.unwrap_or(0.0) == 0.0 {
|
||||
continue;
|
||||
}
|
||||
let mut props = s.doc.para(story, &path).map(|p| p.props_at(0)).cloned().unwrap_or_default();
|
||||
// The revision first: `new_revision` edits `s.doc`, so it runs before the paragraph borrow.
|
||||
// The spaces are marked as a tracked insertion through `props.ins`, as typing is.
|
||||
props.fmt_change = None;
|
||||
props.del = None;
|
||||
if s.doc.settings.track_changes && n > 0 {
|
||||
props.ins = Some(new_revision(s, RevisionKind::Insert));
|
||||
} else {
|
||||
props.ins = None;
|
||||
}
|
||||
let before = s.doc.para(story, &path).map(|p| p.props.clone()).unwrap_or_default();
|
||||
let mut after = before.clone();
|
||||
after.indent_first = Some(0.0);
|
||||
after.indent_left = Some(0.0);
|
||||
after.cancel_char_indents(true, false, true);
|
||||
after.fmt_change = tracker.changed(s, &before, &after);
|
||||
let p = s.doc.para_mut(story, &path)?;
|
||||
if n > 0 {
|
||||
p.insert_text(0, &" ".repeat(n), &props)?;
|
||||
}
|
||||
p.props = after;
|
||||
p.touch();
|
||||
}
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// `para.removeLineBreaks`: delete every manual line break in the selection, so the lines
|
||||
/// join into one paragraph.
|
||||
pub(super) fn remove_line_breaks(s: &mut Session, v: &Value) -> CmdResult {
|
||||
scope_all(s, v)?;
|
||||
let (story, paths) = targets(s)?;
|
||||
check_target_edits(s, story, &paths)?;
|
||||
for path in paths {
|
||||
let Some(para) = s.doc.para(story, &path) else { continue };
|
||||
apply_deletions(s, story, &path, break_ranges(para))?;
|
||||
}
|
||||
sel_result(s)
|
||||
}
|
||||
@@ -652,19 +652,33 @@ pub(crate) fn typing_pos(s: &Session, p: &Pos) -> Pos {
|
||||
if para.objects.is_empty() {
|
||||
return p.clone();
|
||||
}
|
||||
let editable: Vec<u32> = s.doc.perm_ranges().iter().filter(|r| editable_by(r, &s.author)).map(|r| r.id).collect();
|
||||
let is_start = |off: usize| matches!(para.object_at(off), Some(InlineObject::PermStart { id, .. }) if editable.contains(id));
|
||||
let is_end = |off: usize| matches!(para.object_at(off), Some(InlineObject::PermEnd { id }) if editable.contains(id));
|
||||
let Some(before) = para.text.get(..p.off) else { return p.clone() };
|
||||
let Some(after) = para.text.get(p.off..) else { return p.clone() };
|
||||
if !before.ends_with(OBJ) && !after.starts_with(OBJ) {
|
||||
return p.clone();
|
||||
}
|
||||
let editable: HashSet<u32> = s.doc.perm_ranges().iter().filter(|r| editable_by(r, &s.author)).map(|r| r.id).collect();
|
||||
// Count the preceding objects once. Each adjacent marker can then use the matching
|
||||
// object directly, forwards for starts and backwards for ends, even with short storage.
|
||||
let index = before.chars().filter(|c| *c == OBJ).count();
|
||||
let w = OBJ.len_utf8();
|
||||
let mut off = p.off;
|
||||
if is_start(off) {
|
||||
while is_start(off) {
|
||||
off += w;
|
||||
for (ch, object) in after.chars().zip(para.objects.iter().skip(index)) {
|
||||
if ch != OBJ || !matches!(object, InlineObject::PermStart { id, .. } if editable.contains(id)) {
|
||||
break;
|
||||
}
|
||||
off += w;
|
||||
}
|
||||
if off > p.off {
|
||||
return Pos { off, ..p.clone() };
|
||||
}
|
||||
while off >= w && is_end(off - w) {
|
||||
off -= w;
|
||||
if let Some(objects) = para.objects.get(..index) {
|
||||
for (ch, object) in before.chars().rev().zip(objects.iter().rev()) {
|
||||
if ch != OBJ || !matches!(object, InlineObject::PermEnd { id } if editable.contains(id)) {
|
||||
break;
|
||||
}
|
||||
off -= w;
|
||||
}
|
||||
}
|
||||
Pos { off, ..p.clone() }
|
||||
}
|
||||
|
||||
@@ -922,3 +922,159 @@ fn legacy_password_is_verified_and_unknown_forms_refused() {
|
||||
assert!(e.to_string().contains(super::UNSUPPORTED_PASSWORD));
|
||||
assert!(super::enforced(&s.doc));
|
||||
}
|
||||
|
||||
/// Consecutive permission boundaries must not recount the paragraph prefix or search all
|
||||
/// editable IDs once per marker. Both ends are used by typing and Enter.
|
||||
#[test]
|
||||
fn typing_boundary_many_permission_markers_is_not_quadratic() {
|
||||
use wordcraft_doc::para::Run;
|
||||
let cpu = || std::fs::read_to_string("/proc/thread-self/schedstat").ok()?.split_whitespace().next()?.parse::<u64>().ok();
|
||||
let measure = |n: usize, at_end: bool| {
|
||||
let mut p = wordcraft_doc::Paragraph::new();
|
||||
p.text.push_str("λ prefix ");
|
||||
let start = p.text.len();
|
||||
for i in 0..n {
|
||||
p.text.push(OBJ);
|
||||
p.objects.push(InlineObject::PermStart { id: i as u32, group: "everyone".into(), editor: String::new() });
|
||||
}
|
||||
p.text.push_str("body");
|
||||
let end = p.text.len();
|
||||
for i in 0..n {
|
||||
p.text.push(OBJ);
|
||||
p.objects.push(InlineObject::PermEnd { id: i as u32 });
|
||||
}
|
||||
let after = p.text.len();
|
||||
p.runs.push(Run { len: p.text.len(), props: CharProps::default() });
|
||||
let mut doc = Document::new();
|
||||
doc.body = vec![Block::Para(p).into()];
|
||||
doc.settings.protection = Some("readOnly".into());
|
||||
let s = Session::new(doc);
|
||||
let at = Pos::body(0, if at_end { after } else { start });
|
||||
let expected = Pos::body(0, if at_end { end } else { start + n * OBJ.len_utf8() });
|
||||
let before = cpu();
|
||||
for _ in 0..3 {
|
||||
assert_eq!(typing_pos(std::hint::black_box(&s), &at), expected);
|
||||
}
|
||||
match (before, cpu()) {
|
||||
(Some(a), Some(b)) if b > a => Some((b - a) as f64 * 1e-9),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
for at_end in [false, true] {
|
||||
let (small, large) = (measure(2_000, at_end), measure(16_000, at_end));
|
||||
let (Some(a), Some(b)) = (small, large) else {
|
||||
eprintln!("Typing boundary: CPU counter unavailable; semantic checks passed, timing assertion skipped");
|
||||
continue;
|
||||
};
|
||||
eprintln!("Typing boundary end={at_end}: small={a:.9}s large={b:.9}s ratio={:.2}", b / a);
|
||||
assert!(b < a * 24.0 + 0.005, "8x permission markers must not take quadratic boundary work: {a:.9}s -> {b:.9}s");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_boundary_keeps_author_precedence_and_malformed_pairing() {
|
||||
use wordcraft_doc::para::Run;
|
||||
// Frozen boundary semantics from before the one-pass lookup. This oracle deliberately
|
||||
// uses object_at independently of the new traversal, including malformed storage.
|
||||
let original = |s: &Session, p: &Pos| {
|
||||
if !matches!(s.doc.settings.protection.as_deref(), Some("readOnly" | "comments")) {
|
||||
return p.clone();
|
||||
}
|
||||
let Some(para) = s.doc.para_at(p) else { return p.clone() };
|
||||
if para.objects.is_empty() {
|
||||
return p.clone();
|
||||
}
|
||||
let editable: Vec<u32> = s.doc.perm_ranges().iter().filter(|r| editable_by(r, &s.author)).map(|r| r.id).collect();
|
||||
let start = |off| matches!(para.object_at(off), Some(InlineObject::PermStart { id, .. }) if editable.contains(id));
|
||||
let end = |off| matches!(para.object_at(off), Some(InlineObject::PermEnd { id }) if editable.contains(id));
|
||||
let mut off = p.off;
|
||||
if start(off) {
|
||||
while start(off) {
|
||||
off += OBJ.len_utf8();
|
||||
}
|
||||
} else {
|
||||
while off >= OBJ.len_utf8() && end(off - OBJ.len_utf8()) {
|
||||
off -= OBJ.len_utf8();
|
||||
}
|
||||
}
|
||||
Pos { off, ..p.clone() }
|
||||
};
|
||||
let objects = [
|
||||
InlineObject::PermStart { id: 1, group: "everyone".into(), editor: String::new() },
|
||||
InlineObject::PermStart { id: 2, group: String::new(), editor: " ALICE ".into() },
|
||||
InlineObject::PermStart { id: 3, group: String::new(), editor: "alice".into() },
|
||||
InlineObject::PermEnd { id: 1 },
|
||||
InlineObject::PermEnd { id: 3 },
|
||||
InlineObject::BookmarkStart { name: "Original".into() },
|
||||
InlineObject::PermEnd { id: 7 },
|
||||
InlineObject::PermStart { id: 7, group: String::new(), editor: "Bob".into() },
|
||||
];
|
||||
for text in
|
||||
[format!("λ{}x{}終", OBJ.to_string().repeat(4), OBJ.to_string().repeat(4)), OBJ.to_string().repeat(9), format!("λ{OBJ}{OBJ}x{OBJ}{OBJ}{OBJ}")]
|
||||
{
|
||||
for count in 0..=objects.len() {
|
||||
for mode in [None, Some("readOnly"), Some("comments"), Some("forms"), Some("trackedChanges")] {
|
||||
let mut p = wordcraft_doc::Paragraph::new();
|
||||
p.text = text.clone();
|
||||
p.objects = objects[..count].to_vec();
|
||||
p.runs.push(Run { len: p.text.len(), props: CharProps::default() });
|
||||
let mut doc = Document::new();
|
||||
doc.body = vec![Block::Para(p).into()];
|
||||
doc.settings.protection = mode.map(str::to_string);
|
||||
let mut s = Session::new(doc);
|
||||
s.author = " Alice ".into();
|
||||
for off in 0..=text.len() + 1 {
|
||||
let at = Pos::body(0, off);
|
||||
assert_eq!(typing_pos(&s, &at), original(&s, &at), "mode={mode:?} count={count} off={off} text={text:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut s = Session::new(Document::from_text("outside"));
|
||||
s.doc.settings.protection = Some("readOnly".into());
|
||||
assert_eq!(typing_pos(&s, &Pos::body(99, 0)), Pos::body(99, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_boundary_keeps_duplicate_ids_across_stories_and_start_precedence() {
|
||||
use wordcraft_doc::para::Run;
|
||||
let paragraph = |text: String, objects| wordcraft_doc::Paragraph {
|
||||
runs: vec![Run { len: text.len(), props: CharProps::default() }],
|
||||
text,
|
||||
objects,
|
||||
..Default::default()
|
||||
};
|
||||
let mut doc = Document::new();
|
||||
doc.body = vec![
|
||||
Block::Para(paragraph(
|
||||
format!("{OBJ}x{OBJ}"),
|
||||
vec![InlineObject::PermStart { id: 1, group: String::new(), editor: "Bob".into() }, InlineObject::PermEnd { id: 1 }],
|
||||
))
|
||||
.into(),
|
||||
];
|
||||
doc.add_part(
|
||||
wordcraft_doc::PartKind::Header,
|
||||
vec![
|
||||
Block::Para(paragraph(
|
||||
format!("{OBJ}y{OBJ}"),
|
||||
vec![InlineObject::PermStart { id: 1, group: "everyone".into(), editor: String::new() }, InlineObject::PermEnd { id: 1 }],
|
||||
))
|
||||
.into(),
|
||||
],
|
||||
);
|
||||
doc.settings.protection = Some("comments".into());
|
||||
let mut s = Session::new(doc);
|
||||
s.author = "Alice".into();
|
||||
// Preserve the existing document-wide ID membership, even for a duplicate in another story.
|
||||
assert_eq!(typing_pos(&s, &Pos::body(0, 0)), Pos::body(0, OBJ.len_utf8()));
|
||||
assert_eq!(typing_pos(&s, &Pos::body(0, 7)), Pos::body(0, 4));
|
||||
s.doc.body = vec![
|
||||
Block::Para(paragraph(
|
||||
format!("{OBJ}{OBJ}x"),
|
||||
vec![InlineObject::PermEnd { id: 1 }, InlineObject::PermStart { id: 1, group: "everyone".into(), editor: String::new() }],
|
||||
))
|
||||
.into(),
|
||||
];
|
||||
// A start to the right wins over an end to the left.
|
||||
assert_eq!(typing_pos(&s, &Pos::body(0, 3)), Pos::body(0, 6));
|
||||
}
|
||||
|
||||
@@ -103,8 +103,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
/// A field instruction split into its name, positional arguments and switches (ECMA-376
|
||||
/// §17.16.1): `TOC \o "1-3" \h` → name `TOC`, switches `o` = `1-3` and `h`. Quoted text stays
|
||||
/// whole (without the quotes; `\"` and `\\` inside it are a quote and a backslash). A switch
|
||||
/// takes the word after it as its argument unless that word is another switch.
|
||||
/// whole (without the quotes; a backslash inside it makes the next character literal, as in Word:
|
||||
/// `\"` is a quote, `\\` a backslash, `\*` an asterisk). A switch takes the word after it as its
|
||||
/// argument unless that word is another switch.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct FieldCode {
|
||||
pub name: String,
|
||||
@@ -126,7 +127,7 @@ pub fn field_words(instr: &str) -> Vec<(String, bool)> {
|
||||
while let Some(c) = chars.next() {
|
||||
match c {
|
||||
'"' => break,
|
||||
'\\' if matches!(chars.peek(), Some('"' | '\\')) => w.extend(chars.next()),
|
||||
'\\' if chars.peek().is_some() => w.extend(chars.next()),
|
||||
c => w.push(c),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,7 +102,9 @@ struct SeqCount {
|
||||
|
||||
/// The fresh results of the unlocked SEQ fields in the body and in the text boxes anchored there
|
||||
/// ([`update_seq`]). A text box's fields count where the box is anchored, before the fields after
|
||||
/// the anchor in its paragraph and after those before it (measured in Word for Windows).
|
||||
/// the anchor in its paragraph and after those before it (measured in Word for Windows). A locked
|
||||
/// SEQ keeps its result but still counts, `\r` included; a SEQ in a tracked deletion keeps its
|
||||
/// result and doesn't count, with markup shown or not (measured in Word for Windows, exporting).
|
||||
fn seq_results(doc: &wordcraft_doc::Document) -> Vec<FieldResult> {
|
||||
let mut count = SeqCount::default();
|
||||
for path in doc.para_paths(StoryRef::Body) {
|
||||
@@ -120,7 +122,11 @@ fn seq_results(doc: &wordcraft_doc::Document) -> Vec<FieldResult> {
|
||||
|
||||
/// Number the SEQ fields of paragraph `p` (at `path` in `story`) and of the text boxes anchored in it.
|
||||
fn count_seq(doc: &wordcraft_doc::Document, story: StoryRef, path: &Path, p: &Paragraph, count: &mut SeqCount) {
|
||||
for (k, o) in p.objects.iter().enumerate() {
|
||||
// The runs come in order and the objects by offset, so one cursor walks the deleted ranges.
|
||||
let mut deleted = p.deleted_ranges().into_iter().peekable();
|
||||
for (k, (off, o)) in p.objects_with_offsets().enumerate() {
|
||||
while deleted.next_if(|r| r.end <= off).is_some() {}
|
||||
let in_deletion = deleted.peek().is_some_and(|r| r.contains(&off));
|
||||
for id in o.text_boxes() {
|
||||
if count.boxes.insert(id) {
|
||||
let part = StoryRef::Part(id);
|
||||
@@ -131,9 +137,9 @@ fn count_seq(doc: &wordcraft_doc::Document, story: StoryRef, path: &Path, p: &Pa
|
||||
}
|
||||
}
|
||||
}
|
||||
let InlineObject::Field { instr, locked: false, .. } = o else { continue };
|
||||
let InlineObject::Field { instr, locked, .. } = o else { continue };
|
||||
let f = FieldCode::parse(instr);
|
||||
if f.name != "SEQ" {
|
||||
if f.name != "SEQ" || in_deletion {
|
||||
continue;
|
||||
}
|
||||
let Some(id) = f.args.first() else { continue };
|
||||
@@ -149,6 +155,9 @@ fn count_seq(doc: &wordcraft_doc::Document, story: StoryRef, path: &Path, p: &Pa
|
||||
} else if !f.has('c') {
|
||||
n.0 = n.0.saturating_add(1);
|
||||
}
|
||||
if *locked {
|
||||
continue;
|
||||
}
|
||||
let fmt = wordcraft_layout::fields::format_switch(instr).unwrap_or_default();
|
||||
let r = if f.has('h') { String::new() } else { wordcraft_layout::fields::field_number(instr, fmt, n.0) };
|
||||
count.out.push((story, path.clone(), k, r));
|
||||
|
||||
@@ -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
|
||||
@@ -713,6 +793,45 @@ fn output_shows_references_to_bookmarks_outside_the_body() {
|
||||
assert_eq!(results, expected);
|
||||
}
|
||||
|
||||
/// Printed or exported, a locked SEQ keeps its result but still counts, its `\r` too, and a SEQ
|
||||
/// in a tracked deletion keeps its result and doesn't count (measured in Word for Windows,
|
||||
/// exporting with markup shown and hidden: Del 1, 9, 2; Lock 1, 7, 3; R 7, 6).
|
||||
#[test]
|
||||
fn output_counts_locked_seq_fields_and_not_deleted_ones() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text(""));
|
||||
let fields_in = ["SEQ Del", "SEQ Del", "SEQ Del", "SEQ Lock", "SEQ Lock", "SEQ Lock", "SEQ R \\r 5", "SEQ R"];
|
||||
for instr in fields_in {
|
||||
s.run("insert.field", &json!({"instr": instr, "result": "9"})).unwrap();
|
||||
s.run("text.insert", &json!({"text": " "})).unwrap();
|
||||
}
|
||||
let top = wordcraft_doc::Path::top(0);
|
||||
let para = s.doc.para_mut(StoryRef::Body, &top).unwrap();
|
||||
for (k, o) in para.objects.iter_mut().enumerate() {
|
||||
if let InlineObject::Field { result, locked, .. } = o {
|
||||
*locked = matches!(k, 4 | 6);
|
||||
*result = if *locked { "7" } else { "9" }.into();
|
||||
}
|
||||
}
|
||||
para.touch();
|
||||
// The second Del field is a tracked deletion.
|
||||
let off = s.doc.para(StoryRef::Body, &top).unwrap().object_offsets()[1];
|
||||
let at = |off| Pos { story: StoryRef::Body, path: top.clone(), off };
|
||||
s.doc.format_range(&at(off), &at(off + '\u{FFFC}'.len_utf8()), &|c| c.del = Some(1)).unwrap();
|
||||
let out = output_fields(&s.doc).expect("the stale results change");
|
||||
let shown: Vec<String> = out
|
||||
.para(StoryRef::Body, &top)
|
||||
.unwrap()
|
||||
.objects
|
||||
.iter()
|
||||
.filter_map(|o| match o {
|
||||
InlineObject::Field { result, .. } => Some(result.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(shown, ["1", "9", "2", "1", "7", "3", "7", "6"]);
|
||||
assert!(fields(&s).iter().all(|f| f.1 == "9" || f.1 == "7"), "the document keeps its results");
|
||||
}
|
||||
|
||||
/// Printed or exported, a REF to a bookmark the document doesn't have shows Word's error
|
||||
/// (measured in Word for Windows: "Error! Reference source not found."); the editor and the
|
||||
/// document keep the stored result, and a REF showing "above" or "below" (`\p`) keeps it too.
|
||||
|
||||
@@ -54,11 +54,20 @@ fn resolve_para(
|
||||
// An inserted paragraph mark that is rejected, or a deleted one that is accepted, goes away:
|
||||
// the next paragraph joins this one (the split is undone). A paragraph left empty goes away
|
||||
// whole, so the next one keeps its own style and numbering, as in Word. The mark at the end
|
||||
// of a story or cell can't go, so it just stops being a revision.
|
||||
if ((del.is_some() && accept) || (ins.is_some() && !accept))
|
||||
&& (crate::cmd::remove_empty_para(s, story, path)? || crate::cmd::join_next_para(s, story, path)?)
|
||||
{
|
||||
return Ok(());
|
||||
// of a story or cell can't go, so it just stops being a revision. An empty paragraph whose
|
||||
// mark carries a section break goes whole too: its section joins the following one, as when
|
||||
// the break is deleted (#5667). Joining instead gave the next paragraph its properties.
|
||||
if (del.is_some() && accept) || (ins.is_some() && !accept) {
|
||||
let sectioned = story == StoryRef::Body
|
||||
&& path.0.len() == 1
|
||||
&& s.doc.para(story, path).is_some_and(|p| p.is_empty() && p.section.is_some())
|
||||
&& crate::cmd::has_next_para(s, story, path);
|
||||
if sectioned {
|
||||
s.doc.remove_section_break(path.last());
|
||||
}
|
||||
if crate::cmd::remove_empty_para(s, story, path)? || crate::cmd::join_next_para(s, story, path)? {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
let para = s.doc.para_mut(story, path)?;
|
||||
para.mark.ins = None;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -14,7 +14,7 @@ mod enter_tab;
|
||||
mod unicode;
|
||||
|
||||
use autoformat::insert;
|
||||
use delete::{backspace, delete, delete_word_back, delete_word_fwd};
|
||||
use delete::{backspace, delete, delete_sentence_back, delete_word_back, delete_word_fwd};
|
||||
pub use enter_tab::MAX_TYPED_PARAGRAPHS;
|
||||
use enter_tab::{back_tab, new_paragraph, tab};
|
||||
pub use unicode::{alt_code_char, oem_code_page, system_code_pages, system_oem_code_page, windows_code_pages};
|
||||
@@ -36,6 +36,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("text.backspace", "Backspace", "Editing", backspace).key("Backspace"),
|
||||
CommandSpec::new("text.delete", "Delete", "Editing", delete).key("Delete"),
|
||||
CommandSpec::new("text.deleteWordBack", "Delete Previous Word", "Editing", delete_word_back).key("Mod+Backspace / Alt+Backspace"),
|
||||
CommandSpec::new("text.deleteSentenceBack", "Delete Previous Sentence", "Editing", delete_sentence_back).key("Mod+Shift+Backspace"),
|
||||
CommandSpec::new("text.deleteWordForward", "Delete Next Word", "Editing", delete_word_fwd).key("Mod+Delete / Alt+Delete"),
|
||||
CommandSpec::new("text.nbsp", "Nonbreaking Space", "Insert › Symbols", |s, _| ins_char(s, NBSP)).key("Mod+Shift+Space"),
|
||||
CommandSpec::new("text.nbHyphen", "Nonbreaking Hyphen", "Insert › Symbols", |s, _| ins_char(s, NB_HYPHEN)).key("Mod+Shift+-"),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user