mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-30 21:37:41 -06:00
Compare commits
58
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac2fa7bbb7 | ||
|
|
087d330a24 | ||
|
|
5b5810fe1f | ||
|
|
3b7f7ed10b | ||
|
|
5d063c5bc2 | ||
|
|
1cbb027962 | ||
|
|
a57dd81dba | ||
|
|
a4b1682b41 | ||
|
|
2f78615291 | ||
|
|
9632646c5e | ||
|
|
18ae8b2150 | ||
|
|
9a172e2241 | ||
|
|
db2b3cab99 | ||
|
|
665ded2800 | ||
|
|
d40e0eeb47 | ||
|
|
2e790d549c | ||
|
|
74b2cbb5c9 | ||
|
|
d39be3bab8 | ||
|
|
f2debdecff | ||
|
|
48f5a905b1 | ||
|
|
4abb77aa70 | ||
|
|
e855daf279 | ||
|
|
f774f6f5c0 | ||
|
|
5b952b2463 | ||
|
|
81833cfc33 | ||
|
|
48545479b0 | ||
|
|
990b57a1e8 | ||
|
|
0debb7afda | ||
|
|
bc437049fa | ||
|
|
6e8a420d28 | ||
|
|
64ebfda971 | ||
|
|
c99552c958 | ||
|
|
250a9c5164 | ||
|
|
1be606de03 | ||
|
|
685b420104 | ||
|
|
4fcc409c8b | ||
|
|
bd85c72f39 | ||
|
|
8a964609c4 | ||
|
|
4a12ab9a19 | ||
|
|
41d5284fa8 | ||
|
|
931324e049 | ||
|
|
983f1e0267 | ||
|
|
39ae5f6778 | ||
|
|
5714408dd8 | ||
|
|
964a7bc5a8 | ||
|
|
3508cac3b0 | ||
|
|
8148a239e6 | ||
|
|
acccf913ab | ||
|
|
dc48046057 | ||
|
|
22e99fc2b7 | ||
|
|
f658c872a2 | ||
|
|
a066fe5f13 | ||
|
|
ccb8ce8494 | ||
|
|
165d5725dc | ||
|
|
01a1bf3921 | ||
|
|
5a9e414d1e | ||
|
|
aadb077e07 | ||
|
|
9724f15c3d |
@@ -11,7 +11,7 @@ on:
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
@@ -19,6 +19,9 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
release:
|
||||
# Only this job publishes the GitHub release, so only it gets write access.
|
||||
permissions:
|
||||
contents: write
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
|
||||
with:
|
||||
|
||||
@@ -26,8 +26,8 @@ permissions:
|
||||
|
||||
jobs:
|
||||
windows-arm64:
|
||||
# Immutable supporting workflow revision until the new install contract lands upstream.
|
||||
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main
|
||||
with:
|
||||
app-name: wordcraft
|
||||
app-dir: WordCraft
|
||||
|
||||
@@ -82,3 +82,8 @@ Progress docs follow [`craftrules/standards/progress-docs.md`](https://github.co
|
||||
GitHub profiles). Never hand-edit `contributors.json`.
|
||||
- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also
|
||||
re-verifies who wrote each `people.toml` entry; `--check` only verifies).
|
||||
|
||||
## CI workflows
|
||||
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
|
||||
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
|
||||
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
|
||||
|
||||
@@ -63,3 +63,8 @@ Typing, Enter, Backspace, Tab, lists, AutoCorrect and AutoFormat follow Word's o
|
||||
|
||||
## Roadmap
|
||||
Progress docs follow [`craftrules/standards/progress-docs.md`](https://github.com/storytold/craftrules/blob/main/standards/progress-docs.md): `ROADMAP.md` (stage, headline numbers, progress log), `docs/target-app-parity.md` (the assessment), `docs/gaps.md` (ranked work list), `docs/roadmap.md` (milestones, Current focus), `docs/architecture.md`, and the parity checklists `docs/file-format-parity.md`, `docs/layout-parity.md`, `docs/typing-parity.md`, `docs/ui-parity.md`, `docs/hardware-parity.md`, `docs/localization-parity.md`. When work lands, update the gap, the parity doc and the progress log, and bump each touched doc's status line and revision history.
|
||||
|
||||
## CI workflows
|
||||
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
|
||||
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
|
||||
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
|
||||
|
||||
Generated
+7
-5
@@ -809,9 +809,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "clipboard-win"
|
||||
version = "5.4.1"
|
||||
version = "5.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4"
|
||||
checksum = "052af7878466feccda12ba7a6a67d019d712f296809559b47c12c8cc196d3a9a"
|
||||
dependencies = [
|
||||
"error-code",
|
||||
]
|
||||
@@ -1084,13 +1084,14 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-update"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=04aa1703b32d299e54f78fcde4182f2b393a9116#04aa1703b32d299e54f78fcde4182f2b393a9116"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dunce",
|
||||
"egui",
|
||||
"flate2",
|
||||
"getrandom 0.3.4",
|
||||
"image",
|
||||
"log",
|
||||
"plist",
|
||||
"semver",
|
||||
@@ -1207,9 +1208,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ctutils"
|
||||
version = "0.4.3"
|
||||
version = "0.4.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574"
|
||||
checksum = "a2376ffd6f96d8a9db59875b4b6b0237d6900b5baf64279714e05493ce64828a"
|
||||
dependencies = [
|
||||
"cmov",
|
||||
]
|
||||
@@ -6812,6 +6813,7 @@ dependencies = [
|
||||
"quick-xml 0.41.0",
|
||||
"wordcraft-doc",
|
||||
"wordcraft-geom",
|
||||
"wordcraft-metafile",
|
||||
"zip 3.0.0",
|
||||
]
|
||||
|
||||
|
||||
+2
-1
@@ -59,7 +59,8 @@ brotli-decompressor = "5"
|
||||
vello_cpu = "0.2"
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp", "tiff"] }
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", default-features = false, features = ["write"] }
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] }
|
||||
# craft-update: app-specific pretend versions apply to startup and manual checks.
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "04aa1703b32d299e54f78fcde4182f2b393a9116", features = ["egui"] }
|
||||
quick-xml = "0.41"
|
||||
zip = { version = "3", default-features = false, features = ["deflate"] }
|
||||
# zlib streams (compressed metafile pictures in `.doc` drawings); already in the lock as a dependency of other crates.
|
||||
|
||||
+56
-2
File diff suppressed because one or more lines are too long
+26
@@ -0,0 +1,26 @@
|
||||
# Security policy
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
Please report security problems privately through GitHub:
|
||||
**[Report a vulnerability](https://github.com/storytold/wordcraft/security/advisories/new)**
|
||||
(Security tab › Advisories › Report a vulnerability).
|
||||
|
||||
Don't open a public issue or pull request for a vulnerability until a fix is released.
|
||||
|
||||
Useful to include:
|
||||
- the WordCraft version or commit, and the platform (macOS, Windows, Linux, BSD, web);
|
||||
- what an attacker can do, and what they need (a crafted document, MCP or control-channel access, a local account…);
|
||||
- steps or a sample file that reproduce it.
|
||||
|
||||
We'll acknowledge the report, keep you updated while we work on a fix, and credit you in the advisory unless you'd rather not be named.
|
||||
|
||||
## Supported versions
|
||||
|
||||
Fixes go into `main` and ship in the next release. Older releases don't get backported fixes.
|
||||
|
||||
## Scope
|
||||
|
||||
In scope: anything that lets a document, file, command, MCP or control-channel request, or network response read or write files it shouldn't, run code, leak data, or crash WordCraft and lose the user's work.
|
||||
|
||||
Crashes from hostile input count, because WordCraft treats every panic as a bug (see [Never crash](AGENTS.md)).
|
||||
@@ -49,9 +49,9 @@ impl PrefsSync {
|
||||
pub fn changed(&mut self, app: &WordApp) -> Option<String> {
|
||||
let mut prefs = app.prefs();
|
||||
if let Some((host, own)) = &self.host_author
|
||||
&& prefs.author == *host
|
||||
&& prefs.author.as_deref() == Some(host.as_str())
|
||||
{
|
||||
prefs.author = own.clone();
|
||||
prefs.author = Some(own.clone());
|
||||
}
|
||||
let json = match serde_json::to_string(&prefs) {
|
||||
Ok(j) => j,
|
||||
|
||||
@@ -47,10 +47,9 @@ pub fn poll(inbox: &Inbox, app: &mut WordApp, ctx: &egui::Context) {
|
||||
// Leave Backstage, as opening from the Open dialog does.
|
||||
Ok(_) => app.ui.backstage = false,
|
||||
Err(e) => {
|
||||
log::warn!("{p}: {e}");
|
||||
let e = e.to_string();
|
||||
let msg = wordcraft_ui_egui::tl!("Couldn't open {path}: {error}");
|
||||
app.status(wordcraft_ui_egui::i18n::fmt(msg, &[("path", p), ("error", &e)]));
|
||||
app.report_open_failure(p, &e);
|
||||
// Backstage hides the status bar, so leave it to show the error.
|
||||
app.ui.backstage = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -329,6 +329,26 @@ fn text_args() -> (Vec<String>, Vec<std::path::PathBuf>) {
|
||||
(args, files)
|
||||
}
|
||||
|
||||
/// The arguments without the ones macOS adds when it launches an app (#1103): `-psn_0_12345`, and
|
||||
/// the `-NS…` / `-Apple…` options (`-AppleLanguages (en)`, `-NSDocumentRevisionsDebugMode YES`) with
|
||||
/// the value after each. They are not files, and opening them as files fails.
|
||||
fn without_launch_args(args: Vec<String>) -> Vec<String> {
|
||||
let mut kept = Vec::with_capacity(args.len());
|
||||
let mut args = args.into_iter().peekable();
|
||||
while let Some(a) = args.next() {
|
||||
if a.starts_with("-psn_") {
|
||||
continue;
|
||||
}
|
||||
if a.starts_with("-NS") || a.starts_with("-Apple") {
|
||||
// Its value, unless the next argument is another flag.
|
||||
args.next_if(|v| !v.starts_with('-'));
|
||||
continue;
|
||||
}
|
||||
kept.push(a);
|
||||
}
|
||||
kept
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -340,7 +360,9 @@ fn open_non_unicode(app: &mut WordApp, path: &std::path::Path) {
|
||||
// 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(_) => {}
|
||||
Err(e) => log::warn!("{name}: {e}"),
|
||||
Err(e) => {
|
||||
app.report_open_failure(&name, &e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,7 +372,7 @@ fn open_non_unicode(app: &mut WordApp, path: &std::path::Path) {
|
||||
fn main() -> eframe::Result {
|
||||
// Windows: the update helper is a copy of this program started with `--finish-update`.
|
||||
#[cfg(windows)]
|
||||
if let Some(result) = craft_update::self_update::finish(&craft_update::suite::WORDCRAFT, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
|
||||
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()));
|
||||
}
|
||||
// First, so the panic hook and every start-up warning are recorded (`logging`).
|
||||
@@ -360,6 +382,8 @@ fn main() -> eframe::Result {
|
||||
let mut files = Vec::new();
|
||||
let mut sample = false;
|
||||
let (args, other_files) = text_args();
|
||||
// Only macOS (Finder / LaunchServices) adds these; elsewhere `-NS…` could be a real file name.
|
||||
let args = if cfg!(target_os = "macos") { without_launch_args(args) } else { args };
|
||||
let mut bad_control: Option<Option<String>> = None;
|
||||
let mut args = args.into_iter().peekable();
|
||||
while let Some(a) = args.next() {
|
||||
@@ -504,7 +528,7 @@ fn main() -> eframe::Result {
|
||||
}
|
||||
for f in files {
|
||||
if let Err(e) = app.run("file.open", serde_json::json!({"path": f})) {
|
||||
log::warn!("{f}: {e}");
|
||||
app.report_open_failure(&f, &e);
|
||||
}
|
||||
}
|
||||
for f in other_files {
|
||||
@@ -590,6 +614,33 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod launch_arg_tests {
|
||||
use super::without_launch_args;
|
||||
|
||||
fn strip(args: &[&str]) -> Vec<String> {
|
||||
without_launch_args(args.iter().map(|a| a.to_string()).collect())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macos_launch_arguments_are_not_files() {
|
||||
assert_eq!(strip(&["-psn_0_12345", "a.docx"]), ["a.docx"]);
|
||||
assert_eq!(strip(&["-AppleLanguages", "(en)", "a.docx"]), ["a.docx"]);
|
||||
assert_eq!(strip(&["a.docx", "-NSDocumentRevisionsDebugMode", "YES", "b.docx"]), ["a.docx", "b.docx"]);
|
||||
// A flag with no value after it, or followed by another flag, takes nothing with it.
|
||||
assert_eq!(strip(&["a.docx", "-AppleLocale"]), ["a.docx"]);
|
||||
assert_eq!(strip(&["-NSFoo", "--sample", "a.docx"]), ["--sample", "a.docx"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_arguments_are_kept() {
|
||||
assert_eq!(strip(&["--control", "7981", "--sample", "-h"]), ["--control", "7981", "--sample", "-h"]);
|
||||
// Files whose names merely start like a launch argument keep their place when they are paths.
|
||||
assert_eq!(strip(&["./-psn_x.docx", "Apple.docx", "-", "-nsfoo"]), ["./-psn_x.docx", "Apple.docx", "-", "-nsfoo"]);
|
||||
assert!(strip(&[]).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod prefs_tests {
|
||||
use super::write_prefs;
|
||||
|
||||
@@ -181,3 +181,12 @@ sync_github_name = true
|
||||
|
||||
[people.pakiat]
|
||||
display_name = "OtorO"
|
||||
|
||||
[people.TobyWu666]
|
||||
display_name = "tobywu"
|
||||
|
||||
[people.Bra1nZ]
|
||||
sync_github_name = true
|
||||
|
||||
[people.dylanmtaylor]
|
||||
sync_github_name = true
|
||||
|
||||
@@ -115,12 +115,22 @@ pub fn next_cluster(text: &str, off: usize) -> usize {
|
||||
}
|
||||
}
|
||||
|
||||
/// Scripts whose Backspace removes one character at a time (a vowel sign, a medial, a stacked
|
||||
/// consonant) rather than the whole cluster, as on Windows and macOS: the Brahmic scripts of
|
||||
/// South and South-East Asia.
|
||||
/// Characters Backspace removes individually rather than with their whole cluster: Arabic
|
||||
/// combining marks and characters of the Brahmic scripts of South and South-East Asia.
|
||||
pub fn backspaces_by_char(c: char) -> bool {
|
||||
matches!(u32::from(c),
|
||||
0x0900..=0x0DFF // Devanagari … Sinhala
|
||||
0x0610..=0x061A // Arabic nonspacing marks (Unicode 17.0)
|
||||
| 0x064B..=0x065F
|
||||
| 0x0670
|
||||
| 0x06D6..=0x06DC
|
||||
| 0x06DF..=0x06E4
|
||||
| 0x06E7..=0x06E8
|
||||
| 0x06EA..=0x06ED
|
||||
| 0x0897..=0x089F
|
||||
| 0x08CA..=0x08E1
|
||||
| 0x08E3..=0x08FF
|
||||
| 0x10EFA..=0x10EFF
|
||||
| 0x0900..=0x0DFF // Devanagari … Sinhala
|
||||
| 0x0E00..=0x0EFF // Thai, Lao
|
||||
| 0x0F00..=0x0FFF // Tibetan
|
||||
| 0x1000..=0x109F // Myanmar
|
||||
@@ -250,8 +260,10 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backspace_by_character_in_brahmic_scripts_only() {
|
||||
fn backspace_by_character_in_brahmic_scripts_and_arabic_marks() {
|
||||
assert!(backspaces_by_char('ေ') && backspaces_by_char('ि') && backspaces_by_char('្'));
|
||||
assert!(backspaces_by_char('\u{064E}') && backspaces_by_char('\u{06E7}') && backspaces_by_char('\u{10EFF}'));
|
||||
assert!(!backspaces_by_char('\u{301}') && !backspaces_by_char('a') && !backspaces_by_char('語'));
|
||||
assert!(!backspaces_by_char('ب') && !backspaces_by_char('\u{06E5}') && !backspaces_by_char('\u{08E2}'));
|
||||
}
|
||||
}
|
||||
|
||||
+26
-13
@@ -14,7 +14,7 @@
|
||||
//! outside what's deleted (so deleting across a control's edge empties it rather than breaking
|
||||
//! it); writers call [`Document::balance_controls`] so a saved file always has whole controls.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -422,24 +422,37 @@ fn scan(story: StoryRef, bl: &Blocks, prefix: &mut Vec<u32>, depth: usize, out:
|
||||
}
|
||||
|
||||
/// Positions of the markers without a partner in a block list and the cells inside it.
|
||||
fn orphans(bl: &Blocks, prefix: &mut Vec<u32>, depth: usize, out: &mut Vec<(Path, usize)>, tables: &mut Vec<(Path, usize, bool)>) {
|
||||
pub(crate) fn orphans(bl: &Blocks, prefix: &mut Vec<u32>, depth: usize, out: &mut Vec<(Path, usize)>, tables: &mut Vec<(Path, usize, bool)>) {
|
||||
if depth > MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
let events = control_events(bl);
|
||||
let (_, lone) = pair_events(&events);
|
||||
for k in lone {
|
||||
let Some(e) = events.get(k) else { continue };
|
||||
let mut path = prefix.clone();
|
||||
path.push(u32::try_from(e.block()).unwrap_or(u32::MAX));
|
||||
match (e, e.off()) {
|
||||
(_, Some(off)) => out.push((Path(path), off)),
|
||||
(ControlEvent::Open { .. }, None) => {
|
||||
// Which of the table's opens: count those before it on the same table.
|
||||
let nth = events.iter().take(k).filter(|x| matches!(x, ControlEvent::Open { block, off: None, .. } if *block == e.block())).count();
|
||||
tables.push((Path(path), nth, true));
|
||||
if !lone.is_empty() {
|
||||
let mut table_counts: HashMap<usize, usize> = HashMap::new();
|
||||
let mut open_nth = Vec::with_capacity(events.len());
|
||||
for e in &events {
|
||||
if let ControlEvent::Open { block, off: None, .. } = e {
|
||||
let count = table_counts.entry(*block).or_insert(0);
|
||||
open_nth.push(*count);
|
||||
*count += 1;
|
||||
} else {
|
||||
open_nth.push(0);
|
||||
}
|
||||
}
|
||||
for k in lone {
|
||||
let Some(e) = events.get(k) else { continue };
|
||||
let mut path = prefix.clone();
|
||||
path.push(u32::try_from(e.block()).unwrap_or(u32::MAX));
|
||||
match (e, e.off()) {
|
||||
(_, Some(off)) => out.push((Path(path), off)),
|
||||
(ControlEvent::Open { .. }, None) => {
|
||||
// Which of the table's opens: index among table opens on the same table.
|
||||
let nth = open_nth.get(k).copied().unwrap_or(0);
|
||||
tables.push((Path(path), nth, true));
|
||||
}
|
||||
(ControlEvent::Close { .. }, None) => tables.push((Path(path), 0, false)),
|
||||
}
|
||||
(ControlEvent::Close { .. }, None) => tables.push((Path(path), 0, false)),
|
||||
}
|
||||
}
|
||||
for (i, b) in bl.iter().enumerate() {
|
||||
|
||||
@@ -795,7 +795,9 @@ impl Document {
|
||||
p.kind = "lpwstr".into();
|
||||
p.value = value.to_string();
|
||||
}
|
||||
None => self.custom_props.push(CustomProp { name: name.to_string(), kind: "lpwstr".into(), value: value.to_string() }),
|
||||
None => {
|
||||
self.custom_props.push(CustomProp { name: name.to_string(), kind: "lpwstr".into(), value: value.to_string(), ..Default::default() })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+71
-1
@@ -434,10 +434,16 @@ impl Document {
|
||||
at.extend(offs.get(k).copied());
|
||||
}
|
||||
}
|
||||
// Reference offsets and run boundaries are ordered; do not restart at the first
|
||||
// reference for every run, including runs after the last reference.
|
||||
let mut at = at.into_iter().peekable();
|
||||
let mut start = 0usize;
|
||||
for r in &mut p.runs {
|
||||
let end = start.saturating_add(r.len);
|
||||
if at.iter().any(|&o| o >= start && o < end) {
|
||||
while at.peek().is_some_and(|&o| o < start) {
|
||||
at.next();
|
||||
}
|
||||
if at.peek().is_some_and(|&o| o < end) {
|
||||
swap(&mut r.props.style);
|
||||
}
|
||||
start = end;
|
||||
@@ -792,6 +798,63 @@ impl Document {
|
||||
Fragment { blocks, parts }
|
||||
}
|
||||
|
||||
/// A copy of the block at `path` (a whole table, say) as a fragment, with its text boxes.
|
||||
pub fn copy_block(&self, story: crate::StoryRef, path: &Path) -> Option<Fragment> {
|
||||
Some(self.fragment_of(vec![self.block(story, path)?.clone()]))
|
||||
}
|
||||
|
||||
/// A copy of rows `rows` of the table at `tp`, with the cells of each that reach into grid
|
||||
/// columns `cols` (see [`crate::Table::slice`]), as a fragment holding that table.
|
||||
pub fn copy_table_cells(&self, story: crate::StoryRef, tp: &Path, rows: (usize, usize), cols: (usize, usize)) -> Option<Fragment> {
|
||||
let t = self.table(story, tp)?.slice(rows, cols);
|
||||
if t.rows.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(self.fragment_of(vec![Block::Table(t)]))
|
||||
}
|
||||
|
||||
/// `blocks` as a fragment: half content controls dropped, text boxes copied along.
|
||||
fn fragment_of(&self, mut blocks: Vec<Block>) -> Fragment {
|
||||
crate::control::balance_blocks(&mut blocks);
|
||||
let parts = self.text_box_parts(&blocks);
|
||||
Fragment { blocks, parts }
|
||||
}
|
||||
|
||||
/// The table whose whole text `a..b` covers when the range runs across its cells: from the
|
||||
/// start of its first paragraph to the end of its last. Of the tables holding both ends, the
|
||||
/// outermost one covered whole (a table nested in a cell when the range covers just it).
|
||||
/// `None` for a range inside one cell, or one that doesn't cover a whole table.
|
||||
pub fn whole_table(&self, a: &Pos, b: &Pos) -> Option<Path> {
|
||||
let (a, b) = order(a, b);
|
||||
if a.story != b.story || a.off != 0 || a.path.parent() == b.path.parent() {
|
||||
return None;
|
||||
}
|
||||
let mut paths: Option<Vec<Path>> = None;
|
||||
// Table block paths are the prefixes of a cell paragraph's path one block deep, then
|
||||
// every three steps (block, row, cell) deeper.
|
||||
let mut n = 1usize;
|
||||
while n.saturating_add(3) <= a.path.0.len() {
|
||||
let pre = a.path.0.get(..n)?;
|
||||
if !b.path.0.starts_with(pre) {
|
||||
return None;
|
||||
}
|
||||
let tp = Path(pre.to_vec());
|
||||
if self.table(a.story, &tp).is_some() {
|
||||
let paths = paths.get_or_insert_with(|| self.para_paths(a.story));
|
||||
let mut inside = paths.iter().filter(|p| p.0.starts_with(pre));
|
||||
if let Some(first) = inside.next() {
|
||||
let last = inside.next_back().unwrap_or(first);
|
||||
let end = self.para(a.story, last).map_or(0, Paragraph::len);
|
||||
if *first == a.path && *last == b.path && b.off >= end {
|
||||
return Some(tp);
|
||||
}
|
||||
}
|
||||
}
|
||||
n = n.saturating_add(3);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The whole body as a fragment (with its text boxes), to insert into another document.
|
||||
pub fn body_fragment(&self) -> Fragment {
|
||||
let blocks: Vec<Block> = self.body.iter().map(|b| (**b).clone()).collect();
|
||||
@@ -846,6 +909,13 @@ impl Document {
|
||||
}
|
||||
return out;
|
||||
}
|
||||
// Across the cells of a whole table: the table itself, not its cells' paragraphs (#1744).
|
||||
if let Some(tp) = self.whole_table(&a, &b)
|
||||
&& let Some(blk) = self.block(a.story, &tp)
|
||||
{
|
||||
out.push(blk.clone());
|
||||
return out;
|
||||
}
|
||||
for p in self.paths_between(&a, &b) {
|
||||
if let Some(para) = self.para(a.story, &p) {
|
||||
let mut q = para.clone();
|
||||
|
||||
@@ -501,9 +501,8 @@ impl Paragraph {
|
||||
pub fn next_boundary(&self, off: usize) -> usize {
|
||||
crate::cluster::next_cluster(&self.text, self.clamp(off))
|
||||
}
|
||||
/// Where Backspace at `off` deletes from: the previous character in the Brahmic scripts
|
||||
/// (Myanmar, Khmer, Devanagari, Thai…), where it removes one vowel sign or stacked consonant
|
||||
/// at a time, otherwise the previous caret stop.
|
||||
/// Where Backspace at `off` deletes from: one Arabic combining mark or one character in the
|
||||
/// Brahmic scripts (Myanmar, Khmer, Devanagari, Thai…), otherwise the previous caret stop.
|
||||
pub fn backspace_from(&self, off: usize) -> usize {
|
||||
let off = self.clamp(off);
|
||||
match self.text.get(..off).and_then(|t| t.char_indices().next_back()) {
|
||||
|
||||
@@ -273,6 +273,12 @@ pub struct CharProps {
|
||||
pub small_caps: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub hidden: Option<bool>,
|
||||
/// Hidden in Web Layout (`w:webHidden`); distinct from `w:vanish` (`hidden`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub web_hidden: Option<bool>,
|
||||
/// Snap characters to the document grid (`w:snapToGrid`). Unset inherits; Word's default is on.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub snap_to_grid: Option<bool>,
|
||||
/// Character spacing (expanded/condensed), points.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub spacing: Option<f32>,
|
||||
@@ -448,6 +454,7 @@ impl CharProps {
|
||||
/// overlaid on `base`.
|
||||
pub fn minus(mut self, base: &CharProps) -> CharProps {
|
||||
self.minus_text_style(base);
|
||||
self.minus_run_visibility(base);
|
||||
self.minus_script_style(base);
|
||||
self.minus_document_marks(base);
|
||||
self
|
||||
@@ -458,6 +465,10 @@ impl CharProps {
|
||||
shading, border, vert_align, caps, small_caps, hidden, spacing, scale, position, kern, outline, shadow, emboss, engrave, lang, no_proof, rtl);
|
||||
}
|
||||
|
||||
fn minus_run_visibility(&mut self, base: &CharProps) {
|
||||
drop_same_fields!(self, base; web_hidden, snap_to_grid);
|
||||
}
|
||||
|
||||
fn minus_script_style(&mut self, base: &CharProps) {
|
||||
drop_same_fields!(self, base; cs, font_cs, font_ea, size_cs, bold_cs, italic_cs, lang_bidi, lang_ea, east_asian_layout);
|
||||
}
|
||||
@@ -472,6 +483,7 @@ impl CharProps {
|
||||
/// format both scripts must set both values explicitly.
|
||||
pub fn overlay(&mut self, patch: &CharProps) {
|
||||
self.overlay_text_style(patch);
|
||||
self.overlay_run_visibility(patch);
|
||||
self.overlay_script_style(patch);
|
||||
self.overlay_document_marks(patch);
|
||||
if let Some(p) = &patch.text_effects {
|
||||
@@ -484,6 +496,10 @@ impl CharProps {
|
||||
shading, border, vert_align, caps, small_caps, hidden, spacing, scale, position, kern, outline, shadow, emboss, engrave, lang, no_proof, rtl);
|
||||
}
|
||||
|
||||
fn overlay_run_visibility(&mut self, patch: &CharProps) {
|
||||
overlay_fields!(self, patch; web_hidden, snap_to_grid);
|
||||
}
|
||||
|
||||
fn overlay_script_style(&mut self, patch: &CharProps) {
|
||||
overlay_fields!(self, patch; cs, font_cs, font_ea, size_cs, bold_cs, italic_cs, lang_bidi, lang_ea, east_asian_layout);
|
||||
}
|
||||
|
||||
@@ -270,7 +270,12 @@ impl ResolvedPara {
|
||||
drop_cap_margin: p.drop_cap_margin.unwrap_or(false),
|
||||
drop_cap_distance: p.drop_cap_distance.filter(|d| d.is_finite()).unwrap_or(0.0).clamp(0.0, MAX_DROP_CAP_DISTANCE),
|
||||
drop_cap_font: p.drop_cap_font.clone().filter(|f| !f.trim().is_empty()),
|
||||
frame: p.frame.clone().filter(|_| p.drop_cap.unwrap_or(0) == 0),
|
||||
// Explicit zero dimensions cancel a frame; omitted dimensions still fit its text.
|
||||
frame: p.frame.clone().filter(|f| p.drop_cap.unwrap_or(0) == 0 && (f.w != Some(0.0) || f.h != Some(0.0))).map(|mut f| {
|
||||
f.w = f.w.filter(|v| *v != 0.0);
|
||||
f.h = f.h.filter(|v| *v != 0.0);
|
||||
f
|
||||
}),
|
||||
suppress_hyphens: p.suppress_hyphens.unwrap_or(false),
|
||||
suppress_line_numbers: p.suppress_line_numbers.unwrap_or(false),
|
||||
kinsoku: p.kinsoku.unwrap_or(true),
|
||||
|
||||
@@ -10,6 +10,10 @@ pub struct Source {
|
||||
pub kind: String,
|
||||
/// "Last, First; Last, First"
|
||||
pub author: String,
|
||||
/// A corporate author ("World Health Organization"): written as it is, never split into
|
||||
/// names, and in place of `author` when set (Create Source › Corporate Author, `b:Corporate`,
|
||||
/// #4848).
|
||||
pub corporate: String,
|
||||
pub title: String,
|
||||
pub year: String,
|
||||
pub publisher: String,
|
||||
@@ -39,6 +43,9 @@ pub struct CustomProp {
|
||||
/// the property's XML content kept verbatim (vectors, blobs).
|
||||
pub kind: String,
|
||||
pub value: String,
|
||||
/// Bookmark associated with this property (`linkTarget`), retained without evaluating it.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub link_target: Option<String>,
|
||||
}
|
||||
|
||||
/// Document properties (File › Info).
|
||||
|
||||
@@ -77,6 +77,80 @@ pub const MAX_COLS: usize = 63;
|
||||
pub const MAX_TABLE_DEPTH: usize = 24;
|
||||
|
||||
impl Table {
|
||||
/// Grid columns `lo..hi` that [`Table::slice`] copies for `rows`/`cols` (widened over merged cells),
|
||||
/// and whether any cell touches them (Cut clears the same cells it copies).
|
||||
pub fn slice_bounds(&self, rows: (usize, usize), cols: (usize, usize)) -> (usize, usize, bool) {
|
||||
let ((r0, r1), (g0, g1)) = (rows, cols);
|
||||
let kept = || self.rows.iter().take(r1.saturating_add(1)).skip(r0);
|
||||
// Grid columns `lo..hi` the cells touching them cover: a cell merged across that is partly
|
||||
// in them widens them, for every row, so the copy isn't ragged. Each round only widens
|
||||
// (at most once per grid column), so a bound of the grid's width plus one round is enough.
|
||||
let (mut lo, mut hi) = (g0, g1.saturating_add(1));
|
||||
let mut any = false;
|
||||
for _ in 0..=MAX_COLS.saturating_add(1) {
|
||||
let (mut nlo, mut nhi, mut found) = (lo, hi, false);
|
||||
for row in kept() {
|
||||
let mut g = 0usize;
|
||||
for cell in &row.cells {
|
||||
let end = g.saturating_add(cell.span());
|
||||
if g < hi && end > lo {
|
||||
(nlo, nhi, found) = (nlo.min(g), nhi.max(end), true);
|
||||
}
|
||||
g = end;
|
||||
}
|
||||
}
|
||||
any = found;
|
||||
if (nlo, nhi) == (lo, hi) {
|
||||
break;
|
||||
}
|
||||
(lo, hi) = (nlo, nhi);
|
||||
}
|
||||
(lo, hi, any)
|
||||
}
|
||||
|
||||
/// A copy of rows `rows` of this table holding, of each row, the cells that reach into grid
|
||||
/// columns `cols` (both inclusive): what Copy takes from selected rows or cells. Content
|
||||
/// controls around rows and the table aren't copied (they'd lose their other end); a cell
|
||||
/// that continues a vertical merge from a row left out starts the merge instead.
|
||||
pub fn slice(&self, rows: (usize, usize), cols: (usize, usize)) -> Table {
|
||||
let (r0, r1) = rows;
|
||||
let kept = || self.rows.iter().take(r1.saturating_add(1)).skip(r0);
|
||||
let (lo, hi, any) = self.slice_bounds(rows, cols);
|
||||
let mut out_rows = Vec::new();
|
||||
for row in kept() {
|
||||
let mut g = 0usize;
|
||||
let mut cells = Vec::new();
|
||||
for cell in &row.cells {
|
||||
let end = g.saturating_add(cell.span());
|
||||
if any && g < hi && end > lo {
|
||||
cells.push(cell.clone());
|
||||
}
|
||||
g = end;
|
||||
}
|
||||
if !cells.is_empty() {
|
||||
out_rows.push(Row { props: row.props.clone(), cells, controls: ControlWrap::default() });
|
||||
}
|
||||
}
|
||||
if let Some(first) = out_rows.first_mut() {
|
||||
for c in &mut first.cells {
|
||||
if c.props.vmerge == VMerge::Continue {
|
||||
c.props.vmerge = VMerge::Restart;
|
||||
}
|
||||
}
|
||||
}
|
||||
let whole = lo == 0 && hi >= self.grid.len();
|
||||
let grid = if whole { self.grid.clone() } else { self.grid.get(lo..hi.min(self.grid.len())).map(<[f32]>::to_vec).unwrap_or_default() };
|
||||
let mut props = self.props.clone();
|
||||
if !whole {
|
||||
// Part of the columns: as wide as they are, not the whole table's width.
|
||||
if props.width.is_some() || props.width_pct.is_some() {
|
||||
props.width = Some(grid.iter().sum());
|
||||
}
|
||||
props.width_pct = None;
|
||||
}
|
||||
Table { props, grid, rows: out_rows, controls: ControlWrap::default() }
|
||||
}
|
||||
|
||||
/// A `rows × cols` table spanning `width` points, "Table Grid" style.
|
||||
pub fn new(rows: usize, cols: usize, width: f32) -> Table {
|
||||
let rows = rows.clamp(1, MAX_ROWS);
|
||||
@@ -566,6 +640,69 @@ impl Table {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A 3×3 table, 100 pt columns: (0,0)–(0,1) merged across, (1,2)–(2,2) merged down.
|
||||
fn merged() -> Table {
|
||||
let mut t = Table::new(3, 3, 300.0);
|
||||
t.merge(0, 0, 0, 1);
|
||||
t.merge(1, 2, 2, 2);
|
||||
assert_eq!(t.rows[0].cells.iter().map(Cell::span).collect::<Vec<_>>(), [2, 1]);
|
||||
assert_eq!((t.rows[1].cells[2].props.vmerge, t.rows[2].cells[2].props.vmerge), (VMerge::Restart, VMerge::Continue));
|
||||
t
|
||||
}
|
||||
|
||||
fn spans(t: &Table) -> Vec<Vec<usize>> {
|
||||
t.rows.iter().map(|r| r.cells.iter().map(Cell::span).collect()).collect()
|
||||
}
|
||||
|
||||
/// #1744 review: a cell merged across that is partly selected takes its whole width, and the
|
||||
/// other rows widen with it, so the copy isn't ragged.
|
||||
#[test]
|
||||
fn slice_widens_to_a_merged_cell_in_every_row() {
|
||||
let t = merged().slice((0, 1), (1, 2));
|
||||
assert_eq!(t.grid, [100.0, 100.0, 100.0]);
|
||||
assert_eq!(spans(&t), [vec![2, 1], vec![1, 1, 1]]);
|
||||
// Rows without the merged cell: just the selected columns.
|
||||
let t = merged().slice((1, 2), (1, 2));
|
||||
assert_eq!(t.grid, [100.0, 100.0]);
|
||||
assert_eq!(spans(&t), [vec![1, 1], vec![1, 1]]);
|
||||
}
|
||||
|
||||
/// #1744 review: a cell continuing a vertical merge from a row left out starts it instead.
|
||||
#[test]
|
||||
fn slice_starts_a_vertical_merge_whose_first_row_is_left_out() {
|
||||
let t = merged().slice((2, 2), (0, 2));
|
||||
assert_eq!(t.rows.len(), 1);
|
||||
assert_eq!(t.rows[0].cells[2].props.vmerge, VMerge::Restart);
|
||||
// Both rows kept: the merge is kept as it is.
|
||||
let t = merged().slice((1, 2), (2, 2));
|
||||
assert_eq!((t.rows[0].cells[0].props.vmerge, t.rows[1].cells[0].props.vmerge), (VMerge::Restart, VMerge::Continue));
|
||||
}
|
||||
|
||||
/// #1744 review: part of the columns is as wide as they are; content controls around rows
|
||||
/// and the table aren't copied.
|
||||
#[test]
|
||||
fn slice_width_and_controls() {
|
||||
let mut t = Table::new(3, 3, 300.0);
|
||||
(t.props.width, t.props.width_pct) = (Some(300.0), Some(50.0));
|
||||
let wrap = ControlWrap { open: vec![crate::control::ContentControl::default()], close: 1 };
|
||||
t.controls = wrap.clone();
|
||||
t.rows[1].controls = wrap;
|
||||
let part = t.slice((0, 2), (2, 2));
|
||||
assert_eq!((part.grid.len(), part.props.width, part.props.width_pct), (1, Some(100.0), None));
|
||||
let whole = t.slice((0, 2), (0, 2));
|
||||
assert_eq!((whole.props.width, whole.props.width_pct), (Some(300.0), Some(50.0)), "all columns keep the table's width");
|
||||
let row = t.slice((1, 1), (0, 2));
|
||||
assert!(row.controls.is_empty() && row.rows[0].controls.is_empty());
|
||||
// A table sized only by a percentage keeps a width for the part.
|
||||
let mut pct = Table::new(3, 3, 300.0);
|
||||
(pct.props.width, pct.props.width_pct) = (None, Some(50.0));
|
||||
let part = pct.slice((0, 2), (2, 2));
|
||||
assert_eq!((part.props.width, part.props.width_pct), (Some(100.0), None));
|
||||
// Nothing in range: no rows.
|
||||
assert!(t.slice((5, 6), (0, 2)).rows.is_empty());
|
||||
assert!(t.slice((0, 2), (9, 9)).rows.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_before_shifts_the_row() {
|
||||
let mut t = Table::new(2, 3, 300.0);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use super::*;
|
||||
|
||||
mod note_conversion;
|
||||
|
||||
#[test]
|
||||
fn new_document_is_valid() {
|
||||
let d = Document::new();
|
||||
@@ -651,3 +653,66 @@ fn control_markers_kept_are_sorted() {
|
||||
assert!(kept.windows(2).all(|p| p[0] < p[1]), "sorted, no repeats: {kept:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lone_table_open_markers_linear_time() {
|
||||
let mut d = Document::new();
|
||||
let mut t = Table::new(1, 1, 100.0);
|
||||
const COUNT: usize = 2_000;
|
||||
t.controls.open = (0..COUNT).map(|i| ContentControl { tag: format!("ctrl_{i}"), ..Default::default() }).collect();
|
||||
d.body.insert(0, Arc::new(Block::Table(t)));
|
||||
|
||||
let mut paras = Vec::new();
|
||||
let mut tables = Vec::new();
|
||||
let timer = std::time::Instant::now();
|
||||
crate::control::orphans(&d.body, &mut Vec::new(), 0, &mut paras, &mut tables);
|
||||
|
||||
assert_eq!(paras.len(), 0);
|
||||
assert_eq!(tables.len(), COUNT);
|
||||
for (i, (path, nth, is_open)) in tables.iter().enumerate() {
|
||||
assert_eq!(*path, Path::top(0));
|
||||
assert_eq!(*nth, i);
|
||||
assert!(*is_open);
|
||||
}
|
||||
|
||||
assert!(d.has_unbalanced_controls());
|
||||
let removed = d.balance_controls();
|
||||
assert_eq!(removed, COUNT);
|
||||
assert!(!d.has_unbalanced_controls());
|
||||
assert!(timer.elapsed().as_secs_f32() < 10.0, "{:?}", timer.elapsed());
|
||||
let Block::Table(tbl) = &*d.body[0] else { panic!("expected table") };
|
||||
assert!(tbl.controls.open.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lone_table_open_markers_mixed_tables_and_paired() {
|
||||
let mut d = Document::new();
|
||||
let mut t0 = Table::new(1, 1, 100.0);
|
||||
t0.controls.open = (0..3).map(|i| ContentControl { tag: format!("t0_{i}"), ..Default::default() }).collect();
|
||||
t0.controls.close = 1;
|
||||
|
||||
let mut t1 = Table::new(1, 1, 100.0);
|
||||
t1.controls.open = (0..2).map(|i| ContentControl { tag: format!("t1_{i}"), ..Default::default() }).collect();
|
||||
|
||||
d.body = vec![Arc::new(Block::Table(t0)), Arc::new(Block::Table(t1))];
|
||||
|
||||
let mut paras = Vec::new();
|
||||
let mut tables = Vec::new();
|
||||
crate::control::orphans(&d.body, &mut Vec::new(), 0, &mut paras, &mut tables);
|
||||
|
||||
assert_eq!(paras.len(), 0);
|
||||
assert_eq!(tables, vec![(Path::top(0), 0, true), (Path::top(0), 1, true), (Path::top(1), 0, true), (Path::top(1), 1, true),]);
|
||||
|
||||
let removed = d.balance_controls();
|
||||
assert_eq!(removed, 4);
|
||||
assert!(!d.has_unbalanced_controls());
|
||||
|
||||
let Block::Table(res0) = &*d.body[0] else { panic!("expected table") };
|
||||
assert_eq!(res0.controls.open.len(), 1);
|
||||
assert_eq!(res0.controls.open[0].tag, "t0_2");
|
||||
assert_eq!(res0.controls.close, 1);
|
||||
|
||||
let Block::Table(res1) = &*d.body[1] else { panic!("expected table") };
|
||||
assert!(res1.controls.open.is_empty());
|
||||
assert_eq!(res1.controls.close, 0);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
use super::*;
|
||||
use crate::para::{InlineObject, NoteKind, OBJ};
|
||||
|
||||
fn reference(id: u32, kind: NoteKind) -> InlineObject {
|
||||
InlineObject::NoteRef { kind, id, custom: "*".into() }
|
||||
}
|
||||
|
||||
fn styled(len: usize, style: &str) -> Run {
|
||||
Run { len, props: CharProps { style: Some(style.into()), italic: Some(true), ..Default::default() } }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conversion_preserves_reference_run_boundaries_and_unmatched_storage() {
|
||||
for (foot, end, expected) in [
|
||||
(true, false, ["FootnoteReference", "EndnoteReference", "FootnoteReference", "EndnoteReference", "EndnoteReference", "FootnoteReference"]),
|
||||
(false, true, ["FootnoteReference", "FootnoteReference", "FootnoteReference", "EndnoteReference", "FootnoteReference", "FootnoteReference"]),
|
||||
(true, true, ["FootnoteReference", "EndnoteReference", "FootnoteReference", "EndnoteReference", "FootnoteReference", "FootnoteReference"]),
|
||||
] {
|
||||
let mut d = Document::new();
|
||||
let note = |style: &str| {
|
||||
let mut p = Paragraph::with_text("Original note 漢", CharProps::default());
|
||||
p.props.style = Some(style.into());
|
||||
p.mark.style = Some(style.into());
|
||||
vec![para_block(p)]
|
||||
};
|
||||
let f = d.add_part(PartKind::Footnote, note("FootnoteText"));
|
||||
let e = d.add_part(PartKind::Endnote, note("EndnoteText"));
|
||||
let p = Paragraph {
|
||||
text: format!("é{OBJ}{OBJ}漢{OBJ}{OBJ}"),
|
||||
objects: vec![
|
||||
reference(f, NoteKind::Footnote),
|
||||
InlineObject::BookmarkStart { name: "kept".into() },
|
||||
reference(e, NoteKind::Endnote),
|
||||
reference(999, NoteKind::Footnote),
|
||||
reference(f, NoteKind::Footnote),
|
||||
],
|
||||
runs: vec![
|
||||
styled(2, "FootnoteReference"),
|
||||
styled(3, "FootnoteReference"),
|
||||
styled(3, "FootnoteReference"),
|
||||
styled(3, "EndnoteReference"),
|
||||
styled(3, "EndnoteReference"),
|
||||
styled(3, "FootnoteReference"),
|
||||
],
|
||||
..Paragraph::new()
|
||||
};
|
||||
let mut table = Table::new(1, 1, 200.0);
|
||||
table.rows[0].cells[0].blocks = vec![para_block(p.clone())];
|
||||
d.body = vec![para_block(p.clone()), Arc::new(Block::Table(table))];
|
||||
let h = d.add_part(PartKind::Header, vec![para_block(p.clone())]);
|
||||
let t = d.add_part(PartKind::TextBox, vec![para_block(p.clone())]);
|
||||
let before = d.clone();
|
||||
assert_eq!(d.convert_notes(foot, end), usize::from(foot) + usize::from(end));
|
||||
for (story, path) in [
|
||||
(StoryRef::Body, Path::top(0)),
|
||||
(StoryRef::Body, Path(vec![1, 0, 0, 0])),
|
||||
(StoryRef::Part(h), Path::top(0)),
|
||||
(StoryRef::Part(t), Path::top(0)),
|
||||
] {
|
||||
let got = d.para(story, &path).unwrap();
|
||||
assert_eq!(got.text, p.text);
|
||||
assert_eq!(got.runs.iter().map(|r| r.props.style.as_deref().unwrap()).collect::<Vec<_>>(), expected);
|
||||
assert!(got.runs.iter().all(|r| r.props.italic == Some(true)));
|
||||
assert_eq!(got.objects[1], p.objects[1]);
|
||||
assert_eq!(got.objects[3], p.objects[3], "a missing note stays unchanged");
|
||||
assert_eq!(got.objects[0], reference(f, if foot { NoteKind::Endnote } else { NoteKind::Footnote }));
|
||||
assert_eq!(got.objects[2], reference(e, if end { NoteKind::Footnote } else { NoteKind::Endnote }));
|
||||
assert_eq!(got.objects[4], got.objects[0], "surplus object storage retains the existing conversion");
|
||||
}
|
||||
for (id, flipped, from, to) in [(f, foot, "FootnoteText", "EndnoteText"), (e, end, "EndnoteText", "FootnoteText")] {
|
||||
let got = d.para(StoryRef::Part(id), &Path::top(0)).unwrap();
|
||||
assert_eq!(got.text, before.plain_text(StoryRef::Part(id)));
|
||||
assert_eq!(got.props.style.as_deref(), Some(if flipped { to } else { from }));
|
||||
assert_eq!(got.mark.style, got.props.style);
|
||||
}
|
||||
let mut missing = Paragraph {
|
||||
text: format!("{OBJ}{OBJ}"),
|
||||
objects: vec![reference(f, if foot { NoteKind::Endnote } else { NoteKind::Footnote })],
|
||||
runs: vec![styled(0, "FootnoteReference"), styled(3, "FootnoteReference"), styled(3, "FootnoteReference")],
|
||||
..Paragraph::new()
|
||||
};
|
||||
missing.runs.push(styled(usize::MAX, "FootnoteReference"));
|
||||
d.body = vec![para_block(missing)];
|
||||
d.convert_notes(true, true);
|
||||
let got = d.para(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
assert_eq!(
|
||||
got.runs.iter().map(|r| r.props.style.as_deref().unwrap()).collect::<Vec<_>>(),
|
||||
["FootnoteReference", "EndnoteReference", "FootnoteReference", "FootnoteReference"]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn dense(n: usize) -> Document {
|
||||
let mut d = Document::new();
|
||||
let id = d.add_part(PartKind::Footnote, vec![para_block(Paragraph::with_text("Shared original note", CharProps::default()))]);
|
||||
d.body = vec![para_block(Paragraph {
|
||||
text: format!("{}{}", OBJ.to_string().repeat(n), "x".repeat(n)),
|
||||
objects: (0..n).map(|_| reference(id, NoteKind::Footnote)).collect(),
|
||||
runs: (0..n).map(|_| styled(OBJ.len_utf8(), "FootnoteReference")).chain((0..n).map(|_| styled(1, "FootnoteReference"))).collect(),
|
||||
..Paragraph::new()
|
||||
})];
|
||||
d
|
||||
}
|
||||
|
||||
// Thread CPU excludes scheduler delays. Missing or nonadvancing counters provide no timing
|
||||
// evidence; the semantic assertions still run on those platforms.
|
||||
fn cpu_cost(f: impl FnOnce()) -> Option<f64> {
|
||||
let ran = || std::fs::read_to_string("/proc/thread-self/schedstat").ok()?.split_whitespace().next()?.parse::<u64>().ok();
|
||||
let before = ran();
|
||||
f();
|
||||
match (before, ran()) {
|
||||
(Some(a), Some(b)) if b > a => Some((b - a) as f64 * 1e-9),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conversion_work_scales_with_reference_and_formatting_runs() {
|
||||
let (small, big) = (dense(2_000), dense(16_000));
|
||||
let convert = |input: &Document| {
|
||||
let mut d = input.clone();
|
||||
assert_eq!(d.convert_notes(true, false), 1);
|
||||
let p = d.para(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
let n = p.objects.len();
|
||||
assert!(p.runs[..n].iter().all(|r| r.props.style.as_deref() == Some("EndnoteReference")));
|
||||
assert!(p.runs[n..].iter().all(|r| r.props.style.as_deref() == Some("FootnoteReference")));
|
||||
std::hint::black_box(d);
|
||||
};
|
||||
convert(&small);
|
||||
convert(&big);
|
||||
let (mut t1, mut t8) = (f64::INFINITY, f64::INFINITY);
|
||||
for _ in 0..3 {
|
||||
let a = cpu_cost(|| (0..8).for_each(|_| convert(&small)));
|
||||
let b = cpu_cost(|| convert(&big));
|
||||
if let (Some(a), Some(b)) = (a, b) {
|
||||
t1 = t1.min(a / 8.0);
|
||||
t8 = t8.min(b);
|
||||
}
|
||||
}
|
||||
if t1.is_finite() && t8.is_finite() {
|
||||
let ratio = t8 / t1;
|
||||
eprintln!("note conversion CPU: small={t1:.6}s big={t8:.6}s ratio={ratio:.2}");
|
||||
assert!(ratio < 24.0, "8x the references and runs cost {ratio:.2}x CPU");
|
||||
}
|
||||
}
|
||||
@@ -90,13 +90,16 @@ pub(crate) fn parse(root: &El, seen: &mut Seen, room: usize) -> Sources {
|
||||
let tag = child_text(src_el, "Tag");
|
||||
let raw_kind = child_text(src_el, "SourceType");
|
||||
let kind = crate::write::from_source_type(&raw_kind);
|
||||
let (author, editor) = match src_el.els().find(|e| e.local() == "Author") {
|
||||
let (author, corporate, editor) = match src_el.els().find(|e| e.local() == "Author") {
|
||||
Some(a) => {
|
||||
let auth = a.els().find(|e| e.local() == "Author").map(parse_persons).filter(|s| !s.is_empty()).unwrap_or_else(|| parse_persons(a));
|
||||
let role = a.els().find(|e| e.local() == "Author");
|
||||
let auth = role.map(parse_persons).filter(|s| !s.is_empty()).unwrap_or_else(|| parse_persons(a));
|
||||
// Word's Corporate Author: one name, kept whole (`b:Corporate`, #4848).
|
||||
let corporate = role.map(|r| child_text(r, "Corporate").trim().to_string()).unwrap_or_default();
|
||||
let ed = a.els().find(|e| e.local() == "Editor").map(parse_persons).unwrap_or_default();
|
||||
(auth, ed)
|
||||
(auth, corporate, ed)
|
||||
}
|
||||
None => (String::new(), String::new()),
|
||||
None => (String::new(), String::new(), String::new()),
|
||||
};
|
||||
let editor = if editor.is_empty() { src_el.els().find(|e| e.local() == "Editor").map(parse_persons).unwrap_or_default() } else { editor };
|
||||
let title = child_text(src_el, "Title");
|
||||
@@ -132,11 +135,12 @@ pub(crate) fn parse(root: &El, seen: &mut Seen, room: usize) -> Sources {
|
||||
};
|
||||
|
||||
// Keep sources with some content, once each (by tag, or title and author untagged).
|
||||
if tag.is_empty() && title.is_empty() && author.is_empty() && editor.is_empty() {
|
||||
if tag.is_empty() && title.is_empty() && author.is_empty() && corporate.is_empty() && editor.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let fresh = if tag.is_empty() {
|
||||
seen.untagged.insert((title.clone(), if author.is_empty() { editor.clone() } else { author.clone() }))
|
||||
let who = [&author, &corporate, &editor].into_iter().find(|w| !w.is_empty()).cloned().unwrap_or_default();
|
||||
seen.untagged.insert((title.clone(), who))
|
||||
} else {
|
||||
seen.tags.insert(tag.clone())
|
||||
};
|
||||
@@ -147,6 +151,7 @@ pub(crate) fn parse(root: &El, seen: &mut Seen, room: usize) -> Sources {
|
||||
tag,
|
||||
kind,
|
||||
author,
|
||||
corporate,
|
||||
title,
|
||||
year,
|
||||
city,
|
||||
@@ -265,6 +270,25 @@ mod tests {
|
||||
assert!(read_sources(br#"<o:Sources xmlns:o="urn:example:other"><o:Source/></o:Sources>"#).is_err(), "an unrelated namespace");
|
||||
}
|
||||
|
||||
/// #4848: a corporate author (Word's Corporate Author box, `b:Corporate`) is read whole, and
|
||||
/// an untagged source with only a corporate author is still kept, once.
|
||||
#[test]
|
||||
fn a_corporate_author_is_read_whole() {
|
||||
let xml = br#"<b:Sources xmlns:b="http://schemas.openxmlformats.org/officeDocument/2006/bibliography">
|
||||
<b:Source><b:Tag>Wor20</b:Tag><b:SourceType>Report</b:SourceType>
|
||||
<b:Author><b:Author><b:Corporate> World Health Organization </b:Corporate></b:Author></b:Author>
|
||||
<b:Title>World report on hearing</b:Title><b:Year>2020</b:Year></b:Source>
|
||||
<b:Source><b:SourceType>Book</b:SourceType>
|
||||
<b:Author><b:Author><b:Corporate>Harbor Guild, Inc.</b:Corporate></b:Author></b:Author></b:Source>
|
||||
<b:Source><b:SourceType>Book</b:SourceType>
|
||||
<b:Author><b:Author><b:Corporate>Harbor Guild, Inc.</b:Corporate></b:Author></b:Author></b:Source>
|
||||
</b:Sources>"#;
|
||||
let f = read_sources(xml).unwrap();
|
||||
assert_eq!(f.sources.len(), 2, "{:?}", f.sources);
|
||||
assert_eq!((f.sources[0].corporate.as_str(), f.sources[0].author.as_str()), ("World Health Organization", ""));
|
||||
assert_eq!(f.sources[1].corporate, "Harbor Guild, Inc.", "a comma doesn't split a corporate name");
|
||||
}
|
||||
|
||||
/// Word's own 2004/10 bibliography namespace (`Sources.xml` written by scripts and older
|
||||
/// builds, the VBA `Source.XML` format) reads as well as OOXML's.
|
||||
#[test]
|
||||
|
||||
@@ -193,6 +193,25 @@ fn hostile_encryption_info_errors_without_panicking() {
|
||||
assert!(decrypt(&fake, Some("pw")).is_err());
|
||||
}
|
||||
|
||||
/// The key a real-world Standard-encrypted package derives: Apache POI's test file
|
||||
/// `protected_passtika.xlsx` (Apache-2.0 test data), password "tika", written by Excel
|
||||
/// itself. The value was computed from [MS-OFFCRYPTO] §2.3.4.7 independently of this code —
|
||||
/// the file decrypts to a valid package under it and nothing else opens that file — so real
|
||||
/// Word 2007 documents stay pinned, not just spec-derived ones.
|
||||
#[test]
|
||||
fn standard_key_matches_a_real_world_package() {
|
||||
let salt: [u8; 16] = [0x19, 0xd0, 0xc7, 0xf3, 0x95, 0x28, 0x78, 0x43, 0x6a, 0x49, 0x49, 0xd7, 0xf0, 0xa2, 0xcf, 0x1e];
|
||||
const KEY: &str = "8d4d00143a5f2e4918af7e6e7410d46c";
|
||||
let hex = |b: &[u8]| b.iter().map(|x| format!("{x:02x}")).collect::<String>();
|
||||
assert_eq!(hex(&standard_key(&salt, "tika", 16)), KEY);
|
||||
// A different password or salt gives a different key; the key is exactly the length
|
||||
// asked for, for each AES width.
|
||||
assert_ne!(hex(&standard_key(&salt, "tika2", 16)), KEY);
|
||||
assert_ne!(hex(&standard_key(&[0; 16], "tika", 16)), KEY);
|
||||
assert_eq!(standard_key(&salt, "tika", 24).len(), 24);
|
||||
assert_eq!(standard_key(&salt, "tika", 32).len(), 32);
|
||||
}
|
||||
|
||||
/// Standard encryption's key derivation (§2.3.4.7) for the password "Password1234_" and the
|
||||
/// salt 00 01 … 0F. The expected keys were computed outside WordCraft (Python's hashlib)
|
||||
/// following the specification's steps, so the encryptor and decryptor can't share a mistake.
|
||||
|
||||
@@ -25,11 +25,11 @@ pub fn read(root: &El) -> Vec<CustomProp> {
|
||||
let Some(v) = p.els().next() else { continue };
|
||||
let kind = v.local();
|
||||
let prop = if SIMPLE.contains(&kind) {
|
||||
CustomProp { name: name.to_string(), kind: kind.to_string(), value: v.text() }
|
||||
CustomProp { name: name.to_string(), kind: kind.to_string(), value: v.text(), link_target: p.attr("linkTarget").map(str::to_string) }
|
||||
} else {
|
||||
let mut xml = String::new();
|
||||
el_xml(v, &mut xml, 0);
|
||||
CustomProp { name: name.to_string(), kind: "raw".into(), value: xml }
|
||||
CustomProp { name: name.to_string(), kind: "raw".into(), value: xml, link_target: p.attr("linkTarget").map(str::to_string) }
|
||||
};
|
||||
out.push(prop);
|
||||
}
|
||||
@@ -51,7 +51,12 @@ pub fn write(props: &[CustomProp]) -> Vec<u8> {
|
||||
if p.kind == "raw" && !raw_is_wellformed(&p.value) {
|
||||
continue;
|
||||
}
|
||||
w.open("property", &[("fmtid", FMTID), ("pid", &pid.to_string()), ("name", &p.name)]);
|
||||
let pid = pid.to_string();
|
||||
let mut attrs = vec![("fmtid", FMTID), ("pid", &pid), ("name", &p.name)];
|
||||
if let Some(target) = &p.link_target {
|
||||
attrs.push(("linkTarget", target));
|
||||
}
|
||||
w.open("property", &attrs);
|
||||
if p.kind == "raw" {
|
||||
w.raw(&p.value);
|
||||
} else {
|
||||
|
||||
@@ -210,18 +210,20 @@ pub(crate) fn read_opened(pkg: &Package) -> Result<Document, DocxError> {
|
||||
if let Some(p) = part(rt::SETTINGS) {
|
||||
lenient("settings", r.read_settings(&p));
|
||||
}
|
||||
if let Some(p) = part(rt::FOOTNOTES) {
|
||||
lenient("footnotes", r.read_notes(&p, true));
|
||||
}
|
||||
if let Some(p) = part(rt::ENDNOTES) {
|
||||
lenient("endnotes", r.read_notes(&p, false));
|
||||
}
|
||||
// Comments before notes: a comment range inside a footnote or endnote resolves its id against
|
||||
// the comments already read, and was dropped when the notes came first.
|
||||
if let Some(p) = part(rt::COMMENTS) {
|
||||
let ex = part(rt::COMMENTS_EX);
|
||||
let ids = part(rt::COMMENTS_IDS);
|
||||
let cex = part(rt::COMMENTS_EXTENSIBLE);
|
||||
lenient("comments", r.read_comments(&p, ex.as_deref(), ids.as_deref(), cex.as_deref()));
|
||||
}
|
||||
if let Some(p) = part(rt::FOOTNOTES) {
|
||||
lenient("footnotes", r.read_notes(&p, true));
|
||||
}
|
||||
if let Some(p) = part(rt::ENDNOTES) {
|
||||
lenient("endnotes", r.read_notes(&p, false));
|
||||
}
|
||||
// Bibliography sources live in a custom XML data store (`b:Sources`), related from the
|
||||
// package root or the main part; Word's own files sometimes leave the relationship out, so
|
||||
// the conventional `customXml/itemN.xml` names are tried too. Each part is read at most once.
|
||||
|
||||
@@ -23,8 +23,8 @@ fn text_frame(k: &El) -> wordcraft_doc::props::TextFrame {
|
||||
let word = |name: &str, allowed: &[&str], default: &str| k.attr(name).filter(|v| allowed.contains(v)).unwrap_or(default).to_string();
|
||||
let align = |name: &str, allowed: &[&str]| k.attr(name).filter(|v| allowed.contains(v)).map(str::to_string);
|
||||
wordcraft_doc::props::TextFrame {
|
||||
w: len("w:w").filter(|v| *v > 0.0),
|
||||
h: len("w:h").map(f32::abs).filter(|v| *v > 0.0),
|
||||
w: len("w:w").filter(|v| *v >= 0.0),
|
||||
h: len("w:h").map(f32::abs),
|
||||
h_exact: k.attr("w:hRule") == Some("exact"),
|
||||
x: len("w:x").unwrap_or(0.0),
|
||||
y: len("w:y").unwrap_or(0.0),
|
||||
@@ -258,6 +258,8 @@ impl PropCtx {
|
||||
"w:imprint" => c.engrave = Some(on_off(k)),
|
||||
"w:noProof" => c.no_proof = Some(on_off(k)),
|
||||
"w:vanish" => c.hidden = Some(on_off(k)),
|
||||
"w:webHidden" => c.web_hidden = Some(on_off(k)),
|
||||
"w:snapToGrid" => c.snap_to_grid = Some(on_off(k)),
|
||||
"w:rtl" => c.rtl = Some(on_off(k)),
|
||||
"w:color" => {
|
||||
c.color = match (self.theme_color(k, "w:themeColor", "w:themeTint", "w:themeShade"), k.attr("w:val")) {
|
||||
|
||||
@@ -42,6 +42,13 @@ pub(crate) fn ui_style_name(n: &str) -> String {
|
||||
n.to_string()
|
||||
}
|
||||
|
||||
/// The outline level (0 for Heading 1) of Word's built-in style named `heading 1` … `heading 9`.
|
||||
fn builtin_heading_level(name: &str) -> Option<u8> {
|
||||
let name = name.trim();
|
||||
let digit = name.get(..8).filter(|p| p.eq_ignore_ascii_case("heading ")).and(name.get(8..))?;
|
||||
digit.parse::<u8>().ok().filter(|d| digit.len() == 1 && (1..=9).contains(d)).map(|d| d - 1)
|
||||
}
|
||||
|
||||
impl Reader<'_> {
|
||||
pub(super) fn read_styles(&mut self, path: &str) -> Result<(), DocxError> {
|
||||
/// Largest `w:latentStyles` kept (Word's has ~380 exceptions, ~40 KB).
|
||||
@@ -115,7 +122,7 @@ impl Reader<'_> {
|
||||
priority: s.child_val("w:uiPriority").and_then(u32_of),
|
||||
quick: flag(s, "w:qFormat").unwrap_or(false),
|
||||
hidden: flag(s, "w:semiHidden").unwrap_or(false) || flag(s, "w:hidden").unwrap_or(false),
|
||||
builtin: !s.attr("w:customStyle").is_some_and(|v| v == "1" || v == "true"),
|
||||
builtin: !s.attr("w:customStyle").is_some_and(|v| matches!(v.trim(), "1" | "true" | "on")),
|
||||
locked: flag(s, "w:locked").unwrap_or(false),
|
||||
// Only where the writer couldn't tell from the id (it flags the English ids).
|
||||
default: s.attr("w:default").is_some_and(|v| v == "1" || v == "true")
|
||||
@@ -131,6 +138,16 @@ impl Reader<'_> {
|
||||
if let Some(p) = s.child("w:pPr") {
|
||||
st.para = self.pc.ppr(p).0;
|
||||
}
|
||||
// Word gives its built-in heading styles their outline level whether or not the file
|
||||
// writes `w:outlineLvl` (some producers leave it out): "heading 2" is level 2 in the
|
||||
// Navigation pane and the table of contents, whatever its id (#4696).
|
||||
if kind == StyleKind::Paragraph
|
||||
&& st.builtin
|
||||
&& st.para.outline_level.is_none()
|
||||
&& let Some(level) = s.child_val("w:name").and_then(builtin_heading_level)
|
||||
{
|
||||
st.para.outline_level = Some(level);
|
||||
}
|
||||
if let Some(rp) = s.child("w:rPr") {
|
||||
st.chr = self.pc.rpr(rp);
|
||||
}
|
||||
|
||||
@@ -121,10 +121,16 @@ pub(super) fn bibliography_xml(doc: &Document) -> Vec<u8> {
|
||||
w.open("b:Source", &[]);
|
||||
w.leaf("b:Tag", &[], &src.tag);
|
||||
w.leaf("b:SourceType", &[], to_source_type(&src.kind));
|
||||
// Author & Editor
|
||||
if !src.author.is_empty() || !src.editor.is_empty() {
|
||||
// Author & Editor. A role holds a name list or a corporate name, not both (ECMA-376
|
||||
// `CT_NameOrCorporateType`), so a corporate author takes the author's place (#4848).
|
||||
let corporate = src.corporate.trim();
|
||||
if !src.author.is_empty() || !corporate.is_empty() || !src.editor.is_empty() {
|
||||
w.open("b:Author", &[]);
|
||||
if !src.author.is_empty() {
|
||||
if !corporate.is_empty() {
|
||||
w.open("b:Author", &[]);
|
||||
w.leaf("b:Corporate", &[], corporate);
|
||||
w.close("b:Author");
|
||||
} else if !src.author.is_empty() {
|
||||
w.open("b:Author", &[]);
|
||||
write_name_list(&mut w, &src.author);
|
||||
w.close("b:Author");
|
||||
|
||||
@@ -429,13 +429,15 @@ fn write_comments(
|
||||
("xmlns:mc", "http://schemas.openxmlformats.org/markup-compatibility/2006"),
|
||||
],
|
||||
);
|
||||
let pid_of = |cid: u32| comment_paras.iter().find(|(c, _)| *c == cid).map(|(_, p)| p.clone());
|
||||
// Assigned in BTreeMap comment-ID order above, including gaps for missing parts.
|
||||
let pid_of =
|
||||
|cid: u32| comment_paras.binary_search_by_key(&cid, |(c, _)| *c).ok().and_then(|i| comment_paras.get(i)).map(|(_, p)| p.as_str());
|
||||
for (cid, c) in &doc.comments {
|
||||
let Some(pid) = pid_of(*cid) else { continue };
|
||||
let done = if c.resolved { "1" } else { "0" };
|
||||
match c.parent.and_then(pid_of) {
|
||||
Some(parent) => w.empty("w15:commentEx", &[("w15:paraId", &pid), ("w15:paraIdParent", &parent), ("w15:done", done)]),
|
||||
None => w.empty("w15:commentEx", &[("w15:paraId", &pid), ("w15:done", done)]),
|
||||
Some(parent) => w.empty("w15:commentEx", &[("w15:paraId", pid), ("w15:paraIdParent", parent), ("w15:done", done)]),
|
||||
None => w.empty("w15:commentEx", &[("w15:paraId", pid), ("w15:done", done)]),
|
||||
}
|
||||
}
|
||||
w.close("w15:commentsEx");
|
||||
|
||||
@@ -53,7 +53,9 @@ pub fn rpr_inner(w: &mut W, c: &CharProps) {
|
||||
toggle(w, "w:emboss", c.emboss);
|
||||
toggle(w, "w:imprint", c.engrave);
|
||||
toggle(w, "w:noProof", c.no_proof);
|
||||
toggle(w, "w:snapToGrid", c.snap_to_grid);
|
||||
toggle(w, "w:vanish", c.hidden);
|
||||
toggle(w, "w:webHidden", c.web_hidden);
|
||||
match c.color {
|
||||
Some(TextColor::Auto) => w.val("w:color", "auto"),
|
||||
Some(TextColor::Rgb(rgb)) => w.val("w:color", &rgb.hex()),
|
||||
|
||||
@@ -172,6 +172,8 @@ pub(super) fn write_comment_ids(
|
||||
if comment_paras.is_empty() {
|
||||
return;
|
||||
}
|
||||
// Keep the existing exact-string reservation rule, including comments without a written part.
|
||||
let reserved: std::collections::HashSet<&str> = doc.comments.values().filter_map(|c| c.durable_id.as_deref()).collect();
|
||||
let mut used: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
let mut next = 0x1000_0000u32;
|
||||
let mut rows: Vec<(String, String, Option<String>)> = Vec::new();
|
||||
@@ -183,7 +185,7 @@ pub(super) fn write_comment_ids(
|
||||
None => loop {
|
||||
next = next.saturating_add(1);
|
||||
let d = format!("{next:08X}");
|
||||
if !used.contains(&d) && !doc.comments.values().any(|o| o.durable_id.as_deref() == Some(d.as_str())) {
|
||||
if !used.contains(&d) && !reserved.contains(d.as_str()) {
|
||||
break d;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -155,6 +155,38 @@ fn zotero_fields_and_prefs_round_trip() {
|
||||
assert_eq!(read(&write(&d2).unwrap()).unwrap().custom_props, d.custom_props);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_property_bookmark_links_survive_saves_and_value_edits() {
|
||||
let props = r#"<c:Properties xmlns:c="http://schemas.openxmlformats.org/officeDocument/2006/custom-properties" xmlns:v="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes">
|
||||
<c:property fmtid="{D5CDD505-2E9C-101B-9397-08002B2CF9AE}" pid="2" name="Linked" linkTarget="OriginalBookmark"><v:lpwstr>Original value</v:lpwstr></c:property>
|
||||
<c:property fmtid="{D5CDD505-2E9C-101B-9397-08002B2CF9AE}" pid="3" name="Raw" linkTarget="RawBookmark"><v:vector size="1" baseType="lpwstr"><v:lpwstr>Ω</v:lpwstr></v:vector></c:property>
|
||||
<c:property fmtid="{D5CDD505-2E9C-101B-9397-08002B2CF9AE}" pid="4" name="Unlinked"><v:i4>17</v:i4></c:property>
|
||||
</c:Properties>"#;
|
||||
let doc = format!(
|
||||
r#"<w:document {W_NS}><w:body><w:p><w:bookmarkStart w:id="1" w:name="OriginalBookmark"/>{}<w:bookmarkEnd w:id="1"/></w:p><w:p><w:bookmarkStart w:id="2" w:name="RawBookmark"/>{}<w:bookmarkEnd w:id="2"/></w:p></w:body></w:document>"#,
|
||||
t("Original value"),
|
||||
t("Ω")
|
||||
);
|
||||
let bytes = zip(&[("_rels/.rels", ROOT_RELS.as_bytes()), ("word/document.xml", doc.as_bytes()), ("docProps/custom.xml", props.as_bytes())]);
|
||||
let mut d = read(&bytes).unwrap();
|
||||
for value in ["Original value", "Edited value"] {
|
||||
d.set_custom_prop("linked", value);
|
||||
let before = d.custom_props.clone();
|
||||
for _ in 0..2 {
|
||||
let out = write(&d).unwrap();
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(&out)).unwrap();
|
||||
let mut xml = String::new();
|
||||
std::io::Read::read_to_string(&mut z.by_name("docProps/custom.xml").unwrap(), &mut xml).unwrap();
|
||||
assert!(xml.contains(r#"linkTarget="OriginalBookmark""#), "{xml}");
|
||||
assert!(xml.contains(r#"linkTarget="RawBookmark""#), "{xml}");
|
||||
assert_eq!(xml.matches("linkTarget=").count(), 2, "unlinked properties stay unlinked");
|
||||
d = read(&out).unwrap();
|
||||
assert_eq!(d.custom_props, before);
|
||||
assert_eq!(d.custom_prop("Linked"), Some(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unterminated_and_resultless_addin_fields() {
|
||||
// A field left open at the end of the document, and one with no result at all.
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
//! Saving large comment threads must not search all comments for each emitted ID.
|
||||
|
||||
use wordcraft_doc::{Comment, Document, Paragraph, Part, PartKind, para_block};
|
||||
|
||||
fn comments(n: u32, imported: bool) -> Document {
|
||||
let mut d = Document::from_text("Original comment save fixture.");
|
||||
for i in 0..n {
|
||||
// Sparse IDs and replies to a later comment also exercise paragraph-ID lookup order.
|
||||
let id = i * 3 + 7;
|
||||
d.parts.insert(id, Part { kind: PartKind::Comment, blocks: vec![para_block(Paragraph::new())], ..Default::default() });
|
||||
d.comments.insert(
|
||||
id,
|
||||
Comment { part: id, parent: (i + 1 < n).then_some(id + 3), durable_id: imported.then(|| format!("{:08X}", i + 1)), ..Default::default() },
|
||||
);
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
fn cpu_cost(mut f: impl FnMut()) -> f64 {
|
||||
let ran = || std::fs::read_to_string("/proc/thread-self/schedstat").ok()?.split_whitespace().next()?.parse::<u64>().ok();
|
||||
let (wall, before) = (std::time::Instant::now(), ran());
|
||||
f();
|
||||
match (before, ran()) {
|
||||
(Some(a), Some(b)) if b > a => (b - a) as f64 * 1e-9,
|
||||
_ => wall.elapsed().as_secs_f64(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_comments_save_without_quadratic_id_searches() {
|
||||
for imported in [true, false] {
|
||||
let small = comments(4_000, imported);
|
||||
let big = comments(32_000, imported);
|
||||
let save = |d: &Document| drop(std::hint::black_box(wordcraft_docx::write(d).unwrap()));
|
||||
save(&small);
|
||||
save(&big);
|
||||
let mut ratios = Vec::new();
|
||||
// Alternating inputs and thread CPU time leave scheduler delays out on Linux. Elsewhere
|
||||
// use elapsed time; retry ratios far from linear before declaring a regression.
|
||||
for _ in 0..3 {
|
||||
let (mut s, mut b) = (f64::INFINITY, f64::INFINITY);
|
||||
for _ in 0..3 {
|
||||
s = s.min(cpu_cost(|| (0..8).for_each(|_| save(&small))) / 8.0);
|
||||
b = b.min(cpu_cost(|| save(&big)));
|
||||
}
|
||||
let ratio = b / s.max(1e-9);
|
||||
eprintln!("comments imported={imported}: {s:.4}s -> {b:.4}s, ratio {ratio:.2}");
|
||||
if ratio < 24.0 {
|
||||
break;
|
||||
}
|
||||
ratios.push(ratio);
|
||||
}
|
||||
assert!(ratios.len() < 3, "8x comments took {ratios:?}x CPU: repeated ID searches");
|
||||
}
|
||||
}
|
||||
@@ -114,6 +114,68 @@ fn paragraph_default_presence_survives_repeated_saves() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_frame_keeps_normal_flow() {
|
||||
const ROUND_TRIPS: usize = 2;
|
||||
let zero = r#"<w:framePr w:w="0" w:h="0" w:xAlign="left" w:y="0" w:hRule="exact" w:vAnchor="margin"/>"#;
|
||||
let sized = r#"<w:framePr w:w="2400" w:h="1200" w:x="1440" w:y="2880" w:hAnchor="page" w:vAnchor="page" w:hRule="exact"/>"#;
|
||||
for frame in [zero, sized] {
|
||||
let styles = format!(
|
||||
r#"<w:styles {W_NS}><w:docDefaults><w:pPrDefault><w:pPr>{frame}<w:spacing w:after="160"/></w:pPr></w:pPrDefault></w:docDefaults>
|
||||
<w:style w:type="paragraph" w:styleId="Framed"><w:name w:val="Framed"/><w:pPr>{sized}</w:pPr></w:style></w:styles>"#
|
||||
);
|
||||
let header = format!(r#"<w:hdr {W_NS}><w:p><w:r><w:t>Header alpha</w:t></w:r></w:p><w:p><w:r><w:t>Header beta</w:t></w:r></w:p></w:hdr>"#);
|
||||
let footer = format!(r#"<w:ftr {W_NS}><w:p><w:r><w:t>Footer delta</w:t></w:r></w:p></w:ftr>"#);
|
||||
let body = format!(
|
||||
r#"<w:p><w:r><w:t>Body one</w:t></w:r></w:p>
|
||||
<w:p><w:pPr>{sized}</w:pPr><w:r><w:t>Direct frame</w:t></w:r></w:p>
|
||||
<w:p><w:pPr><w:pStyle w:val="Framed"/></w:pPr><w:r><w:t>Styled frame</w:t></w:r></w:p>
|
||||
<w:p><w:pPr><w:pStyle w:val="Framed"/>{zero}</w:pPr><w:r><w:t>Frame reset</w:t></w:r></w:p>
|
||||
<w:sectPr><w:headerReference w:type="default" r:id="header"/><w:footerReference w:type="default" r:id="footer"/></w:sectPr>"#
|
||||
);
|
||||
let mut document = wordcraft_docx::read(&docx(
|
||||
&body,
|
||||
&[("styles", "styles", "styles.xml"), ("header", "header", "header1.xml"), ("footer", "footer", "footer1.xml")],
|
||||
&[("word/styles.xml", &styles), ("word/header1.xml", &header), ("word/footer1.xml", &footer)],
|
||||
))
|
||||
.unwrap();
|
||||
for pass in 0..=ROUND_TRIPS {
|
||||
let body = paras(&document);
|
||||
let resolved = document.styles.resolve_para(&body[0].props);
|
||||
assert!(resolved.frame.is_none(), "document defaults never create a frame");
|
||||
assert!(document.styles.default_para.frame.is_none());
|
||||
let reset = body[3].props.frame.as_ref().unwrap();
|
||||
assert_eq!((reset.w, reset.h), (Some(0.0), Some(0.0)), "the direct reset survives saving");
|
||||
assert_eq!(resolved.space_after, 8.0);
|
||||
assert!(document.styles.resolve_para(&body[1].props).frame.is_some());
|
||||
assert!(document.styles.resolve_para(&body[2].props).frame.is_some());
|
||||
assert!(document.styles.resolve_para(&body[3].props).frame.is_none());
|
||||
for id in [document.last_section.headers.default, document.last_section.footers.default] {
|
||||
for block in &document.parts.get(&id.unwrap()).unwrap().blocks {
|
||||
let paragraph = block.as_para().unwrap();
|
||||
assert!(document.styles.resolve_para(¶graph.props).frame.is_none(), "defaults do not frame headers or footers");
|
||||
}
|
||||
}
|
||||
if pass == ROUND_TRIPS {
|
||||
break;
|
||||
}
|
||||
document = wordcraft_docx::read(&wordcraft_docx::write(&document).unwrap()).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_zero_side_is_automatic() {
|
||||
for (attrs, width, height) in
|
||||
[(r#"w:w="0" w:h="1200""#, None, Some(60.0)), (r#"w:w="2400" w:h="0" w:hRule="exact""#, Some(120.0), None), ("", None, None)]
|
||||
{
|
||||
let document = read_body(&format!(r#"<w:p><w:pPr><w:framePr {attrs}/></w:pPr><w:r><w:t>Original automatic frame</w:t></w:r></w:p>"#));
|
||||
let paragraph = paras(&document)[0];
|
||||
let frame = document.styles.resolve_para(¶graph.props).frame.unwrap();
|
||||
assert_eq!((frame.w, frame.h), (width, height));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omitted_paragraph_defaults_can_be_edited() {
|
||||
let styles =
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
//! #4696: Word's built-in heading styles have their outline level even when the file's
|
||||
//! `styles.xml` doesn't write `w:outlineLvl` (some producers leave it out). Word for Windows,
|
||||
//! opening such a file, reports `OutlineLevel` 1 and 2 for "heading 1" and "heading 2" (whatever
|
||||
//! their style ids) and body text for a custom style.
|
||||
use std::io::Write;
|
||||
use wordcraft_doc::Paragraph;
|
||||
|
||||
const W: &str = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
|
||||
|
||||
fn package(styles: &str, 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 rels = 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/styles" Target="styles.xml"/></Relationships>"#;
|
||||
let doc = format!(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="{W}"><w:body>{body}</w:body></w:document>"#);
|
||||
let styles = format!(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:styles xmlns:w="{W}">{styles}</w:styles>"#);
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (name, body) in
|
||||
[("_rels/.rels", root), ("word/document.xml", doc.as_str()), ("word/_rels/document.xml.rels", rels), ("word/styles.xml", styles.as_str())]
|
||||
{
|
||||
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zw.write_all(body.as_bytes()).unwrap();
|
||||
}
|
||||
zw.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
fn para(style: &str, text: &str) -> String {
|
||||
format!(r#"<w:p><w:pPr><w:pStyle w:val="{style}"/></w:pPr><w:r><w:t>{text}</w:t></w:r></w:p>"#)
|
||||
}
|
||||
|
||||
fn levels(d: &wordcraft_doc::Document) -> Vec<Option<u8>> {
|
||||
d.body.iter().filter_map(|b| b.as_para()).map(|p: &Paragraph| d.styles.resolve_para(&p.props).outline_level).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn built_in_heading_styles_have_their_outline_level_without_outline_lvl() {
|
||||
let styles = r#"<w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/></w:style>
|
||||
<w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="heading 1"/><w:basedOn w:val="Normal"/></w:style>
|
||||
<w:style w:type="paragraph" w:styleId="Titolo2"><w:name w:val="heading 2"/><w:basedOn w:val="Normal"/></w:style>
|
||||
<w:style w:type="paragraph" w:styleId="Heading9"><w:name w:val="Heading 9"/></w:style>
|
||||
<w:style w:type="paragraph" w:styleId="Below"><w:name w:val="Below heading 2"/><w:basedOn w:val="Titolo2"/></w:style>
|
||||
<w:style w:type="paragraph" w:customStyle="1" w:styleId="MyHead"><w:name w:val="My heading 3"/><w:basedOn w:val="Normal"/></w:style>
|
||||
<w:style w:type="paragraph" w:styleId="Heading10"><w:name w:val="heading 10"/></w:style>
|
||||
<w:style w:type="paragraph" w:customStyle="on" w:styleId="Fake4"><w:name w:val="heading 4"/></w:style>
|
||||
<w:style w:type="paragraph" w:styleId="Heading3"><w:name w:val="heading 3"/><w:pPr><w:outlineLvl w:val="5"/></w:pPr></w:style>"#;
|
||||
let body = ["Heading1", "Titolo2", "Heading9", "Below", "MyHead", "Heading10", "Fake4", "Heading3", "Normal"]
|
||||
.iter()
|
||||
.map(|s| para(s, "x"))
|
||||
.collect::<String>();
|
||||
let d = wordcraft_docx::read(&package(styles, &body)).unwrap();
|
||||
// Heading 1, 2 and 9 by name; a style based on a heading inherits its level; a custom style,
|
||||
// "heading 10", a custom style named "heading 4" (`w:customStyle="on"`) and Normal have none; a
|
||||
// level the file gives is kept.
|
||||
let want = vec![Some(0), Some(1), Some(8), Some(1), None, None, None, Some(5), None];
|
||||
assert_eq!(levels(&d), want);
|
||||
// The level survives a save and reopen.
|
||||
let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||||
assert_eq!(levels(&again), want);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ mod bidi_runs;
|
||||
mod cell_margin_inheritance;
|
||||
mod char_units;
|
||||
mod citations;
|
||||
mod comment_save_cost;
|
||||
mod complex_script_inheritance;
|
||||
mod content_control_save_cost;
|
||||
mod content_controls;
|
||||
@@ -13,14 +14,17 @@ mod east_asian_lang;
|
||||
mod east_asian_layout;
|
||||
mod fixtures;
|
||||
mod glossary;
|
||||
mod heading_levels;
|
||||
mod legacy_forms;
|
||||
mod ligatures;
|
||||
mod malformed;
|
||||
mod note_comments;
|
||||
mod passthrough;
|
||||
mod positional_tabs;
|
||||
mod privacy;
|
||||
mod roundtrip;
|
||||
mod row_property_exceptions;
|
||||
mod run_props;
|
||||
mod section_form_protection;
|
||||
mod section_page_number_type;
|
||||
mod section_text_direction;
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
//! Comments anchored inside footnotes and endnotes keep their ranges on read. All markup here is
|
||||
//! synthetic, written from ECMA-376 §17.13.4 and §17.11.
|
||||
|
||||
use wordcraft_doc::para::{NoteKind, OBJ};
|
||||
use wordcraft_doc::{Document, InlineObject};
|
||||
|
||||
use crate::fixtures::zip;
|
||||
|
||||
const W_NS: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships""#;
|
||||
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>"#;
|
||||
const DOC_RELS: &str = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/footnotes" Target="footnotes.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/endnotes" Target="endnotes.xml"/><Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments" Target="comments.xml"/></Relationships>"#;
|
||||
|
||||
fn note(tag: &str, mark: &str, comment: u32, text: &str) -> String {
|
||||
format!(
|
||||
r#"<w:{tag} w:id="1"><w:p><w:r><w:{mark}/></w:r><w:commentRangeStart w:id="{comment}"/><w:r><w:t xml:space="preserve"> {text}</w:t></w:r><w:commentRangeEnd w:id="{comment}"/><w:r><w:commentReference w:id="{comment}"/></w:r></w:p></w:{tag}>"#
|
||||
)
|
||||
}
|
||||
|
||||
fn read_doc() -> Document {
|
||||
let doc = format!(
|
||||
r#"<w:document {W_NS}><w:body><w:p><w:r><w:t>Body</w:t></w:r><w:r><w:footnoteReference w:id="1"/></w:r><w:r><w:endnoteReference w:id="1"/></w:r></w:p></w:body></w:document>"#
|
||||
);
|
||||
let footnotes = format!(r#"<w:footnotes {W_NS}>{}</w:footnotes>"#, note("footnote", "footnoteRef", 7, "Foot."));
|
||||
let endnotes = format!(r#"<w:endnotes {W_NS}>{}</w:endnotes>"#, note("endnote", "endnoteRef", 8, "End."));
|
||||
let comments = format!(
|
||||
r#"<w:comments {W_NS}><w:comment w:id="7" w:author="Ann" w:initials="A"><w:p><w:r><w:t>On the footnote</w:t></w:r></w:p></w:comment><w:comment w:id="8" w:author="Bob" w:initials="B"><w:p><w:r><w:t>On the endnote</w:t></w:r></w:p></w:comment></w:comments>"#
|
||||
);
|
||||
let bytes = zip(&[
|
||||
("_rels/.rels", ROOT_RELS.as_bytes()),
|
||||
("word/document.xml", doc.as_bytes()),
|
||||
("word/_rels/document.xml.rels", DOC_RELS.as_bytes()),
|
||||
("word/footnotes.xml", footnotes.as_bytes()),
|
||||
("word/endnotes.xml", endnotes.as_bytes()),
|
||||
("word/comments.xml", comments.as_bytes()),
|
||||
]);
|
||||
wordcraft_docx::read(&bytes).unwrap()
|
||||
}
|
||||
|
||||
/// comments.xml has to be read before the notes: a note's `w:commentRangeStart` resolves its id
|
||||
/// against the comments already read, and was dropped when the notes came first.
|
||||
#[test]
|
||||
fn comment_ranges_inside_notes_are_kept() {
|
||||
let d = read_doc();
|
||||
let body = d.body[0].as_para().unwrap();
|
||||
let refs: Vec<(NoteKind, u32)> = body
|
||||
.objects
|
||||
.iter()
|
||||
.filter_map(|o| match o {
|
||||
InlineObject::NoteRef { kind, id, .. } => Some((*kind, *id)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(refs.len(), 2, "{:?}", body.objects);
|
||||
for ((kind, id), (author, text)) in refs.into_iter().zip([("Ann", " Foot."), ("Bob", " End.")]) {
|
||||
let p = d.parts.get(&id).unwrap().blocks[0].as_para().unwrap();
|
||||
let comment = match &p.objects[..] {
|
||||
[InlineObject::NoteRef { kind: k, id: n, .. }, InlineObject::CommentStart { id: c }, InlineObject::CommentEnd { id: e }]
|
||||
if *k == kind && *n == id && c == e =>
|
||||
{
|
||||
*c
|
||||
}
|
||||
o => panic!("{kind:?} {id}: {o:?}"),
|
||||
};
|
||||
assert_eq!(d.comments.get(&comment).unwrap().author, author);
|
||||
assert_eq!(p.text.chars().filter(|c| *c == OBJ).count(), 3);
|
||||
assert_eq!(p.plain_text(), text);
|
||||
}
|
||||
}
|
||||
@@ -443,3 +443,51 @@ fn embedded_fonts_load_and_save_when_the_package_spells_the_font_table_in_capita
|
||||
assert_eq!(fonts.len(), 1, "the font survives a save: {:?}", files.keys());
|
||||
assert_eq!(fonts[0].data, font_bytes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comment_ids_keep_sparse_threads_reservations_and_missing_parts() {
|
||||
use wordcraft_doc::{Comment, Document, Paragraph, Part, PartKind, para_block};
|
||||
let mut doc = Document::from_text("Original thread fixture.");
|
||||
// New IDs must skip the exact durable ID reserved by a later comment, even one whose
|
||||
// missing part keeps it out of commentsIds.xml. Duplicate imported IDs are reassigned.
|
||||
let rows = [
|
||||
(3, None, Some(90), false, true),
|
||||
(11, Some("5a1b2c3d"), Some(90), true, true),
|
||||
(25, Some("5A1B2C3D"), Some(999), false, true),
|
||||
(90, Some("10000001"), None, true, true),
|
||||
(999, Some("10000002"), None, false, false),
|
||||
(u32::MAX, Some("not-hex"), Some(3), false, true),
|
||||
];
|
||||
for (id, durable, parent, resolved, has_part) in rows {
|
||||
let part = id;
|
||||
if has_part {
|
||||
doc.parts.insert(
|
||||
part,
|
||||
Part {
|
||||
kind: PartKind::Comment,
|
||||
blocks: vec![para_block(Paragraph::with_text(&format!("Comment {id} β"), Default::default()))],
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
doc.comments.insert(id, Comment { part, parent, resolved, durable_id: durable.map(str::to_string), ..Default::default() });
|
||||
}
|
||||
let files = save(&doc);
|
||||
let (ids, _) = written_ids(&files);
|
||||
assert_eq!(ids.iter().map(|(_, id)| id.as_str()).collect::<Vec<_>>(), ["10000003", "5A1B2C3D", "10000004", "10000001", "10000005"]);
|
||||
let ex = text(&files, "word/commentsExtended.xml");
|
||||
assert_eq!(ex.matches("<w15:commentEx ").count(), 5);
|
||||
assert!(ex.contains(r#"w15:paraId="10000001" w15:paraIdParent="10000004" w15:done="0""#));
|
||||
assert!(ex.contains(r#"w15:paraId="10000002" w15:paraIdParent="10000004" w15:done="1""#));
|
||||
assert!(ex.contains(r#"w15:paraId="10000003" w15:done="0""#), "a missing parent part has no paragraph ID");
|
||||
assert!(ex.contains(r#"w15:paraId="10000006" w15:paraIdParent="10000001" w15:done="0""#));
|
||||
let back = wordcraft_docx::read(&wordcraft_docx::write(&doc).unwrap()).unwrap();
|
||||
// Reader IDs need not match the sparse input IDs. Check the thread by authored text.
|
||||
let comment = |text: &str| back.comments.iter().find(|(_, c)| back.plain_text(wordcraft_doc::StoryRef::Part(c.part)).trim() == text).unwrap();
|
||||
let (root, _) = comment("Comment 90 β");
|
||||
assert_eq!(comment("Comment 3 β").1.parent, Some(*root));
|
||||
assert_eq!(comment("Comment 11 β").1.parent, Some(*root));
|
||||
assert!(comment("Comment 11 β").1.resolved);
|
||||
let (first, _) = comment("Comment 3 β");
|
||||
assert_eq!(comment(&format!("Comment {} β", u32::MAX)).1.parent, Some(*first));
|
||||
}
|
||||
|
||||
@@ -89,6 +89,8 @@ fn every_char_prop_round_trips() {
|
||||
caps: Some(true),
|
||||
small_caps: Some(false),
|
||||
hidden: Some(true),
|
||||
web_hidden: Some(true),
|
||||
snap_to_grid: Some(false),
|
||||
spacing: Some(1.5),
|
||||
scale: Some(150.0),
|
||||
position: Some(3.0),
|
||||
@@ -2267,6 +2269,39 @@ fn bibliography_sources_round_trip() {
|
||||
assert_bibliography_sources_round_trip(&bytes);
|
||||
}
|
||||
|
||||
/// #4848: a corporate author saves as Word's `b:Corporate`, in place of a name list (a role holds
|
||||
/// one or the other), and reads back whole.
|
||||
#[test]
|
||||
fn a_corporate_author_round_trips() {
|
||||
let mut d = Document::new();
|
||||
d.sources.push(Source {
|
||||
tag: "Wor20".into(),
|
||||
kind: "report".into(),
|
||||
corporate: "World Health Organization".into(),
|
||||
title: "World report on hearing".into(),
|
||||
year: "2020".into(),
|
||||
editor: "Chadha, Shelly".into(),
|
||||
..Default::default()
|
||||
});
|
||||
d.sources.push(Source {
|
||||
tag: "Both".into(),
|
||||
author: "Rivera, Alex".into(),
|
||||
corporate: "Harbor Guild, Inc.".into(),
|
||||
title: "Shared Spaces".into(),
|
||||
..Default::default()
|
||||
});
|
||||
let bytes = wordcraft_docx::write(&d).expect("write");
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes[..])).unwrap();
|
||||
let mut item_xml = String::new();
|
||||
std::io::Read::read_to_string(&mut zip.by_name("customXml/item1.xml").unwrap(), &mut item_xml).unwrap();
|
||||
assert!(item_xml.contains("<b:Author><b:Author><b:Corporate>World Health Organization</b:Corporate></b:Author><b:Editor>"), "{item_xml}");
|
||||
assert!(item_xml.contains("<b:Corporate>Harbor Guild, Inc.</b:Corporate>"), "{item_xml}");
|
||||
assert!(!item_xml.contains("<b:Last>Rivera</b:Last>"), "the corporate author takes the person authors' place: {item_xml}");
|
||||
let back = wordcraft_docx::read(&bytes).expect("read");
|
||||
let got: Vec<(&str, &str, &str)> = back.sources.iter().map(|s| (s.corporate.as_str(), s.author.as_str(), s.editor.as_str())).collect();
|
||||
assert_eq!(got, [("World Health Organization", "", "Chadha, Shelly"), ("Harbor Guild, Inc.", "", "")]);
|
||||
}
|
||||
|
||||
fn assert_bibliography_parts(bytes: &[u8]) {
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
assert!(zip.by_name("customXml/item1.xml").is_ok());
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
//! Run and style properties that must survive repeated DOCX saves.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
|
||||
fn docx(body: &str, styles: &str) -> Vec<u8> {
|
||||
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 rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>"#;
|
||||
let document = format!(
|
||||
r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>{body}<w:sectPr/></w:body></w:document>"#
|
||||
);
|
||||
let styles = format!(r#"<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">{styles}</w:styles>"#);
|
||||
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (name, xml) in [
|
||||
("_rels/.rels", root.to_string()),
|
||||
("word/document.xml", document),
|
||||
("word/_rels/document.xml.rels", rels.to_string()),
|
||||
("word/styles.xml", styles),
|
||||
] {
|
||||
zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zip.write_all(xml.as_bytes()).unwrap();
|
||||
}
|
||||
zip.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
fn part(bytes: &[u8], name: &str) -> String {
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut xml = String::new();
|
||||
zip.by_name(name).unwrap().read_to_string(&mut xml).unwrap();
|
||||
xml
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_and_style_snap_to_grid_and_web_hidden_survive_two_saves() {
|
||||
let styles = r#"
|
||||
<w:style w:type="character" w:styleId="On"><w:rPr><w:snapToGrid/><w:webHidden/></w:rPr></w:style>
|
||||
<w:style w:type="character" w:styleId="Off"><w:rPr><w:snapToGrid w:val="0"/><w:webHidden w:val="0"/></w:rPr></w:style>
|
||||
"#;
|
||||
let body = r#"
|
||||
<w:p>
|
||||
<w:r><w:rPr><w:rStyle w:val="On"/></w:rPr><w:t>inherit</w:t></w:r>
|
||||
<w:r><w:rPr><w:snapToGrid/><w:webHidden/></w:rPr><w:t>direct true</w:t></w:r>
|
||||
<w:r><w:rPr><w:rStyle w:val="On"/><w:snapToGrid w:val="0"/><w:webHidden w:val="0"/></w:rPr><w:t>override</w:t></w:r>
|
||||
<w:r><w:rPr><w:rStyle w:val="Off"/><w:vanish/></w:rPr><w:t>visible in print</w:t></w:r>
|
||||
<w:r><w:t>absent</w:t></w:r>
|
||||
</w:p>
|
||||
"#;
|
||||
let mut doc = wordcraft_docx::read(&docx(body, styles)).unwrap();
|
||||
let p = doc.body.iter().find_map(|b| b.as_para()).unwrap();
|
||||
assert_eq!(p.runs[0].props.snap_to_grid, None);
|
||||
assert_eq!(p.runs[0].props.web_hidden, None);
|
||||
assert_eq!(p.runs[1].props.snap_to_grid, Some(true));
|
||||
assert_eq!(p.runs[1].props.web_hidden, Some(true));
|
||||
assert_eq!(p.runs[2].props.snap_to_grid, Some(false));
|
||||
assert_eq!(p.runs[2].props.web_hidden, Some(false));
|
||||
assert_eq!(p.runs[3].props.hidden, Some(true));
|
||||
assert_eq!(p.runs[3].props.web_hidden, None);
|
||||
assert_eq!(p.runs[4].props.snap_to_grid, None);
|
||||
assert_eq!(p.runs[4].props.web_hidden, None);
|
||||
assert!(!doc.styles.resolve_char(None, &p.runs[0].props).hidden, "w:webHidden does not hide Print Layout text");
|
||||
assert!(doc.styles.resolve_char(None, &p.runs[3].props).hidden, "w:vanish still controls hidden text");
|
||||
for pass in 0..2 {
|
||||
let out = wordcraft_docx::write(&doc).unwrap();
|
||||
let style_xml = part(&out, "word/styles.xml");
|
||||
assert!(style_xml.contains(r#"<w:snapToGrid/><w:webHidden/>"#), "pass {pass}: {style_xml}");
|
||||
assert!(style_xml.contains(r#"<w:snapToGrid w:val="0"/><w:webHidden w:val="0"/>"#), "pass {pass}: {style_xml}");
|
||||
let document_xml = part(&out, "word/document.xml");
|
||||
assert!(document_xml.contains(r#"<w:snapToGrid w:val="0"/>"#), "pass {pass}: {document_xml}");
|
||||
assert!(document_xml.contains(r#"<w:webHidden w:val="0"/>"#), "pass {pass}: {document_xml}");
|
||||
assert!(document_xml.contains("<w:snapToGrid/><w:webHidden/>"), "pass {pass}: {document_xml}");
|
||||
assert!(document_xml.contains("<w:vanish/>"), "pass {pass}: {document_xml}");
|
||||
assert!(document_xml.contains("visible in print"));
|
||||
doc = wordcraft_docx::read(&out).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::generated::page_of;
|
||||
use super::{CharProps, CmdError, CmdResult, InlineObject, Paragraph, Pos, Selection, Session, StoryRef, Value, delete_selection, json, p};
|
||||
use super::{CharProps, CmdError, CmdResult, Document, InlineObject, Paragraph, Pos, Selection, Session, StoryRef, Value, delete_selection, json, p};
|
||||
|
||||
pub(super) fn cross_ref(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let to = p::str(v, "to").unwrap_or("heading");
|
||||
@@ -77,13 +77,19 @@ pub(super) fn caption_label(p: &Paragraph, text: &str) -> Option<String> {
|
||||
(!label.is_empty() && label.chars().count() <= 40).then_some(label)
|
||||
}
|
||||
|
||||
/// Whether `p` is a caption: its style is Caption or one based on it, as a caption restyled with a
|
||||
/// style of its own (a `Table Caption` based on Caption) still is (#4411).
|
||||
pub(super) fn is_caption(doc: &Document, p: &Paragraph) -> bool {
|
||||
p.props.style.as_deref().is_some_and(|id| id == "Caption" || doc.styles.chain(id).iter().any(|s| s.id == "Caption"))
|
||||
}
|
||||
|
||||
/// The caption paragraphs of the body: (text, position, label).
|
||||
fn captions(s: &Session) -> Vec<(String, Pos, Option<String>)> {
|
||||
s.doc
|
||||
.para_paths(StoryRef::Body)
|
||||
.into_iter()
|
||||
.filter_map(|path| {
|
||||
let p = s.doc.para(StoryRef::Body, &path).filter(|p| p.props.style.as_deref() == Some("Caption"))?;
|
||||
let p = s.doc.para(StoryRef::Body, &path).filter(|p| is_caption(&s.doc, p))?;
|
||||
let text = p.plain_text();
|
||||
let label = caption_label(p, &text);
|
||||
Some((text, Pos { story: StoryRef::Body, path, off: 0 }, label))
|
||||
@@ -125,7 +131,7 @@ pub fn ref_targets(s: &Session, to: &str) -> Vec<(String, Pos)> {
|
||||
};
|
||||
for path in s.doc.para_paths(StoryRef::Body) {
|
||||
if let Some(p) = s.doc.para(StoryRef::Body, &path)
|
||||
&& p.props.style.as_deref() == Some("Caption")
|
||||
&& is_caption(&s.doc, p)
|
||||
&& p.plain_text().starts_with(label)
|
||||
{
|
||||
targets.push((p.plain_text(), Pos { story: StoryRef::Body, path: path.clone(), off: 0 }));
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
use super::cross_refs::caption_label;
|
||||
use super::cross_refs::{caption_label, is_caption};
|
||||
use super::render::entry;
|
||||
use super::sources::bib_order;
|
||||
use super::styles::style;
|
||||
use super::{
|
||||
Block, CharProps, CmdError, CmdResult, FieldCode, InlineObject, Paragraph, Path, Pos, Session, SortKey, StoryRef, TabAlign, TabLeader, TabStop,
|
||||
Value, delete_selection, heading_letter, p, sel_result, sort_key,
|
||||
Block, CharProps, CmdError, CmdResult, Document, FieldCode, InlineObject, Paragraph, Path, Pos, Session, SortKey, StoryRef, TabAlign, TabLeader,
|
||||
TabStop, Value, delete_selection, heading_letter, p, sel_result, sort_key,
|
||||
};
|
||||
|
||||
/// The TOC field for `references.tableOfFigures` (ECMA-376 §17.16.5.68): `\c "<label>"` lists the
|
||||
@@ -86,8 +86,8 @@ fn figures_label(instr: &str) -> Option<String> {
|
||||
|
||||
/// Whether `p` is a caption numbered by SEQ identifier `ident` (any case, as Word matches it). A
|
||||
/// caption typed by hand, without a SEQ field, goes by its label.
|
||||
fn captioned_as(p: &Paragraph, ident: &str) -> bool {
|
||||
if p.props.style.as_deref() != Some("Caption") {
|
||||
fn captioned_as(doc: &Document, p: &Paragraph, ident: &str) -> bool {
|
||||
if !is_caption(doc, p) {
|
||||
return false;
|
||||
}
|
||||
let seq = p.objects.iter().find_map(|o| match o {
|
||||
@@ -256,7 +256,7 @@ fn entries_for(s: &mut Session, kind: &str) -> Vec<(String, bool)> {
|
||||
for path in s.doc.para_paths(StoryRef::Body) {
|
||||
let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue };
|
||||
if let Some(ident) = &figures {
|
||||
if captioned_as(p, ident) {
|
||||
if captioned_as(&s.doc, p, ident) {
|
||||
let text = if bare { caption_body(p, &ident.replace('_', " ")) } else { p.plain_text() };
|
||||
marks.push((text, Pos { story: StoryRef::Body, path: path.clone(), off: 0 }));
|
||||
}
|
||||
@@ -280,7 +280,7 @@ fn entries_for(s: &mut Session, kind: &str) -> Vec<(String, bool)> {
|
||||
for path in s.doc.para_paths(StoryRef::Body) {
|
||||
let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue };
|
||||
if let Some(ident) = &figures {
|
||||
if captioned_as(p, ident) {
|
||||
if captioned_as(&s.doc, p, ident) {
|
||||
v.push(Pos { story: StoryRef::Body, path: path.clone(), off: 0 });
|
||||
}
|
||||
continue;
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
use super::Source;
|
||||
use super::styles::{Cite, Name, Names, style};
|
||||
|
||||
/// A source's corporate author, when it has one: written as it is in place of the person names
|
||||
/// (Word's Corporate Author, #4848).
|
||||
pub(super) fn corporate(src: &Source) -> Option<&str> {
|
||||
Some(src.corporate.trim()).filter(|c| !c.is_empty())
|
||||
}
|
||||
|
||||
pub(super) fn authors(a: &str) -> Vec<(String, String)> {
|
||||
a.split(';')
|
||||
.map(str::trim)
|
||||
@@ -110,12 +116,13 @@ pub fn cite(src: &Source, style_name: &str, n: usize, pages: &str) -> String {
|
||||
let st = style(style_name);
|
||||
let au = authors(&src.author);
|
||||
let lasts: Vec<&str> = au.iter().map(|a| a.0.as_str()).collect();
|
||||
let who = match lasts.as_slice() {
|
||||
[] => src.title.clone(),
|
||||
[one] => one.to_string(),
|
||||
[first, ..] if lasts.len() >= st.et_al_from.max(2) => format!("{first} et al."),
|
||||
[a, b] => format!("{a} {} {b}", st.and),
|
||||
[init @ .., z] => format!("{} {} {z}", init.join(", "), st.and),
|
||||
let who = match (corporate(src), lasts.as_slice()) {
|
||||
(Some(c), _) => c.to_string(),
|
||||
(None, []) => src.title.clone(),
|
||||
(None, [one]) => one.to_string(),
|
||||
(None, [first, ..]) if lasts.len() >= st.et_al_from.max(2) => format!("{first} et al."),
|
||||
(None, [a, b]) => format!("{a} {} {b}", st.and),
|
||||
(None, [init @ .., z]) => format!("{} {} {z}", init.join(", "), st.and),
|
||||
};
|
||||
match st.cite {
|
||||
Cite::AuthorPage if pages.is_empty() => format!("({who})"),
|
||||
@@ -148,7 +155,7 @@ pub fn entry(src: &Source, style_name: &str, n: usize) -> String {
|
||||
let field = |key: &str| -> String {
|
||||
match key {
|
||||
"n" => n.to_string(),
|
||||
"names" => name_list(&au, &st.names),
|
||||
"names" => corporate(src).map_or_else(|| name_list(&au, &st.names), str::to_string),
|
||||
"year" if src.year.is_empty() => "n.d.".into(),
|
||||
"year" => src.year.clone(),
|
||||
"title" => src.title.clone(),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::generated::update_generated;
|
||||
use super::render::{authors, cite};
|
||||
use super::render::{authors, cite, corporate};
|
||||
use super::styles::{CiteStyle, Order, style};
|
||||
use super::{CmdError, CmdResult, Document, InlineObject, Selection, Session, Source, StoryRef, Value, delete_selection, json, p, sort_key};
|
||||
|
||||
@@ -14,18 +14,19 @@ fn auto_tag(doc: &Document, src: &Source) -> String {
|
||||
(1u32..).map(|n| format!("{base}{n}")).find(|t| !taken(t)).unwrap_or(base)
|
||||
}
|
||||
|
||||
/// The author of a source for matching untagged entries: the editor when there is no author.
|
||||
/// The author of a source for matching untagged entries: the corporate author (which takes the
|
||||
/// author's place when a source is saved), else the author, else the editor.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn by_author(x: &Source) -> &str {
|
||||
if x.author.is_empty() { x.editor.as_str() } else { x.author.as_str() }
|
||||
[x.corporate.trim(), x.author.as_str(), x.editor.as_str()].into_iter().find(|w| !w.is_empty()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The stem of a new source's tag, before `1`, `2`… are added while it is taken: the first three
|
||||
/// alphanumerics of the first author's name (nothing of the author's, `Src`) and the last two
|
||||
/// digits of the year.
|
||||
/// alphanumerics of the first author's name or the corporate author (nothing of the author's,
|
||||
/// `Src`) and the last two digits of the year.
|
||||
fn auto_tag_base(src: &Source) -> String {
|
||||
let name: String =
|
||||
authors(&src.author).first().map(|x| x.0.clone()).unwrap_or_default().chars().filter(|c| c.is_alphanumeric()).take(3).collect();
|
||||
let first = corporate(src).map(str::to_string).or_else(|| authors(&src.author).first().map(|x| x.0.clone())).unwrap_or_default();
|
||||
let name: String = first.chars().filter(|c| c.is_alphanumeric()).take(3).collect();
|
||||
let name = if name.is_empty() { "Src".to_string() } else { name };
|
||||
let digits: Vec<char> = src.year.chars().filter(char::is_ascii_digit).collect();
|
||||
let year: String = digits.get(digits.len().saturating_sub(2)..).unwrap_or_default().iter().collect();
|
||||
@@ -184,8 +185,13 @@ fn cited_tags(doc: &Document) -> Vec<String> {
|
||||
out
|
||||
}
|
||||
|
||||
/// A source's place in an author-sorted bibliography: the corporate author, else the first
|
||||
/// author's surname, else the title.
|
||||
fn author_key(src: &Source) -> String {
|
||||
authors(&src.author).first().map(|a| a.0.to_lowercase()).unwrap_or_else(|| src.title.to_lowercase())
|
||||
corporate(src)
|
||||
.map(str::to_lowercase)
|
||||
.or_else(|| authors(&src.author).first().map(|a| a.0.to_lowercase()))
|
||||
.unwrap_or_else(|| src.title.to_lowercase())
|
||||
}
|
||||
|
||||
/// Indices into `doc.sources` in bibliography order: the cited sources (every source while
|
||||
|
||||
@@ -103,8 +103,10 @@ pub static STYLES: [CiteStyle; 12] = [
|
||||
et_al_from: 3,
|
||||
order: Order::Author,
|
||||
names: names(Name::LastInitials, Name::LastInitials, ", ", ", & ", ", & "),
|
||||
book: "⟦{names} ⟧({year}). {title}.⟦ {publisher}.⟧⟦ {url}⟧",
|
||||
article: "⟦{names} ⟧({year}). {title}. {journal}⟦, {volume}⟧⟦, {pages}⟧.⟦ {url}⟧",
|
||||
// The names end with a full stop: an initial's, or one added after a group author or a
|
||||
// name without initials ("World Health Organization. (2021).", #4848).
|
||||
book: "⟦{names}. ⟧({year}). {title}.⟦ {publisher}.⟧⟦ {url}⟧",
|
||||
article: "⟦{names}. ⟧({year}). {title}. {journal}⟦, {volume}⟧⟦, {pages}⟧.⟦ {url}⟧",
|
||||
bib_title: "References",
|
||||
},
|
||||
CiteStyle {
|
||||
|
||||
@@ -18,6 +18,49 @@ fn citations_and_bibliography() {
|
||||
assert!(s.doc.plain_text(StoryRef::Body).contains("[1]"));
|
||||
}
|
||||
|
||||
/// #4848: a corporate author is cited and listed as written, in every style: never turned into
|
||||
/// a surname and initials ("Organization, W. H."), never left out for the title. Its tag and its
|
||||
/// place in an author-sorted list come from the name too.
|
||||
#[test]
|
||||
fn a_corporate_author_is_cited_and_listed_as_written() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.run("text.insert", &json!({"text": "Hearing loss is common "})).unwrap();
|
||||
let who = "World Health Organization";
|
||||
s.run("references.citation", &json!({"source": {"kind": "report", "corporate": who, "title": "World report on hearing", "year": "2021", "publisher": "WHO Press", "city": "Geneva"}}))
|
||||
.unwrap();
|
||||
s.run("text.insert", &json!({"text": " and rising "})).unwrap();
|
||||
s.run("references.citation", &json!({"source": {"author": "Adams, Kim", "title": "Quiet Rooms", "year": "2019"}})).unwrap();
|
||||
s.run("text.newParagraph", &json!({})).unwrap();
|
||||
s.run("references.bibliography", &json!({})).unwrap();
|
||||
assert_eq!(s.doc.sources[0].tag, "Wor21", "the tag starts with the corporate name");
|
||||
let t = s.doc.plain_text(StoryRef::Body);
|
||||
assert!(t.contains(&format!("({who}, 2021)")), "{t}");
|
||||
assert!(t.contains(&format!("{who}. (2021). World report on hearing. WHO Press.")), "{t}");
|
||||
// Sorted by the corporate name: Adams before World.
|
||||
let adams = t.find("Adams, K. (2019)").unwrap_or(usize::MAX);
|
||||
let world = t.find(&format!("{who}. (2021)")).unwrap_or(0);
|
||||
assert!(adams < world, "{t}");
|
||||
for style in [
|
||||
"MLA",
|
||||
"Chicago",
|
||||
"GB/T 7714",
|
||||
"GOST (name sort)",
|
||||
"GOST (title sort)",
|
||||
"ISO 690 (author-date)",
|
||||
"ISO 690 (numerical)",
|
||||
"Harvard (Anglia)",
|
||||
"Turabian",
|
||||
"IEEE",
|
||||
"SIST02",
|
||||
] {
|
||||
s.run("references.citationStyle", &json!({"style": style})).unwrap();
|
||||
let t = s.doc.plain_text(StoryRef::Body);
|
||||
let entry = t.lines().find(|l| l.contains("World report on hearing")).unwrap_or_default();
|
||||
assert!(entry.contains(who), "{style}: {entry}");
|
||||
assert!(!entry.contains("Organization, W") && !entry.contains("ORGANIZATION, W"), "{style}: {entry}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Updating fields in a document without generated lists leaves its styles alone: a new
|
||||
/// style would make the next layout start over from nothing.
|
||||
#[test]
|
||||
@@ -162,6 +205,49 @@ fn caption_labels_do_not_need_a_digit() {
|
||||
assert!(s.doc.plain_text(StoryRef::Body).ends_with("See Photo Plate I"), "{}", s.doc.plain_text(StoryRef::Body));
|
||||
}
|
||||
|
||||
/// #4411: captions restyled with a style based on Caption, directly (`Caption Table`) or through
|
||||
/// another (`Caption Table Wide`), are still captions after a DOCX save and reopen: every `tableX`
|
||||
/// caption is a cross-reference target and in the table of figures, and a Table caption is a
|
||||
/// Table target. Captions in a style not based on Caption are not, nor are their labels.
|
||||
#[test]
|
||||
fn captions_in_a_style_based_on_caption() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("T1\nT2\nT3\nT4\nT5\nNote\nGrid\nStone\nSee "));
|
||||
let caption = |s: &mut Session, text: &str, label: &str, title: &str| {
|
||||
s.run("select.text", &json!({"text": text})).unwrap();
|
||||
s.run("references.caption", &json!({"label": label, "text": title})).unwrap();
|
||||
};
|
||||
for k in 1..=5 {
|
||||
caption(&mut s, &format!("T{k}"), "tableX", &format!("Row {k}"));
|
||||
}
|
||||
caption(&mut s, "Note", "tableX", "Aside");
|
||||
caption(&mut s, "Grid", "Table", "Grid");
|
||||
caption(&mut s, "Stone", "Plate", "Stone");
|
||||
s.run("styles.create", &json!({"name": "Caption Table", "basedOn": "Caption", "fromSelection": false})).unwrap();
|
||||
s.run("styles.create", &json!({"name": "Caption Table Wide", "basedOn": "Caption Table", "fromSelection": false})).unwrap();
|
||||
s.run("styles.create", &json!({"name": "Aside", "fromSelection": false})).unwrap();
|
||||
let style = |s: &Session, name: &str| s.doc.styles.find(name).map(|st| st.id.clone());
|
||||
let (table, wide, aside) = (style(&s, "Caption Table"), style(&s, "Caption Table Wide"), style(&s, "Aside"));
|
||||
for path in s.doc.para_paths(StoryRef::Body) {
|
||||
let p = s.doc.para_mut(StoryRef::Body, &path).unwrap();
|
||||
let text = p.plain_text();
|
||||
p.props.style = match text.get(..8).unwrap_or_default() {
|
||||
"tableX 1" | "tableX 2" => table.clone(),
|
||||
"tableX 3" | "Table 1:" => wide.clone(),
|
||||
"tableX 6" | "Plate 1:" => aside.clone(),
|
||||
_ => continue,
|
||||
};
|
||||
}
|
||||
let bytes = wordcraft_docx::write(&s.doc).expect("docx write");
|
||||
let mut s = Session::new(wordcraft_docx::read(&bytes).expect("docx read"));
|
||||
let want: Vec<String> = (1..=5).map(|k| format!("tableX {k}: Row {k}")).collect();
|
||||
assert_eq!(s.run("insert.crossReference", &json!({"to": "label:tableX"})).unwrap(), json!(want));
|
||||
assert_eq!(s.run("insert.crossReference", &json!({"to": "table"})).unwrap(), json!(["Table 1: Grid"]));
|
||||
assert_eq!(caption_labels(&s), ["tableX", "Table"]);
|
||||
s.run("caret.docEnd", &json!({})).unwrap();
|
||||
s.run("references.tableOfFigures", &json!({"label": "tableX", "pageNumbers": false})).unwrap();
|
||||
assert_eq!(list_entries(&s).into_iter().map(|(t, _)| t).collect::<Vec<_>>(), want);
|
||||
}
|
||||
|
||||
/// The entries of every generated list, in order, with their last tab stop's leader.
|
||||
fn list_entries(s: &Session) -> Vec<(String, Option<TabLeader>)> {
|
||||
s.doc
|
||||
|
||||
@@ -180,6 +180,8 @@ pub(crate) fn dispatch(s: &mut Session, id: &str, mutates: bool, run: fn(&mut Se
|
||||
// Whole cells: table commands take the rectangle between the corners, and Tab moves on
|
||||
// from it without clearing it, as in Word.
|
||||
_ if cells && (id.starts_with("table.") || matches!(id, "text.tab" | "text.backTab")) => run(s, v),
|
||||
// Copy and Cut of whole cells take a table of them (#1744).
|
||||
"edit.copy" | "edit.cut" if cells => run(s, v),
|
||||
"edit.copy" => copy(s, &segs),
|
||||
"edit.cut" => {
|
||||
let r = copy(s, &segs)?;
|
||||
|
||||
@@ -48,10 +48,10 @@ fn scan(s: &Session) -> (HashMap<u32, Pos>, Vec<Pos>, Vec<Pos>) {
|
||||
let (mut ids, mut conns, mut groups) = (HashMap::new(), Vec::new(), Vec::new());
|
||||
for path in s.doc.para_paths(StoryRef::Body) {
|
||||
let Some(para) = s.doc.para(StoryRef::Body, &path) else { continue };
|
||||
for off in para.object_offsets() {
|
||||
for (off, obj) in para.objects_with_offsets() {
|
||||
let pos = Pos { story: StoryRef::Body, path: path.clone(), off };
|
||||
match para.object_at(off) {
|
||||
Some(InlineObject::Shape { kind, extra, float, .. }) => {
|
||||
match obj {
|
||||
InlineObject::Shape { kind, extra, float, .. } => {
|
||||
if extra.id != 0 {
|
||||
ids.entry(extra.id).or_insert(pos.clone());
|
||||
}
|
||||
@@ -59,7 +59,7 @@ fn scan(s: &Session) -> (HashMap<u32, Pos>, Vec<Pos>, Vec<Pos>) {
|
||||
conns.push(pos);
|
||||
}
|
||||
}
|
||||
Some(InlineObject::Group { children, .. })
|
||||
InlineObject::Group { children, .. }
|
||||
if children.iter().any(|c| matches!(&c.obj, InlineObject::Shape { kind, extra, .. } if kind.is_connector() && (extra.start.is_some() || extra.end.is_some()))) =>
|
||||
{
|
||||
groups.push(pos)
|
||||
@@ -91,10 +91,11 @@ pub fn reroute(s: &mut Session) -> bool {
|
||||
s.touch();
|
||||
}
|
||||
let layout = s.layout();
|
||||
let objects = layout.object_index();
|
||||
let site = |e: Option<ConnEnd>, page: usize| -> Option<(f32, f32)> {
|
||||
let e = e?;
|
||||
let target = ids.get(&e.id)?;
|
||||
let hit = layout.object(target, page)?;
|
||||
let hit = objects.object(target, page)?;
|
||||
let kind = match s.doc.para_at(target)?.object_at(target.off)? {
|
||||
InlineObject::Shape { kind, .. } => *kind,
|
||||
_ => return None,
|
||||
@@ -103,7 +104,7 @@ pub fn reroute(s: &mut Session) -> bool {
|
||||
};
|
||||
let mut plans = Vec::new();
|
||||
for pos in conns {
|
||||
let Some(hit) = layout.object(&pos, s.page_hint) else { continue };
|
||||
let Some(hit) = objects.object(&pos, s.page_hint) else { continue };
|
||||
let Some(InlineObject::Shape { extra, float, .. }) = s.doc.para_at(&pos).and_then(|p| p.object_at(pos.off)) else { continue };
|
||||
let r = hit.rect;
|
||||
let (s0, e0) = ends(r.x, r.y, r.w, r.h, float.flip_h, float.flip_v);
|
||||
@@ -313,6 +314,49 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Drawing commands must not search every page again for each glued connector.
|
||||
#[test]
|
||||
fn rerouting_many_pages_does_not_rescan_all_objects_per_connector() {
|
||||
use wordcraft_doc::{Document, Paragraph, para_block};
|
||||
let mut doc = Document::new();
|
||||
doc.body = (0..8000u32)
|
||||
.map(|i| {
|
||||
let mut p = Paragraph::default();
|
||||
p.props.page_break_before = Some(i > 0);
|
||||
let id = i * 2 + 1;
|
||||
for (kind, x, extra) in [
|
||||
(ShapeKind::Rectangle, 100.0, ShapeExtra { id, ..Default::default() }),
|
||||
(ShapeKind::Ellipse, 300.0, ShapeExtra { id: id + 1, ..Default::default() }),
|
||||
(
|
||||
ShapeKind::ElbowConnector,
|
||||
0.0,
|
||||
ShapeExtra { start: Some(ConnEnd { id, site: 3 }), end: Some(ConnEnd { id: id + 1, site: 2 }), ..Default::default() },
|
||||
),
|
||||
] {
|
||||
let float = Float { wrap: Wrap::InFrontOfText, h_rel: Anchor::Page, v_rel: Anchor::Page, x, y: 200.0, ..Default::default() };
|
||||
p.insert_object(p.len(), shape(kind, extra, float), &Default::default()).unwrap();
|
||||
}
|
||||
para_block(p)
|
||||
})
|
||||
.collect();
|
||||
let mut s = Session::new(doc);
|
||||
s.view.proofing = false;
|
||||
s.page_hint = 0;
|
||||
assert_eq!(s.layout().pages.len(), 8000);
|
||||
let started = std::time::Instant::now();
|
||||
assert!(reroute(&mut s));
|
||||
let elapsed = started.elapsed();
|
||||
eprintln!("8000-page reroute: {elapsed:?}");
|
||||
assert!(elapsed < std::time::Duration::from_secs(5), "reroute took {elapsed:?}");
|
||||
for path in s.doc.para_paths(StoryRef::Body) {
|
||||
let para = s.doc.para(StoryRef::Body, &path).unwrap();
|
||||
let InlineObject::Shape { w, h, float, .. } = para.object_at(6).unwrap() else { panic!("connector") };
|
||||
assert_eq!((*w, *h, float.x, float.y), (160.0, 0.0, 140.0, 210.0));
|
||||
}
|
||||
s.touch();
|
||||
assert!(!reroute(&mut s), "rerouting must settle after refreshing the layout");
|
||||
}
|
||||
|
||||
/// The per-case shape/geometry of one scaled-group reroute case.
|
||||
struct ScaledGroupSpec {
|
||||
w: f32,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use wordcraft_doc::edit::Fragment;
|
||||
use wordcraft_doc::{Pos, StoryRef};
|
||||
use wordcraft_doc::{Path, Pos, StoryRef};
|
||||
|
||||
use super::{delete_selection, find_pattern, pos_json, sel_result};
|
||||
use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
|
||||
@@ -27,10 +27,16 @@ fn undo_to(s: &mut Session, v: &Value) -> CmdResult {
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
fn has_sel(s: &Session) -> Option<&'static str> {
|
||||
/// Something the Spike can collect: text (it doesn't take empty cells).
|
||||
fn has_text_sel(s: &Session) -> Option<&'static str> {
|
||||
if s.sel.is_collapsed() { Some("nothing is selected") } else { None }
|
||||
}
|
||||
|
||||
/// Something to cut: text, or whole table cells (an empty cell's selection has no length, #1744).
|
||||
fn has_sel(s: &Session) -> Option<&'static str> {
|
||||
if s.sel.is_collapsed() && s.cell_range().is_none() { Some("nothing is selected") } else { None }
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("edit.undo", "Undo", "Quick Access Toolbar", |s, _| {
|
||||
@@ -63,7 +69,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("edit.copy", "Copy", "Home › Clipboard", copy).key("Mod+C / Mod+Insert").pure(),
|
||||
// Word's Spike (Ctrl+F3, Ctrl+Shift+F3; ⌘ on the Mac): the AutoText entry "Spike", so Quick
|
||||
// Parts and AutoText insert it without emptying it, as in Word.
|
||||
CommandSpec::new("edit.spike", "Spike", "Editing", spike).key("Mod+F3").when(has_sel),
|
||||
CommandSpec::new("edit.spike", "Spike", "Editing", spike).key("Mod+F3").when(has_text_sel),
|
||||
CommandSpec::new("edit.insertSpike", "Insert Spike", "Editing", insert_spike).key("Mod+Shift+F3").when(|s| {
|
||||
if s.math.is_some() {
|
||||
Some("leave the equation first")
|
||||
@@ -167,11 +173,25 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
|
||||
fn copy(s: &mut Session, _: &Value) -> CmdResult {
|
||||
let whole = super::table::selected_table(s);
|
||||
copy_with(s, whole.as_ref())
|
||||
}
|
||||
|
||||
/// Copy, `whole` being the table the selection covers whole, if it does.
|
||||
fn copy_with(s: &mut Session, whole: Option<&Path>) -> CmdResult {
|
||||
let (a, b) = s.sel.ordered();
|
||||
if a == b {
|
||||
return Ok(json!({"text": ""}));
|
||||
}
|
||||
let f = s.doc.copy_range(&a, &b);
|
||||
// Whole table cells selected copy as a table, even empty ones (their selection has no
|
||||
// length): the whole table, or a table of just the selected rows or cells (#1744).
|
||||
let table = match (whole, s.cell_range()) {
|
||||
(Some(tp), _) => s.doc.copy_block(a.story, tp),
|
||||
(None, Some(r)) => s.doc.copy_table_cells(r.story, &r.table, r.rows, r.cols),
|
||||
(None, None) => None,
|
||||
};
|
||||
let f = match table {
|
||||
Some(f) => f,
|
||||
None if a == b => return Ok(json!({"text": ""})),
|
||||
None => s.doc.copy_range(&a, &b),
|
||||
};
|
||||
s.clipboard_text = f.clipboard_text();
|
||||
s.clip_history.push(&f, &s.clipboard_text);
|
||||
s.clipboard = Some(f);
|
||||
@@ -200,8 +220,26 @@ pub(crate) fn copy_result(s: &mut Session) -> Value {
|
||||
r
|
||||
}
|
||||
|
||||
fn cut(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let r = copy(s, v)?;
|
||||
fn cut(s: &mut Session, _: &Value) -> CmdResult {
|
||||
let whole = super::table::selected_table(s);
|
||||
let r = copy_with(s, whole.as_ref())?;
|
||||
// A whole table selected: Cut removes the table, as Delete Table does (#1744). With Track
|
||||
// Changes on, the cut below marks its text deleted (a tracked cut may become a move).
|
||||
if !s.doc.settings.track_changes
|
||||
&& let Some(tp) = &whole
|
||||
{
|
||||
super::table::delete_table_at(s, tp)?;
|
||||
super::controls::restore_placeholder(s)?;
|
||||
return Ok(r);
|
||||
}
|
||||
// Whole rows or some cells: their text goes, the rows and cells stay, as in Word.
|
||||
if whole.is_none()
|
||||
&& let Some(range) = s.cell_range().cloned()
|
||||
{
|
||||
super::table::clear_range_cells(s, &range)?;
|
||||
super::controls::restore_placeholder(s)?;
|
||||
return Ok(r);
|
||||
}
|
||||
// A tracked cut of a paragraph or sentence may become a move when pasted.
|
||||
if !super::moves::cut_tracked(s)? {
|
||||
delete_selection(s)?;
|
||||
|
||||
@@ -147,7 +147,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.params(r#"{"id"?: n (an open document, see view.switchWindows; default the active one), "force"?: bool (discard unsaved changes; without it a document with unsaved changes is an error and stays open)} → {"closed": id, "active": id, "documents": [{"id", "title", "path", "dirty", "active"}]}; closing the last document leaves a new blank one"#)
|
||||
.pure(),
|
||||
CommandSpec::new("file.properties", "Properties", "File › Info", properties)
|
||||
.params(r#"{"title"?, "subject"?, "author"?, "keywords"?, "comments"?, "category"?, "custom"?: {name: string | null (removes)}}"#),
|
||||
.params(r#"{"title"?, "subject"?, "author"?, "keywords"?, "comments"?, "category"?, "custom"?: {name: string | {"value": string, "linkTarget"?: string | null} | null (removes)}}; text values and bookmark targets are at most 1 MiB; a string keeps an existing target, an object replaces it (omitted/null target unlinks)"#),
|
||||
CommandSpec::new("file.info", "Info", "File", info).pure(),
|
||||
CommandSpec::new("file.setAuthor", "User Name", "File › Options › General", set_author)
|
||||
.params(r#"{"name": string (author of new tracked changes and comments)}"#)
|
||||
@@ -445,6 +445,15 @@ fn export_png(s: &mut Session, v: &Value) -> CmdResult {
|
||||
Ok(json!({"path": path, "width": img.width, "height": img.height, "bytes": png.len()}))
|
||||
}
|
||||
|
||||
/// Text and its bookmark association cross the dialog/command boundary together on rename.
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct LinkedCustomText<'a> {
|
||||
value: &'a str,
|
||||
#[serde(borrow)]
|
||||
link_target: Option<&'a str>,
|
||||
}
|
||||
|
||||
fn properties(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let c = &mut s.doc.core;
|
||||
let set = |dst: &mut String, k: &str| {
|
||||
@@ -460,7 +469,7 @@ fn properties(s: &mut Session, v: &Value) -> CmdResult {
|
||||
set(&mut c.category, "category");
|
||||
if let Some(custom) = v.get("custom") {
|
||||
let Some(m) = custom.as_object() else {
|
||||
return Err(CmdError::Params("`custom` must be an object of name → string or null".into()));
|
||||
return Err(CmdError::Params("`custom` must be an object of name → text, linked text value, or null".into()));
|
||||
};
|
||||
// Removals first: a case-only rename sends the old spelling as null and the new one
|
||||
// with its value, and `set_custom_prop` matches names case-insensitively, so setting
|
||||
@@ -471,17 +480,32 @@ fn properties(s: &mut Session, v: &Value) -> CmdResult {
|
||||
}
|
||||
}
|
||||
for (name, val) in m {
|
||||
match val {
|
||||
// Removals took the name as stored above: a file opened from an older Word may
|
||||
// hold one longer than today's limit, and it must still be deletable (#2982).
|
||||
Value::Null => {}
|
||||
Value::String(x) if x.len() <= MAX_CUSTOM_VALUE => {
|
||||
if name.trim().is_empty() || name.chars().count() > MAX_CUSTOM_NAME {
|
||||
return Err(CmdError::Params(format!("custom property names are 1–{MAX_CUSTOM_NAME} characters")));
|
||||
}
|
||||
s.doc.set_custom_prop(name, x);
|
||||
let bad_value =
|
||||
|| CmdError::Params(format!("custom property `{name}` must be text (≤ {MAX_CUSTOM_VALUE} bytes), a linked text value, or null"));
|
||||
let (value, target) = match val {
|
||||
// Removal also accepts names longer than today's limit (#2982).
|
||||
Value::Null => continue,
|
||||
Value::String(value) => (value.as_str(), None),
|
||||
Value::Object(_) => {
|
||||
let linked: LinkedCustomText<'_> = serde::Deserialize::deserialize(val).map_err(|_| bad_value())?;
|
||||
(linked.value, Some(linked.link_target))
|
||||
}
|
||||
_ => return Err(CmdError::Params(format!("custom property `{name}` must be a string (≤ {MAX_CUSTOM_VALUE} bytes) or null"))),
|
||||
_ => return Err(bad_value()),
|
||||
};
|
||||
if value.len() > MAX_CUSTOM_VALUE {
|
||||
return Err(bad_value());
|
||||
}
|
||||
if name.trim().is_empty() || name.chars().count() > MAX_CUSTOM_NAME {
|
||||
return Err(CmdError::Params(format!("custom property names are 1–{MAX_CUSTOM_NAME} characters")));
|
||||
}
|
||||
if target.flatten().is_some_and(|t| t.len() > MAX_CUSTOM_VALUE) {
|
||||
return Err(CmdError::Params(format!("custom property bookmark targets are at most {MAX_CUSTOM_VALUE} bytes")));
|
||||
}
|
||||
s.doc.set_custom_prop(name, value);
|
||||
if let Some(target) = target
|
||||
&& let Some(prop) = s.doc.custom_props.iter_mut().find(|p| p.name.eq_ignore_ascii_case(name))
|
||||
{
|
||||
prop.link_target = target.map(str::to_string);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -495,7 +519,7 @@ fn properties(s: &mut Session, v: &Value) -> CmdResult {
|
||||
/// Longest custom property name (Word's limit). The Custom Properties dialog validates with it
|
||||
/// too (#2982).
|
||||
pub const MAX_CUSTOM_NAME: usize = 255;
|
||||
/// Largest custom property value we accept from a command. The Custom Properties dialog
|
||||
/// Largest custom property value or bookmark target we accept from a command. The Custom Properties dialog
|
||||
/// validates with it too (#2982).
|
||||
pub const MAX_CUSTOM_VALUE: usize = 1024 * 1024;
|
||||
|
||||
|
||||
@@ -197,6 +197,17 @@ fn column_rows(s: &Session) -> Option<Vec<(Pos, Pos)>> {
|
||||
|
||||
/// Apply a change to the selection (or the pending caret formatting).
|
||||
pub fn apply(s: &mut Session, f: &dyn Fn(&mut CharProps)) -> CmdResult {
|
||||
apply_to(s, target, f)
|
||||
}
|
||||
|
||||
/// The selection only, not the word at a caret: what highlighting formats (#4086).
|
||||
fn selection(s: &Session) -> Option<(Pos, Pos)> {
|
||||
let (a, b) = s.sel.ordered();
|
||||
(a != b).then_some((a, b))
|
||||
}
|
||||
|
||||
/// [`apply`], with `target` choosing what a non-column selection formats.
|
||||
fn apply_to(s: &mut Session, target: fn(&Session) -> Option<(Pos, Pos)>, f: &dyn Fn(&mut CharProps)) -> CmdResult {
|
||||
// A column selection formats each row's piece.
|
||||
if let Some(rows) = column_rows(s) {
|
||||
for (a, b) in rows {
|
||||
@@ -211,6 +222,12 @@ pub fn apply(s: &mut Session, f: &dyn Fn(&mut CharProps)) -> CmdResult {
|
||||
match selected_text_box(s).or_else(|| target(s)) {
|
||||
Some((a, b)) => {
|
||||
s.doc.format_range(&a, &b, f)?;
|
||||
// The word at a caret took the change: so does formatting waiting for typing (#4086).
|
||||
if s.sel.is_collapsed()
|
||||
&& let Some(pend) = s.pending.as_mut()
|
||||
{
|
||||
f(pend);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let mut pend = s.typing_props();
|
||||
@@ -354,26 +371,59 @@ fn current_size(s: &Session) -> f32 {
|
||||
}
|
||||
|
||||
fn step_size(s: &mut Session, dir: i32) -> CmdResult {
|
||||
let cur = current_size(s);
|
||||
let next = if dir > 0 {
|
||||
SIZES.iter().copied().find(|x| *x > cur + 0.01).unwrap_or(((cur / 10.0).floor() + 1.0) * 10.0)
|
||||
} else {
|
||||
SIZES.iter().rev().copied().find(|x| *x < cur - 0.01).unwrap_or(1.0)
|
||||
};
|
||||
let next = next.clamp(1.0, 1638.0);
|
||||
// Each run steps from its own size when the selection mixes sizes.
|
||||
apply(s, &|c| {
|
||||
c.size = Some(next);
|
||||
c.size_cs = Some(next);
|
||||
resize(s, &|cur| {
|
||||
if dir > 0 {
|
||||
SIZES.iter().copied().find(|x| *x > cur + 0.01).unwrap_or(((cur / 10.0).floor() + 1.0) * 10.0)
|
||||
} else {
|
||||
SIZES.iter().rev().copied().find(|x| *x < cur - 0.01).unwrap_or(1.0)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn nudge_size(s: &mut Session, d: f32) -> CmdResult {
|
||||
let next = (current_size(s) + d).clamp(1.0, 1638.0);
|
||||
apply(s, &|c| {
|
||||
c.size = Some(next);
|
||||
c.size_cs = Some(next);
|
||||
})
|
||||
resize(s, &|cur| cur + d)
|
||||
}
|
||||
|
||||
/// Relative commands transform each run's effective size, including inherited sizes. Keep
|
||||
/// `apply`'s targets and the existing explicit override of both normal and complex-script size.
|
||||
fn resize(s: &mut Session, next: &dyn Fn(f32) -> f32) -> CmdResult {
|
||||
let set = |c: &mut CharProps, size: f32| {
|
||||
let size = next(size).clamp(1.0, 1638.0);
|
||||
c.size = Some(size);
|
||||
c.size_cs = Some(size);
|
||||
};
|
||||
let ranges = column_rows(s).or_else(|| selected_text_box(s).or_else(|| target(s)).map(|range| vec![range]));
|
||||
let Some(ranges) = ranges else {
|
||||
// Pending typing props and an empty paragraph's mark get the same single change.
|
||||
let size = current_size(s);
|
||||
return apply(s, &|c| set(c, size));
|
||||
};
|
||||
// Resolve against one immutable snapshot while formatting splits and normalizes runs.
|
||||
let styles = s.doc.styles.clone();
|
||||
for (a, b) in ranges {
|
||||
for path in s.doc.paths_between(&a, &b) {
|
||||
let p = s.doc.para_mut(a.story, &path)?;
|
||||
let style = p.props.style.clone();
|
||||
let from = if path == a.path { a.off } else { 0 };
|
||||
let to = if path == b.path { b.off } else { p.len() };
|
||||
let format = |c: &mut CharProps| set(c, styles.resolve_char(style.as_deref(), c).size);
|
||||
// Avoid format_range here: its path walk would repeat a paragraph-prefix scan.
|
||||
p.format(from, to, &format)?;
|
||||
// Keep format_range's paragraph-mark rule, including filtering linked marks.
|
||||
if to == p.len() && (from == 0 || path != a.path) {
|
||||
let style = p.mark.style.clone();
|
||||
format(&mut p.mark);
|
||||
if p.mark.link.take().is_some() {
|
||||
p.mark.style = style.filter(|s| s != "Hyperlink");
|
||||
}
|
||||
}
|
||||
}
|
||||
// An empty selected cell has only its paragraph mark, which needs a new layout too.
|
||||
if a == b {
|
||||
s.doc.para_mut(a.story, &a.path)?.touch();
|
||||
}
|
||||
}
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
fn color(s: &mut Session, v: &Value) -> CmdResult {
|
||||
@@ -410,7 +460,7 @@ fn target_has_highlight(s: &Session, h: Highlight) -> bool {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if let Some((a, b)) = selected_text_box(s).or_else(|| target(s)) {
|
||||
if let Some((a, b)) = selected_text_box(s).or_else(|| selection(s)) {
|
||||
for path in s.doc.paths_between(&a, &b) {
|
||||
let Some(p) = s.doc.para(a.story, &path) else { continue };
|
||||
let from = if path == a.path { a.off } else { 0 };
|
||||
@@ -422,7 +472,12 @@ fn target_has_highlight(s: &Session, h: Highlight) -> bool {
|
||||
}
|
||||
}
|
||||
}
|
||||
// No runs: retain the caret/pending and empty text-box paragraph-mark rules.
|
||||
// No runs: retain the caret/pending and empty text-box paragraph-mark rules. A caret checks
|
||||
// what is typed next, not the word it's in (#4086).
|
||||
if selected_text_box(s).or_else(|| selection(s)).is_none() {
|
||||
let style = s.doc.para_at(&s.sel.focus).and_then(|p| p.props.style.as_deref());
|
||||
return seen || matches(style, &s.typing_props());
|
||||
}
|
||||
seen || resolved(s).iter().all(|r| r.highlight == h.rgb())
|
||||
}
|
||||
|
||||
@@ -472,7 +527,8 @@ fn highlight(s: &mut Session, v: &Value) -> CmdResult {
|
||||
};
|
||||
let remove = color.is_none() && target_has_highlight(s, h);
|
||||
let value = if remove { Highlight::None } else { h };
|
||||
let result = apply(s, &|x| x.highlight = Some(value))?;
|
||||
// A caret highlights what is typed next, not the word it's in, as in Word (#4086).
|
||||
let result = apply_to(s, selection, &|x| x.highlight = Some(value))?;
|
||||
s.last_highlight = h;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
@@ -222,7 +222,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
r#"{"delivery": string, "return"?: string, "size"?: "Envelope #10|#9|Monarch|DL|C4|C5|C6|B5", "width"?: pt, "height"?: pt, "add"?: bool (add to this document instead of a new one)}"#,
|
||||
),
|
||||
CommandSpec::new("mailings.labels", "Labels", "Mailings › Create", labels).params(
|
||||
r#"{"text"?: string, "product"?: "letter-3x10|letter-2x10|letter-2x7|letter-2x5|letter-4x20|a4-3x7|a4-2x7|a4-3x8|a4-2x4", "rows"?: n, "cols"?: n, "fromRecipients"?: bool, "single"?: {"row": n, "col": n}}"#,
|
||||
r#"{"text"?: string, "product"?: "letter-3x10|letter-2x10|letter-2x7|letter-2x5|letter-4x20|a4-3x7|a4-2x7|a4-3x8|a4-2x4", "rows"?: n, "cols"?: n, "fromRecipients"?: bool (one label per selected recipient, continuing across sheets), "single"?: {"row": n, "col": n}}"#,
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -254,8 +254,9 @@ impl LabelProduct {
|
||||
}
|
||||
|
||||
/// Labels: a new document with a table of labels laid out like the sheet (`product`; spacer
|
||||
/// columns and rows stand for the gaps). A full page of the same `text` (or one recipient per
|
||||
/// label), or (`single`: `{"row", "col"}`, from 1) one label at that place on the sheet.
|
||||
/// columns and rows stand for the gaps). A full page of the same `text`, as many sheets as
|
||||
/// needed for one label per recipient, or (`single`: `{"row", "col"}`, from 1) one label
|
||||
/// at that place on the sheet.
|
||||
pub(super) fn labels(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let prod = match p::str(v, "product") {
|
||||
Some(id) => *LabelProduct::by_id(id).ok_or_else(|| CmdError::Params(format!("unknown label product `{id}`")))?,
|
||||
@@ -285,46 +286,58 @@ pub(super) fn labels(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let grid_cols: Vec<Option<usize>> = (0..cols).flat_map(|c| [Some(c)].into_iter().chain((c + 1 < cols && h_gap > 0.5).then_some(None))).collect();
|
||||
let grid_rows: Vec<Option<usize>> = (0..rows).flat_map(|r| [Some(r)].into_iter().chain((r + 1 < rows && v_gap > 0.5).then_some(None))).collect();
|
||||
let width: f32 = grid_cols.iter().map(|c| if c.is_some() { prod.width } else { h_gap }).sum();
|
||||
let mut t = Table::new(grid_rows.len(), grid_cols.len(), width);
|
||||
t.props.style = None;
|
||||
t.props.borders = Some(wordcraft_doc::props::Borders::default());
|
||||
t.props.fixed = true;
|
||||
t.grid = grid_cols.iter().map(|c| if c.is_some() { prod.width } else { h_gap }).collect();
|
||||
let per_sheet = rows * cols;
|
||||
let sheets = if from_rec { s.merge.rows.len().div_ceil(per_sheet).max(1) } else { 1 };
|
||||
let mut k = 0usize;
|
||||
let mut made = 0usize;
|
||||
for (row, r) in t.rows.iter_mut().zip(&grid_rows) {
|
||||
row.props.height = Some(if r.is_some() { prod.height } else { v_gap });
|
||||
row.props.height_rule = wordcraft_doc::props::HeightRule::Exact;
|
||||
for (cell, c) in row.cells.iter_mut().zip(&grid_cols) {
|
||||
cell.props.width = Some(if c.is_some() { prod.width } else { h_gap });
|
||||
let (Some(r), Some(c)) = (r, c) else { continue };
|
||||
let content = match single {
|
||||
Some((sr, sc)) if (sr, sc) != (r + 1, c + 1) => String::new(),
|
||||
_ if from_rec => {
|
||||
let rec = if k < s.merge.rows.len() { address_block(s, k) } else { String::new() };
|
||||
k += 1;
|
||||
rec
|
||||
d.body.clear();
|
||||
for sheet in 0..sheets {
|
||||
let mut t = Table::new(grid_rows.len(), grid_cols.len(), width);
|
||||
t.props.style = None;
|
||||
t.props.borders = Some(wordcraft_doc::props::Borders::default());
|
||||
t.props.fixed = true;
|
||||
t.grid = grid_cols.iter().map(|c| if c.is_some() { prod.width } else { h_gap }).collect();
|
||||
for (row, r) in t.rows.iter_mut().zip(&grid_rows) {
|
||||
row.props.height = Some(if r.is_some() { prod.height } else { v_gap });
|
||||
row.props.height_rule = wordcraft_doc::props::HeightRule::Exact;
|
||||
for (cell, c) in row.cells.iter_mut().zip(&grid_cols) {
|
||||
cell.props.width = Some(if c.is_some() { prod.width } else { h_gap });
|
||||
let (Some(r), Some(c)) = (r, c) else { continue };
|
||||
let content = match single {
|
||||
Some((sr, sc)) if (sr, sc) != (r + 1, c + 1) => String::new(),
|
||||
_ if from_rec => {
|
||||
let rec = if k < s.merge.rows.len() { address_block(s, k) } else { String::new() };
|
||||
k += 1;
|
||||
rec
|
||||
}
|
||||
_ => text.clone(),
|
||||
};
|
||||
if !content.is_empty() {
|
||||
made += 1;
|
||||
}
|
||||
_ => text.clone(),
|
||||
};
|
||||
if !content.is_empty() {
|
||||
made += 1;
|
||||
cell.props.valign = Some(wordcraft_doc::props::VAlign::Center);
|
||||
cell.blocks = content
|
||||
.split('\n')
|
||||
.take(20)
|
||||
.map(|l| {
|
||||
let mut p = Paragraph::with_text(l, CharProps::default()).styled("NoSpacing");
|
||||
p.props.align = Some(Align::Left);
|
||||
p.props.indent_left = Some(9.0);
|
||||
para_block(p)
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
cell.props.valign = Some(wordcraft_doc::props::VAlign::Center);
|
||||
cell.blocks = content
|
||||
.split('\n')
|
||||
.take(20)
|
||||
.map(|l| {
|
||||
let mut p = Paragraph::with_text(l, CharProps::default()).styled("NoSpacing");
|
||||
p.props.align = Some(Align::Left);
|
||||
p.props.indent_left = Some(9.0);
|
||||
para_block(p)
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
d.body.push(std::sync::Arc::new(Block::Table(t)));
|
||||
let mut end = Paragraph::new();
|
||||
if sheet + 1 < sheets {
|
||||
// End each sheet with the same next-page section, so the next table
|
||||
// starts at the product's top margin without a leading blank line.
|
||||
end.section = Some(Box::new(d.last_section.clone()));
|
||||
}
|
||||
d.body.push(para_block(end));
|
||||
}
|
||||
d.body = vec![std::sync::Arc::new(Block::Table(t)), para_block(Paragraph::new())];
|
||||
s.add_document(d);
|
||||
let _ = Pos::body(0, 0);
|
||||
Ok(json!({"labels": if single.is_some() { 1 } else { rows * cols }, "filled": made, "product": prod.id}))
|
||||
Ok(json!({"labels": if single.is_some() { 1 } else { sheets * per_sheet }, "filled": made, "product": prod.id}))
|
||||
}
|
||||
|
||||
@@ -375,6 +375,75 @@ fn envelope_sizes_add_to_document_and_label_products() {
|
||||
assert!(s.run("mailings.labels", &json!({"product": "nope"})).is_err());
|
||||
}
|
||||
|
||||
/// #4880: every selected recipient gets a label, including those beyond the first sheet.
|
||||
#[test]
|
||||
fn recipient_labels_continue_on_product_sheets() {
|
||||
for product in LABEL_PRODUCTS {
|
||||
let per_sheet = product.rows * product.cols;
|
||||
for count in [1, per_sheet, per_sheet + 1, per_sheet * 2, per_sheet * 2 + 5] {
|
||||
let mut s = Session::new(Document::from_text("Original letter"));
|
||||
let rows: Vec<Value> = (0..count).map(|i| json!({"FirstName": format!("Recipient{i:03}"), "Address": "Original Street"})).collect();
|
||||
s.run("mailings.recipients", &json!({"rows": rows})).unwrap();
|
||||
let r = s.run("mailings.labels", &json!({"product": product.id, "fromRecipients": true})).unwrap();
|
||||
let sheets = count.div_ceil(per_sheet);
|
||||
assert_eq!(r["filled"], count, "{}: {count} recipients", product.id);
|
||||
assert_eq!(r["labels"], sheets * per_sheet);
|
||||
assert_eq!(s.doc.sections().len(), sheets);
|
||||
let tables: Vec<_> = s
|
||||
.doc
|
||||
.body
|
||||
.iter()
|
||||
.filter_map(|b| match &**b {
|
||||
Block::Table(t) => Some(t),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(tables.len(), sheets);
|
||||
let label_text: Vec<_> = tables
|
||||
.iter()
|
||||
.flat_map(|t| t.rows.iter())
|
||||
.flat_map(|r| &r.cells)
|
||||
.flat_map(|c| &c.blocks)
|
||||
.filter_map(|b| match &**b {
|
||||
Block::Para(p) if !p.text.is_empty() => Some(p.text.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let expected: Vec<_> = (0..count).flat_map(|i| [format!("Recipient{i:03}"), "Original Street".to_string()]).collect();
|
||||
assert_eq!(label_text, expected);
|
||||
assert!(tables.windows(2).all(|t| t[0].grid == t[1].grid && t[0].props == t[1].props));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recipient_label_sheets_keep_the_source_and_selected_order() {
|
||||
let mut s = Session::new(Document::from_text("Original letter"));
|
||||
s.run("text.insert", &json!({"text": "Draft "})).unwrap();
|
||||
let original = s.doc.clone();
|
||||
let id = s.document_id();
|
||||
let rows: Vec<Value> = (0..65).map(|i| json!({"FirstName": format!("Recipient{i:03}")})).collect();
|
||||
s.run("mailings.recipients", &json!({"rows": rows})).unwrap();
|
||||
s.run(
|
||||
"mailings.filterRecipients",
|
||||
&json!({"rules": [{"field": "FirstName", "op": "notEqual", "value": "Recipient003"}],
|
||||
"sort": [{"field": "FirstName", "descending": true}]}),
|
||||
)
|
||||
.unwrap();
|
||||
let selected = s.run("mailings.editRecipients", &json!({})).unwrap();
|
||||
let r = s.run("mailings.labels", &json!({"fromRecipients": true})).unwrap();
|
||||
assert_eq!(r["filled"], 64);
|
||||
assert_eq!(r["labels"], 90);
|
||||
assert_eq!(s.undo_depth(), 0, "labels are a new document");
|
||||
let text = s.doc.plain_text(StoryRef::Body);
|
||||
let names: Vec<_> = text.split_whitespace().filter(|l| l.starts_with("Recipient")).collect();
|
||||
assert_eq!(names, (0..65).rev().filter(|i| *i != 3).map(|i| format!("Recipient{i:03}")).collect::<Vec<_>>());
|
||||
s.run("view.switchWindows", &json!({"id": id})).unwrap();
|
||||
assert_eq!(s.doc, original, "the original letter stays open and unchanged");
|
||||
assert_eq!(s.run("mailings.editRecipients", &json!({})).unwrap(), selected);
|
||||
assert_eq!(s.undo_depth(), 1, "the source keeps its edit history");
|
||||
}
|
||||
|
||||
fn b64(b: &[u8]) -> String {
|
||||
super::super::insert::base64_encode(b)
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ pub(super) use cells::grid_cols;
|
||||
pub use cells::selected_cells;
|
||||
use cells::{cell, with_cells, with_table};
|
||||
pub(crate) use cells::{cell_text, row_ends};
|
||||
pub(super) use clear::clear_range_cells;
|
||||
pub(crate) use clear::{backspace_cells, clear_cells};
|
||||
use clear::{cell_prefix, guard_delete, text_spans};
|
||||
use convert::{formula, from_text, quick_table, sort_table, to_text};
|
||||
@@ -31,6 +32,7 @@ use select::{select_cells, select_column};
|
||||
pub use structure::split_targets;
|
||||
pub(super) use structure::{col, row};
|
||||
use structure::{del_col, del_row, del_table, merge, split, split_table};
|
||||
pub(super) use structure::{delete_table_at, selected_table};
|
||||
|
||||
fn in_table(s: &Session) -> Option<&'static str> {
|
||||
if s.sel.focus.path.cell().is_some() { None } else { Some("the cursor isn't in a table") }
|
||||
@@ -223,7 +225,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
})
|
||||
.params(r#"{"value"?: bool} (true: the first column stands at the right; without a value it toggles)"#)),
|
||||
t(CommandSpec::new("table.properties", "Properties", "Table Layout › Table", properties).params(
|
||||
r#"{"align"?: "left|center|right", "rtl"?: bool, "width"?: pt|null, "widthPct"?: percent, "indent"?: pt, "rowHeight"?: pt|null, "rowHeightRule"?: "atLeast|exact", "allowBreak"?: bool, "headerRow"?: bool, "columnWidth"?: pt, "cellWidth"?: pt|null, "valign"?: "top|center|bottom"} (row, column and cell settings apply to the caret's; without settings it returns the current ones)"#,
|
||||
r#"{"align"?: "left|center|right", "rtl"?: bool, "width"?: pt|null, "widthPct"?: percent, "indent"?: pt, "rowHeight"?: pt|null, "rowHeightRule"?: "atLeast|exact", "allowBreak"?: bool, "headerRow"?: bool, "columnWidth"?: pt, "cellWidth"?: pt|null, "valign"?: "top|center|bottom"} (row, column and cell settings apply to the selected rows, columns and cells, or the caret's; without settings it returns the caret's current ones)"#,
|
||||
)),
|
||||
CommandSpec::new("table.fromText", "Convert Text to Table", "Insert › Tables", from_text)
|
||||
.params(r#"{"separator"?: "tab|comma|paragraph|other", "char"?: string (with other), "columns"?: n, "autofit"?: "initial|fixed|contents|window", "width"?: pt (initial column width, 0 = Auto, at most 1584)}"#),
|
||||
|
||||
@@ -30,6 +30,30 @@ pub(crate) fn backspace_cells(s: &mut Session) -> Option<CmdResult> {
|
||||
Some(r)
|
||||
}
|
||||
|
||||
/// Clear the text of the cells `range` selects, leaving the cells (Cut of rows or cells, #1744);
|
||||
/// tracked when Track Changes is on. The caret goes to the first cell.
|
||||
pub(in crate::cmd) fn clear_range_cells(s: &mut Session, range: &crate::CellRange) -> Result<(), CmdError> {
|
||||
let Some(t) = s.doc.table(range.story, &range.table) else { return Ok(()) };
|
||||
// The cells Copy takes: columns widened over merged cells, as `Table::slice` does.
|
||||
let (lo, hi, any) = t.slice_bounds(range.rows, range.cols);
|
||||
let cells = if any { crate::CellRange { cols: (lo, hi.saturating_sub(1)), ..range.clone() }.cells(t) } else { Vec::new() };
|
||||
let prefixes: Vec<Vec<u32>> = cells.into_iter().map(|(r, c)| cell_prefix(&range.table, r, c)).collect();
|
||||
let spans = text_spans(s, range.story, &prefixes);
|
||||
let first = spans.first().map(|(a, _)| a.clone());
|
||||
if guard_delete(s, &spans)?.is_none() {
|
||||
for (a, b) in spans.iter().rev() {
|
||||
if a != b {
|
||||
s.doc.delete_range(a, b)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
s.column = None;
|
||||
if let Some(a) = first {
|
||||
s.sel = Selection::caret(s.doc.clamp(&a));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete with whole cells selected (#2802): each cell is emptied to one empty paragraph, as in
|
||||
/// Word, nested tables included. That paragraph keeps the formatting of the cell's last one (its
|
||||
/// mark is the end-of-cell mark). `None` without whole cells, and with Track Changes on (the text
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::cells::{cell, with_cells, with_table};
|
||||
use super::cells::{cell, selected_cells, with_cells, with_table};
|
||||
use super::{
|
||||
Align, Border, BorderStyle, Borders, CmdError, CmdResult, HeightRule, Path, Pos, Rgb, Selection, Session, Table, TextDirection, VAlign, Value,
|
||||
json, p, sel_result,
|
||||
@@ -74,18 +74,9 @@ pub(super) fn set_column_widths(t: &mut Table, cells: &[(usize, usize)], w: f32)
|
||||
}
|
||||
}
|
||||
|
||||
fn set_column_width(t: &mut Table, r: usize, c: usize, w: f32) {
|
||||
let g = t.grid_col(r, c);
|
||||
if let Some(x) = t.grid.get_mut(g) {
|
||||
*x = w;
|
||||
t.props.width = None;
|
||||
t.props.width_pct = None;
|
||||
sync_cell_widths(t);
|
||||
}
|
||||
}
|
||||
|
||||
/// Table Properties: table, row, column and cell settings in one undoable step. Row, column and
|
||||
/// cell settings apply to the caret's. Without any setting it returns the current values.
|
||||
/// cell settings apply to the selected rows, columns and cells, as in Word, and to the caret's
|
||||
/// when nothing is selected (#4857). Without any setting it returns the caret's current values.
|
||||
pub(super) fn properties(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let (tp, r, c) = cell(s)?;
|
||||
let keys = [
|
||||
@@ -150,6 +141,11 @@ pub(super) fn properties(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let column_width = p::f32(v, "columnWidth").map(|x| x.clamp(6.0, 1584.0));
|
||||
let cell_width = size("cellWidth", 6.0, 1584.0);
|
||||
let rtl = p::bool(v, "rtl");
|
||||
// The selected cells (a whole table, rows, a column…), else the caret's.
|
||||
let cells = Some(selected_cells(s)).filter(|c| !c.is_empty()).unwrap_or_else(|| vec![(r, c)]);
|
||||
let mut rows: Vec<usize> = cells.iter().map(|&(r, _)| r).collect();
|
||||
rows.sort_unstable();
|
||||
rows.dedup();
|
||||
with_table(s, |t| {
|
||||
if let Some(a) = align {
|
||||
t.props.align = Some(a);
|
||||
@@ -161,7 +157,7 @@ pub(super) fn properties(s: &mut Session, v: &Value) -> CmdResult {
|
||||
t.props.indent = Some(i);
|
||||
}
|
||||
if let Some(cw) = column_width {
|
||||
set_column_width(t, r, c, cw);
|
||||
set_column_widths(t, &cells, cw);
|
||||
}
|
||||
if let Some(w) = width {
|
||||
t.props.width = w;
|
||||
@@ -170,7 +166,8 @@ pub(super) fn properties(s: &mut Session, v: &Value) -> CmdResult {
|
||||
if let Some(pct) = width_pct {
|
||||
t.props.width_pct = Some(pct);
|
||||
}
|
||||
if let Some(row) = t.rows.get_mut(r) {
|
||||
for &r in &rows {
|
||||
let Some(row) = t.rows.get_mut(r) else { continue };
|
||||
if let Some(h) = row_height {
|
||||
row.props.height = h;
|
||||
row.props.height_rule = if h.is_some() { rule.unwrap_or(HeightRule::AtLeast) } else { HeightRule::Auto };
|
||||
@@ -185,13 +182,14 @@ pub(super) fn properties(s: &mut Session, v: &Value) -> CmdResult {
|
||||
if let Some(h) = header {
|
||||
row.props.header = h;
|
||||
}
|
||||
if let Some(cl) = row.cells.get_mut(c) {
|
||||
if let Some(w) = cell_width {
|
||||
cl.props.width = w;
|
||||
}
|
||||
if let Some(va) = valign {
|
||||
cl.props.valign = Some(va);
|
||||
}
|
||||
}
|
||||
for &(r, c) in &cells {
|
||||
let Some(cl) = t.rows.get_mut(r).and_then(|row| row.cells.get_mut(c)) else { continue };
|
||||
if let Some(w) = cell_width {
|
||||
cl.props.width = w;
|
||||
}
|
||||
if let Some(va) = valign {
|
||||
cl.props.valign = Some(va);
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ use super::cells::{cell, selected_cells};
|
||||
use super::clear::{cell_prefix, guard_delete, text_spans};
|
||||
use super::layout::{fix_caret, sync_cell_widths};
|
||||
use super::{Block, CmdError, CmdResult, Paragraph, Pos, Selection, Session, Value, json, p, sel_result};
|
||||
use wordcraft_doc::Path;
|
||||
|
||||
/// Insert Above / Below: rows like the caret's, as many as the selection spans; the caret goes into the first.
|
||||
pub(in crate::cmd) fn row(s: &mut Session, below: bool) -> CmdResult {
|
||||
@@ -104,6 +105,25 @@ pub(super) fn del_col(s: &mut Session, _: &Value) -> CmdResult {
|
||||
|
||||
pub(super) fn del_table(s: &mut Session, _: &Value) -> CmdResult {
|
||||
let (tp, _, _) = cell(s)?;
|
||||
delete_table_at(s, &tp)
|
||||
}
|
||||
|
||||
/// The table the selection covers whole (Select Table, a drag over all of it), if it does (#1744).
|
||||
pub(in crate::cmd) fn selected_table(s: &Session) -> Option<Path> {
|
||||
if let Some(r) = s.cell_range()
|
||||
&& let Some(t) = s.doc.table(r.story, &r.table)
|
||||
&& r.rows == (0, t.rows.len().saturating_sub(1))
|
||||
&& r.cols == (0, t.cols().saturating_sub(1))
|
||||
{
|
||||
return Some(r.table.clone());
|
||||
}
|
||||
let (a, b) = s.sel.ordered();
|
||||
s.doc.whole_table(&a, &b)
|
||||
}
|
||||
|
||||
/// Delete Table for the table at `tp` (in the selection's story): tracked when Track Changes is on.
|
||||
pub(in crate::cmd) fn delete_table_at(s: &mut Session, tp: &Path) -> CmdResult {
|
||||
let tp = tp.clone();
|
||||
let story = s.sel.focus.story;
|
||||
let spans = text_spans(s, story, std::slice::from_ref(&tp.0));
|
||||
if let Some(done) = guard_delete(s, &spans)? {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::enter_tab::tab_indent_on;
|
||||
use super::{Block, CmdResult, NumRef, Pos, Selection, Session, StoryRef, Value, delete_selection, delete_selection_effect, sel_result};
|
||||
use super::{Block, CmdResult, NumRef, OBJ, Pos, Selection, Session, StoryRef, Value, delete_selection, delete_selection_effect, sel_result};
|
||||
|
||||
pub(super) fn backspace(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let r = backspace_inner(s, v)?;
|
||||
@@ -43,8 +43,17 @@ fn backspace_inner(s: &mut Session, _: &Value) -> CmdResult {
|
||||
});
|
||||
return sel_result(s);
|
||||
}
|
||||
let f = s.sel.focus.clone();
|
||||
let mut f = s.sel.focus.clone();
|
||||
let Some(para) = s.doc.para_at(&f) else { return sel_result(s) };
|
||||
// Only floating objects before the caret: they are anchors, not characters, so this is the
|
||||
// paragraph start and the shape stays (Word).
|
||||
let before = para.text.get(..f.off).unwrap_or_default();
|
||||
if !before.is_empty()
|
||||
&& before.chars().all(|c| c == OBJ)
|
||||
&& para.objects_with_offsets().take_while(|(o, _)| *o < f.off).all(|(_, o)| o.is_marker() || o.is_floating())
|
||||
{
|
||||
f.off = 0;
|
||||
}
|
||||
if f.off > 0 {
|
||||
let a = Pos { off: para.backspace_from(f.off), ..f.clone() };
|
||||
if let Some(c) = s.autocap.as_mut() {
|
||||
@@ -98,6 +107,12 @@ fn backspace_inner(s: &mut Session, _: &Value) -> CmdResult {
|
||||
delete_selection(s)?;
|
||||
return sel_result(s);
|
||||
}
|
||||
// Joining moves a content control whose start is at the paragraph edge. Check its
|
||||
// contents explicitly: the paragraph-mark range itself ends before those contents.
|
||||
for control in s.doc.control_ranges(f.story).into_iter().filter(|r| r.start.path == f.path && r.start.off == f.off) {
|
||||
crate::cmd::controls::check_edit(s, &control.content_start(), &control.end)?;
|
||||
}
|
||||
crate::cmd::controls::check_delete(s, &a, &f)?;
|
||||
let at = s.doc.delete_range(&a, &f)?;
|
||||
s.sel = Selection::caret(at);
|
||||
}
|
||||
|
||||
+390
-1
@@ -11,6 +11,7 @@ use crate::{Session, cmd};
|
||||
|
||||
mod bidi;
|
||||
mod documents;
|
||||
mod font_sizes;
|
||||
mod fonts;
|
||||
mod ime_noop;
|
||||
mod revision_checks;
|
||||
@@ -1943,6 +1944,291 @@ fn tables_commands() {
|
||||
assert!(s.run("table.merge", &json!({})).is_err());
|
||||
}
|
||||
|
||||
/// A 2×2 table "A1 B1 / A2 B2" after an "Intro" paragraph, the caret in its first cell.
|
||||
fn table_doc() -> Session {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "Intro"}));
|
||||
run(&mut s, "caret.docEnd", json!({}));
|
||||
run(&mut s, "insert.table", json!({"rows": 2, "cols": 2}));
|
||||
for (i, t) in ["A1", "B1", "A2", "B2"].iter().enumerate() {
|
||||
if i > 0 {
|
||||
run(&mut s, "text.tab", json!({}));
|
||||
}
|
||||
run(&mut s, "text.insert", json!({"text": t}));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn body_table_cells(s: &Session) -> Vec<Vec<String>> {
|
||||
s.doc
|
||||
.body
|
||||
.iter()
|
||||
.filter_map(|b| b.as_table())
|
||||
.map(|t| {
|
||||
t.rows
|
||||
.iter()
|
||||
.flat_map(|r| r.cells.iter())
|
||||
.map(|c| c.blocks.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).collect::<String>())
|
||||
.collect()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// #1744: Select Table → Copy → Paste outside the table pastes a copy of the whole table.
|
||||
#[test]
|
||||
fn copy_whole_table_pastes_a_table() {
|
||||
let mut s = table_doc();
|
||||
run(&mut s, "table.selectTable", json!({}));
|
||||
let r = run(&mut s, "edit.copy", json!({}));
|
||||
assert!(r["text"].as_str().unwrap().contains("A1\tB1"), "clipboard text is tab separated: {r}");
|
||||
assert!(matches!(s.clipboard.as_ref().unwrap().blocks.as_slice(), [wordcraft_doc::Block::Table(_)]), "the copy is the table");
|
||||
run(&mut s, "caret.docStart", json!({}));
|
||||
run(&mut s, "edit.paste", json!({}));
|
||||
let want = vec!["A1".to_string(), "B1".into(), "A2".into(), "B2".into()];
|
||||
assert_eq!(body_table_cells(&s), vec![want.clone(), want]);
|
||||
}
|
||||
|
||||
/// #1744: Select Table → Cut removes the whole table, and one Undo brings it back.
|
||||
#[test]
|
||||
fn cut_whole_table_removes_it_and_undoes() {
|
||||
let mut s = table_doc();
|
||||
run(&mut s, "table.selectTable", json!({}));
|
||||
run(&mut s, "edit.cut", json!({}));
|
||||
assert!(body_table_cells(&s).is_empty(), "the table is gone");
|
||||
assert!(s.sel.focus.path.cell().is_none(), "the caret left the table");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(body_table_cells(&s), vec![vec!["A1".to_string(), "B1".into(), "A2".into(), "B2".into()]]);
|
||||
// Paste puts it back as a table.
|
||||
run(&mut s, "table.selectTable", json!({}));
|
||||
run(&mut s, "edit.cut", json!({}));
|
||||
run(&mut s, "edit.paste", json!({}));
|
||||
assert_eq!(body_table_cells(&s).len(), 1);
|
||||
}
|
||||
|
||||
/// A 3×3 table "A1 B1 C1 / A2 B2 C2 / A3 B3 C3" after an "Intro" paragraph (#1744).
|
||||
fn table3_doc() -> Session {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "Intro"}));
|
||||
run(&mut s, "caret.docEnd", json!({}));
|
||||
run(&mut s, "insert.table", json!({"rows": 3, "cols": 3}));
|
||||
for (i, t) in ["A1", "B1", "C1", "A2", "B2", "C2", "A3", "B3", "C3"].iter().enumerate() {
|
||||
if i > 0 {
|
||||
run(&mut s, "text.tab", json!({}));
|
||||
}
|
||||
run(&mut s, "text.insert", json!({"text": t}));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Select whole cells of the body's table: rows and grid columns, both inclusive.
|
||||
fn select_table_cells(s: &mut Session, rows: (usize, usize), cols: (usize, usize)) {
|
||||
let tp = wordcraft_doc::Path(vec![1]);
|
||||
crate::cmd::table::select_range(s, StoryRef::Body, &tp, rows, cols).unwrap();
|
||||
assert_eq!(s.cell_range().map(|r| (r.rows, r.cols)), Some((rows, cols)));
|
||||
}
|
||||
|
||||
fn row_cells(texts: &[&str]) -> Vec<String> {
|
||||
texts.iter().map(|t| t.to_string()).collect()
|
||||
}
|
||||
|
||||
/// #1744 (Word check A): copying whole rows, two cells of a row, or a block of cells over
|
||||
/// several rows, then pasting outside the table, pastes a table of just those cells.
|
||||
#[test]
|
||||
fn copy_rows_and_cells_pastes_a_table_of_them() {
|
||||
let full = row_cells(&["A1", "B1", "C1", "A2", "B2", "C2", "A3", "B3", "C3"]);
|
||||
for (rows, cols, want) in [
|
||||
((1, 1), (0, 2), row_cells(&["A2", "B2", "C2"])),
|
||||
((0, 0), (0, 1), row_cells(&["A1", "B1"])),
|
||||
((1, 2), (1, 2), row_cells(&["B2", "C2", "B3", "C3"])),
|
||||
] {
|
||||
let mut s = table3_doc();
|
||||
select_table_cells(&mut s, rows, cols);
|
||||
run(&mut s, "edit.copy", json!({}));
|
||||
let Some([wordcraft_doc::Block::Table(t)]) = s.clipboard.as_ref().map(|f| f.blocks.as_slice()) else {
|
||||
panic!("{rows:?} {cols:?}: the copy is a table, not {:?}", s.clipboard_text);
|
||||
};
|
||||
assert_eq!(t.rows.len(), rows.1 - rows.0 + 1, "{rows:?} {cols:?}");
|
||||
assert_eq!(t.grid.len(), cols.1 - cols.0 + 1, "{rows:?} {cols:?}: the grid has the copied columns");
|
||||
run(&mut s, "caret.docStart", json!({}));
|
||||
run(&mut s, "edit.paste", json!({}));
|
||||
assert_eq!(body_table_cells(&s), vec![want, full.clone()], "{rows:?} {cols:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #1744 (Word check A): Cut of whole rows, or of some cells, clears their text and leaves
|
||||
/// the rows and cells; the clipboard still gets a table of them. One Undo brings the text back.
|
||||
#[test]
|
||||
fn cut_rows_and_cells_clears_their_text() {
|
||||
let full = row_cells(&["A1", "B1", "C1", "A2", "B2", "C2", "A3", "B3", "C3"]);
|
||||
for (rows, cols, left, copied) in [
|
||||
((1, 1), (0, 2), row_cells(&["A1", "B1", "C1", "", "", "", "A3", "B3", "C3"]), row_cells(&["A2", "B2", "C2"])),
|
||||
((0, 0), (0, 1), row_cells(&["", "", "C1", "A2", "B2", "C2", "A3", "B3", "C3"]), row_cells(&["A1", "B1"])),
|
||||
((1, 2), (1, 2), row_cells(&["A1", "B1", "C1", "A2", "", "", "A3", "", ""]), row_cells(&["B2", "C2", "B3", "C3"])),
|
||||
] {
|
||||
let mut s = table3_doc();
|
||||
select_table_cells(&mut s, rows, cols);
|
||||
run(&mut s, "edit.cut", json!({}));
|
||||
assert_eq!(body_table_cells(&s), vec![left.clone()], "{rows:?} {cols:?}: rows and cells stay");
|
||||
assert!(s.sel.is_collapsed(), "{rows:?} {cols:?}");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(body_table_cells(&s), vec![full.clone()], "{rows:?} {cols:?}: one undo step");
|
||||
select_table_cells(&mut s, rows, cols);
|
||||
run(&mut s, "edit.cut", json!({}));
|
||||
run(&mut s, "caret.docStart", json!({}));
|
||||
run(&mut s, "edit.paste", json!({}));
|
||||
assert_eq!(body_table_cells(&s), vec![copied, left], "{rows:?} {cols:?}: the cut pastes as a table");
|
||||
}
|
||||
}
|
||||
|
||||
/// #1744 review: Cut clears every cell Copy takes: a cell merged across the selected columns
|
||||
/// widens them, so the cell below the merge's second column is copied and cleared too.
|
||||
#[test]
|
||||
fn cut_clears_the_cells_a_merged_cell_widens_the_copy_to() {
|
||||
let mut s = table3_doc();
|
||||
select_table_cells(&mut s, (0, 0), (0, 1));
|
||||
run(&mut s, "table.merge", json!({}));
|
||||
select_table_cells(&mut s, (0, 1), (0, 0));
|
||||
run(&mut s, "edit.copy", json!({}));
|
||||
run(&mut s, "edit.cut", json!({}));
|
||||
let cells = body_table_cells(&s).remove(0);
|
||||
assert_eq!(cells.len(), 8, "one merged cell and seven others: {cells:?}");
|
||||
assert_eq!(cells[1], "C1");
|
||||
assert!(cells[2..4].iter().all(String::is_empty), "A2 and B2 are cleared: {cells:?}");
|
||||
assert_eq!(cells[4..], ["C2", "A3", "B3", "C3"], "the rest stays");
|
||||
}
|
||||
|
||||
/// #1744 review: an empty cell's selection enables Cut but not the Spike, which takes text only.
|
||||
#[test]
|
||||
fn spike_stays_disabled_on_an_empty_cell_selection() {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "Intro"}));
|
||||
run(&mut s, "caret.docEnd", json!({}));
|
||||
run(&mut s, "insert.table", json!({"rows": 2, "cols": 2}));
|
||||
select_table_cells(&mut s, (0, 0), (0, 0));
|
||||
let r = s.run("edit.spike", &json!({}));
|
||||
assert!(matches!(r, Err(crate::CmdError::Disabled(_))), "{r:?}");
|
||||
}
|
||||
|
||||
fn clip_is_table(s: &Session) -> bool {
|
||||
matches!(s.clipboard.as_ref().map(|f| f.blocks.as_slice()), Some([wordcraft_doc::Block::Table(_)]))
|
||||
}
|
||||
|
||||
/// The body table's cells as they read with every change accepted.
|
||||
fn body_table_final(s: &Session) -> Vec<String> {
|
||||
let t = s.doc.body.iter().find_map(|b| b.as_table()).expect("a table");
|
||||
t.rows.iter().flat_map(|r| r.cells.iter()).map(|c| c.blocks.iter().filter_map(|b| b.as_para()).map(|p| p.final_text()).collect()).collect()
|
||||
}
|
||||
|
||||
/// #1744 review: an empty table or cell selected whole still copies, and Cut of the empty
|
||||
/// table removes it, as in Word.
|
||||
#[test]
|
||||
fn copy_and_cut_of_an_empty_table_or_cell() {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "Intro"}));
|
||||
run(&mut s, "caret.docEnd", json!({}));
|
||||
run(&mut s, "insert.table", json!({"rows": 1, "cols": 1}));
|
||||
run(&mut s, "table.selectTable", json!({}));
|
||||
run(&mut s, "edit.copy", json!({}));
|
||||
assert!(clip_is_table(&s), "an empty 1×1 table copies: {:?}", s.clipboard);
|
||||
run(&mut s, "edit.cut", json!({}));
|
||||
assert!(body_table_cells(&s).is_empty(), "Cut removes the empty table");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(body_table_cells(&s), vec![vec![String::new()]]);
|
||||
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
run(&mut s, "document.setText", json!({"text": "Intro"}));
|
||||
run(&mut s, "caret.docEnd", json!({}));
|
||||
run(&mut s, "insert.table", json!({"rows": 2, "cols": 2}));
|
||||
select_table_cells(&mut s, (0, 0), (0, 0));
|
||||
run(&mut s, "edit.copy", json!({}));
|
||||
assert!(clip_is_table(&s), "an empty cell copies as a one-cell table");
|
||||
run(&mut s, "edit.cut", json!({}));
|
||||
assert_eq!(body_table_cells(&s).len(), 1, "Cut of an empty cell leaves the table");
|
||||
}
|
||||
|
||||
/// #1744 review: a text selection over exactly a whole table (no whole-cell record) copies the
|
||||
/// table; so does one over exactly a table nested in a cell. Other users of the copied range
|
||||
/// read it as a table too: the selected text is tab separated, a Quick Part saved from it
|
||||
/// inserts a table.
|
||||
#[test]
|
||||
fn text_selection_of_a_whole_table_copies_the_table() {
|
||||
let mut s = table_doc();
|
||||
let at = |path: Vec<u32>, off: usize| json!({"story": "body", "path": path, "off": off});
|
||||
run(&mut s, "select.range", json!({"anchor": at(vec![1, 0, 0, 0], 0), "focus": at(vec![1, 1, 1, 0], 2)}));
|
||||
assert!(s.cell_range().is_none(), "a text selection");
|
||||
assert_eq!(s.selected_text(), "A1\tB1\nA2\tB2");
|
||||
run(&mut s, "edit.copy", json!({}));
|
||||
assert!(clip_is_table(&s));
|
||||
run(&mut s, "insert.quickParts", json!({"save": "grid"}));
|
||||
run(&mut s, "caret.docStart", json!({}));
|
||||
run(&mut s, "insert.quickParts", json!({"insert": "grid"}));
|
||||
assert_eq!(body_table_cells(&s).len(), 2, "the Quick Part is a table");
|
||||
|
||||
// A table nested in cell A1: selecting its text whole copies just it.
|
||||
let mut s = table_doc();
|
||||
run(&mut s, "select.range", json!({"anchor": at(vec![1, 0, 0, 0], 2), "focus": at(vec![1, 0, 0, 0], 2)}));
|
||||
run(&mut s, "insert.table", json!({"rows": 2, "cols": 1}));
|
||||
run(&mut s, "text.insert", json!({"text": "N1"}));
|
||||
run(&mut s, "text.tab", json!({}));
|
||||
run(&mut s, "text.insert", json!({"text": "N2"}));
|
||||
let (inner, _, _) = s.sel.focus.path.cell().unwrap();
|
||||
assert_eq!(inner.0.len(), 4, "nested in a cell");
|
||||
let first = [inner.0.clone(), vec![0, 0, 0]].concat();
|
||||
let last = [inner.0.clone(), vec![1, 0, 0]].concat();
|
||||
run(&mut s, "select.range", json!({"anchor": at(first, 0), "focus": at(last, 2)}));
|
||||
assert!(s.cell_range().is_none(), "a text selection");
|
||||
run(&mut s, "edit.copy", json!({}));
|
||||
let Some([wordcraft_doc::Block::Table(t)]) = s.clipboard.as_ref().map(|f| f.blocks.as_slice()) else { panic!("{:?}", s.clipboard_text) };
|
||||
assert_eq!(t.rows.len(), 2, "the nested table, not the outer one");
|
||||
}
|
||||
|
||||
/// #1744 review: with Track Changes on, Cut of the whole table or of a row marks the text
|
||||
/// deleted and keeps the table; one Undo takes the marks back.
|
||||
#[test]
|
||||
fn tracked_cut_of_a_table_or_row_marks_text_deleted() {
|
||||
let full = row_cells(&["A1", "B1", "C1", "A2", "B2", "C2", "A3", "B3", "C3"]);
|
||||
let mut s = table3_doc();
|
||||
s.doc.settings.track_changes = true;
|
||||
run(&mut s, "table.selectTable", json!({}));
|
||||
run(&mut s, "edit.cut", json!({}));
|
||||
assert!(clip_is_table(&s));
|
||||
assert_eq!(body_table_cells(&s), vec![full.clone()], "the table and its text stay, marked");
|
||||
assert!(body_table_final(&s).iter().all(String::is_empty), "{:?}", body_table_final(&s));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(body_table_final(&s), full);
|
||||
|
||||
select_table_cells(&mut s, (1, 1), (0, 2));
|
||||
run(&mut s, "edit.cut", json!({}));
|
||||
assert!(clip_is_table(&s));
|
||||
assert_eq!(body_table_cells(&s), vec![full.clone()]);
|
||||
assert_eq!(body_table_final(&s), row_cells(&["A1", "B1", "C1", "", "", "", "A3", "B3", "C3"]));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(body_table_final(&s), full);
|
||||
}
|
||||
|
||||
/// #1744 review: Cut of a table, or of cells, holding a content control locked against deletion
|
||||
/// is refused and changes nothing in the document; the copy is made first, so the clipboard
|
||||
/// holds the table, as for any refused Cut.
|
||||
#[test]
|
||||
fn cut_of_cells_with_a_locked_control_is_refused() {
|
||||
for whole in [true, false] {
|
||||
let mut s = table3_doc();
|
||||
run(&mut s, "edit.find", json!({"text": "B2"}));
|
||||
run(&mut s, "developer.richText", json!({"tag": "keep"}));
|
||||
run(&mut s, "developer.properties", json!({"control": "keep", "lockDelete": true}));
|
||||
if whole {
|
||||
run(&mut s, "table.selectTable", json!({}));
|
||||
} else {
|
||||
select_table_cells(&mut s, (1, 1), (0, 2));
|
||||
}
|
||||
let before = s.doc.clone();
|
||||
assert!(s.run("edit.cut", &json!({})).is_err(), "whole table: {whole}");
|
||||
assert_eq!(s.doc, before, "whole table: {whole}");
|
||||
assert!(s.status.contains("can't be deleted"), "{}", s.status);
|
||||
assert!(clip_is_table(&s), "whole table: {whole}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inserting_a_column_keeps_cell_widths_in_step_with_the_grid() {
|
||||
let mut s = s();
|
||||
@@ -2432,6 +2718,79 @@ fn table_properties_apply_in_one_step() {
|
||||
assert!(s.run("table.properties", &json!({"valign": "middle"})).is_err());
|
||||
}
|
||||
|
||||
/// #4857: Table Properties' row settings apply to every selected row (the whole table, say), the
|
||||
/// column width to every selected column and the cell settings to every selected cell, as in
|
||||
/// Word, in one undo step; with nothing selected, to the caret's alone.
|
||||
#[test]
|
||||
fn table_properties_apply_to_the_selected_rows_columns_and_cells() {
|
||||
use wordcraft_doc::props::VAlign;
|
||||
let mut s = s();
|
||||
run(&mut s, "insert.table", json!({"rows": 3, "cols": 2}));
|
||||
let table = |s: &Session| s.doc.body.iter().find_map(|b| b.as_table()).unwrap().clone();
|
||||
let cant_split = |s: &Session| table(s).rows.iter().map(|r| r.props.cant_split).collect::<Vec<_>>();
|
||||
run(&mut s, "table.properties", json!({"allowBreak": false}));
|
||||
assert_eq!(cant_split(&s), [true, false, false], "the caret's row alone");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
let before = table(&s);
|
||||
run(&mut s, "table.selectTable", json!({}));
|
||||
run(&mut s, "table.properties", json!({"allowBreak": false, "rowHeight": 30.0, "columnWidth": 100.0, "cellWidth": 90.0, "valign": "center"}));
|
||||
let t = table(&s);
|
||||
assert_eq!(cant_split(&s), [true, true, true], "every selected row");
|
||||
assert!(t.rows.iter().all(|r| r.props.height == Some(30.0)));
|
||||
assert_eq!(t.grid, [100.0, 100.0], "every selected column");
|
||||
assert!(t.rows.iter().flat_map(|r| &r.cells).all(|c| c.props.width == Some(90.0) && c.props.valign == Some(VAlign::Center)));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(table(&s), before, "one undo step");
|
||||
// A selected row: that row alone; a selected column: that column alone.
|
||||
run(&mut s, "caret.docStart", json!({}));
|
||||
run(&mut s, "text.tab", json!({}));
|
||||
run(&mut s, "text.tab", json!({}));
|
||||
run(&mut s, "table.selectRow", json!({}));
|
||||
run(&mut s, "table.properties", json!({"allowBreak": false}));
|
||||
assert_eq!(cant_split(&s), [false, true, false]);
|
||||
run(&mut s, "caret.docStart", json!({}));
|
||||
run(&mut s, "text.tab", json!({}));
|
||||
run(&mut s, "table.selectColumn", json!({}));
|
||||
run(&mut s, "table.properties", json!({"columnWidth": 150.0}));
|
||||
assert_eq!(table(&s).grid.get(1), Some(&150.0));
|
||||
assert_ne!(table(&s).grid.first(), Some(&150.0));
|
||||
}
|
||||
|
||||
/// #4857: a tall row across a page break moves to the next page whole once the whole table is
|
||||
/// selected and "Allow row to break across pages" is turned off.
|
||||
#[test]
|
||||
fn a_selected_table_kept_from_breaking_moves_its_row_whole() {
|
||||
// Lines above the table until its 12-line second row starts across the page break.
|
||||
let found = (0..80u32).find_map(|above| {
|
||||
let mut s = s();
|
||||
for k in 0..above {
|
||||
run(&mut s, "text.insert", json!({"text": format!("Line {k}")}));
|
||||
run(&mut s, "text.newParagraph", json!({}));
|
||||
}
|
||||
run(&mut s, "insert.table", json!({"rows": 3, "cols": 1}));
|
||||
run(&mut s, "text.insert", json!({"text": "Kiln"}));
|
||||
run(&mut s, "text.tab", json!({}));
|
||||
for i in 0..12 {
|
||||
run(&mut s, "text.insert", json!({"text": format!("Press note {i}")}));
|
||||
run(&mut s, "text.newParagraph", json!({}));
|
||||
}
|
||||
(row_pages(&mut s, above).len() == 2).then_some((s, above))
|
||||
});
|
||||
let (mut s, table) = found.expect("the row starts across the page break");
|
||||
run(&mut s, "table.selectTable", json!({}));
|
||||
run(&mut s, "table.properties", json!({"allowBreak": false}));
|
||||
assert_eq!(row_pages(&mut s, table), [1], "the row moves to the next page whole");
|
||||
}
|
||||
|
||||
/// The pages the second row of the table at body block `table` has lines on.
|
||||
fn row_pages(s: &mut Session, table: u32) -> Vec<usize> {
|
||||
let row = [table, 1];
|
||||
let on_page = |p: &wordcraft_layout::Page| {
|
||||
p.items.iter().any(|it| matches!(it, wordcraft_layout::Placed::Lines { path, .. } if path.0.get(..2) == Some(&row[..])))
|
||||
};
|
||||
s.layout().pages.iter().enumerate().filter(|(_, p)| on_page(p)).map(|(i, _)| i).collect()
|
||||
}
|
||||
|
||||
/// Table Layout › Text Direction (#226) cycles the selected cells' text through horizontal,
|
||||
/// top-to-bottom and bottom-to-top, and the layout turns the text.
|
||||
#[test]
|
||||
@@ -4594,7 +4953,7 @@ fn word_count_with_an_object_selected_counts_the_document() {
|
||||
fn custom_properties_delete_legacy_names_but_new_names_keep_the_limit() {
|
||||
let mut s = s();
|
||||
let long = "x".repeat(crate::cmd::file::MAX_CUSTOM_NAME + 1);
|
||||
s.doc.custom_props.push(wordcraft_doc::CustomProp { name: long.clone(), kind: "raw".into(), value: "<data/>".into() });
|
||||
s.doc.custom_props.push(wordcraft_doc::CustomProp { name: long.clone(), kind: "raw".into(), value: "<data/>".into(), ..Default::default() });
|
||||
let mut custom = serde_json::Map::new();
|
||||
custom.insert(long.clone(), serde_json::Value::Null);
|
||||
run(&mut s, "file.properties", json!({"custom": custom}));
|
||||
@@ -4631,6 +4990,36 @@ fn custom_properties_case_only_rename_keeps_the_new_spelling() {
|
||||
assert_eq!(props, [("Status", "draft")], "the property keeps the new spelling and its value");
|
||||
}
|
||||
|
||||
/// Linked text objects carry rename metadata explicitly and retain the existing input bounds.
|
||||
#[test]
|
||||
fn custom_properties_linked_values_are_typed_bounded_and_atomic() {
|
||||
let mut s = s();
|
||||
s.doc.set_custom_prop("Status", "draft");
|
||||
s.doc.custom_props[0].link_target = Some("OriginalBookmark".into());
|
||||
let before = s.doc.clone();
|
||||
let depth = s.undo_depth();
|
||||
let max = crate::cmd::file::MAX_CUSTOM_VALUE;
|
||||
for value in [
|
||||
json!({"value": "changed", "linkTarget": 7}),
|
||||
json!({"value": 7, "linkTarget": "OriginalBookmark"}),
|
||||
json!({"value": "changed", "linkTarget": "OriginalBookmark", "kind": "raw"}),
|
||||
json!({"value": "x".repeat(max + 1), "linkTarget": "OriginalBookmark"}),
|
||||
json!({"value": "changed", "linkTarget": "x".repeat(max + 1)}),
|
||||
] {
|
||||
assert!(s.run("file.properties", &json!({"title": "must roll back", "custom": {"Status": null, "Renamed": value}})).is_err());
|
||||
assert_eq!(s.doc, before);
|
||||
assert_eq!(s.undo_depth(), depth);
|
||||
}
|
||||
run(&mut s, "file.properties", json!({"custom": {"Status": {"value": "final", "linkTarget": "OriginalBookmark"}}}));
|
||||
run(&mut s, "file.properties", json!({"custom": {"Status": "string edit"}}));
|
||||
assert_eq!(s.doc.custom_props[0].link_target.as_deref(), Some("OriginalBookmark"));
|
||||
for value in [json!({"value": "unlinked", "linkTarget": null}), json!({"value": "unlinked"})] {
|
||||
s.doc.custom_props[0].link_target = Some("OriginalBookmark".into());
|
||||
run(&mut s, "file.properties", json!({"custom": {"Status": value}}));
|
||||
assert_eq!(s.doc.custom_props[0].link_target, None);
|
||||
}
|
||||
}
|
||||
|
||||
/// #2982: what the Custom Properties dialog saves (through `file.properties`) survives a .docx
|
||||
/// save and reopen.
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
//! Relative font sizes keep each selected run's size (#4903).
|
||||
|
||||
use serde_json::json;
|
||||
use wordcraft_doc::{Document, Pos};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn mixed() -> Session {
|
||||
let mut doc = Document::from_text("Large heading\nSmall detail");
|
||||
for (block, size) in [(0, 20.0), (1, 10.0)] {
|
||||
let end = doc.para_at(&Pos::body(block, 0)).unwrap().len();
|
||||
doc.format_range(&Pos::body(block, 0), &Pos::body(block, end), &|c| {
|
||||
c.size = Some(size);
|
||||
c.size_cs = Some(size);
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
let mut s = Session::new(doc);
|
||||
s.run("select.all", &json!({})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn sizes(s: &Session) -> Vec<f32> {
|
||||
(0..2)
|
||||
.map(|block| {
|
||||
let p = s.doc.para_at(&Pos::body(block, 0)).unwrap();
|
||||
s.doc.styles.resolve_char(p.props.style.as_deref(), p.props_of_char(0)).size
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_font_sizes_preserve_mixed_runs_and_one_undo() {
|
||||
for (command, expected) in [
|
||||
("format.growFont", [22.0, 10.5]),
|
||||
("format.shrinkFont", [18.0, 9.0]),
|
||||
("format.growFont1", [21.0, 11.0]),
|
||||
("format.shrinkFont1", [19.0, 9.0]),
|
||||
] {
|
||||
let mut s = mixed();
|
||||
let before = s.doc.clone();
|
||||
let selection = s.sel.clone();
|
||||
let depth = s.undo_depth();
|
||||
s.run(command, &json!({})).unwrap();
|
||||
assert_eq!(sizes(&s), expected, "{command}");
|
||||
assert_eq!(s.sel, selection);
|
||||
assert_eq!(s.undo_depth(), depth + 1);
|
||||
let after = s.doc.clone();
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc, before);
|
||||
s.run("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc, after);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_font_sizes_resolve_styles_and_preserve_other_properties() {
|
||||
use wordcraft_doc::props::CharProps;
|
||||
use wordcraft_doc::styles::{Style, StyleKind};
|
||||
|
||||
let mut s = mixed();
|
||||
s.doc.styles.default_chr.size_cs = Some(30.0);
|
||||
s.doc.styles.upsert(Style {
|
||||
id: "Large".into(),
|
||||
name: "Large".into(),
|
||||
based_on: Some("Normal".into()),
|
||||
chr: CharProps { size: Some(20.0), bold: Some(true), ..Default::default() },
|
||||
..Default::default()
|
||||
});
|
||||
s.doc.styles.upsert(Style {
|
||||
id: "Small".into(),
|
||||
name: "Small".into(),
|
||||
kind: StyleKind::Character,
|
||||
chr: CharProps { size: Some(10.0), italic: Some(true), ..Default::default() },
|
||||
..Default::default()
|
||||
});
|
||||
for block in 0..2 {
|
||||
let p = s.doc.para_mut(wordcraft_doc::StoryRef::Body, &wordcraft_doc::Path::top(block)).unwrap();
|
||||
p.props.style = Some(if block == 0 { "Large" } else { "Normal" }.into());
|
||||
for c in std::iter::once(&mut p.mark).chain(p.runs.iter_mut().map(|r| &mut r.props)) {
|
||||
c.size = None;
|
||||
c.size_cs = None;
|
||||
if block == 1 {
|
||||
c.style = Some("Small".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
s.run("format.growFont", &json!({})).unwrap();
|
||||
assert_eq!(sizes(&s), [22.0, 10.5]);
|
||||
for (block, size) in [(0, 22.0), (1, 10.5)] {
|
||||
let p = s.doc.para_at(&Pos::body(block, 0)).unwrap();
|
||||
assert_eq!(p.mark.size, Some(size));
|
||||
for (_, c) in p.run_ranges() {
|
||||
assert_eq!((c.size, c.size_cs), (Some(size), Some(size)), "both scripts still get the explicit override");
|
||||
let resolved = s.doc.styles.resolve_char(p.props.style.as_deref(), c);
|
||||
assert!(if block == 0 { resolved.bold } else { resolved.italic });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_font_sizes_change_only_the_selected_unicode_pieces() {
|
||||
let mut s = Session::new(Document::from_text("éA中Bz"));
|
||||
s.doc.format_range(&Pos::body(0, 2), &Pos::body(0, 3), &|c| c.size = Some(20.0)).unwrap();
|
||||
s.doc.format_range(&Pos::body(0, 3), &Pos::body(0, 6), &|c| c.size = Some(10.0)).unwrap();
|
||||
let before = s.doc.clone();
|
||||
s.run("select.range", &json!({"anchor": Pos::body(0, 2), "focus": Pos::body(0, 6)})).unwrap();
|
||||
s.run("format.growFont1", &json!({})).unwrap();
|
||||
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
|
||||
let original = before.para_at(&Pos::body(0, 0)).unwrap();
|
||||
assert_eq!(p.text, "éA中Bz");
|
||||
assert_eq!(p.props_of_char(2).size, Some(21.0));
|
||||
assert_eq!(p.props_of_char(3).size, Some(11.0));
|
||||
assert_eq!(p.props_of_char(0), original.props_of_char(0));
|
||||
assert_eq!(p.props_of_char(6), original.props_of_char(6));
|
||||
assert_eq!(p.mark, original.mark, "a partial selection leaves the paragraph mark alone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_font_sizes_keep_the_caret_and_word_targets() {
|
||||
let mut s = Session::new(Document::new());
|
||||
s.run("format.size", &json!({"size": 10})).unwrap();
|
||||
for size in [11.0, 12.0] {
|
||||
s.run("format.growFont1", &json!({})).unwrap();
|
||||
assert_eq!(s.pending.as_ref().unwrap().size, Some(size));
|
||||
assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().mark.size, Some(size));
|
||||
}
|
||||
s.run("text.insert", &json!({"text": "abcd ef"})).unwrap();
|
||||
s.doc.format_range(&Pos::body(0, 0), &Pos::body(0, 2), &|c| c.size = Some(20.0)).unwrap();
|
||||
s.doc.format_range(&Pos::body(0, 2), &Pos::body(0, 4), &|c| c.size = Some(10.0)).unwrap();
|
||||
s.run("caret.set", &json!({"pos": Pos::body(0, 2)})).unwrap();
|
||||
let selection = s.sel.clone();
|
||||
s.run("format.growFont1", &json!({})).unwrap();
|
||||
assert_eq!(s.sel, selection);
|
||||
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
|
||||
assert_eq!((p.props_of_char(0).size, p.props_of_char(2).size, p.props_of_char(5).size), (Some(21.0), Some(11.0), Some(12.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_font_sizes_keep_column_cell_and_text_box_targets() {
|
||||
use wordcraft_doc::{Path, StoryRef};
|
||||
|
||||
let mut s = super::column_doc();
|
||||
super::block(&mut s);
|
||||
for (block, size) in [(0, 20.0), (1, 10.0), (2, 14.0)] {
|
||||
s.doc.format_range(&Pos::body(block, 0), &Pos::body(block, 6), &|c| c.size = Some(size)).unwrap();
|
||||
}
|
||||
s.run("format.growFont1", &json!({})).unwrap();
|
||||
assert!(s.column_segments().is_some());
|
||||
for (block, size) in [(0, 20.0), (1, 10.0), (2, 14.0)] {
|
||||
let p = s.doc.para_at(&Pos::body(block, 0)).unwrap();
|
||||
assert_eq!((p.props_of_char(1).size, p.props_of_char(2).size, p.props_of_char(4).size), (Some(size), Some(size + 1.0), Some(size)));
|
||||
assert_eq!(p.mark.size, Some(size));
|
||||
}
|
||||
|
||||
let mut s = Session::new(Document::new());
|
||||
s.run("insert.table", &json!({"rows": 1, "cols": 2})).unwrap();
|
||||
let paths = [Path(vec![0, 0, 0, 0]), Path(vec![0, 0, 1, 0])];
|
||||
for (path, size) in paths.iter().zip([20.0, 10.0]) {
|
||||
s.doc.para_mut(StoryRef::Body, path).unwrap().mark.size = Some(size);
|
||||
}
|
||||
s.run("table.selectTable", &json!({})).unwrap();
|
||||
let _ = s.layout();
|
||||
s.run("format.growFont1", &json!({})).unwrap();
|
||||
for (path, size) in paths.iter().zip([21.0, 11.0]) {
|
||||
assert_eq!(s.doc.para(StoryRef::Body, path).unwrap().mark.size, Some(size), "empty cells retain distinct sizes");
|
||||
}
|
||||
|
||||
let mut s = Session::new(Document::from_text("Body"));
|
||||
let id = s.run("insert.textBox", &json!({"text": "AB"})).unwrap()["story"].as_u64().unwrap() as u32;
|
||||
let story = StoryRef::Part(id);
|
||||
let at = |off| Pos { story, path: Path::top(0), off };
|
||||
for (a, b, size) in [(0, 1, 20.0), (1, 2, 10.0)] {
|
||||
s.doc.format_range(&at(a), &at(b), &|c| c.size = Some(size)).unwrap();
|
||||
}
|
||||
s.run("select.range", &json!({"anchor": Pos::body(0, 0), "focus": Pos::body(0, "\u{fffc}".len())})).unwrap();
|
||||
let before = s.doc.para_at(&Pos::body(0, 0)).unwrap().clone();
|
||||
s.run("format.growFont1", &json!({})).unwrap();
|
||||
assert_eq!(*s.doc.para_at(&Pos::body(0, 0)).unwrap(), before);
|
||||
let p = s.doc.para(story, &Path::top(0)).unwrap();
|
||||
assert_eq!((p.props_of_char(0).size, p.props_of_char(1).size), (Some(21.0), Some(11.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_font_sizes_keep_bounds_protection_and_saved_sizes() {
|
||||
for (command, values, expected) in [
|
||||
("format.growFont", [72.0, 1638.0], [80.0, 1638.0]),
|
||||
("format.shrinkFont", [1.0, 8.0], [1.0, 1.0]),
|
||||
("format.growFont1", [1.0, 1638.0], [2.0, 1638.0]),
|
||||
("format.shrinkFont1", [1.0, 1638.0], [1.0, 1637.0]),
|
||||
] {
|
||||
let mut s = mixed();
|
||||
for (block, size) in values.into_iter().enumerate() {
|
||||
let end = s.doc.para_at(&Pos::body(block, 0)).unwrap().len();
|
||||
s.doc.format_range(&Pos::body(block, 0), &Pos::body(block, end), &|c| c.size = Some(size)).unwrap();
|
||||
}
|
||||
s.run(command, &json!({})).unwrap();
|
||||
assert_eq!(sizes(&s), expected, "{command}");
|
||||
let bytes = crate::io::save_bytes("sizes.docx", &s.doc).unwrap();
|
||||
let reopened = Session::new(crate::io::open_bytes("sizes.docx", &bytes).unwrap());
|
||||
assert_eq!(sizes(&reopened), expected, "saved {command}");
|
||||
s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap();
|
||||
let before = s.doc.clone();
|
||||
let depth = s.undo_depth();
|
||||
assert!(s.run(command, &json!({})).is_err());
|
||||
assert_eq!(s.doc, before);
|
||||
assert_eq!(s.undo_depth(), depth);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_font_sizes_keep_paragraph_mark_link_filtering() {
|
||||
let mut s = mixed();
|
||||
for (block, style) in [(0, "Hyperlink"), (1, "CustomLink")] {
|
||||
let p = s.doc.para_mut(wordcraft_doc::StoryRef::Body, &wordcraft_doc::Path::top(block)).unwrap();
|
||||
p.mark.link = Some("https://example.test/mark".into());
|
||||
p.mark.style = Some(style.into());
|
||||
p.runs[0].props.link = Some("https://example.test/text".into());
|
||||
p.runs[0].props.style = Some("Hyperlink".into());
|
||||
}
|
||||
s.run("format.growFont", &json!({})).unwrap();
|
||||
assert_eq!(sizes(&s), [22.0, 10.5]);
|
||||
for block in 0..2 {
|
||||
let p = s.doc.para_at(&Pos::body(block, 0)).unwrap();
|
||||
assert_eq!(p.mark.link, None);
|
||||
assert_eq!(p.mark.style.as_deref(), if block == 0 { None } else { Some("CustomLink") });
|
||||
assert!(p.run_ranges().all(|(_, c)| c.link.as_deref() == Some("https://example.test/text") && c.style.as_deref() == Some("Hyperlink")));
|
||||
}
|
||||
}
|
||||
@@ -1414,6 +1414,78 @@ fn indic_and_khmer_backspace_removes_one_character() {
|
||||
assert_eq!(paras(&s)[0].0, "a");
|
||||
}
|
||||
|
||||
/// Arabic vowel and Quranic marks are individually editable without retyping their base (#4757).
|
||||
#[test]
|
||||
fn arabic_backspace_removes_one_mark_and_keeps_the_base() {
|
||||
for mark in [
|
||||
'\u{0610}',
|
||||
'\u{064B}',
|
||||
'\u{064C}',
|
||||
'\u{064D}',
|
||||
'\u{064E}',
|
||||
'\u{064F}',
|
||||
'\u{0650}',
|
||||
'\u{0651}',
|
||||
'\u{0652}',
|
||||
'\u{065F}',
|
||||
'\u{0670}',
|
||||
'\u{06D6}',
|
||||
'\u{06DF}',
|
||||
'\u{06E7}',
|
||||
'\u{06EA}',
|
||||
'\u{0897}',
|
||||
'\u{089F}',
|
||||
'\u{08CA}',
|
||||
'\u{08E1}',
|
||||
'\u{08E3}',
|
||||
'\u{08FF}',
|
||||
'\u{10EFA}',
|
||||
'\u{10EFF}',
|
||||
] {
|
||||
let original = format!("Λ ب{mark}");
|
||||
let mut s = typed(&original);
|
||||
typ(&mut s, "\u{8}");
|
||||
assert_eq!(paras(&s)[0].0, "Λ ب", "mark U+{:04X}", u32::from(mark));
|
||||
assert_eq!(s.sel.focus.off, "Λ ب".len());
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(paras(&s)[0].0, original);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
typ(&mut s, "\u{0650}");
|
||||
assert_eq!(paras(&s)[0].0, "Λ ب\u{0650}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arabic_backspace_uses_stored_mark_order_but_delete_keeps_the_cluster() {
|
||||
for marks in ["\u{0651}\u{064E}", "\u{064E}\u{0651}"] {
|
||||
let mut s = typed(&format!("ب{marks}"));
|
||||
typ(&mut s, "\u{8}");
|
||||
assert_eq!(paras(&s)[0].0, format!("ب{}", marks.chars().next().unwrap()));
|
||||
typ(&mut s, "\u{8}");
|
||||
assert_eq!(paras(&s)[0].0, "ب");
|
||||
typ(&mut s, "\u{8}");
|
||||
assert!(paras(&s)[0].0.is_empty());
|
||||
let original = format!("ب{marks}ت");
|
||||
let mut s = typed(&original);
|
||||
// Arabic arrows move visually. They still visit only whole-cluster boundaries.
|
||||
let boundaries = [0, original.len() - "ت".len(), original.len()];
|
||||
let mut visited = vec![s.sel.focus.off];
|
||||
for command in ["caret.right", "caret.right", "caret.left", "caret.left"] {
|
||||
run(&mut s, command, json!({}));
|
||||
assert!(boundaries.contains(&s.sel.focus.off));
|
||||
visited.push(s.sel.focus.off);
|
||||
}
|
||||
assert!(boundaries.iter().all(|off| visited.contains(off)));
|
||||
s.sel = crate::Selection::caret(Pos::body(0, 0));
|
||||
run(&mut s, "text.delete", json!({}));
|
||||
assert_eq!(paras(&s)[0].0, "ت");
|
||||
}
|
||||
for cluster in ["e\u{0301}", "👩💻", "👍🏽", "🇵🇱", "ב\u{05B7}"] {
|
||||
let s = typed(&format!("a{cluster}\u{8}"));
|
||||
assert_eq!(paras(&s)[0].0, "a", "unchanged grapheme {cluster}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Press a shortcut the way the keyboard does: run the command bound to it.
|
||||
fn press(s: &mut Session, combo: &str) -> Result<serde_json::Value, crate::CmdError> {
|
||||
let id = s.registry.by_shortcut(combo).map(|c| c.id).unwrap_or_else(|| panic!("nothing bound to {combo}"));
|
||||
@@ -3124,6 +3196,37 @@ fn highlight_toggle_handles_mixed_inherited_and_pending_formatting() {
|
||||
assert_eq!(serde_json::to_value(&s.doc).unwrap(), before);
|
||||
}
|
||||
|
||||
/// #4086: a caret inside a word highlights what is typed next, not the word; Bold still
|
||||
/// formats the word.
|
||||
#[test]
|
||||
fn highlight_at_a_caret_leaves_the_word_alone() {
|
||||
let mut s = typed("Alpha Beta");
|
||||
run(&mut s, "caret.docStart", json!({}));
|
||||
run(&mut s, "caret.right", json!({}));
|
||||
run(&mut s, "format.highlight", json!({"color":"yellow"}));
|
||||
assert!(s.doc.body[0].as_para().unwrap().runs.iter().all(|r| r.props.highlight.is_none()));
|
||||
assert_eq!(s.pending.as_ref().unwrap().highlight, Some(wordcraft_doc::props::Highlight::Yellow));
|
||||
run(&mut s, "text.insert", json!({"text":"X"}));
|
||||
run(&mut s, "select.text", json!({"text":"X"}));
|
||||
assert_eq!(s.run("format.state", &json!({})).unwrap()["highlight"], json!("FFFF00"));
|
||||
run(&mut s, "select.text", json!({"text":"Beta"}));
|
||||
assert_eq!(s.run("format.state", &json!({})).unwrap()["highlight"], serde_json::Value::Null);
|
||||
// The toggle reads the pending colour, not the word's: a second press turns it off.
|
||||
run(&mut s, "caret.docStart", json!({}));
|
||||
run(&mut s, "caret.right", json!({}));
|
||||
run(&mut s, "format.highlight", json!({}));
|
||||
assert_eq!(s.pending.as_ref().unwrap().highlight, Some(wordcraft_doc::props::Highlight::Yellow));
|
||||
run(&mut s, "format.highlight", json!({}));
|
||||
assert_eq!(s.pending.as_ref().unwrap().highlight, Some(wordcraft_doc::props::Highlight::None));
|
||||
// Bold after Highlight formats the word and keeps both for what is typed next.
|
||||
run(&mut s, "format.highlight", json!({}));
|
||||
run(&mut s, "format.bold", json!({}));
|
||||
run(&mut s, "text.insert", json!({"text":"Y"}));
|
||||
run(&mut s, "select.text", json!({"text":"Y"}));
|
||||
let state = s.run("format.state", &json!({})).unwrap();
|
||||
assert_eq!((state["highlight"].clone(), state["bold"].clone()), (json!("FFFF00"), json!(true)));
|
||||
}
|
||||
|
||||
/// A differently coloured final paragraph must remain visible to the shortcut beyond
|
||||
/// the formatting-state query limit, including a style-inherited tail and Undo/Redo.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
@@ -3386,3 +3489,51 @@ fn double_space_adds_a_period_when_asked() {
|
||||
typ(&mut t, "Hello world");
|
||||
assert_eq!(text(&t), "Hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backspace_after_a_floating_shape_anchor_joins_paragraphs_and_keeps_the_shape() {
|
||||
let mut s = typed("Line one\nLine two");
|
||||
run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1], "off": 0}}));
|
||||
run(&mut s, "insert.textBox", json!({"text": "Box"}));
|
||||
let (a, f) = (Pos::body(1, 0), Pos::body(1, 3));
|
||||
run(&mut s, "select.range", json!({"anchor": a, "focus": f}));
|
||||
run(&mut s, "arrange.wrap", json!({"wrap": "inFrontOfText"}));
|
||||
let objects = |s: &Session| s.doc.para_at(&Pos::body(1, 0)).map(|p| p.object_offsets().len());
|
||||
assert_eq!(objects(&s), Some(1));
|
||||
run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1], "off": 3}}));
|
||||
run(&mut s, "text.backspace", json!({}));
|
||||
assert_eq!(s.doc.body.iter().filter(|b| b.as_para().is_some()).count(), 1);
|
||||
assert_eq!(objects(&s), None);
|
||||
assert_eq!(
|
||||
s.doc.para_at(&Pos::body(0, 0)).map(|p| (p.text.replace('\u{FFFC}', ""), p.object_offsets().len())),
|
||||
Some(("Line oneLine two".to_string(), 1))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backspace_after_marker_and_floating_anchors_still_joins_paragraphs() {
|
||||
let mut s = typed("Line one\nLine two");
|
||||
run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1], "off": 0}}));
|
||||
run(&mut s, "insert.textBox", json!({"text": "Box"}));
|
||||
let (a, f) = (Pos::body(1, 0), Pos::body(1, 3));
|
||||
run(&mut s, "select.range", json!({"anchor": a, "focus": f}));
|
||||
run(&mut s, "arrange.wrap", json!({"wrap": "inFrontOfText"}));
|
||||
s.doc.insert_object(&Pos::body(1, 0), wordcraft_doc::InlineObject::BookmarkStart { name: "anchor".into() }, &Default::default()).unwrap();
|
||||
s.doc.insert_object(&Pos::body(1, 0), wordcraft_doc::InlineObject::CommentStart { id: 1 }, &Default::default()).unwrap();
|
||||
run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1], "off": 9}}));
|
||||
run(&mut s, "text.backspace", json!({}));
|
||||
assert_eq!(s.doc.body.iter().filter(|b| b.as_para().is_some()).count(), 1);
|
||||
assert_eq!(s.doc.para_at(&Pos::body(0, 0)).map(|p| p.text.replace('\u{FFFC}', "")), Some("Line oneLine two".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backspace_cannot_join_into_locked_content_control() {
|
||||
let mut s = typed("Above\nInside");
|
||||
run(&mut s, "select.range", json!({"anchor": {"story": "body", "path": [1], "off": 0}, "focus": {"story": "body", "path": [1], "off": 6}}));
|
||||
run(&mut s, "developer.richText", json!({"tag": "locked"}));
|
||||
run(&mut s, "developer.properties", json!({"control": "locked", "lockEdit": true}));
|
||||
run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1], "off": 0}}));
|
||||
let before = s.doc.clone();
|
||||
assert!(s.run("text.backspace", &json!({})).is_err());
|
||||
assert_eq!(s.doc, before);
|
||||
}
|
||||
|
||||
@@ -76,6 +76,27 @@ fn moving_a_joined_shape_reroutes_its_connector() {
|
||||
assert_eq!(connector_ends(&mut s), ((172.0, 248.0), (300.0, 218.0)));
|
||||
}
|
||||
|
||||
/// A target on another page, or a missing target, leaves the connector's end free.
|
||||
#[test]
|
||||
fn rerouting_keeps_off_page_and_missing_targets_at_their_free_ends() {
|
||||
let mut s = joined();
|
||||
let mut connector = s.doc.para_at(&Pos::body(0, 11)).unwrap().object_at(11).unwrap().clone();
|
||||
let InlineObject::Shape { extra, .. } = &mut connector else { panic!("connector") };
|
||||
extra.end = Some(ConnEnd { id: 404, site: 2 });
|
||||
let mut p = Paragraph::new();
|
||||
p.props.page_break_before = Some(true);
|
||||
p.insert_object(0, connector.clone(), &Default::default()).unwrap();
|
||||
s.doc.body.push(para_block(p));
|
||||
s.touch();
|
||||
select(&mut s, 5);
|
||||
run(&mut s, "arrange.nudge", json!({"dx": 10, "dy": 30}));
|
||||
assert_eq!(s.layout().pages.len(), 2);
|
||||
assert_eq!(s.doc.para_at(&Pos::body(1, 0)).unwrap().object_at(0), Some(&connector));
|
||||
assert_eq!(connector_ends(&mut s), ((182.0, 248.0), (300.0, 218.0)));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.para_at(&Pos::body(1, 0)).unwrap().object_at(0), Some(&connector));
|
||||
}
|
||||
|
||||
/// A command that changes nothing but reroutes a stale connector is still an edit: the
|
||||
/// reroute must not be left without an undo step (#23).
|
||||
#[test]
|
||||
|
||||
@@ -161,4 +161,4 @@ impl FontDb {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
pub(crate) mod tests;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod sysfonts;
|
||||
pub(crate) mod sysfonts;
|
||||
|
||||
#[test]
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
@@ -12,7 +12,7 @@ fn renamed(file: &str) -> Vec<u8> {
|
||||
}
|
||||
|
||||
/// A bundled Source Sans 3 file renamed `family` (13 characters, as long as the original name).
|
||||
fn renamed_to(file: &str, family: &str) -> Vec<u8> {
|
||||
pub(crate) fn renamed_to(file: &str, family: &str) -> Vec<u8> {
|
||||
assert_eq!(family.len(), "Source Sans 3".len());
|
||||
let mut data = std::fs::read(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts").join(file)).unwrap();
|
||||
let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::<Vec<u8>>();
|
||||
@@ -27,7 +27,7 @@ fn renamed_to(file: &str, family: &str) -> Vec<u8> {
|
||||
}
|
||||
|
||||
/// The fonts in `faces` as one collection (TTC) file.
|
||||
fn collection(faces: &[Vec<u8>]) -> Vec<u8> {
|
||||
pub(crate) fn collection(faces: &[Vec<u8>]) -> Vec<u8> {
|
||||
let mut out = b"ttcf".to_vec();
|
||||
out.extend_from_slice(&0x0001_0000_u32.to_be_bytes());
|
||||
out.extend_from_slice(&(faces.len() as u32).to_be_bytes());
|
||||
|
||||
@@ -24,7 +24,7 @@ pub use kurbo::BezPath;
|
||||
pub use script::{fallback_rank, is_emoji, needs_layout_tables, script_fallbacks};
|
||||
use skrifa::MetadataProvider;
|
||||
use skrifa::instance::Size;
|
||||
pub use ui_fallback::{SystemUiFont, is_cjk, system_cjk_ui_font, system_cjk_ui_font_for, ui_needs_system_cjk, ui_uncovered_cjk};
|
||||
pub use ui_fallback::{SystemUiFont, is_cjk, system_cjk_ui_font, system_cjk_ui_font_for, system_hant_ui_font, ui_needs_system_cjk, ui_uncovered_cjk};
|
||||
|
||||
/// A font from the optional craft-fonts build input (https://github.com/storytold/craft-fonts;
|
||||
/// empty unless built with `CRAFT_FONTS_DIR`, see `build.rs`).
|
||||
|
||||
+141
-22
@@ -70,6 +70,31 @@ const MAX_NEEDED: usize = 4096;
|
||||
/// as blank space.
|
||||
pub(crate) const OUTLINE_TABLES: [&[u8; 4]; 4] = [b"glyf", b"CFF ", b"CFF2", b"VARC"];
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Region {
|
||||
Hans,
|
||||
Hant,
|
||||
Other,
|
||||
}
|
||||
|
||||
impl Region {
|
||||
fn from_hans(hans: bool) -> Region {
|
||||
if hans { Region::Hans } else { Region::Other }
|
||||
}
|
||||
|
||||
fn rank(self, family: &str) -> u8 {
|
||||
match self {
|
||||
Region::Hans => u8::from(family.ends_with(" SC") || family.ends_with(" GB") || family.contains("YaHei")),
|
||||
Region::Hant => {
|
||||
let taiwan =
|
||||
[" TC", " TW"].iter().any(|s| family.ends_with(s)) || ["JhengHei", "CNS", "LiHei", "MingLiU"].iter().any(|s| family.contains(s));
|
||||
if taiwan { 2 } else { u8::from(family.ends_with(" HK")) }
|
||||
}
|
||||
Region::Other => 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A character only a font for the language has: a simplified-only hanzi, kana, or Hangul.
|
||||
fn probe(hans: bool, korean: bool) -> char {
|
||||
if korean {
|
||||
@@ -139,6 +164,46 @@ fn candidates(hans: bool, korean: bool) -> Vec<&'static str> {
|
||||
}
|
||||
}
|
||||
|
||||
fn hant_candidates() -> &'static [&'static str] {
|
||||
if cfg!(windows) {
|
||||
&["msjh.ttc", "msjh.ttf", "mingliu.ttc"]
|
||||
} else if cfg!(target_os = "macos") {
|
||||
&[
|
||||
"PingFang.ttc",
|
||||
"Hiragino Sans CNS.ttc",
|
||||
"LiHeiPro.ttf",
|
||||
"/System/Library/PrivateFrameworks/FontServices.framework/Resources/Reserved/PingFangUI.ttc",
|
||||
]
|
||||
} else {
|
||||
&[
|
||||
"noto-cjk/NotoSansCJK-Regular.ttc",
|
||||
"opentype/noto/NotoSansCJK-Regular.ttc",
|
||||
"google-noto-sans-cjk-fonts/NotoSansCJK-Regular.ttc",
|
||||
"google-noto-cjk/NotoSansCJK-Regular.ttc",
|
||||
"noto-cjk/NotoSansCJKtc-Regular.otf",
|
||||
"opentype/noto/NotoSansCJKtc-Regular.otf",
|
||||
"google-noto-cjk/NotoSansCJKtc-Regular.otf",
|
||||
"adobe-source-han-sans/SourceHanSansTW-Regular.otf",
|
||||
"adobe-source-han-sans/SourceHanSansTC-Regular.otf",
|
||||
"opentype/adobe-source-han-sans/SourceHanSansTW-Regular.otf",
|
||||
"NotoSansCJK-Regular.ttc",
|
||||
"NotoSansCJKtc-Regular.otf",
|
||||
"SourceHanSansTW-Regular.otf",
|
||||
"SourceHanSansTC-Regular.otf",
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
pub fn system_hant_ui_font(needed: &[char]) -> Option<SystemUiFont> {
|
||||
let probe = ['體'];
|
||||
let needed = if needed.is_empty() { &probe[..] } else { needed.get(..MAX_NEEDED).unwrap_or(needed) };
|
||||
hant_font_in(&with_asset_data(crate::system_font_dirs()), needed)
|
||||
}
|
||||
|
||||
fn hant_font_in(dirs: &[std::path::PathBuf], needed: &[char]) -> Option<SystemUiFont> {
|
||||
find_ui_font(dirs, hant_candidates(), needed, Region::Hant).filter(|f| Region::Hant.rank(&f.family) > 0)
|
||||
}
|
||||
|
||||
/// The installed CJK font for a Chinese (`hans`) or other interface that best covers `needed`
|
||||
/// (the characters the embedded faces lack; empty: a character typical of the language), if one
|
||||
/// is found. Never panics on damaged files.
|
||||
@@ -150,7 +215,7 @@ pub fn system_cjk_ui_font(hans: bool, needed: &[char]) -> Option<SystemUiFont> {
|
||||
pub fn system_cjk_ui_font_for(hans: bool, korean: bool, needed: &[char]) -> Option<SystemUiFont> {
|
||||
let probe = [probe(hans, korean)];
|
||||
let needed = if needed.is_empty() { &probe[..] } else { needed.get(..MAX_NEEDED).unwrap_or(needed) };
|
||||
find_ui_font(&with_asset_data(crate::system_font_dirs()), &candidates(hans, korean), needed, hans)
|
||||
find_ui_font(&with_asset_data(crate::system_font_dirs()), &candidates(hans, korean), needed, Region::from_hans(hans))
|
||||
}
|
||||
|
||||
/// `dirs` and, for each macOS font asset folder among them (`com_apple_MobileAsset_Font<N>`),
|
||||
@@ -175,9 +240,9 @@ pub(crate) fn with_asset_data(dirs: Vec<std::path::PathBuf>) -> Vec<std::path::P
|
||||
/// The font file among `names` (tried in order, each in every folder of `dirs`) whose best face
|
||||
/// maps all of `needed`, or failing that the one mapping the most of them (the earliest on a
|
||||
/// tie). A file that maps none of them is never chosen.
|
||||
pub(crate) fn find_ui_font(dirs: &[std::path::PathBuf], names: &[&str], needed: &[char], hans: bool) -> Option<SystemUiFont> {
|
||||
pub(crate) fn find_ui_font(dirs: &[std::path::PathBuf], names: &[&str], needed: &[char], region: Region) -> Option<SystemUiFont> {
|
||||
let mut tried = std::collections::HashSet::new();
|
||||
let mut best: Option<(usize, SystemUiFont)> = None;
|
||||
let mut best: Option<((u8, usize), SystemUiFont)> = None;
|
||||
for path in names.iter().flat_map(|n| dirs.iter().map(move |d| d.join(n))) {
|
||||
if !tried.insert(path.clone()) {
|
||||
continue;
|
||||
@@ -187,13 +252,15 @@ pub(crate) fn find_ui_font(dirs: &[std::path::PathBuf], names: &[&str], needed:
|
||||
continue;
|
||||
}
|
||||
let Ok(bytes) = std::fs::read(&path) else { continue };
|
||||
let Some((index, family, covered)) = pick_face(&bytes, needed, hans) else { continue };
|
||||
if best.as_ref().is_some_and(|(b, _)| *b >= covered) {
|
||||
let Some((index, family, covered)) = pick_face(&bytes, needed, region) else { continue };
|
||||
let fit = if region == Region::Hant { region.rank(&family) } else { 0 };
|
||||
if best.as_ref().is_some_and(|(b, _)| *b >= (fit, covered)) {
|
||||
continue;
|
||||
}
|
||||
log::debug!("interface CJK fallback: {family} ({}), {covered} of {} characters", path.display(), needed.len());
|
||||
best = Some((covered, SystemUiFont { family, bytes, index }));
|
||||
if covered >= needed.len() {
|
||||
let done = covered >= needed.len() && (region != Region::Hant || fit == 2);
|
||||
best = Some(((fit, covered), SystemUiFont { family, bytes, index }));
|
||||
if done {
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -205,7 +272,7 @@ pub(crate) fn find_ui_font(dirs: &[std::path::PathBuf], names: &[&str], needed:
|
||||
/// family (`… SC`, `… GB`, as in a pan-CJK collection). Faces with none of the
|
||||
/// [`OUTLINE_TABLES`] are skipped. `None` if the header isn't a font's or no face with outlines
|
||||
/// maps any of `needed`.
|
||||
pub(crate) fn pick_face(data: &[u8], needed: &[char], hans: bool) -> Option<(u32, String, usize)> {
|
||||
pub(crate) fn pick_face(data: &[u8], needed: &[char], region: Region) -> Option<(u32, String, usize)> {
|
||||
let magic = data.get(..4)?;
|
||||
if ![&[0, 1, 0, 0][..], b"OTTO", b"ttcf"].contains(&magic) {
|
||||
return None;
|
||||
@@ -214,7 +281,7 @@ pub(crate) fn pick_face(data: &[u8], needed: &[char], hans: bool) -> Option<(u32
|
||||
FileRef::Font(_) => 1,
|
||||
FileRef::Collection(c) => c.len().min(MAX_FACES),
|
||||
};
|
||||
let mut best: Option<((usize, bool, bool), u32, String)> = None;
|
||||
let mut best: Option<((u8, usize, bool, u8), u32, String)> = None;
|
||||
for i in 0..count {
|
||||
let Ok(f) = skrifa::FontRef::from_index(data, i) else { continue };
|
||||
if !OUTLINE_TABLES.iter().any(|t| f.table_data(skrifa::Tag::new(t)).is_some()) {
|
||||
@@ -226,13 +293,14 @@ pub(crate) fn pick_face(data: &[u8], needed: &[char], hans: bool) -> Option<(u32
|
||||
continue;
|
||||
}
|
||||
let Some((family, style, _)) = crate::fontdb::face_names(&f) else { continue };
|
||||
let regional = !hans || family.ends_with(" SC") || family.ends_with(" GB") || family.contains("YaHei");
|
||||
let score = (covered, style.eq_ignore_ascii_case("Regular"), regional);
|
||||
let rank = region.rank(&family);
|
||||
let first = if region == Region::Hant { rank } else { 0 };
|
||||
let score = (first, covered, style.eq_ignore_ascii_case("Regular"), rank);
|
||||
if best.as_ref().is_none_or(|(s, ..)| score > *s) {
|
||||
best = Some((score, i, family));
|
||||
}
|
||||
}
|
||||
best.map(|((covered, ..), i, family)| (i, family, covered))
|
||||
best.map(|((_, covered, ..), i, family)| (i, family, covered))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -255,6 +323,57 @@ mod tests {
|
||||
assert_eq!(probe(false, true), '한');
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn traditional_chinese_interface_picks_the_taiwan_face() {
|
||||
use crate::fontdb::tests::sysfonts::{collection, renamed_to};
|
||||
assert_eq!(
|
||||
hant_candidates().first(),
|
||||
Some(&if cfg!(windows) {
|
||||
"msjh.ttc"
|
||||
} else if cfg!(target_os = "macos") {
|
||||
"PingFang.ttc"
|
||||
} else {
|
||||
"noto-cjk/NotoSansCJK-Regular.ttc"
|
||||
})
|
||||
);
|
||||
let face = |family| renamed_to("SourceSans3-Regular.ttf", family);
|
||||
let pan = collection(&[face("Probe Sans JP"), face("Probe Sans KR"), face("Probe Sans SC"), face("Probe Sans TC"), face("Probe Sans HK")]);
|
||||
assert_eq!(pick_face(&pan, &['A'], Region::Hant), Some((3, "Probe Sans TC".to_string(), 1)));
|
||||
assert_eq!(pick_face(&pan, &['A'], Region::Hans), Some((2, "Probe Sans SC".to_string(), 1)));
|
||||
assert_eq!(pick_face(&pan, &['A'], Region::Other), Some((0, "Probe Sans JP".to_string(), 1)));
|
||||
let hk = collection(&[face("Probe Sans SC"), face("Probe Sans HK")]);
|
||||
assert_eq!(pick_face(&hk, &['A'], Region::Hant).map(|f| f.0), Some(1));
|
||||
assert_eq!(Region::Hant.rank("Microsoft JhengHei"), 2);
|
||||
assert_eq!(Region::Hant.rank("Microsoft YaHei"), 0);
|
||||
let dir = std::env::temp_dir().join(format!("wc-uifallback-hant-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let names = ["missing.ttc", "damaged.ttc", "sc.ttc", "pan.ttc"];
|
||||
std::fs::write(dir.join("damaged.ttc"), b"ttcf\0\x01\0\0\xff\xff\xff\xff").unwrap();
|
||||
std::fs::write(dir.join("sc.ttc"), collection(&[face("Probe Sans SC"), face("Probe Sans JP")])).unwrap();
|
||||
std::fs::write(dir.join("pan.ttc"), &pan).unwrap();
|
||||
let found = find_ui_font(std::slice::from_ref(&dir), &names, &['A'], Region::Hant).unwrap();
|
||||
assert_eq!((found.family.as_str(), found.index), ("Probe Sans TC", 3));
|
||||
assert_eq!(find_ui_font(std::slice::from_ref(&dir), &names, &['A'], Region::Hans).unwrap().family, "Probe Sans SC");
|
||||
let only_sc = dir.join("only-sc");
|
||||
std::fs::create_dir_all(only_sc.join("noto-cjk")).unwrap();
|
||||
std::fs::write(only_sc.join("noto-cjk/NotoSansCJK-Regular.ttc"), collection(&[face("Probe Sans SC")])).unwrap();
|
||||
assert!(hant_font_in(std::slice::from_ref(&only_sc), &['A']).is_none());
|
||||
std::fs::write(only_sc.join("noto-cjk/NotoSansCJK-Regular.ttc"), &pan).unwrap();
|
||||
if !cfg!(windows) && !cfg!(target_os = "macos") {
|
||||
assert_eq!(hant_font_in(std::slice::from_ref(&only_sc), &['A']).map(|f| f.index), Some(3));
|
||||
}
|
||||
assert!(hant_font_in(std::slice::from_ref(&only_sc), &['體']).is_none());
|
||||
let user = dir.join("user-fonts");
|
||||
std::fs::create_dir_all(&user).unwrap();
|
||||
std::fs::write(user.join("NotoSansCJKtc-Regular.otf"), face("Probe Sans TC")).unwrap();
|
||||
if !cfg!(windows) && !cfg!(target_os = "macos") {
|
||||
assert_eq!(hant_font_in(std::slice::from_ref(&user), &['A']).map(|f| f.family), Some("Probe Sans TC".to_string()));
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn needs_system_cjk_when_an_embedded_face_lacks_an_interface_character() {
|
||||
// A face without CJK glyphs stands in for a craft-fonts face that lacks some (#486).
|
||||
@@ -289,21 +408,21 @@ mod tests {
|
||||
std::fs::write(dir.join("msyh.ttc"), b"<html>not a font</html>").unwrap();
|
||||
std::fs::write(dir.join("sub/good.ttf"), sans).unwrap();
|
||||
let names = ["missing.ttf", "Deng.ttf", "msyh.ttc", "sub/good.ttf"];
|
||||
let found = find_ui_font(std::slice::from_ref(&dir), &names, &['A'], true).unwrap();
|
||||
let found = find_ui_font(std::slice::from_ref(&dir), &names, &['A'], Region::Hans).unwrap();
|
||||
assert_eq!((found.family.as_str(), found.index, found.bytes.len()), ("Source Sans 3", 0, sans.len()));
|
||||
// No candidate maps a CJK character: nothing is chosen.
|
||||
assert!(find_ui_font(std::slice::from_ref(&dir), &names, &['页'], true).is_none());
|
||||
assert!(pick_face(b"OTT", &['A'], false).is_none());
|
||||
assert!(find_ui_font(std::slice::from_ref(&dir), &names, &['页'], Region::Hans).is_none());
|
||||
assert!(pick_face(b"OTT", &['A'], Region::Other).is_none());
|
||||
// macOS 12 keeps PingFang in `AssetsV2/com_apple_MobileAsset_Font7/<hash>.asset/AssetData`.
|
||||
std::fs::write(assets.join("0a1b.asset/AssetData/PingFang.ttc"), sans).unwrap();
|
||||
std::fs::write(assets.join("not-an-asset/AssetData/Hidden.ttc"), sans).unwrap();
|
||||
let dirs = with_asset_data(vec![dir.clone(), assets.clone()]);
|
||||
assert_eq!(dirs, [dir.clone(), assets.join("0a1b.asset/AssetData"), assets.clone()]);
|
||||
let found = find_ui_font(&dirs, &["PingFang.ttc"], &['A'], true).unwrap();
|
||||
let found = find_ui_font(&dirs, &["PingFang.ttc"], &['A'], Region::Hans).unwrap();
|
||||
assert_eq!(found.family, "Source Sans 3");
|
||||
assert!(find_ui_font(&dirs, &["Hidden.ttc"], &['A'], true).is_none());
|
||||
assert!(find_ui_font(&dirs, &["Hidden.ttc"], &['A'], Region::Hans).is_none());
|
||||
// No file maps every needed character: the one mapping the most is used.
|
||||
let found = find_ui_font(&dirs, &["sub/good.ttf", "PingFang.ttc"], &['A', '页'], true).unwrap();
|
||||
let found = find_ui_font(&dirs, &["sub/good.ttf", "PingFang.ttc"], &['A', '页'], Region::Hans).unwrap();
|
||||
assert_eq!(found.family, "Source Sans 3");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -318,17 +437,17 @@ mod tests {
|
||||
let glyf = (0..tables).map(|r| 12 + r * 16).find(|&at| &hvgl[at..at + 4] == b"glyf").unwrap();
|
||||
hvgl[glyf..glyf + 4].copy_from_slice(b"hvgl");
|
||||
assert!(skrifa::FontRef::new(&hvgl).unwrap().charmap().map('A').is_some());
|
||||
assert!(pick_face(&hvgl, &['A'], true).is_none());
|
||||
assert_eq!(pick_face(sans, &['A'], true), Some((0, "Source Sans 3".to_string(), 1)));
|
||||
assert!(pick_face(&hvgl, &['A'], Region::Hans).is_none());
|
||||
assert_eq!(pick_face(sans, &['A'], Region::Hans), Some((0, "Source Sans 3".to_string(), 1)));
|
||||
// `find_ui_font` uses the next file, and returns `None` when no other file is listed.
|
||||
let dir = std::env::temp_dir().join(format!("wc-uifallback-outlines-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("hvgl.ttf"), &hvgl).unwrap();
|
||||
std::fs::write(dir.join("glyf.ttf"), sans).unwrap();
|
||||
let found = find_ui_font(std::slice::from_ref(&dir), &["hvgl.ttf", "glyf.ttf"], &['A'], true).unwrap();
|
||||
let found = find_ui_font(std::slice::from_ref(&dir), &["hvgl.ttf", "glyf.ttf"], &['A'], Region::Hans).unwrap();
|
||||
assert!(found.bytes == sans);
|
||||
assert!(find_ui_font(std::slice::from_ref(&dir), &["hvgl.ttf"], &['A'], true).is_none());
|
||||
assert!(find_ui_font(std::slice::from_ref(&dir), &["hvgl.ttf"], &['A'], Region::Hans).is_none());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ workspace = true
|
||||
[dependencies]
|
||||
wordcraft-doc = { workspace = true }
|
||||
wordcraft-geom = { workspace = true, features = ["image"] }
|
||||
wordcraft-metafile = { workspace = true }
|
||||
image = { workspace = true }
|
||||
quick-xml = { workspace = true }
|
||||
zip = { workspace = true }
|
||||
|
||||
@@ -66,9 +66,9 @@ fn apply_font_attributes(attrs: &[(String, String)], fmt: &mut Fmt) {
|
||||
let Some(size) = attr(attrs, "size") else { return };
|
||||
let size = size.trim();
|
||||
let points = if let Some(rest) = size.strip_prefix('+') {
|
||||
rest.parse::<i32>().ok().map(|delta| 3 + delta)
|
||||
rest.parse::<i32>().ok().map(|delta| 3i32.saturating_add(delta))
|
||||
} else if let Some(rest) = size.strip_prefix('-') {
|
||||
rest.parse::<i32>().ok().map(|delta| 3 - delta)
|
||||
rest.parse::<i32>().ok().map(|delta| 3i32.saturating_sub(delta))
|
||||
} else {
|
||||
size.parse::<i32>().ok()
|
||||
};
|
||||
@@ -336,10 +336,11 @@ impl<'r> Builder<'r> {
|
||||
}
|
||||
|
||||
pub(super) fn text(&mut self, t: &str) {
|
||||
if let Some(title) = &mut self.title {
|
||||
title.push_str(t);
|
||||
return;
|
||||
}
|
||||
if self.in_head() {
|
||||
if let Some(title) = &mut self.title {
|
||||
title.push_str(t);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if t.is_empty() {
|
||||
|
||||
@@ -10,7 +10,20 @@ pub fn parse(s: &str) -> Flow {
|
||||
|
||||
/// [`parse`], loading pictures that aren't `data:` URIs through `images`.
|
||||
pub fn parse_with(s: &str, images: ImageLoader) -> Flow {
|
||||
let mut lx = Lexer { s, pos: 0 };
|
||||
// HTML normalizes input newlines before tokenization, not after decoding character references.
|
||||
let normalized = if s.contains('\r') {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut chunks = s.split('\r');
|
||||
out.push_str(chunks.next().unwrap_or_default());
|
||||
for chunk in chunks {
|
||||
out.push('\n');
|
||||
out.push_str(chunk.strip_prefix('\n').unwrap_or(chunk));
|
||||
}
|
||||
std::borrow::Cow::Owned(out)
|
||||
} else {
|
||||
std::borrow::Cow::Borrowed(s)
|
||||
};
|
||||
let mut lx = Lexer { s: &normalized, pos: 0 };
|
||||
let mut b = Builder::new(images);
|
||||
let mut guard = 0usize;
|
||||
while let Some(t) = lx.next() {
|
||||
|
||||
@@ -50,6 +50,26 @@ fn borderless_tables_export_without_cell_borders() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_newlines_preserve_preformatted_lines() {
|
||||
for newline in ["\n", "\r\n", "\r"] {
|
||||
let f = parse(&format!("<pre>{newline} Alpha{newline} <b>Beta</b>{newline}{newline} Gamma</pre>"));
|
||||
assert_eq!(texts(&f), ["CodeNone: Alpha", "CodeNone: Beta", "CodeNone:", "CodeNone: Gamma"], "{newline:?}");
|
||||
let FBlock::Para(p) = &f.blocks[1] else { panic!() };
|
||||
assert!(matches!(p.inlines.last(), Some(Inline::Text(t, f)) if t == "Beta" && f.bold));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_newlines_are_normalized_before_character_references() {
|
||||
let f = parse("<p style='white-space:pre'>A B\r\nC\rD</p><p>A\r\nB\rC</p>");
|
||||
assert_eq!(texts(&f), ["NormalNone:A\rB\nC\nD", "NormalNone:A B C"]);
|
||||
let f = parse("<p style='white-space:pre-wrap'>α\r\r\nβ γ</p><a href='https://example.test/a\r\nb\rc d'>link</a>");
|
||||
assert_eq!(texts(&f), ["NormalNone:α\n\nβ\rγ", "NormalNone:link"]);
|
||||
let FBlock::Para(p) = &f.blocks[1] else { panic!() };
|
||||
assert!(matches!(p.inlines.first(), Some(Inline::Text(_, f)) if f.link.as_deref() == Some("https://example.test/a\nb\nc\rd")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn css_preformatted_paragraphs_keep_edge_spaces() {
|
||||
// #439: `white-space:pre` keeps leading and trailing spaces like `<pre>`; normal
|
||||
@@ -474,3 +494,23 @@ fn many_formatted_leading_breaks_trim_quickly() {
|
||||
let texts: Vec<String> = f.blocks.iter().filter_map(|b| if let FBlock::Para(p) = b { Some(p.text()) } else { None }).collect();
|
||||
assert_eq!(texts, ["x"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn font_size_at_the_integer_limits_does_not_overflow() {
|
||||
// `<font size>` offsets were added to 3 unchecked, so the extreme values panicked in debug builds.
|
||||
for size in ["+2147483647", "--2147483648", "-2147483647", "+99999999999"] {
|
||||
let f = parse(&format!("<font size=\"{size}\">x</font>"));
|
||||
assert!(texts(&f).iter().any(|t| t.ends_with(":x")), "size={size}");
|
||||
}
|
||||
// The offsets saturate, then clamp to the 1..=7 scale: 7 is 36 pt, 1 is 7.5 pt.
|
||||
let size = |html: &str| match &parse(html).blocks[0] {
|
||||
FBlock::Para(p) => p.inlines.iter().find_map(|i| match i {
|
||||
Inline::Text(_, fmt) => fmt.size,
|
||||
_ => None,
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
assert_eq!(size("<font size=\"+2147483647\">x</font>"), Some(36.0));
|
||||
assert_eq!(size("<font size=\"--2147483648\">x</font>"), Some(36.0));
|
||||
assert_eq!(size("<font size=\"-2147483647\">x</font>"), Some(7.5));
|
||||
}
|
||||
|
||||
@@ -233,7 +233,7 @@ impl Writer {
|
||||
match i {
|
||||
Inline::Text(t, f) => {
|
||||
let s = self.span(t, f);
|
||||
o.push_str(&s);
|
||||
push_apart(&mut o, &s);
|
||||
}
|
||||
Inline::Image(img) => {
|
||||
let alt = if img.alt.trim().is_empty() { "Picture".to_string() } else { format!("Picture: {}", img.alt.trim()) };
|
||||
@@ -244,7 +244,7 @@ impl Writer {
|
||||
// LaTeX keeps no ruby (no package): its base text, in the run's formatting.
|
||||
Inline::Ruby { base, fmt, .. } => {
|
||||
let s = self.span(base, fmt);
|
||||
o.push_str(&s);
|
||||
push_apart(&mut o, &s);
|
||||
}
|
||||
Inline::Equation { linear, display } => {
|
||||
self.uses.math = true;
|
||||
@@ -398,6 +398,18 @@ fn num(v: f32) -> String {
|
||||
if r.fract() == 0.0 { format!("{r:.0}") } else { format!("{r}") }
|
||||
}
|
||||
|
||||
/// Appends a run, writing `{}` between a `-` or `'` ending `o` and the same character starting
|
||||
/// `s`, so TeX does not join them across runs into a dash or quote.
|
||||
fn push_apart(o: &mut String, s: &str) {
|
||||
if let Some(c) = s.chars().next()
|
||||
&& (c == '-' || c == '\'')
|
||||
&& o.ends_with(c)
|
||||
{
|
||||
o.push_str("{}");
|
||||
}
|
||||
o.push_str(s);
|
||||
}
|
||||
|
||||
/// Text with LaTeX's special characters escaped.
|
||||
pub fn escape_text(s: &str) -> String {
|
||||
let mut o = String::with_capacity(s.len() + 8);
|
||||
|
||||
@@ -449,3 +449,14 @@ fn nested_multicolumn_wrappers_are_bounded() {
|
||||
assert_eq!((t.rows[0][0].colspan, t.rows[0][0].rowspan), (2, 2));
|
||||
assert_eq!(t.rows[0][0].text(), "Both");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ligature_characters_split_across_runs_stay_apart() {
|
||||
let d = crate::html::import(
|
||||
"<p><span style=\"font-family:Arial\">a-</span><span style=\"font-family:Georgia\">-b '</span><span style=\"font-family:Arial\">'x</span></p>",
|
||||
);
|
||||
let tex = export(&d);
|
||||
assert!(tex.contains("a-{}-b '{}'x"), "{tex}");
|
||||
let back = import(tex.as_bytes());
|
||||
assert_eq!(back.plain_text(Default::default()).trim(), "a--b ''x", "{tex}");
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::blocks::thematic_break;
|
||||
use super::inlines::within;
|
||||
use super::inlines::{is_punct, within};
|
||||
use super::{Align, Arc, Cell, Document, FBlock, FTable, Fmt, Inline, Kind, model};
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
@@ -166,30 +166,43 @@ fn inlines_md_impl(inlines: &[Inline], in_table: bool) -> String {
|
||||
}
|
||||
let want: Vec<Mark> =
|
||||
[(f.bold, Mark::Bold), (f.italic, Mark::Italic), (f.strike, Mark::Strike)].iter().filter(|x| x.0).map(|x| x.1).collect();
|
||||
let body = if f.code {
|
||||
if in_table { code_span(&text.replace('|', "\\|")) } else { code_span(&text) }
|
||||
} else {
|
||||
escape(&text).replace('\n', "\\\n").replace('\t', " ")
|
||||
};
|
||||
// Close until the stack is a prefix of what we want.
|
||||
let mut close = String::new();
|
||||
while let Some(pos) = stack.iter().position(|m| !want.contains(m)) {
|
||||
while stack.len() > pos {
|
||||
if let Some(m) = stack.pop() {
|
||||
out.push_str(marker(m));
|
||||
close.push_str(marker(m));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut open = String::new();
|
||||
for m in want {
|
||||
if !stack.contains(&m) {
|
||||
out.push_str(marker(m));
|
||||
open.push_str(marker(m));
|
||||
stack.push(m);
|
||||
}
|
||||
}
|
||||
if f.code {
|
||||
if in_table {
|
||||
let t = text.replace('|', "\\|");
|
||||
out.push_str(&code_span(&t));
|
||||
} else {
|
||||
out.push_str(&code_span(&text));
|
||||
}
|
||||
} else {
|
||||
out.push_str(&escape(&text).replace('\n', "\\\n").replace('\t', " "));
|
||||
// CommonMark only reads a closing run after punctuation as closing when whitespace or
|
||||
// punctuation follows it, and an opening run before punctuation as opening when whitespace
|
||||
// or punctuation precedes it. Where a letter sits there instead, or an opening run would
|
||||
// directly follow a closing run (the two would merge into one run), an empty HTML comment (which readers drop) supplies
|
||||
// the punctuation.
|
||||
let prev = out.chars().next_back();
|
||||
let next = body.chars().next();
|
||||
let word = |c: Option<char>| c.is_some_and(|c| !c.is_whitespace() && !is_punct(c));
|
||||
out.push_str(&close);
|
||||
let close_needs = !close.is_empty() && prev.is_some_and(is_punct) && (!open.is_empty() || word(next));
|
||||
let open_needs = !open.is_empty() && next.is_some_and(is_punct) && (!close.is_empty() || word(prev));
|
||||
if close_needs || open_needs {
|
||||
out.push_str(EMPTY_COMMENT);
|
||||
}
|
||||
out.push_str(&open);
|
||||
out.push_str(&body);
|
||||
}
|
||||
close_all(&mut out, &mut stack);
|
||||
if let Some(l) = &link {
|
||||
@@ -229,6 +242,9 @@ fn entity_like(rest: &str) -> bool {
|
||||
!body.is_empty() && body.chars().all(|c| c.is_ascii_alphanumeric())
|
||||
}
|
||||
|
||||
/// Inline HTML that Markdown readers drop, used to separate emphasis markers from letters.
|
||||
const EMPTY_COMMENT: &str = "<!-- -->";
|
||||
|
||||
fn marker(m: Mark) -> &'static str {
|
||||
match m {
|
||||
Mark::Bold => "**",
|
||||
|
||||
@@ -16,7 +16,7 @@ enum Node {
|
||||
Delim { ch: char, orig: usize, left: usize, open: bool, close: bool, opens: Vec<Emph>, closes: Vec<Emph> },
|
||||
}
|
||||
|
||||
fn is_punct(c: char) -> bool {
|
||||
pub(super) fn is_punct(c: char) -> bool {
|
||||
c.is_ascii_punctuation() || (!c.is_alphanumeric() && !c.is_whitespace() && !c.is_control())
|
||||
}
|
||||
|
||||
@@ -116,8 +116,8 @@ struct LinkScan<'a> {
|
||||
budget: usize,
|
||||
}
|
||||
|
||||
impl LinkScan<'_> {
|
||||
fn parse_at(&mut self, open: usize) -> Option<(String, Arc<str>, usize)> {
|
||||
impl<'a> LinkScan<'a> {
|
||||
fn parse_at(&mut self, open: usize) -> Option<(&'a str, Arc<str>, usize)> {
|
||||
let bytes = self.text.as_bytes();
|
||||
let close = match self.known.get(&open).copied() {
|
||||
Some(found) => found,
|
||||
@@ -136,19 +136,32 @@ impl LinkScan<'_> {
|
||||
let destination = link_dest(self.text, close + 1);
|
||||
self.budget = self.budget.saturating_sub(destination.as_ref().map_or(self.text.len(), |d| d.1).saturating_sub(close));
|
||||
let (destination, end) = destination?;
|
||||
return Some((label_text.to_string(), Arc::from(destination), end));
|
||||
return Some((label_text, Arc::from(destination), end));
|
||||
}
|
||||
// Full and shortcut reference links use definitions collected before inline parsing.
|
||||
if self.refs.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let (label, end) = match self.text.get(close + 1..).and_then(|rest| rest.strip_prefix('[')) {
|
||||
Some(rest) => {
|
||||
let shut = rest.find(']')?;
|
||||
let ahead = within(rest, self.budget);
|
||||
let shut = ahead.find(']');
|
||||
self.budget = self.budget.saturating_sub(shut.map_or(ahead.len(), |n| n + 1));
|
||||
let shut = shut?;
|
||||
let inner = rest.get(..shut)?;
|
||||
let label = if inner.trim().is_empty() { label_text } else { inner };
|
||||
(label, close + 2 + shut + 1)
|
||||
}
|
||||
None => (label_text, close + 1),
|
||||
};
|
||||
Some((label_text.to_string(), self.refs.get(&ref_label(label))?.clone(), end))
|
||||
// A cached bracket pair still costs a pass over its label. Without charging it,
|
||||
// nested undefined references normalize every suffix again.
|
||||
if label.len() > self.budget {
|
||||
self.budget = 0;
|
||||
return None;
|
||||
}
|
||||
self.budget -= label.len();
|
||||
Some((label_text, self.refs.get(&ref_label(label))?.clone(), end))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,10 +270,10 @@ fn tag_attr(tag: &str, name: &str) -> Option<String> {
|
||||
|
||||
fn tokenize(s: &str, base: &Fmt, depth: usize, refs: &Refs) -> Vec<Node> {
|
||||
let mut nodes: Vec<Node> = Vec::new();
|
||||
// Bytes the link and image scans below may look ahead in total. Each unmatched `[` or `![(`
|
||||
// would otherwise rescan the rest of the text, which is quadratic on hostile input; once the
|
||||
// budget is gone, further brackets are plain text. A scan pays for what it reads; brackets an
|
||||
// earlier scan passed are looked up in `known` instead.
|
||||
// Bytes the link/image scans and reference-label lookups may read in total. Unmatched
|
||||
// brackets and nested undefined references would otherwise rescan the rest of the text,
|
||||
// which is quadratic on hostile input. Once the budget is gone, further brackets are plain
|
||||
// text. A scan pays for what it reads; earlier bracket matches are looked up in `known`.
|
||||
let ticks = Backticks::new(s);
|
||||
let mut links = LinkScan { text: s, refs, ticks: &ticks, known: HashMap::new(), budget: s.len().saturating_mul(8).saturating_add(8192) };
|
||||
let b = s.as_bytes();
|
||||
@@ -324,7 +337,7 @@ fn tokenize(s: &str, base: &Fmt, depth: usize, refs: &Refs) -> Vec<Node> {
|
||||
match parsed {
|
||||
Some((inner, dest, end)) if img => {
|
||||
flush!();
|
||||
let alt: String = parse_inlines(&inner, &Fmt::default(), depth + 1, refs)
|
||||
let alt: String = parse_inlines(inner, &Fmt::default(), depth + 1, refs)
|
||||
.iter()
|
||||
.filter_map(|x| if let Inline::Text(t, _) = x { Some(t.as_str()) } else { None })
|
||||
.collect();
|
||||
@@ -343,7 +356,7 @@ fn tokenize(s: &str, base: &Fmt, depth: usize, refs: &Refs) -> Vec<Node> {
|
||||
if f.link.is_none() {
|
||||
model::dropped(model::Dropped::Link);
|
||||
}
|
||||
for x in parse_inlines(&inner, &f, depth + 1, refs) {
|
||||
for x in parse_inlines(inner, &f, depth + 1, refs) {
|
||||
nodes.push(Node::Inl(x));
|
||||
}
|
||||
i = end;
|
||||
|
||||
@@ -79,6 +79,53 @@ fn reference_links_resolve_and_their_definitions_leave_the_body() {
|
||||
assert_eq!(links(fenced), vec![], "a fenced definition defines nothing");
|
||||
}
|
||||
|
||||
/// Cached bracket pairs must not make undefined shortcut references normalize every nested
|
||||
/// suffix again. One unrelated definition keeps this on the reference-lookup path.
|
||||
#[test]
|
||||
fn nested_undefined_reference_labels_have_bounded_work() {
|
||||
let literal = format!("prefix {}x{} suffix", "[".repeat(128_000), "]".repeat(128_000));
|
||||
let src = format!("{literal}\n\n[other]: https://example.com/other\n");
|
||||
let started = std::time::Instant::now();
|
||||
let doc = import(&src);
|
||||
let elapsed = started.elapsed();
|
||||
assert_eq!(doc.plain_text(wordcraft_doc::StoryRef::Body), literal);
|
||||
assert!(elapsed < std::time::Duration::from_secs(10), "nested undefined references took {elapsed:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_lookup_keeps_long_unicode_labels_and_nested_text() {
|
||||
// The work guard is not a label-length cap. Collapsed/full/shortcut references keep their
|
||||
// case and whitespace matching, even when lowercasing changes the number of UTF-8 bytes.
|
||||
let label = format!("{}İ END", "Écho ".repeat(1500));
|
||||
let key = label.to_lowercase();
|
||||
let src = format!("[{label}] [{label}][] [full][{label}]\n\n[{key}]: https://example.com/long\n");
|
||||
let flow = parse(&src);
|
||||
let FBlock::Para(p) = &flow.blocks[0] else { panic!("paragraph") };
|
||||
let links: Vec<_> = p
|
||||
.inlines
|
||||
.iter()
|
||||
.filter_map(|x| match x {
|
||||
Inline::Text(t, f) if f.link.is_some() => Some((t.as_str(), f.link.as_deref())),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
links,
|
||||
vec![
|
||||
(label.as_str(), Some("https://example.com/long")),
|
||||
(label.as_str(), Some("https://example.com/long")),
|
||||
("full", Some("https://example.com/long"))
|
||||
]
|
||||
);
|
||||
|
||||
let refs = Refs::from([("known".into(), Arc::from("https://example.com/known"))]);
|
||||
let out = parse_inlines("[outer [known]] tail [inline](https://example.com/inline)", &Fmt::default(), 0, &refs);
|
||||
let text: String = out.iter().filter_map(|x| if let Inline::Text(t, _) = x { Some(t.as_str()) } else { None }).collect();
|
||||
assert_eq!(text, "[outer known] tail inline");
|
||||
assert!(out.iter().any(|x| matches!(x, Inline::Text(t, f) if t == "known" && f.link.as_deref() == Some("https://example.com/known"))));
|
||||
assert!(out.iter().any(|x| matches!(x, Inline::Text(t, f) if t == "inline" && f.link.as_deref() == Some("https://example.com/inline"))));
|
||||
}
|
||||
|
||||
/// A definition has to start a block (CommonMark §4.7), and a fence opens and closes by the
|
||||
/// same rules the block parser uses. Reading a line without that context deleted text: a
|
||||
/// definition-shaped line inside a paragraph, and every such line inside a code block whose
|
||||
@@ -154,6 +201,69 @@ fn export_escapes_round_trip() {
|
||||
assert!(q.list.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_emphasis_next_to_punctuation_round_trips() {
|
||||
let marks: [(&str, fn(&mut Fmt)); 3] = [("bold", |f| f.bold = true), ("italic", |f| f.italic = true), ("strike", |f| f.strike = true)];
|
||||
let cases: [&[(&str, bool)]; 4] = [
|
||||
&[("注意:", true), ("请阅读", false)],
|
||||
&[("a.", true), ("b", false)],
|
||||
&[("a", false), ("!", true), ("b", false)],
|
||||
&[("a", false), ("(x)", true), ("b", false)],
|
||||
];
|
||||
for (name, set) in marks {
|
||||
for runs in cases {
|
||||
let mut p = Para::default();
|
||||
for (t, on) in runs {
|
||||
let mut f = Fmt::default();
|
||||
if *on {
|
||||
set(&mut f);
|
||||
}
|
||||
p.push_text(t, &f);
|
||||
}
|
||||
let md = inlines_md(&p.inlines);
|
||||
let back = parse(&md);
|
||||
let FBlock::Para(q) = &back.blocks[0] else { panic!() };
|
||||
let want: String = runs.iter().map(|r| r.0).collect();
|
||||
assert_eq!(q.text(), want, "{name} exported as {md:?}");
|
||||
let got: Vec<(String, bool)> = q
|
||||
.inlines
|
||||
.iter()
|
||||
.filter_map(|i| if let Inline::Text(t, f) = i { Some((t.clone(), f.bold || f.italic || f.strike)) } else { None })
|
||||
.collect();
|
||||
let expect: Vec<(String, bool)> = runs.iter().map(|(t, on)| (t.to_string(), *on)).collect();
|
||||
assert_eq!(got, expect, "{name} exported as {md:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_adjacent_emphasis_next_to_punctuation_round_trips() {
|
||||
let b = Fmt { bold: true, ..Fmt::default() };
|
||||
let i = Fmt { italic: true, ..Fmt::default() };
|
||||
let s = Fmt { strike: true, ..Fmt::default() };
|
||||
let cases: [&[(&str, &Fmt)]; 6] = [
|
||||
&[("ab", &b), ("!c", &i)],
|
||||
&[("ab", &i), ("!c", &b)],
|
||||
&[("ab", &b), ("!c", &s)],
|
||||
&[("ab", &s), ("(c)", &b)],
|
||||
&[("a.", &b), ("b", &i)],
|
||||
&[("a.", &b), ("!b", &i)],
|
||||
];
|
||||
for runs in cases {
|
||||
let mut p = Para::default();
|
||||
for (t, f) in runs {
|
||||
p.push_text(t, f);
|
||||
}
|
||||
let md = inlines_md(&p.inlines);
|
||||
let back = parse(&md);
|
||||
let FBlock::Para(q) = &back.blocks[0] else { panic!() };
|
||||
let got: Vec<(String, bool, bool, bool)> =
|
||||
q.inlines.iter().filter_map(|x| if let Inline::Text(t, f) = x { Some((t.clone(), f.bold, f.italic, f.strike)) } else { None }).collect();
|
||||
let expect: Vec<(String, bool, bool, bool)> = runs.iter().map(|(t, f)| (t.to_string(), f.bold, f.italic, f.strike)).collect();
|
||||
assert_eq!(got, expect, "exported as {md:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_line_start_preserves_indentation() {
|
||||
assert_eq!(escape_line_start(" indented text".to_string()), " indented text");
|
||||
|
||||
@@ -21,7 +21,7 @@ mod types;
|
||||
|
||||
pub use color::parse_color;
|
||||
pub use from_doc::{ListCounter, flow_paras, from_doc, from_doc_tracked, is_mono, para_kind};
|
||||
pub use media::{base64_decode, base64_encode, data_uri, decode_bounded, image_px, make_img, mime_of, percent_decode, sniff_image};
|
||||
pub use media::{base64_decode, base64_encode, data_uri, decode_bounded, image_px, make_img, mime_of, percent_decode, sized_img, sniff_image};
|
||||
pub use to_doc::{char_props, part_blocks, to_doc, to_doc_in};
|
||||
pub use types::{
|
||||
CODE_STYLE, Cell, FBlock, FTable, Flow, Fmt, FmtChange, Img, Inline, Kind, ListInfo, MAX_DEPTH, MAX_FONT_NAME, MAX_LINK, MONO_FONT, Meta, Para,
|
||||
|
||||
@@ -42,7 +42,7 @@ pub fn parse_color(s: &str) -> Option<Rgb> {
|
||||
let d = |i: usize| h.get(i..i + 1).and_then(|x| u8::from_str_radix(x, 16).ok()).map(|v| v * 17);
|
||||
return Some(Rgb(d(0)?, d(1)?, d(2)?));
|
||||
}
|
||||
if h.len() == 8 && h.is_ascii() {
|
||||
if h.len() == 8 && h.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
let p = |i: usize| h.get(i..i + 2).and_then(|x| u8::from_str_radix(x, 16).ok());
|
||||
return Some(Rgb(p(0)?, p(2)?, p(4)?));
|
||||
}
|
||||
@@ -119,3 +119,14 @@ pub fn parse_color(s: &str) -> Option<Rgb> {
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn eight_digit_hex_rejects_signs() {
|
||||
assert_eq!(parse_color("#+1+2+3+4"), None);
|
||||
assert_eq!(parse_color("#11223344"), Some(Rgb(0x11, 0x22, 0x33)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,14 @@ pub fn decode_bounded(data: &[u8]) -> Option<image::DynamicImage> {
|
||||
pub fn make_img(data: impl Into<Arc<Vec<u8>>>, w: Option<f32>, h: Option<f32>, alt: &str) -> Option<Img> {
|
||||
let data = data.into();
|
||||
let ext = sniff_image(&data)?;
|
||||
let (pw, ph) = image_px(&data).map(|(a, b)| (a as f32 * 0.75, b as f32 * 0.75)).unwrap_or((72.0, 72.0));
|
||||
let natural = image_px(&data).map(|(a, b)| (a as f32 * 0.75, b as f32 * 0.75));
|
||||
Some(sized_img(data, ext, natural, w, h, alt))
|
||||
}
|
||||
|
||||
/// A picture of a known format `ext` whose natural size (points) the caller knows, if at all: a
|
||||
/// metafile, which `image` can't measure. Sized like [`make_img`].
|
||||
pub fn sized_img(data: Arc<Vec<u8>>, ext: &str, natural: Option<(f32, f32)>, w: Option<f32>, h: Option<f32>, alt: &str) -> Img {
|
||||
let (pw, ph) = natural.filter(|(a, b)| a.is_finite() && b.is_finite()).unwrap_or((72.0, 72.0));
|
||||
let (pw, ph) = (pw.max(1.0), ph.max(1.0));
|
||||
let (mut w, mut h) = match (w.filter(|v| v.is_finite() && *v > 0.0), h.filter(|v| v.is_finite() && *v > 0.0)) {
|
||||
(Some(w), Some(h)) => (w, h),
|
||||
@@ -45,7 +52,7 @@ pub fn make_img(data: impl Into<Arc<Vec<u8>>>, w: Option<f32>, h: Option<f32>, a
|
||||
h *= 468.0 / w;
|
||||
w = 468.0;
|
||||
}
|
||||
Some(Img { data, ext: ext.to_string(), w: w.clamp(1.0, 1584.0), h: h.clamp(1.0, 1584.0), alt: alt.to_string() })
|
||||
Img { data, ext: ext.to_string(), w: w.clamp(1.0, 1584.0), h: h.clamp(1.0, 1584.0), alt: alt.to_string() }
|
||||
}
|
||||
|
||||
const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
|
||||
@@ -341,9 +341,19 @@ impl Writer {
|
||||
}
|
||||
|
||||
fn picture(&mut self, img: &model::Img, out: &mut String) {
|
||||
// A metafile's `\picw`/`\pich` are its size in 0.01 mm (#4259).
|
||||
let hundredths = ((img.w * 2540.0 / 72.0).round() as i64, (img.h * 2540.0 / 72.0).round() as i64);
|
||||
let (data, blip) = match img.ext.as_str() {
|
||||
"png" => (img.data.to_vec(), "\\pngblip"),
|
||||
"jpeg" | "jpg" => (img.data.to_vec(), "\\jpegblip"),
|
||||
// The bytes decide, as on reading: an EMF starts with its header record, type 1 (a
|
||||
// DOCX may store one under `.wmf`).
|
||||
"emf" | "wmf" if img.data.starts_with(&[1, 0, 0, 0]) => (img.data.to_vec(), "\\emfblip"),
|
||||
// RTF holds a WMF without its 22-byte placeable header.
|
||||
"emf" | "wmf" => {
|
||||
let data = if img.data.starts_with(&[0xD7, 0xCD, 0xC6, 0x9A]) { img.data.get(22..).unwrap_or_default() } else { img.data.as_slice() };
|
||||
(data.to_vec(), "\\wmetafile8")
|
||||
}
|
||||
_ => {
|
||||
// Re-encode other formats as PNG, bounding the decode so a tiny file declaring
|
||||
// huge dimensions can't exhaust memory on export.
|
||||
@@ -355,7 +365,14 @@ impl Writer {
|
||||
(buf, "\\pngblip")
|
||||
}
|
||||
};
|
||||
let (pw, ph) = model::image_px(&data).unwrap_or((1, 1));
|
||||
// A placeable header with nothing after it leaves no picture to write.
|
||||
if data.is_empty() {
|
||||
return;
|
||||
}
|
||||
let (pw, ph) = match blip {
|
||||
"\\emfblip" | "\\wmetafile8" => hundredths,
|
||||
_ => model::image_px(&data).map(|(a, b)| (i64::from(a), i64::from(b))).unwrap_or((1, 1)),
|
||||
};
|
||||
out.push_str(&format!(
|
||||
"{{\\pict{blip}\\picw{pw}\\pich{ph}\\picwgoal{}\\pichgoal{}\n",
|
||||
(img.w * 20.0).round() as i64,
|
||||
|
||||
@@ -62,6 +62,10 @@ pub(super) struct Reader {
|
||||
pub(super) pict_h: Option<f32>,
|
||||
pub(super) pict_sx: f32,
|
||||
pub(super) pict_sy: f32,
|
||||
/// `\picw`/`\pich` as written, read for metafiles (0 when absent).
|
||||
pub(super) pict_nat: (i32, i32),
|
||||
/// The mapping mode `N` of `\wmetafileN`.
|
||||
pub(super) pict_mm: i32,
|
||||
pub(super) bkmk: Option<(usize, String)>,
|
||||
/// Authors of tracked changes (`\revtbl`), and the entry being read.
|
||||
rev_authors: Vec<String>,
|
||||
|
||||
@@ -457,6 +457,94 @@ fn shppict_imports_one_image_and_skips_legacy_duplicate() -> Result<(), String>
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// #4259: `\wmetafile8` and `\emfblip` pictures are kept as WMF/EMF media (they were dropped),
|
||||
/// sized from `\picwgoal`/`\pichgoal` and `\picscalex`/`\picscaley`, else from `\picw`/`\pich`
|
||||
/// (0.01 mm for a metafile). As Word writes a floating picture: the `\shprslt` copy is read.
|
||||
#[test]
|
||||
fn metafile_pictures_are_imported() -> Result<(), String> {
|
||||
// A plain WMF: the 9-word header (type 1, version 0x300, 12 words, max record 3), then EOF.
|
||||
let wmf: Vec<u8> = [1u16, 9, 0x300, 12, 0, 0, 3, 0, 0, 3, 0, 0].iter().flat_map(|w| w.to_le_bytes()).collect();
|
||||
let hex: String = wmf.iter().map(|b| format!("{b:02x}")).collect();
|
||||
let images = |rtf: String| -> Result<Vec<model::Img>, String> {
|
||||
let f = parse(rtf.as_bytes())?;
|
||||
Ok(f.blocks
|
||||
.iter()
|
||||
.filter_map(|b| match b {
|
||||
FBlock::Para(p) => Some(p.inlines.clone()),
|
||||
FBlock::Table(_) => None,
|
||||
})
|
||||
.flatten()
|
||||
.filter_map(|i| match i {
|
||||
Inline::Image(img) => Some(img),
|
||||
_ => None,
|
||||
})
|
||||
.collect())
|
||||
};
|
||||
let pict = format!("{{\\pict\\wmetafile8\\picw38761\\pich12832\\picwgoal21972\\pichgoal7272\\picscalex45\\picscaley45 {hex}}}");
|
||||
let shape = format!("{{\\shp{{\\*\\shpinst{{\\sp{{\\sn pib}}{{\\sv {pict}}}}}}}{{\\shprslt{pict}}}}}");
|
||||
let got = images(format!("{{\\rtf1\\ansi Before{shape}After\\par}}"))?;
|
||||
let [img] = got.as_slice() else { return Err(format!("{} pictures", got.len())) };
|
||||
assert_eq!((img.ext.as_str(), img.data.as_ref()), ("wmf", &wmf));
|
||||
// 21972 twips at 45% is 494.4 pt, shrunk to the 468 pt column like every imported picture.
|
||||
assert_eq!(img.w, 468.0);
|
||||
assert!((img.h - 7272.0 / 20.0 * 0.45 * 468.0 / 494.37).abs() < 0.1, "{}", img.h);
|
||||
// No goal size: the natural size, 2540 × 1270 hundredths of a millimetre = 72 × 36 pt; under
|
||||
// MM_TEXT (`\wmetafile1`) the same numbers are pixels.
|
||||
let hex_of = |b: &[u8]| -> String { b.iter().map(|b| format!("{b:02x}")).collect() };
|
||||
let mut emf = vec![0u8; 88];
|
||||
emf[0] = 1;
|
||||
emf[4] = 88;
|
||||
emf[40..44].copy_from_slice(b" EMF");
|
||||
let emf_hex = hex_of(&emf);
|
||||
let got = images(format!("{{\\rtf1\\ansi{{\\pict\\emfblip\\picw2540\\pich1270 {emf_hex}}}\\par}}"))?;
|
||||
let [img] = got.as_slice() else { return Err(format!("{} pictures", got.len())) };
|
||||
assert_eq!((img.ext.as_str(), img.w, img.h, img.data.as_ref()), ("emf", 72.0, 36.0, &emf));
|
||||
let got = images(format!("{{\\rtf1\\ansi{{\\pict\\wmetafile1\\picw96\\pich48 {hex}}}\\par}}"))?;
|
||||
let [img] = got.as_slice() else { return Err(format!("{} pictures", got.len())) };
|
||||
assert_eq!((img.ext.as_str(), img.w, img.h), ("wmf", 72.0, 36.0));
|
||||
// `\picscalex`/`\picscaley` scale the natural size when there is no goal size.
|
||||
let got = images(format!("{{\\rtf1\\ansi{{\\pict\\wmetafile8\\picw2540\\pich1270\\picscalex50\\picscaley200 {hex}}}\\par}}"))?;
|
||||
let [img] = got.as_slice() else { return Err(format!("{} pictures", got.len())) };
|
||||
assert_eq!((img.w, img.h), (36.0, 72.0));
|
||||
// The other mapping modes use their own units: 0.1 mm, 0.01 in, 0.001 in and twips.
|
||||
for (mode, w, h) in [(2, 254, 127), (4, 100, 50), (5, 1000, 500), (6, 1440, 720)] {
|
||||
let got = images(format!("{{\\rtf1\\ansi{{\\pict\\wmetafile{mode}\\picw{w}\\pich{h} {hex}}}\\par}}"))?;
|
||||
let [img] = got.as_slice() else { return Err(format!("{} pictures", got.len())) };
|
||||
assert!((img.w - 72.0).abs() < 0.01 && (img.h - 36.0).abs() < 0.01, "mode {mode}: {} x {}", img.w, img.h);
|
||||
}
|
||||
// Hostile: a zero or negative natural size falls back to the default 72 pt square, and bytes
|
||||
// that are no picture at all are dropped, whatever the control word says.
|
||||
let got = images(format!("{{\\rtf1\\ansi{{\\pict\\wmetafile8\\picw0\\pich-5 {hex}}}\\par}}"))?;
|
||||
let [img] = got.as_slice() else { return Err(format!("{} pictures", got.len())) };
|
||||
assert_eq!((img.w, img.h), (72.0, 72.0));
|
||||
for blip in ["wmetafile8", "emfblip"] {
|
||||
assert!(images(format!("{{\\rtf1\\ansi{{\\pict\\{blip}\\picw2540\\pich1270 0123456789abcdef}}\\par}}"))?.is_empty(), "{blip}");
|
||||
}
|
||||
// Saved as RTF and read back: the same picture, the EMF byte for byte, the WMF without the
|
||||
// placeable header RTF has no room for.
|
||||
let placeable: Vec<u8> = [0xD7u8, 0xCD, 0xC6, 0x9A].into_iter().chain([0u8; 18]).chain(wmf.iter().copied()).collect();
|
||||
for (blip, data, want, ext) in [("emfblip", &emf, &emf, "emf"), ("wmetafile8", &wmf, &wmf, "wmf"), ("wmetafile8", &placeable, &wmf, "wmf")] {
|
||||
let rtf = format!("{{\\rtf1\\ansi{{\\pict\\{blip}\\picw2540\\pich1270 {}}}\\par}}", hex_of(data));
|
||||
let got = images(export(&import(rtf.as_bytes())?))?;
|
||||
let [img] = got.as_slice() else { return Err(format!("{blip}: {} pictures", got.len())) };
|
||||
assert_eq!((img.ext.as_str(), img.data.as_ref(), img.w, img.h), (ext, want, 72.0, 36.0), "{blip}");
|
||||
}
|
||||
// An EMF stored under `.wmf` (a DOCX can) is written as the EMF its bytes are.
|
||||
let mut doc = import(format!("{{\\rtf1\\ansi{{\\pict\\wmetafile8\\picw2540\\pich1270 {hex}}}\\par}}").as_bytes())?;
|
||||
let (key, bytes) = doc.media.iter_mut().next().ok_or("no media")?;
|
||||
assert!(key.ends_with(".wmf"), "{key}");
|
||||
*bytes = std::sync::Arc::new(emf.clone());
|
||||
let rtf = export(&doc);
|
||||
assert!(rtf.contains("\\emfblip") && !rtf.contains("\\wmetafile"), "{rtf}");
|
||||
let got = images(rtf)?;
|
||||
let [img] = got.as_slice() else { return Err(format!("{} pictures", got.len())) };
|
||||
assert_eq!((img.ext.as_str(), img.data.as_ref()), ("emf", &emf));
|
||||
// A placeable header with no records after it has nothing to write: no empty picture group.
|
||||
*doc.media.values_mut().next().ok_or("no media")? = std::sync::Arc::new(placeable[..22].to_vec());
|
||||
assert!(!export(&doc).contains("\\pict"), "an empty metafile is not written");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicode_in_info_bookmarks_and_field_instructions() {
|
||||
// #435: `\uN` (with its `\ucN` fallback skipped) decodes in every text destination.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use super::state::{Dest, MAX_PICT, St};
|
||||
use super::{Fmt, Inline, Reader, Rgb, make_img, model};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// The encoding of an East Asian multibyte code page (932 Shift-JIS, 936 GBK, 949 UHC, 950 Big5),
|
||||
/// which [`Reader::finish_multibyte`] decodes a run with; `None` for the single-byte code pages.
|
||||
@@ -155,7 +156,28 @@ impl Reader {
|
||||
let sy = if self.pict_sy > 0.0 { self.pict_sy / 100.0 } else { 1.0 };
|
||||
let w = self.pict_w.map(|v| v * sx);
|
||||
let h = self.pict_h.map(|v| v * sy);
|
||||
if let Some(img) = make_img(data, w, h, "") {
|
||||
// The control word only says "a metafile": the bytes decide what it is (#4259).
|
||||
let img = if matches!(self.pict_kind, 3 | 4) && wordcraft_metafile::is_metafile(&data) {
|
||||
// An EMF starts with its header record, type 1.
|
||||
let ext = if data.get(..4) == Some(&[1, 0, 0, 0]) { "emf" } else { "wmf" };
|
||||
// `\picw`/`\pich` are in the units of the `\wmetafileN` mapping mode; 0.01 mm for the
|
||||
// isotropic and anisotropic modes and for EMF.
|
||||
let unit = match (self.pict_kind, self.pict_mm) {
|
||||
(3, 1) => 0.75, // MM_TEXT: pixels
|
||||
(3, 2) => 72.0 / 254.0, // MM_LOMETRIC: 0.1 mm
|
||||
(3, 4) => 0.72, // MM_LOENGLISH: 0.01 in
|
||||
(3, 5) => 0.072, // MM_HIENGLISH: 0.001 in
|
||||
(3, 6) => 0.05, // MM_TWIPS
|
||||
_ => 72.0 / 2540.0, // MM_HIMETRIC, MM_ISOTROPIC, MM_ANISOTROPIC
|
||||
};
|
||||
let pt = |v: i32| (v > 0).then(|| v.min(1_000_000) as f32 * unit);
|
||||
// `\picscalex`/`\picscaley` scale the natural size too, when no goal size is given.
|
||||
let natural = pt(self.pict_nat.0).zip(pt(self.pict_nat.1)).map(|(a, b)| (a * sx, b * sy));
|
||||
Some(model::sized_img(Arc::new(data), ext, natural, w, h, ""))
|
||||
} else {
|
||||
make_img(data, w, h, "")
|
||||
};
|
||||
if let Some(img) = img {
|
||||
self.para.inlines.push(Inline::Image(img));
|
||||
self.para_has = true;
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@ impl Reader {
|
||||
self.pict_h = None;
|
||||
self.pict_sx = 0.0;
|
||||
self.pict_sy = 0.0;
|
||||
self.pict_nat = (0, 0);
|
||||
self.pict_mm = 0;
|
||||
}
|
||||
Dest::FldInst if self.st.field.is_none() => self.start_field(),
|
||||
// Old character formatting holds the properties that changed (Word) or, after a
|
||||
@@ -453,6 +455,14 @@ impl Reader {
|
||||
match word {
|
||||
"pngblip" => self.pict_kind = 1,
|
||||
"jpegblip" => self.pict_kind = 2,
|
||||
// Windows and Enhanced Metafiles (#4259): drawn by the metafile renderer.
|
||||
"wmetafile" => {
|
||||
self.pict_kind = 3;
|
||||
self.pict_mm = value;
|
||||
}
|
||||
"emfblip" => self.pict_kind = 4,
|
||||
"picw" => self.pict_nat.0 = value,
|
||||
"pich" => self.pict_nat.1 = value,
|
||||
"picwgoal" => self.pict_w = Some(value.clamp(1, 31_680) as f32 / 20.0),
|
||||
"pichgoal" => self.pict_h = Some(value.clamp(1, 31_680) as f32 / 20.0),
|
||||
"picscalex" => self.pict_sx = value.clamp(1, 1000) as f32,
|
||||
|
||||
@@ -147,7 +147,11 @@ pub(super) fn iso_time(v: &str) -> Option<String> {
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
let s = s.round() as u64;
|
||||
let mut s = s.round() as u64;
|
||||
m = m.saturating_add(s / 60);
|
||||
s %= 60;
|
||||
h = h.saturating_add(m / 60);
|
||||
m %= 60;
|
||||
return Some(if s == 0 { format!("{h:02}:{m:02}") } else { format!("{h:02}:{m:02}:{s:02}") });
|
||||
}
|
||||
// xs:time: hh:mm[:ss[.sss]][Z|+...]
|
||||
@@ -159,3 +163,16 @@ pub(super) fn iso_time(v: &str) -> Option<String> {
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::iso_time;
|
||||
|
||||
#[test]
|
||||
fn duration_seconds_round_up_carries() {
|
||||
assert_eq!(iso_time("PT1H59M59.6S").as_deref(), Some("02:00"));
|
||||
assert_eq!(iso_time("PT59M59.5S").as_deref(), Some("01:00"));
|
||||
assert_eq!(iso_time("PT0H0M59.4S").as_deref(), Some("00:00:59"));
|
||||
assert_eq!(iso_time("PT1H2M3S").as_deref(), Some("01:02:03"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1002,3 +1002,12 @@ fn dropped_names_and_links_are_logged_once_per_document() {
|
||||
let (_, warned) = crate::test_log::warnings(|| crate::latex::import(tex.as_bytes()));
|
||||
assert_eq!(count(&warned, "links longer"), 1, "{warned:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_title_outside_head_is_metadata_not_body() {
|
||||
for html in ["<title>T</title><p>b</p>", "<html><title>T</title><body>b</body></html>"] {
|
||||
let d = crate::html::import(html);
|
||||
assert_eq!(para_texts(&d), vec!["b".to_string()], "{html}");
|
||||
assert_eq!(d.core.title, "T", "{html}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::options::DisplayOptions;
|
||||
use super::paint::{Paint, paint_for};
|
||||
use super::{BezPath, Document, Draw, Graphic, GraphicItem, GraphicKind, InlineObject, PathSeg, Rect, Rgb, ShapeKind, Spin, TextAlign};
|
||||
use super::{BezPath, Document, Draw, FaceRef, Graphic, GraphicItem, GraphicKind, InlineObject, PathSeg, Rect, Rgb, ShapeKind, Spin, TextAlign};
|
||||
|
||||
/// The draws of a chart or diagram's items inside `rect` (page coordinates). The items were built
|
||||
/// for the size `g.w` × `g.h` (0: `rect`'s own size), so they are scaled to `rect`; strokes and text
|
||||
@@ -271,51 +271,113 @@ pub fn seg_path(segs: &[PathSeg]) -> BezPath {
|
||||
}
|
||||
|
||||
/// One line of a `GraphicItem::Text` in `r`, shaped in its font (the document's body font when
|
||||
/// unset) and aligned horizontally, centred vertically, at its size times `k`. `None` when there is
|
||||
/// nothing to draw, or the item's rectangle or size isn't a finite number (a degenerate scale:
|
||||
/// `r` itself is always finite, as [`Rect::new`] makes it).
|
||||
fn text_glyphs(doc: &Document, r: Rect, it: &GraphicItem, alpha: f32, k: f32) -> Option<Draw> {
|
||||
let GraphicItem::Text { rect, text, size, color, bold, align, font } = it else { return None };
|
||||
/// unset) and aligned horizontally, centred vertically, at its size times `k`. Characters the font
|
||||
/// lacks (Chinese in a chart drawn in Calibri's substitute, #4720) are drawn in a fallback face, as
|
||||
/// in body text: one draw per run of a face, in reading order. Empty when there is nothing to draw,
|
||||
/// or the item's rectangle or size isn't a finite number (a degenerate scale: `r` itself is always
|
||||
/// finite, as [`Rect::new`] makes it).
|
||||
fn text_glyphs(doc: &Document, r: Rect, it: &GraphicItem, alpha: f32, k: f32) -> Vec<Draw> {
|
||||
let GraphicItem::Text { rect, text, size, color, bold, align, font } = it else { return Vec::new() };
|
||||
let size = *size * k;
|
||||
if !rect.iter().chain([&size]).all(|v| v.is_finite()) {
|
||||
return None;
|
||||
return Vec::new();
|
||||
}
|
||||
let family = font.as_deref().filter(|f| !f.is_empty()).unwrap_or(&doc.settings.minor_font);
|
||||
let size = size.clamp(1.0, 400.0);
|
||||
let resolved = wordcraft_fonts::word::resolve(family, *bold, false);
|
||||
let face = resolved.face.get();
|
||||
let shaped = wordcraft_fonts::shape(face, text, &[], |c| c);
|
||||
if shaped.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let k = size / face.upem.max(1.0) as f32;
|
||||
let width: f32 = shaped.iter().map(|g| g.x_advance as f32 * k).sum();
|
||||
let (ascent, descent) = wordcraft_fonts::word::line_metrics(face);
|
||||
let baseline = r.y + r.h / 2.0 + (ascent - descent) as f32 * k / 2.0;
|
||||
let primary = resolved.face;
|
||||
let scale = |f: FaceRef| size / f.upem.max(1.0) as f32;
|
||||
let runs: Vec<_> = face_runs(text, primary, *bold)
|
||||
.into_iter()
|
||||
.map(|(face, s)| (face, s, wordcraft_fonts::shape(face.get(), s, &[], |c| c)))
|
||||
.filter(|(_, _, shaped)| !shaped.is_empty())
|
||||
.collect();
|
||||
let width: f32 = runs.iter().map(|(f, _, shaped)| shaped.iter().map(|g| g.x_advance as f32).sum::<f32>() * scale(*f)).sum();
|
||||
// The font's own line metrics centre the line, whichever faces draw it.
|
||||
let (ascent, descent) = wordcraft_fonts::word::line_metrics(primary.get());
|
||||
let baseline = r.y + r.h / 2.0 + (ascent - descent) as f32 * scale(primary) / 2.0;
|
||||
let mut pen = match align {
|
||||
TextAlign::Left => r.x,
|
||||
TextAlign::Center => r.x + (r.w - width) / 2.0,
|
||||
TextAlign::Right => r.x + r.w - width,
|
||||
};
|
||||
let mut glyphs = Vec::with_capacity(shaped.len());
|
||||
for g in &shaped {
|
||||
glyphs.push((g.gid, pen + g.x_offset as f32 * k, baseline - g.y_offset as f32 * k));
|
||||
pen += g.x_advance as f32 * k;
|
||||
let mut out = Vec::with_capacity(runs.len());
|
||||
for (face, s, shaped) in runs {
|
||||
let k = scale(face);
|
||||
let mut glyphs = Vec::with_capacity(shaped.len());
|
||||
for g in &shaped {
|
||||
glyphs.push((g.gid, pen + g.x_offset as f32 * k, baseline - g.y_offset as f32 * k));
|
||||
pen += g.x_advance as f32 * k;
|
||||
}
|
||||
// A fallback face is emboldened when it has no bold of its own, as in body text.
|
||||
let synth_bold = if face.id() == primary.id() { resolved.synth_bold } else { *bold && face.weight < 600.0 };
|
||||
out.push(Draw::Glyphs {
|
||||
face,
|
||||
size,
|
||||
glyphs,
|
||||
color: *color,
|
||||
alpha,
|
||||
synth_bold,
|
||||
synth_italic: false,
|
||||
text: s.to_string(),
|
||||
link: None,
|
||||
ranges: Vec::new(),
|
||||
text_bg: None,
|
||||
x_scale: 1.0,
|
||||
});
|
||||
}
|
||||
Some(Draw::Glyphs {
|
||||
face: resolved.face,
|
||||
size,
|
||||
glyphs,
|
||||
color: *color,
|
||||
alpha,
|
||||
synth_bold: resolved.synth_bold,
|
||||
synth_italic: false,
|
||||
text: text.clone(),
|
||||
link: None,
|
||||
ranges: Vec::new(),
|
||||
text_bg: None,
|
||||
x_scale: 1.0,
|
||||
})
|
||||
out
|
||||
}
|
||||
|
||||
/// `text` split into runs of the face that draws them: `primary` where it has the character, else a
|
||||
/// fallback face. A run the primary lacks takes one face covering all of it when one does (so a
|
||||
/// Chinese label doesn't mix serif and sans glyphs), else each character its own fallback. Spaces
|
||||
/// and combining marks stay in the run before them; a character no face has stays in `primary`.
|
||||
fn face_runs(text: &str, primary: FaceRef, bold: bool) -> Vec<(FaceRef, &str)> {
|
||||
let joins = |c: char| c.is_whitespace() || wordcraft_doc::math_linear::is_combining(c);
|
||||
// (start, end, drawn by `primary`)
|
||||
let mut spans: Vec<(usize, usize, bool)> = Vec::new();
|
||||
for (i, c) in text.char_indices() {
|
||||
let end = i + c.len_utf8();
|
||||
let own = primary.covers(c);
|
||||
match spans.last_mut() {
|
||||
Some(last) if last.2 == own || joins(c) => last.1 = end,
|
||||
_ => spans.push((i, end, own)),
|
||||
}
|
||||
}
|
||||
let db = wordcraft_fonts::FontDb::global();
|
||||
let mut out: Vec<(FaceRef, &str)> = Vec::with_capacity(spans.len());
|
||||
for (start, end, own) in spans {
|
||||
let Some(s) = text.get(start..end) else { continue };
|
||||
if own {
|
||||
out.push((primary, s));
|
||||
continue;
|
||||
}
|
||||
let chars: Vec<char> = s.chars().filter(|c| !joins(*c)).collect();
|
||||
if let Some(f) = db.fallback_for_run(&chars, primary.id(), bold, false) {
|
||||
out.push((FaceRef::of(&f), s));
|
||||
continue;
|
||||
}
|
||||
// No one face has them all: each character in its own fallback, neighbours sharing a face
|
||||
// in one run.
|
||||
let mut run: Option<(FaceRef, usize)> = None;
|
||||
for (i, c) in s.char_indices() {
|
||||
let face =
|
||||
if joins(c) { None } else { Some(db.fallback_styled(c, primary.id(), bold, false).map(|f| FaceRef::of(&f)).unwrap_or(primary)) };
|
||||
match (run, face) {
|
||||
(Some((cur, from)), Some(f)) if f.id() != cur.id() => {
|
||||
out.extend(s.get(from..i).map(|t| (cur, t)));
|
||||
run = Some((f, i));
|
||||
}
|
||||
(None, f) => run = Some((f.unwrap_or(primary), i)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some((cur, from)) = run {
|
||||
out.extend(s.get(from..).map(|t| (cur, t)));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `items` turned by `spin` about the centre of `rect` (as they are, unturned).
|
||||
|
||||
@@ -74,6 +74,20 @@ pub struct ObjectHit {
|
||||
pub origin: Point,
|
||||
}
|
||||
|
||||
/// Object positions in one layout, retaining each page occurrence for page-hinted lookups.
|
||||
/// Build once for a batch of lookups; rebuild when the layout changes.
|
||||
pub struct ObjectIndex {
|
||||
by_pos: HashMap<Pos, Vec<ObjectHit>>,
|
||||
}
|
||||
|
||||
impl ObjectIndex {
|
||||
/// Like [`DocLayout::object`]: prefer `page_hint`, otherwise the first matching page.
|
||||
pub fn object(&self, pos: &Pos, page_hint: usize) -> Option<&ObjectHit> {
|
||||
let hits = self.by_pos.get(pos)?;
|
||||
hits.binary_search_by_key(&page_hint, |h| h.page).ok().and_then(|i| hits.get(i)).or_else(|| hits.first())
|
||||
}
|
||||
}
|
||||
|
||||
/// The inputs for a column selection across laid-out lines.
|
||||
pub struct ColumnSelection<'a> {
|
||||
/// The selection's first corner.
|
||||
@@ -456,6 +470,22 @@ impl DocLayout {
|
||||
self.find_object(page_hint, |o| o.story == pos.story && o.path == pos.path && o.off == pos.off)
|
||||
}
|
||||
|
||||
/// Index objects once for repeated [`Self::object`] lookups, preserving their selection
|
||||
/// order, including objects repeated on several pages (headers or repeated table rows).
|
||||
pub fn object_index(&self) -> ObjectIndex {
|
||||
let mut by_pos: HashMap<Pos, Vec<ObjectHit>> = HashMap::new();
|
||||
for (i, page) in self.pages.iter().enumerate() {
|
||||
for hit in all_objects(page, i) {
|
||||
let hits = by_pos.entry(hit.pos()).or_default();
|
||||
// all_objects has the same within-page order as find_object; first wins.
|
||||
if hits.last().is_none_or(|prev| prev.page != i) {
|
||||
hits.push(hit);
|
||||
}
|
||||
}
|
||||
}
|
||||
ObjectIndex { by_pos }
|
||||
}
|
||||
|
||||
/// Where the text box showing story `part` is laid out.
|
||||
pub fn text_box(&self, part: u32, page_hint: usize) -> Option<ObjectHit> {
|
||||
self.find_object(page_hint, |o| o.text_box == Some(part))
|
||||
@@ -939,3 +969,46 @@ fn score(l: &LineHit, x: f32, y: f32) -> f32 {
|
||||
};
|
||||
dy * 4.0 + dx
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod object_index_tests {
|
||||
use super::*;
|
||||
|
||||
/// Compare with the ordinary lookup, including repeated rows/parts and within-page ties.
|
||||
#[test]
|
||||
fn indexed_objects_keep_page_hints_and_first_match_order() {
|
||||
let body = Pos::body(0, 3);
|
||||
let header = Pos::new(StoryRef::Part(7), Path(vec![0, 0, 0, 1]), 6);
|
||||
let footer = Pos::new(StoryRef::Part(8), Path(vec![0]), 0);
|
||||
let object = |pos: &Pos, x| Placed::Object {
|
||||
rect: Rect::new(x, 20.0, 40.0, 30.0),
|
||||
spin: wordcraft_geom::Spin::default(),
|
||||
story: pos.story,
|
||||
path: pos.path.clone(),
|
||||
off: pos.off,
|
||||
text_box: None,
|
||||
wrap: Wrap::InFrontOfText,
|
||||
origin: Point::new(5.0, 6.0),
|
||||
};
|
||||
let layout = DocLayout {
|
||||
pages: vec![
|
||||
Page { items: vec![object(&body, 10.0), object(&body, 11.0)], header: vec![object(&header, 12.0)], ..Default::default() },
|
||||
Page::default(),
|
||||
Page {
|
||||
items: vec![object(&body, 30.0)],
|
||||
header: vec![object(&header, 31.0), object(&header, 32.0)],
|
||||
footer: vec![object(&footer, 33.0)],
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
let index = layout.object_index();
|
||||
for pos in [body, header, footer, Pos::body(99, 0), Pos::new(StoryRef::Part(7), Path(vec![0]), 6)] {
|
||||
for hint in [0, 1, 2, 3, usize::MAX] {
|
||||
assert_eq!(index.object(&pos, hint).cloned(), layout.object(&pos, hint), "{pos:?}, page hint {hint}");
|
||||
}
|
||||
}
|
||||
assert!(DocLayout::default().object_index().object(&Pos::body(0, 0), 0).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -391,9 +391,9 @@ struct TableFrame {
|
||||
x: f32,
|
||||
}
|
||||
|
||||
fn table_frame(ctx: &mut Ctx, t: &Table, avail: f32, depth: usize, style: Option<&TableStyleProps>) -> TableFrame {
|
||||
// Nested measurement needs the same preferred weights as layout before sharing span minima.
|
||||
fn preferred_grid(t: &Table, avail: f32) -> Vec<f32> {
|
||||
let ncols = grid_cols(t).max(1);
|
||||
// Column widths.
|
||||
let mut grid: Vec<f32> = if t.grid.len() == ncols {
|
||||
// Grid units can be narrower than a text box, including omitted row-end columns.
|
||||
t.grid
|
||||
@@ -408,6 +408,12 @@ fn table_frame(ctx: &mut Ctx, t: &Table, avail: f32, depth: usize, style: Option
|
||||
vec![avail / ncols as f32; ncols]
|
||||
};
|
||||
apply_cell_widths(t, &mut grid);
|
||||
grid
|
||||
}
|
||||
|
||||
fn table_frame(ctx: &mut Ctx, t: &Table, avail: f32, depth: usize, style: Option<&TableStyleProps>) -> TableFrame {
|
||||
let ncols = grid_cols(t).max(1);
|
||||
let mut grid = preferred_grid(t, avail);
|
||||
let mut total: f32 = grid.iter().sum();
|
||||
// A top-level table in an inherited header or footer takes its share of the defining
|
||||
// section's width (`Ctx::pct_base`).
|
||||
@@ -437,7 +443,7 @@ fn table_frame(ctx: &mut Ctx, t: &Table, avail: f32, depth: usize, style: Option
|
||||
if !t.props.fixed {
|
||||
// Measuring lays paragraphs out once more; it mustn't count their equations twice.
|
||||
let eq_count = ctx.eq_count;
|
||||
let content = column_content(ctx, t, style, depth);
|
||||
let content = column_content(ctx, t, style, depth, &grid);
|
||||
ctx.eq_count = eq_count;
|
||||
if let Some(content) = content {
|
||||
autofit(&mut grid, &content, t, avail);
|
||||
@@ -1167,7 +1173,7 @@ fn region_text(st: &TableStyleProps, region: Region) -> CellText {
|
||||
/// Each column's narrowest and widest content, cell margins included: its longest word and its
|
||||
/// longest line. A cell spanning columns shares what they lack between them; nested tables count
|
||||
/// with their own. `None` when every cell is empty.
|
||||
fn column_content(ctx: &mut Ctx, t: &Table, style: Option<&TableStyleProps>, depth: usize) -> Option<Vec<(f32, f32)>> {
|
||||
fn column_content(ctx: &mut Ctx, t: &Table, style: Option<&TableStyleProps>, depth: usize, grid: &[f32]) -> Option<Vec<(f32, f32)>> {
|
||||
let margins_def = default_margins(t, style);
|
||||
// The grid's width once: counting it walks every row.
|
||||
let ncols = grid_cols(t);
|
||||
@@ -1196,15 +1202,36 @@ fn column_content(ctx: &mut Ctx, t: &Table, style: Option<&TableStyleProps>, dep
|
||||
for (g, span, need) in spanning {
|
||||
let Some(cs) = cols.get_mut(g..g.saturating_add(span).min(ncols)) else { continue };
|
||||
let n = cs.len().max(1) as f32;
|
||||
let (have_lo, have_hi) = cs.iter().fold((0.0, 0.0), |(l, h), c| (l + c.0, h + c.1));
|
||||
let have_hi: f32 = cs.iter().map(|c| c.1).sum();
|
||||
let preferred = (!is_automatic(t)).then(|| grid.get(g..g + cs.len())).flatten();
|
||||
share_span_min(cs, preferred, need.0);
|
||||
for c in cs {
|
||||
c.0 += (need.0 - have_lo).max(0.0) / n;
|
||||
c.1 += (need.1 - have_hi).max(0.0) / n;
|
||||
}
|
||||
}
|
||||
any.then_some(cols)
|
||||
}
|
||||
|
||||
// Share a span's extra minimum in preferred-column proportions. Each column keeps
|
||||
// its own text minimum; automatic tables share the extra equally.
|
||||
fn share_span_min(cols: &mut [(f32, f32)], grid: Option<&[f32]>, need: f32) {
|
||||
let extra = (need - cols.iter().map(|c| c.0).sum::<f32>()).max(0.0);
|
||||
let weight = |w: &f32| if w.is_finite() { w.clamp(0.0, UNBOUNDED) } else { 0.0 };
|
||||
let total = grid.map_or(0.0, |grid| grid.iter().take(cols.len()).map(weight).sum::<f32>());
|
||||
if total > 0.0 {
|
||||
if let Some(grid) = grid {
|
||||
for (c, w) in cols.iter_mut().zip(grid) {
|
||||
c.0 += extra * weight(w) / total;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
let share = extra / cols.len().max(1) as f32;
|
||||
for c in cols {
|
||||
c.0 += share;
|
||||
}
|
||||
}
|
||||
|
||||
/// The grid columns row `row` leaves empty before its first cell and after its last one (which
|
||||
/// ends at grid column `end`) when it gives their width (`w:wBefore`, `w:wAfter`): (first column,
|
||||
/// columns, width in points). Column widths make room for them like for an empty cell.
|
||||
@@ -1234,7 +1261,8 @@ fn blocks_content(ctx: &mut Ctx, blocks: &Blocks, text: Option<&CellText>, depth
|
||||
(width, width)
|
||||
} else {
|
||||
let style = table_style(ctx, nt);
|
||||
let cols = column_content(ctx, nt, style.as_ref(), depth + 1).unwrap_or_default();
|
||||
let weights = preferred_grid(nt, width);
|
||||
let cols = column_content(ctx, nt, style.as_ref(), depth + 1, &weights).unwrap_or_default();
|
||||
let (l, h) = cols.iter().fold((0.0, 0.0), |(l, h), c| (l + c.0, h + c.1));
|
||||
(width.max(l), width.max(h))
|
||||
}
|
||||
|
||||
@@ -6226,6 +6226,50 @@ fn graphic_text_in_a_degenerate_rectangle_is_not_drawn() {
|
||||
assert!(!draws.iter().any(|x| matches!(x, display::Draw::Glyphs { text, .. } if text == "Q1")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chart_text_the_font_lacks_is_drawn_in_a_fallback_face() {
|
||||
// #4720: a Chinese legend in a chart drawn in the body font (which has no Chinese) showed boxes.
|
||||
use wordcraft_doc::graphic::{Graphic, GraphicItem, GraphicKind, TextAlign};
|
||||
use wordcraft_doc::props::Rgb;
|
||||
if wordcraft_fonts::FontDb::global().fallback_for('销', 0).is_none() {
|
||||
eprintln!("skipped: no Chinese font installed");
|
||||
return;
|
||||
}
|
||||
let label = "Sales 销售额 2024";
|
||||
let items = vec![GraphicItem::Text {
|
||||
rect: [0.0, 100.0, 216.0, 20.0],
|
||||
text: label.into(),
|
||||
size: 10.0,
|
||||
color: Rgb::BLACK,
|
||||
bold: false,
|
||||
align: TextAlign::Center,
|
||||
font: None,
|
||||
}];
|
||||
let graphic = Graphic { kind: GraphicKind::Chart, items, ..Default::default() };
|
||||
let obj = InlineObject::Graphic { w: 216.0, h: 144.0, alt: String::new(), float: Default::default(), graphic: Arc::new(graphic) };
|
||||
let mut d = Document::from_text("Chart");
|
||||
d.insert_object(&Pos::body(0, 0), obj, &Default::default()).unwrap();
|
||||
let draws = flat(display::page_display(&d, &lay(&d).pages[0], &Default::default()));
|
||||
let runs: Vec<_> = draws
|
||||
.iter()
|
||||
.filter_map(|x| if let display::Draw::Glyphs { face, text, glyphs, .. } = x { Some((*face, text.as_str(), glyphs)) } else { None })
|
||||
.filter(|(_, text, _)| label.contains(*text) && text.trim() != "")
|
||||
.collect();
|
||||
// The label is drawn whole, in reading order, each character in a face that has it.
|
||||
assert_eq!(runs.iter().map(|(_, t, _)| *t).collect::<String>(), label);
|
||||
for (face, text, glyphs) in &runs {
|
||||
for c in text.chars().filter(|c| !c.is_whitespace()) {
|
||||
assert!(face.covers(c), "{c} in {}", face.family);
|
||||
}
|
||||
assert!(glyphs.iter().all(|g| g.0 != 0), "no missing glyphs in {text:?}");
|
||||
}
|
||||
// The Chinese takes one face, and the runs follow each other along the line.
|
||||
assert_eq!(runs.iter().filter(|(_, t, _)| t.contains('销')).count(), 1);
|
||||
assert!(runs.iter().any(|(_, t, _)| t.contains("销售额")), "{:?}", runs.iter().map(|r| r.1).collect::<Vec<_>>());
|
||||
let xs: Vec<f32> = runs.iter().flat_map(|(_, _, g)| g.iter().map(|g| g.1)).collect();
|
||||
assert!(xs.windows(2).all(|w| w[0] <= w[1]), "{xs:?}");
|
||||
}
|
||||
|
||||
/// The draws with figures opened up.
|
||||
fn flat(draws: Vec<display::Draw>) -> Vec<display::Draw> {
|
||||
draws.into_iter().flat_map(|x| if let display::Draw::Figure { draws, .. } = x { draws } else { vec![x] }).collect()
|
||||
@@ -11232,6 +11276,114 @@ fn caret_height_ignores_line_spacing() {
|
||||
}
|
||||
}
|
||||
|
||||
enum SpanWidth {
|
||||
Preferred,
|
||||
Automatic,
|
||||
TextOnly,
|
||||
}
|
||||
|
||||
fn banner_layout(width: SpanWidth) -> DocLayout {
|
||||
let widths = [150.0, 100.0, 25.0, 25.0];
|
||||
let mut d = Document::new();
|
||||
let mut t = Table::new(2, widths.len(), 300.0);
|
||||
t.grid = widths.to_vec();
|
||||
t.props.width = match width {
|
||||
SpanWidth::Preferred => Some(300.0),
|
||||
SpanWidth::Automatic | SpanWidth::TextOnly => None,
|
||||
};
|
||||
t.props.cell_margins = Some([0.0; 4]);
|
||||
t.rows[0].cells.truncate(1);
|
||||
t.rows[0].cells[0].props.span = 4;
|
||||
t.rows[0].cells[0].props.width = t.props.width;
|
||||
let mut banner = wordcraft_doc::Paragraph::new();
|
||||
if !matches!(width, SpanWidth::TextOnly) {
|
||||
banner.insert_object(0, picture(300.0, 30.0, Default::default()), &Default::default()).unwrap();
|
||||
}
|
||||
t.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(banner)];
|
||||
for (i, cell) in t.rows[1].cells.iter_mut().enumerate() {
|
||||
cell.props.width = match width {
|
||||
SpanWidth::Preferred => Some(widths[i]),
|
||||
SpanWidth::Automatic | SpanWidth::TextOnly => None,
|
||||
};
|
||||
cell.blocks = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("A", Default::default()))];
|
||||
}
|
||||
d.insert_block(StoryRef::Body, &Path::top(0), wordcraft_doc::Block::Table(t)).unwrap();
|
||||
lay(&d)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spanning_banner_uses_weights() {
|
||||
let text = banner_layout(SpanWidth::TextOnly);
|
||||
let minimum = text.pages[0]
|
||||
.items
|
||||
.iter()
|
||||
.find_map(|item| match item {
|
||||
Placed::Cell { rect, row: 1, .. } => Some(rect.w),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
let widths = [150.0 - minimum, 100.0 - minimum / 3.0, 25.0 + 2.0 * minimum / 3.0, 25.0 + 2.0 * minimum / 3.0];
|
||||
let l = banner_layout(SpanWidth::Preferred);
|
||||
let actual: Vec<_> =
|
||||
l.pages[0].items.iter().filter_map(|it| if let Placed::Cell { rect, row: 1, .. } = it { Some(rect.w) } else { None }).collect();
|
||||
assert_eq!(actual.len(), widths.len());
|
||||
for (got, want) in actual.iter().zip(widths) {
|
||||
assert!((got - want).abs() < 0.01, "spanning banner changed columns: {actual:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_banner_shares_width() {
|
||||
let l = banner_layout(SpanWidth::Automatic);
|
||||
let widths: Vec<_> =
|
||||
l.pages[0].items.iter().filter_map(|it| if let Placed::Cell { rect, row: 1, .. } = it { Some(rect.w) } else { None }).collect();
|
||||
assert_eq!(widths.len(), 4);
|
||||
assert!(widths.iter().all(|w| (w - 75.0).abs() < 0.01), "automatic widths: {widths:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_spans_use_grid_weights() {
|
||||
const NESTED_WIDTH: f32 = 40.0;
|
||||
const IMAGE_WIDTH: f32 = 50.0;
|
||||
const OUTER_WIDTH: f32 = 51.0;
|
||||
let widths: Vec<_> = [0.0, 4.0]
|
||||
.into_iter()
|
||||
.map(|first| {
|
||||
let mut nested = Table::new(2, 3, NESTED_WIDTH);
|
||||
nested.props.style = None;
|
||||
nested.props.width = Some(NESTED_WIDTH);
|
||||
nested.props.cell_margins = Some([0.0; 4]);
|
||||
nested.grid = vec![first, 100.0, 100.0];
|
||||
for (index, row) in nested.rows.iter_mut().enumerate() {
|
||||
row.cells.truncate(1);
|
||||
row.props.grid_before = index as u32;
|
||||
row.cells[0].props.span = 2;
|
||||
row.cells[0].props.width = None;
|
||||
let mut p = Paragraph::new();
|
||||
p.insert_object(0, picture(IMAGE_WIDTH, 10.0, Default::default()), &Default::default()).unwrap();
|
||||
row.cells[0].blocks = vec![wordcraft_doc::para_block(p)];
|
||||
}
|
||||
let mut outer = Table::new(1, 1, OUTER_WIDTH);
|
||||
outer.props.style = None;
|
||||
outer.props.width = Some(OUTER_WIDTH);
|
||||
outer.props.cell_margins = Some([0.0; 4]);
|
||||
outer.rows[0].cells[0].blocks = vec![Arc::new(Block::Table(nested))];
|
||||
let mut d = Document::new();
|
||||
d.body = vec![Arc::new(Block::Table(outer))];
|
||||
lay(&d).pages[0]
|
||||
.items
|
||||
.iter()
|
||||
.find_map(|item| match item {
|
||||
Placed::Cell { rect, table, .. } if table.0 == [0] => Some(rect.w),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap()
|
||||
})
|
||||
.collect();
|
||||
assert!(widths[1] > OUTER_WIDTH + 0.5, "overlapping spans must expand the outer table: {widths:?}");
|
||||
assert!((widths[0] - widths[1]).abs() < 0.01, "equivalent nested grids changed outer fitting: {widths:?}");
|
||||
}
|
||||
|
||||
/// #2048: a picture anchored in a later paragraph but placed higher up the page (fixed on the
|
||||
/// page, or moved up from its paragraph) keeps the text of the paragraphs above its anchor clear
|
||||
/// too, as in Word, not just its own paragraph's.
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
use super::export::Fit;
|
||||
use super::glyphs::{baseline, cluster_ends, glyph_outlines, left_to_right, split_clusters, visual_order};
|
||||
use super::glyphs::{baseline, cluster_ends, glyph_id, glyph_outlines, left_to_right, split_clusters, visual_order};
|
||||
use super::options::{PdfError, PdfOptions};
|
||||
use super::paint::{append_path, fill, grown, norm, ok, paint_fill, pdf_line_cap, rect_path, shape_effects, shape_path};
|
||||
use super::picture::{Vector, build_vector, load_image, paint_of, vector_picture};
|
||||
use super::tags::{Role, TagEntry, figure_alt, graphic_alt, holds_picture};
|
||||
use super::{
|
||||
Annotation, Arc, Artifact, ArtifactType, ClKind, ContentTag, DisplayOptions, DocLayout, DocPath, Document, Draw, FaceRef, Fill, FillRule, Font,
|
||||
GlyphId, HashMap, HashSet, Identifier, Image, InlineObject, ItemDisplay, KrillaGlyph, LineCap, LineEnd, LineJoin, LineStyle, LinkAnnotation,
|
||||
Node, NonZeroU16, Outline, OutlineNode, Page, PathBuilder, Placed, Point, Rect, Rgb, ShapeKind, Size, SpanTag, StoryRef, Stroke, StrokeDash,
|
||||
Surface, Tag, TagGroup, TagKind, TagTree, Target, Transform, WriteError, XyzDestination, krilla, rgb, write,
|
||||
HashMap, HashSet, Identifier, Image, InlineObject, ItemDisplay, KrillaGlyph, LineCap, LineEnd, LineJoin, LineStyle, LinkAnnotation, Node,
|
||||
NonZeroU16, Outline, OutlineNode, Page, PathBuilder, Placed, Point, Rect, Rgb, ShapeKind, Size, SpanTag, StoryRef, Stroke, StrokeDash, Surface,
|
||||
Tag, TagGroup, TagKind, TagTree, Target, Transform, WriteError, XyzDestination, krilla, rgb, write,
|
||||
};
|
||||
|
||||
mod draw;
|
||||
@@ -346,7 +346,7 @@ impl Exporter<'_> {
|
||||
let end = ends.get(i).copied().unwrap_or(text.len());
|
||||
let start = g.cluster.min(end);
|
||||
KrillaGlyph::new(
|
||||
GlyphId::new(g.gid),
|
||||
glyph_id(g.gid),
|
||||
g.x_advance as f32 / upem,
|
||||
g.x_offset as f32 / upem,
|
||||
g.y_offset as f32 / upem,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::{
|
||||
Exporter, FaceRef, Fill, FillRule, GlyphId, KrillaGlyph, Point, Rgb, Stroke, Surface, Transform, baseline, cluster_ends, fill, glyph_outlines,
|
||||
Exporter, FaceRef, Fill, FillRule, KrillaGlyph, Point, Rgb, Stroke, Surface, Transform, baseline, cluster_ends, fill, glyph_id, glyph_outlines,
|
||||
left_to_right, norm, ok, paint_of, rgb, split_clusters, visual_order,
|
||||
};
|
||||
|
||||
@@ -126,7 +126,7 @@ impl Exporter<'_> {
|
||||
Some((_, nx, _, _)) => (nx - x) / size,
|
||||
None => face.advance(*g) as f32 / upem,
|
||||
};
|
||||
KrillaGlyph::new(GlyphId::new(*g), adv, 0.0, (y0 - y) / size, 0.0, r.clone(), None)
|
||||
KrillaGlyph::new(glyph_id(*g), adv, 0.0, (y0 - y) / size, 0.0, r.clone(), None)
|
||||
})
|
||||
.collect();
|
||||
let pushed = italic;
|
||||
@@ -216,7 +216,7 @@ impl Exporter<'_> {
|
||||
None => g.x_advance as f32 / upem,
|
||||
};
|
||||
let dy = lay.glyphs.get(i).map_or(0.0, |p| ((lay.baseline - p.1) / lay.em) as f32);
|
||||
KrillaGlyph::new(GlyphId::new(g.gid), adv, 0.0, dy, 0.0, start..end, None)
|
||||
KrillaGlyph::new(glyph_id(g.gid), adv, 0.0, dy, 0.0, start..end, None)
|
||||
})
|
||||
.collect();
|
||||
let x0 = lay.glyphs.first().map_or(0.0, |p| p.0) as f32;
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
use super::paint::append_path;
|
||||
use super::{FaceRef, HashMap, Path, PathBuilder};
|
||||
use super::{FaceRef, GlyphId, HashMap, Path, PathBuilder};
|
||||
|
||||
/// A shaped glyph id as krilla takes it. Shaping gives 32-bit ids (a format-12 cmap can map to
|
||||
/// any), but krilla's font subsetting takes 16-bit ones and panics on a larger id (#3674): one
|
||||
/// that doesn't fit is written as .notdef (glyph 0), keeping its place. Its own character doesn't
|
||||
/// extract, since krilla maps no text to .notdef.
|
||||
pub(crate) fn glyph_id(g: u32) -> GlyphId {
|
||||
GlyphId::new(u16::try_from(g).map_or(0, u32::from))
|
||||
}
|
||||
|
||||
/// The outlines of `glyphs` (glyph id, pen position) at `size`, as one path in page coordinates.
|
||||
pub(crate) fn glyph_outlines(face: &FaceRef, size: f32, glyphs: &[(u32, f32, f32)]) -> Option<Path> {
|
||||
|
||||
@@ -773,6 +773,32 @@ fn unsubsettable_font_is_drawn_as_outlines() {
|
||||
assert!(paths > 0, "its glyphs are drawn as outlines");
|
||||
}
|
||||
|
||||
/// #3674: shaping gives 32-bit glyph ids (a format-12 cmap can map a character to any of them),
|
||||
/// but krilla takes 16-bit ones and panics on a larger id. That failed the export, and on wasm
|
||||
/// (`panic = "abort"`) lost the tab. A glyph id above 65535 is now written as .notdef.
|
||||
#[test]
|
||||
fn glyph_id_above_u16_exports() {
|
||||
let mut d = Document::new();
|
||||
d.body = vec![para_block(Paragraph::with_text("Wide glyph", CharProps { font: Some("Source Sans 3".into()), ..Default::default() }))];
|
||||
let opts = PdfOptions { tagged: false, ..Default::default() };
|
||||
let mut lay = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default());
|
||||
let (want, _) = count_glyphs_and_paths(&export_layout(&d, &lay, &opts).unwrap());
|
||||
assert!(want > 0);
|
||||
let mut wide = 0;
|
||||
for item in lay.pages.iter_mut().flat_map(|p| p.items.iter_mut()) {
|
||||
if let Placed::Lines { para, .. } = item
|
||||
&& let Some(g) = Arc::make_mut(para).glyphs.first_mut()
|
||||
{
|
||||
g.gid = 70_000;
|
||||
wide += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(wide, 1, "one glyph id is out of range");
|
||||
let bytes = export_layout(&d, &lay, &opts).expect("a glyph id above 65535 must not fail the export");
|
||||
let (glyphs, _) = count_glyphs_and_paths(&bytes);
|
||||
assert_eq!(glyphs, want, "every glyph is still drawn, the wide one as .notdef");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_lines_in_a_group_are_drawn() {
|
||||
// A horizontal divider inside a group has no height (`a:ext cy="0"`): it used to be left out
|
||||
|
||||
@@ -112,6 +112,7 @@ impl WordApp {
|
||||
styled: false,
|
||||
fonts_hans: false,
|
||||
fonts_korean: false,
|
||||
fonts_hant: false,
|
||||
fonts_system_cjk: false,
|
||||
want_system_cjk: false,
|
||||
recover_fonts_for: None,
|
||||
|
||||
@@ -316,6 +316,7 @@ impl WordApp {
|
||||
};
|
||||
}
|
||||
let retry = (id == "file.open").then(|| params.clone());
|
||||
let retry_path = retry.clone();
|
||||
let r = self.execute(id, params);
|
||||
if let (Err(e), Some(params)) = (&r, retry)
|
||||
&& wordcraft_engine::io::needs_password(e)
|
||||
@@ -325,6 +326,22 @@ impl WordApp {
|
||||
self.dialog = Some(dialogs::Dialog::Password { name, params, password: Default::default(), message: String::new() });
|
||||
return Ok(json!({"pending": "password"}));
|
||||
}
|
||||
// Whoever asked, a failed open names the file and the cause (#1103).
|
||||
if let (Err(e), Some(params)) = (&r, retry_path) {
|
||||
let path = params.get("path").and_then(Value::as_str).unwrap_or("");
|
||||
self.report_open_failure(path, e);
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// [`WordApp::execute_user`] for a command a prompt or a finished save held back. A failed
|
||||
/// open also leaves Backstage, whose list isn't where that failure gets shown (#1103); a
|
||||
/// direct open from Recent keeps File open and reports there instead (#2938).
|
||||
pub(crate) fn execute_held_back(&mut self, id: &str, params: Value) -> Result<Value, String> {
|
||||
let r = self.execute_user(id, params);
|
||||
if r.is_err() && id == "file.open" {
|
||||
self.ui.backstage = false;
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
@@ -975,7 +992,8 @@ fn input_dialog_other(id: &str, params: &Value) -> Option<&'static str> {
|
||||
|
||||
/// Commands that work while no document is open (#1129): opening or creating one, closing the
|
||||
/// window, Help, adding fonts (a font file dropped on the empty window), and the settings (File ›
|
||||
/// Options, the view and interface preferences it sets, Customize Ribbon and Keyboard).
|
||||
/// Options, the view and interface preferences it sets, AutoCorrect Options, Customize Ribbon and
|
||||
/// Keyboard).
|
||||
/// Everything else needs a document, `ui.*` included: only the interface commands listed here
|
||||
/// (tabs, Backstage, dialogs, appearance, opening files) work without one.
|
||||
pub(crate) fn works_without_document(id: &str) -> bool {
|
||||
@@ -993,6 +1011,7 @@ pub(crate) fn works_without_document(id: &str) -> bool {
|
||||
| "font.remove"
|
||||
| "readAloud.speed"
|
||||
| "readAloud.skipCitations"
|
||||
| "tools.autocorrect"
|
||||
| "tools.customizeKeyboard"
|
||||
| "tools.customizeRibbon"
|
||||
| "view.darkMode"
|
||||
|
||||
@@ -51,7 +51,7 @@ impl WordApp {
|
||||
if !go {
|
||||
return Ok(json!({"done": false}));
|
||||
}
|
||||
self.execute_user(&then, params).map(|r| json!({"done": true, "result": r}))
|
||||
self.execute_held_back(&then, params).map(|r| json!({"done": true, "result": r}))
|
||||
}
|
||||
|
||||
/// Save before New/Open/Close (and the mailings that replace the document): true once the
|
||||
@@ -144,7 +144,11 @@ impl WordApp {
|
||||
match outcome {
|
||||
// Only when nothing changed since and no later save of it is still unconfirmed.
|
||||
async_saves::SaveOutcome::Saved if !self.session.dirty && self.session.rev() == wait.rev && !self.save_unconfirmed() => {
|
||||
if let Err(e) = self.execute_user(&wait.then, wait.params) {
|
||||
// A failed open was already reported by name in `execute_user`.
|
||||
let is_open = wait.then == "file.open";
|
||||
if let Err(e) = self.execute_held_back(&wait.then, wait.params)
|
||||
&& !is_open
|
||||
{
|
||||
self.status(e);
|
||||
}
|
||||
}
|
||||
@@ -328,7 +332,7 @@ impl WordApp {
|
||||
let lp = path.to_ascii_lowercase();
|
||||
let img = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".wmf", ".emf"].iter().any(|e| lp.ends_with(e));
|
||||
let sheet = wordcraft_engine::cmd::mailings::is_spreadsheet_name(&lp);
|
||||
let _ = if img {
|
||||
let opened = if img {
|
||||
self.caret_after_placed_picture(placed_picture);
|
||||
let r = self.run("insert.picture", json!({"path": path}));
|
||||
placed_picture = Self::placed_in_text(&r);
|
||||
@@ -341,6 +345,11 @@ impl WordApp {
|
||||
} else {
|
||||
self.open_file(&path, None)
|
||||
};
|
||||
if let Err(e) = opened {
|
||||
self.report_open_failure(&path, &e);
|
||||
// Backstage hides the status bar; leave it so the error shows.
|
||||
self.ui.backstage = false;
|
||||
}
|
||||
}
|
||||
if self.session.document_id() != before {
|
||||
self.autosave_path = None;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user