Compare commits

..
Author SHA1 Message Date
Martin Ball 9bd5ddf3d5 Merge remote-tracking branch 'origin/main' into HEAD
; Conflicts:
;	ROADMAP.md
2026-11-08 03:15:16 +00:00
Martin Ball 6704146780 Merge remote-tracking branch 'origin/main' into HEAD
; Conflicts:
;	ROADMAP.md
;	docs/gaps.md
2026-11-08 02:59:39 +00:00
Dylan M. Taylor 64711a718a merge main into fix/sec-xlsx-sheet-rels 2026-11-03 19:34:33 +00:00
Dylan M. Taylor 6e09267039 style: rustfmt 2026-11-03 16:30:40 +00:00
Dylan M. Taylor b218e191da fix(formats): index XLSX worksheet relationships by id 2026-11-03 11:46:15 +00:00
301 changed files with 990 additions and 17798 deletions
-4
View File
@@ -27,10 +27,6 @@ jobs:
name: Windows installation policy (no app build)
runs-on: windows-latest
steps:
- name: Harden runner
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
with:
egress-policy: audit
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
+2 -12
View File
@@ -42,10 +42,6 @@ jobs:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: '0'
steps:
- name: Harden runner
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
with:
egress-policy: audit
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
@@ -68,11 +64,6 @@ jobs:
echo "CRAFT_FONTS_DIR=$GITHUB_WORKSPACE/craft-fonts" >> "$GITHUB_ENV"
echo "CRAFT_FONTS_REQUIRED=1" >> "$GITHUB_ENV"
# apt falls back to mirrors outside ubuntu.com when it doesn't resolve (as on 11 Nov 2026).
- name: Configure apt mirrors
if: runner.os == 'Linux'
uses: storytold/craft-common-actions/actions/configure-apt-mirrors@main
- name: Install Linux build and software-rendering dependencies
if: runner.os == 'Linux'
run: |
@@ -81,13 +72,12 @@ jobs:
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcursor-dev libxrandr-dev \
libxi-dev libgtk-3-dev libfontconfig1-dev libfreetype-dev libvulkan1 mesa-vulkan-drivers
- uses: step-security/dtolnay-rust-toolchain@8dd607977d06adf4ed49b190381e74c17a9dd25f # v1.0.0
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
with:
toolchain: stable
components: rustfmt, clippy
targets: wasm32-unknown-unknown
- uses: step-security/rust-cache@8871c8ba063416da7c32246932ea6ae08700453e # v2.9.2
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
Generated
+24 -42
View File
@@ -1081,7 +1081,7 @@ dependencies = [
[[package]]
name = "craft-pdf"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
dependencies = [
"krilla",
"thiserror 2.0.21",
@@ -1135,7 +1135,7 @@ dependencies = [
[[package]]
name = "craft-update"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
dependencies = [
"chrono",
"dunce",
@@ -1145,7 +1145,6 @@ dependencies = [
"image",
"log",
"plist",
"rustix 1.1.5",
"semver",
"serde",
"serde_json",
@@ -1159,21 +1158,10 @@ dependencies = [
"zip 9.1.0",
]
[[package]]
name = "craft-web-api"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505"
dependencies = [
"js-sys",
"serde_json",
"wasm-bindgen",
"wasm-bindgen-futures",
]
[[package]]
name = "craft-web-host"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505"
source = "git+https://github.com/storytold/craft-libs?rev=2e2312ddaf21bc3d85cd3395d0955c7d239306f6#2e2312ddaf21bc3d85cd3395d0955c7d239306f6"
dependencies = [
"wasm-bindgen",
"web-sys",
@@ -2982,7 +2970,7 @@ dependencies = [
[[package]]
name = "krilla"
version = "0.8.2"
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
dependencies = [
"base64 0.22.1",
"bumpalo",
@@ -6807,7 +6795,7 @@ dependencies = [
[[package]]
name = "wordcraft"
version = "0.18.1"
version = "0.15.1"
dependencies = [
"arboard",
"chrono",
@@ -6839,35 +6827,30 @@ dependencies = [
"wordcraft-engine",
"wordcraft-fonts",
"wordcraft-ui-egui",
"x11-dl",
"x11rb",
"xkbcommon-dl",
"zbus",
]
[[package]]
name = "wordcraft-cli"
version = "0.18.1"
version = "0.15.1"
dependencies = [
"image",
"serde_json",
"sys-locale",
"wordcraft-doc",
"wordcraft-engine",
"wordcraft-fonts",
"wordcraft-mcp",
"wordcraft-render",
"wordcraft-zotero",
"zip 3.0.0",
]
[[package]]
name = "wordcraft-control-key"
version = "0.18.1"
version = "0.15.1"
[[package]]
name = "wordcraft-doc"
version = "0.18.1"
version = "0.15.1"
dependencies = [
"base64 0.23.1",
"log",
@@ -6881,7 +6864,7 @@ dependencies = [
[[package]]
name = "wordcraft-docbin"
version = "0.18.1"
version = "0.15.1"
dependencies = [
"cfb",
"chrono",
@@ -6898,7 +6881,7 @@ dependencies = [
[[package]]
name = "wordcraft-docx"
version = "0.18.1"
version = "0.15.1"
dependencies = [
"aes",
"base64 0.23.1",
@@ -6923,7 +6906,7 @@ dependencies = [
[[package]]
name = "wordcraft-engine"
version = "0.18.1"
version = "0.15.1"
dependencies = [
"cfb",
"chrono",
@@ -6938,7 +6921,6 @@ dependencies = [
"log",
"regex",
"regex-automata",
"rustix 1.1.5",
"serde",
"serde_json",
"thiserror 2.0.21",
@@ -6957,7 +6939,7 @@ dependencies = [
[[package]]
name = "wordcraft-fonts"
version = "0.18.1"
version = "0.15.1"
dependencies = [
"brotli-decompressor",
"harfrust 0.13.3",
@@ -6971,7 +6953,7 @@ dependencies = [
[[package]]
name = "wordcraft-formats"
version = "0.18.1"
version = "0.15.1"
dependencies = [
"aes",
"base64 0.23.1",
@@ -6998,7 +6980,7 @@ dependencies = [
[[package]]
name = "wordcraft-geom"
version = "0.18.1"
version = "0.15.1"
dependencies = [
"image",
"serde",
@@ -7006,7 +6988,7 @@ dependencies = [
[[package]]
name = "wordcraft-layout"
version = "0.18.1"
version = "0.15.1"
dependencies = [
"craft-proof",
"image",
@@ -7026,7 +7008,7 @@ dependencies = [
[[package]]
name = "wordcraft-mcp"
version = "0.18.1"
version = "0.15.1"
dependencies = [
"log",
"serde_json",
@@ -7038,7 +7020,7 @@ dependencies = [
[[package]]
name = "wordcraft-metafile"
version = "0.18.1"
version = "0.15.1"
dependencies = [
"kurbo",
"thiserror 2.0.21",
@@ -7046,7 +7028,7 @@ dependencies = [
[[package]]
name = "wordcraft-pdf"
version = "0.18.1"
version = "0.15.1"
dependencies = [
"chrono",
"craft-pdf",
@@ -7067,7 +7049,7 @@ dependencies = [
[[package]]
name = "wordcraft-render"
version = "0.18.1"
version = "0.15.1"
dependencies = [
"image",
"log",
@@ -7081,7 +7063,7 @@ dependencies = [
[[package]]
name = "wordcraft-ui-egui"
version = "0.18.1"
version = "0.15.1"
dependencies = [
"accesskit_consumer",
"cfb",
@@ -7114,9 +7096,8 @@ dependencies = [
[[package]]
name = "wordcraft-web"
version = "0.18.1"
version = "0.15.1"
dependencies = [
"craft-web-api",
"craft-web-host",
"eframe",
"egui",
@@ -7135,7 +7116,7 @@ dependencies = [
[[package]]
name = "wordcraft-zotero"
version = "0.18.1"
version = "0.15.1"
dependencies = [
"log",
"proptest",
@@ -7298,7 +7279,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.18.1"
version = "0.15.1"
dependencies = [
"serde_json",
]
@@ -7569,6 +7550,7 @@ dependencies = [
"nix",
"typed-path",
"windows-sys 0.61.2",
"zopfli",
]
[[package]]
+4 -4
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/wordcraft", "apps/wordcraft-cli", "xtask"]
[workspace.package]
version = "0.18.1"
version = "0.15.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -67,9 +67,9 @@ wuff = "0.2.9"
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 = "6e03f65bf45181db77ba6229716e6624db9eb25b", version = "=0.1.0", default-features = false, features = ["write"] }
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", version = "=0.1.0", default-features = false, features = ["write"] }
# 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 = "6e03f65bf45181db77ba6229716e6624db9eb25b", features = ["egui"] }
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", 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.
@@ -134,7 +134,7 @@ debug = 0
# Required by craft-pdf: the backend rejects writers that failed during unwinding.
[patch.crates-io]
krilla = { git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b" }
krilla = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
-18
View File
@@ -48,21 +48,3 @@ _Avoid_: User dictionary (in the interface)
A folder of Hunspell dictionaries you supply, used before the system's (an installed Language pack
comes after it).
_Avoid_: Custom dictionaries
## Export
**Strict export**:
An export that writes nothing, and reports why, when a character or picture the output is meant
to show would be missing from it. Text the output doesn't show (hidden text, deletions without
markup) isn't checked.
_Avoid_: Safe mode, validated export
**Missing glyph**:
A character the output draws that no face in the font fallback chain can draw, so it would appear
as an empty box.
_Avoid_: Tofu, box, notdef character
**Missing picture**:
A picture the document refers to that the output cannot include: its part is absent, its data
cannot be decoded, its format is unsupported, or it is linked from outside the file.
_Avoid_: Broken image, dropped image
-40
View File
@@ -157,7 +157,6 @@ Command line:
```sh
wordcraft-cli convert report.docx report.pdf # docx, pdf, odt, rtf, html, md, tex, txt, png
wordcraft-cli convert report.docx report.pdf --strict # exit 3, no file, if a character or picture would be left out
wordcraft-cli text report.docx # plain text
wordcraft-cli inspect report.docx # structure as JSON
wordcraft-cli run --template sample \
@@ -165,40 +164,6 @@ wordcraft-cli run --template sample \
wordcraft-cli run --script job.json # the same from a file: commands, then save to docx and pdf
```
`convert --strict` (a `.pdf` or `.png` output only) and `render --strict` (a PNG of one page) refuse a silently incomplete render: they exit 3
and write nothing if the output would draw a character no font has (an empty box) or leave out a
picture. Exit codes are 0 (written), 1 (unreadable input, usage error, or `--strict` with another
output type, checked before the input is read) and 3 (strict refusal). On exit 3, stderr has one
`wordcraft-cli: strict: …` line per kind, then one JSON line:
```sh
wordcraft-cli convert in.docx out.pdf --strict
# wordcraft-cli: strict: 1 character cannot be drawn: U+2D30 (×3)
# wordcraft-cli: strict: 1 picture(s) left out: word/media/image2.bin (header2.xml): unsupported format
# {"schema":1,"strict":"failed","missing_glyphs":[{"cp":"U+2D30","count":3}],"missing_pictures":[{"part":"word/media/image2.bin","source_part":"word/header2.xml","reason":"unsupported_format"}],"truncated":false,"total_missing_glyphs":1,"total_missing_pictures":1,"glyphs_truncated":false,"pictures_truncated":false}
```
The JSON line (schema 1, also what `file.strictReport` returns) has `schema`, `strict`
(`"passed"` or `"failed"`), `missing_glyphs` (entries `{"cp", "count"}`, sorted by code point),
`missing_pictures` (entries `{"part", "source_part", "reason"}`: the picture's part or target, the
story part it sits in such as `word/header2.xml`, and a reason code; `source_part` is also
`background` (the page picture), `body`, `header`, `footer`, `footnote`, `endnote`, `comment` or
`text box` where the part name is not known (a document not opened from a DOCX), or a chunk path such as
`word/afchunk1.docx > word/header1.xml` for a picture inside an embedded DOCX chunk), `truncated`, the two totals
`total_missing_glyphs` and `total_missing_pictures`, and `glyphs_truncated` and
`pictures_truncated` for each list. The reason codes are `missing_part`, `external_target`,
`link_only`, `undecodable` and `unsupported_format`; the stderr lines use plain words for them. When a list is cut, the stderr line gives the total
(`300 characters cannot be drawn (first 256 listed)`).
Each list stops at 256 entries separately; `truncated` is true when either was cut, and the totals
count every distinct character and picture found, those past the cap included. Only characters the output draws count (not hidden text, or deleted text
without `--markup`), and a character a fallback font draws is fine. Text inside a WMF or EMF picture counts too, shaped in the font the picture names with no fallback. A Private Use Area character
fails only if no installed font maps it. The result depends on the fonts installed on the host
(see #5624 for a fixed font set). An external picture target is never fetched: it is reported, not
loaded. Pictures dropped when a DOCX is opened (a missing part, an external target, or a `link only` picture with no embedded copy) are reported for DOCX input only. They have no page, so a PNG of any page is also refused for a missing or external picture anywhere in the document, unused headers included. `render --strict` checks only the page it
writes (`--page`; `--scale` doesn't matter), prints nothing on stdout when it refuses, and reports the
same way. The engine takes `strict: true` on `file.save`, `file.saveAs`, `file.exportPdf` and
`file.exportPng`, and `file.strictReport {markup?, page?}` returns the report without writing.
Web: `cd apps/wordcraft-web && trunk serve`, then open <http://127.0.0.1:8771/?sample>.
## For agents: CLI and MCP
@@ -354,11 +319,6 @@ XWayland:
WORDCRAFT_WAYLAND=1 wordcraft
```
XWayland needs the X11 libraries, which the packages only recommend and the tarball doesn't
install: libX11, libX11-xcb, libXcursor, libXi and libxkbcommon-x11 (on Debian and Ubuntu,
`libx11-6`, `libx11-xcb1`, `libxcursor1`, `libxi6` and `libxkbcommon-x11-0`). Without them the app
opens on native Wayland, whatever the setting says, and logs which ones are missing.
**Arch Linux (AUR).** Community members maintain three AUR packages; the project doesn't publish
to the AUR itself. Install one of them:
| Package | What it installs | Choose it when |
+1 -98
View File
File diff suppressed because one or more lines are too long
-5
View File
@@ -21,8 +21,3 @@ wordcraft-mcp = { workspace = true }
wordcraft-zotero = { workspace = true }
serde_json = { workspace = true }
sys-locale = "0.3"
[dev-dependencies]
wordcraft-fonts = { workspace = true }
image = { workspace = true }
zip = { workspace = true }
+57 -200
View File
@@ -11,30 +11,23 @@ use std::path::{Path, PathBuf};
use std::process::ExitCode;
use serde_json::{Value, json};
use wordcraft_engine::{CmdError, Session};
use wordcraft_engine::Session;
const USAGE: &str = "\
wordcraft-cli — WordCraft from the command line
USAGE:
wordcraft-cli convert <in> <out> [--markup] [--update-fields] [--strict] [--format wordml2003|flatopc]
wordcraft-cli convert <in> <out> [--markup] [--update-fields] [--format wordml2003|flatopc]
convert between formats (docx, doc [read], pdf, odt, rtf, html, md, tex, txt, json, png,
xml); --update-fields refreshes unlocked fields before saving
(if protection blocks updates, they are skipped with a warning);
xml); --update-fields refreshes unlocked fields before saving;
an .xml file is a Word XML Document (Flat OPC), or with
--format wordml2003 a Word 2003 XML Document;
--markup (Print Markup): comments beside each page and
tracked changes in pdf/png, a comment list in html/md/txt;
--strict (pdf or png output only) writes nothing and exits 3
if a character can't be drawn or a picture is missing or
undecodable; the report is on stderr, with one JSON line
tracked changes in pdf/png, a comment list in html/md/txt
wordcraft-cli info <file> pages, words, paragraphs, properties (JSON)
wordcraft-cli text <file> plain text
wordcraft-cli inspect <file> document structure (JSON)
wordcraft-cli render <file> <out.png> [--page N] [--scale S] [--strict]
--strict writes nothing and exits 3 if a character on the
page can't be drawn or a picture is missing or undecodable
(report on stderr, as for convert --strict)
wordcraft-cli render <file> <out.png> [--page N] [--scale S]
wordcraft-cli run [--file F | --template T] [--script FILE|-] --cmd 'id={json}' [--cmd …]
[--save OUT] [--print] [--author NAME]
run commands headlessly, then save (--author names
@@ -84,14 +77,6 @@ fn wants_help(cmd: &str, rest: &[String]) -> bool {
SUBCOMMANDS.contains(&cmd) && rest.iter().any(|a| a == "--help" || a == "-h")
}
/// A failure whose message may carry text from a file or from the engine's reading of one (a
/// picture's part name, a parse error quoting the input): control, bidi and invisible characters
/// are escaped, so it can't forge or spoof a line (the CLI's own messages, which have line breaks
/// on purpose, are not routed through here). Backslashes stay: messages quote Windows paths.
fn engine_error(e: impl fmt::Display) -> Failure {
Failure::Error(escape_text(&e.to_string(), false))
}
/// The error every subcommand gives when a required path is not there.
fn missing_argument() -> Failure {
format!("missing argument\n\n{USAGE}").into()
@@ -100,11 +85,11 @@ fn missing_argument() -> Failure {
/// Open a document for a subcommand. A password-protected Word file needs its password
/// ([`password_from`]). As in the app, a document opened with one is saved with it again, so
/// `convert` does not quietly write a copy the password no longer protects.
fn open(path: &Path, password: Option<&str>) -> Result<Session, Failure> {
fn open(path: &Path, password: Option<&str>) -> Result<Session, String> {
// A file that gives no page size (HTML, Markdown, plain text) opens on the system region's
// paper, as in the app: A4 in Italy, Letter in the US (#1381).
let region = system_region();
let (doc, encrypted) = wordcraft_engine::io::open_path_in(path, password, &region).map_err(engine_error)?;
let (doc, encrypted) = wordcraft_engine::io::open_path_in(path, password, &region)?;
let mut s = session(doc);
s.path = Some(path.into());
if encrypted {
@@ -213,7 +198,7 @@ fn listed_text(doc: &wordcraft_doc::Document) -> Result<String, String> {
/// The command also checks whether the open document is protected: this name determines which
/// named editing exceptions the session can use, so the flag must not bypass that check.
fn set_author(s: &mut Session, name: &str) -> Result<(), String> {
s.run("file.setAuthor", &json!({"name": name})).map(|_| ()).map_err(|e| escape_text(&format!("--author: {e}"), false))
s.run("file.setAuthor", &json!({"name": name})).map(|_| ()).map_err(|e| format!("--author: {e}"))
}
fn arg_value(args: &[String], name: &str) -> Option<String> {
@@ -325,12 +310,10 @@ const CONVERT_OPTIONS: &[OptionSpec] = &[
OptionSpec { name: "--markup", takes_value: false },
OptionSpec { name: "--format", takes_value: true },
OptionSpec { name: "--update-fields", takes_value: false },
OptionSpec { name: "--strict", takes_value: false },
OptionSpec { name: "--password", takes_value: true },
OptionSpec { name: "--password-file", takes_value: true },
];
const RENDER_OPTIONS: &[OptionSpec] = &[
OptionSpec { name: "--strict", takes_value: false },
OptionSpec { name: "--page", takes_value: true },
OptionSpec { name: "--scale", takes_value: true },
OptionSpec { name: "--password", takes_value: true },
@@ -367,9 +350,6 @@ enum Failure {
/// Whoever read stdout closed it (`wordcraft-cli commands --json | head`): there is nobody left
/// to write to, so stop quietly and exit 0 like other command-line tools.
StdoutClosed,
/// `convert --strict` refused: the text for stderr (the report's lines, then its JSON line).
/// Exit status 3.
Strict(String),
}
impl fmt::Display for Failure {
@@ -377,7 +357,6 @@ impl fmt::Display for Failure {
match self {
Failure::Error(e) => f.write_str(e),
Failure::StdoutClosed => f.write_str("stdout closed"),
Failure::Strict(report) => f.write_str(report),
}
}
}
@@ -423,104 +402,11 @@ fn valid_connect_addr(addr: &str) -> bool {
!host.is_empty() && port.parse::<u16>().is_ok()
}
/// A failed `--strict` export: a strict refusal becomes [`Failure::Strict`] carrying what stderr
/// shows (each line of the report as `wordcraft-cli: …`, then its JSON on one line, from
/// `file.strictReport` for `page` of a page image, else the PDF); any other error is an ordinary
/// failure.
fn refusal(s: &mut Session, e: wordcraft_engine::CmdError, markup: bool, page: Option<u64>) -> Failure {
if !matches!(&e, wordcraft_engine::CmdError::Failed(m) if m.starts_with("strict: ")) {
return engine_error(e);
}
let mut params = json!({"markup": markup});
if let Some(page) = page {
params["page"] = json!(page);
}
Failure::Strict(match s.run("file.strictReport", &params).map_err(|e| e.to_string()) {
Ok(json) => {
let mut text = String::new();
for line in report_lines(&json) {
text += &format!("wordcraft-cli: {}\n", escape_peer(&line));
}
text + &json_line_safe(&json.to_string())
}
Err(e) => format!("wordcraft-cli: strict: refused, and the report could not be made: {}", escape_text(&e, false)),
})
}
/// The report's human lines, from its JSON (the engine's `StrictReport::lines`).
fn report_lines(json: &Value) -> Vec<String> {
wordcraft_engine::strict::lines_from_json(json)
}
/// Explicitly update unlocked fields in a session before saving the document.
///
/// If field updates are disabled by document protection (`CmdError::Disabled`), logs a warning to
/// stderr and continues conversion rather than aborting. Other command failures are converted to
/// [`Failure`] via [`engine_error`].
///
/// # Arguments
///
/// * `s` - The active document [`Session`] whose unlocked fields should be refreshed.
///
/// # Errors
///
/// Returns `Err(Failure)` if the command fails with an error other than `CmdError::Disabled`.
fn update_fields(s: &mut Session) -> Result<(), Failure> {
if let Err(e) = s.run("references.updateFields", &json!({})) {
if matches!(e, CmdError::Disabled(_)) {
eprintln!("wordcraft-cli: warning: {e}");
} else {
return Err(engine_error(e));
}
}
Ok(())
}
/// `convert <in> <out>`: `rest` is the command line after `convert`, `open` opens the input.
fn run_convert(rest: &[String], open: impl Fn(&str, Option<&str>) -> Result<Session, Failure>) -> Result<(), Failure> {
validate_options("convert", rest, CONVERT_OPTIONS)?;
// `--format wordml2003` (or `flatopc`) picks the kind of `.xml` file; its value isn't a path.
let format = match find_option_arg(rest, "--format") {
OptionArg::Absent => None,
OptionArg::Duplicate => return Err("convert: duplicate --format option".into()),
OptionArg::MissingValue => return Err("convert: --format requires a value".into()),
OptionArg::Value(value) => Some(value),
};
let paths = positionals(rest, CONVERT_OPTIONS);
let (i, o) = (paths.first().ok_or_else(missing_argument)?.to_string(), paths.get(1).ok_or_else(missing_argument)?.to_string());
let markup = rest.iter().any(|a| a == "--markup");
let strict = rest.iter().any(|a| a == "--strict");
// Before the input is opened: a wrong output type is a usage error, not a file error.
let to_png = o.to_ascii_lowercase().ends_with(".png");
if strict && !(to_png || o.to_ascii_lowercase().ends_with(".pdf")) {
return Err("convert: --strict needs a .pdf or .png output".into());
}
let password = password_from(rest)?;
let mut s = open(&i, password.as_deref())?;
if rest.iter().any(|a| a == "--update-fields") {
update_fields(&mut s)?;
}
let r = if to_png {
s.run("file.exportPng", &json!({"path": o, "page": 1, "markup": markup, "strict": strict}))
} else {
let mut params = json!({"path": o, "markup": markup, "strict": strict});
if let Some(f) = format {
params["format"] = json!(f);
}
s.run("file.save", &params)
};
if let Err(e) = r {
return Err(if strict { refusal(&mut s, e, markup, to_png.then_some(1)) } else { engine_error(e) });
}
eprintln!("wrote {o}");
Ok(())
}
fn load_run_script(rest: &[String], script_path: Option<&Path>) -> Result<Option<script::Script>, Failure> {
match arg_value(rest, "--script") {
Some(path) if !path.starts_with("--") => {
let source = script_path.unwrap_or(Path::new(&path));
Ok(Some(script::load(source).map_err(engine_error)?))
Ok(Some(script::load(source)?))
}
_ if rest.iter().any(|arg| arg == "--script") => Err("run: --script requires a value".into()),
_ => Ok(None),
@@ -552,13 +438,42 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
return write_stdout(|o| write!(o, "{USAGE}"));
}
match cmd {
"convert" => run_convert(&rest, open),
"convert" => {
validate_options("convert", &rest, CONVERT_OPTIONS)?;
// `--format wordml2003` (or `flatopc`) picks the kind of `.xml` file; its value isn't a path.
let format = match find_option_arg(&rest, "--format") {
OptionArg::Absent => None,
OptionArg::Duplicate => return Err("convert: duplicate --format option".into()),
OptionArg::MissingValue => return Err("convert: --format requires a value".into()),
OptionArg::Value(value) => Some(value),
};
let paths = positionals(&rest, CONVERT_OPTIONS);
let (i, o) = (paths.first().ok_or_else(missing_argument)?.to_string(), paths.get(1).ok_or_else(missing_argument)?.to_string());
let markup = rest.iter().any(|a| a == "--markup");
let password = password_from(&rest)?;
let mut s = open(&i, password.as_deref())?;
if rest.iter().any(|a| a == "--update-fields") {
s.run("references.updateFields", &json!({})).map_err(|e| e.to_string())?;
}
let r = if o.to_ascii_lowercase().ends_with(".png") {
s.run("file.exportPng", &json!({"path": o, "page": 1, "markup": markup}))
} else {
let mut params = json!({"path": o, "markup": markup});
if let Some(f) = format {
params["format"] = json!(f);
}
s.run("file.save", &params)
};
r.map_err(|e| e.to_string())?;
eprintln!("wrote {o}");
Ok(())
}
"info" => {
validate_options("info", &rest, PASSWORD_OPTIONS)?;
let paths = positionals(&rest, PASSWORD_OPTIONS);
let password = password_from(&rest)?;
let mut s = open(paths.first().ok_or_else(missing_argument)?, password.as_deref())?;
let info = serde_json::to_string_pretty(&s.run("file.info", &json!({})).map_err(engine_error)?).unwrap_or_default();
let info = serde_json::to_string_pretty(&s.run("file.info", &json!({})).map_err(|e| e.to_string())?).unwrap_or_default();
write_stdout(|o| writeln!(o, "{info}"))
}
"text" => {
@@ -574,7 +489,7 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
let paths = positionals(&rest, PASSWORD_OPTIONS);
let password = password_from(&rest)?;
let mut s = open(paths.first().ok_or_else(missing_argument)?, password.as_deref())?;
let tree = serde_json::to_string_pretty(&s.run("document.inspect", &json!({})).map_err(engine_error)?).unwrap_or_default();
let tree = serde_json::to_string_pretty(&s.run("document.inspect", &json!({})).map_err(|e| e.to_string())?).unwrap_or_default();
write_stdout(|o| writeln!(o, "{tree}"))
}
"render" => {
@@ -588,17 +503,7 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
let page = parse_page_arg("render", &rest)?.unwrap_or(1);
let scale = parse_scale_arg("render", &rest)?.unwrap_or(2.0);
let mut s = open(&i, password.as_deref())?;
let strict = rest.iter().any(|a| a == "--strict");
let mut params = json!({"path": o, "page": page, "scale": scale, "strict": strict});
if strict {
// The check and its report must see the page as it is written.
params["markup"] = json!(false);
}
let r = match s.run("file.exportPng", &params) {
Ok(r) => r,
Err(e) if strict => return Err(refusal(&mut s, e, false, Some(page))),
Err(e) => return Err(engine_error(e)),
};
let r = s.run("file.exportPng", &json!({"path": o, "page": page, "scale": scale})).map_err(|e| e.to_string())?;
write_stdout(|o| writeln!(o, "{r}"))
}
"run" => {
@@ -617,8 +522,8 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
let mut s = session(wordcraft_doc::Document::new());
let first = s.document_id();
let params = t.map_or_else(|| json!({}), |t| json!({"template": t}));
s.run("file.new", &params).map_err(engine_error)?;
s.close_document(first).map_err(engine_error)?;
s.run("file.new", &params).map_err(|e| e.to_string())?;
s.close_document(first).map_err(|e| e.to_string())?;
s
}
};
@@ -627,7 +532,7 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
}
let print = rest.iter().any(|a| a == "--print");
for (n, (id, params)) in script.iter().flat_map(|c| c.commands.iter()).enumerate() {
let r = s.run(id, params).map_err(|e| engine_error(format!("command {} ({id}): {e}", n + 1)))?;
let r = s.run(id, params).map_err(|e| format!("command {} ({id}): {e}", n + 1))?;
if print {
write_stdout(|o| writeln!(o, "{id}: {r}"))?;
}
@@ -637,12 +542,10 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
if rest.get(i).map(String::as_str) == Some("--cmd") {
let spec = rest.get(i + 1).ok_or("--cmd needs a value")?;
let (id, params) = match spec.split_once('=') {
Some((id, p)) => {
(id.to_string(), serde_json::from_str::<Value>(p).map_err(|e| engine_error(format!("{id}: bad JSON params: {e}")))?)
}
Some((id, p)) => (id.to_string(), serde_json::from_str::<Value>(p).map_err(|e| format!("{id}: bad JSON params: {e}"))?),
None => (spec.clone(), json!({})),
};
let r = s.run(&id, &params).map_err(|e| engine_error(format!("{id}: {e}")))?;
let r = s.run(&id, &params).map_err(|e| format!("{id}: {e}"))?;
if print {
write_stdout(|o| writeln!(o, "{id}: {r}"))?;
}
@@ -662,7 +565,7 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
return Err(format!("{out}: is a folder, not a file").into());
}
if !out.to_ascii_lowercase().ends_with(".png") {
wordcraft_engine::io::save_bytes(out, &s.doc).map_err(engine_error)?;
wordcraft_engine::io::save_bytes(out, &s.doc)?;
}
}
for out in outputs {
@@ -678,7 +581,7 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
} else {
s.run("file.save", &json!({"path": out}))
};
r.map_err(engine_error)?;
r.map_err(|e| e.to_string())?;
eprintln!("wrote {out}");
}
Ok(())
@@ -777,12 +680,10 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
let mut s = open(file, None)?;
for spec in rest.iter().zip(rest.iter().skip(1)).filter(|(a, _)| *a == "--cmd").map(|(_, v)| v) {
let (id, params) = match spec.split_once('=') {
Some((id, p)) => {
(id.to_string(), serde_json::from_str::<Value>(p).map_err(|e| engine_error(format!("{id}: bad JSON params: {e}")))?)
}
Some((id, p)) => (id.to_string(), serde_json::from_str::<Value>(p).map_err(|e| format!("{id}: bad JSON params: {e}"))?),
None => (spec.clone(), json!({})),
};
s.run(&id, &params).map_err(|e| engine_error(format!("{id}: {e}")))?;
s.run(&id, &params).map_err(|e| format!("{id}: {e}"))?;
}
let mut opts = wordcraft_zotero::client::Options::default();
if let Some(p) = arg_value(&rest, "--port") {
@@ -799,13 +700,13 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
}
r
})
.map_err(engine_error)?;
.map_err(|e| e.to_string())?;
for a in &host.alerts {
eprintln!("Zotero: {}", escape_peer(a));
}
eprintln!("{} calls, {}", out.calls, if out.completed { "completed" } else { "ended without completing (cancelled?)" });
if let Some(o) = arg_value(&rest, "--save") {
s.run("file.save", &json!({"path": o})).map_err(engine_error)?;
s.run("file.save", &json!({"path": o})).map_err(|e| e.to_string())?;
eprintln!("wrote {o}");
}
Ok(())
@@ -829,57 +730,17 @@ fn clip(s: &str) -> String {
/// overrides are shown as escapes (`\\`, `\n`, `\u{1b}`), so it can't end its line, drive the
/// terminal or pass for other output.
fn escape_peer(s: &str) -> String {
escape_text(s, true)
}
/// Whether `c` must not reach a terminal as itself: a control character, a line or paragraph
/// separator, a bidi control, or one that shows nothing (soft hyphen, zero-width characters, word
/// joiners, the byte order mark, tag characters), which can hide or reorder what a line says.
fn is_unsafe_char(c: char) -> bool {
c.is_control()
|| matches!(
c,
'\u{00ad}'
| '\u{061c}'
| '\u{200b}'..='\u{200f}'
| '\u{2028}'..='\u{202e}'
| '\u{2060}'..='\u{2064}'
| '\u{2066}'..='\u{2069}'
| '\u{feff}'
| '\u{e0000}'..='\u{e007f}'
)
}
/// [`escape_peer`], with backslashes escaped only when `backslash` (error messages quote Windows
/// paths, and a backslash can't forge or hide anything).
fn escape_text(s: &str, backslash: bool) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'\\' if backslash => out.push_str("\\\\"),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if is_unsafe_char(c) => out.extend(c.escape_unicode()),
c => out.push(c),
}
}
out
}
/// A JSON line safe to print: serde escapes control characters below U+0020 but not the other
/// characters [`is_unsafe_char`] names, which a crafted file could put in a report. They become
/// `\uXXXX` escapes (a surrogate pair above U+FFFF), so the line parses to the same value and
/// can't drive or spoof a terminal.
fn json_line_safe(json: &str) -> String {
let mut out = String::with_capacity(json.len());
for c in json.chars() {
if is_unsafe_char(c) {
for unit in c.encode_utf16(&mut [0u16; 2]) {
out.push_str(&format!("\\u{unit:04x}"));
c if c.is_control() || matches!(c, '\u{061c}' | '\u{200e}' | '\u{200f}' | '\u{2028}'..='\u{202e}' | '\u{2066}'..='\u{2069}') => {
out.extend(c.escape_unicode())
}
} else {
out.push(c);
c => out.push(c),
}
}
out
@@ -925,10 +786,6 @@ fn main() -> ExitCode {
eprintln!("wordcraft-cli: {e}");
ExitCode::FAILURE
}
Err(Failure::Strict(report)) => {
eprintln!("{report}");
ExitCode::from(3)
}
}
}
-16
View File
@@ -564,19 +564,3 @@ fn script_parse_resolves_paths_against_the_base() {
assert_eq!(s.save, vec!["o.docx".to_string()]);
assert_eq!(s.commands.len(), 1);
}
#[test]
fn invisible_characters_are_escaped_and_json_lines_still_parse_back() {
let hidden = "a\u{ad}b\u{200b}c\u{200d}d\u{2060}e\u{2064}f\u{feff}g\u{e0041}h\u{e007f}i\u{85}j\u{202e}k";
let shown = escape_peer(hidden);
assert!(!shown.chars().any(is_unsafe_char), "{shown}");
assert!(shown.contains("\\u{e0041}") && shown.contains("\\u{ad}"), "{shown}");
// Errors keep a Windows path readable.
assert_eq!(escape_text("C:\\dir\\a.docx", false), "C:\\dir\\a.docx");
let value = json!({"part": hidden, "plain": "ünïcode ok"});
let line = json_line_safe(&value.to_string());
assert!(!line.chars().any(is_unsafe_char), "{line}");
assert!(line.contains("\\ud800") || line.contains("\\udb40"), "{line}");
assert_eq!(serde_json::from_str::<Value>(&line).unwrap(), value);
}
@@ -47,65 +47,3 @@ fn convert_refresh_is_opt_in() {
}
std::fs::remove_dir_all(dir).unwrap();
}
/// Converting a protected document with `--update-fields` skips updating fields with a warning,
/// retains the cached field result, and preserves the protection mode on output.
#[test]
fn convert_protected_document_with_update_fields_skips_with_warning() {
let dir = std::env::temp_dir().join(format!("wc-convert-fields-prot-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
for (mode, expected_warning) in [("readOnly", "protected"), ("forms", "protected"), ("comments", "comments")] {
let mut doc = Document::from_text("Contents\nTarget");
doc.para_mut(StoryRef::Body, &Path::top(1)).unwrap().props.page_break_before = Some(true);
doc.insert_object(&Pos::body(1, 0), InlineObject::BookmarkStart { name: "target".into() }, &Default::default()).unwrap();
doc.insert_object(
&Pos::body(0, 0),
InlineObject::Field {
instr: "PAGEREF target".into(),
result: "104".into(),
locked: false,
dirty: true,
code: false,
form: String::new(),
},
&Default::default(),
)
.unwrap();
doc.settings.protection = Some(mode.into());
let input = dir.join(format!("in_{mode}.json"));
std::fs::write(&input, serde_json::to_vec(&doc).unwrap()).unwrap();
let docx = dir.join(format!("in_{mode}.docx"));
let make = Command::new(env!("CARGO_BIN_EXE_wordcraft-cli")).arg("convert").arg(&input).arg(&docx).output().unwrap();
assert!(make.status.success(), "{}", String::from_utf8_lossy(&make.stderr));
let output = dir.join(format!("out_{mode}.json"));
let _ = std::fs::remove_file(&output);
let mut cmd = Command::new(env!("CARGO_BIN_EXE_wordcraft-cli"));
cmd.arg("convert").arg(&docx).arg(&output).arg("--update-fields");
let run = cmd.output().unwrap();
let stderr = String::from_utf8_lossy(&run.stderr);
assert!(run.status.success(), "exit status was failure for mode {mode}: {stderr}");
assert!(stderr.contains("warning:"), "stderr missing warning for mode {mode}: {stderr}");
assert!(stderr.contains(expected_warning), "stderr missing '{expected_warning}' for mode {mode}: {stderr}");
assert!(output.exists(), "output file was not created for mode {mode}");
let out: Document = serde_json::from_slice(&std::fs::read(&output).unwrap()).unwrap();
let results: Vec<_> = out
.para(StoryRef::Body, &Path::top(0))
.unwrap()
.objects
.iter()
.filter_map(|o| match o {
InlineObject::Field { result, .. } => Some(result.as_str()),
_ => None,
})
.collect();
// Result should remain "104" (cached) because the field could not be updated under protection
assert_eq!(results, vec!["104"], "field result was unexpectedly modified for mode {mode}");
assert_eq!(out.settings.protection.as_deref(), Some(mode), "protection mode was not preserved for mode {mode}");
}
std::fs::remove_dir_all(dir).unwrap();
}
-1
View File
@@ -6,5 +6,4 @@ mod help;
#[cfg(all(unix, not(target_os = "macos")))]
mod non_utf8_args;
mod picture_then_typing;
mod strict;
mod text_tracked;
-495
View File
@@ -1,495 +0,0 @@
//! `convert --strict` (#6005): exit 3 and a report when a PDF or PNG would silently lack a
//! character or a picture, nothing written; exit 0 otherwise.
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use wordcraft_doc::Document;
use wordcraft_fonts::FontDb;
const NS: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture""#;
const REL: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
const RELS_NS: &str = "http://schemas.openxmlformats.org/package/2006/relationships";
fn dir(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("wc-cli-strict-{name}-{}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
d
}
fn convert(input: &Path, output: &Path, strict: bool) -> Output {
let mut cmd = Command::new(env!("CARGO_BIN_EXE_wordcraft-cli"));
cmd.arg("convert").arg(input).arg(output);
if strict {
cmd.arg("--strict");
}
cmd.output().unwrap()
}
fn stderr(o: &Output) -> String {
String::from_utf8_lossy(&o.stderr).into_owned()
}
/// The report's JSON line: the last line of stderr.
fn report_json(o: &Output) -> serde_json::Value {
let text = stderr(o);
let last = text.lines().last().unwrap_or_default();
serde_json::from_str(last).unwrap_or_else(|e| panic!("last stderr line is not JSON ({e}): {text}"))
}
fn json_input(d: &Path, text: &str) -> PathBuf {
let path = d.join("in.json");
std::fs::write(&path, serde_json::to_vec(&Document::from_text(text)).unwrap()).unwrap();
path
}
/// Built without craft-fonts, the host's own fonts decide what is drawable: skip, as the layout
/// tests do.
fn without_fonts() -> bool {
let missing = option_env!("CRAFT_FONTS_DIR").is_none_or(str::is_empty);
if missing {
eprintln!("skipped: built without craft-fonts (set CRAFT_FONTS_DIR to a checkout)");
}
missing
}
/// A code point in unassigned plane 4 that no face of the host covers.
fn uncovered() -> char {
let db = FontDb::global();
let primary = wordcraft_fonts::word::resolve(&Document::default().settings.minor_font, false, false).face;
(0x40000u32..0x4FFF0)
.filter_map(char::from_u32)
.find(|c| !primary.covers(*c) && db.fallback_styled(*c, primary.id(), false, false).is_none() && db.fallback_for(*c, primary.id()).is_none())
.expect("the host has faces covering all of plane 4")
}
fn png() -> Vec<u8> {
let mut out = Vec::new();
image::RgbaImage::from_pixel(4, 4, image::Rgba([20, 90, 200, 255]))
.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png)
.unwrap();
out
}
fn pic(rid: &str) -> String {
format!(
r#"<w:r><w:drawing><wp:inline><wp:extent cx="508000" cy="508000"/><wp:docPr id="1" name="P"/><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:nvPicPr><pic:cNvPr id="0" name="p"/><pic:cNvPicPr/></pic:nvPicPr><pic:blipFill><a:blip r:embed="{rid}"/></pic:blipFill><pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="508000" cy="508000"/></a:xfrm><a:prstGeom prst="rect"/></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r>"#
)
}
/// A DOCX whose header holds a picture of `header_media` (`word/media/image2.bin`).
fn docx_with_header_picture(header_media: &[u8]) -> Vec<u8> {
let doc = format!(
r#"<w:document {NS}><w:body><w:p><w:r><w:t>text</w:t></w:r></w:p><w:sectPr><w:headerReference w:type="default" r:id="rIdH"/></w:sectPr></w:body></w:document>"#
);
let drels = format!(r#"<Relationships xmlns="{RELS_NS}"><Relationship Id="rIdH" Type="{REL}/header" Target="header2.xml"/></Relationships>"#);
let hrels = format!(r#"<Relationships xmlns="{RELS_NS}"><Relationship Id="rIdI" Type="{REL}/image" Target="media/image2.bin"/></Relationships>"#);
let hdr = format!(r#"<w:hdr {NS}><w:p>{}</w:p></w:hdr>"#, pic("rIdI"));
package(
&doc,
&drels,
&[("word/header2.xml", hdr.as_bytes()), ("word/_rels/header2.xml.rels", hrels.as_bytes()), ("word/media/image2.bin", header_media)],
)
}
/// A DOCX whose body picture points outside the file, at `target`.
fn docx_with_external_picture(target: &str) -> Vec<u8> {
let doc = format!(r#"<w:document {NS}><w:body><w:p>{}<w:r><w:t>text</w:t></w:r></w:p></w:body></w:document>"#, pic("rIdX"));
let drels = format!(
r#"<Relationships xmlns="{RELS_NS}"><Relationship Id="rIdX" Type="{REL}/image" Target="{target}" TargetMode="External"/></Relationships>"#
);
package(&doc, &drels, &[])
}
fn package(document: &str, document_rels: &str, extra: &[(&str, &[u8])]) -> Vec<u8> {
let rels = format!(
r#"<Relationships xmlns="{RELS_NS}"><Relationship Id="rId1" Type="{REL}/officeDocument" Target="word/document.xml"/></Relationships>"#
);
let types = r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="png" ContentType="image/png"/><Default Extension="bin" ContentType="application/octet-stream"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>"#;
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let mut parts: Vec<(&str, &[u8])> = vec![
("[Content_Types].xml", types.as_bytes()),
("_rels/.rels", rels.as_bytes()),
("word/document.xml", document.as_bytes()),
("word/_rels/document.xml.rels", document_rels.as_bytes()),
];
parts.extend_from_slice(extra);
for (name, bytes) in parts {
zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
zip.write_all(bytes).unwrap();
}
zip.finish().unwrap().into_inner()
}
#[test]
fn multilingual_names_pass_strict() {
if without_fonts() {
return;
}
let names = "Łukasz Żmijewski\nGheorghe Ștefănescu\nKrejčí\nŞahın Gül\nLlŷr Grŵn\n王小明\n김민수\nสมชาย ใจดี\nराम शर्मा";
let d = dir("names");
let input = json_input(&d, names);
for out in ["names.pdf", "names.png"] {
let out = d.join(out);
let r = convert(&input, &out, true);
assert_eq!(r.status.code(), Some(0), "{}", stderr(&r));
assert!(out.exists());
}
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn an_uncovered_code_point_fails_strict_with_a_report_and_no_file() {
let c = uncovered();
let cp = format!("U+{:04X}", c as u32);
let d = dir("uncovered");
let input = json_input(&d, &format!("a{c}b{c}{c}"));
for out in ["out.pdf", "out.png"] {
let out = d.join(out);
let r = convert(&input, &out, true);
assert_eq!(r.status.code(), Some(3), "{}", stderr(&r));
let text = stderr(&r);
assert!(text.contains(&format!("wordcraft-cli: strict: 1 character cannot be drawn: {cp} (×3)")), "{text}");
let json = report_json(&r);
assert_eq!(json["schema"], 1);
assert_eq!(json["strict"], "failed");
assert_eq!(json["missing_glyphs"][0]["cp"], cp.as_str());
assert_eq!(json["missing_glyphs"][0]["count"], 3);
assert!(!out.exists(), "{} was written", out.display());
eprintln!("{text}");
// Without --strict the same input converts, as before.
let r = convert(&input, &out, false);
assert_eq!(r.status.code(), Some(0), "{}", stderr(&r));
assert!(out.exists());
std::fs::remove_file(&out).unwrap();
}
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn a_real_png_in_a_header_passes_strict() {
let d = dir("header-png");
let input = d.join("in.docx");
std::fs::write(&input, docx_with_header_picture(&png())).unwrap();
for out in ["out.pdf", "out.png"] {
let out = d.join(out);
let r = convert(&input, &out, true);
assert_eq!(r.status.code(), Some(0), "{}", stderr(&r));
assert!(out.exists());
}
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn a_corrupt_header_picture_fails_strict_naming_the_part_and_the_header() {
let d = dir("header-corrupt");
let input = d.join("in.docx");
std::fs::write(&input, docx_with_header_picture(b"corrupt bytes, not a picture")).unwrap();
for out in ["out.pdf", "out.png"] {
let out = d.join(out);
let r = convert(&input, &out, true);
assert_eq!(r.status.code(), Some(3), "{}", stderr(&r));
let text = stderr(&r);
assert!(text.contains("word/media/image2.bin (header2.xml): unsupported format"), "{text}");
let json = report_json(&r);
assert_eq!(json["strict"], "failed");
assert_eq!(
json["missing_pictures"][0],
serde_json::json!({"part": "word/media/image2.bin", "source_part": "word/header2.xml", "reason": "unsupported_format"})
);
assert!(!out.exists());
eprintln!("{text}");
}
// Without --strict the grey box is drawn, as before.
let out = d.join("lenient.pdf");
assert_eq!(convert(&input, &out, false).status.code(), Some(0));
assert!(out.exists());
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn an_external_picture_target_is_never_fetched_and_is_reported_under_strict() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
listener.set_nonblocking(true).unwrap();
let target = format!("http://{}/canary.png", listener.local_addr().unwrap());
let d = dir("external");
let input = d.join("in.docx");
std::fs::write(&input, docx_with_external_picture(&target)).unwrap();
let lenient = convert(&input, &d.join("lenient.pdf"), false);
assert_eq!(lenient.status.code(), Some(0), "{}", stderr(&lenient));
let out = d.join("strict.pdf");
let strict = convert(&input, &out, true);
assert_eq!(strict.status.code(), Some(3), "{}", stderr(&strict));
let json = report_json(&strict);
assert_eq!(json["missing_pictures"][0]["reason"], "external_target");
assert!(stderr(&strict).contains("external target"));
assert!(!out.exists());
match listener.accept() {
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {}
Ok((_, from)) => panic!("the converter connected to the external target from {from}"),
Err(e) => panic!("{e}"),
}
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn bad_input_exits_1_with_the_usual_message_with_and_without_strict() {
let d = dir("bad-input");
let good = docx_with_header_picture(&png());
let truncated = d.join("truncated.docx");
std::fs::write(&truncated, &good[..good.len() / 2]).unwrap();
let not_zip = d.join("not-zip.docx");
std::fs::write(&not_zip, b"this is plainly not a zip archive").unwrap();
for input in [&truncated, &not_zip] {
let plain = convert(input, &d.join("a.pdf"), false);
let strict = convert(input, &d.join("b.pdf"), true);
assert_eq!(plain.status.code(), Some(1), "{}", stderr(&plain));
assert_eq!(strict.status.code(), Some(1), "{}", stderr(&strict));
assert_eq!(stderr(&plain), stderr(&strict));
assert!(stderr(&plain).starts_with("wordcraft-cli: "), "{}", stderr(&plain));
assert!(!d.join("a.pdf").exists() && !d.join("b.pdf").exists());
}
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn strict_with_another_output_type_exits_1_before_the_input_is_read() {
let d = dir("wrong-output");
let missing = d.join("does-not-exist.docx");
for out in ["out.docx", "out.txt", "out.odt"] {
let r = convert(&missing, &d.join(out), true);
assert_eq!(r.status.code(), Some(1));
let text = stderr(&r);
assert!(text.contains("--strict needs a .pdf or .png output"), "{text}");
assert!(!d.join(out).exists());
}
// Without --strict the same command fails on the missing input.
let r = convert(&missing, &d.join("out.docx"), false);
assert_eq!(r.status.code(), Some(1));
assert!(!stderr(&r).contains("--strict"));
std::fs::remove_dir_all(d).unwrap();
}
fn render(input: &Path, output: &Path, args: &[&str]) -> Output {
Command::new(env!("CARGO_BIN_EXE_wordcraft-cli")).arg("render").arg(input).arg(output).args(args).output().unwrap()
}
/// Page 1 has `a{c}b`, page 2 plain text.
fn two_page_input(d: &Path, text: &str) -> PathBuf {
let mut doc = Document::from_text(&format!("{text}\nSecond page"));
doc.para_mut(wordcraft_doc::StoryRef::Body, &wordcraft_doc::Path::top(1)).unwrap().props.page_break_before = Some(true);
let path = d.join("two.json");
std::fs::write(&path, serde_json::to_vec(&doc).unwrap()).unwrap();
path
}
#[test]
fn render_strict_refuses_an_uncovered_character_on_the_page_with_no_stdout_or_file() {
let c = uncovered();
let cp = format!("U+{:04X}", c as u32);
let d = dir("render-glyph");
let input = two_page_input(&d, &format!("a{c}b"));
let out = d.join("p1.png");
let r = render(&input, &out, &["--strict"]);
assert_eq!(r.status.code(), Some(3), "{}", stderr(&r));
assert!(r.stdout.is_empty(), "{}", String::from_utf8_lossy(&r.stdout));
assert!(!out.exists());
let text = stderr(&r);
assert!(text.contains(&format!("wordcraft-cli: strict: 1 character cannot be drawn: {cp} (×1)")), "{text}");
assert_eq!(report_json(&r)["missing_glyphs"][0]["cp"], cp.as_str());
eprintln!("{text}");
// The character is not on page 2, and --scale does not matter to the check.
let out2 = d.join("p2.png");
let r = render(&input, &out2, &["--page", "2", "--strict", "--scale", "0.5"]);
assert_eq!(r.status.code(), Some(0), "{}", stderr(&r));
assert!(out2.exists());
assert!(String::from_utf8_lossy(&r.stdout).contains("\"path\""));
// And the same page 1 without --strict renders, with the usual stdout.
let out3 = d.join("lenient.png");
let r = render(&input, &out3, &[]);
assert_eq!(r.status.code(), Some(0), "{}", stderr(&r));
assert!(out3.exists());
let json: serde_json::Value = serde_json::from_slice(&r.stdout).unwrap();
assert_eq!(json["path"], out3.to_string_lossy().as_ref());
assert!(json["width"].as_u64().is_some() && json["bytes"].as_u64().is_some());
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn render_strict_refuses_a_corrupt_header_picture_and_bad_input_still_exits_1() {
let d = dir("render-pic");
let input = d.join("in.docx");
std::fs::write(&input, docx_with_header_picture(b"corrupt bytes, not a picture")).unwrap();
let out = d.join("out.png");
let r = render(&input, &out, &["--strict"]);
assert_eq!(r.status.code(), Some(3), "{}", stderr(&r));
assert!(r.stdout.is_empty());
assert!(stderr(&r).contains("word/media/image2.bin (header2.xml)"), "{}", stderr(&r));
assert!(!out.exists());
let bad = d.join("bad.docx");
std::fs::write(&bad, b"this is plainly not a zip archive").unwrap();
let (plain, strict) = (render(&bad, &out, &[]), render(&bad, &out, &["--strict"]));
assert_eq!((plain.status.code(), strict.status.code()), (Some(1), Some(1)));
assert_eq!(stderr(&plain), stderr(&strict));
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn a_picture_target_with_a_newline_and_a_bidi_override_cannot_forge_a_report_line() {
let d = dir("escape");
let input = d.join("in.docx");
// XML character references put a newline and U+202E in the target.
std::fs::write(&input, docx_with_external_picture("http://example.invalid/a&#10;wordcraft-cli: strict: forged&#x202E;gnp.png")).unwrap();
let out = d.join("out.pdf");
let r = convert(&input, &out, true);
assert_eq!(r.status.code(), Some(3), "{}", stderr(&r));
let text = stderr(&r);
let lines: Vec<&str> = text.lines().collect();
// One human line for the picture, then the JSON line.
assert_eq!(lines.len(), 2, "{text}");
assert!(lines[0].starts_with("wordcraft-cli: strict: 1 picture(s) left out: "), "{text}");
assert!(lines[0].contains("\\nwordcraft-cli: strict: forged\\u{202e}gnp.png"), "{text}");
assert!(!text.contains('\u{202e}'), "the raw bidi override reached the terminal");
// The JSON keeps the raw value, escaped by the JSON encoder.
let json = report_json(&r);
let part = json["missing_pictures"][0]["part"].as_str().unwrap();
assert!(part.contains('\n') && part.contains('\u{202e}'), "{part:?}");
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn run_with_strict_cannot_forge_an_error_line_either() {
let d = dir("run-escape");
let input = d.join("in.docx");
std::fs::write(&input, docx_with_external_picture("http://example.invalid/a&#10;wordcraft-cli: strict: forged&#x1B;[2J&#x202E;gnp.png")).unwrap();
let out = d.join("out.pdf");
let cmd = format!("file.exportPdf={}", serde_json::json!({"path": out, "strict": true}));
let r = Command::new(env!("CARGO_BIN_EXE_wordcraft-cli")).arg("run").arg("--file").arg(&input).arg("--cmd").arg(cmd).output().unwrap();
assert_eq!(r.status.code(), Some(1));
let text = stderr(&r);
assert_eq!(text.lines().count(), 1, "{text}");
assert!(text.contains("\\u{1b}") || text.contains("\\u{1b}[2J"), "{text}");
assert!(!text.contains('\u{1b}') && !text.contains('\u{202e}'), "{text:?}");
assert!(!out.exists());
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn usage_errors_keep_their_line_breaks() {
// Errors go to stderr with the usage after a blank line (`missing argument`, `unknown command`).
for args in [&["frobnicate"][..], &["convert"][..]] {
let r = Command::new(env!("CARGO_BIN_EXE_wordcraft-cli")).args(args).output().unwrap();
let text = stderr(&r);
assert_eq!(r.status.code(), Some(1), "{args:?}: {text}");
assert!(text.lines().count() > 3, "{args:?}: {text}");
assert!(text.lines().any(|l| l == "USAGE:"), "{args:?}: {text}");
assert!(!text.contains("\\n"), "{args:?}: {text}");
}
// No arguments at all prints the usage as help, on stdout.
let r = Command::new(env!("CARGO_BIN_EXE_wordcraft-cli")).output().unwrap();
let all = format!("{}{}", String::from_utf8_lossy(&r.stdout), stderr(&r));
assert!(all.lines().count() > 3 && all.lines().any(|l| l == "USAGE:"), "{all}");
}
/// A minimal EMF (88-byte header, 100 mm square frame) with one EMR_EXTTEXTOUTW record showing
/// `text` in the default font.
fn emf_with_text(text: &str) -> Vec<u8> {
let units: Vec<u16> = text.encode_utf16().collect();
let mut rec = vec![0u8; 76];
rec[0..4].copy_from_slice(&84u32.to_le_bytes());
rec[36..40].copy_from_slice(&10i32.to_le_bytes());
rec[40..44].copy_from_slice(&40i32.to_le_bytes());
rec[44..48].copy_from_slice(&(units.len() as u32).to_le_bytes());
rec[48..52].copy_from_slice(&76u32.to_le_bytes());
rec.extend(units.iter().flat_map(|u| u.to_le_bytes()));
while !rec.len().is_multiple_of(4) {
rec.push(0);
}
let len = rec.len() as u32;
rec[4..8].copy_from_slice(&len.to_le_bytes());
let eof: Vec<u8> = [14u32.to_le_bytes(), 20u32.to_le_bytes(), [0; 4], [0; 4], 20u32.to_le_bytes()].concat();
let total = 88 + rec.len() + eof.len();
let mut h = vec![0u8; 88];
h[0..4].copy_from_slice(&1u32.to_le_bytes());
h[4..8].copy_from_slice(&88u32.to_le_bytes());
h[8..24].copy_from_slice(&[0i32, 0, 100, 100].iter().flat_map(|v| v.to_le_bytes()).collect::<Vec<u8>>());
h[24..40].copy_from_slice(&[0i32, 0, 10000, 10000].iter().flat_map(|v| v.to_le_bytes()).collect::<Vec<u8>>());
h[40..44].copy_from_slice(&0x464D_4520u32.to_le_bytes());
h[44..48].copy_from_slice(&0x0001_0000u32.to_le_bytes());
h[48..52].copy_from_slice(&(total as u32).to_le_bytes());
h[52..56].copy_from_slice(&2u32.to_le_bytes());
h[56..58].copy_from_slice(&1u16.to_le_bytes());
h[72..76].copy_from_slice(&1000i32.to_le_bytes());
h[76..80].copy_from_slice(&1000i32.to_le_bytes());
h[80..84].copy_from_slice(&250i32.to_le_bytes());
h[84..88].copy_from_slice(&250i32.to_le_bytes());
[h, rec, eof].concat()
}
/// The text a metafile picture draws is checked like the page's own: a character no face covers
/// refuses the export from `convert` and `render`, naming the code point, and nothing is written.
#[test]
fn a_character_in_a_metafile_pictures_text_fails_strict_like_the_pages_own() {
if without_fonts() {
return;
}
let c = uncovered();
let cp = format!("U+{:04X}", c as u32);
let d = dir("emf-text");
let emf = emf_with_text(&format!("ab{c}"));
let input = d.join("in.docx");
std::fs::write(&input, docx_with_header_picture(&emf)).unwrap();
for out in ["out.pdf", "out.png"] {
let out = d.join(out);
let r = convert(&input, &out, true);
assert_eq!(r.status.code(), Some(3), "{}", stderr(&r));
assert_eq!(report_json(&r)["missing_glyphs"][0]["cp"], cp.as_str(), "{}", stderr(&r));
assert!(!out.exists(), "{} was written", out.display());
// Without --strict it converts, the character a hollow box.
let r = convert(&input, &out, false);
assert_eq!(r.status.code(), Some(0), "{}", stderr(&r));
std::fs::remove_file(&out).unwrap();
}
let out = d.join("p1.png");
let r = render(&input, &out, &["--strict"]);
assert_eq!(r.status.code(), Some(3), "{}", stderr(&r));
assert!(r.stdout.is_empty() && !out.exists());
assert_eq!(report_json(&r)["missing_glyphs"][0]["cp"], cp.as_str());
// Text the faces cover passes.
std::fs::write(&input, docx_with_header_picture(&emf_with_text("abc"))).unwrap();
let r = convert(&input, &d.join("ok.pdf"), true);
assert_eq!(r.status.code(), Some(0), "{}", stderr(&r));
std::fs::remove_dir_all(d).unwrap();
}
/// The page raster leaves out a metafile page background (it only draws raster ones), so a PNG
/// would silently lack it: strict refuses that output, and a PDF, which draws it as vector, passes.
#[test]
fn a_metafile_page_background_fails_strict_for_png_but_not_for_pdf() {
if without_fonts() {
return;
}
let d = dir("emf-background");
let mut doc = Document::from_text("text");
doc.media.insert("bg.emf".into(), std::sync::Arc::new(emf_with_text("abc")));
doc.settings.page_picture = Some(wordcraft_doc::PagePicture { media: "bg.emf".into(), fill_type: "frame".into(), title: String::new() });
let input = d.join("in.json");
std::fs::write(&input, serde_json::to_vec(&doc).unwrap()).unwrap();
let png = d.join("out.png");
let r = convert(&input, &png, true);
assert_eq!(r.status.code(), Some(3), "{}", stderr(&r));
let json = report_json(&r);
assert_eq!(json["missing_pictures"][0]["part"], "bg.emf", "{}", stderr(&r));
assert_eq!(json["missing_pictures"][0]["source_part"], "background");
assert_eq!(json["missing_pictures"][0]["reason"], "unsupported_format");
assert!(!png.exists());
let pdf = d.join("out.pdf");
let r = convert(&input, &pdf, true);
assert_eq!(r.status.code(), Some(0), "{}", stderr(&r));
assert!(pdf.exists());
std::fs::remove_dir_all(d).unwrap();
}
+1 -3
View File
@@ -17,9 +17,7 @@ path = "src/main.rs"
wordcraft-engine = { workspace = true }
wordcraft-ui-egui = { workspace = true }
# The host messages (storytold/craft-libs): one contract and one bridge for the web builds.
# The page command API (storytold/craft-libs#413): window.wordcraft over the control channel.
craft-web-api = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" }
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" }
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "2e2312ddaf21bc3d85cd3395d0955c7d239306f6", version = "0.1.0" }
egui = { workspace = true }
log = { workspace = true }
serde_json = { workspace = true }
-61
View File
@@ -1,61 +0,0 @@
//! `window.wordcraft`: the page command API (`craft-web-api`, storytold/craft-libs#413) over the
//! app's own control channel, the one the desktop control server drives (`docs/control-protocol.md`).
//!
//! The crate owns the JavaScript side (`request`, `execute`, `commands`, `docInspect`, `uiInspect`,
//! `screenshot`, as promises with a timeout). This module is WordCraft's part: a [`Request`] that
//! queues a [`ControlRequest`] for the app, which answers it on its next frame, and waits for the
//! reply. WordCraft's control request has no abandon state, so a call the page gave up on still runs
//! when the app reaches it, as it does for a desktop client that timed out.
use std::rc::Rc;
use std::sync::mpsc::{Receiver, TryRecvError, channel};
use craft_web_api::methods::Methods;
use craft_web_api::page::{PageApi, Request};
use serde_json::{Value, json};
use wasm_bindgen_futures::JsFuture;
use wordcraft_ui_egui::ControlRequest;
/// The app id in `window.<app>`.
const APP: &str = "wordcraft";
/// How often a waiting call looks for the app's reply, in milliseconds (about a frame).
const POLL_MS: i32 = 16;
/// Put `window.wordcraft` on the page and return the queue the app drains each frame (hand it to
/// `WordApp::with_control`). An existing `window.wordcraft` keeps the functions it already has.
pub fn install(ctx: egui::Context) -> Receiver<ControlRequest> {
let (queue, requests) = channel::<ControlRequest>();
let request: Request = Rc::new(move |method, params| {
let (control, reply) = ControlRequest::new(method, params);
if queue.send(control).is_err() {
return Box::pin(async { json!({"ok": false, "error": "the app is not running"}) });
}
ctx.request_repaint();
Box::pin(wait(reply))
});
if let Err(e) = Rc::new(PageApi::new(APP, Methods::default(), request)).install() {
log::warn!("couldn't install window.{APP}: {e:?}");
}
requests
}
/// The app's reply, polled about once a frame.
async fn wait(reply: Receiver<Value>) -> Value {
loop {
match reply.try_recv() {
Ok(reply) => return reply,
Err(TryRecvError::Empty) => sleep(POLL_MS).await,
Err(TryRecvError::Disconnected) => return json!({"ok": false, "error": "the app dropped the request"}),
}
}
}
async fn sleep(ms: i32) {
let promise = js_sys::Promise::new(&mut |resolve, _| {
if let Some(window) = web_sys::window() {
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, ms);
}
});
let _ = JsFuture::from(promise).await;
}
-2
View File
@@ -20,8 +20,6 @@ mod prefs;
#[cfg(any(target_arch = "wasm32", test))]
mod save_fallback;
#[cfg(target_arch = "wasm32")]
mod api;
#[cfg(target_arch = "wasm32")]
mod host;
#[cfg(target_arch = "wasm32")]
+1 -2
View File
@@ -76,8 +76,7 @@ pub fn start() {
if let Some(bridge) = &app_bridge {
bridge.connect(&mut services, inbox.clone(), cc.egui_ctx.clone());
}
let control = crate::api::install(cc.egui_ctx.clone());
let mut app = WordApp::new(session, services).with_control(control);
let mut app = WordApp::new(session, services);
// Pasted pictures wait apart from dropped files: the app inserts one only when the page is the edit target (#1349).
app.services.pasted = Some(pasted);
app.autosave = false;
-5
View File
@@ -64,11 +64,6 @@ wl-clipboard-rs = "0.9"
# Another program's RTF on X11 (`text/rtf`), which arboard doesn't read. Already in the build
# through arboard, at this version.
x11rb = "0.13"
# Check that the libraries winit's X11 backend opens are installed before choosing XWayland (see
# `x11_libraries` in main.rs). Both are already in the build through winit's `x11` feature, at
# these versions and with this feature.
x11-dl = "2.21"
xkbcommon-dl = { version = "0.4.2", features = ["x11"] }
[target.'cfg(target_os = "windows")'.dependencies]
# The upstream default-apps button opens Windows Settings; printing uses craft-print instead.
+2 -23
View File
@@ -108,8 +108,8 @@ pub fn formats() -> Vec<String> {
/// The picture on the clipboard: copied files, else image pixels, else (Wayland) an encoded
/// picture in a format arboard doesn't decode. A picture that is there but can't be read says
/// why (#833). The UI checks the sizes before inserting anything; on Windows and Wayland they are
/// checked from the header before anything is decoded (#3623).
/// why (#833). The UI checks the sizes before inserting anything; on Windows they are checked
/// from the header before anything is decoded (#3623).
pub fn picture() -> Option<ClipboardPicture> {
let mut clipboard = match arboard::Clipboard::new() {
Ok(c) => c,
@@ -136,10 +136,6 @@ fn decoded_picture(_: &mut arboard::Clipboard) -> Option<ClipboardPicture> {
/// The picture arboard decodes from the clipboard, else an encoded one read past it.
#[cfg(not(target_os = "windows"))]
fn decoded_picture(clipboard: &mut arboard::Clipboard) -> Option<ClipboardPicture> {
#[cfg(all(unix, not(any(target_os = "macos", target_os = "android", target_os = "emscripten"))))]
if let Some(picture) = platform::picture() {
return Some(picture);
}
match clipboard.get_image() {
Ok(image) => Some(ClipboardPicture::Pixels { width: image.width, height: image.height, rgba: image.bytes.into_owned() }),
Err(arboard::Error::ContentNotAvailable) => platform::encoded_picture().map(ClipboardPicture::Encoded),
@@ -164,7 +160,6 @@ mod platform {
use std::io::Read;
use wl_clipboard_rs::paste::{ClipboardType, MimeType, Seat, get_contents, get_mime_types_ordered};
use wordcraft_ui_egui::paste_picture::ClipboardPicture;
fn wayland() -> bool {
std::env::var_os("WAYLAND_DISPLAY").is_some()
@@ -348,22 +343,6 @@ mod platform {
}
}
/// The Wayland clipboard's picture, read encoded under the paste's byte cap and refused from
/// its header when a paste can't take its size, before anything decodes it. arboard decodes
/// and converts the PNG to RGBA with no size limits. `None` when Wayland offers no picture
/// (or isn't reachable): arboard is asked then, which also covers X11.
pub fn picture() -> Option<ClipboardPicture> {
if !wayland() {
return None;
}
let offered = formats();
let mime = super::PICTURE_MIMES.into_iter().find(|m| offered.iter().any(|o| o == m))?;
Some(match read(mime, wordcraft_ui_egui::paste_picture::MAX_FILE_BYTES) {
Some(bytes) => wordcraft_ui_egui::paste_picture::encoded_picture(bytes),
None => ClipboardPicture::Unreadable("the clipboard picture is larger than 200 MB, or couldn't be read".into()),
})
}
#[cfg(test)]
mod tests {
use super::*;
+11 -183
View File
@@ -9,7 +9,6 @@ use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::mpsc::{Receiver, Sender, channel};
use std::time::{Duration, Instant};
@@ -22,38 +21,6 @@ const MAX_LINE: usize = 1 << 20;
/// A connection that sends no authorised request in this time is closed.
const FIRST_REQUEST: Duration = Duration::from_secs(30);
/// How long a caller waits for the UI to answer a request. A request still queued after this
/// is cancelled and never runs.
const REPLY_WAIT: Duration = Duration::from_secs(60);
/// Connections that have not yet sent an authorised request are limited to this many; more are
/// closed at once. Each one holds a thread until its first request or [`FIRST_REQUEST`].
const MAX_UNAUTHENTICATED: usize = 64;
/// Counts one unauthenticated connection until it authenticates or ends.
struct Admission(Option<Arc<AtomicUsize>>);
impl Admission {
fn enter(count: &Arc<AtomicUsize>, max: usize) -> Option<Self> {
if count.fetch_add(1, Ordering::SeqCst) >= max {
count.fetch_sub(1, Ordering::SeqCst);
return None;
}
Some(Self(Some(count.clone())))
}
fn release(&mut self) {
if let Some(c) = self.0.take() {
c.fetch_sub(1, Ordering::SeqCst);
}
}
}
impl Drop for Admission {
fn drop(&mut self) {
self.release();
}
}
/// The key file of a running control server; [`KeyFile::remove`] it when the window closes.
pub struct KeyFile(PathBuf);
@@ -70,15 +37,6 @@ impl KeyFile {
/// cannot be bound or there is no random key (the control channel is off); the key file is
/// `None` when it could not be written (requests are then refused).
pub fn start(port: u16, ctx: egui::Context, settings: Option<&Path>) -> Option<(Receiver<ControlRequest>, Option<KeyFile>)> {
start_with(port, ctx, settings, |listener, ctx, key| serve_on(listener, ctx, key, FIRST_REQUEST))
}
fn start_with(
port: u16,
ctx: egui::Context,
settings: Option<&Path>,
serve: impl FnOnce(TcpListener, egui::Context, String) -> std::io::Result<Receiver<ControlRequest>>,
) -> Option<(Receiver<ControlRequest>, Option<KeyFile>)> {
let listener = match TcpListener::bind(("127.0.0.1", port)) {
Ok(l) => l,
Err(e) => {
@@ -109,21 +67,11 @@ fn start_with(
None
}
};
let rx = match serve(listener, ctx, key) {
Ok(rx) => rx,
Err(e) => {
if let Some(f) = &key_file {
f.remove();
}
log::error!("control server off: cannot start the accept thread ({e})");
return None;
}
};
match &key_file {
Some(f) => log::info!("control server listening on 127.0.0.1:{port}, key in {}", f.path().display()),
None => log::info!("control server listening on 127.0.0.1:{port}"),
}
Some((rx, key_file))
Some((serve_on(listener, ctx, key, FIRST_REQUEST), key_file))
}
/// A new key: 32 random bytes from the operating system, as hex.
@@ -155,47 +103,16 @@ fn keys_match(given: &str, key: &str) -> bool {
!b.is_empty() && a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
}
fn serve_on(listener: TcpListener, ctx: egui::Context, key: String, first: Duration) -> std::io::Result<Receiver<ControlRequest>> {
serve_with(listener, ctx, key, Limits { first, wait: REPLY_WAIT, max_unauthenticated: MAX_UNAUTHENTICATED })
}
/// The time and count budgets of a server (small ones in tests).
#[derive(Clone, Copy)]
struct Limits {
first: Duration,
wait: Duration,
max_unauthenticated: usize,
}
fn serve_with(listener: TcpListener, ctx: egui::Context, key: String, limits: Limits) -> std::io::Result<Receiver<ControlRequest>> {
serve_with_spawner(listener, ctx, key, limits, |accept| std::thread::Builder::new().name("control-accept".into()).spawn(accept).map(|_| ()))
}
fn serve_with_spawner(
listener: TcpListener,
ctx: egui::Context,
key: String,
limits: Limits,
spawn: impl FnOnce(Box<dyn FnOnce() + Send>) -> std::io::Result<()>,
) -> std::io::Result<Receiver<ControlRequest>> {
fn serve_on(listener: TcpListener, ctx: egui::Context, key: String, first: Duration) -> Receiver<ControlRequest> {
let (tx, rx) = channel::<ControlRequest>();
let key: Arc<str> = key.into();
let unauthenticated = Arc::new(AtomicUsize::new(0));
let accept = move || {
std::thread::spawn(move || {
for stream in listener.incoming().flatten() {
let Some(admission) = Admission::enter(&unauthenticated, limits.max_unauthenticated) else {
log::warn!("control server: too many unauthenticated connections, one closed");
continue;
};
let (tx, ctx, key) = (tx.clone(), ctx.clone(), key.clone());
// A thread that cannot start closes this connection only (the guard releases its slot).
if let Err(e) = std::thread::Builder::new().name("control-conn".into()).spawn(move || serve(stream, tx, ctx, &key, limits, admission)) {
log::warn!("control server: no thread for a connection: {e}");
}
std::thread::spawn(move || serve(stream, tx, ctx, &key, first));
}
};
spawn(Box::new(accept))?;
Ok(rx)
});
rx
}
/// The read side of a connection with an optional overall deadline: each read waits only for
@@ -258,11 +175,11 @@ fn discard_rest_of_line(r: &mut BufReader<Timed>) {
}
}
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context, key: &str, limits: Limits, mut admission: Admission) {
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context, key: &str, first: Duration) {
let Ok(read) = stream.try_clone() else { return };
let mut out = stream;
// One overall deadline from accept to the first authorised request; after it, none.
let mut reader = BufReader::new(Timed { s: read, deadline: Some(Instant::now() + limits.first) });
let mut reader = BufReader::new(Timed { s: read, deadline: Some(Instant::now() + first) });
let mut authorised = false;
// The key an `auth` request gave this connection.
let mut conn_key: Option<String> = None;
@@ -311,7 +228,6 @@ fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context, key:
}
if !authorised {
authorised = true;
admission.release();
reader.get_mut().set_deadline(None);
}
if method == "auth" {
@@ -321,18 +237,12 @@ fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context, key:
}
continue;
}
let (req, rrx, waiter) = ControlRequest::with_waiter(method, params);
let (req, rrx) = ControlRequest::new(method, params);
if tx.send(req).is_err() {
break;
}
ctx.request_repaint();
let r = rrx.recv_timeout(limits.wait).unwrap_or_else(|_| {
if waiter.cancel() {
json!({"ok": false, "error": "timeout before execution: the request was not run and never will be"})
} else {
json!({"ok": false, "error": "timeout: outcome unknown, the request had started when the wait ended; inspect the document to check"})
}
});
let r = rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"}));
if !reply(r) {
break;
}
@@ -350,7 +260,7 @@ mod tests {
fn boot_with(first: Duration) -> (u16, Receiver<ControlRequest>) {
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let port = listener.local_addr().unwrap().port();
(port, serve_on(listener, egui::Context::default(), KEY.to_string(), first).unwrap())
(port, serve_on(listener, egui::Context::default(), KEY.to_string(), first))
}
fn boot() -> (u16, Receiver<ControlRequest>) {
@@ -602,40 +512,6 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn an_accept_thread_spawn_failure_aborts_start_and_removes_the_key() {
let dir = temp_dir("spawn-failure");
let path = wordcraft_control_key::key_path(&dir, 0);
let mut bound = None;
let result = start_with(0, egui::Context::default(), Some(&dir), |listener, ctx, key| {
bound = Some(listener.local_addr().unwrap());
assert_eq!(std::fs::read_to_string(&path).unwrap(), key, "startup wrote the key before spawning");
serve_with_spawner(listener, ctx, key, small(150, 8), |accept| {
drop(accept);
Err(std::io::Error::other("injected accept thread spawn failure"))
})
});
assert!(result.is_none(), "a server without its accept thread must not report success");
assert!(!path.exists(), "failed startup must remove its published key");
let _listener = TcpListener::bind(bound.unwrap()).unwrap();
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_complete_json_request_at_eof_can_reach_the_ui() {
// This is why a bridge write failure before the newline is outcome-unknown.
let (port, rx) = boot();
let mut c = Conn::open(port);
let body = json!({"id": 1, "key": KEY, "method": "engine.execute", "params": {"command": "text.insert", "params": {"text": "once"}}});
c.1.write_all(body.to_string().as_bytes()).unwrap();
c.1.shutdown(std::net::Shutdown::Write).unwrap();
let req = answer(&rx);
assert_eq!(req.params["params"]["text"], "once");
assert_eq!(c.recv()["ok"], true);
assert!(c.closed());
assert!(rx.try_recv().is_err());
}
#[test]
fn a_stale_key_file_is_replaced() {
let dir = temp_dir("stale");
@@ -656,52 +532,4 @@ mod tests {
}
let _ = std::fs::remove_dir_all(&dir);
}
fn boot_limits(limits: Limits) -> (u16, Receiver<ControlRequest>) {
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let port = listener.local_addr().unwrap().port();
(port, serve_with(listener, egui::Context::default(), KEY.to_string(), limits).unwrap())
}
fn small(wait_ms: u64, max_unauthenticated: usize) -> Limits {
Limits { first: FIRST_REQUEST, wait: Duration::from_millis(wait_ms), max_unauthenticated }
}
#[test]
fn a_request_that_timed_out_in_the_queue_never_runs() {
let (port, rx) = boot_limits(small(150, 8));
let mut c = Conn::open(port);
let r = c.call(&format!(r#"{{"id":1,"key":"{KEY}","method":"engine.execute","params":{{"command":"text.insert"}}}}"#));
assert_eq!(r["ok"], false);
assert!(r["error"].as_str().unwrap_or("").starts_with("timeout before execution"), "{r}");
// The UI gets to it late: it must not run it.
let req = rx.recv_timeout(Duration::from_secs(5)).unwrap();
assert!(!req.start(), "a cancelled request was started");
}
#[test]
fn a_request_that_started_before_the_timeout_reports_an_unknown_outcome() {
let (port, rx) = boot_limits(small(150, 8));
let mut c = Conn::open(port);
c.send(&format!(r#"{{"id":1,"key":"{KEY}","method":"engine.execute","params":{{"command":"text.insert"}}}}"#));
let req = rx.recv_timeout(Duration::from_secs(5)).unwrap();
assert!(req.start());
let r = c.recv();
assert!(r["error"].as_str().unwrap_or("").contains("outcome unknown"), "{r}");
}
#[test]
fn unauthenticated_connections_are_limited() {
let (port, rx) = boot_limits(small(5_000, 2));
let mut idle = [Conn::open(port), Conn::open(port)];
// Give the server time to count both.
std::thread::sleep(Duration::from_millis(200));
assert!(Conn::open(port).closed(), "the third idle connection was admitted");
// One authenticates: its slot is free again.
assert_eq!(idle[0].call(&format!(r#"{{"id":1,"method":"auth","key":"{KEY}"}}"#))["ok"], true);
std::thread::sleep(Duration::from_millis(200));
let mut again = Conn::open(port);
assert_eq!(again.call(&format!(r#"{{"id":1,"method":"auth","key":"{KEY}"}}"#))["ok"], true);
assert!(rx.try_recv().is_err());
}
}
+24 -88
View File
@@ -69,8 +69,6 @@ impl eframe::App for App {
if let Some((pos, size)) = self.1.and_then(|g| g.fit_on_monitor(&window)) {
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(size));
ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(pos));
let b = self.0.window_builder().clone().with_inner_size(size);
self.0.set_window_builder(b);
}
// The geometry saved for the next start is the active window's.
let prev = self.0.ui.window;
@@ -133,7 +131,6 @@ impl eframe::App for App {
if let Some(key_file) = &self.2 {
key_file.remove();
}
self.0.start_pending_update();
}
}
@@ -193,8 +190,6 @@ fn schedule_macos_terminate(app: &mut WordApp, key_file: Option<&control_server:
if let Some(key_file) = key_file {
key_file.remove();
}
// `terminate:` ends the process without dropping the app, so a ready update's helper starts here.
app.start_pending_update();
dispatch2::DispatchQueue::main().exec_async(|| {
if let Some(mtm) = objc2::MainThreadMarker::new() {
objc2_app_kit::NSApplication::sharedApplication(mtm).terminate(None);
@@ -276,16 +271,9 @@ fn saved_ui() -> Option<UiState> {
/// whenever there's an X server: winit has no file drag-and-drop on Wayland, so dropping files from
/// the file manager showed a "no" cursor. X11 is only forced with an X server (`DISPLAY`), and
/// unforced winit picks Wayland when it's there and X11 otherwise, so a choice that isn't
/// available falls back to the other instead of failing to start. The X11 libraries are only
/// recommended by the packages, so when `x11_libraries` reports some missing (it's asked only when
/// X11 would be forced) the app stays on Wayland: forced X11 without them failed to start, and
/// winit panicked without libxkbcommon-x11.
/// available falls back to the other instead of failing to start.
#[cfg(all(unix, not(target_os = "macos")))]
fn uses_x11(
setting: wordcraft_ui_egui::DisplayServer,
env: impl Fn(&str) -> Option<std::ffi::OsString>,
x11_libraries: impl FnOnce() -> Result<(), String>,
) -> (bool, String) {
fn uses_x11(setting: wordcraft_ui_egui::DisplayServer, env: impl Fn(&str) -> Option<std::ffi::OsString>) -> (bool, String) {
use wordcraft_ui_egui::DisplayServer;
let set = |name: &str| env(name).is_some_and(|v| !v.is_empty());
let (wanted, by) = match env("WORDCRAFT_WAYLAND") {
@@ -295,60 +283,39 @@ fn uses_x11(
Some(_) => (DisplayServer::Wayland, "WORDCRAFT_WAYLAND"),
None => (setting, "the setting"),
};
let missing = if wanted != DisplayServer::Wayland && set("DISPLAY") { x11_libraries().err() } else { None };
let x11 = wanted != DisplayServer::Wayland && set("DISPLAY") && missing.is_none();
let wayland = set("WAYLAND_DISPLAY") || set("WAYLAND_SOCKET");
let x11 = wanted != DisplayServer::Wayland && set("DISPLAY");
let runs = if x11 {
"X11"
} else if wayland {
} else if set("WAYLAND_DISPLAY") || set("WAYLAND_SOCKET") {
"native Wayland"
} else if set("DISPLAY") {
"X11 (no Wayland)"
} else {
"neither (no Wayland or X server found)"
};
let mut why = format!("display: {runs}; {} asks for {}", by, wanted.code());
if let Some(missing) = missing {
why += &format!("; not X11 because its libraries are missing: {missing}");
}
(x11, why)
}
/// `Err` naming the libraries winit's X11 backend opens at startup that can't be loaded: libX11,
/// libXcursor, libX11-xcb (which brings in libxcb), libXi, and libxkbcommon-x11 for the keyboard.
#[cfg(all(unix, not(target_os = "macos")))]
fn x11_libraries() -> Result<(), String> {
use x11_dl::{xcursor::Xcursor, xinput2::XInput2, xlib::Xlib, xlib_xcb::Xlib_xcb};
let mut missing: Vec<String> = [Xlib::open().err(), Xcursor::open().err(), Xlib_xcb::open().err(), XInput2::open().err()]
.into_iter()
.flatten()
.map(|e| e.to_string())
.collect();
if xkbcommon_dl::x11::xkbcommon_x11_option().is_none() {
missing.push("libxkbcommon-x11.so.0 could not be loaded".into());
}
if missing.is_empty() { Ok(()) } else { Err(missing.join("; ")) }
(x11, format!("display: {runs}; {} asks for {}", by, wanted.code()))
}
fn save_prefs(app: &WordApp) {
if let Err(e) = try_save_prefs(app) {
log::warn!("preferences: {e}");
}
}
/// Saves the preferences and says why when it could not. `Ok(false)`: nothing was written
/// because preferences are switched off or there is no settings folder.
fn try_save_prefs(app: &WordApp) -> Result<bool, String> {
if !prefs_enabled() {
return Ok(false);
return;
}
let Some(p) = prefs_path() else { return Ok(false) };
if let Some(dir) = p.parent() {
std::fs::create_dir_all(dir).map_err(|e| format!("cannot create {}: {e}", dir.display()))?;
if let Some(p) = prefs_path() {
if let Some(dir) = p.parent()
&& let Err(e) = std::fs::create_dir_all(dir)
{
log::warn!("preferences: cannot create {}: {e}", dir.display());
return;
}
match serde_json::to_vec_pretty(&app.prefs()) {
Ok(bytes) => {
if let Err(e) = write_prefs(&p, &bytes) {
log::warn!("preferences: cannot save {}: {e}", p.display());
}
}
Err(e) => log::warn!("preferences: cannot serialize: {e}"),
}
}
let bytes = serde_json::to_vec_pretty(&app.prefs()).map_err(|e| format!("cannot serialize: {e}"))?;
write_prefs(&p, &bytes).map_err(|e| format!("cannot save {}: {e}", p.display()))?;
Ok(true)
}
/// Writes `bytes` to a sibling temporary file and renames it over `path`, so a failed or
@@ -383,7 +350,6 @@ fn services(file_dialog: file_dialogs::Hook, ctx: &egui::Context) -> Services {
set_clipboard_rich: Some(Box::new(clipboard::set_rich)),
clipboard_rtf: Some(Box::new(clipboard::rtf)),
clipboard_formats: Some(Box::new(clipboard::formats)),
save_prefs: Some(Box::new(try_save_prefs)),
..Default::default()
}
}
@@ -587,7 +553,7 @@ fn main() -> eframe::Result {
}
#[cfg(all(unix, not(target_os = "macos")))]
{
let (x11, why) = uses_x11(saved.as_ref().map(|ui| ui.display_server).unwrap_or_default(), |name| std::env::var_os(name), x11_libraries);
let (x11, why) = uses_x11(saved.as_ref().map(|ui| ui.display_server).unwrap_or_default(), |name| std::env::var_os(name));
log::info!("{why}");
if x11 {
options.event_loop_builder = Some(Box::new(|b| {
@@ -719,7 +685,7 @@ mod tests {
fn the_display_server_follows_the_variable_then_the_setting() {
use wordcraft_ui_egui::DisplayServer::{Auto, Wayland, X11};
let x11 = |setting, vars: &[(&str, &str)]| {
uses_x11(setting, |name| vars.iter().find(|(k, _)| *k == name).map(|(_, v)| std::ffi::OsString::from(v)), || Ok(())).0
uses_x11(setting, |name| vars.iter().find(|(k, _)| *k == name).map(|(_, v)| std::ffi::OsString::from(v))).0
};
let both = [("DISPLAY", ":0"), ("WAYLAND_DISPLAY", "wayland-0")];
assert!(x11(Auto, &both), "Automatic keeps XWayland, for file drag-and-drop");
@@ -735,40 +701,10 @@ mod tests {
let wayland_only = [("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", "")];
assert!(!x11(X11, &wayland_only) && !x11(Auto, &wayland_only));
// Without Wayland, Wayland isn't forced either: unforced winit picks X11.
let (forced, why) = uses_x11(Wayland, |name| (name == "DISPLAY").then(|| ":0".into()), || Ok(()));
let (forced, why) = uses_x11(Wayland, |name| (name == "DISPLAY").then(|| ":0".into()));
assert!(!forced && why.contains("X11 (no Wayland)"), "{why}");
}
/// Without the X11 libraries (the packages only recommend them) X11 isn't forced, whatever asks
/// for it: forced X11 failed to start, and winit panicked without libxkbcommon-x11. The check
/// runs only when X11 would be forced.
#[test]
fn missing_x11_libraries_leave_the_app_on_wayland() {
use wordcraft_ui_egui::DisplayServer::{Auto, Wayland, X11};
let both = [("DISPLAY", ":0"), ("WAYLAND_DISPLAY", "wayland-0")];
for (setting, variable) in [(Auto, None), (X11, None), (Wayland, Some("0"))] {
let env = |name: &str| {
both.iter()
.find(|(k, _)| *k == name)
.map(|(_, v)| (*v).into())
.or_else(|| (name == "WORDCRAFT_WAYLAND").then_some(variable).flatten().map(Into::into))
};
let (x11, why) = uses_x11(setting, env, || Err("libxkbcommon-x11.so.0 could not be loaded".into()));
assert!(!x11, "{setting:?} with WORDCRAFT_WAYLAND={variable:?}: {why}");
assert!(why.contains("native Wayland") && why.contains("libxkbcommon-x11"), "{why}");
}
let checked = std::cell::Cell::new(false);
let (x11, _) = uses_x11(
Wayland,
|name| both.iter().find(|(k, _)| *k == name).map(|(_, v)| (*v).into()),
|| {
checked.set(true);
Ok(())
},
);
assert!(!x11 && !checked.get(), "asked for Wayland, the libraries aren't checked");
}
/// A named pipe or a device under such a name is a warning, not a read: the pipe blocked
/// start-up until something wrote to it, and `/dev/zero` read until memory ran out.
#[test]
-10
View File
@@ -14,10 +14,6 @@
# To remove your names, delete your entry (or set fields empty) in a PR. The next
# contributors.py run, and the next build, drop them.
[people.SenuraMalaka]
real_name = "Senura Dissanayake"
sync_github_name = true
[people.divyanshu2074]
real_name = "Divyanshu Gupta"
sync_github_name = true
@@ -201,9 +197,3 @@ display_name = "entropybeater"
[people.helenkwok]
real_name = "Helen Kwok"
[people.ashokram-si]
real_name = "Ashok Ram"
[people.benandez]
sync_github_name = true
-3
View File
@@ -77,7 +77,6 @@ impl Document {
glossary: Vec::new(),
media_index: MediaIndex::default(),
kept: Default::default(),
missing_pictures: Default::default(),
}
}
@@ -155,7 +154,6 @@ impl Document {
glossary,
media_index: _,
kept: _,
missing_pictures: _,
} = self;
let Document {
body: other_body,
@@ -176,7 +174,6 @@ impl Document {
glossary: other_glossary,
media_index: _,
kept: _,
missing_pictures: _,
} = other;
same_blocks(body, other_body)
&& same_parts(parts, other_parts)
+2 -9
View File
@@ -772,10 +772,6 @@ impl Document {
if a.path.parent() == b.path.parent() {
// Same container: trim the ends, drop the blocks between, join.
let (ia, ib) = (a.path.last(), b.path.last());
// Removing an already-empty paragraph must not impose its style or mark formatting
// on the following content. Test before trimming: deleting actual heading text is
// still an ordinary merge that keeps the first paragraph's properties.
let empty_start = self.para(a.story, &a.path).is_some_and(Paragraph::is_empty);
// Kept markers of the paragraphs in between move to the join.
let mut moved: Vec<(InlineObject, CharProps)> = Vec::new();
// `kept` is sorted: one walk of each paragraph's objects and runs (#3614).
@@ -823,18 +819,15 @@ impl Document {
if ib < bl.len() && ia < ib {
bl.drain(ia + 1..=ib);
}
tail.props = ParaProps::default();
// The deleted marks include `a`'s, so a section break there goes too: the joined
// paragraph ends where `b`'s mark did, and its text takes the following section's
// setup, as in Word (#2127).
let sect = tail.section.take();
let pa = self.para_mut(a.story, &a.path)?;
let keep_props = if empty_start { tail.props.clone() } else { pa.props.clone() };
let keep_mark = empty_start.then(|| tail.mark.clone());
let keep_props = pa.props.clone();
pa.append(tail);
pa.props = keep_props;
if let Some(mark) = keep_mark {
pa.mark = mark;
}
let before = std::mem::replace(&mut pa.section, sect);
if a.story == crate::StoryRef::Body && a.path.0.len() == 1 {
// Nearest first, as "link to previous" resolved them before the delete.
-6
View File
@@ -75,7 +75,6 @@ mod block;
mod compare;
mod document;
mod error;
mod missing;
mod parts;
mod position;
mod settings;
@@ -85,7 +84,6 @@ pub use block::{Block, Blocks, BoxBudget, MAX_TEXT_BOX_DEPTH};
pub use compare::para_block;
use document::MediaIndex;
pub use error::{DocError, Result};
pub use missing::{MAX_MISSING_PICTURES, MissingPicture, MissingPictureReason, MissingPictures, picture_hash};
pub use parts::{MAX_LINKED_BOXES, MAX_TEXT_BOX_INSET, NoteSeparators, Part, PartKind, PermRange, TEXT_BOX_INSET, TextBody, free_id};
pub use position::{Path, Pos, StoryRef};
pub use settings::{
@@ -140,10 +138,6 @@ pub struct Document {
/// [`kept`]); never saved, ignored by `==`.
#[serde(skip)]
pub kept: kept::Kept,
/// Pictures a reader dropped because their target is missing, external or link-only (strict
/// export reports them); never saved, never drawn, ignored by `==`.
#[serde(skip)]
pub missing_pictures: MissingPictures,
}
#[cfg(test)]
-10
View File
@@ -19,12 +19,6 @@ pub fn is_web_or_mail_link(url: &str) -> bool {
/// names another machine — following it on Windows leaks the user's NTLM credentials — so it is
/// refused too.
pub fn is_safe_href(url: &str) -> bool {
// Exporters drop other control characters and noncharacters when they write the link
// (`file\u{85}:` comes out `file:`), so a link holding one is refused, not judged as it
// stands and written as something else.
if url.chars().any(|c| (c.is_control() && !c.is_ascii_control()) || matches!(c, '\u{FFFE}' | '\u{FFFF}')) {
return false;
}
let cleaned: String = url.chars().filter(|c| !c.is_ascii_control() && *c != ' ').collect();
// `//host` (protocol-relative) and `\\host` (UNC), with `\` read as `/` the way Windows does.
if cleaned.replace('\\', "/").starts_with("//") {
@@ -206,10 +200,6 @@ mod tests {
"\\\\attacker.example\\share\\x",
"/\\attacker.example\\x",
" //attacker.example/x",
// Characters the exporters drop on writing don't hide a scheme.
"file\u{85}:///etc/passwd",
"java\u{9F}script:alert(1)",
"/\u{FFFE}/attacker.example/x",
"",
] {
assert!(!is_safe_href(bad), "{bad:?}");
-143
View File
@@ -1,143 +0,0 @@
//! Pictures a reader could not bring in or an exporter could not draw, for strict export (#6005).
//! Kept beside the document while it is open; never saved, never drawn, and not part of the
//! document's content (`==` ignores it).
use serde::{Deserialize, Serialize};
/// Most pictures remembered per document; past it [`MissingPictures::truncated`] is set.
pub const MAX_MISSING_PICTURES: usize = 256;
/// Most characters of a picture's part or story name a record keeps (names come from the file).
pub const MAX_PICTURE_NAME_CHARS: usize = 1024;
/// Why a picture is not in the document or not drawn.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum MissingPictureReason {
/// The relationship names a part the file does not contain, or no relationship at all.
MissingPart,
/// The target is outside the file (`TargetMode="External"`); it is never fetched.
ExternalTarget,
/// The picture only links to a file (`r:link`) and holds no embedded copy.
LinkOnly,
/// The bytes are a picture format we know but they do not decode.
Undecodable,
/// The bytes are not a picture format we can draw.
UnsupportedFormat,
}
impl MissingPictureReason {
/// The words used in reports (`"missing part"`, `"external target"`, `"link only"`,
/// `"undecodable"`, `"unsupported format"`).
pub fn label(self) -> &'static str {
match self {
Self::MissingPart => "missing part",
Self::ExternalTarget => "external target",
Self::LinkOnly => "link only",
Self::Undecodable => "undecodable",
Self::UnsupportedFormat => "unsupported format",
}
}
/// The reason a [`code`](Self::code) names.
pub fn from_code(code: &str) -> Option<Self> {
[Self::MissingPart, Self::ExternalTarget, Self::LinkOnly, Self::Undecodable, Self::UnsupportedFormat].into_iter().find(|r| r.code() == code)
}
/// The stable snake_case code the JSON report carries (`"missing_part"`, `"external_target"`,
/// `"link_only"`, `"undecodable"`, `"unsupported_format"`); never reworded.
pub fn code(self) -> &'static str {
match self {
Self::MissingPart => "missing_part",
Self::ExternalTarget => "external_target",
Self::LinkOnly => "link_only",
Self::Undecodable => "undecodable",
Self::UnsupportedFormat => "unsupported_format",
}
}
}
/// One picture that is missing.
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct MissingPicture {
/// The target as written, or the part name it resolved to (a media key for a decoded one).
pub part: String,
/// The story part it is in (`word/document.xml`, `word/header2.xml`), as the source file
/// names it; empty when not known.
pub story: String,
pub reason: MissingPictureReason,
}
/// A capped list of [`MissingPicture`]s, with the source names a report needs to say where a
/// picture is (a reader fills them; a document made or opened another way leaves them empty).
#[derive(Clone, Debug, Default)]
pub struct MissingPictures {
pub list: Vec<MissingPicture>,
/// More than [`MAX_MISSING_PICTURES`] were found; the rest are not listed.
pub truncated: bool,
/// Pictures found, those past the cap included (saturating). Each distinct one counts once.
pub total: u64,
/// The main story's part name in the source file (`word/document.xml`).
pub main: String,
/// A header, footer, note or comment story's part id ([`crate::Document::parts`]) to the
/// part it was read from (`word/header2.xml`). Ids freed and reused after opening may name
/// the old part.
pub part_names: std::collections::BTreeMap<u32, std::sync::Arc<str>>,
/// A [`crate::Document::media`] key to the part its bytes were read from
/// (`word/media/image2.bin`).
pub media_parts: std::collections::BTreeMap<String, String>,
/// What `list` holds, for an O(1) test of "already there" (a hostile file has many).
seen: std::collections::HashSet<MissingPicture>,
/// Hashes of the distinct pictures past the cap (8 bytes each), so the total counts them once.
unlisted: std::collections::HashSet<u64>,
/// The chunk parts whose unlisted pictures [`Self::add_unlisted`] has counted, so a chunk
/// imported twice counts them once.
counted_chunks: std::collections::HashSet<String>,
}
impl MissingPictures {
/// Add `n` pictures of the chunk part `chunk` that its reader counted but did not list. The
/// same chunk part counts once however often it is imported (its listed pictures dedupe
/// through [`Self::push`]).
pub fn add_unlisted(&mut self, chunk: &str, n: u64) {
if n > 0 && self.counted_chunks.insert(chunk.to_string()) {
self.total = self.total.saturating_add(n);
}
}
/// Add one (the same part, story and reason once).
pub fn push(&mut self, mut item: MissingPicture) {
for name in [&mut item.part, &mut item.story] {
if name.len() > MAX_PICTURE_NAME_CHARS {
*name = name.chars().take(MAX_PICTURE_NAME_CHARS).collect();
}
}
if self.seen.contains(&item) {
return;
}
if self.list.len() >= MAX_MISSING_PICTURES {
self.truncated = true;
if self.unlisted.insert(picture_hash(&item)) {
self.total = self.total.saturating_add(1);
}
return;
}
self.total = self.total.saturating_add(1);
self.seen.insert(item.clone());
self.list.push(item);
}
}
/// A 64-bit hash of a record, to tell the ones past a cap apart without keeping them.
pub fn picture_hash(item: &MissingPicture) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
item.hash(&mut h);
h.finish()
}
impl PartialEq for MissingPictures {
/// Not part of the document's content.
fn eq(&self, _: &Self) -> bool {
true
}
}
-28
View File
@@ -186,16 +186,6 @@ pub struct Float {
/// top. 0 = unknown, drawn in document order (objects inserted here, or from other formats).
#[serde(skip_serializing_if = "is_zero_u32")]
pub z: u32,
/// The page a drawing anchored to a page is on, from 1 (ODF's `text:anchor-page-number`,
/// §19.872); 0 = anchored to its paragraph or not said. Kept for saving: the layout places a
/// drawing on its anchor paragraph's page.
#[serde(skip_serializing_if = "is_zero_u32")]
pub page: u32,
/// Turned, but the file keeps no room for the turn (`wp:effectExtent` missing or zero, as
/// files from other tools have): Word then keeps an inline one's line to its frame and lets
/// the turned object overhang it (see [`Float::line_pad`]). Cleared by [`Float::set_spin`].
#[serde(skip_serializing_if = "is_false")]
pub bare_turn: bool,
}
/// Longest alternative text kept (characters): hostile files and commands are capped.
@@ -251,7 +241,6 @@ impl Float {
self.rot = wordcraft_geom::normalize_degrees(s.deg);
self.flip_h = s.flip_h;
self.flip_v = s.flip_v;
self.bare_turn = false;
}
/// How far a `w` × `h` frame's rotated bounds reach past it on each side (x, y): the room a
/// rotated object takes beyond its frame (zero unrotated; negative where the turned object
@@ -261,23 +250,6 @@ impl Float {
let (bw, bh) = self.spin().extent(w, h);
((bw - w) / 2.0, (bh - h) / 2.0)
}
/// A [`Float::bare_turn`] still inline and without effects room: made floating, or given room
/// for a shadow or glow since, it saves, and so lays out, with the turn's room again.
pub fn is_bare_turn(&self) -> bool {
self.bare_turn && self.wrap == Wrap::Inline && self.effect_extent() == [0.0; 4]
}
/// The room an inline `w` × `h` object's line keeps past its frame on each side (x, y) for its
/// turn: its rotated bounds ([`Float::spin_pad`]) — or, for a [`Float::is_bare_turn`], none, except
/// that Word 2013+ layout (`modern`) exchanges the frame's width and height from 45° up to 135°
/// and from 225° up to 315°.
pub fn line_pad(&self, w: f32, h: f32, modern: bool) -> (f32, f32) {
if !self.is_bare_turn() {
return self.spin_pad(w, h);
}
let (w, h) = (wordcraft_geom::finite(w).clamp(0.0, 4000.0), wordcraft_geom::finite(h).clamp(0.0, 4000.0));
let deg = wordcraft_geom::normalize_degrees(self.rot);
if modern && ((45.0..135.0).contains(&deg) || (225.0..315.0).contains(&deg)) { ((h - w) / 2.0, (w - h) / 2.0) } else { (0.0, 0.0) }
}
/// The effect extents, finite and clamped (left, top, right, bottom).
pub fn effect_extent(&self) -> [f32; 4] {
self.effect.map(|v| if v.is_finite() { v.clamp(0.0, 1584.0) } else { 0.0 })
+1 -2
View File
@@ -27,8 +27,7 @@ pub use char_props::{
};
pub use color::{Rgb, TextColor};
pub use para_props::{
Align, Border, BorderStyle, Borders, LineSpacing, NumRef, ParaProps, ParagraphTextDirection, TAB_POS_EPS, TabAlign, TabLeader, TabStop,
TextAlignment, TextFrame,
Align, Border, BorderStyle, Borders, LineSpacing, NumRef, ParaProps, TAB_POS_EPS, TabAlign, TabLeader, TabStop, TextAlignment, TextFrame,
};
pub use table_props::{
CellPicture, CellProps, HeightRule, PictureRepeat, RowProps, TableFloat, TableLook, TableProps, TextDirection, TextVert, VAlign, VMerge,
-65
View File
@@ -26,67 +26,6 @@ impl Align {
}
}
/// Paragraph-level text flow (`w:pPr/w:textDirection`, ECMA-376 §17.3.1.41).
///
/// Kept separate from [`super::TextDirection`], whose three values model the table-cell direction
/// control. These values preserve the exact OOXML direction through JSON and DOCX saves.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ParagraphTextDirection {
LrTb,
LrTbV,
Tb,
TbRl,
TbRlV,
TbLr,
TbLrV,
BtLr,
TbV,
Rl,
RlV,
Lr,
LrV,
}
impl ParagraphTextDirection {
pub fn from_ooxml(value: &str) -> Option<Self> {
match value {
"lrTb" => Some(Self::LrTb),
"lrTbV" => Some(Self::LrTbV),
"tb" => Some(Self::Tb),
"tbRl" => Some(Self::TbRl),
"tbRlV" => Some(Self::TbRlV),
"tbLr" => Some(Self::TbLr),
"tbLrV" => Some(Self::TbLrV),
"btLr" => Some(Self::BtLr),
"tbV" => Some(Self::TbV),
"rl" => Some(Self::Rl),
"rlV" => Some(Self::RlV),
"lr" => Some(Self::Lr),
"lrV" => Some(Self::LrV),
_ => None,
}
}
pub fn ooxml(self) -> &'static str {
match self {
Self::LrTb => "lrTb",
Self::LrTbV => "lrTbV",
Self::Tb => "tb",
Self::TbRl => "tbRl",
Self::TbRlV => "tbRlV",
Self::TbLr => "tbLr",
Self::TbLrV => "tbLrV",
Self::BtLr => "btLr",
Self::TbV => "tbV",
Self::Rl => "rl",
Self::RlV => "rlV",
Self::Lr => "lr",
Self::LrV => "lrV",
}
}
}
/// Vertical alignment of characters of different sizes on a line (`w:textAlignment`,
/// ECMA-376 §17.3.1.39).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
@@ -452,9 +391,6 @@ pub struct ParaProps {
pub suppress_line_numbers: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bidi: Option<bool>,
/// Paragraph text direction (`w:textDirection`); `None` inherits from the paragraph style.
#[serde(skip_serializing_if = "Option::is_none")]
pub text_direction: Option<ParagraphTextDirection>,
/// Drop cap: lines to drop (0 = none) — applies to the first character(s).
#[serde(skip_serializing_if = "Option::is_none")]
pub drop_cap: Option<u8>,
@@ -526,7 +462,6 @@ impl ParaProps {
}
fn overlay_script_layout(&mut self, patch: &ParaProps) {
overlay_fields!(self, patch; bidi, text_direction, kinsoku, word_wrap, overflow_punct, top_line_punct, auto_space_de, auto_space_dn);
overlay_fields!(self, patch; bidi, kinsoku, word_wrap, overflow_punct, top_line_punct, auto_space_de, auto_space_dn, text_alignment);
}
pub fn overlaid(mut self, patch: &ParaProps) -> ParaProps {
+2 -31
View File
@@ -137,12 +137,9 @@ impl ResolvedChar {
pub fn uses_complex(&self, c: char) -> bool {
self.rtl || self.cs || crate::bidi::is_complex_script(c)
}
/// The size glyphs are drawn and measured at. Word sets superscript and subscript text at
/// 0.65 of the run's size rounded to the half point, a font size the format can store
/// (measured in Word for Mac from digit advances: 8 pt → 5, 9 → 6, 11 → 7, 12 → 8,
/// 16 → 10.5, 24 → 15.5, 36 → 23.5).
/// The size glyphs are drawn at (super/subscript shrink to ~2/3).
pub fn draw_size(&self) -> f32 {
if self.vert_align == VertAlign::Baseline { self.size } else { (self.size * 0.65 * 2.0).round() / 2.0 }
if self.vert_align == VertAlign::Baseline { self.size } else { self.size * 0.65 }
}
/// Baseline shift, points (positive = up).
pub fn baseline_shift(&self) -> f32 {
@@ -456,32 +453,6 @@ fn diff_patch<T: Serialize + serde::de::DeserializeOwned + Default>(with: &T, wi
mod tests {
use super::*;
/// Superscript and subscript sizes as Word for Mac sets them (measured from digit advances in
/// its PDF export): 0.65 of the size, rounded to the half point.
#[test]
fn raised_and_lowered_text_is_sized_to_the_half_point() {
for (size, want) in [
(8.0, 5.0),
(9.0, 6.0),
(10.0, 6.5),
(11.0, 7.0),
(12.0, 8.0),
(13.0, 8.5),
(14.0, 9.0),
(16.0, 10.5),
(20.0, 13.0),
(24.0, 15.5),
(36.0, 23.5),
] {
for vert_align in [VertAlign::Superscript, VertAlign::Subscript] {
let rc = ResolvedChar { size, vert_align, ..StyleSheet::builtin().resolve_char(None, &CharProps::default()) };
assert_eq!(rc.draw_size(), want, "{size} pt {vert_align:?}");
}
}
let rc = StyleSheet::builtin().resolve_char(None, &CharProps { size: Some(11.0), ..Default::default() });
assert_eq!(rc.draw_size(), 11.0, "baseline text keeps its size");
}
/// #2036: only a character itself (direct formatting) has a symbol font of its own; a
/// style never gives one to its text.
#[test]
+2 -7
View File
@@ -19,8 +19,8 @@
//!
//! Tables are indexed by code − 0x20; 0 = no Unicode equivalent.
/// The symbol table for a single family name, by lowercase family name.
fn single_table(family: &str) -> Option<&'static [u32; 224]> {
/// The symbol fonts this module knows, by lowercase family name.
fn table(family: &str) -> Option<&'static [u32; 224]> {
let f = family.trim();
if f.eq_ignore_ascii_case("symbol") || f.eq_ignore_ascii_case("symbol (pcl6)") {
Some(&SYMBOL)
@@ -37,11 +37,6 @@ fn single_table(family: &str) -> Option<&'static [u32; 224]> {
}
}
/// The symbol fonts this module knows, by lowercase family name.
fn table(family: &str) -> Option<&'static [u32; 224]> {
if family.contains(';') { family.split(';').map(str::trim).filter(|s| !s.is_empty()).find_map(single_table) } else { single_table(family) }
}
/// Whether `family` is one of Word's symbol fonts (Symbol, Wingdings, Wingdings 2, Wingdings 3,
/// Webdings), whose characters [`symbol_char`] maps.
pub fn is_symbol_font(family: &str) -> bool {
-28
View File
@@ -786,31 +786,3 @@ fn lone_table_open_markers_mixed_tables_and_paired() {
assert!(res1.controls.open.is_empty());
assert_eq!(res1.controls.close, 0);
}
#[test]
fn missing_picture_names_are_capped_at_a_char_boundary() {
let mut d = Document::new();
let long: String = "é".repeat(5000);
d.missing_pictures.push(MissingPicture { part: long.clone(), story: long, reason: MissingPictureReason::MissingPart });
let item = &d.missing_pictures.list[0];
assert_eq!((item.part.chars().count(), item.story.chars().count()), (1024, 1024));
}
#[test]
fn missing_pictures_are_capped_deduplicated_and_not_content() {
let mut d = Document::new();
let item = |n: usize| MissingPicture { part: format!("p{n}"), story: "word/document.xml".into(), reason: MissingPictureReason::MissingPart };
d.missing_pictures.push(item(0));
d.missing_pictures.push(item(0));
assert_eq!(d.missing_pictures.list.len(), 1);
for n in 1..MAX_MISSING_PICTURES + 3 {
d.missing_pictures.push(item(n));
}
assert_eq!(d.missing_pictures.list.len(), MAX_MISSING_PICTURES);
assert!(d.missing_pictures.truncated);
let e = Document::new();
assert_eq!(d, e);
assert!(d.same_content(&e));
assert!(!serde_json::to_string(&d).unwrap().contains("p0"));
assert_eq!(MissingPictureReason::ExternalTarget.label(), "external target");
}
-158
View File
@@ -1,158 +0,0 @@
use std::sync::Arc;
use wordcraft_doc::props::{Align, CharProps, ParaProps};
use wordcraft_doc::{Block, Document, Paragraph, Path, Pos, StoryRef, Table, para_block};
/// Build an empty heading followed by explicitly formatted, mixed-run body text.
fn fixture(first: &str, style: Option<&str>) -> Document {
let mut d = Document::from_text(&format!("{first}\nBody paragraph."));
let p = d.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
p.props.style = Some("Heading1".into());
p.props.outline_level = Some(0);
p.mark = CharProps { size: Some(40.0), bold: Some(true), ..Default::default() };
let p = d.para_mut(StoryRef::Body, &Path::top(1)).unwrap();
p.props = ParaProps {
style: style.map(str::to_string),
align: Some(Align::Right),
indent_left: Some(18.0),
space_after: Some(7.0),
keep_next: Some(false),
..Default::default()
};
p.mark = CharProps { size: Some(11.0), bold: Some(false), lang: Some("en-GB".into()), ..Default::default() };
p.format(0, 4, &|c| c.italic = Some(true)).unwrap();
d
}
/// Compare paragraph, mark and inline-run formatting independently of text.
fn assert_format(p: &Paragraph, expected: &Paragraph) {
assert_eq!(p.props, expected.props, "the following paragraph keeps all its properties");
assert_eq!(p.mark, expected.mark, "the empty heading's mark must not replace the following mark");
assert_eq!(serde_json::to_value(&p.runs).unwrap(), serde_json::to_value(&expected.runs).unwrap());
}
/// Deleting an empty paragraph preserves the following style, including implicit Normal.
#[test]
fn deleting_an_empty_paragraph_keeps_the_following_format() {
for first_style in ["Heading1", "Heading3", "Normal"] {
for style in [None, Some("Normal"), Some("Heading2")] {
let mut d = fixture("", style);
d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().props.style = Some(first_style.into());
let expected = d.para_at(&Pos::body(1, 0)).unwrap().clone();
assert_eq!(d.delete_range(&Pos::body(0, 0), &Pos::body(1, 0)).unwrap(), Pos::body(0, 0));
let p = d.para_at(&Pos::body(0, 0)).unwrap();
assert_eq!(p.text, expected.text);
assert_format(p, &expected);
}
}
}
/// A selection spanning several paragraphs keeps the surviving tail format in both directions.
#[test]
fn an_empty_start_preserves_the_last_paragraph_over_a_multi_paragraph_selection() {
for backwards in [false, true] {
let mut d = fixture("", None);
d.body.insert(1, para_block(Paragraph::with_text("Removed middle paragraph", Default::default()).styled("Heading2")));
let mut expected = d.para_at(&Pos::body(2, 0)).unwrap().clone();
expected.delete(0, 5).unwrap();
let (a, b) = (Pos::body(0, 0), Pos::body(2, 5));
d.delete_range(if backwards { &b } else { &a }, if backwards { &a } else { &b }).unwrap();
assert_eq!(d.body.len(), 1);
let p = d.para_at(&Pos::body(0, 0)).unwrap();
assert_eq!(p.text, "paragraph.");
assert_format(p, &expected);
}
}
/// A control whose closing marker survives must move across the join without replacing tail formatting.
#[test]
fn an_empty_start_keeps_a_control_from_a_deleted_middle_paragraph() {
use wordcraft_doc::control::ContentControl;
use wordcraft_doc::{InlineObject, Revision, RevisionKind};
let mut d = fixture("", Some("Normal"));
d.body.insert(1, para_block(Paragraph::with_text("Removed", Default::default())));
d.body.push(para_block(Paragraph::with_text("After", Default::default())));
d.revisions.push(Revision { kind: RevisionKind::Insert, ..Default::default() });
d.revisions.push(Revision { kind: RevisionKind::Delete, ..Default::default() });
let p = d.para_mut(StoryRef::Body, &Path::top(2)).unwrap();
p.mark.ins = Some(0);
p.mark.del = Some(1);
let expected = p.clone();
let marker = CharProps { bold: Some(true), ins: Some(0), ..Default::default() };
d.insert_object(&Pos::body(1, 0), InlineObject::ControlStart { control: Box::new(ContentControl::default()) }, &marker).unwrap();
d.insert_object(&Pos::body(3, 5), InlineObject::ControlEnd, &Default::default()).unwrap();
assert_eq!(d.control_ranges(StoryRef::Body).len(), 1);
d.delete_range(&Pos::body(0, 0), &Pos::body(2, 0)).unwrap();
let p = d.para_at(&Pos::body(0, 0)).unwrap();
assert_eq!(p.props, expected.props);
assert_eq!(p.mark, expected.mark, "tail revision IDs and formatting survive the moved marker");
assert_eq!(p.props_of_char(0), &marker);
assert!(matches!(p.objects.as_slice(), [InlineObject::ControlStart { .. }]));
assert_eq!(p.text, format!("\u{fffc}{}", expected.text));
let ranges = d.control_ranges(StoryRef::Body);
assert_eq!(ranges.len(), 1);
assert_eq!(ranges[0].start, Pos::body(0, 0));
assert_eq!(ranges[0].end, Pos::body(1, 5));
assert!(!d.has_unbalanced_controls());
}
/// The empty-paragraph rule applies in header stories and table-cell containers.
#[test]
fn empty_paragraph_merges_work_in_a_header_and_a_table_cell() {
for cell in [false, true] {
let mut d = fixture("", Some("Normal"));
let expected = d.para_at(&Pos::body(1, 0)).unwrap().clone();
let (story, a, b) = if cell {
let mut table = Table::new(1, 1, 300.0);
table.rows[0].cells[0].blocks = d.body.clone();
d.body = vec![Arc::new(Block::Table(table)), para_block(Paragraph::new())];
(StoryRef::Body, Path(vec![0, 0, 0, 0]), Path(vec![0, 0, 0, 1]))
} else {
let id = d.add_part(wordcraft_doc::PartKind::Header, d.body.clone());
(StoryRef::Part(id), Path::top(0), Path::top(1))
};
d.delete_range(&Pos { story, path: a.clone(), off: 0 }, &Pos { story, path: b, off: 0 }).unwrap();
assert_format(d.para(story, &a).unwrap(), &expected);
}
}
/// Absorbing an empty section keeps the following format and inherited header.
#[test]
fn deleting_an_empty_section_paragraph_keeps_format_and_linked_headers() {
let mut d = fixture("", None);
let expected = d.para_at(&Pos::body(1, 0)).unwrap().clone();
let header = d.add_part(wordcraft_doc::PartKind::Header, vec![para_block(Paragraph::with_text("Header", Default::default()))]);
let mut section = d.last_section.clone();
section.headers.default = Some(header);
d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().section = Some(Box::new(section));
d.last_section.margin_left = 96.0;
d.delete_range(&Pos::body(0, 0), &Pos::body(1, 0)).unwrap();
assert_format(d.para_at(&Pos::body(0, 0)).unwrap(), &expected);
assert!(d.para_at(&Pos::body(0, 0)).unwrap().section.is_none());
assert_eq!(d.last_section.headers.default, Some(header));
assert_eq!(d.last_section.margin_left, 96.0);
}
/// Deleting actual text continues to use the first paragraph merge rule.
#[test]
fn joining_nonempty_paragraphs_keeps_the_existing_first_paragraph_rule() {
for off in [0, 7] {
let mut d = fixture("Heading", None);
let expected = d.para_at(&Pos::body(0, 0)).unwrap().props.clone();
d.delete_range(&Pos::body(0, off), &Pos::body(1, 0)).unwrap();
assert_eq!(d.para_at(&Pos::body(0, 0)).unwrap().props, expected);
}
}
/// Removing a soft line break must not change paragraph formatting.
#[test]
fn a_soft_break_is_not_an_empty_paragraph_merge() {
let mut d = fixture("", None);
let p = d.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
p.insert_text(0, "\nHeading", &Default::default()).unwrap();
let expected = p.props.clone();
d.delete_range(&Pos::body(0, 0), &Pos::body(0, 1)).unwrap();
assert_eq!(d.body.len(), 2);
assert_eq!(d.para_at(&Pos::body(0, 0)).unwrap().props, expected);
}
+1 -5
View File
@@ -211,11 +211,7 @@ fn leaf(sp: &Sp, w: f32, h: f32, float: Float, b: &Build, pics: &mut Pictures) -
kind = ShapeKind::TextBox;
}
let line = kind == ShapeKind::Line;
// Brackets and braces (85 to 88, bracket and brace pairs 185, 186) are unfilled unless
// `fFilled` says otherwise, as Word's VML shapetypes for them say (`filled="f"`). They are
// drawn as their box here, which a default fill would make an opaque block over the text.
let unfilled = p.filled == Some(false) || (matches!(sp.spt, 85..=88 | 185 | 186) && p.filled != Some(true));
let fill = if line || unfilled { None } else { Some(Fill::solid(p.fill_color.unwrap_or(Rgb::WHITE))) };
let fill = if line || p.filled == Some(false) { None } else { Some(Fill::solid(p.fill_color.unwrap_or(Rgb::WHITE))) };
let stroke = if p.line == Some(false) { None } else { Some(p.line_color.unwrap_or(Rgb::BLACK)) };
// Line widths are EMUs; Word's default is 0.75 pt.
let stroke_width = p.line_width.map_or(0.75, |e| (e as f32 / 12_700.0).clamp(0.0, 100.0));
+1 -34
View File
@@ -260,9 +260,6 @@ fn read_limited(bytes: &[u8], password: Option<&str>, max_paragraphs: usize) ->
};
let mut doc = Document::new();
// Word opens Word 97-2003 files in compatibility mode 11 (Word 2003); the model's default
// 15 would apply Word 2013+ justification and line-breaking rules to them.
doc.settings.compat_mode = 11;
if let Some(core) = metadata::read(&mut comp) {
doc.core = core;
}
@@ -348,7 +345,7 @@ fn read_limited(bytes: &[u8], password: Option<&str>, max_paragraphs: usize) ->
0 | 1 | 4 => PartKind::Header,
_ => PartKind::Footer,
};
let blocks = header_story_blocks(&ctx, a, b, &mut pics, max_paragraphs)?;
let blocks = story_blocks(&ctx, a, b, &mut pics, max_paragraphs)?;
if !blocks.is_empty() {
let id = doc.add_part(kind, blocks);
hf_ids.push((si, k, id));
@@ -436,36 +433,6 @@ fn story_blocks(ctx: &WalkCtx<'_>, a: u32, b: u32, pics: &mut media::Pictures, m
Ok(table::assemble(paras))
}
/// A header or footer story's blocks without its guard paragraph mark. [MS-DOC] 2.3.3: a
/// non-empty header story ends with a paragraph mark that only separates it from the next story
/// and is not part of its contents, so a one-line header is stored as `text\r\r`. Laid out, the
/// guard would add an empty paragraph to every header and footer and push the body down when
/// the header reaches past the top margin (#5922).
///
/// Only an empty last paragraph after an ordinary one is dropped: a story that is nothing but a
/// mark stays a part (an empty header, not one linked to the previous section), and a mark right
/// after a table row stays the paragraph that closes the table.
fn header_story_blocks(
ctx: &WalkCtx<'_>,
a: u32,
b: u32,
pics: &mut media::Pictures,
max_paragraphs: usize,
) -> Result<wordcraft_doc::Blocks, DocbinError> {
let mut paras = walk(ctx, a, b, pics);
if ctx.paragraphs.exceeded.get() {
return Err(paragraph_limit(max_paragraphs));
}
let outside_table = |p: &table::ParaOut| p.row.as_ref().is_none_or(|r| !r.in_table && !r.ttp && !r.cell_mark);
if let [.., prev, last] = paras.as_slice() {
let guard = outside_table(last) && last.para.text.is_empty() && last.para.objects.is_empty() && last.para.section.is_none();
if guard && outside_table(prev) {
paras.pop();
}
}
Ok(table::assemble(paras))
}
/// A story's CP range `[a, b)`, relative to a subdocument starting at `base` with `len`
/// CPs, as absolute CPs clamped to that subdocument; `None` when empty.
fn story(base: u32, len: u32, (a, b): (u32, u32)) -> Option<(u32, u32)> {
-61
View File
@@ -337,15 +337,6 @@ fn empty_document_gets_a_paragraph() {
assert_eq!(doc.body.len(), 1);
}
#[test]
fn a_doc_opens_in_word_97_2003_compatibility_mode() {
let f = text_doc(&[Piece { compressed: false, text: "body\r".into() }], &[], 0);
let doc = read(&f).expect("opens");
// Word 97-2003 files are mode 11 in Word 2013+; mode 15 would change justification and
// line breaking.
assert_eq!(doc.settings.compat_mode, 11);
}
#[test]
fn header_text_stays_out_of_body() {
let f = text_doc(
@@ -1157,58 +1148,6 @@ fn header_story_becomes_part() {
assert!(part.blocks.iter().any(|b| b.as_para().is_some_and(|p| p.text.contains("Header text"))));
}
/// The odd header's blocks for a one-section document whose header story is `story`, with
/// `papx_entries` over the whole stream (the header story starts at CP 5, after `body\r`):
/// paragraph texts, a table as `<table>`; `None` when the slot has no part.
fn odd_header_blocks(story: &'static str, papx_entries: Vec<(u32, u16, Vec<u8>)>) -> Option<Vec<String>> {
let spec = StructSpec {
text: "body\r",
header_stories: vec![story],
sections: vec![(5, Vec::new())],
chpx_runs: vec![(0, Vec::new())],
papx_entries,
plf_lst: Vec::new(),
plf_lfo: Vec::new(),
note_stories: vec![],
endnote_stories: vec![],
extra_pairs: Vec::new(),
data_stream: Vec::new(),
};
let doc = read(&struct_doc(&spec)).expect("opens");
let id = doc.last_section.headers.default?;
let part = doc.parts.get(&id).expect("part");
Some(part.blocks.iter().map(|b| b.as_para().map_or_else(|| "<table>".to_string(), |p| p.text.clone())).collect())
}
/// #5922: [MS-DOC] 2.3.3 ends every non-empty header story with a guard paragraph mark that
/// is not part of its contents. Read as a paragraph, it put an empty line under every header
/// and pushed the first body line down when the header reached past the top margin.
#[test]
fn header_story_guard_mark_is_not_a_paragraph() {
let plain = || vec![(0, 0, Vec::new())];
// One line typed into the header: content "Header\r" + guard.
assert_eq!(odd_header_blocks("Header\r\r", plain()), Some(vec!["Header".to_string()]));
// Enter pressed after it: the empty paragraph is content and stays, only the guard goes.
assert_eq!(odd_header_blocks("Header\r\r\r", plain()), Some(vec!["Header".to_string(), String::new()]));
// An empty header (one empty content paragraph + guard) is still a header of its own.
assert_eq!(odd_header_blocks("\r\r", plain()), Some(vec![String::new()]));
// A story that is only a mark (no guard) keeps its paragraph and its part.
assert_eq!(odd_header_blocks("\r", plain()), Some(vec![String::new()]));
// A story without a guard keeps its last paragraph.
assert_eq!(odd_header_blocks("Header\r", plain()), Some(vec!["Header".to_string()]));
}
/// #5922: a mark right after a table row in a header story is kept: it is the paragraph that
/// closes the table, not a guard to drop.
#[test]
fn header_story_mark_after_a_table_row_is_kept() {
let in_tbl = grpprl(&[(0x2416, &[0x01])]);
let ttp: Vec<u8> = [grpprl(&[(0x2416, &[0x01]), (0x2417, &[0x01])]), t_def_table(&[0, 2880])].concat();
// Stream: "body\r" (CPs 0-4), then the header story "a\x07" (5-6), row end "\x07" (7), "\r" (8).
let papx = vec![(0, 0, Vec::new()), (5, 0, in_tbl), (7, 0, ttp), (8, 0, Vec::new())];
assert_eq!(odd_header_blocks("a\u{7}\u{7}\r", papx), Some(vec!["<table>".to_string(), String::new()]));
}
/// A `sprmTDefTable` Prl for `edges` twip boundaries and TC80s of 20 bytes per column,
/// built per the spec (cb counts the bytes after it, plus one).
fn t_def_table(edges: &[i16]) -> Vec<u8> {
-35
View File
@@ -183,41 +183,6 @@ fn shapes_keep_kind_colours_wrap_and_text_box_text() {
assert_eq!(part.blocks.first().and_then(|b| b.as_para()).map(|p| p.text.as_str()), Some("Inside"));
}
#[test]
fn braces_and_brackets_are_unfilled_unless_filled_is_set() {
// A right brace (88) in front of the text with no fill properties, and a left bracket (85)
// whose fFilled is set: only the bracket is filled. A plain rectangle (1) keeps its default
// white fill.
let brace = sp(&[fsp(88, 1026, 0xA00)]);
let bracket = sp(&[fsp(85, 1027, 0xA00), fopt(&[(0x01BF, (1 << 20) | (1 << 4))], &[])]);
let rect = sp(&[fsp(1, 1028, 0xA00)]);
let art = art_content(&[], &[brace, bracket, rect]);
let flags = spa_flags(0, 0, 3, false);
let spas =
plc(&[1, 2, 3, 5], &[spa(1026, [0, 0, 240, 1140], flags), spa(1027, [0, 0, 240, 1140], flags), spa(1028, [0, 0, 240, 1140], flags)].concat());
let text = "A\u{8}\u{8}\u{8}B\r";
let ccp = text.chars().count() as u32;
let spec = StructSpec { text, extra_pairs: vec![(40, spas), (50, art)], ..structured_spec(text, ccp) };
let doc = read(&struct_doc(&spec)).expect("opens");
let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para");
let fills: Vec<_> = p0
.objects
.iter()
.map(|o| match o {
InlineObject::Shape { fill, stroke, w, h, .. } => (fill.clone(), *stroke, *w, *h),
other => panic!("{other:?}"),
})
.collect();
assert_eq!(
fills,
[
(None, Some(Rgb::BLACK), 12.0, 57.0),
(Some(Fill::solid(Rgb::WHITE)), Some(Rgb::BLACK), 12.0, 57.0),
(Some(Fill::solid(Rgb::WHITE)), Some(Rgb::BLACK), 12.0, 57.0),
]
);
}
#[test]
fn text_boxes_pointing_at_the_same_story_text_read_it_once() {
// Two text boxes whose PlcftxbxTxt stories are both [0, 7) (the table steps back to CP 0
+1 -20
View File
@@ -350,7 +350,6 @@ impl Package {
let path = resolve(dir, target);
self.canonical(&path).map_or(path, str::to_string)
};
rels.ids.entry(id.to_string()).or_insert(rels.list.len());
rels.list.push(Rel { id: id.to_string(), kind: r.attr("Type").unwrap_or("").to_string(), target: resolved, external });
}
rels
@@ -406,13 +405,11 @@ pub struct Rel {
#[derive(Clone, Debug, Default)]
pub struct Rels {
pub list: Vec<Rel>,
/// Each id's first relationship in `list`, so a lookup doesn't scan every relationship.
ids: HashMap<String, usize>,
}
impl Rels {
pub fn by_id(&self, id: &str) -> Option<&Rel> {
self.ids.get(id).and_then(|&i| self.list.get(i))
self.list.iter().find(|r| r.id == id)
}
pub fn by_type(&self, full: &str) -> Option<&Rel> {
self.list.iter().find(|r| rel_is(&r.kind, full))
@@ -550,22 +547,6 @@ mod tests {
assert_eq!(crate::package_title(std::io::Cursor::new(z), 1 << 20).as_deref(), Some("Plan"));
}
#[test]
fn relationships_are_looked_up_by_id_the_first_one_winning() {
let mut rels = String::from(r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">"#);
for i in 0..20_000 {
rels.push_str(&format!(r#"<Relationship Id="rId{i}" Type="hyperlink" Target="https://example.com/{i}" TargetMode="External"/>"#));
}
rels.push_str(r#"<Relationship Id="rId7" Type="hyperlink" Target="https://example.com/dup" TargetMode="External"/></Relationships>"#);
let z = zip_of(&[("word/_rels/document.xml.rels", rels.into_bytes())]);
let r = Package::open(&z).unwrap().rels("word/document.xml");
assert_eq!(r.by_id("rId7").map(|r| r.target.as_str()), Some("https://example.com/7"), "the first rId7 wins");
assert_eq!(r.by_id("rId19999").map(|r| r.target.as_str()), Some("https://example.com/19999"));
assert!(r.by_id("rId20000").is_none());
// Hyperlink-style lookups, one per relationship, are each a map lookup.
assert_eq!((0..20_000).filter(|i| r.by_id(&format!("rId{i}")).is_some()).count(), 20_000);
}
#[test]
fn decompression_bomb_is_rejected() {
let z = zip_of(&[("word/document.xml", vec![b' '; 2_000_000])]);
+1 -10
View File
@@ -27,8 +27,7 @@ use wordcraft_doc::props::VAlign;
use wordcraft_doc::section::NumFormat;
use wordcraft_doc::styles::{PartFormat, Style, StyleKind, StyleSheet, TablePart, TableStyleParts};
use wordcraft_doc::{
Block, Blocks, Comment, Document, InlineObject, MissingPicture, MissingPictureReason, PagePicture, Paragraph, PartKind, Revision, RevisionKind,
Zoom, ZoomFit, para_block,
Block, Blocks, Comment, Document, InlineObject, PagePicture, Paragraph, PartKind, Revision, RevisionKind, Zoom, ZoomFit, para_block,
};
use crate::DocxError;
@@ -104,8 +103,6 @@ pub(crate) struct Reader<'p> {
pub shape_ids: bool,
/// The main document part's name.
main: String,
/// The part being read (`word/header2.xml`), for naming where a dropped picture was.
story: Arc<str>,
/// The package's content types, read when first needed.
content_types: Option<ContentTypes>,
/// Parts kept for charts, diagrams and OLE objects (see `embed`).
@@ -186,7 +183,6 @@ fn read_opened_in(pkg: &Package, chunks: ChunkState) -> Result<(Document, usize,
glued: HashSet::new(),
shape_ids: false,
main: main.clone(),
story: main.as_str().into(),
content_types: None,
embedded: EmbeddedManifest::default(),
txbx_links: Vec::new(),
@@ -194,7 +190,6 @@ fn read_opened_in(pkg: &Package, chunks: ChunkState) -> Result<(Document, usize,
group_fill: None,
chunks,
};
r.doc.missing_pictures.main = main.clone();
r.pc.major_font = r.doc.settings.major_font.clone();
r.pc.minor_font = r.doc.settings.minor_font.clone();
let part = |t: &str| rels.by_type(t).filter(|x| !x.external).map(|x| x.target.clone());
@@ -235,18 +230,14 @@ fn read_opened_in(pkg: &Package, chunks: ChunkState) -> Result<(Document, usize,
let ex = part(rt::COMMENTS_EX);
let ids = part(rt::COMMENTS_IDS);
let cex = part(rt::COMMENTS_EXTENSIBLE);
r.story = p.as_str().into();
lenient("comments", r.read_comments(&p, ex.as_deref(), ids.as_deref(), cex.as_deref()));
}
if let Some(p) = part(rt::FOOTNOTES) {
r.story = p.as_str().into();
lenient("footnotes", r.read_notes(&p, true));
}
if let Some(p) = part(rt::ENDNOTES) {
r.story = p.as_str().into();
lenient("endnotes", r.read_notes(&p, false));
}
r.story = main.as_str().into();
// 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.
-3
View File
@@ -66,9 +66,6 @@ pub(super) struct Axis {
pub(super) struct Series {
pub(super) name: String,
/// The series' `c:idx` (an `xsd:unsignedInt`), which picks its automatic colour; `None`
/// when missing or not a valid number.
pub(super) idx: Option<usize>,
pub(super) color: Option<Rgb>,
pub(super) line_w: f32,
/// Whether the series is drawn as a line (scatter series without one get markers).
+2 -6
View File
@@ -91,11 +91,8 @@ impl Chart {
self.accents.get(i % 6).copied().unwrap_or(Rgb::BLACK)
}
/// Colour of series `j`: its own, else the accent its `c:idx` picks (as Word does; `c:order`
/// doesn't matter), else the one its place in the file picks.
pub(super) fn series_colour(&self, j: usize) -> Rgb {
let s = self.series.get(j);
s.and_then(|s| s.color).unwrap_or_else(|| self.accent(s.and_then(|s| s.idx).unwrap_or(j)))
self.series.get(j).and_then(|s| s.color).unwrap_or_else(|| self.accent(j))
}
/// Colour of point `i` of series `j`: its `c:dPt` override, else the varied accent (pie) or
@@ -202,7 +199,6 @@ pub(super) fn text_size(space: &El) -> f32 {
impl Series {
fn parse(ser: &El, scatter: bool, theme: &[Rgb], cap: usize) -> Series {
let name = ser.child("c:tx").and_then(|t| t.find("c:v")).map(|v| label(v.text().trim())).unwrap_or_default();
let idx = ser.child("c:idx").and_then(|e| e.attr("val")).and_then(|v| v.trim().parse::<u32>().ok()).and_then(|v| usize::try_from(v).ok());
let sp = ser.child("c:spPr");
let ln = sp.and_then(|p| p.child("a:ln"));
let marker = ser.child("c:marker").and_then(|m| m.child("c:spPr")).and_then(|p| fill_colour(p, theme));
@@ -223,7 +219,7 @@ impl Series {
}
}
let labels = show_values(ser.child("c:dLbls"));
Series { name, idx, color, line_w, line, values, xs, points, fmt, x_fmt, labels, raw: Vec::new() }
Series { name, color, line_w, line, values, xs, points, fmt, x_fmt, labels, raw: Vec::new() }
}
}
-62
View File
@@ -528,65 +528,3 @@ fn negative_sections_and_scientific_numbers() {
assert_eq!(format_value(0.0, "0.00E+00", 1.0), "0.00E+00");
assert_eq!(format_value(-12345.0, "0.0E-00", 1.0), "-1.2E04");
}
// An automatic series colour follows the series' `c:idx`, not its place in the file (#5963), as
// measured in Word: `idx` 3 then 0 are accent4 then accent1, a lone `idx` 2 is accent3, and
// `c:order` changes nothing.
/// The fill colours of the bars of a column chart of `sers`, and of its legend swatches.
fn column_colours(sers: &[String]) -> (Vec<Rgb>, Vec<Rgb>) {
let plot = format!(
r#"<c:barChart><c:barDir val="col"/><c:grouping val="clustered"/><c:varyColors val="0"/>{}<c:axId val="1"/><c:axId val="2"/></c:barChart><c:catAx><c:axId val="1"/></c:catAx><c:valAx><c:axId val="2"/></c:valAx>"#,
sers.concat()
);
let items = with_legend(&plot, "r", 300.0, 200.0);
let bars = filled(&items).iter().map(|b| b.0).collect();
let keys = items
.iter()
.filter_map(|it| if let GraphicItem::Shape { rect, fill, .. } = it { (rect[2] == SWATCH).then_some(*fill).flatten() } else { None })
.collect();
(bars, keys)
}
/// `ser` with its `c:idx` / `c:order` replaced by `idx_order` (an empty string drops them).
fn renumbered(ser: &str, idx_order: &str) -> String {
let start = ser.find("<c:idx").expect("ser writes c:idx");
let end = ser.find("<c:tx>").expect("ser writes c:tx");
format!("{}{idx_order}{}", &ser[..start], &ser[end..])
}
#[test]
fn automatic_series_colours_follow_c_idx() {
let accent = |k: usize| theme()[4 + k];
let a = ser(0, "A", None, &["Q1"], &["1"]);
let b = ser(1, "B", None, &["Q1"], &["2"]);
// idx 3 first, idx 0 second: accent4, accent1, in the bars and the legend.
let sers = [renumbered(&a, r#"<c:idx val="3"/><c:order val="0"/>"#), renumbered(&b, r#"<c:idx val="0"/><c:order val="1"/>"#)];
assert_eq!(column_colours(&sers), (vec![accent(3), accent(0)], vec![accent(3), accent(0)]));
// A lone series with idx 2 is accent3.
let lone = [renumbered(&a, r#"<c:idx val="2"/><c:order val="0"/>"#)];
assert_eq!(column_colours(&lone).0, [accent(2)]);
// c:order doesn't matter: idx 0 and 1 stay accent1 and accent2 with their orders swapped.
let swapped = [renumbered(&a, r#"<c:idx val="0"/><c:order val="1"/>"#), renumbered(&b, r#"<c:idx val="1"/><c:order val="0"/>"#)];
assert_eq!(column_colours(&swapped).0, [accent(0), accent(1)]);
// A series' own colour still wins.
let own = [renumbered(&ser(0, "A", Some("FF0000"), &["Q1"], &["1"]), r#"<c:idx val="3"/><c:order val="0"/>"#)];
assert_eq!(column_colours(&own).0, [Rgb(0xFF, 0, 0)]);
}
#[test]
fn series_without_a_usable_c_idx_take_their_place() {
let accent = |k: usize| theme()[4 + k];
let a = ser(0, "A", None, &["Q1"], &["1"]);
let b = ser(1, "B", None, &["Q1"], &["2"]);
// No c:idx at all: the place in the file.
assert_eq!(column_colours(&[renumbered(&a, ""), renumbered(&b, "")]).0, [accent(0), accent(1)]);
// Junk, negative and out-of-range values: no panic, the place in the file.
for junk in ["x", "-1", "1e3", "99999999999999999999999"] {
let sers = [renumbered(&a, &format!(r#"<c:idx val="{junk}"/>"#)), renumbered(&b, &format!(r#"<c:idx val="{junk}"/>"#))];
assert_eq!(column_colours(&sers).0, [accent(0), accent(1)], "{junk:?}");
}
// The largest unsignedInt `c:idx` allows draws, without a panic.
let big = [renumbered(&a, &format!(r#"<c:idx val="{}"/>"#, u32::MAX))];
assert_eq!(column_colours(&big).0.len(), 1);
}
-15
View File
@@ -80,21 +80,6 @@ impl Reader<'_> {
// `w:matchSrc`: a Word document's content keeps its own styles (other formats' formatting
// is their own already).
let keep = is_docx && e.child("w:altChunkPr").and_then(|p| p.child("w:matchSrc")).is_some_and(crate::units::on_off);
// The pictures the chunk's own reader dropped are the file's too (strict export, #6005),
// each named by the chunk and its part in it (`word/afchunk1.docx > word/header1.xml`).
// A chunk in another format brings none: those importers record nothing yet (#6007).
let nested = std::mem::take(&mut doc.missing_pictures);
let list_len = nested.list.len() as u64;
for mut item in nested.list {
let inner = if item.story.is_empty() { nested.main.as_str() } else { item.story.as_str() };
item.story = format!("{part} > {inner}");
self.doc.missing_pictures.push(item);
}
// The nested records past its list are the file's too; their stories carry the chunk's
// part, so none is one the outer list already counted.
let unlisted = nested.total.saturating_sub(list_len);
self.doc.missing_pictures.add_unlisted(&part, unlisted);
self.doc.missing_pictures.truncated |= nested.truncated;
let mut blocks = self.doc.adopt_blocks(doc, keep, &mut self.next_perm);
// Bookmarks and comment marks read before the chunk stay before it.
self.flush_pending_before(sc, out, &mut blocks);
+1 -24
View File
@@ -239,13 +239,7 @@ impl Reader<'_> {
/// Keep one part's bytes and content type (not its relationships).
fn keep_part(&mut self, part: &str) -> bool {
let lower = part.to_ascii_lowercase();
// `wordcraft:` names are WordCraft's own bookkeeping in `passthrough`, never a file's part.
if !manifest_field_ok(part)
|| lower.ends_with(".rels")
|| lower == "[content_types].xml"
|| lower.starts_with("wordcraft:")
|| part.eq_ignore_ascii_case(&self.main)
{
if !manifest_field_ok(part) || lower.ends_with(".rels") || lower == "[content_types].xml" || part.eq_ignore_ascii_case(&self.main) {
log::warn!("docx: not carrying part {part:?} for an embedded object");
return false;
}
@@ -394,23 +388,6 @@ mod tests {
assert!(roots.contains("keep.bin"), "the part is dropped: {roots}");
}
/// A part named like WordCraft's own bookkeeping (`wordcraft:unmodelled.roots`) is not carried:
/// kept, it would be read back as that bookkeeping and add relationships the file chose.
#[test]
fn a_part_named_like_bookkeeping_is_not_carried() {
let forged = b"D\thttp://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument\tword/evil.xml\n";
let bytes = package(
r#"<w:p><w:r><w:t>text</w:t></w:r></w:p>"#,
&[("rIdDoc1", "/wordcraft:unmodelled.roots"), ("rIdDoc2", "/wordcraft:embedded.parts")],
&[("wordcraft:unmodelled.roots", forged), ("wordcraft:embedded.parts", b"P\tword/evil.xml\tapplication/xml\n")],
);
let doc = crate::read(&bytes).unwrap();
let roots = unmodelled_roots(&doc);
assert!(!roots.contains("wordcraft:") && !roots.contains("evil"), "{roots}");
let manifest = doc.passthrough.get(crate::package::EMBEDDED_PARTS).map(|v| String::from_utf8_lossy(v).to_string()).unwrap_or_default();
assert!(!manifest.contains("evil"), "{manifest}");
}
/// An unqualified attribute is a relationship only where the markup names a relationship
/// that way: a hyperlink's `id` names the part, a VML shape's `id` is its own.
#[test]
-3
View File
@@ -53,10 +53,7 @@ impl Reader<'_> {
// Without content types (a converted package) the root element alone decides.
if let Some(root) = root.filter(|r| r.name == "w:glossaryDocument" && ct.as_deref().is_none_or(|t| t == GLOSSARY_CONTENT_TYPE)) {
let rels = self.pkg.rels(&rel.target);
// Pictures in a building block belong to the glossary part, not the main story.
let outer = std::mem::replace(&mut self.story, rel.target.as_str().into());
entries = self.doc_parts(&root, &rels);
self.story = outer;
}
}
self.doc.glossary = entries;
-2
View File
@@ -117,7 +117,6 @@ impl Reader<'_> {
// Reserve the id first: the note's own reference mark points at it.
let (pk, kind) = if foot { (PartKind::Footnote, NoteKind::Footnote) } else { (PartKind::Endnote, NoteKind::Endnote) };
let id = self.doc.add_part(pk, Blocks::new());
self.doc.missing_pictures.part_names.insert(id, self.story.clone());
let mut sc = StoryCtx::default();
let mut blocks = Blocks::new();
self.current_note = Some((kind, id));
@@ -215,7 +214,6 @@ impl Reader<'_> {
self.read_blocks(&mut sc, c, &rels, &mut blocks, self.chunks.depth);
self.flush_pending(&mut sc, &mut blocks);
let part = self.doc.add_part(PartKind::Comment, blocks);
self.doc.missing_pictures.part_names.insert(part, self.story.clone());
if let Some(pid) = c.children("w:p").last().and_then(|p| p.attr("w14:paraId")) {
by_para.insert(pid.to_string(), id);
}
+4 -26
View File
@@ -1,6 +1,6 @@
use super::{
Arc, Blocks, ContentTypes, DocxError, El, MAX_FONT_NAME, MAX_HF_DEPTH, MAX_PARTS, MAX_TABLE_FONTS, MAX_VBA_RELATED, MissingPicture,
MissingPictureReason, PartKind, Reader, Rels, Revision, RevisionKind, Rgb, StoryCtx, VBA_PROJECT_PART, VBA_RELATED, props, shape_style,
Arc, Blocks, ContentTypes, DocxError, El, MAX_FONT_NAME, MAX_HF_DEPTH, MAX_PARTS, MAX_TABLE_FONTS, MAX_VBA_RELATED, PartKind, Reader, Rels,
Revision, RevisionKind, Rgb, StoryCtx, VBA_PROJECT_PART, VBA_RELATED, props, shape_style,
};
impl Reader<'_> {
@@ -55,8 +55,6 @@ impl Reader<'_> {
.children("w:font")
.filter(|f| f.child("w:family").and_then(|v| v.attr("w:val")) == Some("roman"))
.filter_map(|f| f.attr("w:name"))
.flat_map(|n| n.split(';'))
.map(str::trim)
.filter(|n| !n.is_empty() && n.len() <= MAX_FONT_NAME)
.take(MAX_TABLE_FONTS)
.map(str::to_string)
@@ -91,22 +89,14 @@ impl Reader<'_> {
/// Media key for an image relationship (loads the bytes once).
pub fn media_for(&mut self, rels: &Rels, id: &str) -> Option<String> {
let Some(rel) = rels.by_id(id) else {
self.note_missing_picture(id, MissingPictureReason::MissingPart);
return None;
};
let rel = rels.by_id(id)?;
if rel.external {
// Never fetched: a picture whose target is outside the file is dropped (and reported).
self.note_missing_picture(&rel.target, MissingPictureReason::ExternalTarget);
return None;
}
if let Some(k) = self.media_by_path.get(&rel.target) {
return Some(k.clone());
}
let Some(bytes) = self.pkg.get_shared(&rel.target) else {
self.note_missing_picture(&rel.target, MissingPictureReason::MissingPart);
return None;
};
let bytes = self.pkg.get_shared(&rel.target)?;
let base: String =
rel.target.rsplit('/').next().unwrap_or("image").chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')).collect();
let base = if base.is_empty() || base.starts_with('.') { format!("image{base}") } else { base };
@@ -120,18 +110,10 @@ impl Reader<'_> {
};
}
self.doc.media.insert(key.clone(), bytes.clone());
self.doc.missing_pictures.media_parts.insert(key.clone(), rel.target.clone());
self.media_by_path.insert(rel.target.clone(), key.clone());
Some(key)
}
/// Remember a picture that was dropped at open, in the story being read.
pub(super) fn note_missing_picture(&mut self, part: &str, reason: MissingPictureReason) {
let part: String = part.chars().take(1024).collect();
let story = self.story.to_string();
self.doc.missing_pictures.push(MissingPicture { part, story, reason });
}
/// A chart or diagram part, parsed once however often it's referenced (`None` when unreadable).
pub(super) fn graphic_part(&mut self, path: &str) -> Option<Arc<El>> {
if let Some(p) = self.graphic_parts.get(path) {
@@ -153,8 +135,6 @@ impl Reader<'_> {
let kind = if footer { PartKind::Footer } else { PartKind::Header };
let id = self.doc.add_part(kind, Blocks::new());
self.hf_by_path.insert(path.to_string(), id);
let name: Arc<str> = path.into();
self.doc.missing_pictures.part_names.insert(id, Arc::clone(&name));
let root = match self.xml(path) {
Ok(Some(r)) => r,
_ => return Some(id),
@@ -164,9 +144,7 @@ impl Reader<'_> {
sc.in_hf = true;
let mut blocks = Blocks::new();
self.hf_depth += 1;
let outer = std::mem::replace(&mut self.story, name);
self.read_blocks(&mut sc, &root, &rels, &mut blocks, self.chunks.depth);
self.story = outer;
self.hf_depth = self.hf_depth.saturating_sub(1);
self.flush_pending(&mut sc, &mut blocks);
if let Some(p) = self.doc.parts.get_mut(&id)
+10 -41
View File
@@ -5,17 +5,16 @@ use std::collections::HashMap;
use wordcraft_doc::para::cap_alt;
use wordcraft_doc::props::{
Align, Border, BorderStyle, Borders, CellProps, CharProps, CombineBrackets, EastAsianLayout, HeightRule, Highlight, LineSpacing, NumRef,
ParaProps, ParagraphTextDirection, Rgb, RowProps, TabAlign, TabLeader, TabStop, TableFloat, TableLook, TableProps, TextAlignment, TextColor,
TextDirection, Underline, VAlign, VMerge, VertAlign,
ParaProps, Rgb, RowProps, TabAlign, TabLeader, TabStop, TableFloat, TableLook, TableProps, TextAlignment, TextColor, TextDirection, Underline,
VAlign, VMerge, VertAlign,
};
use wordcraft_doc::section::{
Columns, DocGrid, DocGridType, LineNumberRestart, LineNumbering, MAX_CHAR_SPACE, MAX_GRID_PITCH, NotePos, NoteProps, NoteRestart, NumFormat,
PageVAlign, SectionProps, SectionStart,
};
use super::MAX_FONT_NAME;
use super::drawing_color::adjust_lum;
use crate::units::{int, line_multiple, measure, on_off, tw, tw_i16, tw_u16, u32_of};
use crate::units::{int, line_multiple, measure, on_off, tw, u32_of};
/// A text frame (`w:framePr` without a drop cap, ECMA-376 §17.3.1.11), sizes and offsets in
/// points (clamped), names kept as OOXML writes them.
@@ -146,29 +145,6 @@ pub(crate) fn resolve_level_only(sheet: &wordcraft_doc::styles::StyleSheet, p: &
}
}
/// A `w:rFonts` value as the document keeps it: `None` when it names no family, else its leading
/// families up to [`MAX_FONT_NAME`] bytes, cut at a `;` so no name is split. A LibreOffice file
/// names its Latin font as a semicolon-separated fallback list, and a real list names a few
/// families; a longer value is corrupt or hostile, and every piece of text drawn in it would pay
/// for its whole length as the list is classified and resolved. Keeping the leading families keeps
/// the family such a list picks, and the rest of the value is dropped like `w:cs`/`w:eastAsia`
/// drop an over-long name.
fn bounded_font_list(v: &str) -> Option<String> {
if v.len() <= MAX_FONT_NAME {
return (!v.is_empty()).then(|| v.to_string());
}
let mut end = 0usize;
for part in v.split_inclusive(';') {
let next = end + part.len();
if next > MAX_FONT_NAME {
break;
}
end = next;
}
let kept = v.get(..end)?.trim_end_matches(';');
(!kept.is_empty()).then(|| kept.to_string())
}
impl PropCtx {
pub fn style_id(&self, id: &str) -> String {
self.alias.get(id).cloned().unwrap_or_else(|| id.to_string())
@@ -250,8 +226,6 @@ impl PropCtx {
shd_fill(e)
}
/// The character properties `e` (a `w:rPr`) sets: direct formatting, plus the fonts, size,
/// weight and posture of the complex-script and East Asian text beside them.
pub fn rpr(&self, e: &El) -> CharProps {
let mut c = CharProps::default();
for k in e.els() {
@@ -260,11 +234,7 @@ impl PropCtx {
"w:rFonts" => {
// A theme font supersedes the fixed font beside it (`w:asciiTheme` over
// `w:ascii`, `w:hAnsiTheme` over `w:hAnsi`).
// A LibreOffice file names the Latin font as a semicolon-separated fallback
// list. Names are bounded like `w:cs`/`w:eastAsia` below: a longer one is
// corrupt or hostile, and every piece of text drawn in it would pay for its
// whole length as the list is classified (see `bounded_font_list`).
let named = |a: &str| k.attr(a).and_then(bounded_font_list);
let named = |a: &str| k.attr(a).filter(|f| !f.is_empty()).map(str::to_string);
let themed = |a: &str| k.attr(a).and_then(|t| self.theme_font(t));
// `w:eastAsia` and `w:cs` name fonts for other scripts and stay in `font_ea` /
// `font_cs`: `w:hint` only picks between them for ambiguous characters, never for
@@ -368,7 +338,6 @@ impl PropCtx {
"w:suppressAutoHyphens" => p.suppress_hyphens = Some(on_off(k)),
"w:suppressLineNumbers" => p.suppress_line_numbers = Some(on_off(k)),
"w:bidi" => p.bidi = Some(on_off(k)),
"w:textDirection" => p.text_direction = k.attr("w:val").and_then(ParagraphTextDirection::from_ooxml),
"w:kinsoku" => p.kinsoku = Some(on_off(k)),
"w:wordWrap" => p.word_wrap = Some(on_off(k)),
"w:overflowPunct" => p.overflow_punct = Some(on_off(k)),
@@ -407,10 +376,10 @@ impl PropCtx {
p.tabs = Some(tabs);
}
"w:spacing" => {
if let Some(v) = tw_u16(k, "w:before") {
if let Some(v) = tw(k, "w:before") {
p.space_before = Some(v.max(0.0));
}
if let Some(v) = tw_u16(k, "w:after") {
if let Some(v) = tw(k, "w:after") {
p.space_after = Some(v.max(0.0));
}
// HTML-style automatic spacing; "0" switches an inherited one off.
@@ -433,15 +402,15 @@ impl PropCtx {
}
}
"w:ind" => {
if let Some(v) = tw_i16(k, "w:left").or_else(|| tw_i16(k, "w:start")) {
if let Some(v) = tw(k, "w:left").or_else(|| tw(k, "w:start")) {
p.indent_left = Some(v);
}
if let Some(v) = tw_i16(k, "w:right").or_else(|| tw_i16(k, "w:end")) {
if let Some(v) = tw(k, "w:right").or_else(|| tw(k, "w:end")) {
p.indent_right = Some(v);
}
if let Some(v) = tw_i16(k, "w:hanging") {
if let Some(v) = tw(k, "w:hanging") {
p.indent_first = Some(-v);
} else if let Some(v) = tw_i16(k, "w:firstLine") {
} else if let Some(v) = tw(k, "w:firstLine") {
p.indent_first = Some(v);
}
// Character units (hundredths of a character), which win when non-zero.
+1 -14
View File
@@ -41,7 +41,6 @@ impl Reader<'_> {
}
let source = self.keep_embedded(c.child("a:graphic")?, rels, &[])?;
float.set_spin(xfrm_spin(content.child("w14:xfrm")));
float.bare_turn = !anchored && float.rot != 0.0 && float.effect == [0.0; 4];
let (px, py) = float.spin_pad(w, h);
for (e, p) in float.effect.iter_mut().zip([px, py, px, py]) {
if *e != 0.0 {
@@ -107,11 +106,7 @@ impl Reader<'_> {
self.set_shape_id(&mut obj, c.child("wp:docPr").and_then(|p| p.attr("id")));
// Word's effect extent also covers a rotated object's overhang; the model keeps that
// apart (it follows from the angle), so take it back out.
// Shadow or glow room the file didn't record: saving records it (and the turn's room).
let fx_room = matches!(&obj, InlineObject::Shape { effects, .. } if effects.extent() != [0.0; 4]);
if let Some(f) = obj.float_mut() {
// None recorded: Word keeps an inline one's line to the frame (see `Float::bare_turn`).
f.bare_turn = !anchored && !fx_room && f.rot != 0.0 && f.effect == [0.0; 4];
let (px, py) = f.spin_pad(w, h);
for (e, p) in f.effect.iter_mut().zip([px, py, px, py]) {
// Zero adds no effects room when rotation contracts this axis.
@@ -279,15 +274,7 @@ impl Reader<'_> {
fn read_pic(&mut self, pic: &El, rels: &Rels, w: f32, h: f32, alt: String, mut float: Float) -> Option<InlineObject> {
let blip = pic.find("a:blip")?;
float.set_spin(xfrm_spin(pic.find("pic:spPr")));
let Some(embed) = blip.attr("r:embed") else {
// A linked picture holds no copy: nothing to draw, and nothing is fetched.
if let Some(link) = blip.attr("r:link") {
let target = rels.by_id(link).map_or_else(|| link.to_string(), |r| r.target.clone());
self.note_missing_picture(&target, wordcraft_doc::MissingPictureReason::LinkOnly);
}
return None;
};
let media = self.media_for(rels, embed)?;
let media = blip.attr("r:embed").and_then(|id| self.media_for(rels, id))?;
let mut crop = [0.0f32; 4];
if let Some(sr) = pic.find("a:srcRect") {
for (i, n) in ["l", "t", "r", "b"].iter().enumerate() {
+15 -42
View File
@@ -1,5 +1,3 @@
use wordcraft_doc::math::MathJc;
use super::{
CharProps, El, InlineObject, Item, MAX_FORM, MAX_INSTR, NoteKind, PB, PTabAlign, PTabBase, Phase, Reader, Rels, RevisionKind, RunCtx, StoryCtx,
TextColor, children_of_choice, clean_text, int, measure, on_off_attr, placeholder_of, sink,
@@ -15,7 +13,6 @@ impl Reader<'_> {
}
}
/// Read an inline child element (`w:r`, `m:oMath`, `m:oMathPara`, `w:ins`, etc.).
fn read_inline(&mut self, sc: &mut StoryCtx, pb: &mut PB, k: &El, rels: &Rels, ctx: &RunCtx, depth: usize) {
match k.name.as_str() {
"w:r" => self.read_run(sc, pb, k, rels, ctx, depth),
@@ -79,38 +76,25 @@ impl Reader<'_> {
}
"w:smartTag" | "w:customXml" | "w:dir" | "w:bdo" => self.read_inline_children(sc, pb, k, rels, ctx, depth + 1),
"m:oMath" => {
let props = self.run_props_none(ctx);
self.emit_omath(sc, pb, k, Default::default(), false, &props);
let mut props = self.run_props_none(ctx);
let mut math = crate::read::math::read_omath(k, Default::default());
if let Some(c) = crate::read::math::lift_color(&mut math) {
props.color = Some(TextColor::Rgb(c));
}
let linear = wordcraft_doc::math::to_linear(&math.nodes);
self.emit_obj(sc, pb, InlineObject::Equation { linear, display: false, math }, &props);
}
"m:oMathPara" => {
let base_props = self.run_props_none(ctx);
let props = self.run_props_none(ctx);
let jc = crate::read::math::para_jc(k);
for c in k.els() {
match c.name.as_str() {
"m:oMath" => self.emit_omath(sc, pb, c, jc, true, &base_props),
"w:ins" | "w:moveTo" => {
let mut sub_ctx = ctx.clone();
sub_ctx.ins =
Some(if c.name == "w:moveTo" { self.move_revision(true, c, false) } else { self.revision(RevisionKind::Insert, c) });
let sub_props = self.run_props_none(&sub_ctx);
for m in c.children("m:oMath") {
self.emit_omath(sc, pb, m, jc, true, &sub_props);
}
}
"w:del" | "w:moveFrom" => {
let mut sub_ctx = ctx.clone();
sub_ctx.del = Some(if c.name == "w:moveFrom" {
self.move_revision(false, c, false)
} else {
self.revision(RevisionKind::Delete, c)
});
let sub_props = self.run_props_none(&sub_ctx);
for m in c.children("m:oMath") {
self.emit_omath(sc, pb, m, jc, true, &sub_props);
}
}
_ => {}
for m in k.children("m:oMath") {
let mut math = crate::read::math::read_omath(m, jc);
let mut props = props.clone();
if let Some(c) = crate::read::math::lift_color(&mut math) {
props.color = Some(TextColor::Rgb(c));
}
let linear = wordcraft_doc::math::to_linear(&math.nodes);
self.emit_obj(sc, pb, InlineObject::Equation { linear, display: true, math }, &props);
}
}
"mc:AlternateContent" => {
@@ -400,17 +384,6 @@ impl Reader<'_> {
}
}
/// Read an `m:oMath` element and emit its [`InlineObject::Equation`].
fn emit_omath(&mut self, sc: &mut StoryCtx, pb: &mut PB, m: &El, jc: MathJc, display: bool, props: &CharProps) {
let mut math = crate::read::math::read_omath(m, jc);
let mut props = props.clone();
if let Some(color) = crate::read::math::lift_color(&mut math) {
props.color = Some(TextColor::Rgb(color));
}
let linear = wordcraft_doc::math::to_linear(&math.nodes);
self.emit_obj(sc, pb, InlineObject::Equation { linear, display, math }, &props);
}
pub(super) fn emit_obj(&mut self, sc: &mut StoryCtx, pb: &mut PB, o: InlineObject, props: &CharProps) {
match sink(sc) {
Some(f) => {
+1 -17
View File
@@ -131,14 +131,7 @@ impl Reader<'_> {
None => vml_float(shape, style, sc.in_hf),
};
if let Some(img) = shape.find("v:imagedata") {
let Some(id) = img.attr("r:id").or_else(|| img.attr("r:pict")) else {
// `r:href` names a file outside the package: never fetched.
if let Some(href) = img.attr("r:href") {
let target = rels.by_id(href).map_or_else(|| href.to_string(), |r| r.target.clone());
self.note_missing_picture(&target, wordcraft_doc::MissingPictureReason::LinkOnly);
}
return None;
};
let id = img.attr("r:id").or_else(|| img.attr("r:pict"))?;
let media = self.media_for(rels, id)?;
let alt = shape.attr("alt").or_else(|| img.attr("o:title")).unwrap_or("").to_string();
// Brightness and contrast (Word's picture watermark "Washout" sets them, #1711).
@@ -231,15 +224,6 @@ impl Reader<'_> {
{
let id = self.read_textbox(sc, t, rels);
if let Some(part) = self.doc.parts.get_mut(&id) {
// Text direction (#5193), as Word reads it: `mso-layout-flow-alt:bottom-to-top` is
// `vert270` whatever `layout-flow` says; otherwise `layout-flow:vertical…` (also
// `vertical-ideographic`, our `eaVert`) is `vert`, and anything else horizontal.
let tb_style = tb.attr("style").unwrap_or("");
if vml_style(tb_style, "mso-layout-flow-alt").is_some_and(|v| v.eq_ignore_ascii_case("bottom-to-top")) {
part.body.vert = wordcraft_doc::props::TextVert::Vert270;
} else if vml_style(tb_style, "layout-flow").is_some_and(|v| v.to_ascii_lowercase().starts_with("vertical")) {
part.body.vert = wordcraft_doc::props::TextVert::Vert;
}
// Internal margins `inset="l,t,r,b"` (ECMA-376 Part 4 §19.1.2.22), each one left
// out keeping Word's default; a bare number is in EMUs, VML's base unit.
// `o:insetmode="auto"` leaves them to the application.
+3 -4
View File
@@ -2,7 +2,6 @@ use super::{
AbstractNum, DocxError, El, Level, LevelSuffix, Num, NumFormat, PartFormat, Reader, Style, StyleKind, StyleSheet, TablePart, TableStyleParts,
VAlign, flag, int, props, tw, u32_of,
};
use crate::units::tw_i16;
use std::sync::Arc;
/// Built-in style names Word stores in lower case, and how the UI shows them.
@@ -318,12 +317,12 @@ impl Reader<'_> {
lv.align = props::align(j);
}
if let Some(ind) = l.child("w:pPr").and_then(|p| p.child("w:ind")) {
if let Some(v) = tw_i16(ind, "w:left").or_else(|| tw_i16(ind, "w:start")) {
if let Some(v) = tw(ind, "w:left").or_else(|| tw(ind, "w:start")) {
lv.indent = v;
}
if let Some(h) = tw_i16(ind, "w:hanging") {
if let Some(h) = tw(ind, "w:hanging") {
lv.hanging = h;
} else if let Some(f) = tw_i16(ind, "w:firstLine") {
} else if let Some(f) = tw(ind, "w:firstLine") {
lv.hanging = -f;
}
}
+3 -4
View File
@@ -83,14 +83,13 @@ pub(super) fn toc_control_to_toc(body: &mut Blocks) {
}
// An empty paragraph opening the control takes the field. It may end a section, as above
// entries in columns (#3889).
let empty_heading = engine.get(s).and_then(|b| b.as_para()).is_some_and(|p| p.is_empty());
if empty_heading {
if engine.get(s).and_then(|b| b.as_para()).is_some_and(|p| p.is_empty()) {
field_to_heading(&mut engine, s + 1);
} else {
toc_field_to_heading(&mut engine);
}
if crate::write::written_toc_span(&engine) == Some((s, last)) {
*body = if empty_heading { toc } else { engine };
*body = toc;
}
}
@@ -98,7 +97,7 @@ pub(super) fn toc_control_to_toc(body: &mut Blocks) {
/// reproduce exactly. Other imported TOCs keep their explicit boundaries, independent of styles.
pub(super) fn toc_field_to_heading(body: &mut Blocks) {
for i in 1..body.len() {
if body.get(i - 1).and_then(|b| b.as_para()).is_some_and(|p| p.props.style.as_deref() == Some("TOCHeading")) {
if body[i - 1].as_para().is_some_and(|p| p.props.style.as_deref() == Some("TOCHeading")) {
field_to_heading(body, i);
}
}
-39
View File
@@ -116,27 +116,6 @@ pub fn tw(e: &El, attr: &str) -> Option<f32> {
e.attr(attr).and_then(|v| measure(v, 20.0))
}
/// Signed twips Word keeps in 16 bits (paragraph and list indents, ECMA-376 Part 1 §17.3.1.12):
/// a plain integer outside `i16` keeps its low 16 bits, two's complement, as Word reads it
/// (66256 → 720). Values with a unit or a fraction read as [`tw`].
pub fn tw_i16(e: &El, attr: &str) -> Option<f32> {
let v = e.attr(attr)?;
match v.trim().parse::<i64>() {
Ok(i) => Some(f32::from(i as i16) / 20.0),
Err(_) => measure(v, 20.0),
}
}
/// Unsigned twips Word keeps in 16 bits (paragraph spacing, §17.3.1.33): a plain integer above
/// 65535 keeps its low 16 bits (65537 → 1). Other values read as [`tw`].
pub fn tw_u16(e: &El, attr: &str) -> Option<f32> {
let v = e.attr(attr)?;
match v.trim().parse::<i64>() {
Ok(i) if i > 0xFFFF => Some(f32::from((i & 0xFFFF) as u16) / 20.0),
_ => measure(v, 20.0),
}
}
/// ST_OnOff: absent `w:val` = on; `0`, `false`, `off` = off.
pub fn on_off(e: &El) -> bool {
match e.attr("w:val") {
@@ -169,24 +148,6 @@ pub fn emu(pt: f32) -> String {
mod tests {
use super::*;
#[test]
fn twips_beyond_16_bits_wrap() {
let el = |a: &str| El { attrs: vec![("w:v".into(), a.into())], ..El::default() };
let s = |a: &str| tw_i16(&el(a), "w:v");
let u = |a: &str| tw_u16(&el(a), "w:v");
assert_eq!(s("66256"), Some(36.0));
assert_eq!(s("-65516"), Some(1.0));
assert_eq!(s("98304"), Some(-1638.4));
assert_eq!(s("32767"), Some(1638.35));
assert_eq!(s("-32768"), Some(-1638.4));
assert_eq!(s("1in"), Some(72.0));
assert_eq!(s("x"), None);
assert_eq!(u("65537"), Some(0.05));
assert_eq!(u("65535"), Some(3276.75));
assert_eq!(u("12pt"), Some(12.0));
assert_eq!(u("-20"), Some(-1.0));
}
#[test]
fn measures() {
assert_eq!(measure("1440", 20.0), Some(72.0));
+7 -28
View File
@@ -353,11 +353,11 @@ impl super::Writer<'_> {
};
self.embeds.markup_bytes = left;
let mut root = xml::parse(src.xml.as_bytes()).ok()?;
let mut used = HashSet::new();
let mut used = Vec::new();
rel_attr_values(&root, &mut used, 0);
// Every relationship the markup uses must be writable, or the object would be broken.
let writable: HashSet<&str> = src.refs.iter().filter(|r| r.external || self.embeds.available(&r.target)).map(|r| r.id.as_str()).collect();
if !used.iter().all(|id| writable.contains(id.as_str())) {
let writable = |id: &String| src.refs.iter().any(|r| r.id == *id && (r.external || self.embeds.available(&r.target)));
if !used.iter().all(writable) {
log::warn!("docx: an embedded object's parts are missing; it is left out");
return None;
}
@@ -499,9 +499,8 @@ impl super::Writer<'_> {
}
}
"wp:effectExtent" => {
// Word's effect extent also covers a rotated object's overhang (its rotated bounds),
// unless the file it came from kept none.
let (px, py) = if float.is_bare_turn() { (0.0, 0.0) } else { float.spin_pad(w, h) };
// Word's effect extent also covers a rotated object's overhang (its rotated bounds).
let (px, py) = float.spin_pad(w, h);
let [el, et, er, eb] = float.effect_extent();
let ext = [el + px, et + py, er + px, eb + py].map(emu);
e.attrs.retain(|(k, _)| !matches!(k.as_str(), "l" | "t" | "r" | "b"));
@@ -638,15 +637,13 @@ fn set_attr(e: &mut El, k: &str, v: &str) {
}
}
/// The distinct relationship ids `e` uses: a set, so a large tree (a native document's markup
/// never went through the reader's id limit) costs no scan per attribute.
fn rel_attr_values(e: &El, out: &mut HashSet<String>, depth: usize) {
fn rel_attr_values(e: &El, out: &mut Vec<String>, depth: usize) {
if depth > MAX_DEPTH {
return;
}
for (k, v) in &e.attrs {
if xml::is_rel_attr(&e.name, k) && !out.contains(v) {
out.insert(v.clone());
out.push(v.clone());
}
}
for c in e.els() {
@@ -854,24 +851,6 @@ fn valid_part_name(name: &str) -> String {
mod tests {
use super::*;
#[test]
fn relationship_ids_are_collected_once_each() {
// A native document's embedded markup skips the reader's id limit: 40,000 distinct ids
// (each named twice) were a scan of every id collected so far, per attribute.
let mut xml = String::from(
r#"<w:p xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">"#,
);
for i in 0..40_000 {
xml.push_str(&format!(r#"<w:r r:id="rId{i}"/><w:r r:id="rId{i}"/>"#));
}
xml.push_str("</w:p>");
let root = xml::parse(xml.as_bytes()).unwrap();
let mut used = HashSet::new();
rel_attr_values(&root, &mut used, 0);
assert_eq!(used.len(), 40_000);
assert!(used.contains("rId39999"));
}
#[test]
fn charts_cut_to_the_limits_on_save_say_so_once() {
// A model deserialised as is can hold more than a chart part keeps; the save left the rest
-3
View File
@@ -287,9 +287,6 @@ pub fn ppr_inner(w: &mut W, p: &ParaProps, framed: bool, num_change: Option<&Cha
if let Some(a) = p.align {
w.val("w:jc", align_val(a));
}
if let Some(direction) = p.text_direction {
w.val("w:textDirection", direction.ooxml());
}
if let Some(t) = p.text_alignment {
w.val("w:textAlignment", t.ooxml());
}
+1 -18
View File
@@ -86,28 +86,11 @@ pub fn plan(bl: &Blocks) -> Plan<'_> {
&& (0..off).step_by(OBJ.len_utf8()).all(|o| matches!(p.object_at(o), Some(wordcraft_doc::InlineObject::ControlStart { .. })))
})
};
// Bookmark, comment and permission markers after the end may stay there: a body-level
// bookmark after a block control (Word's `_GoBack`) is read onto the end of its paragraph,
// and the control still covers the whole paragraph.
let at_end = |b: usize, off: usize| {
use wordcraft_doc::InlineObject as O;
para(b).is_some_and(|p| {
let from = off + OBJ.len_utf8();
p.text.get(from..).is_some_and(|t| t.chars().all(|c| c == OBJ))
&& (from..p.len()).step_by(OBJ.len_utf8()).all(|o| {
matches!(
p.object_at(o),
Some(
O::ControlEnd
| O::BookmarkStart { .. }
| O::BookmarkEnd { .. }
| O::CommentStart { .. }
| O::CommentEnd { .. }
| O::PermStart { .. }
| O::PermEnd { .. }
)
)
})
&& (from..p.len()).step_by(OBJ.len_utf8()).all(|o| matches!(p.object_at(o), Some(wordcraft_doc::InlineObject::ControlEnd)))
})
};
let mut mode = vec![Mode::Inline; ranges.len()];
+2 -3
View File
@@ -298,9 +298,8 @@ impl Writer<'_> {
}
}
w.empty("wp:extent", &[("cx", &cx), ("cy", &cy)]);
// Word's effect extent also covers a rotated object's overhang (its rotated bounds), unless
// the file it came from kept none.
let (px, py) = if float.is_bare_turn() { (0.0, 0.0) } else { float.spin_pad(iw, ih) };
// Word's effect extent also covers a rotated object's overhang (its rotated bounds).
let (px, py) = float.spin_pad(iw, ih);
let [el, et, er, eb] = float.effect_extent();
let [el, et, er, eb] = [el + px, et + py, er + px, eb + py].map(emu);
w.empty("wp:effectExtent", &[("l", &el), ("t", &et), ("r", &er), ("b", &eb)]);
-3
View File
@@ -4,7 +4,6 @@ use super::section::comment_reference_style;
use super::{CharProps, Fill, InlineObject, NoteKind, PartRels, TextColor, W, Wrap, Writer, emu, n, rpr};
impl Writer<'_> {
/// Write an inline object (`Bookmark`, `Comment`, `Equation`, `Field`, `Shape`, etc.).
pub(super) fn object(&mut self, w: &mut W, o: &InlineObject, props: &CharProps, rels: &mut PartRels, depth: usize) {
match o {
// Written by `para_content` (inline) or `blocks` (around paragraphs).
@@ -52,12 +51,10 @@ impl Writer<'_> {
Some(TextColor::Rgb(c)) => Some(c),
_ => None,
};
self.rev_open(w, props);
match crate::write::math::resolve_numbers(math, *display, &mut self.eq_number) {
Some(m) => crate::write::math::write_equation(w, linear, *display, &m, color),
None => crate::write::math::write_equation(w, linear, *display, math, color),
}
self.rev_close(w, props);
}
InlineObject::Field { instr, result, locked, dirty, form, .. } => {
self.field(w, FieldData { instr, result, locked: *locked, dirty: *dirty, form }, props)
+2 -31
View File
@@ -462,22 +462,15 @@ fn parse_with(bytes: &[u8], extra: &[(&'static str, &str)], strict: bool) -> Res
attach(&mut stack, &mut root, el);
}
}
// Line ends are normalised as XML 1.0 says (§2.11): CR LF and a lone CR are read as
// LF, so a file saved with Windows line ends has no CR in its text. A CR written as
// `&#13;` is a character reference and stays.
Event::Text(t) => {
if let Some(top) = stack.last_mut() {
let s = t.xml_content(XmlVersion::Explicit1_0).map_err(|e| DocxError::Xml(e.to_string()))?;
let s = t.decode().map_err(|e| DocxError::Xml(e.to_string()))?;
push_text(top, &s);
}
}
Event::CData(t) => {
if let Some(top) = stack.last_mut() {
// CDATA that isn't UTF-8 is decoded lossily, with its line ends normalised the same way.
let s = t
.xml_content(XmlVersion::Explicit1_0)
.map(|s| s.into_owned())
.unwrap_or_else(|_| String::from_utf8_lossy(&t).replace("\r\n", "\n").replace('\r', "\n"));
let s = String::from_utf8_lossy(&t).into_owned();
push_text(top, &s);
}
}
@@ -731,28 +724,6 @@ mod tests {
assert_eq!(t.attr("xml:space"), Some("preserve"));
}
/// CDATA that isn't UTF-8 is decoded lossily, and its line ends are still normalised.
#[test]
fn line_ends_in_cdata_that_isnt_utf8_are_normalised() {
let x =
b"<x:document xmlns:x=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><x:t><![CDATA[a\xff\r\nb\rc]]></x:t></x:document>";
assert_eq!(parse(x).unwrap().child("w:t").unwrap().text(), "a\u{FFFD}\nb\nc");
}
/// XML 1.0 §2.11: CR LF and a lone CR in text and CDATA are read as LF; `&#13;` stays a CR.
#[test]
fn line_ends_in_text_are_normalised_but_a_cr_reference_stays() {
let x = b"<x:document xmlns:x=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><x:t>a\r\nb\rc&#13;d<![CDATA[e\r\nf]]></x:t></x:document>";
let e = parse(x).unwrap();
assert_eq!(e.child("w:t").unwrap().text(), "a\nb\nc\rde\nf");
// The writer escapes a CR, so a CR in the text survives a save.
let x = format!(
"<x:document xmlns:x=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><x:t>{}</x:t></x:document>",
esc("a\r\nb\rc")
);
assert_eq!(parse(x.as_bytes()).unwrap().child("w:t").unwrap().text(), "a\r\nb\rc");
}
#[test]
fn choice_requirements_resolve_namespace_aliases_and_rebindings() {
let x = br#"<mc:AlternateContent xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:pen="http://schemas.microsoft.com/office/word/2010/wordprocessingInk"><mc:Choice Requires="pen"/><mc:Choice xmlns:pen="urn:future" Requires="pen"/><mc:Choice Requires="undeclared"/></mc:AlternateContent>"#;
-71
View File
@@ -372,74 +372,3 @@ fn hostile_chunks_never_panic() {
let bytes = package(&format!(r#"<w:altChunk r:id="nope"/><w:altChunk/>{}"#, p("y")), None);
assert_eq!(body_lines(&read(&bytes).unwrap()), ["y"]);
}
/// A DOCX whose only picture names a part the package lacks (`media/gone.png`), or sits outside it.
fn inner_with_dropped_picture(external: bool) -> Vec<u8> {
inner_with_dropped_pictures(1, external)
}
/// As [`inner_with_dropped_picture`], with `n` pictures each naming its own missing part.
fn inner_with_dropped_pictures(n: usize, external: bool) -> Vec<u8> {
let drawing = r#"<w:r><w:drawing><wp:inline xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"><wp:extent cx="254000" cy="254000"/><wp:docPr id="1" name="P"/><a:graphic xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:nvPicPr><pic:cNvPr id="0" name="p"/><pic:cNvPicPr/></pic:nvPicPr><pic:blipFill><a:blip r:embed="rIdP{i}"/></pic:blipFill><pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="254000" cy="254000"/></a:xfrm><a:prstGeom prst="rect"/></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r>"#;
let body: String = (0..n).map(|i| drawing.replace("{i}", &i.to_string())).collect();
let doc = format!(r#"<w:document {NS}><w:body><w:p>{body}</w:p><w:sectPr/></w:body></w:document>"#);
let mode = if external { r#" TargetMode="External""# } else { "" };
let image = |i: usize| {
let target = if external { format!("http://127.0.0.1:9/never-fetched{i}.png") } else { format!("media/gone{i}.png") };
format!(
r#"<Relationship Id="rIdP{i}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="{target}"{mode}/>"#
)
};
let rels = format!(
r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">{}</Relationships>"#,
(0..n).map(image).collect::<String>()
);
let types = r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>"#;
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, bytes) in [
("[Content_Types].xml", types.as_bytes()),
("_rels/.rels", ROOT_RELS.as_bytes()),
("word/document.xml", doc.as_bytes()),
("word/_rels/document.xml.rels", rels.as_bytes()),
] {
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
zw.write_all(bytes).unwrap();
}
zw.finish().unwrap().into_inner()
}
/// A picture the chunk's own reader dropped is the file's too (#6005), named by the chunk part.
#[test]
fn a_picture_dropped_inside_a_chunk_is_recorded_against_the_chunk() {
for (external, reason) in [(false, wordcraft_doc::MissingPictureReason::MissingPart), (true, wordcraft_doc::MissingPictureReason::ExternalTarget)]
{
let inner = inner_with_dropped_picture(external);
let bytes = package(&format!("{}{CHUNK}", p("Before")), Some(("docx", DOCX_MAIN, &inner)));
let d = read(&bytes).unwrap();
let got: Vec<(&str, &str, _)> = d.missing_pictures.list.iter().map(|m| (m.part.as_str(), m.story.as_str(), m.reason)).collect();
assert_eq!(got.len(), 1, "{got:?}");
assert_eq!(got[0].1, "word/afchunk.docx > word/document.xml");
assert_eq!(got[0].2, reason);
}
}
/// A chunk with more dropped pictures than the list holds still counts them all (#6005).
#[test]
fn a_chunks_total_of_dropped_pictures_counts_past_the_list() {
let n = wordcraft_doc::MAX_MISSING_PICTURES + 44;
let inner = inner_with_dropped_pictures(n, false);
let d = read(&package(&format!("{}{CHUNK}", p("Before")), Some(("docx", DOCX_MAIN, &inner)))).unwrap();
assert_eq!(d.missing_pictures.list.len(), wordcraft_doc::MAX_MISSING_PICTURES);
assert!(d.missing_pictures.truncated);
assert_eq!(d.missing_pictures.total, n as u64);
}
/// The same chunk imported twice counts its unlisted pictures once, as it dedupes its listed ones.
#[test]
fn a_chunk_imported_twice_counts_its_dropped_pictures_past_the_list_once() {
let n = wordcraft_doc::MAX_MISSING_PICTURES + 44;
let inner = inner_with_dropped_pictures(n, false);
let d = read(&package(&format!("{}{CHUNK}{CHUNK}", p("Before")), Some(("docx", DOCX_MAIN, &inner)))).unwrap();
assert_eq!(d.missing_pictures.list.len(), wordcraft_doc::MAX_MISSING_PICTURES);
assert_eq!(d.missing_pictures.total, n as u64);
}
-54
View File
@@ -342,57 +342,3 @@ fn toc_gallery_around_a_toc_whose_heading_is_in_a_block_control_round_trips() {
assert!(xml.rfind("</w:sdt>").unwrap() < xml.rfind(">Chapter One<").unwrap(), "pass {pass}: {xml}");
}
}
#[test]
fn word_toc_gallery_with_field_end_after_entries_round_trips_without_update() {
let style = |s: &str, inner: &str| format!(r#"<w:p><w:pPr><w:pStyle w:val="{s}"/></w:pPr>{inner}</w:p>"#);
let begin = r#"<w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> TOC \o "1-3" \h \z \u </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r>"#;
let content = format!(
"{}{}{}<w:p><w:r><w:fldChar w:fldCharType=\"end\"/></w:r></w:p>",
style("TOCHeading", &t("Contents")),
style("TOC1", &format!("{begin}{}", t("Chapter One\t1"))),
style("TOC2", &t("Section\t2"))
);
let pr = r#"<w:id w:val="-1234567"/><w:docPartObj><w:docPartGallery w:val="Table of Contents"/><w:docPartUnique/></w:docPartObj>"#;
let mut bytes = package(&format!("{}{}", sdt(pr, &content), p(&t("Chapter One"))));
for pass in 0..2 {
let d = read(&bytes).unwrap();
bytes = write(&d).unwrap();
let xml = document_xml(&bytes);
assert_eq!(xml.matches("<w:sdt>").count(), 1, "pass {pass}: {xml}");
assert!(xml.contains(r#"<w:docPartGallery w:val="Table of Contents"/>"#), "pass {pass}: {xml}");
assert!(xml.find("</w:sdt>").unwrap() < xml.rfind(">Chapter One<").unwrap(), "pass {pass}: {xml}");
}
}
#[test]
fn range_toc_without_control_stays_unwrapped_and_section_break_toc_control_hands_over() {
let style = |s: &str, inner: &str| format!(r#"<w:p><w:pPr><w:pStyle w:val="{s}"/></w:pPr>{inner}</w:p>"#);
let begin = r#"<w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> TOC \o "1-3" \h \z \u </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r>"#;
// A range TOC that was never in a content control (with FieldEnd in its own paragraph) must
// not gain a Table of Contents w:sdt on save.
let bare = format!(
"{}{}{}<w:p><w:r><w:fldChar w:fldCharType=\"end\"/></w:r></w:p>{}",
style("TOCHeading", &t("Contents")),
style("TOC1", &format!("{begin}{}", t("Chapter One\t1"))),
style("TOC2", &t("Section\t2")),
p(&t("Chapter One")),
);
let d_bare = read(&package(&bare)).unwrap();
let xml_bare = document_xml(&write(&d_bare).unwrap());
assert_eq!(xml_bare.matches("<w:sdt>").count(), 0, "{xml_bare}");
// #3889: a Table of Contents control opening on an empty paragraph that ends a section above
// the entries hands its control over on read so Update Table reuses the section-break paragraph.
let sect_toc = format!(
r#"<w:p><w:pPr><w:sectPr><w:pgSz w:w="11906" w:h="16838"/></w:sectPr></w:pPr></w:p>{}{}<w:p><w:r><w:fldChar w:fldCharType="end"/></w:r></w:p>"#,
style("TOC1", &format!("{begin}{}", t("Chapter One\t1"))),
style("TOC2", &t("Section\t2")),
);
let pr = r#"<w:id w:val="-1234567"/><w:docPartObj><w:docPartGallery w:val="Table of Contents"/><w:docPartUnique/></w:docPartObj>"#;
let d_sect = read(&package(&format!("{}{}", sdt(pr, &sect_toc), p(&t("Chapter One"))))).unwrap();
assert!(controls(&d_sect).is_empty());
assert!(d_sect.body[0].as_para().unwrap().section.is_some());
let xml_sect = document_xml(&write(&d_sect).unwrap());
assert!(xml_sect.contains("Chapter One") && xml_sect.contains("Section"), "{xml_sect}");
}
-84
View File
@@ -3947,33 +3947,6 @@ fn bidi_theme_slots_follow_the_theme_font_language() {
}
}
/// A `w:rFonts` fallback list longer than the reader keeps is cut at the last complete family
/// within the bound, so the family a real list would pick survives while a hostile list cannot
/// cost its whole length in every piece of text drawn in it.
#[test]
fn a_hostile_fallback_list_keeps_its_leading_families() {
let candidates: Vec<String> = (0..500).map(|i| format!("MissingCandidateFont{i}")).collect();
let list = format!("{};Source Serif 4", candidates.join(";"));
let body = format!(r#"<w:p><w:r><w:rPr><w:rFonts w:ascii="{list}" w:hAnsi="{list}"/></w:rPr><w:t>x</w:t></w:r></w:p>"#);
let d = read_body(&body);
let c = &d.body[0].as_para().unwrap().runs[0].props;
let kept = c.font.as_deref().expect("the leading families are kept");
assert!(kept.len() <= 256, "{} bytes kept", kept.len());
assert!(kept.starts_with("MissingCandidateFont0"), "{kept}");
// Every kept entry is a whole family name: the cut lands on a `;`, never inside a name.
assert!(kept.split(';').all(|f| f.starts_with("MissingCandidateFont") || f.contains("Source Serif")), "{kept}");
assert!(!kept.ends_with(';'), "no dangling separator: {kept}");
}
/// A real LibreOffice fallback list is read whole: a few families, well under any bound.
#[test]
fn a_real_fallback_list_is_read_whole() {
let body = r#"<w:p><w:r><w:rPr><w:rFonts w:ascii="Liberation Sans;Arial" w:hAnsi="Liberation Sans;Arial"/></w:rPr><w:t>x</w:t></w:r></w:p>"#;
let d = read_body(body);
let c = &d.body[0].as_para().unwrap().runs[0].props;
assert_eq!(c.font.as_deref(), Some("Liberation Sans;Arial"));
}
/// The font table's family classes: Word draws a missing roman font in a serif face.
#[test]
fn roman_fonts_are_read_from_the_font_table() {
@@ -3983,15 +3956,6 @@ fn roman_fonts_are_read_from_the_font_table() {
assert_eq!(d.settings.roman_fonts, ["Brand Serif"]);
}
/// Semicolon-separated roman font fallback lists in `fontTable.xml` register each candidate family.
#[test]
fn roman_fonts_split_fallback_lists() {
let fonts = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:fonts xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:font w:name="Liberation Serif;Times New Roman"><w:family w:val="roman"/></w:font></w:fonts>"#;
let rels = [("rId9", "http://schemas.openxmlformats.org/officeDocument/2006/relationships/fontTable", "fontTable.xml")];
let d = wordcraft_docx::read(&docx("<w:p/>", &rels, &[("word/fontTable.xml", fonts)])).unwrap();
assert_eq!(d.settings.roman_fonts, ["Liberation Serif", "Times New Roman"]);
}
/// `w:doNotUseHTMLParagraphAutoSpacing` in the settings' `w:compat`: paragraph spacing adds up.
#[test]
fn html_auto_spacing_off_is_read_and_kept() {
@@ -4390,54 +4354,6 @@ fn zero_effects_stay_zero_rotated() {
}
}
/// A turned picture whose file keeps no room for the turn (no or a zero `wp:effectExtent`) is
/// marked so its line keeps to its frame as in Word, and saves that way; one with the room keeps
/// its rotated bounds.
#[test]
fn bare_turns_are_kept_through_a_save() {
let pic = |effects: &str| {
format!(
r#"<w:p><w:r><w:drawing><wp:inline><wp:extent cx="1524000" cy="762000"/>{effects}<wp:docPr id="1" name="Synthetic picture"/><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:blipFill><a:blip r:embed="rId1"/></pic:blipFill><pic:spPr><a:xfrm rot="2700000"><a:off x="0" y="0"/><a:ext cx="1524000" cy="762000"/></a:xfrm></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r></w:p>"#
)
};
let read =
|body: &str| wordcraft_docx::read(&docx(body, &[("rId1", "image", "media/marker.png")], &[("word/media/marker.png", "synthetic")])).unwrap();
let bare = |d: &Document| paras(d)[0].objects[0].frame().unwrap().2.bare_turn;
for effects in ["", r#"<wp:effectExtent l="0" t="0" r="0" b="0"/>"#] {
let d = read(&pic(effects));
assert!(bare(&d), "{effects:?}");
let out = wordcraft_docx::write(&d).unwrap();
let body = text(&unzip(&out), "word/document.xml");
assert_eq!(attr_values(&body, "wp:effectExtent", "t"), ["0"]);
assert!(bare(&wordcraft_docx::read(&out).unwrap()));
}
// 120 × 60 pt at 45°: Word's rotated-bounds room, 3.64 pt wider and 33.64 pt taller each side.
let d = read(&pic(r#"<wp:effectExtent l="46210" t="427210" r="46210" b="427210"/>"#));
assert!(!bare(&d));
let mut d = read(&pic(""));
let f = first_object(&mut d).float_mut().unwrap();
f.set_spin(wordcraft_geom::Spin::new(30.0, false, false));
assert!(!f.bare_turn, "turned here: the rotated bounds again");
// A shadow's room added later: saved, and so laid out, with the turn's room again.
let mut d = read(&pic(""));
let f = first_object(&mut d).float_mut().unwrap();
f.effect = [2.0; 4];
assert!(!f.is_bare_turn());
let out = wordcraft_docx::write(&d).unwrap();
assert_ne!(attr_values(&text(&unzip(&out), "word/document.xml"), "wp:effectExtent", "t"), ["0"]);
// Made floating: saved with the turn's room, so it opens again as an ordinary turn.
let mut d = read(&pic(""));
let f = first_object(&mut d).float_mut().unwrap();
f.wrap = wordcraft_doc::para::Wrap::Square;
assert!(!f.is_bare_turn());
let out = wordcraft_docx::write(&d).unwrap();
assert_ne!(attr_values(&text(&unzip(&out), "word/document.xml"), "wp:effectExtent", "t"), ["0"]);
assert!(!bare(&wordcraft_docx::read(&out).unwrap()));
// Only inline ones: a floating one keeps text clear of its rotated bounds as before.
let anchored = pic("").replace("<wp:inline>", "<wp:anchor>").replace("</wp:inline>", "</wp:anchor>");
assert!(!bare(&read(&anchored)));
}
/// The math namespace, declared on the math element itself (the fixture root doesn't).
const M_NS: &str = r#"xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math""#;
-20
View File
@@ -560,23 +560,3 @@ fn a_private_save_keeps_character_references_in_attribute_values_as_written() {
let again = wordcraft_docx::read(&saved).unwrap();
assert_eq!(again.glossary.first().map(|e| e.description.as_str()), Some(before.as_str()));
}
/// A picture in a building block that names a part the package lacks is recorded against the
/// glossary part, not the main document (#6005).
#[test]
fn a_missing_picture_in_a_building_block_names_the_glossary_part() {
let drawing = r#"<w:r><w:drawing><wp:inline xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"><wp:extent cx="254000" cy="254000"/><wp:docPr id="1" name="P"/><a:graphic xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:nvPicPr><pic:cNvPr id="0" name="p"/><pic:cNvPicPr/></pic:nvPicPr><pic:blipFill><a:blip r:embed="rIdPic"/></pic:blipFill><pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="254000" cy="254000"/></a:xfrm><a:prstGeom prst="rect"/></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r>"#;
let part =
format!(r#"<w:docPart><w:docPartPr><w:name w:val="Logo"/></w:docPartPr><w:docPartBody><w:p>{drawing}</w:p></w:docPartBody></w:docPart>"#);
let bytes = package(
&[("rId7", GLOSSARY_REL, "glossary/document.xml")],
&[
("[Content_Types].xml", content_types(&[("/word/glossary/document.xml", GLOSSARY_CT)])),
("word/glossary/document.xml", glossary_xml(&part)),
("word/glossary/_rels/document.xml.rels", rels(&[("rIdPic", &format!("{REL}/image"), "media/gone.png")])),
],
);
let d = wordcraft_docx::read(&bytes).unwrap();
let stories: Vec<&str> = d.missing_pictures.list.iter().map(|m| m.story.as_str()).collect();
assert_eq!(stories, ["word/glossary/document.xml"], "{:?}", d.missing_pictures.list);
}
-31
View File
@@ -1,31 +0,0 @@
//! Line numbering (`w:lnNumType`, ECMA-376 Part 1 §17.6.8) and the built-in "line number"
//! character style that formats the numbers (#6018).
use std::sync::Arc;
use wordcraft_doc::section::{Columns, LineNumberRestart, LineNumbering};
use wordcraft_doc::{CharProps, Document, Rgb, Style, StyleKind, TextColor};
/// Every line-numbering option and a localised "line number" style (German Word calls its id
/// `Zeilennummer`) survive a save and reopen, beside two columns.
#[test]
fn line_numbering_options_and_style_round_trip() {
let mut d = Document::from_text("one\ntwo\nthree");
let numbering = LineNumbering { count_by: 5, start: 3, distance: 21.6, restart: LineNumberRestart::Continuous };
d.last_section.line_numbers = Some(numbering.clone());
d.last_section.columns = Columns { count: 2, space: 36.0, separator: false, widths: Vec::new() };
let red = TextColor::Rgb(Rgb(0xC0, 0, 0));
Arc::make_mut(&mut d.styles).upsert(Style {
id: "Zeilennummer".into(),
name: "Line Number".into(),
kind: StyleKind::Character,
chr: CharProps { size: Some(8.0), color: Some(red), italic: Some(true), ..Default::default() },
..Default::default()
});
let back = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
assert_eq!(back.last_section.line_numbers, Some(numbering));
assert_eq!(back.last_section.columns.count, 2);
let st = back.styles.find("line number").expect("the line number style");
assert_eq!(st.id, "Zeilennummer");
assert_eq!((st.chr.size, st.chr.color, st.chr.italic), (Some(8.0), Some(red), Some(true)));
}
-4
View File
@@ -20,10 +20,7 @@ mod glossary;
mod heading_levels;
mod legacy_forms;
mod ligatures;
mod line_numbering;
mod malformed;
mod missing_pictures;
mod nested_block_controls;
mod note_comments;
mod passthrough;
mod picture_links;
@@ -42,7 +39,6 @@ mod strict_drawings;
mod text_alignment;
mod text_boxes;
mod toc_ranges;
mod twips_wrap;
mod undefined_style;
mod vml_shapes;
mod word_xml;
-163
View File
@@ -1,163 +0,0 @@
//! Pictures dropped at open are recorded for strict export (#6005): a missing part, an external
//! target and a link-only picture (ECMA-376 Part 1 §20.1.8.13 `a:blip` `r:embed`/`r:link`).
//! Original synthetic packages; nothing is fetched, the reader has no network code.
use std::io::Write;
use wordcraft_doc::{Document, InlineObject, MissingPictureReason};
const NS: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture" xmlns:v="urn:schemas-microsoft-com:vml""#;
const REL: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
fn png() -> Vec<u8> {
let mut out = Vec::new();
image::RgbaImage::from_pixel(2, 2, image::Rgba([20, 90, 200, 255]))
.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png)
.unwrap();
out
}
fn blip(attrs: &str) -> String {
format!(
r#"<w:r><w:drawing><wp:inline><wp:extent cx="254000" cy="254000"/><wp:docPr id="1" name="P"/><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:nvPicPr><pic:cNvPr id="0" name="p"/><pic:cNvPicPr/></pic:nvPicPr><pic:blipFill><a:blip {attrs}/></pic:blipFill><pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="254000" cy="254000"/></a:xfrm><a:prstGeom prst="rect"/></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r>"#
)
}
/// A package: `body` in `word/document.xml` (whose relationships are `doc_rels`), an optional
/// header with `header` as its content (relationships `header_rels`) and `extra` parts.
fn package(body: &str, doc_rels: &str, header: Option<(&str, &str)>, extra: &[(&str, Vec<u8>)]) -> Vec<u8> {
let hdr_rel = if header.is_some() { format!(r#"<Relationship Id="rIdH" Type="{REL}/header" Target="header2.xml"/>"#) } else { String::new() };
let sect = if header.is_some() { r#"<w:sectPr><w:headerReference w:type="default" r:id="rIdH"/></w:sectPr>"# } else { "<w:sectPr/>" };
let doc = format!(r#"<w:document {NS}><w:body><w:p>{body}</w:p>{sect}</w:body></w:document>"#);
let rels = format!(
r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="{REL}/officeDocument" Target="word/document.xml"/></Relationships>"#
);
let drels = format!(r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">{doc_rels}{hdr_rel}</Relationships>"#);
let types = r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="png" ContentType="image/png"/><Default Extension="bin" ContentType="application/octet-stream"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>"#;
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let mut put = |name: &str, bytes: &[u8]| {
zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
zip.write_all(bytes).unwrap();
};
put("[Content_Types].xml", types.as_bytes());
put("_rels/.rels", rels.as_bytes());
put("word/document.xml", doc.as_bytes());
put("word/_rels/document.xml.rels", drels.as_bytes());
if let Some((content, hrels)) = header {
put("word/header2.xml", format!(r#"<w:hdr {NS}><w:p>{content}</w:p></w:hdr>"#).as_bytes());
put(
"word/_rels/header2.xml.rels",
format!(r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">{hrels}</Relationships>"#).as_bytes(),
);
}
for (n, b) in extra {
put(n, b);
}
zip.finish().unwrap().into_inner()
}
fn image_rel(id: &str, target: &str, external: bool) -> String {
let mode = if external { r#" TargetMode="External""# } else { "" };
format!(r#"<Relationship Id="{id}" Type="{REL}/image" Target="{target}"{mode}/>"#)
}
fn missing(d: &Document) -> Vec<(String, String, MissingPictureReason)> {
d.missing_pictures.list.iter().map(|m| (m.part.clone(), m.story.clone(), m.reason)).collect()
}
fn pictures(d: &Document) -> usize {
d.body[0].as_para().unwrap().objects.iter().filter(|o| matches!(o, InlineObject::Image { .. })).count()
}
#[test]
fn external_missing_and_link_only_pictures_are_recorded_and_still_dropped() {
let body = [blip(r#"r:embed="rIdExt""#), blip(r#"r:embed="rIdGone""#), blip(r#"r:link="rIdLink""#), blip(r#"r:embed="rIdNone""#)].concat();
let rels = [
image_rel("rIdExt", "http://127.0.0.1:9/never-fetched.png", true),
image_rel("rIdGone", "media/absent.png", false),
image_rel("rIdLink", "file:///C:/pics/linked.png", true),
]
.concat();
let d = wordcraft_docx::read(&package(&body, &rels, None, &[])).unwrap();
assert_eq!(pictures(&d), 0, "nothing is drawn for them, as before");
assert_eq!(
missing(&d),
[
("http://127.0.0.1:9/never-fetched.png".into(), "word/document.xml".into(), MissingPictureReason::ExternalTarget),
("word/media/absent.png".into(), "word/document.xml".into(), MissingPictureReason::MissingPart),
("file:///C:/pics/linked.png".into(), "word/document.xml".into(), MissingPictureReason::LinkOnly),
("rIdNone".into(), "word/document.xml".into(), MissingPictureReason::MissingPart),
]
);
assert!(!d.missing_pictures.truncated);
}
#[test]
fn a_header_pictures_story_is_the_header_part() {
let rels = [image_rel("rIdHx", "https://example.invalid/logo.png", true), image_rel("rIdOk", "media/ok.png", false)].concat();
let header = [blip(r#"r:embed="rIdHx""#), blip(r#"r:embed="rIdOk""#)].concat();
let bytes = package("", "", Some((&header, &rels)), &[("word/media/ok.png", png())]);
let d = wordcraft_docx::read(&bytes).unwrap();
assert_eq!(missing(&d), [("https://example.invalid/logo.png".into(), "word/header2.xml".into(), MissingPictureReason::ExternalTarget)]);
assert_eq!(d.missing_pictures.main, "word/document.xml");
assert!(d.missing_pictures.part_names.values().any(|n| &**n == "word/header2.xml"));
assert_eq!(d.missing_pictures.media_parts.get("ok.png").map(String::as_str), Some("word/media/ok.png"));
}
#[test]
fn a_picture_that_is_there_is_not_recorded() {
let body = blip(r#"r:embed="rIdOk""#);
let d = wordcraft_docx::read(&package(&body, &image_rel("rIdOk", "media/ok.png", false), None, &[("word/media/ok.png", png())])).unwrap();
assert_eq!(pictures(&d), 1);
assert!(d.missing_pictures.list.is_empty());
}
#[test]
fn vml_image_data_links_are_recorded() {
let pict =
|attrs: &str| format!(r#"<w:r><w:pict><v:shape id="s" style="width:50pt;height:50pt"><v:imagedata {attrs}/></v:shape></w:pict></w:r>"#);
let body = [pict(r#"r:id="rIdExt""#), pict(r#"r:href="rIdLink""#), pict(r#"r:id="rIdGone""#)].concat();
let rels = [
image_rel("rIdExt", "https://example.invalid/a.png", true),
image_rel("rIdLink", "https://example.invalid/b.png", true),
image_rel("rIdGone", "media/none.png", false),
]
.concat();
let d = wordcraft_docx::read(&package(&body, &rels, None, &[])).unwrap();
let got: Vec<_> = missing(&d).into_iter().map(|(p, _, r)| (p, r)).collect();
assert_eq!(
got,
[
("https://example.invalid/a.png".to_string(), MissingPictureReason::ExternalTarget),
("https://example.invalid/b.png".to_string(), MissingPictureReason::LinkOnly),
("word/media/none.png".to_string(), MissingPictureReason::MissingPart),
]
);
}
#[test]
fn the_list_is_capped_and_the_document_is_still_equal() {
let n = wordcraft_doc::MAX_MISSING_PICTURES + 5;
let body: String = (0..n).map(|i| blip(&format!(r#"r:embed="r{i}""#))).collect();
let rels: String = (0..n).map(|i| image_rel(&format!("r{i}"), &format!("media/gone{i}.png"), false)).collect();
let d = wordcraft_docx::read(&package(&body, &rels, None, &[])).unwrap();
assert_eq!(d.missing_pictures.list.len(), wordcraft_doc::MAX_MISSING_PICTURES);
assert!(d.missing_pictures.truncated);
// Not part of the document: `==` and the clone ignore it, and a save doesn't carry it.
let mut e = d.clone();
e.missing_pictures = Default::default();
assert_eq!(d, e);
assert!(!serde_json::to_string(&d).unwrap().contains("gone"));
}
/// Every note of a part shares the one allocation of its source name, so a file with many notes
/// in a part with a long name does not keep a copy of the name per note.
#[test]
fn the_notes_of_a_part_share_one_copy_of_its_name() {
let notes: String = (1..=3).map(|i| format!(r#"<w:footnote w:id="{i}"><w:p><w:r><w:t>n{i}</w:t></w:r></w:p></w:footnote>"#)).collect();
let rels = format!(r#"<Relationship Id="rIdF" Type="{REL}/footnotes" Target="footnotes.xml"/>"#);
let part = format!(r#"<w:footnotes {NS}>{notes}</w:footnotes>"#);
let d = wordcraft_docx::read(&package("x", &rels, None, &[("word/footnotes.xml", part.into_bytes())])).unwrap();
let names: Vec<_> = d.missing_pictures.part_names.values().collect();
assert!(names.len() >= 3, "{names:?}");
assert!(names.iter().all(|n| &***n == "word/footnotes.xml"));
assert!(names.windows(2).all(|w| std::sync::Arc::ptr_eq(w[0], w[1])), "each note holds its own copy of the part name");
}
@@ -1,180 +0,0 @@
//! A body-level content control nested directly in another body-level one stays body-level when
//! saved, even when it holds a single paragraph (ECMA-376 §17.5.2.29-30; [MS-DOCX] `w15`
//! repeating sections, which must wrap whole paragraphs or rows). All markup here is synthetic.
use std::io::Write;
use wordcraft_doc::{ContentControl, Document, StoryRef};
use wordcraft_docx::{read, write};
const NS: &str =
r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:w15="http://schemas.microsoft.com/office/word/2012/wordml""#;
const ROOT_RELS: &str = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
fn package(body: &str) -> Vec<u8> {
let doc = format!(r#"<w:document {NS}><w:body>{body}</w:body></w:document>"#);
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, bytes) in [("_rels/.rels", ROOT_RELS.as_bytes()), ("word/document.xml", doc.as_bytes())] {
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
zw.write_all(bytes).unwrap();
}
zw.finish().unwrap().into_inner()
}
fn document_xml(docx: &[u8]) -> String {
let mut z = zip::ZipArchive::new(std::io::Cursor::new(docx)).unwrap();
let mut s = String::new();
std::io::Read::read_to_string(&mut z.by_name("word/document.xml").unwrap(), &mut s).unwrap();
s
}
fn t(s: &str) -> String {
format!(r#"<w:r><w:t xml:space="preserve">{s}</w:t></w:r>"#)
}
fn p(inner: &str) -> String {
format!("<w:p>{inner}</w:p>")
}
fn sdt(pr: &str, content: &str) -> String {
format!("<w:sdt><w:sdtPr>{pr}</w:sdtPr><w:sdtContent>{content}</w:sdtContent></w:sdt>")
}
fn controls(d: &Document) -> Vec<ContentControl> {
let mut out = Vec::new();
wordcraft_doc::control::walk_controls(&d.body, &mut |c| out.push(c.clone()));
out
}
/// How many `w:p` elements are open at byte `at` of `xml`.
fn open_paragraphs(xml: &str, at: usize) -> usize {
let head = &xml[..at];
let opened = head.matches("<w:p>").count() + head.matches("<w:p ").count();
opened - head.matches("</w:p>").count()
}
/// Paragraph depth at the start of the `w:sdt` whose `w:sdtPr` holds `marker`.
fn depth_of_control(xml: &str, marker: &str) -> usize {
let at = xml.find(marker).unwrap_or_else(|| panic!("{marker} not written:\n{xml}"));
let start = xml[..at].rfind("<w:sdt>").expect("control start");
open_paragraphs(xml, start)
}
fn round_trip(body: &str) -> (Document, Document, String) {
let d = read(&package(body)).unwrap();
let bytes = write(&d).unwrap();
let back = read(&bytes).unwrap();
(d, back, document_xml(&bytes))
}
fn repeating_section() -> String {
let field = sdt(r#"<w:alias w:val="note"/><w:id w:val="3"/><w:text/>"#, &t("note 1"));
let item = sdt(r#"<w:alias w:val="item"/><w:id w:val="2"/><w15:repeatingSectionItem/>"#, &p(&format!("{field}{}", t(" "))));
let section = sdt(r#"<w:alias w:val="notes"/><w:id w:val="1"/><w15:repeatingSection/>"#, &item);
format!("{}{section}{}", p(&t("Notes")), p(&t("End")))
}
#[test]
fn a_repeating_section_around_one_paragraph_stays_around_the_paragraph() {
let (_, _, xml) = round_trip(&repeating_section());
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="notes"/>"#), 0, "section written inside a paragraph:\n{xml}");
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="item"/>"#), 0, "item written inside a paragraph:\n{xml}");
// The text control stays inside the paragraph, and the item holds that paragraph.
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="note"/>"#), 1);
assert!(xml.contains("<w15:repeatingSectionItem/></w:sdtPr><w:sdtContent><w:p"), "{xml}");
}
#[test]
fn a_repeating_section_round_trips_its_controls_and_text() {
let (d, back, _) = round_trip(&repeating_section());
assert_eq!(controls(&back), controls(&d));
assert_eq!(back.plain_text(StoryRef::Body), d.plain_text(StoryRef::Body));
assert_eq!(back.body.len(), d.body.len());
}
#[test]
fn plain_nested_block_controls_around_one_paragraph_stay_block_level() {
let inner = sdt(r#"<w:alias w:val="inner"/><w:id w:val="11"/>"#, &p(&t("Only paragraph")));
let outer = sdt(r#"<w:alias w:val="outer"/><w:id w:val="10"/>"#, &inner);
let (d, back, xml) = round_trip(&format!("{outer}{}", p(&t("After"))));
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="outer"/>"#), 0, "{xml}");
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="inner"/>"#), 0, "{xml}");
assert_eq!(controls(&back), controls(&d));
}
#[test]
fn nested_block_controls_in_a_table_cell_stay_block_level() {
let inner = sdt(r#"<w:alias w:val="cellInner"/><w:id w:val="21"/>"#, &p(&t("In cell")));
let outer = sdt(r#"<w:alias w:val="cellOuter"/><w:id w:val="20"/>"#, &inner);
let table = format!(r#"<w:tbl><w:tblGrid><w:gridCol w:w="4000"/></w:tblGrid><w:tr><w:tc>{outer}</w:tc></w:tr></w:tbl>"#);
let (_, _, xml) = round_trip(&format!("{table}{}", p(&t("After"))));
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="cellOuter"/>"#), 0, "{xml}");
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="cellInner"/>"#), 0, "{xml}");
}
#[test]
fn a_run_level_control_stays_inside_its_paragraph() {
let field = sdt(r#"<w:alias w:val="inline"/><w:id w:val="30"/>"#, &t("value"));
let (_, _, xml) = round_trip(&p(&format!("{}{field}", t("Name: "))));
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="inline"/>"#), 1, "{xml}");
}
/// A bound repeating section as Word writes one at the end of a form, optionally followed by
/// the body-level `_GoBack` bookmark Word leaves after the last control.
fn bound_section_body(bound: bool, bookmark: bool) -> String {
let section_binding = if bound {
r#"<w15:dataBinding w:prefixMappings="" w:xpath="/a[1]/n[1]" w:storeItemID="{00000000-0000-0000-0000-0000000000AA}"/>"#
} else {
""
};
let trailing = if bookmark { r#"<w:bookmarkStart w:id="0" w:name="_GoBack"/><w:bookmarkEnd w:id="0"/>"# } else { "" };
let bind = |x: &str| format!(r#"<w:dataBinding w:prefixMappings="" w:xpath="{x}" w:storeItemID="{{00000000-0000-0000-0000-0000000000AA}}"/>"#);
let field = format!(
r#"<w:sdt><w:sdtPr><w:alias w:val="note"/><w:placeholder><w:docPart w:val="DefaultPlaceholder_1"/></w:placeholder>{}<w:text/></w:sdtPr><w:sdtEndPr/><w:sdtContent>{}</w:sdtContent></w:sdt>"#,
if bound { bind("/a[1]/n[1]") } else { String::new() },
t("note 1")
);
let item = format!(
r#"<w:sdt><w:sdtPr><w:alias w:val="item"/><w:placeholder><w:docPart w:val="DefaultPlaceholder_2"/></w:placeholder><w15:repeatingSectionItem/></w:sdtPr><w:sdtEndPr/><w:sdtContent>{}</w:sdtContent></w:sdt>"#,
p(&format!("{field}{}", t(" ")))
);
let section = format!(
r#"<w:sdt><w:sdtPr><w:alias w:val="notes"/>{section_binding}<w15:repeatingSection/></w:sdtPr><w:sdtContent>{item}</w:sdtContent></w:sdt>"#
);
format!(r#"{}{section}{trailing}<w:sectPr><w:pgSz w:w="12240" w:h="15840"/></w:sectPr>"#, p(&t("Notes")))
}
fn assert_section_block_level(body: &str) {
let (d, back, xml) = round_trip(body);
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="notes"/>"#), 0, "section written inside a paragraph:\n{xml}");
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="item"/>"#), 0, "item written inside a paragraph:\n{xml}");
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="note"/>"#), 1, "{xml}");
assert_eq!(controls(&back), controls(&d));
assert_eq!(back.plain_text(StoryRef::Body), d.plain_text(StoryRef::Body));
}
#[test]
fn a_bound_repeating_section_followed_by_a_body_bookmark_stays_block_level() {
assert_section_block_level(&bound_section_body(true, true));
}
#[test]
fn a_bound_repeating_section_at_the_end_of_the_body_stays_block_level() {
assert_section_block_level(&bound_section_body(true, false));
}
#[test]
fn a_repeating_section_followed_by_a_body_bookmark_stays_block_level() {
assert_section_block_level(&bound_section_body(false, true));
}
#[test]
fn a_block_control_followed_by_a_body_bookmark_stays_around_its_paragraph() {
let inner = sdt(r#"<w:alias w:val="inner"/><w:id w:val="41"/>"#, &p(&t("Last paragraph")));
let outer = sdt(r#"<w:alias w:val="outer"/><w:id w:val="40"/>"#, &inner);
let body = format!(r#"{}{outer}<w:bookmarkStart w:id="0" w:name="_GoBack"/><w:bookmarkEnd w:id="0"/>"#, p(&t("First")));
let (d, back, xml) = round_trip(&body);
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="outer"/>"#), 0, "{xml}");
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="inner"/>"#), 0, "{xml}");
assert_eq!(controls(&back), controls(&d));
// The bookmark is kept.
assert!(xml.contains(r#"w:name="_GoBack""#), "{xml}");
}
+2 -77
View File
@@ -6,8 +6,8 @@ use wordcraft_doc::fill::Fill;
use wordcraft_doc::numbering::ListKind;
use wordcraft_doc::para::{Anchor, Float, FloatAlign, NoteKind, ShapeKind, Wrap};
use wordcraft_doc::props::{
Align, Border, BorderStyle, Borders, CharProps, HeightRule, Highlight, LineSpacing, NumRef, ParaProps, ParagraphTextDirection, Rgb, RowProps,
TabAlign, TabLeader, TabStop, TableLook, TextAlignment, TextColor, TextDirection, Underline, VAlign, VMerge, VertAlign,
Align, Border, BorderStyle, Borders, CharProps, HeightRule, Highlight, LineSpacing, NumRef, ParaProps, Rgb, RowProps, TabAlign, TabLeader,
TabStop, TableLook, TextAlignment, TextColor, TextDirection, Underline, VAlign, VMerge, VertAlign,
};
use wordcraft_doc::section::{Columns, DocGrid, DocGridType, LineNumberRestart, LineNumbering, NumFormat, PageVAlign, SectionProps, SectionStart};
use wordcraft_doc::styles::{Style, StyleKind};
@@ -195,7 +195,6 @@ fn every_para_prop_round_trips() {
suppress_hyphens: Some(true),
suppress_line_numbers: Some(true),
bidi: Some(false),
text_direction: Some(ParagraphTextDirection::TbRl),
drop_cap: None,
drop_cap_margin: None,
drop_cap_distance: None,
@@ -994,80 +993,6 @@ fn tracked_changes_round_trip() {
assert!(r.settings.track_changes);
}
#[test]
fn tracked_equations_round_trip() {
// Issue #6011: equations saved with tracked revision marks (w:ins / w:del) around math.
let mut d = Document::new();
d.revisions.push(Revision { kind: RevisionKind::Insert, author: "Alice".into(), date: "2026-05-01T10:00:00Z".into(), move_name: None });
d.revisions.push(Revision { kind: RevisionKind::Delete, author: "Bob".into(), date: "2026-05-02T10:00:00Z".into(), move_name: None });
d.settings.track_changes = true;
let ins = CharProps { ins: Some(0), ..Default::default() };
let del = CharProps { del: Some(1), ..Default::default() };
let plain = CharProps::default();
let mut p = Paragraph::with_text("Formula: ", CharProps::default());
let end = p.len();
p.insert_object(end, InlineObject::Equation { linear: "x+1".into(), display: false, math: Default::default() }, &ins).unwrap();
let end = p.len();
p.insert_object(end, InlineObject::Equation { linear: "E=mc^2".into(), display: true, math: Default::default() }, &del).unwrap();
let end = p.len();
p.insert_object(end, InlineObject::Equation { linear: "a^2+b^2=c^2".into(), display: true, math: Default::default() }, &ins).unwrap();
let end = p.len();
p.insert_object(end, InlineObject::Equation { linear: "y=mx+b".into(), display: false, math: Default::default() }, &del).unwrap();
let end = p.len();
p.insert_object(end, InlineObject::Equation { linear: "z=1".into(), display: false, math: Default::default() }, &plain).unwrap();
d.body = vec![para_block(p.clone())];
let bytes = wordcraft_docx::write(&d).expect("write");
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
let mut xml = String::new();
std::io::Read::read_to_string(&mut zip.by_name("word/document.xml").unwrap(), &mut xml).unwrap();
assert!(xml.contains(r#"<w:ins w:id="1" w:author="Alice" w:date="2026-05-01T10:00:00Z"><m:oMath>"#), "ins directly encloses inline math: {xml}");
assert!(
xml.contains(r#"<w:del w:id="2" w:author="Bob" w:date="2026-05-02T10:00:00Z"><m:oMathPara>"#),
"del directly encloses display math: {xml}"
);
assert!(
xml.contains(r#"<w:ins w:id="3" w:author="Alice" w:date="2026-05-01T10:00:00Z"><m:oMathPara>"#),
"ins directly encloses display math: {xml}"
);
assert!(xml.contains(r#"<w:del w:id="4" w:author="Bob" w:date="2026-05-02T10:00:00Z"><m:oMath>"#), "del directly encloses inline math: {xml}");
assert!(xml.contains(r#"</w:del><m:oMath>"#), "plain equation follows closing tag without revision wrapper: {xml}");
let r = wordcraft_docx::read(&bytes).expect("read");
assert_eq!(r.revisions, d.revisions);
let got = &paras(&r)[0];
assert_eq!(got.objects.len(), 5);
let eq_summary: Vec<(bool, String)> = got
.objects
.iter()
.map(|o| match o {
InlineObject::Equation { display, linear, .. } => (*display, linear.clone()),
_ => panic!("expected equation object, got {o:?}"),
})
.collect();
assert_eq!(
eq_summary,
vec![(false, "x+1".into()), (true, "E=mc^2".into()), (true, "a^2+b^2=c^2".into()), (false, "y=mx+b".into()), (false, "z=1".into()),]
);
assert_eq!(got.runs, p.runs);
}
#[test]
fn nested_omath_para_revisions_read() {
// Defensive reader check: w:ins and w:del nested inside m:oMathPara.
let mut d = Document::new();
d.revisions.push(Revision { kind: RevisionKind::Insert, author: "Ada".into(), date: "2026-05-01T10:00:00Z".into(), move_name: None });
d.revisions.push(Revision { kind: RevisionKind::Delete, author: "Charles".into(), date: "2026-05-02T10:00:00Z".into(), move_name: None });
let doc_xml = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math"><w:body><w:p><m:oMathPara><w:ins w:id="1" w:author="Ada" w:date="2026-05-01T10:00:00Z"><m:oMath><m:r><m:t>x+1</m:t></m:r></m:oMath></w:ins><w:del w:id="2" w:author="Charles" w:date="2026-05-02T10:00:00Z"><m:oMath><m:r><m:t>y-1</m:t></m:r></m:oMath></w:del></m:oMathPara></w:p></w:body></w:document>"#;
let base = wordcraft_docx::write(&d).unwrap();
let bytes = replace_part(&base, "word/document.xml", doc_xml.as_bytes());
let r = wordcraft_docx::read(&bytes).expect("read");
let got = &paras(&r)[0];
assert_eq!(got.objects.len(), 2);
assert!(matches!(&got.objects[0], InlineObject::Equation { display: true, linear, .. } if linear == "x+1"));
assert!(matches!(&got.objects[1], InlineObject::Equation { display: true, linear, .. } if linear == "y-1"));
assert_eq!(got.runs[0].props.ins, Some(0));
assert_eq!(got.runs[1].props.del, Some(1));
}
#[test]
fn tracked_paragraph_marks_round_trip() {
// Issue #229: an inserted / deleted paragraph mark is `w:ins` / `w:del` in the mark's
@@ -18,25 +18,6 @@ fn docx(body: &str) -> Vec<u8> {
zw.finish().unwrap().into_inner()
}
fn docx_with_styles(body: &str, styles: &str) -> Vec<u8> {
let mut bytes = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let root = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
let document_rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdStyles" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>"#;
let doc = format!(
r#"<?xml version="1.0"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>{body}</w:body></w:document>"#
);
for (name, value) in [
("_rels/.rels", root.as_bytes()),
("word/_rels/document.xml.rels", document_rels.as_bytes()),
("word/document.xml", doc.as_bytes()),
("word/styles.xml", styles.as_bytes()),
] {
bytes.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
bytes.write_all(value).unwrap();
}
bytes.finish().unwrap().into_inner()
}
fn document_xml(bytes: &[u8]) -> String {
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut s = String::new();
@@ -78,52 +59,3 @@ fn explicit_horizontal_direction_is_kept_in_sections_and_cells() {
let plain = wordcraft_docx::read(&docx(r#"<w:p><w:r><w:t>Body</w:t></w:r></w:p><w:sectPr/>"#)).unwrap();
assert!(!document_xml(&wordcraft_docx::write(&plain).unwrap()).contains("textDirection"));
}
#[test]
fn paragraph_text_direction_round_trips_through_two_saves_and_json() {
use wordcraft_doc::props::ParaProps;
use wordcraft_doc::props::ParagraphTextDirection;
let values = ["lrTb", "lrTbV", "tb", "tbRl", "tbRlV", "tbLr", "tbLrV", "btLr", "tbV", "rl", "rlV", "lr", "lrV"];
let mut body: String =
values.iter().map(|value| format!(r#"<w:p><w:pPr><w:textDirection w:val="{value}"/></w:pPr><w:r><w:t>{value}</w:t></w:r></w:p>"#)).collect();
body.push_str(r#"<w:p><w:r><w:t>Inherited</w:t></w:r></w:p>"#);
let d = wordcraft_docx::read(&docx(&body)).unwrap();
let paras: Vec<_> = d.body.iter().filter_map(|b| b.as_para()).collect();
for (para, value) in paras.iter().zip(values) {
assert_eq!(para.props.text_direction, ParagraphTextDirection::from_ooxml(value), "{value}");
}
assert_eq!(paras[values.len()].props.text_direction, None);
let json = serde_json::to_value(&paras[0].props).unwrap();
assert_eq!(json["textDirection"], "lrTb");
let decoded: ParaProps = serde_json::from_value(json).unwrap();
assert_eq!(decoded.text_direction, Some(ParagraphTextDirection::LrTb));
let first = wordcraft_docx::write(&d).unwrap();
let xml = document_xml(&first);
for value in values {
assert!(xml.contains(&format!(r#"<w:textDirection w:val="{value}"/>"#)), "{xml}");
}
assert_eq!(xml.matches("w:textDirection").count(), values.len(), "inherited direction stays absent: {xml}");
let second = wordcraft_docx::write(&wordcraft_docx::read(&first).unwrap()).unwrap();
let twice = document_xml(&second);
assert_eq!(twice.matches("w:textDirection").count(), values.len(), "{twice}");
for value in values {
assert!(twice.contains(&format!(r#"<w:textDirection w:val="{value}"/>"#)), "{twice}");
}
}
#[test]
fn paragraph_style_text_direction_is_preserved_and_direct_value_overrides_it() {
let styles = r#"<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:style w:type="paragraph" w:styleId="Vertical"><w:name w:val="Vertical"/><w:pPr><w:textDirection w:val="tbRl"/></w:pPr></w:style></w:styles>"#;
let body = r#"<w:p><w:pPr><w:pStyle w:val="Vertical"/></w:pPr><w:r><w:t>Style</w:t></w:r></w:p><w:p><w:pPr><w:pStyle w:val="Vertical"/><w:textDirection w:val="lrTb"/></w:pPr><w:r><w:t>Override</w:t></w:r></w:p>"#;
let d = wordcraft_docx::read(&docx_with_styles(body, styles)).unwrap();
let xml = document_xml(&wordcraft_docx::write(&d).unwrap());
assert_eq!(xml.matches(r#"<w:pStyle w:val="Vertical"/>"#).count(), 2, "both paragraphs keep their style: {xml}");
assert!(xml.contains(r#"<w:pStyle w:val="Vertical"/><w:textDirection w:val="lrTb"/>"#), "direct horizontal is explicit: {xml}");
assert_eq!(xml.matches("w:textDirection").count(), 1, "only the direct paragraph writes a direction: {xml}");
let mut z = zip::ZipArchive::new(std::io::Cursor::new(wordcraft_docx::write(&d).unwrap())).unwrap();
let mut styles_xml = String::new();
z.by_name("word/styles.xml").unwrap().read_to_string(&mut styles_xml).unwrap();
assert!(styles_xml.contains(r#"<w:textDirection w:val="tbRl"/>"#), "style value survives: {styles_xml}");
}
+1 -12
View File
@@ -132,15 +132,6 @@ fn vml_text_boxes_keep_their_inset_fit_and_anchor() {
shape("", r#"o:insetmode="auto" inset="0,0,0,0""#),
// In a group, a member's margins go with that member's text, not an empty box's before it.
format!(r#"<w:p><w:r><w:pict><v:group style="width:100pt;height:40pt"><v:shape><v:textbox inset="9pt,9pt,9pt,9pt"/></v:shape><v:shape><v:textbox inset="0,0,0,0">{content}</v:textbox></v:shape></v:group></w:pict></w:r></w:p>"#),
// Text direction (#5193), as Word for Windows reads it: `vertical` and
// `vertical-ideographic` are `vert`; a `bottom-to-top` alt is `vert270` with any
// `layout-flow` or none; `horizontal-ideographic` stays horizontal.
shape("", r#"style="layout-flow:vertical""#),
shape("", r#"style="layout-flow:vertical;mso-layout-flow-alt:bottom-to-top;mso-next-textbox:#Text Box 4""#),
shape("", r#"style="layout-flow:vertical-ideographic""#),
shape("", r#"style="mso-layout-flow-alt:bottom-to-top""#),
shape("", r#"style="layout-flow:horizontal;mso-layout-flow-alt:bottom-to-top""#),
shape("", r#"style="layout-flow:horizontal-ideographic""#),
]
.concat();
let doc = format!(
@@ -149,15 +140,13 @@ fn vml_text_boxes_keep_their_inset_fit_and_anchor() {
let rels = br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
let d = wordcraft_docx::read(&zip(&[("_rels/.rels", rels), ("word/document.xml", doc.as_bytes())])).unwrap();
let bodies: Vec<TextBody> = boxes(&d).iter().map(|id| d.parts[id].body).collect();
assert_eq!(bodies.len(), 12);
assert_eq!(bodies.len(), 6);
assert_eq!(bodies[0].inset, Some([0, 0, 0, 0]));
assert_eq!((bodies[1].inset, bodies[1].auto_fit, bodies[1].anchor), (Some([36_000, 45_720, 25_400, 45_720]), true, VAlign::Center));
assert_eq!(bodies[2], TextBody::default());
assert_eq!((bodies[3].inset, bodies[3].anchor), (Some([12_700, 45_720, 0, wordcraft_doc::MAX_TEXT_BOX_INSET]), VAlign::Bottom));
assert_eq!(bodies[4].inset, None);
assert_eq!(bodies[5].inset, Some([0, 0, 0, 0]));
let verts: Vec<TextVert> = bodies[6..].iter().map(|b| b.vert).collect();
assert_eq!(verts, [TextVert::Vert, TextVert::Vert270, TextVert::Vert, TextVert::Vert270, TextVert::Vert270, TextVert::Horz]);
// Saved and read again, they stay.
let back = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
let again: Vec<TextBody> = boxes(&back).iter().map(|id| back.parts[id].body).collect();
-55
View File
@@ -1,55 +0,0 @@
//! Paragraph indents and spacing written as plain twips beyond 16 bits: Word keeps only the low
//! 16 bits (ECMA-376 Part 1 §17.3.1.12 `w:ind`, §17.3.1.33 `w:spacing`). Measured in Word: a
//! `w:left` of 66256 starts the text 36 pt in, a `w:right` of 66976 leaves a 72 pt right indent.
//! Fixtures are hand-written.
use wordcraft_doc::Document;
use wordcraft_doc::props::ParaProps;
use crate::fixtures::zip;
/// A package whose body is `body`.
fn docx(body: &str) -> Vec<u8> {
let root = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
let doc = format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>{body}</w:body></w:document>"#
);
zip(&[("_rels/.rels", root.as_bytes()), ("word/document.xml", doc.as_bytes())])
}
fn props(body: &str) -> Vec<ParaProps> {
let d: Document = wordcraft_docx::read(&docx(body)).unwrap();
d.body.iter().filter_map(|b| b.as_para()).map(|p| p.props.clone()).collect()
}
#[test]
fn indents_beyond_16_bits_wrap() {
let p = props(
r#"<w:p><w:pPr><w:ind w:left="66256"/></w:pPr><w:r><w:t>a</w:t></w:r></w:p>
<w:p><w:pPr><w:ind w:right="66976"/></w:pPr><w:r><w:t>b</w:t></w:r></w:p>
<w:p><w:pPr><w:ind w:start="65556" w:end="-65516"/></w:pPr><w:r><w:t>c</w:t></w:r></w:p>
<w:p><w:pPr><w:ind w:left="98304" w:hanging="65896"/></w:pPr><w:r><w:t>d</w:t></w:r></w:p>"#,
);
assert_eq!(p[0].indent_left, Some(36.0));
assert_eq!(p[1].indent_right, Some(72.0));
// 65556 -> 20 twips; -65516 -> 20 twips.
assert_eq!((p[2].indent_left, p[2].indent_right), (Some(1.0), Some(1.0)));
// 98304 = 0x18000 -> 0x8000 = -32768 as a signed 16-bit value; hanging 65896 -> 360.
assert_eq!((p[3].indent_left, p[3].indent_first), (Some(-1638.4), Some(-18.0)));
}
#[test]
fn spacing_beyond_16_bits_wraps() {
let p = props(r#"<w:p><w:pPr><w:spacing w:before="65537" w:after="65776"/></w:pPr><w:r><w:t>a</w:t></w:r></w:p>"#);
assert_eq!((p[0].space_before, p[0].space_after), (Some(0.05), Some(12.0)));
}
#[test]
fn in_range_and_unit_values_are_unchanged() {
let p = props(
r#"<w:p><w:pPr><w:ind w:left="32767" w:right="-32768"/><w:spacing w:before="65535"/></w:pPr><w:r><w:t>a</w:t></w:r></w:p>
<w:p><w:pPr><w:ind w:left="1in" w:right="720"/><w:spacing w:before="12pt"/></w:pPr><w:r><w:t>b</w:t></w:r></w:p>"#,
);
assert_eq!((p[0].indent_left, p[0].indent_right, p[0].space_before), (Some(1638.35), Some(-1638.4), Some(3276.75)));
assert_eq!((p[1].indent_left, p[1].indent_right, p[1].space_before), (Some(72.0), Some(36.0), Some(12.0)));
}
-9
View File
@@ -270,12 +270,3 @@ fn flat_opc_limits_count_what_is_not_kept() {
// A part nothing reads, one element over what the XML tree reader takes in a part (8 000 000).
assert!(limited(&format!(r#"<pkg:part pkg:name="/word/unused.xml"><pkg:xmlData><r>{}</r></pkg:xmlData></pkg:part>"#, "<y/>".repeat(8_000_000))));
}
/// A Word 2003 XML Document saved with Windows line ends: XML reads CR LF as one line feed (XML
/// 1.0 §2.11), so a line break inside `w:t` is one space, not two.
#[test]
fn word_2003_xml_with_windows_line_ends_reads_one_space_per_line_break() {
let xml = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\r\n<w:wordDocument xmlns:w=\"http://schemas.microsoft.com/office/word/2003/wordml\">\r\n<w:body><w:p><w:r><w:t>a\r\nb</w:t></w:r></w:p></w:body></w:wordDocument>\r\n";
let d = read_xml(xml.as_bytes()).unwrap();
assert_eq!(paras(&d)[0].plain_text(), "a b");
}
-5
View File
@@ -51,11 +51,6 @@ chrono = { workspace = true, features = ["clock"] }
# O_NONBLOCK, so a path swapped for a named pipe can't block `open` (#3666).
libc = { workspace = true }
# A plain fsync where a file system refuses the full flush (`sync_file`, safe_save.rs).
# Already in Cargo.lock on Apple targets (craft-atomic), so this adds no new crate.
[target.'cfg(target_vendor = "apple")'.dependencies]
rustix = { version = "1.1", default-features = false, features = ["std", "fs"] }
[dev-dependencies]
wordcraft-doc = { workspace = true, features = ["test-support"] }
encoding_rs = { workspace = true }
+4 -12
View File
@@ -347,24 +347,19 @@ pub(crate) struct InsertEffect {
/// Insert typed text at the caret (replacing the selection), with pending formatting and track changes.
pub fn type_text(s: &mut Session, text: &str) -> Result<(), CmdError> {
insert_text(s, text, true, None).map(|_| ())
insert_text(s, text, true).map(|_| ())
}
/// [`type_text`] with the effect the insertion had.
pub(crate) fn type_text_effect(s: &mut Session, text: &str) -> Result<InsertEffect, CmdError> {
insert_text(s, text, true, None)
insert_text(s, text, true)
}
/// Insert pasted plain text like [`type_text`], except that its paragraph separators are text
/// being transferred, not Enter key presses: an empty list item between them stays (Enter on
/// one would end the list).
pub fn paste_text(s: &mut Session, text: &str) -> Result<(), CmdError> {
insert_text(s, text, false, None).map(|_| ())
}
/// Insert typed text in a chosen language without losing caret formatting or tracked changes.
pub fn type_text_with_lang(s: &mut Session, text: &str, language: Option<&str>) -> Result<(), CmdError> {
insert_text(s, text, true, language).map(|_| ())
insert_text(s, text, false).map(|_| ())
}
/// Inserts typed or pasted text at the caret: resolves field ends and content-control placeholders,
@@ -373,7 +368,7 @@ pub fn type_text_with_lang(s: &mut Session, text: &str, language: Option<&str>)
/// text; a one-line control suppresses typed breaks only. It gives an otherwise undirected paragraph
/// right-to-left direction when its first strong letter is RTL, and returns whether content or state
/// changed.
fn insert_text(s: &mut Session, text: &str, enter: bool, language: Option<&str>) -> Result<InsertEffect, CmdError> {
fn insert_text(s: &mut Session, text: &str, enter: bool) -> Result<InsertEffect, CmdError> {
let mut props = s.typing_props();
if s.sel.is_collapsed() {
s.sel = crate::Selection::caret(s.doc.past_field_ends(&s.sel.focus));
@@ -388,9 +383,6 @@ fn insert_text(s: &mut Session, text: &str, enter: bool, language: Option<&str>)
if !enter {
props = props.without_symbol();
}
if let Some(language) = language {
props.lang = Some(language.to_string());
}
let deletion = delete_selection_effect(s)?;
// Typed text is new: never deleted, and not a formatting change of the text around it.
props.fmt_change = None;
+6 -46
View File
@@ -94,9 +94,9 @@ pub fn specs() -> Vec<CommandSpec> {
.pure(),
CommandSpec::new("file.save", "Save", "File", save)
.key("Mod+S / Shift+F12 / Alt+Shift+F2")
.params(r#"{"path"?: string, "password"?: string (encrypt this and later saves with it, as file.encrypt does), "markup"?: bool (Print Markup for a PDF, PNG, HTML, Markdown or text copy: comments shown; default file.printMarkup), "format"?: "wordml2003" (an .xml file as a Word 2003 XML Document) | "flatopc" (as a Word XML Document, the default; saving over a Word 2003 XML file keeps that format), "strict"?: bool (a .pdf or .png only: refuse, writing nothing, if a character can't be drawn or a picture is missing; see file.strictReport)}"#)
.params(r#"{"path"?: string, "password"?: string (encrypt this and later saves with it, as file.encrypt does), "markup"?: bool (Print Markup for a PDF, PNG, HTML, Markdown or text copy: comments shown; default file.printMarkup), "format"?: "wordml2003" (an .xml file as a Word 2003 XML Document) | "flatopc" (as a Word XML Document, the default; saving over a Word 2003 XML file keeps that format)}"#)
.pure(),
CommandSpec::new("file.saveAs", "Save As", "File", save_as).key(super::mac_or("F12 / Mod+Shift+S", "F12 / Alt+F2")).params(r#"{"path": string, "password"?: string, "markup"?: bool, "format"?: string (as file.save), "strict"?: bool (as file.save)}"#).pure(),
CommandSpec::new("file.saveAs", "Save As", "File", save_as).key(super::mac_or("F12 / Mod+Shift+S", "F12 / Alt+F2")).params(r#"{"path": string, "password"?: string, "markup"?: bool, "format"?: string (as file.save)}"#).pure(),
// Pure: the password must not land in Repeat or a recorded macro, and it isn't an edit
// to undo; it marks the document as changed itself.
CommandSpec::new("file.encrypt", "Encrypt with Password", "File › Info › Protect Document", encrypt)
@@ -114,13 +114,10 @@ pub fn specs() -> Vec<CommandSpec> {
crate::io::save_path_with(std::path::Path::new(&path), &s.doc, None, &opts).map_err(CmdError::Failed)?;
Ok(json!({"path": path, "markup": opts.markup}))
})
.params(r#"{"path": string, "markup"?: bool (Print Markup: tracked changes marked up and each page scaled down beside the comments in balloons; default file.printMarkup), "strict"?: bool (refuse, writing nothing, if a character can't be drawn or a picture is missing; see file.strictReport)}"#)
.params(r#"{"path": string, "markup"?: bool (Print Markup: tracked changes marked up and each page scaled down beside the comments in balloons; default file.printMarkup)}"#)
.pure(),
CommandSpec::new("file.exportPng", "Export Page as PNG", "File › Export", export_png)
.params(r#"{"path": string, "page"?: n (1-based), "scale"?: px per pt, "markup"?: bool (the page with its comments in balloons beside it; default file.printMarkup), "strict"?: bool (refuse, writing nothing, if a character on the page can't be drawn or a picture is missing; see file.strictReport)}"#)
.pure(),
CommandSpec::new("file.strictReport", "Strict Export Report", "File › Export", strict_report)
.params(r#"{"markup"?: bool (as file.exportPdf), "page"?: n (1-based: check that page of the page image file.exportPng writes; omit for every page of a PDF)} → {"schema": 1, "strict": "passed" | "failed", "missing_glyphs": [{"cp": "U+2D30", "count": n}], "missing_pictures": [{"part", "source_part": the story part it is in (word/header2.xml; background, body, header, footer, text box or "" where unknown), "reason": "missing_part" | "external_target" | "link_only" | "undecodable" | "unsupported_format"}], "truncated": bool (either list was cut at 256 entries), "total_missing_glyphs": n (distinct, past the cap too), "total_missing_pictures": n (past the cap too), "glyphs_truncated": bool, "pictures_truncated": bool}"#)
.params(r#"{"path": string, "page"?: n (1-based), "scale"?: px per pt, "markup"?: bool (the page with its comments in balloons beside it; default file.printMarkup)}"#)
.pure(),
// A print setting, like Word's: not part of the document, so no undo step.
CommandSpec::new("file.printMarkup", "Print Markup", "File › Print", |s, v| {
@@ -372,7 +369,6 @@ fn save(s: &mut Session, v: &Value) -> CmdResult {
return Ok(json!({"saved": false}));
}
};
check_strict_output(p::bool(v, "strict").unwrap_or(false), &path.to_string_lossy())?;
if let Some(pw) = v.get("password").filter(|pw| !pw.is_null()) {
let Some(pw) = pw.as_str().filter(|pw| !pw.is_empty()) else {
return Err(CmdError::Params("`password` must be a non-empty string (file.encrypt removes a password)".into()));
@@ -454,39 +450,9 @@ pub fn export_options(s: &Session, v: &Value) -> crate::io::ExportOptions {
comments: markup && s.prefs.markup.comments,
word2003: p::str(v, "format") == Some(FORMAT_WORD_2003),
numeral: s.view.numeral.resolve(crate::session::system_locale()),
strict: p::bool(v, "strict").unwrap_or(false),
}
}
/// `strict: true` is for a PDF or a page image only: any other output is a parameter error.
fn check_strict_output(strict: bool, path: &str) -> Result<(), CmdError> {
if strict && !crate::strict::is_strict_output(path) {
return Err(CmdError::Params("`strict` applies to a .pdf or .png output only".into()));
}
Ok(())
}
/// The strict report of a PDF export of the document (every page), or with `page` of the page
/// image `file.exportPng` writes. Pure: it reads the document and writes nothing.
fn strict_report(s: &mut Session, v: &Value) -> CmdResult {
let opts = export_options(s, v);
let report = match p::u64(v, "page") {
Some(n) => {
let page = crate::p::page_index(n);
let l = s.export_layout_with(opts.markup);
if l.pages.get(page).is_none() {
return Err(CmdError::Params(format!("no page {}", page + 1)));
}
crate::strict::strict_report(&s.doc, &l, &crate::strict::png_display(), Some(page..=page), wordcraft_layout::StrictOutput::Png)
}
None => {
let out = super::references::output_fields(&s.doc);
crate::strict::pdf_report(out.as_ref().unwrap_or(&s.doc), &opts)
}
};
Ok(report.to_json())
}
fn export_png(s: &mut Session, v: &Value) -> CmdResult {
// No path: the app asks where to save, as file.exportPdf does (#2313).
let Some(path) = p::str(v, "path") else {
@@ -494,22 +460,16 @@ fn export_png(s: &mut Session, v: &Value) -> CmdResult {
return Ok(json!({"exported": false}));
};
let page = crate::p::page_index(p::u64(v, "page").unwrap_or(1));
let strict = p::bool(v, "strict").unwrap_or(false);
let scale = p::f32(v, "scale").unwrap_or(2.0).clamp(0.1, 8.0);
let l = s.export_layout_with(export_options(s, v).markup);
let pg = l.pages.get(page).ok_or_else(|| CmdError::Params(format!("no page {}", page + 1)))?;
// A page image is the printed page: headers and footers at full strength. Dimming them is
// the on-screen cue while editing the body, not part of the page.
let opts = wordcraft_render::RenderOptions { display: crate::strict::png_display(), ..Default::default() };
let mut opts = wordcraft_render::RenderOptions::default();
opts.display.dim_header = false;
if !path.to_ascii_lowercase().ends_with(".png") {
return Err(CmdError::Params("path must end in .png".into()));
}
if strict {
let report = crate::strict::strict_report(&s.doc, &l, &opts.display, Some(page..=page), wordcraft_layout::StrictOutput::Png);
if !report.passed() {
return Err(CmdError::Failed(report.refusal()));
}
}
let img = wordcraft_render::render_page(&s.doc, pg, scale, &opts);
let png = img.to_png();
crate::io::write_file(path, &png).map_err(CmdError::Failed)?;
-26
View File
@@ -212,32 +212,6 @@ pub(crate) fn record(s: &mut Session, id: &str, before: &Document) {
}
}
/// Builds the tracked paragraph-property change for a command that also changes the paragraph's
/// text. The ordinary after-command recorder deliberately compares only paragraphs with identical
/// text, so mixed text/paragraph-format transformations use this before assigning their props.
/// Made once per command, before it creates revisions: it keeps the revisions as they were then
/// (all `Recorder::own` needs), instead of copying the list for every paragraph.
pub(crate) struct ParaPropTracker {
before: Vec<Revision>,
}
impl ParaPropTracker {
pub(crate) fn new(s: &Session) -> Self {
let tracking = s.doc.settings.track_changes && s.prefs.markup.track_formatting;
ParaPropTracker { before: if tracking { s.doc.revisions.clone() } else { Vec::new() } }
}
pub(crate) fn changed(&self, s: &mut Session, before: &ParaProps, after: &ParaProps) -> Option<Box<PropChange<ParaProps>>> {
if !s.doc.settings.track_changes || !s.prefs.markup.track_formatting {
return after.fmt_change.clone();
}
let mut r = Recorder { author: s.author.clone(), date: super::now_iso(), before: &self.before, revisions: &mut s.doc.revisions, rid: None };
r.change(before, after, ParaProps::formatting, |p| &p.fmt_change)
.map(|Replacement(change)| change)
.unwrap_or_else(|| after.fmt_change.clone())
}
}
/// Record the paragraph formatting a command changed on the paragraph at `path` whose text it also
/// changed, which [`record`] doesn't compare: `before` is the paragraph's properties before the
/// command. As in [`record`], the author's own earlier change keeps the formatting before it and
+3 -6
View File
@@ -5,7 +5,7 @@ use wordcraft_doc::para::{Float, InlineObject, ShapeKind, Wrap};
use wordcraft_doc::props::{Align, Border, Borders, CharProps, Rgb, TabAlign, TabStop, TextColor};
use wordcraft_doc::{Block, Paragraph, PartKind, Path, Pos, StoryRef, Table, para_block};
use super::{delete_selection, sel_result, split_para, type_text, type_text_with_lang};
use super::{delete_selection, sel_result, split_para, type_text};
use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
mod header_footer;
@@ -15,9 +15,7 @@ mod objects;
pub(crate) use header_footer::saved_copy;
use header_footer::{create_hf, edit_hf, header_footer, new_empty_headers, strip_headers};
pub use items::{
DropCapState, default_date_picture, drop_cap_state, format_date, format_date_at, format_date_in, format_date_parts, format_date_parts_in,
};
pub use items::{DropCapState, drop_cap_state, format_date, format_date_at, format_date_parts};
use items::{cover_page, date_time, drop_cap, drop_cap_off, field, page_number, symbol};
use links::{bookmark, delete_bookmark, link, link_targets};
pub use links::{bookmark_name_ok, bookmark_range, bookmark_span};
@@ -25,7 +23,6 @@ pub use objects::{base64_decode, base64_encode};
use objects::{icon, shape, table, text_box};
pub(crate) use objects::{image_ext, picture};
/// Command specifications for the Insert ribbon tab and menu.
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("insert.pageBreak", "Page Break", "Insert › Pages", |s, _| {
@@ -116,7 +113,7 @@ pub fn specs() -> Vec<CommandSpec> {
s.doc.last_section.footers = Default::default();
sel_result(s)
}),
CommandSpec::new("insert.dateTime", "Date & Time", "Insert › Text", date_time).params(r#"{"format"?: "M/d/yyyy", "update"?: bool, "language"?: "en-US|pt-BR"}"#),
CommandSpec::new("insert.dateTime", "Date & Time", "Insert › Text", date_time).params(r#"{"format"?: "M/d/yyyy", "update"?: bool}"#),
CommandSpec::new("insert.symbol", "Symbol", "Insert › Symbols", symbol).params(r#"{"char": string, "font"?: family (the symbol's own font)}"#),
CommandSpec::new("insert.field", "Field", "Insert › Text › Quick Parts", field).key("Mod+F9").params(r#"{"instr": string, "result"?: string}"#),
// Word's Alt+Shift+D / T / P (Control+Shift+… in Word for Mac): DATE, TIME and PAGE fields.
+32 -283
View File
@@ -1,72 +1,8 @@
use super::PartKind;
use super::header_footer::hf_part;
use super::{
Align, Block, CharProps, CmdError, CmdResult, InlineObject, Paragraph, Rgb, Selection, Session, StoryRef, TabAlign, TabStop, TextColor, Value,
delete_selection, json, p, para_block, sel_result, type_text, type_text_with_lang,
delete_selection, json, p, para_block, sel_result, type_text,
};
use std::collections::BTreeSet;
fn story_has_page_field(doc: &wordcraft_doc::Document, story: StoryRef, except: Option<usize>) -> bool {
let Some(blocks) = doc.story(story) else { return false };
let mut seen = BTreeSet::from([story]);
let mut pending: Vec<&Block> = blocks.iter().enumerate().filter(|(i, _)| Some(*i) != except).map(|(_, b)| &**b).collect();
while let Some(block) = pending.pop() {
let para = match block {
Block::Para(para) => para,
Block::Table(table) => {
pending.extend(table.rows.iter().flat_map(|r| &r.cells).flat_map(|c| &c.blocks).map(|b| &**b));
continue;
}
};
for object in &para.objects {
if matches!(object, InlineObject::Field { instr, .. } | InlineObject::FieldStart { instr, .. } if instr.split_whitespace().next().is_some_and(|name| name.eq_ignore_ascii_case("PAGE")))
{
return true;
}
for id in object.text_boxes() {
if let Some(part) = doc.parts.get(&id).filter(|part| part.kind == PartKind::TextBox)
&& seen.insert(StoryRef::Part(id))
{
pending.extend(part.blocks.iter().map(|b| &**b));
}
}
}
}
false
}
fn page_number_paragraph(style: &str, align: Align, x_of_y: bool) -> Result<Paragraph, CmdError> {
let field = InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false, dirty: false, code: false, form: String::new() };
let mut para = Paragraph::new().styled(style);
para.props.align = Some(align);
para.props.tabs = Some(vec![TabStop { pos: 0.0, align: TabAlign::Clear, leader: Default::default() }]);
if x_of_y {
para.insert_text(0, "Page of ", &CharProps::default())?;
para.insert_object(5, field, &CharProps { bold: Some(true), ..Default::default() })?;
let end = para.len();
para.insert_object(
end,
InlineObject::Field { instr: "NUMPAGES".into(), result: "1".into(), locked: false, dirty: false, code: false, form: String::new() },
&CharProps { bold: Some(true), ..Default::default() },
)?;
} else {
para.insert_object(0, field, &CharProps::default())?;
}
Ok(para)
}
// Only cached display text is transferable; instructions, flags and formatting remain part of ownership.
fn copy_page_number_results(to: &mut Paragraph, from: &Paragraph) {
for object in &mut to.objects {
let InlineObject::Field { instr, result, .. } = object else { continue };
if let Some(cached) = from.objects.iter().find_map(|object| match object {
InlineObject::Field { instr: source, result, .. } if source.as_str() == instr.as_str() => Some(result),
_ => None,
}) {
result.clone_from(cached);
}
}
}
pub(super) fn page_number(s: &mut Session, v: &Value) -> CmdResult {
let position = p::str(v, "position").unwrap_or("bottom");
@@ -86,86 +22,30 @@ pub(super) fn page_number(s: &mut Session, v: &Value) -> CmdResult {
}
let header = position == "top";
let id = hf_part(s, header);
let story = StoryRef::Part(id);
let style = if header { "Header" } else { "Footer" };
let mut para = page_number_paragraph(style, align, x_of_y)?;
// Replace only a top-level paragraph matching our generated structure. User text,
// field switches/metadata and paragraph/run formatting must not be mistaken for a gallery number.
// Cached field results and paragraph-mark formatting survive gallery changes and identical requests.
if story_has_page_field(&s.doc, story, None) {
let generated = s.doc.story(story).and_then(|blocks| {
blocks.iter().enumerate().find_map(|(i, block)| {
let Block::Para(existing) = &**block else { return None };
let Some(align @ (Align::Left | Align::Center | Align::Right)) = existing.props.align else { return None };
[false, true]
.into_iter()
.any(|x_of_y| {
page_number_paragraph(style, align, x_of_y).is_ok_and(|mut p| {
if p.objects.len() != existing.objects.len() {
return false;
}
p.mark = existing.mark.clone();
copy_page_number_results(&mut p, existing);
*existing == p
})
})
.then_some((i, existing))
})
});
// More than one PAGE makes ownership ambiguous, including in tables or anchored text
// boxes. The traversal visits each story once so cycles cannot hang insertion.
if let Some((i, existing)) = generated
&& !story_has_page_field(&s.doc, story, Some(i))
{
para.mark = existing.mark.clone();
copy_page_number_results(&mut para, existing);
let replacement = para_block(para);
if s.doc.story(story).and_then(|blocks| blocks.get(i)) != Some(&replacement) {
let block = s.doc.story_mut(story)?.get_mut(i).ok_or_else(|| CmdError::Failed("page-number paragraph is missing".into()))?;
*block = replacement;
}
}
return Ok(json!({"story": id}));
let mut para = Paragraph::new().styled(if header { "Header" } else { "Footer" });
para.props.align = Some(align);
para.props.tabs = Some(vec![TabStop { pos: 0.0, align: TabAlign::Clear, leader: Default::default() }]);
if x_of_y {
para.insert_text(0, "Page of ", &CharProps::default())?;
para.insert_object(5, field, &CharProps { bold: Some(true), ..Default::default() })?;
let end = para.len();
para.insert_object(
end,
InlineObject::Field { instr: "NUMPAGES".into(), result: "1".into(), locked: false, dirty: false, code: false, form: String::new() },
&CharProps { bold: Some(true), ..Default::default() },
)?;
} else {
para.insert_object(0, field, &CharProps::default())?;
}
let blocks = s.doc.story_mut(story)?;
// A DOCX round trip can add paragraph-mark formatting to the otherwise untouched
// Header/Footer placeholder. Reuse it without losing that formatting or adding a blank line.
if let [block] = blocks.as_slice()
&& let Block::Para(existing) = &**block
{
let mut placeholder = Paragraph::new().styled(style);
placeholder.mark = existing.mark.clone();
if *existing == placeholder {
para.mark = existing.mark.clone();
blocks.clear();
}
}
blocks.push(para_block(para));
s.doc.set_story(StoryRef::Part(id), vec![para_block(para)])?;
Ok(json!({"story": id}))
}
/// Insert date and time at the caret or selection, formatted with a Word picture or default format.
pub(super) fn date_time(s: &mut Session, v: &Value) -> CmdResult {
let language = p::str(v, "language");
if language.is_some_and(|l| !matches!(l, "en-US" | "pt-BR")) {
return Err(CmdError::Params("language must be en-US or pt-BR".into()));
}
let mut props = s.typing_props();
if let Some(lang) = language {
props.lang = Some(lang.to_string());
}
// Resolve the same run language used by refresh_date_fields on subsequent updates.
// Resolving language from the selection start ordered().0 is intentional for the fixed-text path
// so the typed text takes the character/paragraph styling where the insertion begins.
let effective_language = s.doc.para_at(&s.sel.ordered().0).and_then(|p| s.doc.styles.resolve_char(p.props.style.as_deref(), &props).lang);
// A missing picture follows the selected or inherited language, just like a DATE field
// without a \\@ switch; an explicitly supplied picture is never overridden.
let fmt = p::str(v, "format").unwrap_or_else(|| default_date_picture(effective_language.as_deref()));
if fmt.is_empty() || fmt.len() > 120 || fmt.contains('"') || fmt.contains('\\') {
return Err(CmdError::Params("invalid date picture".into()));
}
let text = format_date_in(fmt, effective_language.as_deref());
let fmt = p::str(v, "format").unwrap_or("M/d/yyyy");
let text = format_date(fmt);
if p::bool(v, "update").unwrap_or(false) {
let props = s.typing_props();
let at = delete_selection(s)?;
let end = s.doc.insert_object(
&at,
@@ -174,87 +54,32 @@ pub(super) fn date_time(s: &mut Session, v: &Value) -> CmdResult {
)?;
s.sel = Selection::caret(end);
} else {
type_text_with_lang(s, &text, language)?;
type_text(s, &text)?;
}
sel_result(s)
}
/// Format today's date with a Word picture (`M/d/yyyy`, `MMMM d, yyyy`, `yyyy-MM-dd`, `dddd`…),
/// in English.
/// Format today's date with a Word picture (`M/d/yyyy`, `MMMM d, yyyy`, `yyyy-MM-dd`, `dddd`…).
pub fn format_date(fmt: &str) -> String {
format_date_in(fmt, None)
}
/// Format today's date with a Word picture, month and weekday names in the language `lang` (a
/// tag such as `pt-BR`; English when `None` or a language without names here).
pub fn format_date_in(fmt: &str, lang: Option<&str>) -> String {
// Word shows the computer's local date and time (#1707), not UTC's.
let now = crate::cmd::now_unix();
format_date_at_in(fmt, lang, now, crate::cmd::local_offset_secs(now))
format_date_at(fmt, now, crate::cmd::local_offset_secs(now))
}
/// Format the instant `unix` (seconds since the epoch, UTC) with a Word picture, in the zone
/// `offset_secs` east of UTC.
pub fn format_date_at(fmt: &str, unix: u64, offset_secs: i64) -> String {
format_date_at_in(fmt, None, unix, offset_secs)
}
/// Format the instant `unix` (seconds since the epoch, UTC) with a Word picture, in the zone
/// `offset_secs` east of UTC, with language `lang`.
pub fn format_date_at_in(fmt: &str, lang: Option<&str>, unix: u64, offset_secs: i64) -> String {
let (y, m, d, hms, days) = crate::cmd::local_civil(unix, offset_secs);
format_date_parts_in(fmt, lang, y, m as usize, d, hms, days)
}
/// Month and weekday names of a language, as Word writes them in date fields: full names, the
/// three-letter forms (`MMM`, `ddd`) being their first three letters, without a full stop.
/// Weekdays start on Thursday (1970-01-01).
struct DateNames {
/// Full month names (January through December).
months: [&'static str; 12],
/// Weekday names starting on Thursday (1970-01-01).
days: [&'static str; 7],
/// The picture of a `DATE` field without `\@`.
default: &'static str,
}
const ENGLISH: DateNames = DateNames {
months: ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"],
days: ["Thursday", "Friday", "Saturday", "Sunday", "Monday", "Tuesday", "Wednesday"],
default: "M/d/yyyy",
};
/// Portuguese (observed in Word for Mac with a pt-BR document): lower-case names, `MMM` = `out`,
/// `ddd` = `qui`, and `dd/MM/yyyy` for a field without a picture.
const PORTUGUESE: DateNames = DateNames {
months: ["janeiro", "fevereiro", "março", "abril", "maio", "junho", "julho", "agosto", "setembro", "outubro", "novembro", "dezembro"],
days: ["quinta-feira", "sexta-feira", "sábado", "domingo", "segunda-feira", "terça-feira", "quarta-feira"],
default: "dd/MM/yyyy",
};
/// Choose month and weekday names according to the primary language subtag.
fn date_names(lang: Option<&str>) -> &'static DateNames {
let primary = lang.and_then(|l| l.trim().split(['-', '_']).next()).unwrap_or("");
if primary.eq_ignore_ascii_case("pt") { &PORTUGUESE } else { &ENGLISH }
}
/// The picture Word gives a `DATE` field that has no `\@` switch, in the field's language.
pub fn default_date_picture(lang: Option<&str>) -> &'static str {
date_names(lang).default
format_date_parts(fmt, y, m as usize, d, hms, days)
}
/// Format a date and time with a Word picture; `days_since` (days since 1970-01-01) gives the weekday.
pub fn format_date_parts(fmt: &str, y: i64, m: usize, d: u32, hms: (u32, u32, u32), days_since: i64) -> String {
format_date_parts_in(fmt, None, y, m, d, hms, days_since)
}
/// [`format_date_parts`] with month and weekday names in `lang`. Text between single quotes is
/// copied as it is (`'de'`), while doubled apostrophes emit one literal quote anywhere.
#[allow(clippy::too_many_arguments)]
pub fn format_date_parts_in(fmt: &str, lang: Option<&str>, y: i64, m: usize, d: u32, (hh, mm, ss): (u32, u32, u32), days_since: i64) -> String {
let names = date_names(lang);
let month = names.months.get(m.saturating_sub(1)).copied().unwrap_or(names.months[0]);
let day = names.days.get(days_since.rem_euclid(7) as usize).copied().unwrap_or(names.days[0]);
let three = |s: &str| -> String { s.chars().take(3).collect() };
pub fn format_date_parts(fmt: &str, y: i64, m: usize, d: u32, (hh, mm, ss): (u32, u32, u32), days_since: i64) -> String {
const MONTHS: [&str; 12] =
["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
const DAYS: [&str; 7] = ["Thursday", "Friday", "Saturday", "Sunday", "Monday", "Tuesday", "Wednesday"];
let month = MONTHS.get(m.saturating_sub(1)).copied().unwrap_or("January");
let day = DAYS.get(days_since.rem_euclid(7) as usize).copied().unwrap_or("Monday");
let mut out = String::new();
let chars: Vec<char> = fmt.chars().collect();
let mut i = 0;
@@ -296,11 +121,11 @@ pub fn format_date_parts_in(fmt: &str, lang: Option<&str>, y: i64, m: usize, d:
('y' | 'Y', 4..) => out.push_str(&format!("{y:04}")),
('y' | 'Y', _) => out.push_str(&format!("{:02}", y % 100)),
('M', 4..) => out.push_str(month),
('M', 3) => out.push_str(&three(month)),
('M', 3) => out.push_str(month.get(..3).unwrap_or(month)),
('M', 2) => out.push_str(&format!("{m:02}")),
('M', 1) => out.push_str(&m.to_string()),
('d' | 'D', 4..) => out.push_str(day),
('d' | 'D', 3) => out.push_str(&three(day)),
('d' | 'D', 3) => out.push_str(day.get(..3).unwrap_or(day)),
('d' | 'D', 2) => out.push_str(&format!("{d:02}")),
('d' | 'D', 1) => out.push_str(&d.to_string()),
('H', 2..) => out.push_str(&format!("{hh:02}")),
@@ -512,79 +337,3 @@ pub fn drop_cap_state(s: &Session) -> DropCapState {
text_font,
}
}
#[cfg(test)]
mod date_time_locale_tests {
use super::*;
/// A DATE field without an explicit language initially uses the paragraph's and document's
/// inherited pt-BR language, just as the subsequent refresh will.
#[test]
fn date_field_inherits_document_default_locale_on_first_render() {
let mut s = Session::new(wordcraft_doc::Document::new());
std::sync::Arc::make_mut(&mut s.doc.styles).default_chr.lang = Some("pt-BR".into());
s.run(
"insert.dateTime",
&json!({
"format": "d 'de' MMMM 'de' yyyy", "update": true
}),
)
.unwrap();
let path = s.doc.para_paths(StoryRef::Body).remove(0);
let p = s.doc.para(StoryRef::Body, &path).unwrap();
let InlineObject::Field { result, .. } = &p.objects[0] else { panic!("expected DATE field") };
let expected = format_date_in("d 'de' MMMM 'de' yyyy", Some("pt-BR"));
assert_eq!(*result, expected);
assert!(result.contains(" de "));
let before = result.clone();
crate::cmd::references::refresh_date_fields(&mut s.doc);
let p = s.doc.para(StoryRef::Body, &path).unwrap();
let InlineObject::Field { result, .. } = &p.objects[0] else { panic!("expected DATE field") };
assert_eq!(*result, before, "refresh must not switch languages");
}
/// Fixed date text uses the selected pt-BR editing language, without dropping the
/// caret's other character formatting or making the insertion refreshable.
#[test]
fn fixed_date_text_keeps_selected_language() {
let mut s = Session::new(wordcraft_doc::Document::new());
s.run(
"insert.dateTime",
&json!({
"format": "d 'de' MMMM 'de' yyyy", "language": "pt-BR", "update": false
}),
)
.unwrap();
let path = s.doc.para_paths(StoryRef::Body).remove(0);
let p = s.doc.para(StoryRef::Body, &path).unwrap();
assert_eq!(p.props_of_char(0).lang.as_deref(), Some("pt-BR"));
assert_eq!(p.plain_text(), format_date_in("d 'de' MMMM 'de' yyyy", Some("pt-BR")));
assert!(p.objects.iter().all(|o| !matches!(o, InlineObject::Field { .. })));
}
/// Word's doubled apostrophe escapes one literal quote both before and inside a quoted run.
/// The existing three-character abbreviations must preserve accented Portuguese characters.
#[test]
fn word_date_picture_doubled_apostrophes_and_accented_abbreviations() {
let format = |pic, day| format_date_parts_in(pic, Some("pt-BR"), 2026, 10, day, (12, 0, 0), 2);
assert_eq!(format("d''MM", 20), "20'10");
assert_eq!(format("d 'd''água' MMMM", 20), "20 d'água outubro");
assert_eq!(format("ddd", 20), "sáb");
assert_eq!(format("MMM", 20), "out");
assert_eq!(format("MMMM", 20), "outubro");
}
/// A pt-BR dynamic field keeps its result after DOCX round-trip and refresh.
#[test]
fn pt_br_date_time_dialog_inserts_dynamic_field() {
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("insert.dateTime", &json!({"format": "d 'de' MMMM 'de' yyyy", "language": "pt-BR", "update": true})).unwrap();
let bytes = wordcraft_docx::write(&s.doc).unwrap();
let mut doc = wordcraft_docx::read(&bytes).unwrap();
crate::cmd::references::refresh_date_fields(&mut doc);
let paths = doc.para_paths(StoryRef::Body);
let p = doc.para(StoryRef::Body, &paths[0]).unwrap();
let InlineObject::Field { result, .. } = &p.objects[0] else { panic!("expected field") };
assert_eq!(result, &format_date_in("d 'de' MMMM 'de' yyyy", Some("pt-BR")));
}
}
-6
View File
@@ -185,10 +185,6 @@ fn move_object_once(s: &mut Session, pos: &Pos, page: usize, x: f32, y: f32, (w,
if f.wrap == Wrap::Inline {
f.wrap = Wrap::Square;
}
// A drawing anchored to a page is on this one now.
if f.page > 0 {
f.page = u32::try_from(page).map_or(f.page, |p| p.saturating_add(1));
}
f.h_rel = Anchor::Page;
f.v_rel = Anchor::Page;
f.h_align = None;
@@ -203,8 +199,6 @@ fn move_object_once(s: &mut Session, pos: &Pos, page: usize, x: f32, y: f32, (w,
s.touch();
let origin = s.layout().object(&at, page).ok_or_else(|| CmdError::Failed("the object isn't laid out".into()))?.origin;
edit_float(s, &at, |f| {
// On its paragraph now: a page it was anchored to is gone, not only hidden.
f.page = 0;
f.h_rel = Anchor::Column;
f.v_rel = Anchor::Paragraph;
f.x = (x - origin.x).clamp(-MAX_OFFSET, MAX_OFFSET);
+5 -17
View File
@@ -132,26 +132,14 @@ pub fn specs() -> Vec<CommandSpec> {
} else {
return Err(CmdError::Params("`path` or `data` required".into()));
};
let (px_w, px_h) = wordcraft_geom::picture::probe(&bytes)
.map(|p| (p.width, p.height))
.map_err(|e| match e {
wordcraft_geom::picture::Refusal::Unsupported(why) => CmdError::Failed(why.into()),
_ => CmdError::Failed("not a supported image".into()),
})?;
wordcraft_geom::picture::probe(&bytes).map_err(|e| match e {
wordcraft_geom::picture::Refusal::Unsupported(why) => CmdError::Failed(why.into()),
_ => CmdError::Failed("not a supported image".into()),
})?;
let key = s.doc.add_media(bytes.clone(), crate::cmd::insert::image_ext(&bytes));
with_obj(s, |o| {
if let InlineObject::Image { media, w, h, crop, .. } = o {
if let InlineObject::Image { media, .. } = o {
*media = key.clone();
// The replacement keeps the picture's width and takes its own aspect ratio, so a
// picture of another shape isn't stretched into the old frame. The old crop
// described the old picture, so it is dropped.
if px_w > 0 && px_h > 0 && w.is_finite() && *w > 0.0 {
let nh = *w * px_h as f32 / px_w as f32;
if nh.is_finite() && nh > 0.0 {
*h = nh;
}
}
*crop = [0.0; 4];
}
})
})
-37
View File
@@ -77,43 +77,6 @@ fn a_shape_dropped_just_after_a_page_break_in_a_paragraph_stays_there() {
assert!((o.rect.x - 100.0).abs() < 0.5 && (o.rect.y - 80.0).abs() < 0.5, "where it was dropped: {:?}", o.rect);
}
/// Leaf #107: wrapping can leave the anchor alone to the left and put the text on the right
/// of the same physical row. A drop after the break must still stay on the destination page.
#[test]
fn a_picture_with_wrap_spacing_dropped_after_a_leading_break_stays_there() {
for gap in [0.0, 6.0, 12.0] {
let mut s = Session::new(wordcraft_doc::Document::from_text("Source\nDestination"));
s.run("caret.set", &json!({"pos": Pos::body(1, 0)})).unwrap();
s.run("layout.break", &json!({"kind": "page"})).unwrap();
s.run("caret.set", &json!({"pos": Pos::body(0, 0)})).unwrap();
let data = super::super::insert::base64_encode(&png(20, 10));
s.run("insert.picture", &json!({"data": data, "width": 120})).unwrap();
s.run("arrange.wrap", &json!({"wrap": "square"})).unwrap();
super::select::with_float(&mut s, |f| {
f.dist = gap;
f.dist_top = gap;
f.dist_bottom = gap;
})
.unwrap();
let before = serde_json::to_value(&s.doc.body).unwrap();
let depth = s.undo_depth();
s.run("arrange.bounds", &json!({"page": 1, "x": 100, "y": 110})).unwrap();
let hit = object_area(&mut s);
assert_eq!(hit.page, 1, "gap={gap}: {hit:?}");
assert!((hit.rect.x - 100.0).abs() < 0.5 && (hit.rect.y - 110.0).abs() < 0.5, "gap={gap}: {:?}", hit.rect);
assert_eq!(s.undo_depth(), depth + 1);
let after = serde_json::to_value(&s.doc.body).unwrap();
s.run("edit.undo", &json!({})).unwrap();
assert_eq!(serde_json::to_value(&s.doc.body).unwrap(), before);
s.run("edit.redo", &json!({})).unwrap();
assert_eq!(serde_json::to_value(&s.doc.body).unwrap(), after);
let mut copy = Session::new(wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap());
let saved = object_area(&mut copy);
assert_eq!(saved.page, 1, "DOCX gap={gap}: {saved:?}");
assert!((saved.rect.x - hit.rect.x).abs() < 0.5 && (saved.rect.y - hit.rect.y).abs() < 0.5, "saved at the drop position");
}
}
/// What a click and a handle or body drag do in the editor (#142, #82): hit-test the object,
/// select it, then resize and move it with `arrange.bounds`.
fn click_resize_and_drag(insert: &str, params: Value) {
-25
View File
@@ -16,7 +16,6 @@ mod lists;
mod outline;
mod styles;
mod tabs_borders;
mod tools;
use format::{align, asian_typography, cur, direction, line_spacing, ls, set_align, set_props, tog};
pub use format::{cur as selection_para, fmt, indent, outdent};
@@ -27,10 +26,6 @@ use outline::{move_paras, outline_step, sort};
use styles::{apply_style, apply_styles, create_style, delete_style, modify_style, set_visibility, style_id, update_to_match};
pub use tabs_borders::border_sides;
use tabs_borders::{borders, tabs};
use tools::{
blank_between, convert_breaks, indent_to_spaces, remove_blank, remove_extra_spaces, remove_line_breaks, separate_paragraphs, spaces_to_indent,
trim_leading,
};
pub fn specs() -> Vec<CommandSpec> {
vec![
@@ -275,26 +270,6 @@ pub fn specs() -> Vec<CommandSpec> {
}
sel_result(s)
}),
// Advanced paragraph tools (#5043, as in WPS Office): conversions and cleanup over the
// selection, or the whole document with `scope: "all"`. One undo step per command.
CommandSpec::new("para.convertBreaks", "Convert Line Breaks to Paragraphs", "Home › Paragraph › Paragraph Tools", convert_breaks)
.params(r#"{"scope"?: "selection|all"}"#),
CommandSpec::new("para.blankBetween", "Insert Blank Paragraphs Between", "Home › Paragraph › Paragraph Tools", blank_between)
.params(r#"{"scope"?: "selection|all", "after"?: bool (also after the last paragraph)}"#),
CommandSpec::new("para.separateParagraphs", "Separate Paragraphs", "Home › Paragraph › Paragraph Tools", separate_paragraphs)
.params(r#"{"scope"?: "selection|all"}"#),
CommandSpec::new("para.removeBlank", "Delete Blank Paragraphs", "Home › Paragraph › Paragraph Tools", remove_blank)
.params(r#"{"scope"?: "selection|all"}"#),
CommandSpec::new("para.trimLeadingSpaces", "Remove Leading Spaces", "Home › Paragraph › Paragraph Tools", trim_leading)
.params(r#"{"scope"?: "selection|all"}"#),
CommandSpec::new("para.removeExtraSpaces", "Remove Extra Spaces", "Home › Paragraph › Paragraph Tools", remove_extra_spaces)
.params(r#"{"scope"?: "selection|all"}"#),
CommandSpec::new("para.spacesToIndent", "Convert Spaces to Indentation", "Home › Paragraph › Paragraph Tools", spaces_to_indent)
.params(r#"{"scope"?: "selection|all"}"#),
CommandSpec::new("para.indentToSpaces", "Convert Indents to Spaces", "Home › Paragraph › Paragraph Tools", indent_to_spaces)
.params(r#"{"scope"?: "selection|all"}"#),
CommandSpec::new("para.removeLineBreaks", "Remove Line Breaks", "Home › Paragraph › Paragraph Tools", remove_line_breaks)
.params(r#"{"scope"?: "selection|all"}"#),
]
}
-332
View File
@@ -1,332 +0,0 @@
//! Advanced paragraph tools (#5043): converting and cleaning up paragraphs, like WPS Office's
//! paragraph tools. Each command works on the paragraphs the selection covers, or on the whole
//! document with `scope: "all"`, and runs as one undo step. Deletions respect Track Changes.
use serde_json::{Value, json};
use wordcraft_doc::para::LINE_BREAK;
use wordcraft_doc::{Path, Pos, RevisionKind, StoryRef};
use super::{CmdError, CmdResult, Session, p, sel_result};
use crate::cmd::{has_next_para, new_revision, split_para, track_delete};
/// The maximum number of paragraphs one call may touch: the work, and the blank paragraphs a
/// command can add, stay bounded whatever the document says.
const MAX_TOOL_PARAS: usize = 10_000;
/// Leading spaces one paragraph may gain from an indent, whatever the file's indent says.
const MAX_INDENT_SPACES: usize = 1_000;
/// The scope of a tools command: `{"scope": "selection|all"}`, `selection` by default.
/// `all` selects the whole document first, so the command, its undo step and the result all
/// follow the full selection like any other command.
fn scope_all(s: &mut Session, v: &Value) -> Result<(), CmdError> {
match p::str(v, "scope").unwrap_or("selection") {
"selection" => Ok(()),
"all" => {
s.run("select.all", &json!({"whole": true}))?;
Ok(())
}
x => Err(CmdError::Params(format!("unknown scope `{x}` (selection|all)"))),
}
}
/// Refuse a paragraph rewrite that would touch content locked against editing. Commands operate
/// on whole paragraphs, so checking each full paragraph also covers controls outside the exact
/// selection offsets in a selected paragraph.
fn check_target_edits(s: &mut Session, story: StoryRef, paths: &[Path]) -> Result<(), CmdError> {
let ranges: Vec<(Pos, Pos)> = paths
.iter()
.filter_map(|path| {
let len = s.doc.para(story, path)?.len();
Some((Pos { story, path: path.clone(), off: 0 }, Pos { story, path: path.clone(), off: len }))
})
.collect();
for (a, b) in ranges {
crate::cmd::controls::check_edit(s, &a, &b)?;
}
Ok(())
}
/// The paragraphs a command runs over, in document order, and the story they are in.
fn targets(s: &Session) -> Result<(StoryRef, Vec<Path>), CmdError> {
let (a, b) = s.sel.ordered();
let paths = s.doc.para_paths_in(&a, &b);
if paths.is_empty() {
return Err(CmdError::Failed("no paragraphs selected".into()));
}
if paths.len() > MAX_TOOL_PARAS {
return Err(CmdError::Params(format!("select at most {MAX_TOOL_PARAS} paragraphs to process them in one command")));
}
Ok((a.story, paths))
}
/// Deletes `a..b` inside one paragraph, recording a tracked deletion when Track Changes is on.
/// Offsets past the paragraph's end are clamped, so the document is never half-edited.
fn delete_in_para(s: &mut Session, story: StoryRef, path: &Path, a: usize, b: usize) -> Result<(), CmdError> {
let len = s.doc.para(story, path).ok_or_else(|| CmdError::Failed("no paragraph here".into()))?.len();
let (a, b) = (a.min(b).min(len), b.min(len));
if a >= b {
return Ok(());
}
let at = |off: usize| Pos { story, path: path.clone(), off };
crate::cmd::controls::check_delete(s, &at(a), &at(b))?;
if s.doc.settings.track_changes {
track_delete(s, &at(a), &at(b))?;
} else {
s.doc.delete_range(&at(a), &at(b))?;
}
Ok(())
}
/// The deletions to make in one paragraph, as byte ranges, merged where they touch and applied
/// last first so the earlier offsets stay valid.
fn apply_deletions(s: &mut Session, story: StoryRef, path: &Path, mut ranges: Vec<(usize, usize)>) -> Result<(), CmdError> {
ranges.sort();
let mut merged: Vec<(usize, usize)> = Vec::new();
for (a, b) in ranges {
match merged.last_mut() {
Some(last) if a <= last.1 => last.1 = last.1.max(b),
_ => merged.push((a, b)),
}
}
for (a, b) in merged.into_iter().rev() {
delete_in_para(s, story, path, a, b)?;
}
Ok(())
}
/// The characters of the paragraph that are not tracked-deleted, as (byte offset, char). Under
/// Track Changes deleted text stays in `para.text`, so every scan that decides what to delete or
/// count goes through this.
fn live_chars(para: &wordcraft_doc::Paragraph) -> impl Iterator<Item = (usize, char)> + '_ {
para.run_ranges().filter(|(_, c)| c.del.is_none()).flat_map(|(r, _)| para.text[r.clone()].char_indices().map(move |(i, c)| (r.start + i, c)))
}
/// The byte ranges of every manual line break in the paragraph that is not already deleted.
fn break_ranges(para: &wordcraft_doc::Paragraph) -> Vec<(usize, usize)> {
live_chars(para).filter(|(_, c)| *c == LINE_BREAK).map(|(i, _)| (i, i + 1)).collect()
}
/// `para.convertBreaks`: every manual line break (Shift+Enter) in the selection becomes a
/// paragraph break. Paragraphs are handled last first: a split only shifts the paths after it.
pub(super) fn convert_breaks(s: &mut Session, v: &Value) -> CmdResult {
scope_all(s, v)?;
let (story, paths) = targets(s)?;
check_target_edits(s, story, &paths)?;
let breaks: usize = paths.iter().filter_map(|path| s.doc.para(story, path)).map(|p| break_ranges(p).len()).sum();
if breaks > MAX_TOOL_PARAS {
return Err(CmdError::Params(format!("convert at most {MAX_TOOL_PARAS} line breaks at once")));
}
for path in paths.iter().rev() {
let Some(para) = s.doc.para(story, path) else { continue };
for (i, _) in break_ranges(para).into_iter().rev() {
let new = split_para(s, &Pos { story, path: path.clone(), off: i })?;
// The break stays at the head of the new paragraph; the paragraph break replaces it.
delete_in_para(s, story, &new.path, 0, 1)?;
}
}
sel_result(s)
}
/// `para.blankBetween`: insert one empty paragraph between the selected paragraphs; `after`
/// adds one blank paragraph after the last selected paragraph too.
pub(super) fn blank_between(s: &mut Session, v: &Value) -> CmdResult {
let after = p::bool(v, "after").unwrap_or(false);
scope_all(s, v)?;
let (story, paths) = targets(s)?;
check_target_edits(s, story, &paths)?;
let count = paths.len().saturating_sub(1) + usize::from(after);
if count > MAX_TOOL_PARAS {
return Err(CmdError::Params(format!("insert at most {MAX_TOOL_PARAS} blank paragraphs at once")));
}
// Last first, each split at a paragraph's end: a split only shifts the paths after it.
if after {
let last = paths.last().ok_or_else(|| CmdError::Failed("no paragraph selected".into()))?;
let len = s.doc.para(story, last).map(|p| p.len()).unwrap_or(0);
split_para(s, &Pos { story, path: last.clone(), off: len })?;
}
for path in paths[..paths.len().saturating_sub(1)].iter().rev() {
let len = s.doc.para(story, path).map(|p| p.len()).unwrap_or(0);
split_para(s, &Pos { story, path: path.clone(), off: len })?;
}
sel_result(s)
}
/// `para.separateParagraphs`: every selected paragraph becomes a paragraph of its own with a
/// blank line between them.
pub(super) fn separate_paragraphs(s: &mut Session, v: &Value) -> CmdResult {
blank_between(s, &json!({"scope": p::str(v, "scope").unwrap_or("selection"), "after": true}))
}
/// `para.removeBlank`: delete every empty paragraph in the selection.
pub(super) fn remove_blank(s: &mut Session, v: &Value) -> CmdResult {
scope_all(s, v)?;
let (story, paths) = targets(s)?;
check_target_edits(s, story, &paths)?;
// Last first: removing a block leaves the paths before it as they are.
for path in paths.iter().rev() {
let Some(para) = s.doc.para(story, path) else { continue };
// A paragraph must be empty, carry no section break and have a paragraph after it in
// its container: a story's, part's or cell's last mark can't be removed, and a paragraph
// before a table can't be joined across it.
if !para.is_empty() || para.section.is_some() || !has_next_para(s, story, path) {
continue;
}
let next = path.with_last(path.last() + 1);
let (a, b) = (Pos { story, path: path.clone(), off: 0 }, Pos { story, path: next, off: 0 });
crate::cmd::controls::check_delete(s, &a, &b)?;
if s.doc.settings.track_changes {
track_delete(s, &a, &b)?;
} else {
s.doc.remove_block(story, path)?;
}
}
sel_result(s)
}
/// The number of leading spaces in the paragraph's text, deleted ones included (the range
/// `trimLeadingSpaces` deletes).
fn leading_spaces(para: &wordcraft_doc::Paragraph) -> usize {
para.text.chars().take_while(|c| *c == ' ').count()
}
/// The byte offsets of the paragraph's leading spaces that are not tracked-deleted.
fn live_leading_spaces(para: &wordcraft_doc::Paragraph) -> Vec<usize> {
live_chars(para).take_while(|(_, c)| *c == ' ').map(|(i, _)| i).collect()
}
/// `para.trimLeadingSpaces`: delete the spaces at the start of each selected paragraph.
pub(super) fn trim_leading(s: &mut Session, v: &Value) -> CmdResult {
scope_all(s, v)?;
let (story, paths) = targets(s)?;
check_target_edits(s, story, &paths)?;
for path in paths {
let Some(para) = s.doc.para(story, &path) else { continue };
let n = leading_spaces(para);
apply_deletions(s, story, &path, vec![(0, n)])?;
}
sel_result(s)
}
/// `para.removeExtraSpaces`: every run of spaces outside the paragraph's start becomes one
/// space. Tabs and page breaks are document structure, not redundant spaces, and stay in place.
/// Leading spaces stay (that is `para.trimLeadingSpaces`).
pub(super) fn remove_extra_spaces(s: &mut Session, v: &Value) -> CmdResult {
scope_all(s, v)?;
let (story, paths) = targets(s)?;
check_target_edits(s, story, &paths)?;
for path in paths {
let Some(para) = s.doc.para(story, &path) else { continue };
// Runs of live spaces (deleted characters are skipped, as if absent). A run at the
// paragraph's start stays; a run inside the text collapses to its last space, and one
// that reaches the paragraph's end to its first.
let mut runs = Vec::new();
let mut run: Vec<usize> = Vec::new();
let mut leading = true;
for (i, c) in live_chars(para) {
if c == ' ' {
run.push(i);
continue;
}
if !leading && run.len() > 1 {
runs.extend(run[..run.len() - 1].iter().map(|&a| (a, a + 1)));
}
run.clear();
leading = false;
}
if !leading && run.len() > 1 {
runs.extend(run[1..].iter().map(|&a| (a, a + 1)));
}
apply_deletions(s, story, &path, runs)?;
}
sel_result(s)
}
/// `para.spacesToIndent`: each paragraph's leading spaces become a first-line indent of 72 pt
/// per space (Word's point-based indent from typing spaces), and are deleted.
pub(super) fn spaces_to_indent(s: &mut Session, v: &Value) -> CmdResult {
scope_all(s, v)?;
let (story, paths) = targets(s)?;
check_target_edits(s, story, &paths)?;
let tracker = crate::cmd::fmt_revisions::ParaPropTracker::new(s);
for path in paths {
let Some(para) = s.doc.para(story, &path) else { continue };
let live = live_leading_spaces(para);
let spaces = live.len();
if spaces == 0 {
continue;
}
let current = para.props.indent_first.unwrap_or(0.0).clamp(-1584.0, 1584.0);
let represented = spaces.min(((1584.0 - current).max(0.0) / 72.0).floor() as usize);
if represented == 0 {
continue;
}
let before = para.props.clone();
let mut after = before.clone();
after.indent_first = Some(current + represented as f32 * 72.0);
after.cancel_char_indents(false, false, true);
after.fmt_change = tracker.changed(s, &before, &after);
{
let p = s.doc.para_mut(story, &path)?;
p.props = after;
p.touch();
}
// A space is one byte; deleted spaces in between stay as they are.
apply_deletions(s, story, &path, live[..represented].iter().map(|&a| (a, a + 1)).collect())?;
}
sel_result(s)
}
/// `para.indentToSpaces`: each paragraph's first-line and left indents become leading spaces
/// (72 pt per space, as typing spaces indents), and the indents go to zero.
pub(super) fn indent_to_spaces(s: &mut Session, v: &Value) -> CmdResult {
scope_all(s, v)?;
let (story, paths) = targets(s)?;
check_target_edits(s, story, &paths)?;
let tracker = crate::cmd::fmt_revisions::ParaPropTracker::new(s);
for path in paths {
let Some(para) = s.doc.para(story, &path) else { continue };
let pts = para.props.indent_left.unwrap_or(0.0) + para.props.indent_first.unwrap_or(0.0);
let n = (pts / 72.0).round().clamp(0.0, MAX_INDENT_SPACES as f32) as usize;
if n == 0 && para.props.indent_left.unwrap_or(0.0) == 0.0 && para.props.indent_first.unwrap_or(0.0) == 0.0 {
continue;
}
let mut props = s.doc.para(story, &path).map(|p| p.props_at(0)).cloned().unwrap_or_default();
// The revision first: `new_revision` edits `s.doc`, so it runs before the paragraph borrow.
// The spaces are marked as a tracked insertion through `props.ins`, as typing is.
props.fmt_change = None;
props.del = None;
if s.doc.settings.track_changes && n > 0 {
props.ins = Some(new_revision(s, RevisionKind::Insert));
} else {
props.ins = None;
}
let before = s.doc.para(story, &path).map(|p| p.props.clone()).unwrap_or_default();
let mut after = before.clone();
after.indent_first = Some(0.0);
after.indent_left = Some(0.0);
after.cancel_char_indents(true, false, true);
after.fmt_change = tracker.changed(s, &before, &after);
let p = s.doc.para_mut(story, &path)?;
if n > 0 {
p.insert_text(0, &" ".repeat(n), &props)?;
}
p.props = after;
p.touch();
}
sel_result(s)
}
/// `para.removeLineBreaks`: delete every manual line break in the selection, so the lines
/// join into one paragraph.
pub(super) fn remove_line_breaks(s: &mut Session, v: &Value) -> CmdResult {
scope_all(s, v)?;
let (story, paths) = targets(s)?;
check_target_edits(s, story, &paths)?;
for path in paths {
let Some(para) = s.doc.para(story, &path) else { continue };
apply_deletions(s, story, &path, break_ranges(para))?;
}
sel_result(s)
}
+8 -29
View File
@@ -102,9 +102,7 @@ struct SeqCount {
/// The fresh results of the unlocked SEQ fields in the body and in the text boxes anchored there
/// ([`update_seq`]). A text box's fields count where the box is anchored, before the fields after
/// the anchor in its paragraph and after those before it (measured in Word for Windows). A locked
/// SEQ keeps its result but still counts, `\r` included; a SEQ in a tracked deletion keeps its
/// result and doesn't count, with markup shown or not (measured in Word for Windows, exporting).
/// the anchor in its paragraph and after those before it (measured in Word for Windows).
fn seq_results(doc: &wordcraft_doc::Document) -> Vec<FieldResult> {
let mut count = SeqCount::default();
for path in doc.para_paths(StoryRef::Body) {
@@ -122,11 +120,7 @@ fn seq_results(doc: &wordcraft_doc::Document) -> Vec<FieldResult> {
/// Number the SEQ fields of paragraph `p` (at `path` in `story`) and of the text boxes anchored in it.
fn count_seq(doc: &wordcraft_doc::Document, story: StoryRef, path: &Path, p: &Paragraph, count: &mut SeqCount) {
// The runs come in order and the objects by offset, so one cursor walks the deleted ranges.
let mut deleted = p.deleted_ranges().into_iter().peekable();
for (k, (off, o)) in p.objects_with_offsets().enumerate() {
while deleted.next_if(|r| r.end <= off).is_some() {}
let in_deletion = deleted.peek().is_some_and(|r| r.contains(&off));
for (k, o) in p.objects.iter().enumerate() {
for id in o.text_boxes() {
if count.boxes.insert(id) {
let part = StoryRef::Part(id);
@@ -137,9 +131,9 @@ fn count_seq(doc: &wordcraft_doc::Document, story: StoryRef, path: &Path, p: &Pa
}
}
}
let InlineObject::Field { instr, locked, .. } = o else { continue };
let InlineObject::Field { instr, locked: false, .. } = o else { continue };
let f = FieldCode::parse(instr);
if f.name != "SEQ" || in_deletion {
if f.name != "SEQ" {
continue;
}
let Some(id) = f.args.first() else { continue };
@@ -155,9 +149,6 @@ fn count_seq(doc: &wordcraft_doc::Document, story: StoryRef, path: &Path, p: &Pa
} else if !f.has('c') {
n.0 = n.0.saturating_add(1);
}
if *locked {
continue;
}
let fmt = wordcraft_layout::fields::format_switch(instr).unwrap_or_default();
let r = if f.has('h') { String::new() } else { wordcraft_layout::fields::field_number(instr, fmt, n.0) };
count.out.push((story, path.clone(), k, r));
@@ -378,9 +369,8 @@ pub fn update_fields_in(s: &mut Session, all: bool) -> Result<(), CmdError> {
let name = wordcraft_layout::fields::field_name(instr);
match name.as_str() {
"DATE" | "TIME" | "CREATEDATE" | "SAVEDATE" | "PRINTDATE" => {
let lang = field_lang(&s.doc, p, k);
let pic = date_picture(instr).unwrap_or_else(|| default_picture(&name, lang.as_deref()));
updates.push((k, text_result(instr, crate::cmd::insert::format_date_in(&pic, lang.as_deref()))));
let pic = date_picture(instr).unwrap_or_else(|| if name == "TIME" { "h:mm am/pm".into() } else { "M/d/yyyy".into() });
updates.push((k, text_result(instr, crate::cmd::insert::format_date(&pic))));
}
"REF" | "PAGEREF" => {
let f = FieldCode::parse(instr);
@@ -506,9 +496,8 @@ pub fn refresh_date_fields(doc: &mut wordcraft_doc::Document) {
continue;
}
// Word's default picture when the code carries no `\@` switch.
let lang = field_lang(doc, p, k);
let pic = date_picture(instr).unwrap_or_else(|| default_picture(&name, lang.as_deref()));
updates.push((k, text_result(instr, crate::cmd::insert::format_date_in(&pic, lang.as_deref()))));
let pic = date_picture(instr).unwrap_or_else(|| if name == "TIME" { "h:mm am/pm".into() } else { "M/d/yyyy".into() });
updates.push((k, text_result(instr, crate::cmd::insert::format_date(&pic))));
}
if updates.is_empty() {
continue;
@@ -524,16 +513,6 @@ pub fn refresh_date_fields(doc: &mut wordcraft_doc::Document) {
}
}
/// Resolve a field object's language through the paragraph and document defaults.
fn field_lang(doc: &wordcraft_doc::Document, p: &wordcraft_doc::Paragraph, k: usize) -> Option<String> {
let off = p.object_offsets().get(k).copied()?;
doc.styles.resolve_char(p.props.style.as_deref(), p.props_of_char(off)).lang
}
/// Word's default date or time picture when a field instruction carries no `\@` picture switch.
fn default_picture(name: &str, lang: Option<&str>) -> String {
if name == "TIME" { "h:mm am/pm".into() } else { crate::cmd::insert::default_date_picture(lang).into() }
}
/// Most names SET fields give values.
const MAX_SET: usize = 10_000;
-39
View File
@@ -793,45 +793,6 @@ fn output_shows_references_to_bookmarks_outside_the_body() {
assert_eq!(results, expected);
}
/// Printed or exported, a locked SEQ keeps its result but still counts, its `\r` too, and a SEQ
/// in a tracked deletion keeps its result and doesn't count (measured in Word for Windows,
/// exporting with markup shown and hidden: Del 1, 9, 2; Lock 1, 7, 3; R 7, 6).
#[test]
fn output_counts_locked_seq_fields_and_not_deleted_ones() {
let mut s = Session::new(wordcraft_doc::Document::from_text(""));
let fields_in = ["SEQ Del", "SEQ Del", "SEQ Del", "SEQ Lock", "SEQ Lock", "SEQ Lock", "SEQ R \\r 5", "SEQ R"];
for instr in fields_in {
s.run("insert.field", &json!({"instr": instr, "result": "9"})).unwrap();
s.run("text.insert", &json!({"text": " "})).unwrap();
}
let top = wordcraft_doc::Path::top(0);
let para = s.doc.para_mut(StoryRef::Body, &top).unwrap();
for (k, o) in para.objects.iter_mut().enumerate() {
if let InlineObject::Field { result, locked, .. } = o {
*locked = matches!(k, 4 | 6);
*result = if *locked { "7" } else { "9" }.into();
}
}
para.touch();
// The second Del field is a tracked deletion.
let off = s.doc.para(StoryRef::Body, &top).unwrap().object_offsets()[1];
let at = |off| Pos { story: StoryRef::Body, path: top.clone(), off };
s.doc.format_range(&at(off), &at(off + '\u{FFFC}'.len_utf8()), &|c| c.del = Some(1)).unwrap();
let out = output_fields(&s.doc).expect("the stale results change");
let shown: Vec<String> = out
.para(StoryRef::Body, &top)
.unwrap()
.objects
.iter()
.filter_map(|o| match o {
InlineObject::Field { result, .. } => Some(result.clone()),
_ => None,
})
.collect();
assert_eq!(shown, ["1", "9", "2", "1", "7", "3", "7", "6"]);
assert!(fields(&s).iter().all(|f| f.1 == "9" || f.1 == "7"), "the document keeps its results");
}
/// Printed or exported, a REF to a bookmark the document doesn't have shows Word's error
/// (measured in Word for Windows: "Error! Reference source not found."); the editor and the
/// document keep the stored result, and a REF showing "above" or "below" (`\p`) keeps it too.
+5 -14
View File
@@ -54,20 +54,11 @@ fn resolve_para(
// An inserted paragraph mark that is rejected, or a deleted one that is accepted, goes away:
// the next paragraph joins this one (the split is undone). A paragraph left empty goes away
// whole, so the next one keeps its own style and numbering, as in Word. The mark at the end
// of a story or cell can't go, so it just stops being a revision. An empty paragraph whose
// mark carries a section break goes whole too: its section joins the following one, as when
// the break is deleted (#5667). Joining instead gave the next paragraph its properties.
if (del.is_some() && accept) || (ins.is_some() && !accept) {
let sectioned = story == StoryRef::Body
&& path.0.len() == 1
&& s.doc.para(story, path).is_some_and(|p| p.is_empty() && p.section.is_some())
&& crate::cmd::has_next_para(s, story, path);
if sectioned {
s.doc.remove_section_break(path.last());
}
if crate::cmd::remove_empty_para(s, story, path)? || crate::cmd::join_next_para(s, story, path)? {
return Ok(());
}
// of a story or cell can't go, so it just stops being a revision.
if ((del.is_some() && accept) || (ins.is_some() && !accept))
&& (crate::cmd::remove_empty_para(s, story, path)? || crate::cmd::join_next_para(s, story, path)?)
{
return Ok(());
}
let para = s.doc.para_mut(story, path)?;
para.mark.ins = None;
+1 -2
View File
@@ -14,7 +14,7 @@ mod enter_tab;
mod unicode;
use autoformat::insert;
use delete::{backspace, delete, delete_sentence_back, delete_word_back, delete_word_fwd};
use delete::{backspace, delete, delete_word_back, delete_word_fwd};
pub use enter_tab::MAX_TYPED_PARAGRAPHS;
use enter_tab::{back_tab, new_paragraph, tab};
pub use unicode::{alt_code_char, oem_code_page, system_code_pages, system_oem_code_page, windows_code_pages};
@@ -36,7 +36,6 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("text.backspace", "Backspace", "Editing", backspace).key("Backspace"),
CommandSpec::new("text.delete", "Delete", "Editing", delete).key("Delete"),
CommandSpec::new("text.deleteWordBack", "Delete Previous Word", "Editing", delete_word_back).key("Mod+Backspace / Alt+Backspace"),
CommandSpec::new("text.deleteSentenceBack", "Delete Previous Sentence", "Editing", delete_sentence_back).key("Mod+Shift+Backspace"),
CommandSpec::new("text.deleteWordForward", "Delete Next Word", "Editing", delete_word_fwd).key("Mod+Delete / Alt+Delete"),
CommandSpec::new("text.nbsp", "Nonbreaking Space", "Insert › Symbols", |s, _| ins_char(s, NBSP)).key("Mod+Shift+Space"),
CommandSpec::new("text.nbHyphen", "Nonbreaking Hyphen", "Insert › Symbols", |s, _| ins_char(s, NB_HYPHEN)).key("Mod+Shift+-"),
-17
View File
@@ -237,20 +237,3 @@ pub(super) fn delete_word_fwd(s: &mut Session, v: &Value) -> CmdResult {
crate::cmd::controls::restore_placeholder(s)?;
sel_result(s)
}
/// Delete to the current sentence start, or the preceding sentence when exactly at its boundary.
/// Sentence boundaries match Select Sentence. Never remove a paragraph or cell boundary.
pub(super) fn delete_sentence_back(s: &mut Session, v: &Value) -> CmdResult {
if !s.sel.is_collapsed() {
return backspace(s, v);
}
let mut f = s.sel.focus.clone();
let Some(para) = s.doc.para_at(&f) else { return sel_result(s) };
f.off = para.clamp(f.off);
let Some((previous, _)) = para.text.get(..f.off).and_then(|text| text.char_indices().next_back()) else { return sel_result(s) };
let start = para.sentence_at(previous).0;
s.sel = Selection { anchor: Pos { off: start, ..f.clone() }, focus: f };
delete_selection(s)?;
crate::cmd::controls::restore_placeholder(s)?;
sel_result(s)
}
-39
View File
@@ -341,42 +341,3 @@ pub(super) fn customize_ribbon(s: &mut Session, v: &Value) -> CmdResult {
}
Ok(Value::Object(out))
}
/// The rows of an `insert.spreadsheet` call: `values` (a rectangular array of cells) or `csv`.
pub(super) fn spreadsheet_rows(v: &Value) -> Result<Vec<Vec<String>>, CmdError> {
// The CSV fallback is for an absent `values`; an explicit null is a malformed argument.
let Some(values) = v.get("values") else {
return Ok(crate::cmd::mailings::parse_csv(p::str(v, "csv").unwrap_or("A,B,C\n1,2,3\n4,5,6")));
};
let list = values.as_array().ok_or_else(|| CmdError::Params("`values` must be an array of rows".into()))?;
if list.is_empty() || list.len() > 1000 {
return Err(CmdError::Params("`values` needs 1 to 1000 rows".into()));
}
let mut rows: Vec<Vec<String>> = Vec::with_capacity(list.len());
for (r, row) in list.iter().enumerate() {
let cells = row.as_array().ok_or_else(|| CmdError::Params(format!("`values` row {} is not an array", r + 1)))?;
if cells.is_empty() || cells.len() > 63 {
return Err(CmdError::Params(format!("`values` row {} needs 1 to 63 cells", r + 1)));
}
if rows.first().is_some_and(|f| f.len() != cells.len()) {
return Err(CmdError::Params(format!(
"`values` row {} has {} cells, row 1 has {}",
r + 1,
cells.len(),
rows.first().map_or(0, Vec::len)
)));
}
let mut out = Vec::with_capacity(cells.len());
for cell in cells {
out.push(match cell {
Value::String(x) => x.clone(),
Value::Number(x) => x.to_string(),
Value::Bool(x) => x.to_string(),
Value::Null => String::new(),
_ => return Err(CmdError::Params(format!("`values` row {} holds a cell that is not text, a number or a boolean", r + 1))),
});
}
rows.push(out);
}
Ok(rows)
}
+56 -143
View File
@@ -5,7 +5,7 @@ use super::{
use wordcraft_doc::para::OBJ;
/// Compare and Combine take the same settings.
pub(super) const COMPARE_PARAMS: &str = r#"{"path"?: string (revised document), "text"?: string (revised text), "original"?: string (original document; default: this one), "author"?: string (label changes with), "caseChanges"?: bool, "whiteSpace"?: bool, "formatting"?: bool (default true: differences count), "level"?: "word|character" (character: paragraphs over 2000 characters fall back to word; see characterFallback/notHonoured), "showIn"?: "original|revised|new" (revised/new: an untitled document), "moves"|"comments"|"tables"|"headersFooters"|"footnotes"|"textBoxes"|"fields"?: bool (accepted; not honoured yet except tables, always compared)}"#;
pub(super) const COMPARE_PARAMS: &str = r#"{"path"?: string (revised document), "text"?: string (revised text), "original"?: string (original document; default: this one), "author"?: string (label changes with), "caseChanges"?: bool, "whiteSpace"?: bool, "formatting"?: bool (default true: differences count), "level"?: "word|character", "showIn"?: "original|revised|new" (revised/new: an untitled document), "moves"|"comments"|"tables"|"headersFooters"|"footnotes"|"textBoxes"|"fields"?: bool (accepted; not honoured yet except tables, always compared)}"#;
/// What Compare / Combine compares (the More >> settings of Word's dialog).
struct CompareOptions {
@@ -27,15 +27,6 @@ impl CompareOptions {
}
}
/// Context and tracking counters for diffing paragraph contents.
struct DiffCtx<'a> {
ins: u32,
del: u32,
opts: &'a CompareOptions,
budget: &'a mut u64,
char_fallbacks: &'a mut usize,
}
/// Changed paragraphs longer than this (in characters) are compared word by word even at
/// character level, so the comparison table stays small.
const MAX_CHAR_DIFF: usize = 2000;
@@ -44,9 +35,6 @@ const MAX_CHAR_DIFF: usize = 2000;
/// result shows the differences as tracked insertions, deletions and formatting changes, labelled
/// with `author`. Options Word offers that the comparison can't honour yet (moves, comments,
/// headers and footers, footnotes, text boxes, fields) are accepted and listed in the result.
/// When character-level comparison is requested (`"level": "character"`) but any paragraph exceeds
/// [`MAX_CHAR_DIFF`] characters, it falls back to word-level diff for that paragraph, surfaces the
/// fallback count in `"characterFallback"`, and includes `"level"` in `"notHonoured"`.
pub(super) fn compare(s: &mut Session, v: &Value) -> CmdResult {
let revised = if let Some(t) = p::str(v, "text") {
Document::from_text(t)
@@ -73,7 +61,7 @@ pub(super) fn compare(s: &mut Session, v: &Value) -> CmdResult {
formatting: p::bool(v, "formatting").unwrap_or(true),
characters: matches!(level, "character" | "char"),
};
let mut not_honoured: Vec<&str> =
let not_honoured: Vec<&str> =
["moves", "comments", "headersFooters", "footnotes", "textBoxes", "fields"].into_iter().filter(|k| v.get(*k).is_some()).collect();
let author = p::str(v, "author").map(str::trim).filter(|a| !a.is_empty()).map(str::to_string).unwrap_or_else(|| s.author.clone());
let date = crate::cmd::now_iso();
@@ -95,9 +83,7 @@ pub(super) fn compare(s: &mut Session, v: &Value) -> CmdResult {
Block::Table(_) => format!("\u{0}table\u{0}{}", opts.key(&block_text(b, 0))),
};
let mut budget = MAX_COMPARE_CELLS;
let mut char_fallbacks = 0usize;
let ops = lcs_ops(&old.iter().map(key).collect::<Vec<_>>(), &new.iter().map(key).collect::<Vec<_>>(), &mut budget);
let mut ctx = DiffCtx { ins, del, opts: &opts, budget: &mut budget, char_fallbacks: &mut char_fallbacks };
let mut out: Vec<Arc<Block>> = Vec::new();
let (mut i, mut j) = (0usize, 0usize);
let mut changes = 0usize;
@@ -111,7 +97,7 @@ pub(super) fn compare(s: &mut Session, v: &Value) -> CmdResult {
if !same_objects(&s.doc, pa, pb) {
// Same text, but objects (text boxes, pictures…) differ: diffed, so the
// original's objects aren't lost.
out.push(para_block(word_diff(&s.doc, pa, pb, &mut ctx)));
out.push(para_block(word_diff(&s.doc, pa, pb, ins, del, &opts, &mut budget)));
changes += 1;
i += 1;
j += 1;
@@ -143,7 +129,9 @@ pub(super) fn compare(s: &mut Session, v: &Value) -> CmdResult {
for x in 0..dels.max(inss) {
let (a, b) = (if x < dels { old.get(i + x) } else { None }, if x < inss { new.get(j + x) } else { None });
match (a.map(|x| &**x), b.map(|x| &**x)) {
(Some(Block::Para(pa)), Some(Block::Para(pb))) => out.push(para_block(word_diff(&s.doc, pa, pb, &mut ctx))),
(Some(Block::Para(pa)), Some(Block::Para(pb))) => {
out.push(para_block(word_diff(&s.doc, pa, pb, ins, del, &opts, &mut budget)))
}
_ => {
if let Some(x) = a {
out.push(Arc::new(mark_block(x, &|c| c.del = Some(del), 0)));
@@ -174,21 +162,8 @@ pub(super) fn compare(s: &mut Session, v: &Value) -> CmdResult {
// Compare creates an untitled result from the current document.
s.clear_versions();
}
if char_fallbacks > 0 {
log::warn!(
"compare: {char_fallbacks} paragraph(s) exceeded character diff limit of {MAX_CHAR_DIFF}; fell back from character to word level diff"
);
if !not_honoured.contains(&"level") {
not_honoured.push("level");
}
}
s.sel = Selection::caret(s.doc.start_of(StoryRef::Body));
Ok(json!({
"changes": changes,
"author": author,
"notHonoured": not_honoured,
"characterFallback": char_fallbacks,
}))
Ok(json!({"changes": changes, "author": author, "notHonoured": not_honoured}))
}
/// A block's text (a table's cells in order), for matching blocks.
@@ -202,14 +177,13 @@ fn block_text(b: &Block, depth: usize) -> String {
}
}
/// Returns a copy of `b` with all text runs and paragraph marks (or nested table cells) updated by `f`.
/// A copy of the block with every run (in tables, every cell's) changed by `f`.
fn mark_block(b: &Block, f: &dyn Fn(&mut CharProps), depth: usize) -> Block {
match b {
Block::Para(p) => {
let mut p = p.clone();
let len = p.len();
let _ = p.format(0, len, f);
f(&mut p.mark);
Block::Para(p)
}
Block::Table(t) => {
@@ -231,96 +205,59 @@ pub(super) enum Op {
Ins,
}
/// Longest sequence window per LCS comparison table chunk; sequences longer than this are
/// aligned in successive chunked windows rather than truncated, preserving tail alignment.
/// Longest sequence the comparison table is built for; the rest of a longer sequence is
/// compared as deleted and inserted, never dropped.
const MAX_LCS: usize = 4000;
/// Comparison table cells one compare may fill over all its tables (blocks and every changed
/// paragraph pair); once spent, the remaining middles are compared as deleted and inserted.
const MAX_COMPARE_CELLS: u64 = 50_000_000;
/// Computes the longest common subsequence (LCS) edit script (`Op::Same`, `Op::Del`, `Op::Ins`)
/// between sequences `a` and `b`.
///
/// Strips common prefix and suffix first, then iterates over windows capped at [`MAX_LCS`],
/// charging comparison cells against `budget`. Between windows, intermediate common prefixes
/// are trimmed to re-anchor alignment without table allocations. When the remaining cells
/// exceed `budget`, the unaligned tail degrades honestly as whole deletions and insertions.
/// LCS edit script between two sequences: the common start and end are matched first, then the
/// middle (bounded size). The middle's table is charged to `budget`; when it doesn't fit, the
/// middle is deleted and inserted whole.
pub(super) fn lcs_ops<T: PartialEq>(a: &[T], b: &[T], budget: &mut u64) -> Vec<Op> {
let pre = a.iter().zip(b).take_while(|(x, y)| x == y).count();
let (ra, rb) = (a.get(pre..).unwrap_or_default(), b.get(pre..).unwrap_or_default());
let suf = ra.iter().rev().zip(rb.iter().rev()).take_while(|(x, y)| x == y).count();
let mut rem_a = ra.get(..ra.len().saturating_sub(suf)).unwrap_or_default();
let mut rem_b = rb.get(..rb.len().saturating_sub(suf)).unwrap_or_default();
let (a, b) = (ra.get(..ra.len() - suf).unwrap_or_default(), rb.get(..rb.len() - suf).unwrap_or_default());
let mut ops = vec![Op::Same; pre];
while !rem_a.is_empty() && !rem_b.is_empty() {
let p = rem_a.iter().zip(rem_b).take_while(|(x, y)| x == y).count();
if p > 0 {
ops.extend(std::iter::repeat_n(Op::Same, p));
rem_a = rem_a.get(p..).unwrap_or_default();
rem_b = rem_b.get(p..).unwrap_or_default();
if rem_a.is_empty() || rem_b.is_empty() {
break;
}
}
let n = rem_a.len().min(MAX_LCS);
let m = rem_b.len().min(MAX_LCS);
let cells = (n as u64).saturating_mul(m as u64);
if cells > *budget || n == 0 || m == 0 {
break;
}
*budget = budget.saturating_sub(cells);
let stride = m.saturating_add(1);
let table_len = n.saturating_add(1).saturating_mul(stride);
let mut t = vec![0u32; table_len];
for i in (0..n).rev() {
let row = i.saturating_mul(stride);
let next_row = i.saturating_add(1).saturating_mul(stride);
for j in (0..m).rev() {
let v = if rem_a.get(i) == rem_b.get(j) {
t.get(next_row.saturating_add(j).saturating_add(1)).copied().unwrap_or(0).saturating_add(1)
} else {
let down = t.get(next_row.saturating_add(j)).copied().unwrap_or(0);
let right = t.get(row.saturating_add(j).saturating_add(1)).copied().unwrap_or(0);
down.max(right)
};
if let Some(c) = t.get_mut(row.saturating_add(j)) {
*c = v;
}
}
}
let (mut i, mut j) = (0usize, 0usize);
while i < n && j < m {
let row = i.saturating_mul(stride);
let next_row = i.saturating_add(1).saturating_mul(stride);
if rem_a.get(i) == rem_b.get(j) {
ops.push(Op::Same);
i = i.saturating_add(1);
j = j.saturating_add(1);
} else {
let down = t.get(next_row.saturating_add(j)).copied().unwrap_or(0);
let right = t.get(row.saturating_add(j).saturating_add(1)).copied().unwrap_or(0);
if down >= right {
ops.push(Op::Del);
i = i.saturating_add(1);
} else {
ops.push(Op::Ins);
j = j.saturating_add(1);
}
}
}
rem_a = rem_a.get(i..).unwrap_or_default();
rem_b = rem_b.get(j..).unwrap_or_default();
let (mut n, mut m) = (a.len().min(MAX_LCS), b.len().min(MAX_LCS));
let cells = (n as u64).saturating_mul(m as u64);
if cells > *budget {
(n, m) = (0, 0);
} else {
*budget -= cells;
}
ops.extend(std::iter::repeat_n(Op::Del, rem_a.len()));
ops.extend(std::iter::repeat_n(Op::Ins, rem_b.len()));
let mut t = vec![vec![0u32; m + 1]; n + 1];
for i in (0..n).rev() {
for j in (0..m).rev() {
let v = if a.get(i) == b.get(j) {
t.get(i + 1).and_then(|r| r.get(j + 1)).copied().unwrap_or(0) + 1
} else {
t.get(i + 1).and_then(|r| r.get(j)).copied().unwrap_or(0).max(t.get(i).and_then(|r| r.get(j + 1)).copied().unwrap_or(0))
};
if let Some(c) = t.get_mut(i).and_then(|r| r.get_mut(j)) {
*c = v;
}
}
}
let (mut i, mut j) = (0, 0);
while i < n && j < m {
if a.get(i) == b.get(j) {
ops.push(Op::Same);
i += 1;
j += 1;
} else if t.get(i + 1).and_then(|r| r.get(j)) >= t.get(i).and_then(|r| r.get(j + 1)) {
ops.push(Op::Del);
i += 1;
} else {
ops.push(Op::Ins);
j += 1;
}
}
ops.extend(std::iter::repeat_n(Op::Del, a.len() - i));
ops.extend(std::iter::repeat_n(Op::Ins, b.len() - j));
ops.extend(std::iter::repeat_n(Op::Same, suf));
ops
}
@@ -473,39 +410,15 @@ fn put(p: &mut Paragraph, t: Option<&Tok<'_>>, props: &CharProps) {
}
}
/// Returns `true` if paragraph `p` contains more characters than `limit`.
///
/// Bounded to scan at most `limit + 1` characters without allocating unless inline objects
/// are present, avoiding O(n) traversal of large paragraphs.
fn para_exceeds_char_limit(p: &Paragraph, limit: usize) -> bool {
if p.objects.is_empty() {
p.text.chars().take(limit.saturating_add(1)).count() > limit
} else {
p.plain_text().chars().take(limit.saturating_add(1)).count() > limit
}
}
/// Diffs two matched paragraphs either at word granularity or at character granularity according
/// to `ctx.opts.characters`. When character-level diff is requested but either paragraph exceeds
/// [`MAX_CHAR_DIFF`] characters, falls back to word-level diff and increments `ctx.char_fallbacks`
/// so callers can discover the fallback.
fn word_diff(doc: &Document, a: &Paragraph, b: &Paragraph, ctx: &mut DiffCtx<'_>) -> Paragraph {
let chars = if ctx.opts.characters {
if para_exceeds_char_limit(a, MAX_CHAR_DIFF) || para_exceeds_char_limit(b, MAX_CHAR_DIFF) {
*ctx.char_fallbacks = ctx.char_fallbacks.saturating_add(1);
false
} else {
true
}
} else {
false
};
fn word_diff(doc: &Document, a: &Paragraph, b: &Paragraph, ins: u32, del: u32, opts: &CompareOptions, budget: &mut u64) -> Paragraph {
let (at, bt) = (a.plain_text(), b.plain_text());
let chars = opts.characters && at.chars().count().max(bt.chars().count()) <= MAX_CHAR_DIFF;
let (ta, tb) = (tokens(a, chars), tokens(b, chars));
let key = |t: &Tok<'_>| match t {
Tok::Text(t) => Key::Text(ctx.opts.key(t)),
Tok::Text(t) => Key::Text(opts.key(t)),
Tok::Obj(o) => obj_key(doc, o),
};
let ops = lcs_ops(&ta.iter().map(key).collect::<Vec<_>>(), &tb.iter().map(key).collect::<Vec<_>>(), ctx.budget);
let ops = lcs_ops(&ta.iter().map(key).collect::<Vec<_>>(), &tb.iter().map(key).collect::<Vec<_>>(), budget);
// Only at character granularity: a matched whole word almost never happens by coincidence,
// but a matched single character very often does (digits, punctuation), so only there does
// a short interior match need folding back into its surrounding edit.
@@ -523,11 +436,11 @@ fn word_diff(doc: &Document, a: &Paragraph, b: &Paragraph, ctx: &mut DiffCtx<'_>
j += 1;
}
Op::Del => {
put(&mut p, ta.get(i), &CharProps { del: Some(ctx.del), ..base.clone() });
put(&mut p, ta.get(i), &CharProps { del: Some(del), ..base.clone() });
i += 1;
}
Op::Ins => {
put(&mut p, tb.get(j), &CharProps { ins: Some(ctx.ins), ..base.clone() });
put(&mut p, tb.get(j), &CharProps { ins: Some(ins), ..base.clone() });
j += 1;
}
}
+4 -10
View File
@@ -1,6 +1,5 @@
use super::actions::{
customize_keyboard, customize_ribbon, hf_nav, language, macros, preferred_language, quick_parts, search_commands, select_similar,
spreadsheet_rows,
};
use super::autocorrect::autocorrect_options;
use super::compare::{COMPARE_PARAMS, compare};
@@ -214,10 +213,10 @@ pub fn specs() -> Vec<CommandSpec> {
})
.params(r#"{"path": string}"#),
CommandSpec::new("insert.spreadsheet", "Spreadsheet Table", "Insert › Tables", |s, v| {
let rows = spreadsheet_rows(v)?;
let csv = p::str(v, "csv").unwrap_or("A,B,C\n1,2,3\n4,5,6");
let rows = crate::cmd::mailings::parse_csv(csv);
let cols = rows.iter().map(Vec::len).max().unwrap_or(1).clamp(1, 63);
let style = p::str(v, "style").unwrap_or("GridTable1Light");
s.run("insert.table", &json!({"rows": rows.len().max(1), "cols": cols, "style": style}))?;
s.run("insert.table", &json!({"rows": rows.len().max(1), "cols": cols, "style": "GridTable1Light"}))?;
let Some((tp, _, _)) = s.sel.focus.path.cell() else { return sel_result(s) };
let t = s.doc.table_mut(s.sel.focus.story, &tp)?;
for (r, row) in rows.iter().enumerate() {
@@ -227,14 +226,9 @@ pub fn specs() -> Vec<CommandSpec> {
}
}
}
if p::bool(v, "header").unwrap_or(false)
&& let Some(first) = t.rows.first_mut()
{
first.props.header = true;
}
sel_result(s)
})
.params(r#"{"csv"?: string, "values"?: [[cell, …], …] (rows of equal length, at most 1000 rows × 63 columns; wins over csv), "header"?: bool (first row repeats on each page), "style"?: table style name (default GridTable1Light)}"#),
.params(r#"{"csv"?: string}"#),
CommandSpec::new("layout.linkToPrevious", "Link to Previous", "Header & Footer › Navigation", |s, v| {
let header = p::bool(v, "footer").map(|f| !f).unwrap_or(true);
let block = s.sel.focus.path.0.first().copied().unwrap_or(0) as usize;
-613
View File
@@ -66,309 +66,6 @@ fn collapse_noise_matches_merges_short_interior_runs_but_keeps_prefix_suffix() {
assert_eq!(collapse_noise_matches(&ops), ops);
}
/// Verifies that `lcs_ops` chunks sequence alignment across multiple `MAX_LCS` windows,
/// replaying operations to prove exact reconstruction and asserting specific edit indices.
#[test]
fn lcs_ops_chunks_across_multiple_windows() {
// Two sequences of 9,000 elements (spanning three 4,000-element MAX_LCS windows).
// Element at 50 moves to 8,500. Substitutions at 0 and 8,999 zero the outer prefix/suffix.
let total = 9000;
let mut a: Vec<usize> = (0..total).collect();
let mut b = a.clone();
a[0] = 99999;
b[0] = 88888;
a[total - 1] = 77777;
b[total - 1] = 66666;
let moved = a[50];
b.remove(50);
b.insert(8500, moved);
let mut budget = u64::MAX;
let ops = lcs_ops(&a, &b, &mut budget);
// Replay ops against `a` and `b` to verify exact sequence reconstruction and order.
let mut ia = 0;
let mut ib = 0;
let mut dels_seen = Vec::new();
let mut inss_seen = Vec::new();
let mut reconstructed = Vec::new();
for op in &ops {
match op {
Op::Same => {
assert_eq!(a.get(ia), b.get(ib), "Same op must match elements at ia={ia}, ib={ib}");
if let Some(&val) = a.get(ia) {
reconstructed.push(val);
}
ia += 1;
ib += 1;
}
Op::Del => {
if let Some(&val) = a.get(ia) {
dels_seen.push((ia, val));
}
ia += 1;
}
Op::Ins => {
if let Some(&val) = b.get(ib) {
inss_seen.push((ib, val));
reconstructed.push(val);
}
ib += 1;
}
}
}
assert_eq!(ia, a.len());
assert_eq!(ib, b.len());
assert_eq!(reconstructed, b);
// Truth: 2 substitutions (indices 0 and 8999), 1 deletion at 50, 1 insertion at 8500.
assert_eq!(dels_seen, vec![(0, 99999), (50, 50), (8999, 77777)]);
assert_eq!(inss_seen, vec![(0, 88888), (8500, 50), (8999, 66666)]);
}
/// Verifies that `lcs_ops` forces successive full 4,000-element windows without
/// intermediate prefix trimming collapsing them, asserting exact budget exhaustion.
#[test]
fn lcs_ops_forces_successive_full_windows_without_intermediate_prefix_trim() {
// Force successive full 4,000-element windows without intermediate prefix trimming
// collapsing the middle into fewer tables.
// Length: 9,000 elements across 3 windows (4,000 + 4,000 + 1,000).
// Boundary substitutions at indices 0, 4000, 8000, and 8999 guarantee rem_a[0] != rem_b[0]
// at the beginning of each window, forcing intermediate prefix trim to find p == 0 so
// all three full DP tables must execute.
let total = 9000;
let mut a: Vec<usize> = (0..total).collect();
let mut b = a.clone();
a[0] = 11111;
b[0] = 22222;
a[4000] = 33333;
b[4000] = 44444;
a[8000] = 55555;
b[8000] = 66666;
a[total - 1] = 77777;
b[total - 1] = 88888;
// Budget for 3 full windows:
// Window 1: 4,000 * 4,000 = 16,000,000 cells
// Window 2: 4,000 * 4,000 = 16,000,000 cells
// Window 3: 1,000 * 1,000 = 1,000,000 cells
// Total cells = 33,000,000.
let mut budget = 33_000_000u64;
let ops = lcs_ops(&a, &b, &mut budget);
// Exact budget is fully exhausted (budget == 0), proving all 3 full DP tables were built.
assert_eq!(budget, 0, "all 3 successive windows must execute their DP tables without collapsing");
let mut ia = 0;
let mut ib = 0;
let mut reconstructed = Vec::new();
let mut dels_seen = Vec::new();
let mut inss_seen = Vec::new();
for op in &ops {
match op {
Op::Same => {
assert_eq!(a.get(ia), b.get(ib));
if let Some(&val) = a.get(ia) {
reconstructed.push(val);
}
ia += 1;
ib += 1;
}
Op::Del => {
if let Some(&val) = a.get(ia) {
dels_seen.push((ia, val));
}
ia += 1;
}
Op::Ins => {
if let Some(&val) = b.get(ib) {
inss_seen.push((ib, val));
reconstructed.push(val);
}
ib += 1;
}
}
}
assert_eq!(ia, a.len());
assert_eq!(ib, b.len());
assert_eq!(reconstructed, b);
assert_eq!(dels_seen, vec![(0, 11111), (4000, 33333), (8000, 55555), (8999, 77777)]);
assert_eq!(inss_seen, vec![(0, 22222), (4000, 44444), (8000, 66666), (8999, 88888)]);
// Also verify completely disjoint sequences across multiple windows with zero common items.
// Window 1: 4,000 * 4,000 = 16,000,000 cells (deletes 4,000 of a_diff)
// Window 2: 4,000 * 4,000 = 16,000,000 cells (deletes next 4,000 of a_diff)
// Window 3: 1,000 of a_diff * 4,000 of b_diff = 4,000,000 cells
// Total cells = 36,000,000.
let a_diff: Vec<usize> = (0..9000).collect();
let b_diff: Vec<usize> = (10_000..19_000).collect();
let mut budget_diff = 36_000_000u64;
let ops_diff = lcs_ops(&a_diff, &b_diff, &mut budget_diff);
assert_eq!(budget_diff, 0, "all 3 windows in disjoint comparison must exhaust exact 36,000,000 budget");
assert_eq!(ops_diff.iter().filter(|o| **o == Op::Del).count(), 9000);
assert_eq!(ops_diff.iter().filter(|o| **o == Op::Ins).count(), 9000);
assert_eq!(ops_diff.iter().filter(|o| **o == Op::Same).count(), 0);
}
/// Tests `lcs_ops` budget behavior across edge cases: zero initial budget, exact-fit allocation,
/// one-short exhaustion, and multi-window exhaustion after the first chunk.
#[test]
fn lcs_ops_exhausted_budget_falls_back_whole() {
// 1. Budget 0 before any table: middle degrades whole, budget remains 0
let a = vec![1, 2, 3, 4, 5];
let b = vec![1, 8, 9, 5];
let mut budget = 0;
let ops = lcs_ops(&a, &b, &mut budget);
assert_eq!(ops, vec![Op::Same, Op::Del, Op::Del, Op::Del, Op::Ins, Op::Ins, Op::Same]);
assert_eq!(budget, 0, "budget must remain 0");
// 2. Exact-fit: cells == budget
// Middle has 3 elements in a (2, 3, 4) and 2 in b (8, 9) -> 3 * 2 = 6 cells.
let mut budget_exact = 6;
let ops_exact = lcs_ops(&a, &b, &mut budget_exact);
assert_eq!(budget_exact, 0, "budget exactly exhausted to 0");
assert_eq!(ops_exact, vec![Op::Same, Op::Del, Op::Del, Op::Del, Op::Ins, Op::Ins, Op::Same]);
// Exact-fit with shared elements in middle
let a_mid = vec![1, 2, 3, 5];
let b_mid = vec![1, 2, 9, 5];
// Middle is (3) vs (9): 1 * 1 = 1 cell
let mut budget_mid = 1;
let ops_mid = lcs_ops(&a_mid, &b_mid, &mut budget_mid);
assert_eq!(budget_mid, 0);
assert_eq!(ops_mid, vec![Op::Same, Op::Same, Op::Del, Op::Ins, Op::Same]);
// 3. One-short: budget == cells - 1
let mut budget_short = 5;
let ops_short = lcs_ops(&a, &b, &mut budget_short);
assert_eq!(budget_short, 5, "one-short budget must not be charged");
assert_eq!(ops_short, vec![Op::Same, Op::Del, Op::Del, Op::Del, Op::Ins, Op::Ins, Op::Same]);
// 4. Multi-window: Exhaustion after the first window
// Two windows: window 1 has 4,000 elements (16,000,000 cells), window 2 has 500 elements (250,000 cells).
let mut a_multi: Vec<usize> = (0..4500).collect();
let mut b_multi = a_multi.clone();
a_multi[0] = 99999;
b_multi[0] = 88888;
a_multi[4000] = 77777;
b_multi[4000] = 66666;
a_multi[4499] = 55555;
b_multi[4499] = 44444;
// Case 4A: budget fits window 1 exactly (16,000,000), exhausts for window 2
let mut budget_w1_only = 16_000_000u64;
let ops_w1_only = lcs_ops(&a_multi, &b_multi, &mut budget_w1_only);
assert_eq!(budget_w1_only, 0, "budget spent on window 1, 0 remaining for window 2");
// Window 1 aligned via DP table, window 2 fell back to 500 Dels and 500 Inss
let tail_dels = ops_w1_only.iter().rev().skip(500).take(500).filter(|o| **o == Op::Del).count();
let tail_inss = ops_w1_only.iter().rev().take(500).filter(|o| **o == Op::Ins).count();
assert_eq!(tail_dels, 500);
assert_eq!(tail_inss, 500);
// Case 4B: budget fits both windows exactly: 16,250,000
let mut budget_both = 16_250_000u64;
let ops_both = lcs_ops(&a_multi, &b_multi, &mut budget_both);
assert_eq!(budget_both, 0, "both windows charged exactly to 0");
assert_eq!(ops_both.iter().filter(|o| **o == Op::Del).count(), 3);
assert_eq!(ops_both.iter().filter(|o| **o == Op::Ins).count(), 3);
// Case 4C: one short on window 2: 16_249_999
let mut budget_both_short = 16_249_999u64;
let _ = lcs_ops(&a_multi, &b_multi, &mut budget_both_short);
assert_eq!(budget_both_short, 249_999, "remaining budget must reflect window 1 spent and window 2 unspent");
}
/// Tests boundary conditions for `lcs_ops`: empty slices, one-sided empty, exact `MAX_LCS`,
/// `MAX_LCS + 1`, `2 * MAX_LCS`, and prefix-trim loop break paths.
#[test]
fn lcs_ops_boundary_cases_and_break_paths() {
// 1. Empty sequences
let mut b = u64::MAX;
assert_eq!(lcs_ops::<i32>(&[], &[], &mut b), Vec::<Op>::new());
// 2. One-sided empty
let items = [1, 2, 3];
assert_eq!(lcs_ops(&[], &items, &mut b), vec![Op::Ins, Op::Ins, Op::Ins]);
assert_eq!(lcs_ops(&items, &[], &mut b), vec![Op::Del, Op::Del, Op::Del]);
// 3. Exact MAX_LCS (4,000)
let len_max = 4000;
let mut a_max: Vec<usize> = (0..len_max).collect();
let mut b_max = a_max.clone();
a_max[0] = 99999;
b_max[0] = 88888;
a_max[len_max - 1] = 77777;
b_max[len_max - 1] = 66666;
let mut b_budget = u64::MAX;
let ops_max = lcs_ops(&a_max, &b_max, &mut b_budget);
assert_eq!(ops_max.len(), len_max + 2);
assert_eq!(ops_max.iter().filter(|o| **o == Op::Same).count(), len_max - 2);
// 4. Exact MAX_LCS + 1 (4,001)
let len_plus1 = 4001;
let mut a_p1: Vec<usize> = (0..len_plus1).collect();
let mut b_p1 = a_p1.clone();
a_p1[0] = 99999;
b_p1[0] = 88888;
a_p1[len_plus1 - 1] = 77777;
b_p1[len_plus1 - 1] = 66666;
let mut b_budget2 = u64::MAX;
let ops_p1 = lcs_ops(&a_p1, &b_p1, &mut b_budget2);
assert_eq!(ops_p1.len(), len_plus1 + 2);
assert_eq!(ops_p1.iter().filter(|o| **o == Op::Same).count(), len_plus1 - 2);
// 5. Exact 2 * MAX_LCS (8,000)
let len_2m = 8000;
let mut a_2m: Vec<usize> = (0..len_2m).collect();
let mut b_2m = a_2m.clone();
a_2m[0] = 11111;
b_2m[0] = 22222;
a_2m[4000] = 33333;
b_2m[4000] = 44444;
a_2m[len_2m - 1] = 55555;
b_2m[len_2m - 1] = 66666;
let mut b_budget3 = u64::MAX;
let ops_2m = lcs_ops(&a_2m, &b_2m, &mut b_budget3);
assert_eq!(ops_2m.iter().filter(|o| **o == Op::Del).count(), 3);
assert_eq!(ops_2m.iter().filter(|o| **o == Op::Ins).count(), 3);
assert_eq!(ops_2m.iter().filter(|o| **o == Op::Same).count(), len_2m - 3);
// 6. Prefix-trim break paths inside the while loop:
// Case 6A: rem_a becomes empty after matching p elements
let mut a_break_a: Vec<usize> = (0..4005).collect();
let mut b_break_a: Vec<usize> = (0..4008).collect();
a_break_a[0] = 99999;
b_break_a[0] = 88888;
let mut b_break1 = u64::MAX;
let ops_break_a = lcs_ops(&a_break_a, &b_break_a, &mut b_break1);
assert_eq!(ops_break_a.iter().filter(|o| **o == Op::Del).count(), 1);
assert_eq!(ops_break_a.iter().filter(|o| **o == Op::Ins).count(), 4);
// Case 6B: rem_b becomes empty after matching p elements
let mut a_break_b: Vec<usize> = (0..4008).collect();
let mut b_break_b: Vec<usize> = (0..4005).collect();
a_break_b[0] = 99999;
b_break_b[0] = 88888;
let mut b_break2 = u64::MAX;
let ops_break_b = lcs_ops(&a_break_b, &b_break_b, &mut b_break2);
assert_eq!(ops_break_b.iter().filter(|o| **o == Op::Del).count(), 4);
assert_eq!(ops_break_b.iter().filter(|o| **o == Op::Ins).count(), 1);
// Case 6C: both become empty simultaneously after matching p elements
let mut a_break_both: Vec<usize> = (0..4005).collect();
let mut b_break_both: Vec<usize> = (0..4005).collect();
a_break_both[0] = 99999;
b_break_both[0] = 88888;
let mut b_break3 = u64::MAX;
let ops_break_both = lcs_ops(&a_break_both, &b_break_both, &mut b_break3);
assert_eq!(ops_break_both.iter().filter(|o| **o == Op::Del).count(), 1);
assert_eq!(ops_break_both.iter().filter(|o| **o == Op::Ins).count(), 1);
assert_eq!(ops_break_both.iter().filter(|o| **o == Op::Same).count(), 4004);
}
#[test]
fn compare_at_word_level_still_matches_a_whole_unchanged_word() {
// `collapse_noise_matches` only runs for character-level compares (wired up in
@@ -383,145 +80,6 @@ fn compare_at_word_level_still_matches_a_whole_unchanged_word() {
);
}
#[test]
fn compare_character_level_surfaces_fallback_on_long_paragraphs() {
// Under MAX_CHAR_DIFF (2,000 characters), character-level diff engages normally:
// sub-word fragment is marked, characterFallback is 0, and notHonoured does not contain "level".
let original_short = "The quick brown fox jumps over the lazy dog. ".repeat(10);
let revised_short = original_short.replacen("jumps", "jumped", 1);
assert!(original_short.chars().count() < 2000);
let mut s = Session::new(Document::from_text(&original_short));
let r = s.run("review.compare", &json!({"text": revised_short, "level": "character"})).unwrap();
assert_eq!(r["characterFallback"].as_u64(), Some(0));
let not_honoured = r["notHonoured"].as_array().expect("notHonoured array");
assert!(!not_honoured.iter().any(|v| v == "level"), "level should be honoured for short paragraphs");
let p = s.doc.body[0].as_para().expect("body paragraph");
let deleted: String = p.run_ranges().filter(|(_, c)| c.del.is_some()).map(|(r, _)| p.text[r].to_string()).collect();
let inserted: String = p.run_ranges().filter(|(_, c)| c.ins.is_some()).map(|(r, _)| p.text[r].to_string()).collect();
assert_eq!(deleted, "s", "character level marks only the sub-word suffix for short paragraphs");
assert_eq!(inserted, "ed", "character level marks only the sub-word suffix for short paragraphs");
// Above MAX_CHAR_DIFF (2,000 characters), character-level diff falls back to word-level diff.
// The fallback is surfaced in result metadata: characterFallback is incremented and
// notHonoured reports "level" rather than silently degrading without signal.
// In the output document, the fallback marks the whole word rather than sub-word fragments.
let original_long = "The quick brown fox jumps over the lazy dog. ".repeat(50);
let revised_long = original_long.replacen("jumps", "jumped", 1);
assert!(original_long.chars().count() > 2000);
let mut s = Session::new(Document::from_text(&original_long));
let r = s.run("review.compare", &json!({"text": revised_long, "level": "character"})).unwrap();
assert_eq!(r["characterFallback"].as_u64(), Some(1));
let not_honoured = r["notHonoured"].as_array().expect("notHonoured array");
assert!(not_honoured.iter().any(|v| v == "level"), "level should be listed in notHonoured when fallback occurs");
let p = s.doc.body[0].as_para().expect("body paragraph");
let deleted: String = p.run_ranges().filter(|(_, c)| c.del.is_some()).map(|(r, _)| p.text[r].to_string()).collect();
let inserted: String = p.run_ranges().filter(|(_, c)| c.ins.is_some()).map(|(r, _)| p.text[r].to_string()).collect();
assert_eq!(deleted, "jumps ", "long paragraph fallback marks the whole word");
assert_eq!(inserted, "jumped ", "long paragraph fallback marks the whole word");
}
#[test]
fn compare_character_level_exact_boundaries_at_limit() {
let prefix = "The quick brown fox jumps over the lazy dog. ".repeat(44); // 1980 chars
// Exactly MAX_CHAR_DIFF (2000) characters: character-level diff engages without fallback.
let original_2000 = format!("{prefix}1234567890123456789x");
let revised_2000 = format!("{prefix}1234567890123456789y");
assert_eq!(original_2000.chars().count(), 2000);
assert_eq!(revised_2000.chars().count(), 2000);
let mut s = Session::new(Document::from_text(&original_2000));
let r = s.run("review.compare", &json!({"text": revised_2000, "level": "character"})).unwrap();
assert_eq!(r["characterFallback"].as_u64(), Some(0));
let not_honoured = r["notHonoured"].as_array().expect("notHonoured array");
assert!(!not_honoured.iter().any(|v| v == "level"), "2000 chars should not fall back");
let p = s.doc.body[0].as_para().expect("body paragraph");
let deleted: String = p.run_ranges().filter(|(_, c)| c.del.is_some()).map(|(r, _)| p.text[r].to_string()).collect();
let inserted: String = p.run_ranges().filter(|(_, c)| c.ins.is_some()).map(|(r, _)| p.text[r].to_string()).collect();
assert_eq!(deleted, "x", "character level marks single changed character at exactly 2000 chars");
assert_eq!(inserted, "y", "character level marks single changed character at exactly 2000 chars");
// Exactly 2001 characters (MAX_CHAR_DIFF + 1): triggers fallback to word-level diff.
let original_2001 = format!("{prefix}12345678901234567890x");
let revised_2001 = format!("{prefix}12345678901234567890y");
assert_eq!(original_2001.chars().count(), 2001);
assert_eq!(revised_2001.chars().count(), 2001);
let mut s = Session::new(Document::from_text(&original_2001));
let r = s.run("review.compare", &json!({"text": revised_2001, "level": "character"})).unwrap();
assert_eq!(r["characterFallback"].as_u64(), Some(1));
let not_honoured = r["notHonoured"].as_array().expect("notHonoured array");
assert!(not_honoured.iter().any(|v| v == "level"), "2001 chars must trigger fallback");
let p = s.doc.body[0].as_para().expect("body paragraph");
let deleted: String = p.run_ranges().filter(|(_, c)| c.del.is_some()).map(|(r, _)| p.text[r].to_string()).collect();
let inserted: String = p.run_ranges().filter(|(_, c)| c.ins.is_some()).map(|(r, _)| p.text[r].to_string()).collect();
assert_eq!(deleted, "12345678901234567890x", "fallback marks whole word at 2001 chars");
assert_eq!(inserted, "12345678901234567890y", "fallback marks whole word at 2001 chars");
}
#[test]
fn compare_character_level_multiple_paragraphs_and_asymmetric_lengths() {
let short_a = "Short original text here.";
let short_b = "Short revised text here.";
let long_a = "The quick brown fox jumps over the lazy dog. ".repeat(50);
let long_b = long_a.replacen("jumps", "jumped", 1);
let long_c = "Another long paragraph that exceeds two thousand chars. ".repeat(40);
let long_d = long_c.replacen("Another", "Different", 1);
// Document with 1 short modified paragraph and 1 long modified paragraph: fallback count is 1.
let doc_mixed_a = format!("{short_a}\n{long_a}");
let doc_mixed_b = format!("{short_b}\n{long_b}");
let mut s = Session::new(Document::from_text(&doc_mixed_a));
let r = s.run("review.compare", &json!({"text": doc_mixed_b, "level": "character"})).unwrap();
assert_eq!(r["characterFallback"].as_u64(), Some(1));
// Document with 2 long modified paragraphs: fallback count is 2.
let doc_2long_a = format!("{long_a}\n{long_c}");
let doc_2long_b = format!("{long_b}\n{long_d}");
let mut s = Session::new(Document::from_text(&doc_2long_a));
let r = s.run("review.compare", &json!({"text": doc_2long_b, "level": "character"})).unwrap();
assert_eq!(r["characterFallback"].as_u64(), Some(2));
// Asymmetric lengths: original short, revised long (> 2000 chars) -> fallback count is 1.
let mut s = Session::new(Document::from_text(short_a));
let r = s.run("review.compare", &json!({"text": long_b, "level": "character"})).unwrap();
assert_eq!(r["characterFallback"].as_u64(), Some(1));
// Asymmetric lengths: original long (> 2000 chars), revised short -> fallback count is 1.
let mut s = Session::new(Document::from_text(&long_a));
let r = s.run("review.compare", &json!({"text": short_b, "level": "character"})).unwrap();
assert_eq!(r["characterFallback"].as_u64(), Some(1));
}
#[test]
fn compare_word_level_default_and_explicit_never_flag_fallback() {
let original_long = "The quick brown fox jumps over the lazy dog. ".repeat(50);
let revised_long = original_long.replacen("jumps", "jumped", 1);
assert!(original_long.chars().count() > 2000);
// Explicit "level": "word" on long paragraph: characterFallback is 0 and "level" not in notHonoured.
let mut s = Session::new(Document::from_text(&original_long));
let r = s.run("review.compare", &json!({"text": revised_long, "level": "word"})).unwrap();
assert_eq!(r["characterFallback"].as_u64(), Some(0));
let not_honoured = r["notHonoured"].as_array().expect("notHonoured array");
assert!(!not_honoured.iter().any(|v| v == "level"));
let p = s.doc.body[0].as_para().expect("body paragraph");
let deleted: String = p.run_ranges().filter(|(_, c)| c.del.is_some()).map(|(r, _)| p.text[r].to_string()).collect();
let inserted: String = p.run_ranges().filter(|(_, c)| c.ins.is_some()).map(|(r, _)| p.text[r].to_string()).collect();
assert_eq!(deleted, "jumps ");
assert_eq!(inserted, "jumped ");
// Default (no "level" param provided): behaves as word level, characterFallback is 0 and "level" not in notHonoured.
let mut s = Session::new(Document::from_text(&original_long));
let r = s.run("review.compare", &json!({"text": revised_long})).unwrap();
assert_eq!(r["characterFallback"].as_u64(), Some(0));
let not_honoured = r["notHonoured"].as_array().expect("notHonoured array");
assert!(!not_honoured.iter().any(|v| v == "level"));
let p = s.doc.body[0].as_para().expect("body paragraph");
let deleted: String = p.run_ranges().filter(|(_, c)| c.del.is_some()).map(|(r, _)| p.text[r].to_string()).collect();
let inserted: String = p.run_ranges().filter(|(_, c)| c.ins.is_some()).map(|(r, _)| p.text[r].to_string()).collect();
assert_eq!(deleted, "jumps ");
assert_eq!(inserted, "jumped ");
}
#[test]
fn compare_keeps_text_boxes_of_the_original() {
// Text boxes are anchored in body paragraphs: one after changed text, one alone in an
@@ -635,124 +193,6 @@ fn compare_bounds_total_diff_work() {
assert_eq!(s.doc.plain_text(StoryRef::Body), revised);
}
/// Verifies that moving a paragraph past `MAX_LCS` in a long document does not cascade into false
/// revisions in the tail, asserting exact changed block indices, text, and edit types.
#[test]
fn compare_lcs_window_move_past_cap_does_not_cascade_false_revisions() {
// Issue #6110: 4,600 blocks where prefix/suffix trim is zeroed (substitutions at blocks 0
// and 4599) and a record moves from index 100 to index 4400 (past MAX_LCS = 4000).
// Without chunking/continuation past the window, the tail misaligns and positional pairing
// marks ~202 paragraphs instead of the true 4 changes (substitutions at 0 and 4599,
// deletion at 100, insertion at 4400).
let total = 4600;
let mut orig_paras: Vec<String> = (0..total).map(|i| format!("Record {i} has alpha beta gamma payload.")).collect();
let mut rev_paras = orig_paras.clone();
// Zero out prefix trim: substitution at 0
orig_paras[0] = "Record 0 has alpha beta gamma original.".into();
rev_paras[0] = "Record 0 has alpha beta gamma revised.".into();
// Zero out suffix trim: substitution at 4599
orig_paras[total - 1] = format!("Record {} has alpha beta gamma original.", total - 1);
rev_paras[total - 1] = format!("Record {} has alpha beta gamma revised.", total - 1);
// Move record 100 to 4400
let moved = orig_paras[100].clone();
rev_paras.remove(100);
rev_paras.insert(4400, moved);
let original = orig_paras.join("\n");
let revised = rev_paras.join("\n");
let mut s = Session::new(Document::from_text(&original));
let r = s.run("review.compare", &json!({"text": revised})).unwrap();
// The true number of changed paragraphs is 4 (the two substitutions, the move's deletion,
// and the move's insertion). On unpatched code, window truncation misaligned the tail
// and marked ~202 paragraphs.
assert_eq!(r["changes"], 4, "move past MAX_LCS cap must not cascade into false revisions in the tail");
// In s.doc.body (total 4601 blocks: 4598 unchanged, 1 del at 100, 1 ins at 4401, 2 substituted at 0 and 4600):
let changed_blocks: Vec<(usize, String)> = s
.doc
.body
.iter()
.enumerate()
.filter_map(|(idx, b)| {
let p = b.as_para()?;
let has_change = p.mark.del.is_some() || p.mark.ins.is_some() || p.runs.iter().any(|r| r.props.del.is_some() || r.props.ins.is_some());
if has_change { Some((idx, p.plain_text())) } else { None }
})
.collect();
assert_eq!(changed_blocks.len(), 4);
assert_eq!(changed_blocks[0], (0, "Record 0 has alpha beta gamma original.revised.".into()));
assert_eq!(changed_blocks[1], (100, "Record 100 has alpha beta gamma payload.".into()));
assert_eq!(changed_blocks[2], (4401, "Record 100 has alpha beta gamma payload.".into()));
assert_eq!(changed_blocks[3], (4600, "Record 4599 has alpha beta gamma original.revised.".into()));
// Verify exact change types on those blocks:
let b0 = s.doc.body[0].as_para().unwrap();
assert!(b0.runs.iter().any(|r| r.props.del.is_some()) && b0.runs.iter().any(|r| r.props.ins.is_some()), "block 0 is text replacement");
let b100 = s.doc.body[100].as_para().unwrap();
assert!(b100.runs.iter().all(|r| r.props.del.is_some()) && b100.mark.del.is_some(), "block 100 is deleted whole");
let b4401 = s.doc.body[4401].as_para().unwrap();
assert!(b4401.runs.iter().all(|r| r.props.ins.is_some()) && b4401.mark.ins.is_some(), "block 4401 is inserted whole");
let b4600 = s.doc.body[4600].as_para().unwrap();
assert!(b4600.runs.iter().any(|r| r.props.del.is_some()) && b4600.runs.iter().any(|r| r.props.ins.is_some()), "block 4600 is text replacement");
s.run("review.acceptAll", &json!({})).unwrap();
assert_eq!(s.doc.plain_text(StoryRef::Body), revised);
}
/// Verifies that `mark_block` records paragraph mark revisions on whole-block deletions,
/// whole-block insertions, empty paragraphs, and ensures clean resolution via `review.acceptAll`.
#[test]
fn compare_mark_block_records_paragraph_mark_revisions_on_whole_block_changes() {
// Whole block delete/insert: test with differing paragraph count so whole-block paths trigger
let orig_multi = "Keep start.\nDelete me.\nKeep end.";
let rev_multi = "Keep start.\nKeep end.";
let mut s2 = Session::new(Document::from_text(orig_multi));
if let Some(Block::Para(p)) = s2.doc.body.get_mut(1).map(Arc::make_mut) {
p.mark.bold = Some(true);
}
s2.run("review.compare", &json!({"text": rev_multi})).unwrap();
// In s2.doc.body, block 1 was deleted whole.
// Verify that p.mark.del is recorded by mark_block and formatting is preserved.
let del_para = s2.doc.body[1].as_para().expect("block 1 must be paragraph");
assert!(del_para.mark.del.is_some(), "whole-block delete must record revision on paragraph mark");
assert_eq!(del_para.mark.bold, Some(true), "paragraph mark formatting must be preserved on deleted block");
// Accept all changes: deleted paragraph is removed
s2.run("review.acceptAll", &json!({})).unwrap();
assert_eq!(s2.doc.plain_text(StoryRef::Body), rev_multi);
// Test whole-block insertion
let mut s3 = Session::new(Document::from_text(rev_multi));
s3.run("review.compare", &json!({"text": orig_multi})).unwrap();
let ins_para = s3.doc.body[1].as_para().expect("block 1 must be paragraph");
assert!(ins_para.mark.ins.is_some(), "whole-block insert must record revision on paragraph mark");
s3.run("review.acceptAll", &json!({})).unwrap();
assert_eq!(s3.doc.plain_text(StoryRef::Body), orig_multi);
// Test empty paragraph deletion and insertion (len == 0, where only mark carries the revision)
let orig_empty = "Before.\n\nAfter.";
let rev_empty = "Before.\nAfter.";
let mut s4 = Session::new(Document::from_text(orig_empty));
s4.run("review.compare", &json!({"text": rev_empty})).unwrap();
let empty_del = s4.doc.body[1].as_para().expect("empty paragraph block");
assert_eq!(empty_del.len(), 0);
assert!(empty_del.mark.del.is_some(), "empty paragraph deletion must set mark.del");
s4.run("review.acceptAll", &json!({})).unwrap();
assert_eq!(s4.doc.plain_text(StoryRef::Body), rev_empty);
let mut s5 = Session::new(Document::from_text(rev_empty));
s5.run("review.compare", &json!({"text": orig_empty})).unwrap();
let empty_ins = s5.doc.body[1].as_para().expect("empty paragraph block");
assert_eq!(empty_ins.len(), 0);
assert!(empty_ins.mark.ins.is_some(), "empty paragraph insertion must set mark.ins");
s5.run("review.acceptAll", &json!({})).unwrap();
assert_eq!(s5.doc.plain_text(StoryRef::Body), orig_empty);
}
#[test]
fn macros_record_and_run() {
let mut s = Session::new(Document::new());
@@ -815,59 +255,6 @@ fn proofing_language_options() {
assert!(s.run("review.language", &json!({"lang": "<script>"})).is_err());
}
/// #2242: `insert.spreadsheet` fills a table from `values`, with a repeating header and a style.
#[test]
fn spreadsheet_takes_values_header_and_style() {
let mut s = Session::new(Document::from_text(""));
s.run("insert.spreadsheet", &json!({"values": [["Month", "Cases"], ["Jan", 12], ["Feb", null]], "header": true, "style": "TableGrid"})).unwrap();
let Some(Block::Table(t)) = s.doc.body.iter().map(|b| &**b).find(|b| matches!(b, Block::Table(_))) else { panic!("no table") };
assert_eq!((t.rows.len(), t.rows[0].cells.len()), (3, 2));
assert!(t.rows[0].props.header && !t.rows[1].props.header);
assert_eq!(t.rows[1].cells[1].blocks[0].as_para().unwrap().text, "12");
assert_eq!(t.props.style.as_deref(), Some("TableGrid"));
}
/// An explicit `values: null` is a parameter error, not the legacy CSV sample table: the document
/// and the undo history stay as they were. Only an absent `values` falls back to `csv`.
#[test]
fn spreadsheet_rejects_an_explicit_null_values_without_changing_anything() {
let mut s = Session::new(Document::from_text("keep"));
let depth = s.undo_depth();
for args in [json!({"values": null}), json!({"values": null, "csv": "a,b\n1,2"})] {
assert!(s.run("insert.spreadsheet", &args).is_err(), "{args}");
assert_eq!(s.doc.plain_text(StoryRef::Body), "keep");
assert!(!s.doc.body.iter().any(|b| matches!(&**b, Block::Table(_))));
assert_eq!(s.undo_depth(), depth, "{args}: no undo step");
}
s.run("insert.spreadsheet", &json!({"csv": "a,b\n1,2"})).unwrap();
let Some(Block::Table(t)) = s.doc.body.iter().map(|b| &**b).find(|b| matches!(b, Block::Table(_))) else { panic!("no table") };
assert_eq!((t.rows.len(), t.rows[0].cells.len()), (2, 2), "an absent `values` still takes the csv");
}
/// #2242: malformed `values` is an error, never a panic, and leaves the document alone.
#[test]
fn spreadsheet_rejects_hostile_values() {
let wide: Vec<Value> = (0..64).map(|i| json!(i)).collect();
let many: Vec<Value> = (0..1001).map(|_| json!(["x"])).collect();
for bad in [
json!("x"),
json!(5),
json!([]),
json!(["a"]),
json!([["a", "b"], ["c"]]),
json!([wide]),
json!(many),
json!([[{"a": 1}]]),
json!([[[1]]]),
json!([[]]),
] {
let mut s = Session::new(Document::from_text("keep"));
assert!(s.run("insert.spreadsheet", &json!({"values": bad})).is_err(), "{bad}");
assert_eq!(s.doc.plain_text(StoryRef::Body), "keep");
assert!(!s.doc.body.iter().any(|b| matches!(&**b, Block::Table(_))));
}
}
/// The inspector's removals delete straight from the document, so they aren't tracked: on a
/// document that tracks changes the text would go with no revision to show for it, and the
/// revision trail would be broken behind the user's back. The removal is refused instead,
+2 -158
View File
@@ -1,6 +1,5 @@
//! View tab and status bar: views, zoom, show/hide, panes.
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use wordcraft_doc::{Zoom, ZoomFit};
use wordcraft_layout::{NumeralMode, ViewMode};
@@ -8,14 +7,12 @@ use wordcraft_layout::{NumeralMode, ViewMode};
use super::sel_result;
use crate::{CmdError, CmdResult, CommandSpec, Session, p};
/// Toggles or sets a boolean view property, returning the updated state.
fn toggle(s: &mut Session, v: &Value, f: fn(&mut crate::ViewState) -> &mut bool) -> CmdResult {
let cur = *f(&mut s.view);
*f(&mut s.view) = p::bool(v, "value").unwrap_or(!cur);
Ok(json!({"value": *f(&mut s.view)}))
}
/// Returns the command specifications for the View ribbon tab and window management commands.
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("view.marks", "Show/Hide ¶", "Home › Paragraph", |s, v| {
@@ -43,15 +40,6 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("view.readPageColor", "Reading Page Color", "Reading › Layout", read_page_color)
.params(r#"{"value": "none" | "sepia" | "inverse"}"#)
.pure(),
CommandSpec::new("view.paperColor", "Screen Paper Color", "View › Display", |s, v| {
let color: PaperColor = serde_json::from_value(v.get("value").cloned().unwrap_or(Value::Null))
.map_err(|_| CmdError::Params("screen paper color must be document, green, cream, or gray".into()))?;
s.prefs.paper_color = color;
if color != PaperColor::Document {
s.view.dark_mode = false;
}
Ok(json!({"paperColor": color}))
}).params(r#"{"value": "document" | "green" | "cream" | "gray"}"#).pure(),
// Print Layout: hide the white space (margins, headers, footers) between pages. Screen only.
CommandSpec::new("view.hideWhiteSpace", "Hide White Space", "View › Show", |s, v| toggle(s, v, |x| &mut x.hide_white_space))
.params(r#"{"value"?: bool}"#)
@@ -145,7 +133,7 @@ pub fn specs() -> Vec<CommandSpec> {
})
.pure(),
CommandSpec::new("view.switchWindows", "Switch Windows", "View › Window", switch_windows)
.params(r#"{"id"?: n (make this open document the active one), "window"?: n (make this open window the active one)} → {"documents": [{"id", "title", "path", "dirty", "active"}]} in the order they were opened"#)
.params(r#"{"id"?: n (make this open document the active one)} → {"documents": [{"id", "title", "path", "dirty", "active"}]} in the order they were opened"#)
.pure(),
CommandSpec::new("view.split", "Split", "View › Window", |s, _| {
s.push_ui_request(json!({"toggle": "split"}));
@@ -153,30 +141,6 @@ pub fn specs() -> Vec<CommandSpec> {
})
.key("Mod+Alt+S")
.pure(),
// View › Window across WordCraft windows (#322): the desktop app's native windows
// perform these (see `Windows`), elsewhere they are disabled.
CommandSpec::new("view.arrangeAll", "Arrange All", "View › Window", |s, _| {
s.ui_requests.push(json!({"windows": "arrange"}));
Ok(json!({"windows": s.windows.others.len() + 1}))
})
.when(windows_available)
.pure(),
CommandSpec::new("view.sideBySide", "View Side by Side", "View › Window", side_by_side)
.params(r#"{"value"?: bool, "window"?: id of the other window (optional when one other window is open)}"#)
.when(|s| match windows_available(s) {
None if s.windows.side_by_side.is_none() => has_other_window(s),
r => r,
})
.pure(),
CommandSpec::new("view.syncScroll", "Synchronous Scrolling", "View › Window", |s, v| {
let Some(with) = s.windows.side_by_side else { return Err(CmdError::Disabled("turn on View Side by Side first".into())) };
s.windows.sync_scroll = p::bool(v, "value").unwrap_or(!s.windows.sync_scroll);
s.ui_requests.push(json!({"windows": "syncScroll", "with": with, "value": s.windows.sync_scroll}));
Ok(json!({"value": s.windows.sync_scroll}))
})
.params(r#"{"value"?: bool}"#)
.when(|s| if s.windows.side_by_side.is_some() { None } else { Some("turn on View Side by Side first") })
.pure(),
// The window fills the screen, OS-level full screen (#980). F11 as in browsers and most
// desktop apps; ⌃⌘F first on macOS, where Ctrl is a key of its own (elsewhere Mod is Ctrl,
// so "Mod+Ctrl+F" would read Ctrl+Ctrl+F). Escape also leaves it. The front end applies it.
@@ -206,85 +170,6 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
/// View › Window (#322): the other native windows of the app as the window being drawn sees them
/// (by window id, the ids of `ui.inspect`'s `windows`), and its Side by Side pairing. The front
/// end keeps these current as each window draws; elsewhere (web, CLI, MCP without the app) they
/// stay empty and the window commands are disabled.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct Windows {
/// The host can list and move its windows (the desktop app).
pub available: bool,
/// The active window, when known.
pub current: Option<u64>,
/// The other open windows, by id.
pub others: Vec<OtherWindow>,
/// View Side by Side is on, with this window.
pub side_by_side: Option<u64>,
/// Synchronous Scrolling (only while Side by Side is on).
pub sync_scroll: bool,
}
/// Another open WordCraft window.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct OtherWindow {
pub id: u64,
pub title: String,
}
fn windows_available(s: &Session) -> Option<&'static str> {
if s.windows.available { None } else { Some("only the desktop app has several windows") }
}
fn has_other_window(s: &Session) -> Option<&'static str> {
windows_available(s).or(if s.windows.others.is_empty() { Some("no other WordCraft window is open") } else { None })
}
/// The window a command means: `window`, or the only other one.
fn pick_window(s: &Session, v: &Value) -> Result<u64, CmdError> {
let others = &s.windows.others;
match p::u64(v, "window") {
Some(id) if others.iter().any(|w| w.id == id) => Ok(id),
Some(id) => Err(CmdError::Params(format!("no open window {id}"))),
None => match others.as_slice() {
[only] => Ok(only.id),
[] => Err(CmdError::Disabled("no other WordCraft window is open".into())),
several => {
let list: Vec<String> = several.iter().map(|w| format!("{} ({})", w.id, w.title)).collect();
Err(CmdError::Params(format!("pass `window`: one of {}", list.join(", "))))
}
},
}
}
/// View Side by Side: on with a window (left this one, right that one, scrolling together), or
/// off.
fn side_by_side(s: &mut Session, v: &Value) -> CmdResult {
let on = p::bool(v, "value").unwrap_or(s.windows.side_by_side.is_none() || v.get("window").is_some());
if !on {
if let Some(id) = s.windows.side_by_side.take() {
s.ui_requests.push(json!({"windows": "sideBySide", "off": id}));
}
s.windows.sync_scroll = false;
return Ok(json!({"value": false}));
}
let id = match (s.windows.side_by_side, v.get("window")) {
(Some(id), None) => id,
_ => pick_window(s, v)?,
};
if let Some(old) = s.windows.side_by_side.filter(|old| *old != id) {
s.ui_requests.push(json!({"windows": "sideBySide", "off": old}));
}
// The partner it already has keeps the user's Synchronous Scrolling choice and is not asked
// again; a new pairing starts with scrolling on.
if s.windows.side_by_side != Some(id) {
s.windows.side_by_side = Some(id);
s.windows.sync_scroll = true;
s.ui_requests.push(json!({"windows": "sideBySide", "with": id}));
}
Ok(json!({"value": true, "window": id, "syncScroll": s.windows.sync_scroll}))
}
/// The browser keeps full screen to itself (its own F11); the web app can't ask for it.
fn full_screen_available(_: &Session) -> Option<&'static str> {
cfg!(target_arch = "wasm32").then_some("Use the browser's full screen (F11)")
@@ -349,40 +234,6 @@ fn read_line_spacing(s: &mut Session, v: &Value) -> CmdResult {
Ok(json!({"spacing": spacing}))
}
/// Screen-only paper presets, saved with user preferences, never in the document.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum PaperColor {
#[default]
Document,
Green,
Cream,
Gray,
}
impl PaperColor {
pub const ALL: [Self; 4] = [Self::Document, Self::Green, Self::Cream, Self::Gray];
pub fn label(self) -> &'static str {
match self {
Self::Document => "Document background",
Self::Green => "Soft green",
Self::Cream => "Warm cream",
Self::Gray => "Soft gray",
}
}
pub fn color(self) -> wordcraft_doc::props::Rgb {
use wordcraft_doc::props::Rgb;
match self {
Self::Document => Rgb::WHITE,
Self::Green => Rgb(0xCC, 0xE8, 0xCF),
Self::Cream => Rgb(0xFA, 0xF0, 0xDC),
Self::Gray => Rgb(0xE8, 0xEA, 0xED),
}
}
}
/// Read Mode › View › Page Color, as in Word. Screen only: the document and its own page color
/// are unchanged.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
@@ -435,7 +286,6 @@ pub(crate) fn restore_zoom(s: &mut Session, z: Zoom) {
s.view.single_column = fit == "onePage" || fit == "pageWidth";
}
/// Sets the view zoom level or fit mode, or requests opening the Zoom dialog.
fn zoom(s: &mut Session, v: &Value) -> CmdResult {
match v.get("value") {
Some(Value::String(f)) if ["pageWidth", "onePage", "multiplePages", "textWidth"].contains(&f.as_str()) => {
@@ -461,13 +311,7 @@ fn zoom(s: &mut Session, v: &Value) -> CmdResult {
/// View › Window › Switch Windows: the open documents, or make one of them active.
fn switch_windows(s: &mut Session, v: &Value) -> CmdResult {
if let Some(w) = v.get("window").filter(|x| !x.is_null()) {
let wid = w.as_u64().ok_or_else(|| CmdError::Params("`window` must be an open window's id".into()))?;
if s.windows.available && s.windows.current != Some(wid) && !s.windows.others.iter().any(|other| other.id == wid) {
return Err(CmdError::Params(format!("no open window {wid}")));
}
s.push_ui_request(json!({"windows": "switchWindow", "window": wid}));
} else if let Some(x) = v.get("id").filter(|x| !x.is_null()) {
if let Some(x) = v.get("id").filter(|x| !x.is_null()) {
let id = x.as_u64().ok_or_else(|| CmdError::Params("`id` must be an open document's id".into()))?;
s.activate(id)?;
}
+2 -6
View File
@@ -66,10 +66,6 @@ pub struct ExportOptions {
pub word2003: bool,
/// How digits look in PDFs and page images (the Numeral setting, `System` resolved).
pub numeral: wordcraft_layout::NumeralMode,
/// A PDF or page image is written only if nothing in it is silently missing (a character no
/// face can draw, a picture that is missing or can't be decoded); else the save fails with
/// `strict: …` (#6005). Ignored for other formats.
pub strict: bool,
}
/// A document password: wiped from memory when dropped, never printed (`Debug` shows `***`).
@@ -775,8 +771,8 @@ pub fn list_dir(path: &std::path::Path, max: usize) -> Result<(Vec<DirEntry>, bo
#[cfg(not(target_arch = "wasm32"))]
pub fn package_title(path: &std::path::Path, max_file: u64, max_part: u64) -> Option<String> {
crate::file_access::check_read(&path.to_string_lossy()).ok()?;
let (file, meta) = open_regular_file(path).ok()?;
if meta.len() > max_file {
let file = std::fs::File::open(path).ok()?;
if file.metadata().ok()?.len() > max_file {
return None;
}
wordcraft_docx::package_title(std::io::BufReader::new(file), max_part)
+1 -71
View File
@@ -11,9 +11,6 @@ use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
/// Writes `bytes` safely to `path`, replacing an existing destination atomically via a
/// temporary file. If the destination's folder refuses the temporary file, a new file
/// is created directly and removed on failure; an existing file is left untouched.
pub(super) fn write(path: &Path, bytes: &[u8]) -> io::Result<()> {
let permissions = match fs::metadata(path) {
Ok(meta) if meta.is_file() => Some(meta.permissions()),
@@ -31,41 +28,11 @@ pub(super) fn write(path: &Path, bytes: &[u8]) -> io::Result<()> {
}
// Synchronize the prepared file before replacing the destination. Directory durability
// and network-filesystem/power-loss guarantees are outside this operation's contract.
sync_file(file)
file.sync_all()
});
written.map_err(explain)
}
/// Flushes `file` to stable storage (`sync_all`). On Apple platforms that is a full flush
/// (`F_FULLFSYNC`); where the file system can't do one (SMB shares and virtual file systems
/// refuse it with `ENOTSUP` #6155, `EINVAL`, or `ENOTTY`), this falls back to a plain `fsync`,
/// as Apple's own `removefile` and SQLite do. Plain `fsync` flushes to the drive controller but
/// does not guarantee disk cache durability; any other error is returned as it is.
fn sync_file(file: &File) -> io::Result<()> {
#[cfg(target_vendor = "apple")]
let plain = || rustix::fs::fsync(file).map_err(io::Error::from);
#[cfg(not(target_vendor = "apple"))]
let plain = || Ok(());
or_plain_fsync(file.sync_all(), plain)
}
/// Returns `full`, or calls `fsync()` when `full` failed only because the file system can't do a full flush.
fn or_plain_fsync(full: io::Result<()>, fsync: impl FnOnce() -> io::Result<()>) -> io::Result<()> {
match full {
Err(e) if full_flush_unsupported(&e) => fsync(),
other => other,
}
}
/// Whether `e` is a file system refusing a full flush (`F_FULLFSYNC`): `ENOTSUP` (45, SMB),
/// `EINVAL` (22), or `ENOTTY` (25) on Apple platforms. Never elsewhere: there `sync_all` is a plain `fsync`.
fn full_flush_unsupported(e: &io::Error) -> bool {
const EINVAL: i32 = 22;
const ENOTTY: i32 = 25;
const ENOTSUP: i32 = 45;
cfg!(target_vendor = "apple") && matches!(e.raw_os_error(), Some(ENOTSUP | EINVAL | ENOTTY))
}
/// A folder that keeps no Unix modes refuses to set one with "Operation not supported": a NAS
/// share mounted by GVFS (`smb://` in the Linux file manager) refuses every mode change (#3003).
/// There is no mode to keep there, so the save goes on; any other failure still stops it.
@@ -481,41 +448,4 @@ mod tests {
assert_eq!(fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o640);
assert_eq!(fs::read(&path).unwrap(), b"Saved again.");
}
/// A share that refuses the full flush (`F_FULLFSYNC`, #6155) still saves through a plain fsync
/// on Apple platforms; every other sync error still fails the save.
#[test]
fn a_refused_full_flush_falls_back_to_a_plain_fsync_on_apple_only() {
use std::cell::Cell;
let refused = |errno| Err(io::Error::from_raw_os_error(errno));
for (errno, name) in [(45, "ENOTSUP, SMB"), (22, "EINVAL"), (25, "ENOTTY")] {
let fsynced = Cell::new(false);
let r = or_plain_fsync(refused(errno), || {
fsynced.set(true);
Ok(())
});
let apple = cfg!(target_vendor = "apple");
assert_eq!(r.is_ok(), apple, "{name}");
assert_eq!(fsynced.get(), apple, "{name}: the plain fsync runs only on Apple platforms");
}
// The plain fsync's own failure is reported.
if cfg!(target_vendor = "apple") {
let r = or_plain_fsync(refused(45), || Err(io::Error::from_raw_os_error(5)));
assert_eq!(r.map_err(|e| e.raw_os_error()), Err(Some(5)));
}
// Real errors (EIO, ENOSPC, EACCES) never fall back, and success needs no fallback.
for errno in [5, 28, 13] {
let r = or_plain_fsync(refused(errno), || panic!("no fallback for errno {errno}"));
assert_eq!(r.map_err(|e| e.raw_os_error()), Err(Some(errno)));
}
assert!(or_plain_fsync(Ok(()), || panic!("no fallback after a full flush")).is_ok());
}
#[test]
fn sync_file_flushes_a_local_file() {
let d = Scratch::new();
let path = d.0.join("f.bin");
let file = OpenOptions::new().write(true).create_new(true).open(&path).unwrap();
sync_file(&file).unwrap();
}
}
+9 -14
View File
@@ -10,15 +10,10 @@ pub fn open(ext: &str, bytes: &[u8], page: &wordcraft_doc::SectionProps) -> Opti
"doc" | "dot" => Some(wordcraft_docbin::read(bytes).map_err(|e| e.to_string())),
// Word XML Document (Flat OPC) or Word 2003 XML, told apart by the root element.
"xml" => Some(wordcraft_docx::read_xml(bytes).map_err(|e| e.to_string())),
// HTML, Markdown, RTF…: pictures fit the text width (see `io::fit_pictures`). An
// OpenDocument file says how big they are, and one wider than the text is meant to be;
// changing it would take its paragraph off being written as read. Its reader fits those
// that state no size.
// HTML, Markdown, ODT, RTF…: pictures fit the text width (see `io::fit_pictures`).
other => wordcraft_formats::import_on(other, bytes, page).map(|r| {
r.map(|mut doc| {
if !matches!(other, "odt" | "ott" | "fodt") {
crate::io::fit_pictures(&mut doc);
}
crate::io::fit_pictures(&mut doc);
draw_charts(&mut doc);
doc
})
@@ -65,7 +60,13 @@ pub fn save(ext: &str, doc: &Document, opts: &crate::io::ExportOptions) -> Optio
// Output shows the fields Word fills in as it prints (see `output_fields`).
let out = crate::cmd::references::output_fields(doc);
let doc = out.as_ref().unwrap_or(doc);
Some(crate::strict::export_pdf(doc, opts))
let pdf = wordcraft_pdf::PdfOptions {
include_markup: opts.markup,
comments: opts.markup && opts.comments,
numeral: opts.numeral,
..Default::default()
};
Some(wordcraft_pdf::export(doc, &pdf).map_err(|e| e.to_string()))
}
other => wordcraft_formats::export_with(other, doc, &wordcraft_formats::ExportOptions { comments: opts.markup && opts.comments }),
}
@@ -79,12 +80,6 @@ pub fn render_png(doc: &Document, page: usize, scale: f32, opts: &crate::io::Exp
let lo = wordcraft_layout::LayoutOptions { comment_balloons: opts.markup && opts.comments, numeral: opts.numeral, ..Default::default() };
let l = wordcraft_layout::layout(doc, &mut wordcraft_layout::LayoutCache::new(), &lo);
let p = l.pages.get(page).ok_or_else(|| format!("no page {}", page + 1))?;
if opts.strict {
let report = crate::strict::strict_report(doc, &l, &crate::strict::png_display(), Some(page..=page), wordcraft_layout::StrictOutput::Png);
if !report.passed() {
return Err(report.refusal());
}
}
let img = wordcraft_render::render_page(doc, p, scale, &Default::default());
Ok(img.to_png())
}
-1
View File
@@ -25,7 +25,6 @@ pub mod sample;
mod session;
pub(crate) mod snapshot;
pub mod speech;
pub mod strict;
pub(crate) mod view;
use std::collections::HashMap;
-9
View File
@@ -7,9 +7,6 @@ use serde::{Deserialize, Serialize};
pub struct Prefs {
/// Word Count includes text boxes, footnotes and endnotes (Word's default).
pub count_notes: bool,
/// On-screen paper for pages without a document background; excluded from saved documents.
#[serde(deserialize_with = "lenient_paper_color")]
pub paper_color: crate::cmd::view::PaperColor,
/// Track Changes Options: what markup shows and how revisions are drawn (per user, as in
/// Word).
pub markup: wordcraft_layout::display::MarkupOptions,
@@ -67,7 +64,6 @@ pub struct Prefs {
impl Default for Prefs {
fn default() -> Self {
Prefs {
paper_color: Default::default(),
count_notes: true,
markup: Default::default(),
print_markup: false,
@@ -109,11 +105,6 @@ fn lenient_default_language<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Op
Ok(value.as_str().and_then(wordcraft_proof::lang::normalize_tag))
}
fn lenient_paper_color<'de, D: serde::Deserializer<'de>>(d: D) -> Result<crate::cmd::view::PaperColor, D::Error> {
let value = serde_json::Value::deserialize(d)?;
Ok(serde_json::from_value(value).unwrap_or_default())
}
/// Ignore an invalid saved Page Color without discarding other preferences.
fn lenient_read_page_color<'de, D: serde::Deserializer<'de>>(d: D) -> Result<crate::cmd::view::ReadPageColor, D::Error> {
let value = serde_json::Value::deserialize(d)?;

Some files were not shown because too many files have changed in this diff Show More