mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-05 17:59:58 -06:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4fee0ef1d | ||
|
|
f691fe9f11 |
+1
-20
@@ -7,30 +7,11 @@ updates:
|
||||
interval: "daily"
|
||||
time: "06:00"
|
||||
timezone: "America/New_York"
|
||||
# Minor and patch updates share one PR. A breaking bump gets its own PR so one API change
|
||||
# doesn't hold the safe updates back (#2973). For cargo, Dependabot counts a 0.y bump of a
|
||||
# 0.x crate as major, as Cargo does. A dependency goes to the first group it matches, so the
|
||||
# crate families that must move together come before the catch-all.
|
||||
# Consolidate updates into a single PR rather than one PR per crate
|
||||
groups:
|
||||
egui:
|
||||
patterns:
|
||||
- "egui"
|
||||
- "egui_*"
|
||||
- "eframe"
|
||||
- "winit"
|
||||
fonts:
|
||||
patterns:
|
||||
- "skrifa"
|
||||
- "harfrust"
|
||||
hayro:
|
||||
patterns:
|
||||
- "hayro-*"
|
||||
cargo-dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
update-types:
|
||||
- "minor"
|
||||
- "patch"
|
||||
commit-message:
|
||||
prefix: "chore(deps)"
|
||||
|
||||
|
||||
@@ -46,4 +46,3 @@ jobs:
|
||||
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
|
||||
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
|
||||
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
|
||||
LINUX_SIGNING_KEY: ${{ secrets.LINUX_SIGNING_KEY }}
|
||||
|
||||
Generated
+2
-14
@@ -1081,7 +1081,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-pdf"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
dependencies = [
|
||||
"krilla",
|
||||
"thiserror 2.0.21",
|
||||
@@ -1157,15 +1157,6 @@ dependencies = [
|
||||
"zip 9.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-web-host"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=2e2312ddaf21bc3d85cd3395d0955c7d239306f6#2e2312ddaf21bc3d85cd3395d0955c7d239306f6"
|
||||
dependencies = [
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-window"
|
||||
version = "0.1.0"
|
||||
@@ -2969,7 +2960,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "krilla"
|
||||
version = "0.8.2"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bumpalo",
|
||||
@@ -6824,7 +6815,6 @@ dependencies = [
|
||||
"wordcraft-engine",
|
||||
"wordcraft-fonts",
|
||||
"wordcraft-ui-egui",
|
||||
"x11rb",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
@@ -6890,7 +6880,6 @@ dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
"quick-xml 0.41.0",
|
||||
"serde_json",
|
||||
"sha1 0.11.0",
|
||||
"sha2 0.11.1",
|
||||
"thiserror 2.0.21",
|
||||
@@ -7095,7 +7084,6 @@ dependencies = [
|
||||
name = "wordcraft-web"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"craft-web-host",
|
||||
"eframe",
|
||||
"egui",
|
||||
"js-sys",
|
||||
|
||||
+2
-4
@@ -67,7 +67,7 @@ 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 = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", version = "=0.1.0", default-features = false, features = ["write"] }
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", 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 = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] }
|
||||
quick-xml = "0.41"
|
||||
@@ -92,8 +92,6 @@ zeroize = "1"
|
||||
# dependency of other crates.
|
||||
libc = "0.2"
|
||||
|
||||
# time
|
||||
|
||||
# ui
|
||||
egui = "0.37"
|
||||
egui_extras = { version = "0.37", default-features = false }
|
||||
@@ -134,7 +132,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 = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
|
||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||
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" }
|
||||
|
||||
@@ -299,21 +299,10 @@ adapter. The log file records the choice (lines starting with
|
||||
RISC-V (riscv64): `wordcraft-<ver>-linux-riscv64.tar.gz`, a tarball cross-compiled for glibc 2.39+
|
||||
(Ubuntu 24.04, Debian 13 and newer).
|
||||
|
||||
**Read Aloud** (Review › Read Aloud) speaks through a speech program installed on the system: it
|
||||
tries `espeak-ng`, then `espeak`, and when neither is installed it talks to Speech Dispatcher.
|
||||
The `.rpm` package recommends `espeak-ng`; the `.deb` recommends `espeak-ng` or
|
||||
`speech-dispatcher`, so the package manager installs one of the two by default. With the
|
||||
AppImage or the tarball, install one yourself if neither is there (`sudo apt install espeak-ng`,
|
||||
`sudo dnf install espeak-ng`, `sudo pacman -S espeak-ng`). The Flatpak doesn't speak yet: it has
|
||||
no audio access and doesn't include `espeak-ng`, `espeak` or Speech Dispatcher.
|
||||
|
||||
**Wayland:** on a Wayland desktop the app runs through XWayland when it is available, because
|
||||
winit (the windowing library) supports dragging files into the window only on X11. To use the
|
||||
native Wayland backend instead (for example for sharper text with fractional scaling, at the cost
|
||||
of file drag-and-drop), choose **File › Options › Advanced › Linux display server › Wayland**; it
|
||||
applies the next time WordCraft starts, and **X11 (XWayland)** or **Automatic** switches back. The
|
||||
`WORDCRAFT_WAYLAND` environment variable overrides the setting: `1` for native Wayland, `0` for
|
||||
XWayland:
|
||||
of file drag-and-drop), start it with `WORDCRAFT_WAYLAND=1`:
|
||||
|
||||
```sh
|
||||
WORDCRAFT_WAYLAND=1 wordcraft
|
||||
|
||||
+1
-43
File diff suppressed because one or more lines are too long
@@ -68,6 +68,10 @@ Paper: documents created here, and HTML, Markdown and text files (which give no
|
||||
the system region's paper (A4, or Letter in the US and other Letter regions), read from
|
||||
LC_ALL / LC_PAPER / LANG or the platform. Set WORDCRAFT_REGION=en-US (or another tag) for the
|
||||
same output on every machine.
|
||||
|
||||
OpenDocument (.odt, .ott, .fodt) files fill in what their standard leaves to the application in
|
||||
Microsoft Word's style (WORDCRAFT_ODF=word, the default) or LibreOffice's
|
||||
(WORDCRAFT_ODF=libreoffice); see docs/odf-interpretation.md.
|
||||
";
|
||||
|
||||
/// The subcommands, in `USAGE` order; `--help` or `-h` after any of them prints `USAGE` (#968).
|
||||
@@ -86,11 +90,18 @@ fn missing_argument() -> Failure {
|
||||
/// ([`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, String> {
|
||||
open_in(path, password, odf_profile()?)
|
||||
}
|
||||
|
||||
/// [`open`] with the OpenDocument style `odf`, which the session keeps: a later `file.open` or
|
||||
/// `file.new` in a `run` script reads files the same way.
|
||||
fn open_in(path: &Path, password: Option<&str>, odf: wordcraft_doc::odf_profile::OdfProfile) -> 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, ®ion)?;
|
||||
let (doc, encrypted) = wordcraft_engine::io::open_path_in(path, password, ®ion, &odf)?;
|
||||
let mut s = session(doc);
|
||||
s.prefs.odf = odf;
|
||||
s.path = Some(path.into());
|
||||
if encrypted {
|
||||
s.password = password.map(wordcraft_engine::io::Password::new);
|
||||
@@ -172,6 +183,24 @@ fn session(doc: wordcraft_doc::Document) -> Session {
|
||||
s
|
||||
}
|
||||
|
||||
/// How OpenDocument files fill in what their standard leaves open: `WORDCRAFT_ODF` names a preset
|
||||
/// (`word`, Microsoft Word's style and the default, or `libreoffice`; `wordcraft` is taken too).
|
||||
fn odf_profile() -> Result<wordcraft_doc::odf_profile::OdfProfile, String> {
|
||||
odf_from(std::env::var("WORDCRAFT_ODF").ok())
|
||||
}
|
||||
|
||||
/// [`odf_profile`] from `WORDCRAFT_ODF`'s value, if set.
|
||||
fn odf_from(var: Option<String>) -> Result<wordcraft_doc::odf_profile::OdfProfile, String> {
|
||||
use wordcraft_doc::odf_profile::{OdfPreset, OdfProfile};
|
||||
match var.filter(|v| !v.trim().is_empty()) {
|
||||
None => Ok(OdfProfile::default()),
|
||||
Some(v) => match OdfPreset::from_id(&v).filter(|p| *p != OdfPreset::Custom) {
|
||||
Some(p) => Ok(OdfProfile::of(p)),
|
||||
None => Err(format!("WORDCRAFT_ODF={v}: one of word, libreoffice")),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The system region, read as the app reads it (locale variables, else the platform's locale).
|
||||
fn system_region() -> wordcraft_engine::Region {
|
||||
wordcraft_engine::Region::from_locale_vars(|name| std::env::var(name).ok(), sys_locale::get_locale)
|
||||
@@ -430,12 +459,7 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
|
||||
"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 format = arg_value(&rest, "--format");
|
||||
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");
|
||||
@@ -509,6 +533,8 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
|
||||
// run starts with one open document.
|
||||
(None, t) => {
|
||||
let mut s = session(wordcraft_doc::Document::new());
|
||||
// The script's own `file.open`s read OpenDocument files in `WORDCRAFT_ODF`'s style.
|
||||
s.prefs.odf = odf_profile()?;
|
||||
let first = s.document_id();
|
||||
let params = t.map_or_else(|| json!({}), |t| json!({"template": t}));
|
||||
s.run("file.new", ¶ms).map_err(|e| e.to_string())?;
|
||||
|
||||
@@ -75,22 +75,6 @@ fn unknown_options_are_rejected_before_file_access() {
|
||||
assert_eq!(run(&args(&["zotero", "refresh", "missing.docx", "--prot", "1"])), Err("zotero: unknown option --prot".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_rejects_malformed_format_options_before_file_access() {
|
||||
for (options, error) in [
|
||||
(vec!["--format"], "convert: --format requires a value"),
|
||||
(vec!["--format", "--markup"], "convert: --format requires a value"),
|
||||
(vec!["--format", "flatopc", "--format", "wordml2003"], "convert: duplicate --format option"),
|
||||
] {
|
||||
let mut argv = args(&["convert", "missing.docx", "out.xml"]);
|
||||
argv.extend(args(&options));
|
||||
assert_eq!(run(&argv), Err(error.into()), "{options:?}");
|
||||
}
|
||||
// A valid single value reaches file access, rather than a format-option error.
|
||||
let error = run(&args(&["convert", "missing.docx", "out.xml", "--format", "flatopc"])).unwrap_err().to_string();
|
||||
assert!(error.starts_with("missing.docx:"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_or_h_after_a_subcommand_asks_for_the_usage() {
|
||||
for cmd in SUBCOMMANDS {
|
||||
@@ -559,3 +543,21 @@ fn script_parse_resolves_paths_against_the_base() {
|
||||
assert_eq!(s.save, vec!["o.docx".to_string()]);
|
||||
assert_eq!(s.commands.len(), 1);
|
||||
}
|
||||
|
||||
/// `WORDCRAFT_ODF` names a display style, and a session opened in one keeps it, so a `run`
|
||||
/// script's later `file.open` reads OpenDocument files the same way.
|
||||
#[test]
|
||||
fn the_odf_style_is_kept_by_the_session() {
|
||||
use wordcraft_doc::odf_profile::OdfProfile;
|
||||
assert_eq!(odf_from(None), Ok(OdfProfile::default()));
|
||||
assert_eq!(odf_from(Some("libreoffice".into())), Ok(OdfProfile::libreoffice()));
|
||||
assert_eq!(odf_from(Some("word".into())), Ok(OdfProfile::word()));
|
||||
assert!(odf_from(Some("custom".into())).is_err());
|
||||
let dir = std::env::temp_dir().join(format!("wc-cli-odf-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("a.txt");
|
||||
std::fs::write(&path, "Hello").unwrap();
|
||||
let s = open_in(&path, None, OdfProfile::libreoffice()).unwrap();
|
||||
assert_eq!(s.prefs.odf, OdfProfile::libreoffice());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
@@ -16,8 +16,6 @@ path = "src/main.rs"
|
||||
[dependencies]
|
||||
wordcraft-engine = { workspace = true }
|
||||
wordcraft-ui-egui = { workspace = true }
|
||||
# The host messages (storytold/craft-libs): one contract and one bridge for the web builds.
|
||||
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "2e2312ddaf21bc3d85cd3395d0955c7d239306f6", version = "0.1.0" }
|
||||
egui = { workspace = true }
|
||||
log = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
@@ -31,4 +29,4 @@ rfd = { workspace = true }
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "BeforeUnloadEvent", "ClipboardEvent", "DataTransfer", "FileList", "File", "DomStringList", "IdbFactory", "IdbOpenDbRequest", "IdbRequest", "IdbDatabase", "IdbObjectStore", "IdbTransaction", "IdbTransactionMode", "MediaQueryList", "Storage", "MessageEvent", "UrlSearchParams", "CustomEvent", "CustomEventInit"] }
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "BeforeUnloadEvent", "ClipboardEvent", "DataTransfer", "FileList", "File", "DomStringList", "IdbFactory", "IdbOpenDbRequest", "IdbRequest", "IdbDatabase", "IdbObjectStore", "IdbTransaction", "IdbTransactionMode", "MediaQueryList", "Storage", "MessageEvent", "UrlSearchParams"] }
|
||||
|
||||
+107
-65
@@ -1,20 +1,14 @@
|
||||
//! Host modes: a page that runs WordCraft (as an `<iframe>`, or in its own document) opens
|
||||
//! documents in it and stores what it saves instead of the browser downloading. The messages and
|
||||
//! the two transports are `craft-web-host`'s (storytold/craft-libs), the same contract as the
|
||||
//! other Craft apps' bridges, with the `wordcraft:` prefix. This module is WordCraft's part of it.
|
||||
//! The `postMessage` bridge to a same-origin parent page (`?host=parent`), for pages that embed
|
||||
//! WordCraft in an `<iframe>` and keep its files (a Nextcloud app, a personal file server). The
|
||||
//! same messages as GridCraft's and PdfCraft's bridges; each is a plain object with a `type`:
|
||||
//!
|
||||
//! - `?host=parent` (an `<iframe>`, such as a Nextcloud app) talks to the same-origin parent over
|
||||
//! `postMessage`; `data-host="page"` on the canvas (the page's own document, no iframe) uses
|
||||
//! DOM `CustomEvent`s on the canvas instead. `?host=parent` wins when both are present. With no
|
||||
//! parent, or a parent on another origin, there is no bridge: the normal app with downloads.
|
||||
//! - `wordcraft:ready` once the app is running and listening; send a file now. WordCraft reads
|
||||
//! bytes, so it says `file: false` and a host sends `bytes`.
|
||||
//! - `wordcraft:open` (from the host): `{ name, bytes }`. It opens like a document picked with
|
||||
//! File › Open or dropped on the window; a file over [`MAX_OPEN_BYTES`] is refused before it is
|
||||
//! copied.
|
||||
//! - `wordcraft:save` (to the host): `{ name, saveAs, bytes }` for every file WordCraft would
|
||||
//! otherwise download. The host stores it and reports failures itself. `saveAs` says whether
|
||||
//! the file becomes the document's file:
|
||||
//! - `wordcraft:ready` (to the parent): `{ version }`, sent once the app is running and
|
||||
//! listening; send a file now.
|
||||
//! - `wordcraft:open` (from the parent): `{ name, bytes }`, bytes an `ArrayBuffer` or
|
||||
//! `Uint8Array`. It opens like a document picked with File › Open or dropped on the window.
|
||||
//! - `wordcraft:save` (to the parent): `{ name, saveAs, bytes }` (a transferred `ArrayBuffer`) for
|
||||
//! every file WordCraft would otherwise download. The parent stores it and reports failures
|
||||
//! itself. `saveAs` says whether the file becomes the document's file:
|
||||
//! - Save: `false`. It writes the opened file under its own name and format, as the desktop
|
||||
//! app does. A name the parent never sent (an untitled document, an imported .doc saved as
|
||||
//! .docx) is a new file: `true`, as for Save As.
|
||||
@@ -23,57 +17,70 @@
|
||||
//! is now that file; the next Save sends its name with `false`.
|
||||
//! - Save As or Export to a format that doesn't (PDF, Markdown, plain text), Export as PDF and
|
||||
//! Export Page as PNG: `false`. They are copies; the document's file stays the same.
|
||||
//! - `wordcraft:dirty` whenever unsaved changes appear or go away, `wordcraft:failed` when the
|
||||
//! app couldn't start.
|
||||
//! - `wordcraft:dirty` (to the parent): `{ dirty }` whenever unsaved changes appear or go away.
|
||||
//! - `wordcraft:failed` (to the parent): `{ error }` when the app couldn't start.
|
||||
//!
|
||||
//! In both host modes the host sends the document to edit, so no sample opens, saves are handed
|
||||
//! to the host rather than downloaded, the host guards leaving the page, and `?author=` names the
|
||||
//! user for comments and tracked changes.
|
||||
//! Only the parent window on the page's own origin is heard or answered. In host mode the parent
|
||||
//! guards leaving the page, and `?author=` names the user for comments and tracked changes.
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use craft_web_host::web::{FileData, Host, Message};
|
||||
use wasm_bindgen::JsCast as _;
|
||||
use wasm_bindgen::JsValue;
|
||||
use wasm_bindgen::closure::Closure;
|
||||
use wordcraft_ui_egui::{Inbox, Services, WordApp};
|
||||
|
||||
/// The app id in the host messages (`wordcraft:ready`).
|
||||
const APP: &str = "wordcraft";
|
||||
|
||||
/// The name a document gets when the host sent none (the crate says `document` for a blank name).
|
||||
const DEFAULT_NAME: &str = "Document.docx";
|
||||
|
||||
/// The largest document taken from the host page, refused before it is copied.
|
||||
const MAX_OPEN_BYTES: u64 = 512 << 20;
|
||||
|
||||
/// The longest user name taken from `?author=`.
|
||||
const MAX_AUTHOR_CHARS: usize = 200;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Bridge {
|
||||
host: Rc<Host>,
|
||||
window: web_sys::Window,
|
||||
parent: web_sys::Window,
|
||||
origin: String,
|
||||
}
|
||||
|
||||
impl Bridge {
|
||||
/// The bridge for this page, or `None` when WordCraft isn't hosted: no `?host=parent` and no
|
||||
/// `data-host="page"` on the canvas, an unframed `?host=parent` page (it stays the normal app
|
||||
/// with downloads), or a parent on another origin.
|
||||
pub fn detect(canvas: &web_sys::HtmlCanvasElement) -> Option<Self> {
|
||||
Some(Self { host: Rc::new(Host::connect(APP, canvas)?) })
|
||||
/// `None` when the page isn't framed (there is no parent to talk to), or when the parent is
|
||||
/// on another origin: messages only go to this origin, so the browser would drop every save
|
||||
/// without an error while the document showed as saved.
|
||||
pub fn new() -> Option<Self> {
|
||||
let window = web_sys::window()?;
|
||||
let parent = window.parent().ok().flatten()?;
|
||||
if js_sys::Object::is(&parent, &window) {
|
||||
return None;
|
||||
}
|
||||
let origin = window.location().origin().ok()?;
|
||||
// An opaque origin (a `file://` page in some browsers) can't be a `postMessage` target.
|
||||
if origin == "null" {
|
||||
return None;
|
||||
}
|
||||
// Reading another origin's location throws, which is `None` too.
|
||||
if parent.location().origin().ok()? != origin {
|
||||
return None;
|
||||
}
|
||||
Some(Self { window, parent, origin })
|
||||
}
|
||||
|
||||
/// Hand saved and exported files to the host instead of downloading them, report unsaved
|
||||
/// Hand saved and exported files to the parent instead of downloading them, report unsaved
|
||||
/// changes to it, and hear the files it sends.
|
||||
pub fn connect(&self, services: &mut Services, inbox: Inbox, ctx: egui::Context) {
|
||||
self.listen(inbox, ctx);
|
||||
let bridge = self.clone();
|
||||
services.host_save = Some(Box::new(move |name: &str, bytes: &[u8], save_as: bool| bridge.post_save(name, bytes, save_as)));
|
||||
// Anything else that would download goes to the host too.
|
||||
// Anything else that would download goes to the parent too.
|
||||
let bridge = self.clone();
|
||||
services.download = Some(Box::new(move |name: &str, bytes: &[u8]| bridge.post_save(name, bytes, false)));
|
||||
// The browser's save picker is never used, so no save finishes later.
|
||||
services.async_saves = None;
|
||||
// The crate does not repeat a state it already reported.
|
||||
let host = self.host.clone();
|
||||
services.on_dirty = Some(Box::new(move |dirty: bool| host.post_dirty(dirty)));
|
||||
let bridge = self.clone();
|
||||
let reported: Rc<Cell<Option<bool>>> = Rc::default();
|
||||
services.on_dirty = Some(Box::new(move |dirty: bool| {
|
||||
if reported.get() != Some(dirty) && bridge.send(&[("type", "wordcraft:dirty".into()), ("dirty", dirty.into())]) {
|
||||
reported.set(Some(dirty));
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/// `?author=`: the host's name for the user signs comments and tracked changes.
|
||||
@@ -85,43 +92,78 @@ impl Bridge {
|
||||
}
|
||||
|
||||
pub fn post_ready(&self) {
|
||||
let _ = self.host.post_ready(env!("CARGO_PKG_VERSION"), false);
|
||||
self.send(&[("type", "wordcraft:ready".into()), ("version", env!("CARGO_PKG_VERSION").into())]);
|
||||
}
|
||||
|
||||
/// The app couldn't start (no WebGPU or WebGL2, for example): let the host say so.
|
||||
/// The app couldn't start (no WebGPU or WebGL2, for example): let the parent say so.
|
||||
pub fn post_failed(&self, error: &str) {
|
||||
let _ = self.host.post_failed(error, None);
|
||||
self.send(&[("type", "wordcraft:failed".into()), ("error", error.into())]);
|
||||
}
|
||||
|
||||
fn post_save(&self, path: &str, bytes: &[u8], save_as: bool) -> Result<(), String> {
|
||||
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
|
||||
self.host.post_save(&name, bytes, None, save_as)
|
||||
let buffer = js_sys::Uint8Array::from(bytes).buffer();
|
||||
let message = message(&[
|
||||
("type", "wordcraft:save".into()),
|
||||
("name", name.as_str().into()),
|
||||
("saveAs", save_as.into()),
|
||||
("bytes", buffer.clone().into()),
|
||||
])
|
||||
.ok_or_else(|| "couldn't build the message".to_string())?;
|
||||
self.parent
|
||||
.post_message_with_transfer(&message, &self.origin, &js_sys::Array::of1(&buffer))
|
||||
.map_err(|e| e.as_string().unwrap_or_else(|| format!("{e:?}")))
|
||||
}
|
||||
|
||||
/// Hear `wordcraft:open` and queue the file like a dropped one.
|
||||
/// Hear `wordcraft:open` from the parent and queue the file like a dropped one.
|
||||
fn listen(&self, inbox: Inbox, ctx: egui::Context) {
|
||||
self.host.listen(move |message| {
|
||||
if let Message::Open { name, data } = message {
|
||||
deliver_open(&inbox, &ctx, &name, &data);
|
||||
let parent = self.parent.clone();
|
||||
let origin = self.origin.clone();
|
||||
let on_message = Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |event: web_sys::MessageEvent| {
|
||||
if event.origin() != origin || !event.source().is_some_and(|s| js_sys::Object::is(&s, &parent)) {
|
||||
return;
|
||||
}
|
||||
let data = event.data();
|
||||
if string(&data, "type").as_deref() != Some("wordcraft:open") {
|
||||
return;
|
||||
}
|
||||
let name = string(&data, "name").filter(|n| !n.trim().is_empty()).unwrap_or_else(|| "Document.docx".to_string());
|
||||
let Some(bytes) = bytes(&data) else { return };
|
||||
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes));
|
||||
ctx.request_repaint();
|
||||
});
|
||||
if self.window.add_event_listener_with_callback("message", on_message.as_ref().unchecked_ref()).is_ok() {
|
||||
// The listener lives as long as the page.
|
||||
on_message.forget();
|
||||
}
|
||||
}
|
||||
|
||||
/// Post a message to the parent; false if it couldn't be sent.
|
||||
fn send(&self, fields: &[(&str, JsValue)]) -> bool {
|
||||
message(fields).is_some_and(|m| self.parent.post_message(&m, &self.origin).is_ok())
|
||||
}
|
||||
}
|
||||
|
||||
/// The file an open message carries, pushed onto the inbox for the next frame, like a dropped
|
||||
/// file. A host's file is untrusted input: an oversized one is refused before it is copied into
|
||||
/// the app's memory (the data's own length, not the host's claim). The crate has cleaned the name
|
||||
/// (and cut it to 255 characters); a blank one comes as `document` and becomes `Document.docx`.
|
||||
fn deliver_open(inbox: &Inbox, ctx: &egui::Context, name: &str, data: &FileData) {
|
||||
let name = if name == "document" { DEFAULT_NAME } else { name }.to_string();
|
||||
let FileData::Bytes(array) = data else {
|
||||
log::warn!("host file {name} came as a File; WordCraft reads bytes only; not opened");
|
||||
return;
|
||||
};
|
||||
if data.size() > MAX_OPEN_BYTES as f64 {
|
||||
log::warn!("host file is {} bytes, over the {} MiB host open limit; not opened", data.size(), MAX_OPEN_BYTES >> 20);
|
||||
return;
|
||||
fn message(fields: &[(&str, JsValue)]) -> Option<js_sys::Object> {
|
||||
let m = js_sys::Object::new();
|
||||
for (key, value) in fields {
|
||||
js_sys::Reflect::set(&m, &JsValue::from_str(key), value).ok()?;
|
||||
}
|
||||
Some(m)
|
||||
}
|
||||
|
||||
fn string(data: &JsValue, key: &str) -> Option<String> {
|
||||
js_sys::Reflect::get(data, &JsValue::from_str(key)).ok()?.as_string()
|
||||
}
|
||||
|
||||
/// `bytes` as an `ArrayBuffer` or `Uint8Array`.
|
||||
fn bytes(data: &JsValue) -> Option<Vec<u8>> {
|
||||
let bytes = js_sys::Reflect::get(data, &JsValue::from_str("bytes")).ok()?;
|
||||
if let Some(array) = bytes.dyn_ref::<js_sys::Uint8Array>() {
|
||||
Some(array.to_vec())
|
||||
} else if bytes.is_instance_of::<js_sys::ArrayBuffer>() {
|
||||
Some(js_sys::Uint8Array::new(&bytes).to_vec())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, array.to_vec()));
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
@@ -9,10 +9,8 @@
|
||||
//! Preferences (theme, interface size, editing options…) are kept in `localStorage` (`prefs`).
|
||||
//! URL flags: `?webgl` forces WebGL2; `?sample` opens the sample document.
|
||||
//! `?host=parent` (inside an `<iframe>`) talks to a same-origin parent page instead: it sends the
|
||||
//! document to open and stores what WordCraft saves (`host`). A page can also host WordCraft in
|
||||
//! its own document (no iframe) with `data-host="page"` on the canvas, talking over DOM
|
||||
//! `CustomEvent`s on the canvas. `&author=` names the user for comments and tracked changes in
|
||||
//! either host mode.
|
||||
//! document to open and stores what WordCraft saves (`host`), and `&author=` names the user for
|
||||
//! comments and tracked changes.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
|
||||
@@ -28,12 +28,9 @@ pub fn start() {
|
||||
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
|
||||
return;
|
||||
};
|
||||
// Host modes (`craft-web-host`): `?host=parent`, a same-origin page framing WordCraft, opens documents in
|
||||
// it and stores what it saves (`host`); unframed there is no parent, so that URL on its
|
||||
// own stays the normal app with downloads. `data-host="page"` on the canvas opts a page
|
||||
// in instead: it runs WordCraft in its own document and talks over DOM `CustomEvent`s
|
||||
// on the canvas (`host`). The host guards leaving the page, so this one doesn't.
|
||||
let bridge = crate::host::Bridge::detect(&canvas);
|
||||
// `?host=parent`: a same-origin page framing WordCraft opens documents in it and stores
|
||||
// what it saves (`host`). That page guards leaving it, so this one doesn't.
|
||||
let bridge = if query_param("host").as_deref() == Some("parent") { crate::host::Bridge::new() } else { None };
|
||||
let app_bridge = bridge.clone();
|
||||
let dirty = Rc::new(Cell::new(false));
|
||||
let saves = AsyncSaves::default();
|
||||
|
||||
@@ -38,14 +38,13 @@ fmv-macos-events = "=0.1.0"
|
||||
# The system's Reduce motion setting (System Settings › Accessibility › Display), read through
|
||||
# NSWorkspace: a safe call in these bindings. Already in the build through eframe, rfd and arboard,
|
||||
# at this version; this adds the `NSAccessibility` bindings and, to fit the title bar around the
|
||||
# traffic lights, the window, button and view ones; and `NSPasteboard` for the clipboard's RTF.
|
||||
# traffic lights, the window, button and view ones.
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSWorkspace", "NSAccessibility", "NSApplication", "NSWindow", "NSButton", "NSControl", "NSView", "NSResponder", "NSPasteboard"] }
|
||||
objc2 = "0.6"
|
||||
# A copied equation's MathML goes on the pasteboard as `public.mathml.presentation` beside
|
||||
# arboard's HTML and text (#1297), through NSPasteboard's safe `setString:forType:`. Already in
|
||||
# the build through arboard, at this version; this adds only the NSString bindings, and NSData
|
||||
# for the clipboard's RTF (`public.rtf`).
|
||||
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSString", "NSData"] }
|
||||
# the build through arboard, at this version; this adds only the NSString bindings.
|
||||
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSString"] }
|
||||
# Closing the window must quit through NSApplication rather than by dropping the NSWindow
|
||||
# inside the AppKit run loop (see `schedule_macos_terminate`).
|
||||
dispatch2 = "0.3"
|
||||
@@ -59,9 +58,6 @@ zbus = { version = "5", default-features = false, features = ["async-io", "block
|
||||
# types that arboard doesn't read, and the formats offered. Already in the build through arboard's
|
||||
# `wayland-data-control` feature, at this version.
|
||||
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"
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
# The upstream default-apps button opens Windows Settings; printing uses craft-print instead.
|
||||
|
||||
+15
-222
@@ -18,11 +18,6 @@
|
||||
//! they paste math (learn.microsoft.com/office/math/mathml, "Clipboard support"). Pasting into
|
||||
//! Word and LibreOffice is not yet verified. Linux and the BSDs have no Word, and arboard can't
|
||||
//! add MIME types of other names, so there the HTML carries the MathML alone.
|
||||
//!
|
||||
//! RTF goes beside them where we can add it: Windows ("Rich Text Format") and macOS
|
||||
//! (`public.rtf`) after arboard's HTML, Wayland (`text/rtf`) in one offer with the text and HTML.
|
||||
//! On X11 arboard owns the selection and serves only its own targets, so a copy there carries text
|
||||
//! and HTML only; reading another program's RTF works on all four.
|
||||
|
||||
use wordcraft_engine::cmd::paste::MAX_PASTE_BYTES;
|
||||
use wordcraft_ui_egui::paste_picture::ClipboardPicture;
|
||||
@@ -36,24 +31,17 @@ thread_local! {
|
||||
static WRITER: std::cell::RefCell<Option<arboard::Clipboard>> = const { std::cell::RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// Put a copy on the clipboard: `text`, with `html` and `rtf` beside it where the platform takes
|
||||
/// them (an empty one isn't offered), and `mathml` (a copy of one equation alone; empty for any
|
||||
/// other copy) in the platform's MathML formats. `false` when the text and HTML couldn't be set
|
||||
/// (the caller copies the text alone), including a Wayland session without the data-control
|
||||
/// protocol, where arboard would fall back to X11 and native Wayland programs might not see it.
|
||||
/// RTF or MathML that can't be added is logged: the HTML still carries the copy.
|
||||
pub fn set_rich(text: &str, html: &str, rtf: &str, mathml: &str) -> bool {
|
||||
// Wayland: one offer with the text, HTML and RTF (no MathML formats there; see above).
|
||||
if platform::set_rich(text, html, rtf) {
|
||||
return true;
|
||||
}
|
||||
/// Put `html` on the clipboard with `text` as its plain-text alternative, and `mathml` (a copy of
|
||||
/// one equation alone; empty for any other copy) in the platform's MathML formats. `false` when
|
||||
/// the HTML and text couldn't be set (the caller copies the text alone), including a Wayland
|
||||
/// session without the data-control protocol, where arboard would fall back to X11 and native
|
||||
/// Wayland programs might not see it. MathML that can't be added is logged: the HTML still
|
||||
/// carries it.
|
||||
pub fn set_html(html: &str, text: &str, mathml: &str) -> bool {
|
||||
if !platform::can_set() {
|
||||
return false;
|
||||
}
|
||||
let set = set_html_text(html, text);
|
||||
if set && !rtf.is_empty() {
|
||||
platform::add_rtf(rtf);
|
||||
}
|
||||
if set && !mathml.is_empty() && mathml.len() <= MAX_PASTE_BYTES {
|
||||
platform::add_mathml(mathml);
|
||||
}
|
||||
@@ -66,8 +54,7 @@ fn set_html_text(html: &str, text: &str) -> bool {
|
||||
if slot.is_none() {
|
||||
*slot = open();
|
||||
}
|
||||
let set = slot.as_mut().map(|c| if html.is_empty() { c.set_text(text) } else { c.set().html(html, Some(text)) });
|
||||
match set {
|
||||
match slot.as_mut().map(|c| c.set().html(html, Some(text))) {
|
||||
Some(Ok(())) => true,
|
||||
Some(Err(e)) => {
|
||||
log::warn!("clipboard: {e}");
|
||||
@@ -89,8 +76,8 @@ pub fn html() -> Option<String> {
|
||||
open()?.get().html().ok().filter(|h| h.len() <= MAX_PASTE_BYTES)
|
||||
}
|
||||
|
||||
/// The clipboard's RTF: Windows ("Rich Text Format"), macOS (`public.rtf`), Wayland and X11
|
||||
/// (`text/rtf`); not offered by arboard, so read here.
|
||||
/// The clipboard's RTF: Windows ("Rich Text Format") and Wayland (`text/rtf`); not offered by
|
||||
/// arboard, so read here.
|
||||
pub fn rtf() -> Option<String> {
|
||||
let bytes = platform::rtf()?;
|
||||
if bytes.len() > MAX_PASTE_BYTES {
|
||||
@@ -189,51 +176,15 @@ mod platform {
|
||||
)
|
||||
}
|
||||
|
||||
/// Put the copy on a Wayland clipboard in one offer: the text (with the usual plain-text
|
||||
/// aliases), HTML and RTF, served from a background thread until something else is copied.
|
||||
/// `false` outside Wayland (X11 goes through arboard) or without the data-control protocol.
|
||||
pub fn set_rich(text: &str, html: &str, rtf: &str) -> bool {
|
||||
use wl_clipboard_rs::copy::{MimeSource, MimeType, Options, Source};
|
||||
|
||||
if !wayland() {
|
||||
return false;
|
||||
}
|
||||
let offer = |mime: &str, data: &str| MimeSource { source: Source::Bytes(data.as_bytes().into()), mime_type: MimeType::Specific(mime.into()) };
|
||||
// The text first: the plain-text aliases serve the first text type offered.
|
||||
let mut sources = vec![offer("text/plain;charset=utf-8", text)];
|
||||
for (mime, data) in [("text/html", html), ("text/rtf", rtf)] {
|
||||
if !data.is_empty() {
|
||||
sources.push(offer(mime, data));
|
||||
}
|
||||
}
|
||||
match Options::new().copy_multi(sources) {
|
||||
Ok(()) => true,
|
||||
Err(e) => {
|
||||
log::warn!("clipboard: {e}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Wayland copies carry their RTF already; X11 ones can't (see the module docs).
|
||||
pub fn add_rtf(_rtf: &str) {}
|
||||
|
||||
/// RTF types a clipboard may offer, best first.
|
||||
const RTF_MIMES: [&str; 3] = ["text/rtf", "application/rtf", "text/richtext"];
|
||||
|
||||
/// The RTF type to ask for among those `offered`.
|
||||
fn rtf_target(offered: &[String]) -> Option<&'static str> {
|
||||
RTF_MIMES.into_iter().find(|m| offered.iter().any(|o| o == m))
|
||||
}
|
||||
|
||||
/// arboard serves its own MIME types only; Word, which reads the MathML formats, isn't here.
|
||||
pub fn add_mathml(_mathml: &str) {}
|
||||
|
||||
pub fn rtf() -> Option<Vec<u8>> {
|
||||
if !wayland() {
|
||||
return std::env::var_os("DISPLAY").is_some().then(|| x11::rtf(super::MAX_PASTE_BYTES)).flatten();
|
||||
return None;
|
||||
}
|
||||
let mime = rtf_target(&formats())?;
|
||||
let offered = formats();
|
||||
let mime = ["text/rtf", "application/rtf", "text/richtext"].into_iter().find(|m| offered.iter().any(|o| o == m))?;
|
||||
read(mime, super::MAX_PASTE_BYTES as u64)
|
||||
}
|
||||
|
||||
@@ -245,125 +196,6 @@ mod platform {
|
||||
let mime = super::PICTURE_MIMES.into_iter().find(|m| offered.iter().any(|o| o == m))?;
|
||||
read(mime, wordcraft_ui_egui::paste_picture::MAX_FILE_BYTES)
|
||||
}
|
||||
|
||||
/// Whether a property read of `len` bytes, with `bytes_after` left unread, is the whole value
|
||||
/// and at most `cap` bytes.
|
||||
fn complete(len: usize, bytes_after: u32, cap: usize) -> bool {
|
||||
bytes_after == 0 && len <= cap
|
||||
}
|
||||
|
||||
/// Reading another program's RTF on X11, which arboard doesn't offer: ask the CLIPBOARD
|
||||
/// selection's owner for its targets, then for its RTF, into a property of a hidden window.
|
||||
mod x11 {
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use x11rb::connection::Connection;
|
||||
use x11rb::protocol::Event;
|
||||
use x11rb::protocol::xproto::{Atom, AtomEnum, ConnectionExt as _, CreateWindowAux, GetPropertyReply, Window, WindowClass};
|
||||
use x11rb::rust_connection::RustConnection;
|
||||
|
||||
/// How long a paste waits for the selection's owner, in all.
|
||||
const TIMEOUT: Duration = Duration::from_secs(1);
|
||||
/// Most targets looked at.
|
||||
const MAX_TARGETS: usize = 128;
|
||||
|
||||
struct Reader {
|
||||
conn: RustConnection,
|
||||
win: Window,
|
||||
clipboard: Atom,
|
||||
prop: Atom,
|
||||
deadline: Instant,
|
||||
}
|
||||
|
||||
fn atom(conn: &RustConnection, name: &str) -> Option<Atom> {
|
||||
conn.intern_atom(false, name.as_bytes()).ok()?.reply().ok().map(|r| r.atom)
|
||||
}
|
||||
|
||||
impl Reader {
|
||||
fn new() -> Option<Self> {
|
||||
let (conn, screen) = x11rb::connect(None).ok()?;
|
||||
let root = conn.setup().roots.get(screen)?.root;
|
||||
let win = conn.generate_id().ok()?;
|
||||
conn.create_window(0, win, root, 0, 0, 1, 1, 0, WindowClass::INPUT_ONLY, x11rb::COPY_FROM_PARENT, &CreateWindowAux::new()).ok()?;
|
||||
let clipboard = atom(&conn, "CLIPBOARD")?;
|
||||
let prop = atom(&conn, "WORDCRAFT_PASTE")?;
|
||||
Some(Self { conn, win, clipboard, prop, deadline: Instant::now() + TIMEOUT })
|
||||
}
|
||||
|
||||
/// The selection converted to `target`, at most `cap` bytes; `None` when the owner
|
||||
/// refuses or doesn't answer in time. A longer value is `None` when `whole`, else
|
||||
/// its first `cap` bytes.
|
||||
fn convert(&self, target: Atom, cap: usize, whole: bool) -> Option<GetPropertyReply> {
|
||||
self.conn.convert_selection(self.win, self.clipboard, target, self.prop, x11rb::CURRENT_TIME).ok()?;
|
||||
self.conn.flush().ok()?;
|
||||
loop {
|
||||
// Checked every time round, so a stream of other events can't keep us here.
|
||||
if Instant::now() >= self.deadline {
|
||||
return None;
|
||||
}
|
||||
match self.conn.poll_for_event().ok()? {
|
||||
Some(Event::SelectionNotify(e)) if e.requestor == self.win => {
|
||||
if e.property == x11rb::NONE {
|
||||
return None;
|
||||
}
|
||||
break;
|
||||
}
|
||||
Some(_) => {}
|
||||
None => std::thread::sleep(Duration::from_millis(5)),
|
||||
}
|
||||
}
|
||||
// In 32-bit units; for a whole value one more than the cap, so a larger one shows
|
||||
// as `bytes_after`.
|
||||
let units = u32::try_from(cap / 4 + usize::from(whole)).unwrap_or(u32::MAX);
|
||||
let r = self.conn.get_property(true, self.win, self.prop, AtomEnum::ANY, 0, units).ok()?.reply().ok()?;
|
||||
(!whole || super::complete(r.value.len(), r.bytes_after, cap)).then_some(r)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Reader {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.conn.destroy_window(self.win);
|
||||
let _ = self.conn.flush();
|
||||
}
|
||||
}
|
||||
|
||||
/// The CLIPBOARD selection's RTF, at most `cap` bytes. Copies too large to send at once
|
||||
/// arrive in pieces (INCR), which aren't read: `None` then.
|
||||
pub fn rtf(cap: usize) -> Option<Vec<u8>> {
|
||||
let r = Reader::new()?;
|
||||
// The first targets only: a longer list is cut short, not refused.
|
||||
let targets = r.convert(atom(&r.conn, "TARGETS")?, MAX_TARGETS.saturating_mul(4), false)?;
|
||||
let cookies: Vec<_> = targets.value32()?.take(MAX_TARGETS).filter_map(|a| r.conn.get_atom_name(a).ok()).collect();
|
||||
let names: Vec<String> =
|
||||
cookies.into_iter().filter_map(|c| c.reply().ok()).map(|n| String::from_utf8_lossy(&n.name).into_owned()).collect();
|
||||
let target = atom(&r.conn, super::rtf_target(&names)?)?;
|
||||
let incr = atom(&r.conn, "INCR")?;
|
||||
let reply = r.convert(target, cap, true)?;
|
||||
(reply.type_ != incr).then_some(reply.value)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rtf_target_prefers_text_rtf() {
|
||||
let names = |n: &[&str]| n.iter().map(|s| s.to_string()).collect::<Vec<_>>();
|
||||
assert_eq!(rtf_target(&names(&["TARGETS", "text/richtext", "application/rtf", "text/rtf"])), Some("text/rtf"));
|
||||
assert_eq!(rtf_target(&names(&["text/richtext", "UTF8_STRING"])), Some("text/richtext"));
|
||||
assert_eq!(rtf_target(&names(&["text/html", "text/rtf;charset=x"])), None);
|
||||
assert_eq!(rtf_target(&[]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn property_reads_must_be_whole_and_capped() {
|
||||
assert!(complete(10, 0, 10));
|
||||
assert!(!complete(11, 0, 10), "over the cap");
|
||||
assert!(!complete(8, 4, 10), "more left to read");
|
||||
assert!(complete(0, 0, 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -419,23 +251,6 @@ mod platform {
|
||||
true
|
||||
}
|
||||
|
||||
/// arboard sets the text and HTML.
|
||||
pub fn set_rich(_text: &str, _html: &str, _rtf: &str) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Add "Rich Text Format" to what arboard just set, NUL-terminated as is usual.
|
||||
pub fn add_rtf(rtf: &str) {
|
||||
let Some(format) = raw::register_format("Rich Text Format") else { return };
|
||||
let Ok(_open) = Clipboard::new_attempts(10) else { return };
|
||||
let mut bytes = Vec::with_capacity(rtf.len().saturating_add(1));
|
||||
bytes.extend_from_slice(rtf.as_bytes());
|
||||
bytes.push(0);
|
||||
if let Err(e) = raw::set_without_clear(format.get(), &bytes) {
|
||||
log::warn!("clipboard RTF: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Add the MathML to what arboard just set, as "MathML" and "MathML Presentation": UTF-16LE
|
||||
/// text (with its terminating NUL, as Windows text formats have).
|
||||
pub fn add_mathml(mathml: &str) {
|
||||
@@ -461,18 +276,14 @@ const MATHML_FORMATS: [&str; 2] = ["MathML", "MathML Presentation"];
|
||||
#[cfg(target_os = "macos")]
|
||||
mod platform {
|
||||
use objc2_app_kit::NSPasteboard;
|
||||
use objc2_foundation::{NSData, NSString};
|
||||
|
||||
/// RTF's pasteboard type (`NSPasteboardTypeRTF`, whose extern static needs unsafe to read).
|
||||
const RTF: &str = "public.rtf";
|
||||
use objc2_foundation::NSString;
|
||||
|
||||
pub fn formats() -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
pub fn rtf() -> Option<Vec<u8>> {
|
||||
let data = NSPasteboard::generalPasteboard().dataForType(&NSString::from_str(RTF))?;
|
||||
(data.len() <= super::MAX_PASTE_BYTES).then(|| data.to_vec())
|
||||
None
|
||||
}
|
||||
|
||||
pub fn encoded_picture() -> Option<Vec<u8>> {
|
||||
@@ -483,18 +294,6 @@ mod platform {
|
||||
true
|
||||
}
|
||||
|
||||
/// arboard sets the text and HTML.
|
||||
pub fn set_rich(_text: &str, _html: &str, _rtf: &str) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Add RTF to the pasteboard item arboard just wrote (it cleared the pasteboard first).
|
||||
pub fn add_rtf(rtf: &str) {
|
||||
if !NSPasteboard::generalPasteboard().setData_forType(Some(&NSData::with_bytes(rtf.as_bytes())), &NSString::from_str(RTF)) {
|
||||
log::warn!("clipboard: the RTF couldn't be added");
|
||||
}
|
||||
}
|
||||
|
||||
/// Add the MathML to the pasteboard item arboard just wrote, as the MathML types (W3C MathML 3
|
||||
/// §6.2.4): presentation markup, and the generic type for readers that only know that one.
|
||||
pub fn add_mathml(mathml: &str) {
|
||||
@@ -530,11 +329,5 @@ mod platform {
|
||||
true
|
||||
}
|
||||
|
||||
pub fn set_rich(_text: &str, _html: &str, _rtf: &str) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
pub fn add_rtf(_rtf: &str) {}
|
||||
|
||||
pub fn add_mathml(_mathml: &str) {}
|
||||
}
|
||||
|
||||
@@ -15,25 +15,9 @@ use eframe::{
|
||||
/// compiler aborts on them, the device is lost and eframe exits before the window opens. WordCraft
|
||||
/// issues no indirect draws or dispatches, so Intel Macs skip them; every other GPU keeps wgpu's
|
||||
/// default. Same fix as PhotoCraft's storytold/PhotoCraft#3171.
|
||||
///
|
||||
/// `x86_64` is the build's architecture. An x86_64 build also runs on Apple silicon under Rosetta
|
||||
/// (#5199), so it skips only when the machine itself isn't Apple silicon.
|
||||
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||
pub fn skips_indirect_validation(macos: bool, x86_64: bool) -> bool {
|
||||
// Short-circuits: only x86_64 macOS builds ask the system.
|
||||
macos && x86_64 && !runs_on_apple_silicon()
|
||||
}
|
||||
|
||||
/// Whether the machine is Apple silicon, also when this process is translated by Rosetta:
|
||||
/// `hw.optional.arm64` describes the hardware, not the process. Assumes not (an Intel Mac) when
|
||||
/// the system can't tell.
|
||||
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||
fn runs_on_apple_silicon() -> bool {
|
||||
cfg!(target_os = "macos")
|
||||
&& std::process::Command::new("/usr/sbin/sysctl")
|
||||
.args(["-n", "hw.optional.arm64"])
|
||||
.output()
|
||||
.is_ok_and(|out| out.status.success() && out.stdout.trim_ascii() == b"1")
|
||||
pub fn skips_indirect_validation(macos: bool, intel: bool) -> bool {
|
||||
macos && intel
|
||||
}
|
||||
|
||||
/// Leaves wgpu's indirect-call validation out of the instance eframe creates when `skip`.
|
||||
@@ -197,8 +181,7 @@ mod tests {
|
||||
#[test]
|
||||
fn intel_macs_skip_the_indirect_validation_shaders() {
|
||||
// #2652: Metal's Intel compiler aborted on them, losing the device before the window opened.
|
||||
// An x86_64 build on Apple silicon (Rosetta) isn't an Intel Mac (#5199).
|
||||
assert_eq!(skips_indirect_validation(true, true), !host_is_apple_silicon());
|
||||
assert!(skips_indirect_validation(true, true));
|
||||
// Apple silicon, Windows and Linux keep wgpu's validation.
|
||||
for (macos, intel) in [(true, false), (false, true), (false, false)] {
|
||||
assert!(!skips_indirect_validation(macos, intel), "macos={macos} intel={intel}");
|
||||
@@ -222,36 +205,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the machine running the tests is Apple silicon, whatever this build's architecture.
|
||||
fn host_is_apple_silicon() -> bool {
|
||||
cfg!(target_os = "macos")
|
||||
&& std::process::Command::new("/usr/sbin/sysctl")
|
||||
.args(["-n", "hw.optional.arm64"])
|
||||
.output()
|
||||
.is_ok_and(|out| out.status.success() && out.stdout.trim_ascii() == b"1")
|
||||
}
|
||||
|
||||
/// #5199: an x86_64 build running under Rosetta on Apple silicon isn't an Intel Mac and keeps
|
||||
/// wgpu's indirect-call validation.
|
||||
#[test]
|
||||
fn x86_64_builds_on_apple_silicon_keep_indirect_validation() {
|
||||
if !host_is_apple_silicon() {
|
||||
return;
|
||||
}
|
||||
// What main.rs passes from an x86_64 build.
|
||||
assert!(!skips_indirect_validation(true, true), "Rosetta build on Apple silicon must keep validation");
|
||||
let mut options = NativeOptions::default();
|
||||
let WgpuSetup::CreateNew(before) = &options.wgpu_options.wgpu_setup else {
|
||||
panic!("default native options must create a wgpu instance");
|
||||
};
|
||||
let before = before.instance_descriptor.flags;
|
||||
configure_indirect_validation(&mut options, skips_indirect_validation(true, true));
|
||||
let WgpuSetup::CreateNew(create) = &options.wgpu_options.wgpu_setup else {
|
||||
panic!("default native options must create a wgpu instance");
|
||||
};
|
||||
assert_eq!(create.instance_descriptor.flags, before, "indirect-call validation kept");
|
||||
}
|
||||
|
||||
fn configured_backends(backends: Backends) -> Backends {
|
||||
let mut options = NativeOptions::default();
|
||||
configure_backends(&mut options, backends);
|
||||
|
||||
+16
-114
@@ -27,7 +27,7 @@ mod traffic_lights;
|
||||
use wordcraft_engine::Session;
|
||||
use wordcraft_ui_egui::{
|
||||
Services, UiState, WordApp,
|
||||
window_geometry::{RawWindowState, WindowGeometry, reachable, take_rescue},
|
||||
window_geometry::{RawWindowState, WindowGeometry, take_rescue},
|
||||
};
|
||||
|
||||
/// The app, the restored window geometry (or, with none, the default: no position) until the
|
||||
@@ -54,13 +54,7 @@ impl eframe::App for App {
|
||||
apple_events::poll(&self.5, &mut self.0, ctx);
|
||||
self.0.logic(ctx);
|
||||
self.3.show(frame);
|
||||
let mut window = self.4;
|
||||
// Only until the off-screen check below has run: it is the one reader.
|
||||
if self.1.is_some() {
|
||||
let (on_a_monitor, primary_monitor_rect) = monitor_geometry(frame, ctx.pixels_per_point());
|
||||
window.on_a_monitor = on_a_monitor;
|
||||
window.primary_monitor_rect = primary_monitor_rect;
|
||||
}
|
||||
let window = self.4;
|
||||
// A window that doesn't fit on its screen (the default size on a laptop) shrinks to fit
|
||||
// there, so all of the scroll bar can be dragged (#1215). Checked on the frame that first
|
||||
// knows the monitor: the off-screen check below takes the pending geometry then.
|
||||
@@ -132,37 +126,6 @@ impl eframe::App for App {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a grippable part of the window is on a monitor, and the primary monitor bounds.
|
||||
/// Winit reports physical pixels; egui positions use points. `None` where the platform cannot
|
||||
/// report the window or primary monitor (Wayland, headless).
|
||||
fn monitor_geometry(frame: &eframe::Frame, pixels_per_point: f32) -> (Option<bool>, Option<egui::Rect>) {
|
||||
let Some(w) = frame.winit_window() else {
|
||||
return (None, None);
|
||||
};
|
||||
if !pixels_per_point.is_finite() || pixels_per_point <= 0.0 {
|
||||
return (None, None);
|
||||
}
|
||||
let Some(pos) = w.outer_position().ok() else {
|
||||
return (None, None);
|
||||
};
|
||||
let size = w.outer_size();
|
||||
let physical_rect = |(x, y): (i32, i32), (width, height): (u32, u32)| {
|
||||
egui::Rect::from_min_size(egui::pos2(x as f32, y as f32), egui::vec2(width as f32, height as f32))
|
||||
};
|
||||
let monitors: Vec<egui::Rect> =
|
||||
w.available_monitors().map(|m| physical_rect((m.position().x, m.position().y), (m.size().width, m.size().height))).collect();
|
||||
let on_a_monitor = (!monitors.is_empty()).then(|| reachable(physical_rect((pos.x, pos.y), (size.width, size.height)), &monitors));
|
||||
let primary_monitor_rect = w.primary_monitor().map(|m| {
|
||||
let pos = m.position();
|
||||
let size = m.size();
|
||||
egui::Rect::from_min_size(
|
||||
egui::pos2(pos.x as f32, pos.y as f32) / pixels_per_point,
|
||||
egui::vec2(size.width as f32, size.height as f32) / pixels_per_point,
|
||||
)
|
||||
});
|
||||
(on_a_monitor, primary_monitor_rect)
|
||||
}
|
||||
|
||||
/// Quit the way Command-Q does, on the next turn of the main queue.
|
||||
///
|
||||
/// Called when the window is closing. Doing it on the next turn lets the close
|
||||
@@ -253,45 +216,13 @@ fn load_user_fonts() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The saved preferences, read before the window opens for its geometry and, on Linux and the
|
||||
/// BSDs, the display server (`load_prefs` runs after).
|
||||
fn saved_ui() -> Option<UiState> {
|
||||
/// The saved window geometry, read before the window opens (`load_prefs` runs after).
|
||||
fn saved_window() -> Option<WindowGeometry> {
|
||||
if !prefs_enabled() {
|
||||
return None;
|
||||
}
|
||||
let bytes = std::fs::read(prefs_path()?).ok()?;
|
||||
UiState::from_json(&bytes)
|
||||
}
|
||||
|
||||
/// Whether the window runs through XWayland (X11) rather than native Wayland, and the log line
|
||||
/// saying why. `WORDCRAFT_WAYLAND` wins (`0`, `false`, `no` or `off` asks for X11, any other value
|
||||
/// for Wayland), then File › Options › Advanced › Linux display server (#2184). Automatic is X11
|
||||
/// 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.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
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") {
|
||||
Some(v) if matches!(v.to_string_lossy().trim().to_ascii_lowercase().as_str(), "0" | "false" | "no" | "off") => {
|
||||
(DisplayServer::X11, "WORDCRAFT_WAYLAND")
|
||||
}
|
||||
Some(_) => (DisplayServer::Wayland, "WORDCRAFT_WAYLAND"),
|
||||
None => (setting, "the setting"),
|
||||
};
|
||||
let x11 = wanted != DisplayServer::Wayland && set("DISPLAY");
|
||||
let runs = if x11 {
|
||||
"X11"
|
||||
} 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)"
|
||||
};
|
||||
(x11, format!("display: {runs}; {} asks for {}", by, wanted.code()))
|
||||
UiState::from_json(&bytes)?.window?.sanitized()
|
||||
}
|
||||
|
||||
fn save_prefs(app: &WordApp) {
|
||||
@@ -344,7 +275,7 @@ fn services(file_dialog: file_dialogs::Hook, ctx: &egui::Context) -> Services {
|
||||
animations_enabled: Some(animations::hook(ctx)),
|
||||
clipboard_text: Some(Box::new(clipboard::text)),
|
||||
clipboard_html: Some(Box::new(clipboard::html)),
|
||||
set_clipboard_rich: Some(Box::new(clipboard::set_rich)),
|
||||
set_clipboard_html: Some(Box::new(clipboard::set_html)),
|
||||
clipboard_rtf: Some(Box::new(clipboard::rtf)),
|
||||
clipboard_formats: Some(Box::new(clipboard::formats)),
|
||||
..Default::default()
|
||||
@@ -518,28 +449,25 @@ fn main() -> eframe::Result {
|
||||
// FXC, which needs no DLL of ours (#603; see graphics.rs).
|
||||
#[cfg(target_os = "windows")]
|
||||
graphics::configure(&mut options, eframe::wgpu::Backends::from_env());
|
||||
// Intel Macs: Metal's compiler aborts on wgpu's indirect-validation shaders (#2652). An x86_64
|
||||
// build under Rosetta on Apple silicon keeps them (#5199).
|
||||
// Intel Macs: Metal's compiler aborts on wgpu's indirect-validation shaders (#2652).
|
||||
#[cfg(target_os = "macos")]
|
||||
graphics::configure_indirect_validation(&mut options, graphics::skips_indirect_validation(true, cfg!(target_arch = "x86_64")));
|
||||
load_user_fonts();
|
||||
// Windows opened for further documents start like the first one, before its saved place.
|
||||
let window_builder = options.viewport.clone();
|
||||
let saved = saved_ui();
|
||||
let restored = saved.as_ref().and_then(|ui| ui.window?.sanitized());
|
||||
let restored = saved_window();
|
||||
if let Some(window) = restored {
|
||||
options.viewport = window.apply(options.viewport);
|
||||
}
|
||||
#[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));
|
||||
log::info!("{why}");
|
||||
if x11 {
|
||||
options.event_loop_builder = Some(Box::new(|b| {
|
||||
use winit::platform::x11::EventLoopBuilderExtX11;
|
||||
b.with_x11();
|
||||
}));
|
||||
}
|
||||
// winit has no file drag-and-drop on Wayland (only on X11), so dropping files from the file
|
||||
// manager showed a "no" cursor. Run through XWayland when it's there; WORDCRAFT_WAYLAND=1
|
||||
// keeps the native Wayland backend.
|
||||
if std::env::var_os("DISPLAY").is_some() && std::env::var_os("WORDCRAFT_WAYLAND").is_none() {
|
||||
options.event_loop_builder = Some(Box::new(|b| {
|
||||
use winit::platform::x11::EventLoopBuilderExtX11;
|
||||
b.with_x11();
|
||||
}));
|
||||
}
|
||||
// Registered before the event loop starts, so it catches the Finder event that launched us as
|
||||
// well as later ones. Lives until the event loop returns; the app creator only borrows it.
|
||||
@@ -653,32 +581,6 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// #2184: `WORDCRAFT_WAYLAND` beats the setting, Automatic keeps XWayland where there's an X
|
||||
/// server, and a choice that isn't available falls back to the other.
|
||||
#[test]
|
||||
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))).0
|
||||
};
|
||||
let both = [("DISPLAY", ":0"), ("WAYLAND_DISPLAY", "wayland-0")];
|
||||
assert!(x11(Auto, &both), "Automatic keeps XWayland, for file drag-and-drop");
|
||||
assert!(x11(X11, &both));
|
||||
assert!(!x11(Wayland, &both));
|
||||
for (value, wants_x11) in [("1", false), ("", false), ("yes", false), ("0", true), (" Off ", true), ("FALSE", true), ("no", true)] {
|
||||
let vars = [both[0], both[1], ("WORDCRAFT_WAYLAND", value)];
|
||||
for setting in [Auto, Wayland, X11] {
|
||||
assert_eq!(x11(setting, &vars), wants_x11, "WORDCRAFT_WAYLAND={value:?} beats {setting:?}");
|
||||
}
|
||||
}
|
||||
// Without an X server X11 isn't forced: winit connects to Wayland instead of failing.
|
||||
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()));
|
||||
assert!(!forced && why.contains("X11 (no Wayland)"), "{why}");
|
||||
}
|
||||
|
||||
/// 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]
|
||||
|
||||
@@ -35,9 +35,7 @@ sync_github_name = false
|
||||
real_name = "Saeed Azarehgar"
|
||||
|
||||
[people.vangel76]
|
||||
real_name = "Valentin Klein"
|
||||
display_name = "vangel76"
|
||||
sync_github_name = false
|
||||
sync_github_name = true
|
||||
|
||||
[people.dkhlapov]
|
||||
real_name = "Dmitrii Khlapov"
|
||||
@@ -162,8 +160,6 @@ sync_github_name = true
|
||||
|
||||
[people.maxm11]
|
||||
real_name = "Max Murphy"
|
||||
display_name = "Max Murphy"
|
||||
sync_github_name = false
|
||||
|
||||
[people.SlavenDj]
|
||||
sync_github_name = true
|
||||
@@ -191,9 +187,3 @@ sync_github_name = true
|
||||
|
||||
[people.dylanmtaylor]
|
||||
sync_github_name = true
|
||||
|
||||
[people.entropybeater]
|
||||
display_name = "entropybeater"
|
||||
|
||||
[people.helenkwok]
|
||||
real_name = "Helen Kwok"
|
||||
|
||||
@@ -10,9 +10,8 @@ workspace = true
|
||||
|
||||
[features]
|
||||
# Test inputs other crates' tests share (`cfb_guard::repeating_difat_sample`,
|
||||
# `zip_guard::zip64_entry_count_sample`) and the step count of
|
||||
# `work::count`; turned on only as a dev-dependency, so never built into
|
||||
# the app.
|
||||
# `zip_guard::zip64_entry_count_sample`); turned on only as a
|
||||
# dev-dependency, so never built into the app.
|
||||
test-support = []
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -213,9 +213,7 @@ impl Document {
|
||||
if !parent.len().is_multiple_of(3) {
|
||||
return None;
|
||||
}
|
||||
crate::work::steps(1);
|
||||
for ch in parent.chunks(3) {
|
||||
crate::work::steps(1);
|
||||
let [b, r, c] = ch else { return None };
|
||||
let Block::Table(t) = &**bl.get(*b as usize)? else { return None };
|
||||
bl = &t.rows.get(*r as usize)?.cells.get(*c as usize)?.blocks;
|
||||
|
||||
@@ -609,10 +609,8 @@ impl Document {
|
||||
let mut at = at.into_iter().peekable();
|
||||
let mut start = 0usize;
|
||||
for r in &mut p.runs {
|
||||
crate::work::steps(1);
|
||||
let end = start.saturating_add(r.len);
|
||||
while at.peek().is_some_and(|&o| o < start) {
|
||||
crate::work::steps(1);
|
||||
at.next();
|
||||
}
|
||||
if at.peek().is_some_and(|&o| o < end) {
|
||||
|
||||
+2
-85
@@ -263,9 +263,7 @@ impl KeptXml {
|
||||
}
|
||||
/// Whether `doc`'s page setup is still as it was read.
|
||||
pub fn pages_as_read(&self, doc: &crate::Document) -> bool {
|
||||
let Some(fp) = self.pages_fp else { return false };
|
||||
let now = in_scope(doc, |sc| *sc.pages.get_or_insert_with(|| pages_fingerprint(doc))).unwrap_or_else(|| pages_fingerprint(doc));
|
||||
fp == now
|
||||
self.pages_fp.is_some_and(|fp| fp == pages_fingerprint(doc))
|
||||
}
|
||||
/// Whether `doc`'s style definitions are still as they were read.
|
||||
pub fn styles_as_read(&self, doc: &crate::Document) -> bool {
|
||||
@@ -386,62 +384,11 @@ const MAX_REPLIES: usize = 10_000;
|
||||
/// What comment `root` is with its replies (and theirs): each one's author, date, state and
|
||||
/// text, and how many they are.
|
||||
pub fn comments_fingerprint(doc: &crate::Document, root: u32) -> (u64, usize) {
|
||||
family_fingerprint(doc, root, &scoped_replies(doc))
|
||||
}
|
||||
|
||||
/// What a writer asks of a document's kept markup for many paragraphs, worked out once while
|
||||
/// [`while_writing`] it: each comment's replies, and its page setup's fingerprint. Without it a
|
||||
/// document of C comments took C² steps (and its page setup serialized once per note) to save.
|
||||
struct Scope {
|
||||
/// The document's address: it can't move or change while borrowed for the writing.
|
||||
doc: usize,
|
||||
replies: Option<std::rc::Rc<HashMap<u32, Vec<u32>>>>,
|
||||
pages: Option<u64>,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static SCOPE: std::cell::RefCell<Option<Scope>> = const { std::cell::RefCell::new(None) };
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// How many times [`replies`] was built (tests).
|
||||
static REPLY_BUILDS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// Puts the scope before it back when dropped (when `f` returns or unwinds).
|
||||
struct Restore(Option<Scope>);
|
||||
|
||||
impl Drop for Restore {
|
||||
fn drop(&mut self) {
|
||||
let prev = self.0.take();
|
||||
SCOPE.with(|s| *s.borrow_mut() = prev);
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `f`, which writes `doc`, with what the kept markup asks of `doc` worked out at most once.
|
||||
pub fn while_writing<R>(doc: &crate::Document, f: impl FnOnce() -> R) -> R {
|
||||
let key = std::ptr::from_ref(doc) as usize;
|
||||
let prev = SCOPE.with(|s| s.replace(Some(Scope { doc: key, replies: None, pages: None })));
|
||||
let _restore = Restore(prev);
|
||||
f()
|
||||
}
|
||||
|
||||
/// The scope for `doc`, if it is being written: `f` of it.
|
||||
fn in_scope<R>(doc: &crate::Document, f: impl FnOnce(&mut Scope) -> R) -> Option<R> {
|
||||
let key = std::ptr::from_ref(doc) as usize;
|
||||
SCOPE.with(|s| s.borrow_mut().as_mut().filter(|sc| sc.doc == key).map(f))
|
||||
}
|
||||
|
||||
/// [`replies`], built once while `doc` is being written.
|
||||
fn scoped_replies(doc: &crate::Document) -> std::rc::Rc<HashMap<u32, Vec<u32>>> {
|
||||
in_scope(doc, |sc| sc.replies.get_or_insert_with(|| std::rc::Rc::new(replies(doc))).clone()).unwrap_or_else(|| std::rc::Rc::new(replies(doc)))
|
||||
family_fingerprint(doc, root, &replies(doc))
|
||||
}
|
||||
|
||||
/// Each comment's replies, by the comment's id.
|
||||
fn replies(doc: &crate::Document) -> HashMap<u32, Vec<u32>> {
|
||||
#[cfg(test)]
|
||||
REPLY_BUILDS.with(|n| n.set(n.get() + 1));
|
||||
let mut out: HashMap<u32, Vec<u32>> = HashMap::new();
|
||||
for (id, c) in &doc.comments {
|
||||
if let Some(p) = c.parent {
|
||||
@@ -747,34 +694,4 @@ mod tests {
|
||||
assert_eq!(k.group(p), Some((1, 1)));
|
||||
assert_eq!(k.story_fps.get(&id), Some(&story_fingerprint(&doc, id)));
|
||||
}
|
||||
|
||||
/// Writing a document checks each kept paragraph's comments against what was read: the
|
||||
/// comments' replies are looked up once for the whole document, not once per comment.
|
||||
#[test]
|
||||
fn writing_many_comments_looks_their_replies_up_once() {
|
||||
use crate::para::InlineObject;
|
||||
let n = 50u32;
|
||||
let text = (0..n).map(|i| format!("Para {i}")).collect::<Vec<_>>().join("\n");
|
||||
let mut doc = crate::Document::from_text(&text);
|
||||
for i in 0..n {
|
||||
let part = doc.add_part(crate::PartKind::Comment, vec![crate::para_block(Paragraph::with_text("Note", Default::default()))]);
|
||||
doc.comments.insert(i, crate::Comment { parent: None, part, ..Default::default() });
|
||||
doc.para_mut(crate::StoryRef::Body, &crate::Path::top(i as usize))
|
||||
.unwrap()
|
||||
.insert_object(0, InlineObject::CommentStart { id: i }, &Default::default())
|
||||
.unwrap();
|
||||
}
|
||||
let mut k = KeptXml { format: "odt".into(), ..Default::default() };
|
||||
for i in 0..n {
|
||||
k.comment_fps.insert(i, comments_fingerprint(&doc, i).0);
|
||||
}
|
||||
let xml = "<text:p><office:annotation>x</office:annotation></text:p>";
|
||||
let before = REPLY_BUILDS.with(std::cell::Cell::get);
|
||||
let all = while_writing(&doc, || doc.body.iter().filter_map(|b| b.as_para()).all(|p| k.comments_as_read(&doc, p, xml)));
|
||||
assert!(all, "every paragraph's comment is as read");
|
||||
assert_eq!(REPLY_BUILDS.with(std::cell::Cell::get) - before, 1);
|
||||
// Outside a writing, nothing is kept between calls.
|
||||
assert!(doc.body.first().and_then(|b| b.as_para()).is_some_and(|p| k.comments_as_read(&doc, p, xml)));
|
||||
assert_eq!(REPLY_BUILDS.with(std::cell::Cell::get) - before, 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,57 +120,14 @@ fn attr_in(tag: &str) -> Option<String> {
|
||||
Some(unescape(rest.get(..end)?))
|
||||
}
|
||||
|
||||
fn unescape(mut s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
while let Some((text, rest)) = s.split_once('&') {
|
||||
out.push_str(text);
|
||||
if let Some(end) = rest.find(['&', ';'])
|
||||
&& rest.as_bytes()[end] == b';'
|
||||
{
|
||||
let entity = &rest[..end];
|
||||
let decoded = match entity {
|
||||
"lt" => Some('<'),
|
||||
"gt" => Some('>'),
|
||||
"quot" => Some('"'),
|
||||
"apos" => Some('\''),
|
||||
"amp" => Some('&'),
|
||||
_ => entity.strip_prefix('#').and_then(|n| {
|
||||
let value = if let Some(hex) = n.strip_prefix('x') {
|
||||
(!hex.is_empty() && hex.bytes().all(|b| b.is_ascii_hexdigit())).then(|| u32::from_str_radix(hex, 16).ok()).flatten()
|
||||
} else {
|
||||
(!n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())).then(|| n.parse::<u32>().ok()).flatten()
|
||||
}?;
|
||||
char::from_u32(value)
|
||||
.filter(|c| matches!(*c, '\t' | '\n' | '\r' | '\u{20}'..='\u{d7ff}' | '\u{e000}'..='\u{fffd}' | '\u{10000}'..='\u{10ffff}'))
|
||||
}),
|
||||
};
|
||||
if let Some(c) = decoded {
|
||||
out.push(c);
|
||||
s = &rest[end + 1..];
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push('&');
|
||||
s = rest;
|
||||
}
|
||||
out.push_str(s);
|
||||
out
|
||||
fn unescape(s: &str) -> String {
|
||||
s.replace("<", "<").replace(">", ">").replace(""", "\"").replace("'", "'").replace("&", "&")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn drop_down_entities_decode_once_including_xml_whitespace() {
|
||||
let xml = r#"<w:ffData><w:name w:val="Name"/><w:ddList><w:listEntry w:val="A B	C D😀&#10;<>"'"/></w:ddList></w:ffData>"#;
|
||||
let f = LegacyForm::parse(xml).unwrap();
|
||||
assert_eq!(f.name(), "Name");
|
||||
assert_eq!(f.shown_text(), Some("A\nB\tC\rD😀 <>\"'"));
|
||||
let malformed = r#"<w:ffData><w:ddList><w:listEntry w:val="���&#-1;&#xZZ;&unknown;&"/></w:ddList></w:ffData>"#;
|
||||
assert_eq!(LegacyForm::parse(malformed).unwrap().shown_text(), Some("���&#-1;&#xZZ;&unknown;&"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_boxes_text_fields_and_drop_downs() {
|
||||
let cb = |inner: &str| format!(r#"<w:ffData><w:name w:val="Box1"/><w:enabled/><w:checkBox>{inner}</w:checkBox></w:ffData>"#);
|
||||
|
||||
@@ -39,8 +39,8 @@ pub mod math_latex;
|
||||
pub mod math_linear;
|
||||
pub mod math_symbols;
|
||||
pub mod numbering;
|
||||
pub mod odf_profile;
|
||||
pub mod para;
|
||||
pub mod picfx;
|
||||
pub mod props;
|
||||
pub mod resolve;
|
||||
pub mod section;
|
||||
@@ -49,7 +49,6 @@ pub mod styles;
|
||||
pub mod symbol_fonts;
|
||||
pub mod table;
|
||||
pub mod wordart;
|
||||
pub mod work;
|
||||
pub mod wrap;
|
||||
pub mod zip_guard;
|
||||
|
||||
@@ -87,11 +86,11 @@ pub use error::{DocError, Result};
|
||||
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::{
|
||||
COMPAT_MODE_CURRENT, CoreProps, CustomProp, DEFAULT_GRID, DEFAULT_HYPHENATION_ZONE, LEGACY_COMPAT_MODE, OdfLayout, PagePicture, Settings, Source,
|
||||
COMPAT_MODE_CURRENT, CoreProps, CustomProp, DEFAULT_GRID, DEFAULT_HYPHENATION_ZONE, LEGACY_COMPAT_MODE, PagePicture, Settings, Source,
|
||||
THEME_COLORS, Watermark, Zoom, ZoomFit,
|
||||
};
|
||||
pub use text_walk::count_words;
|
||||
pub(crate) use text_walk::{PARA_WALK_DEPTH, walk_between};
|
||||
pub use text_walk::{count_chars, count_chars_no_spaces, count_words};
|
||||
|
||||
/// A document.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
|
||||
@@ -22,29 +22,10 @@ pub fn decimal_separator(lang: Option<&str>) -> char {
|
||||
|
||||
const SPACES: &[char] = &[' ', '\u{a0}', '\u{202f}', '\u{2009}'];
|
||||
const CURRENCY: &[char] = &['$', '€', '£', '¥', '¢', '₽', '₴', '₺', '₹', '₩', '₪', '₫', '฿', '₱', '₸', '₼', '₾'];
|
||||
/// Currency signs spelled with more than one character: the Panamanian balboa (`B/.`).
|
||||
const CURRENCY_SYMBOLS: &[&str] = &["B/."];
|
||||
|
||||
/// Trim spaces and single-character currency signs from either end.
|
||||
fn trim_currency(s: &str) -> &str {
|
||||
s.trim_matches(|c: char| c.is_whitespace() || CURRENCY.contains(&c))
|
||||
}
|
||||
|
||||
/// Strip a currency symbol or a short currency word (`zł`, `kr`, `PLN`, `B/.`) from either end.
|
||||
/// As Word does, a multi-character sign is a word of its own (`B/. 1,250.50`, `1,250.50 B/.`);
|
||||
/// `B/.1,250.50` and `B/ 1,250.50` are not numbers.
|
||||
/// Strip a currency symbol or a short currency word (`zł`, `kr`, `PLN`) from either end.
|
||||
fn strip_currency(s: &str) -> &str {
|
||||
let mut s = trim_currency(s);
|
||||
if let Some(rest) =
|
||||
CURRENCY_SYMBOLS.iter().find_map(|sym| s.strip_prefix(sym).filter(|rest| rest.chars().next().is_some_and(char::is_whitespace)))
|
||||
{
|
||||
s = trim_currency(rest);
|
||||
}
|
||||
if let Some(rest) =
|
||||
CURRENCY_SYMBOLS.iter().find_map(|sym| s.strip_suffix(sym).filter(|rest| rest.chars().next_back().is_some_and(char::is_whitespace)))
|
||||
{
|
||||
s = trim_currency(rest);
|
||||
}
|
||||
let mut s = s.trim_matches(|c: char| c.is_whitespace() || CURRENCY.contains(&c));
|
||||
if let Some((head, word)) = s.rsplit_once(SPACES)
|
||||
&& (1..=3).contains(&word.chars().count())
|
||||
&& word.chars().all(|c| c.is_alphabetic() || c == '.')
|
||||
@@ -156,7 +137,6 @@ mod tests {
|
||||
assert_eq!(parse_number("1.5", pl), Some(1.5));
|
||||
assert_eq!(parse_number("-2,25", pl), Some(-2.25));
|
||||
assert_eq!(parse_number("PLN 12", pl), Some(12.0));
|
||||
assert_eq!(parse_number("-B/. 1 250,50", pl), Some(-1250.5));
|
||||
assert_eq!(parse_number("abc", pl), None);
|
||||
assert_eq!(parse_number("1,2,3", pl), None);
|
||||
// From review: ordinals, a leading zero, percentages and an en dash minus.
|
||||
@@ -194,40 +174,11 @@ mod tests {
|
||||
assert_eq!(format_number(5.75, Some("es-ES")), "5,75");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn balboa_numbers() {
|
||||
let en = Some("en-US");
|
||||
// Word 16.0 reads `B/.` as a word of its own, before or after the number.
|
||||
assert_eq!(parse_number("B/. 1,234.50", en), Some(1234.5));
|
||||
assert_eq!(parse_number("1,234.50 B/.", en), Some(1234.5));
|
||||
assert_eq!(parse_number("B/.\u{a0}1,234.50", en), Some(1234.5), "a no-break space after the sign");
|
||||
assert_eq!(parse_number("B/. 1,234.50 B/.", en), Some(1234.5), "the sign at both ends");
|
||||
assert_eq!(parse_number("-B/. 1,234.50", en), Some(-1234.5), "a minus before the sign");
|
||||
assert_eq!(parse_number("B/. 1,234.50", Some("es-PA")), Some(1234.5), "the balboa's own region");
|
||||
// The sign has no space of its own in Word: touching the number is text.
|
||||
assert_eq!(parse_number("B/.1,234.50", en), None);
|
||||
assert_eq!(parse_number("1,234.50B/.", en), None);
|
||||
assert_eq!(parse_number("B/ 1,234.50", en), None, "the balboa is B/. in Word");
|
||||
assert_eq!(parse_number("B/.", en), None);
|
||||
// The slash and the full stop make `B/.` ambiguous with a fraction: the sign is matched at
|
||||
// the ends of the text only, so a fraction is never read as a balboa amount and a sign in
|
||||
// the middle is not a sign.
|
||||
assert_eq!(parse_number("1/.5", en), None, "a fraction is not a price");
|
||||
assert_eq!(parse_number("1/.5", Some("es-PA")), None);
|
||||
assert_eq!(parse_number("1/.5 B/.", en), None, "the sign at the end does not rescue a fraction");
|
||||
assert_eq!(parse_number("B/. 1/.5", en), None);
|
||||
assert_eq!(parse_number("2 B/. 1,234.50", en), None, "the sign sits before the number");
|
||||
assert_eq!(parse_number("1,234.50 B/. 1,234.50", en), None, "the sign sits between two numbers");
|
||||
assert_eq!(parse_number("1,234.50/2", en), None, "a division is not a balboa amount");
|
||||
assert_eq!(parse_number("B/. 1,234.50,50", en), None, "a second decimal separator");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn english_numbers() {
|
||||
let en = Some("en-US");
|
||||
assert_eq!(parse_number("1,250.50", en), Some(1250.5));
|
||||
assert_eq!(parse_number("$1,250", en), Some(1250.0));
|
||||
assert_eq!(parse_number("PAB 1,250.50", en), Some(1250.5), "the ISO code");
|
||||
assert_eq!(parse_number("1.5", en), Some(1.5));
|
||||
assert_eq!(parse_number("12", None), Some(12.0));
|
||||
assert_eq!(parse_number(".5", None), Some(0.5));
|
||||
|
||||
@@ -16,12 +16,6 @@ pub type Arg = Vec<MNode>;
|
||||
/// Deepest structure nesting kept (parsers stop here; layout draws nothing deeper).
|
||||
pub const MAX_DEPTH: usize = 64;
|
||||
|
||||
/// Most matrix columns kept (writers pad every row to the widest, so this bounds a row's cost).
|
||||
pub const MAX_MATRIX_COLS: usize = 64;
|
||||
|
||||
/// Most matrix rows kept.
|
||||
pub const MAX_MATRIX_ROWS: usize = 1000;
|
||||
|
||||
/// An equation's structure, plus what's needed to write it back.
|
||||
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
|
||||
pub struct Math {
|
||||
|
||||
+30
-137
@@ -294,14 +294,6 @@ pub struct Counters {
|
||||
/// Per abstract list: whether each level has counted yet (a value of 0 can be a real count when a
|
||||
/// level starts at 0).
|
||||
flags: HashMap<u32, [LevelFlags; 9]>,
|
||||
/// The last explicitly rendered label and template at each abstract-list level.
|
||||
labels: HashMap<(u32, u8), CountedLabel>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct CountedLabel {
|
||||
label: Option<String>,
|
||||
levels: Vec<usize>,
|
||||
}
|
||||
|
||||
/// Whether a level has counted yet, and the value its next item takes if a start override says so.
|
||||
@@ -328,7 +320,6 @@ impl Counters {
|
||||
let def = level_def(lv)?.clone();
|
||||
// A "none" level shows no label at all (Word's numFmt none) — not even its text.
|
||||
if def.format == NumFormat::None {
|
||||
self.labels.insert((abs.id, lv as u8), CountedLabel { label: None, levels: Vec::new() });
|
||||
return None;
|
||||
}
|
||||
let st = self.state.entry(abs.id).or_insert([0; 9]);
|
||||
@@ -375,101 +366,42 @@ impl Counters {
|
||||
if restarts {
|
||||
*deeper = 0;
|
||||
*f = LevelFlags::default();
|
||||
self.labels.remove(&(abs.id, d as u8));
|
||||
}
|
||||
}
|
||||
let vals = *st;
|
||||
let started = *fl;
|
||||
let numeric = def.format != NumFormat::Bullet;
|
||||
let label = if numeric { render_label(numbering, num, &def, &vals, &started) } else { def.text.clone() };
|
||||
self.labels.insert((abs.id, lv as u8), CountedLabel { label: numeric.then(|| label.clone()), levels: template_levels(&def.text) });
|
||||
if def.format == NumFormat::Bullet {
|
||||
return Some((def.text.clone(), def));
|
||||
}
|
||||
let mut label = String::new();
|
||||
// Word's level text is short; .docx and the lists command already cap it at 64.
|
||||
let mut chars = def.text.chars().take(MAX_LEVEL_TEXT).peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '%'
|
||||
&& let Some(d) = chars.peek().and_then(|d| d.to_digit(10))
|
||||
{
|
||||
chars.next();
|
||||
let k = (d as usize).saturating_sub(1).min(8);
|
||||
let val = vals.get(k).copied().unwrap_or(0);
|
||||
let counted = started.get(k).is_some_and(|f| f.started);
|
||||
let val = if counted { val } else { level_def(k).map(|l| l.start).unwrap_or(1) };
|
||||
// Legal numbering (w:isLgl) shows every level in Arabic digits, whatever its own
|
||||
// format (二 of a Chinese outline becomes 2 in "2.1").
|
||||
let fmt = match level_def(k).map(|l| l.format).unwrap_or(NumFormat::Decimal) {
|
||||
NumFormat::DecimalZero => NumFormat::DecimalZero,
|
||||
_ if def.legal => NumFormat::Decimal,
|
||||
f => f,
|
||||
};
|
||||
label.push_str(&fmt.format(val));
|
||||
if label.len() > MAX_LABEL_BYTES {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
label.push(c);
|
||||
}
|
||||
}
|
||||
Some((label, def))
|
||||
}
|
||||
/// Which levels of `num`'s list have counted a paragraph yet (a level never used has no
|
||||
/// number of its own to show in a reference).
|
||||
pub fn started(&self, numbering: &Numbering, num: u32) -> Option<[bool; 9]> {
|
||||
let abs = numbering.abstract_of(num)?;
|
||||
Some(std::array::from_fn(|k| self.labels.get(&(abs.id, k as u8)).is_some_and(|last| last.label.is_some())))
|
||||
}
|
||||
|
||||
/// The label `level` of `num`'s list shows at this point of the walk, without counting: what
|
||||
/// the last paragraph at that level showed (`"14."` while walking clause 14's sub-clauses).
|
||||
/// `None` for a bullet or "none" level, or a level the list doesn't define.
|
||||
pub fn current_label(&self, numbering: &Numbering, num: u32, level: u8) -> Option<String> {
|
||||
let abs = numbering.abstract_of(num)?;
|
||||
if let Some(last) = self.labels.get(&(abs.id, level.min(8))) {
|
||||
return last.label.clone();
|
||||
}
|
||||
// A deeper item can count a level implicitly without rendering a paragraph there.
|
||||
let def = numbering.level(num, level.min(8))?;
|
||||
if matches!(def.format, NumFormat::None | NumFormat::Bullet) {
|
||||
return None;
|
||||
}
|
||||
let vals = self.state.get(&abs.id).copied().unwrap_or([0; 9]);
|
||||
let started = self.flags.get(&abs.id).copied().unwrap_or_default();
|
||||
Some(render_label(numbering, num, def, &vals, &started))
|
||||
}
|
||||
|
||||
/// Levels shown by the last rendered label, or the current definition for an implicit level.
|
||||
pub fn current_template_levels(&self, numbering: &Numbering, num: u32, level: u8) -> Vec<usize> {
|
||||
if let Some(abs) = numbering.abstract_of(num)
|
||||
&& let Some(last) = self.labels.get(&(abs.id, level.min(8)))
|
||||
{
|
||||
return last.levels.clone();
|
||||
}
|
||||
numbering.level(num, level.min(8)).map(|def| template_levels(&def.text)).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// The zero-based levels a label template shows (`"%1.%2"` → `[0, 1]`, `"(%3)"` → `[2]`).
|
||||
pub fn template_levels(text: &str) -> Vec<usize> {
|
||||
let mut out = Vec::new();
|
||||
let mut chars = text.chars().take(MAX_LEVEL_TEXT).peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '%'
|
||||
&& let Some(d) = chars.peek().and_then(|d| d.to_digit(10))
|
||||
{
|
||||
chars.next();
|
||||
let k = (d as usize).saturating_sub(1).min(8);
|
||||
if !out.contains(&k) {
|
||||
out.push(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Render a list label without advancing counters, retaining the existing hostile-input caps.
|
||||
fn render_label(numbering: &Numbering, num: u32, def: &Level, vals: &[u32; 9], started: &[LevelFlags; 9]) -> String {
|
||||
let level_def = |k: usize| numbering.level(num, k as u8);
|
||||
let mut label = String::new();
|
||||
// Word's level text is short; .docx and the lists command already cap it at 64.
|
||||
let mut chars = def.text.chars().take(MAX_LEVEL_TEXT).peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '%'
|
||||
&& let Some(d) = chars.peek().and_then(|d| d.to_digit(10))
|
||||
{
|
||||
chars.next();
|
||||
let k = (d as usize).saturating_sub(1).min(8);
|
||||
let val = vals.get(k).copied().unwrap_or(0);
|
||||
let counted = started.get(k).is_some_and(|f| f.started);
|
||||
let val = if counted { val } else { level_def(k).map(|l| l.start).unwrap_or(1) };
|
||||
// Legal numbering (w:isLgl) shows every level in Arabic digits, whatever its own
|
||||
// format (二 of a Chinese outline becomes 2 in "2.1").
|
||||
let fmt = match level_def(k).map(|l| l.format).unwrap_or(NumFormat::Decimal) {
|
||||
NumFormat::DecimalZero => NumFormat::DecimalZero,
|
||||
_ if def.legal => NumFormat::Decimal,
|
||||
f => f,
|
||||
};
|
||||
label.push_str(&fmt.format(val));
|
||||
if label.len() > MAX_LABEL_BYTES {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
label.push(c);
|
||||
}
|
||||
}
|
||||
label
|
||||
}
|
||||
|
||||
/// Longest level text (in characters) expanded into a list label.
|
||||
@@ -481,45 +413,6 @@ const MAX_LABEL_BYTES: usize = 256;
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Read-only counter APIs retain the renderer's bounds and never advance a list.
|
||||
#[test]
|
||||
fn reference_labels_are_bounded_and_do_not_advance_counters() {
|
||||
let mut n = Numbering::default();
|
||||
let id = n.add_list(ListKind::Numbered);
|
||||
if let Some(num) = n.nums.iter_mut().find(|x| x.id == id) {
|
||||
num.level_overrides = vec![(0, Level { text: "%1".repeat(30_000), ..Level::default() })];
|
||||
}
|
||||
let mut c = Counters::default();
|
||||
let label = c.next_label(&n, id, 0).unwrap().0;
|
||||
let before = c.values(&n, id);
|
||||
assert_eq!(c.current_label(&n, id, 0).as_deref(), Some(label.as_str()));
|
||||
assert_eq!(c.values(&n, id), before);
|
||||
assert!(label.len() <= 512);
|
||||
assert_eq!(template_levels(&"%1%9".repeat(30_000)), [0, 8]);
|
||||
assert!(c.started(&n, id).unwrap()[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_ancestor_label_keeps_the_instance_that_rendered_it() {
|
||||
let mut numbering = Numbering::default();
|
||||
let first = numbering.add_list(ListKind::Numbered);
|
||||
numbering.abstracts[0].levels[0].text = "Clause %1.".into();
|
||||
let mut second = numbering.num(first).unwrap().clone();
|
||||
second.id = first + 1;
|
||||
second.level_overrides = vec![
|
||||
(0, Level { text: "Part %1.%2.".into(), ..Level::default() }),
|
||||
(1, Level { format: NumFormat::LowerLetter, text: "(%2)".into(), ..Level::default() }),
|
||||
];
|
||||
let second_id = second.id;
|
||||
numbering.nums.push(second);
|
||||
let mut counters = Counters::default();
|
||||
assert_eq!(counters.next_label(&numbering, first, 0).unwrap().0, "Clause 1.");
|
||||
assert_eq!(counters.next_label(&numbering, second_id, 1).unwrap().0, "(a)");
|
||||
assert_eq!(counters.current_label(&numbering, second_id, 0).as_deref(), Some("Clause 1."));
|
||||
assert_eq!(counters.current_template_levels(&numbering, second_id, 0), vec![0]);
|
||||
assert_eq!(counters.started(&numbering, second_id).unwrap()[..2], [true, true]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_huge_level_text_gives_a_bounded_label() {
|
||||
let mut n = Numbering::default();
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
//! How WordCraft fills in what the OpenDocument standard leaves to the application.
|
||||
//!
|
||||
//! Where ODF 1.3 defines a value (directly, or by referring to XSL for an `fo:` property), that
|
||||
//! is the rule and nothing here changes it. Where it doesn't (ODF 1.3 Part 3 §16.2: "in all
|
||||
//! remaining cases an implementation-dependent value is used"), each application chooses, and
|
||||
//! documents from one application can look different in another. An [`OdfProfile`] is that
|
||||
//! choice for every such case: Microsoft Word's style ([`OdfPreset::Word`], the default: as
|
||||
//! WordCraft lays out Word documents), LibreOffice's style ([`OdfPreset::LibreOffice`]), or the
|
||||
//! user's own ([`OdfPreset::Custom`]). `docs/odf-interpretation.md` lists every case.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::props::Rgb;
|
||||
|
||||
/// Whose choices fill in what the standard leaves open.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum OdfPreset {
|
||||
/// Microsoft Word's style: the behaviour WordCraft has for its own and Word documents.
|
||||
/// (`wordcraft`, its earlier id, is still taken.)
|
||||
#[default]
|
||||
#[serde(alias = "wordcraft")]
|
||||
Word,
|
||||
/// LibreOffice's (and Collabora's, which share its engine).
|
||||
LibreOffice,
|
||||
/// The user's own choice for each case ([`OdfProfile`]'s values).
|
||||
Custom,
|
||||
}
|
||||
|
||||
impl OdfPreset {
|
||||
pub const ALL: [OdfPreset; 3] = [OdfPreset::Word, OdfPreset::LibreOffice, OdfPreset::Custom];
|
||||
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
OdfPreset::Word => "word",
|
||||
OdfPreset::LibreOffice => "libreoffice",
|
||||
OdfPreset::Custom => "custom",
|
||||
}
|
||||
}
|
||||
|
||||
/// The preset named `id` (any case): its id, or `wordcraft` / `msword` for Word's.
|
||||
pub fn from_id(id: &str) -> Option<OdfPreset> {
|
||||
let id = id.trim();
|
||||
if ["wordcraft", "msword", "ms-word"].iter().any(|a| a.eq_ignore_ascii_case(id)) {
|
||||
return Some(OdfPreset::Word);
|
||||
}
|
||||
OdfPreset::ALL.into_iter().find(|p| p.id().eq_ignore_ascii_case(id))
|
||||
}
|
||||
|
||||
/// The two styles a document can be shown in, as the user chooses between them (Custom
|
||||
/// aside).
|
||||
pub const STYLES: [OdfPreset; 2] = [OdfPreset::Word, OdfPreset::LibreOffice];
|
||||
|
||||
/// Its name as the user sees it (File › Options, English; the UI translates it).
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
OdfPreset::Word => "Microsoft Word style",
|
||||
OdfPreset::LibreOffice => "LibreOffice style",
|
||||
OdfPreset::Custom => "Custom",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The most a length in a profile may be, points (22 inches).
|
||||
pub const MAX_LENGTH: f32 = 1584.0;
|
||||
|
||||
/// The choice for each case the standard leaves open (see the module docs). With a preset other
|
||||
/// than [`OdfPreset::Custom`], the preset's values apply whatever these hold
|
||||
/// ([`OdfProfile::effective`]).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct OdfProfile {
|
||||
pub preset: OdfPreset,
|
||||
/// A page margin a page layout doesn't state, points. §20.206–20.209 refer to XSL's margins
|
||||
/// (§7.10), whose initial value is 0; whether that initial value comes with the reference
|
||||
/// the standard doesn't say (LibreOffice uses 2 cm).
|
||||
pub page_margin: f32,
|
||||
/// The height of a header or footer whose style states none (§20.212.2 `fo:min-height`
|
||||
/// has no default), points.
|
||||
pub header_min_height: f32,
|
||||
/// A header's or footer's minimum height takes in its spacing from the body (`fo:margin-*`
|
||||
/// of `style:header-footer-properties`; the standard doesn't say), rather than the spacing
|
||||
/// coming after it.
|
||||
pub header_height_includes_spacing: bool,
|
||||
/// What a header or footer whose style doesn't say does with its spacing when its content
|
||||
/// is taller than its height (§20.264 `style:dynamic-spacing` has no default): grows into it
|
||||
/// (`true`), or keeps it (`false`, both presets: with no height stated, growing into it would
|
||||
/// drop the spacing the file states).
|
||||
pub dynamic_spacing: bool,
|
||||
/// Paragraph tab stops are measured from the paragraph's indent rather than the margin when
|
||||
/// the document doesn't say (§19.512.3 `style:position` doesn't say from where; §19.861 `text:relative-tab-stop-position` says so only for indexes; LibreOffice's own
|
||||
/// `TabsRelativeToIndent` setting, in `settings.xml`, says so when present).
|
||||
pub tabs_from_indent: bool,
|
||||
/// The gap between columns whose `style:columns` states neither one nor each column
|
||||
/// (§19.233 `fo:column-gap` has no default), points.
|
||||
pub column_gap: f32,
|
||||
/// A drawing shape's fill colour when no style gives one (§20.119 `draw:fill-color` has no
|
||||
/// default).
|
||||
pub shape_fill: Rgb,
|
||||
/// A drawing shape's line colour when no style gives one (§20.408 `svg:stroke-color` has no default).
|
||||
pub shape_line: Rgb,
|
||||
/// A drawing shape's line width when no style gives one, and what a width of 0 draws at
|
||||
/// (§20.410 `svg:stroke-width` gives neither), points.
|
||||
pub hairline: f32,
|
||||
/// A page-anchored frame placed against a paragraph (a combination §20.398 and §20.299 don't
|
||||
/// define) is placed against the page (`true`), else against the page's text area.
|
||||
pub page_frame_paragraph_is_page: bool,
|
||||
/// An in-line frame placed against something an in-line frame can't be (a paragraph or a
|
||||
/// page, §20.398 Table 23) is placed against the baseline (`true`), else stands on it.
|
||||
pub inline_invalid_relation_is_baseline: bool,
|
||||
/// A line holding only in-line pictures is as tall as they are (`true`), else as tall as
|
||||
/// its text would be too (the standard defines no line-height algorithm).
|
||||
pub picture_lines_fit_pictures: bool,
|
||||
/// A columned section that doesn't say whether its columns are balanced (§20.428
|
||||
/// `text:dont-balance-text-columns` has no default, and its value descriptions contradict
|
||||
/// its name) ends with balanced columns at the end of the document too (`true`), else only
|
||||
/// when text follows on the same page, as in Word.
|
||||
pub balance_section_columns: bool,
|
||||
/// The space between two paragraphs is the first's space after plus the second's space
|
||||
/// before (`true`), else the larger of the two, when the document doesn't say (the
|
||||
/// standard doesn't; LibreOffice's own `AddParaTableSpacing` setting does when present).
|
||||
pub sum_paragraph_spacing: bool,
|
||||
/// Widow and orphan control when no style states `fo:widows` or `fo:orphans` (§20.312,
|
||||
/// §20.244 refer to XSL's, whose initial value is 2 lines; whether that initial value comes
|
||||
/// with the reference the standard doesn't say, and LibreOffice reads none stated as none).
|
||||
pub widow_control: bool,
|
||||
}
|
||||
|
||||
impl Default for OdfProfile {
|
||||
fn default() -> Self {
|
||||
OdfProfile::word()
|
||||
}
|
||||
}
|
||||
|
||||
impl OdfProfile {
|
||||
/// Microsoft Word's style: as WordCraft lays out its own and Word documents.
|
||||
pub fn word() -> OdfProfile {
|
||||
OdfProfile {
|
||||
preset: OdfPreset::Word,
|
||||
page_margin: 0.0,
|
||||
header_min_height: 0.0,
|
||||
header_height_includes_spacing: false,
|
||||
dynamic_spacing: false,
|
||||
tabs_from_indent: false,
|
||||
column_gap: 36.0,
|
||||
shape_fill: Rgb(0x15, 0x60, 0x82),
|
||||
shape_line: Rgb(0x0E, 0x40, 0x5A),
|
||||
hairline: 0.25,
|
||||
page_frame_paragraph_is_page: false,
|
||||
inline_invalid_relation_is_baseline: false,
|
||||
picture_lines_fit_pictures: false,
|
||||
balance_section_columns: false,
|
||||
sum_paragraph_spacing: false,
|
||||
widow_control: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// LibreOffice's choices, measured in LibreOffice 24 where it doesn't document them.
|
||||
pub fn libreoffice() -> OdfProfile {
|
||||
OdfProfile {
|
||||
preset: OdfPreset::LibreOffice,
|
||||
page_margin: 2.0 * 72.0 / 2.54,
|
||||
header_min_height: 0.5 * 72.0 / 2.54,
|
||||
header_height_includes_spacing: true,
|
||||
dynamic_spacing: false,
|
||||
tabs_from_indent: true,
|
||||
column_gap: 0.0,
|
||||
shape_fill: Rgb(0x72, 0x9F, 0xCF),
|
||||
shape_line: Rgb(0x34, 0x65, 0xA4),
|
||||
hairline: 0.5,
|
||||
page_frame_paragraph_is_page: true,
|
||||
inline_invalid_relation_is_baseline: true,
|
||||
picture_lines_fit_pictures: true,
|
||||
balance_section_columns: true,
|
||||
sum_paragraph_spacing: true,
|
||||
widow_control: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// A preset's values (a custom profile starts from Word's style).
|
||||
pub fn of(preset: OdfPreset) -> OdfProfile {
|
||||
match preset {
|
||||
OdfPreset::Word | OdfPreset::Custom => OdfProfile { preset, ..OdfProfile::word() },
|
||||
OdfPreset::LibreOffice => OdfProfile::libreoffice(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The values that apply: a preset's own, or, for a custom profile, these (lengths made
|
||||
/// finite and bounded).
|
||||
pub fn effective(&self) -> OdfProfile {
|
||||
let length = |v: f32, least: f32| if v.is_finite() { v.clamp(least, MAX_LENGTH) } else { least };
|
||||
match self.preset {
|
||||
OdfPreset::Custom => OdfProfile {
|
||||
page_margin: length(self.page_margin, 0.0),
|
||||
header_min_height: length(self.header_min_height, 0.0),
|
||||
column_gap: length(self.column_gap, 0.0),
|
||||
hairline: length(self.hairline, 0.05),
|
||||
..self.clone()
|
||||
},
|
||||
p => OdfProfile::of(p),
|
||||
}
|
||||
}
|
||||
|
||||
/// What the layout needs to know of it for a document opened from ODF.
|
||||
pub fn layout(&self) -> OdfLayout {
|
||||
let e = self.effective();
|
||||
OdfLayout { picture_lines_fit_pictures: e.picture_lines_fit_pictures, tabs_from_indent: e.tabs_from_indent }
|
||||
}
|
||||
}
|
||||
|
||||
/// A document opened from ODF, for the layout: rules of the standard that differ from Word's
|
||||
/// (a paragraph's padding takes room on a side without a border, XSL's box model) apply, and
|
||||
/// the profile's choices that are made as the document is laid out.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct OdfLayout {
|
||||
/// See [`OdfProfile::picture_lines_fit_pictures`].
|
||||
pub picture_lines_fit_pictures: bool,
|
||||
/// Tab stops are measured from the paragraph's left indent, default ones too (LibreOffice's
|
||||
/// `TabsRelativeToIndent`, else [`OdfProfile::tabs_from_indent`]). The document's explicit
|
||||
/// stops are held from the margin either way; the default ones are counted from the indent.
|
||||
pub tabs_from_indent: bool,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn presets_and_custom_values() {
|
||||
assert_eq!(OdfProfile::default().preset, OdfPreset::Word);
|
||||
assert_eq!(OdfPreset::from_id(" LibreOffice "), Some(OdfPreset::LibreOffice));
|
||||
assert_eq!(OdfPreset::from_id("google"), None);
|
||||
// Word's style by its id, and by the id it had before.
|
||||
for id in ["word", "Word", "wordcraft", "MSWord", "ms-word"] {
|
||||
assert_eq!(OdfPreset::from_id(id), Some(OdfPreset::Word), "{id}");
|
||||
}
|
||||
assert_eq!(serde_json::from_value::<OdfPreset>(serde_json::json!("wordcraft")).unwrap(), OdfPreset::Word);
|
||||
assert_eq!(serde_json::from_value::<OdfPreset>(serde_json::json!("word")).unwrap(), OdfPreset::Word);
|
||||
assert_eq!(OdfPreset::STYLES.map(OdfPreset::label), ["Microsoft Word style", "LibreOffice style"]);
|
||||
// Saved and taken by `file.options` as its id.
|
||||
for p in OdfPreset::ALL {
|
||||
assert_eq!(serde_json::to_value(p).unwrap(), serde_json::json!(p.id()));
|
||||
}
|
||||
// A preset's values apply whatever the profile holds.
|
||||
let lo = OdfProfile { column_gap: 99.0, ..OdfProfile::libreoffice() };
|
||||
assert_eq!(lo.effective(), OdfProfile::libreoffice());
|
||||
// A custom profile's own, bounded.
|
||||
let custom = OdfProfile { preset: OdfPreset::Custom, column_gap: f32::NAN, header_min_height: 1e9, ..OdfProfile::word() };
|
||||
let e = custom.effective();
|
||||
assert_eq!((e.column_gap, e.header_min_height), (0.0, MAX_LENGTH));
|
||||
assert!(OdfProfile::libreoffice().layout().picture_lines_fit_pictures);
|
||||
assert!(!OdfProfile::word().layout().picture_lines_fit_pictures);
|
||||
}
|
||||
}
|
||||
@@ -231,7 +231,7 @@ impl Paragraph {
|
||||
|
||||
/// Number of objects in `text[..off]`.
|
||||
fn objects_before(&self, off: usize) -> usize {
|
||||
self.text.get(..off).map(|t| t.chars().inspect(|_| crate::work::steps(1)).filter(|c| *c == OBJ).count()).unwrap_or(0)
|
||||
self.text.get(..off).map(|t| t.chars().filter(|c| *c == OBJ).count()).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// The object at byte `off` (which must hold U+FFFC).
|
||||
@@ -250,13 +250,13 @@ impl Paragraph {
|
||||
}
|
||||
/// Byte offsets of all objects, in order.
|
||||
pub fn object_offsets(&self) -> Vec<usize> {
|
||||
self.text.char_indices().inspect(|_| crate::work::steps(1)).filter(|(_, c)| *c == OBJ).map(|(i, _)| i).collect()
|
||||
self.text.char_indices().filter(|(_, c)| *c == OBJ).map(|(i, _)| i).collect()
|
||||
}
|
||||
/// (byte offset, object) for every object, in text order, in one pass. Prefer it to
|
||||
/// [`Paragraph::object_offsets`] + [`Paragraph::object_at`], which counts the objects before
|
||||
/// each offset again, so a loop over a paragraph's objects is quadratic (#3614).
|
||||
pub fn objects_with_offsets(&self) -> impl Iterator<Item = (usize, &InlineObject)> + '_ {
|
||||
self.text.char_indices().inspect(|_| crate::work::steps(1)).filter(|(_, c)| *c == OBJ).map(|(i, _)| i).zip(self.objects.iter())
|
||||
self.text.char_indices().filter(|(_, c)| *c == OBJ).map(|(i, _)| i).zip(self.objects.iter())
|
||||
}
|
||||
|
||||
/// Insert plain text (U+FFFC is dropped) at `off` with `props`. Returns the inserted length.
|
||||
|
||||
@@ -19,9 +19,6 @@ pub enum InlineObject {
|
||||
/// Crop (left, top, right, bottom) fractions 0..1.
|
||||
#[serde(default)]
|
||||
crop: [f32; 4],
|
||||
/// Brightness, contrast and transparency read from the file (see [`crate::picfx`]).
|
||||
#[serde(default, skip_serializing_if = "crate::picfx::PicFx::is_identity")]
|
||||
fx: crate::picfx::PicFx,
|
||||
/// The OLE object (embedded or linked file) or Word signature line this picture shows, as
|
||||
/// read from a file: saving writes the object back, not only its picture. See
|
||||
/// [`crate::graphic::Embedded`].
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
//! Picture colour corrections read from a file: brightness, contrast and transparency
|
||||
//! (DrawingML `a:lum` and `a:alphaModFix` inside `a:blip`, ECMA-376 §20.1.8.42 and §20.1.8.4;
|
||||
//! VML `v:imagedata` `gain` and `blacklevel`, which Word's picture watermark "Washout" sets).
|
||||
//!
|
||||
//! Values are kept in OOXML units: thousandths of a percent, so 100 000 is 100 %. All zero is
|
||||
//! the picture as it is.
|
||||
//!
|
||||
//! The pixel formula was measured on Word (#1711, #1766): Word bakes the corrected pixels into
|
||||
//! the picture it embeds in a PDF export, so a synthetic grey and colour ramp exported with
|
||||
//! 20 `a:lum` combinations and 6 VML `gain`/`blacklevel` pairs gives the curve. Every channel is
|
||||
//! mapped on its own (no hue or luminance coupling) by one straight line, clamped to 0..=255
|
||||
//! only at the end:
|
||||
//!
|
||||
//! `y = s·(x − 127.5) + 127.5 + 127.5·b·(1 + s)`, with `b = bright / 100 000`, `c = contrast /
|
||||
//! 100 000`, `s = 1 + c` when `c ≤ 0` and `s = 1 / (1 − c)` when `c > 0`
|
||||
//!
|
||||
//! (as if half the brightness shift were applied before the contrast and half after). Measured
|
||||
//! slope and intercept of Word's output for grey input `x` (PNG pictures, exact to ±1 level):
|
||||
//! bright 20 000 → 1.00, +51; bright −40 000 → 1.00, −103; contrast 80 000 → 5.00, −510;
|
||||
//! contrast −80 000 → 0.20, +101.4; (40 000, −40 000) → 0.60, +132.2; (80 000, −80 000) →
|
||||
//! 0.20, +223.8 (also the #1766 header picture: fitted 0.197..0.201, +223.6..224.0);
|
||||
//! (−40 000, 40 000) → 1.667, −221.7; (30 000, 60 000) → 2.50, −57.8.
|
||||
//!
|
||||
//! VML: `gain` is the slope itself and `blacklevel` half the brightness — Word draws `gain` 0.3,
|
||||
//! `blacklevel` 0.35 (Washout) as 0.30, +205.3, the same as `a:lum` (70 000, −70 000), and
|
||||
//! `gain` 1, `blacklevel` 0.2 as the very same picture as `bright="40000"`.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The largest magnitude of a correction (100 %).
|
||||
pub const FX_MAX: i32 = 100_000;
|
||||
|
||||
/// Brightness, contrast and transparency of a picture (see the module docs).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct PicFx {
|
||||
/// −100 000 (black) ..= 100 000 (white).
|
||||
pub bright: i32,
|
||||
/// −100 000 (flat grey) ..= 100 000 (hard threshold).
|
||||
pub contrast: i32,
|
||||
/// 0 (opaque) ..= 100 000 (invisible).
|
||||
pub transparency: i32,
|
||||
}
|
||||
|
||||
impl PicFx {
|
||||
/// Corrections clamped to their ranges.
|
||||
pub fn new(bright: i32, contrast: i32, transparency: i32) -> PicFx {
|
||||
PicFx { bright: bright.clamp(-FX_MAX, FX_MAX), contrast: contrast.clamp(-FX_MAX, FX_MAX), transparency: transparency.clamp(0, FX_MAX) }
|
||||
}
|
||||
|
||||
/// The same corrections, each clamped to its range (for values that came from anywhere).
|
||||
pub fn clamped(self) -> PicFx {
|
||||
PicFx::new(self.bright, self.contrast, self.transparency)
|
||||
}
|
||||
|
||||
/// No correction at all.
|
||||
pub fn is_identity(&self) -> bool {
|
||||
*self == PicFx::default()
|
||||
}
|
||||
|
||||
/// Brightness or contrast changes the pixels (transparency alone doesn't).
|
||||
pub fn changes_pixels(&self) -> bool {
|
||||
let f = self.clamped();
|
||||
f.bright != 0 || f.contrast != 0
|
||||
}
|
||||
|
||||
/// The picture's opacity, 0..=1 (1 − transparency).
|
||||
pub fn opacity(&self) -> f32 {
|
||||
1.0 - self.transparency.clamp(0, FX_MAX) as f32 / FX_MAX as f32
|
||||
}
|
||||
|
||||
/// The corrections a VML `v:imagedata` gives with `gain` and `blacklevel` (already parsed to
|
||||
/// plain numbers, 1 and 0 when missing): contrast from the slope `gain`, brightness twice the
|
||||
/// black level (see the module docs). Non-finite values count as missing.
|
||||
pub fn from_vml(gain: f64, blacklevel: f64) -> PicFx {
|
||||
let gain = if gain.is_finite() { gain.clamp(0.0, 1.0e6) } else { 1.0 };
|
||||
let black = if blacklevel.is_finite() { blacklevel.clamp(-0.5, 0.5) } else { 0.0 };
|
||||
// s = 1 + c (c ≤ 0) or 1 / (1 − c) (c > 0).
|
||||
let c = if gain <= 1.0 { gain - 1.0 } else { 1.0 - 1.0 / gain };
|
||||
let fx = |v: f64| (v * FX_MAX as f64).round().clamp(-(FX_MAX as f64), FX_MAX as f64) as i32;
|
||||
PicFx::new(fx(2.0 * black), fx(c), 0)
|
||||
}
|
||||
|
||||
/// `gain` and `blacklevel` for VML (the inverse of [`PicFx::from_vml`]).
|
||||
pub fn to_vml(&self) -> (f64, f64) {
|
||||
(self.slope(), self.clamped().bright as f64 / FX_MAX as f64 / 2.0)
|
||||
}
|
||||
|
||||
/// The contrast slope `s` (capped at 255: contrast 100 % is a threshold).
|
||||
fn slope(&self) -> f64 {
|
||||
let c = self.contrast.clamp(-FX_MAX, FX_MAX) as f64 / FX_MAX as f64;
|
||||
if c <= 0.0 { 1.0 + c } else { (1.0 / (1.0 - c).max(1.0 / 255.0)).min(255.0) }
|
||||
}
|
||||
|
||||
/// The lookup table every colour channel goes through.
|
||||
pub fn table(&self) -> [u8; 256] {
|
||||
let s = self.slope();
|
||||
let b = self.bright.clamp(-FX_MAX, FX_MAX) as f64 / FX_MAX as f64;
|
||||
let mut t = [0u8; 256];
|
||||
for (x, slot) in t.iter_mut().enumerate() {
|
||||
let y = s * (x as f64 - 127.5) + 127.5 + 127.5 * b * (1.0 + s);
|
||||
*slot = if y.is_finite() { y.round().clamp(0.0, 255.0) as u8 } else { 0 };
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
/// Apply brightness and contrast to straight (not premultiplied) RGBA8 pixels in place. The
|
||||
/// alpha byte is left alone (transparency is drawn as an opacity, see [`PicFx::opacity`]); a
|
||||
/// trailing partial pixel is ignored.
|
||||
pub fn apply_rgba(&self, rgba: &mut [u8]) {
|
||||
if !self.changes_pixels() {
|
||||
return;
|
||||
}
|
||||
let t = self.table();
|
||||
for px in rgba.as_chunks_mut::<4>().0 {
|
||||
for c in px.iter_mut().take(3) {
|
||||
*c = t.get(usize::from(*c)).copied().unwrap_or(*c);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Word's own output for grey input 0, 128 and 255 (PNG pictures in a PDF export).
|
||||
#[test]
|
||||
fn matches_word() {
|
||||
let cases: [(PicFx, [u8; 3]); 6] = [
|
||||
(PicFx::new(40_000, -40_000, 0), [132, 209, 255]),
|
||||
(PicFx::new(80_000, -80_000, 0), [224, 249, 255]),
|
||||
(PicFx::new(-40_000, 40_000, 0), [0, 0, 203]),
|
||||
(PicFx::new(0, 80_000, 0), [0, 130, 255]),
|
||||
(PicFx::new(20_000, 0, 0), [51, 179, 255]),
|
||||
(PicFx::from_vml(19661.0 / 65536.0, 22938.0 / 65536.0), [205, 244, 255]),
|
||||
];
|
||||
for (fx, want) in cases {
|
||||
let t = fx.table();
|
||||
for (x, w) in [0usize, 128, 255].into_iter().zip(want) {
|
||||
let got = t[x];
|
||||
assert!(got.abs_diff(w) <= 2, "{fx:?}: {x} → {got}, Word {w}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channels_apart_alpha_kept() {
|
||||
let fx = PicFx::new(80_000, -80_000, 0);
|
||||
let mut px = [255, 0, 0, 77, 0, 128, 255, 255];
|
||||
fx.apply_rgba(&mut px);
|
||||
// Word: red (255, 0, 0) → (255, 224, 224); 128 → 249 (the formula gives 250.0).
|
||||
assert_eq!(px, [255, 224, 224, 77, 224, 250, 255, 255]);
|
||||
let mut odd = [0u8; 6];
|
||||
fx.apply_rgba(&mut odd);
|
||||
assert_eq!(odd[4..], [0, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vml_mapping() {
|
||||
// 16.16 fixed point: 19661f and 22938f are 0.3 and 0.35 to within 1e-5.
|
||||
let washout = PicFx::from_vml(19661.0 / 65536.0, 22938.0 / 65536.0);
|
||||
assert!((washout.bright - 70_000).abs() <= 2 && (washout.contrast + 70_000).abs() <= 2, "{washout:?}");
|
||||
assert_eq!(PicFx::from_vml(1.0, 0.2), PicFx::new(40_000, 0, 0));
|
||||
assert_eq!(PicFx::from_vml(2.0, 0.0), PicFx::new(0, 50_000, 0));
|
||||
assert!(PicFx::from_vml(1.0, 0.0).is_identity());
|
||||
for (g, b) in [(f64::NAN, f64::INFINITY), (-1.0e30, 1.0e30), (1.0e300, -7.0), (0.0, 0.0)] {
|
||||
let fx = PicFx::from_vml(g, b);
|
||||
assert_eq!(fx, fx.clamped(), "{g} {b}");
|
||||
let _ = fx.table();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_values_clamp() {
|
||||
for v in [i32::MIN, -100_001, 0, 100_001, i32::MAX] {
|
||||
let fx = PicFx { bright: v, contrast: v, transparency: v };
|
||||
let _ = fx.table();
|
||||
assert!((0.0..=1.0).contains(&fx.opacity()));
|
||||
let mut px = [1u8, 2, 3, 4];
|
||||
fx.apply_rgba(&mut px);
|
||||
assert_eq!(px[3], 4);
|
||||
}
|
||||
assert_eq!(PicFx::new(0, 0, 25_000).opacity(), 0.75);
|
||||
assert!(!PicFx::new(0, 0, 25_000).changes_pixels());
|
||||
// Contrast 100 %: a threshold at mid-grey, no NaN.
|
||||
let t = PicFx::new(0, FX_MAX, 0).table();
|
||||
assert_eq!((t[0], t[127], t[128], t[255]), (0, 0, 255, 255));
|
||||
}
|
||||
}
|
||||
@@ -26,9 +26,7 @@ pub use char_props::{
|
||||
CharProps, CombineBrackets, DEFAULT_FONT_SIZE, EastAsianLayout, EmphasisMark, Highlight, Ligatures, NumChange, PropChange, Underline, VertAlign,
|
||||
};
|
||||
pub use color::{Rgb, TextColor};
|
||||
pub use para_props::{
|
||||
Align, Border, BorderStyle, Borders, LineSpacing, NumRef, ParaProps, TAB_POS_EPS, TabAlign, TabLeader, TabStop, TextAlignment, TextFrame,
|
||||
};
|
||||
pub use para_props::{Align, Border, BorderStyle, Borders, LineSpacing, NumRef, ParaProps, TAB_POS_EPS, TabAlign, TabLeader, TabStop, TextFrame};
|
||||
pub use table_props::{
|
||||
CellPicture, CellProps, HeightRule, PictureRepeat, RowProps, TableFloat, TableLook, TableProps, TextDirection, TextVert, VAlign, VMerge,
|
||||
anchor_ooxml, valign_from_anchor,
|
||||
|
||||
@@ -26,40 +26,6 @@ impl Align {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum TextAlignment {
|
||||
Auto,
|
||||
Top,
|
||||
Center,
|
||||
Baseline,
|
||||
Bottom,
|
||||
}
|
||||
|
||||
impl TextAlignment {
|
||||
pub fn ooxml(self) -> &'static str {
|
||||
match self {
|
||||
TextAlignment::Auto => "auto",
|
||||
TextAlignment::Top => "top",
|
||||
TextAlignment::Center => "center",
|
||||
TextAlignment::Baseline => "baseline",
|
||||
TextAlignment::Bottom => "bottom",
|
||||
}
|
||||
}
|
||||
pub fn from_ooxml(s: &str) -> Option<TextAlignment> {
|
||||
match s {
|
||||
"auto" => Some(TextAlignment::Auto),
|
||||
"top" => Some(TextAlignment::Top),
|
||||
"center" => Some(TextAlignment::Center),
|
||||
"baseline" => Some(TextAlignment::Baseline),
|
||||
"bottom" => Some(TextAlignment::Bottom),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Line spacing rule.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "rule", content = "value")]
|
||||
@@ -141,18 +107,6 @@ pub enum BorderStyle {
|
||||
Triple,
|
||||
DotDash,
|
||||
Wave,
|
||||
/// The compound lines of thin and thick strokes (`thinThickSmallGap` to
|
||||
/// `thinThickThinLargeGap`), kept so a file writes back the line it read; drawn as a single
|
||||
/// line of the border's width.
|
||||
ThinThickSmallGap,
|
||||
ThickThinSmallGap,
|
||||
ThinThickThinSmallGap,
|
||||
ThinThickMediumGap,
|
||||
ThickThinMediumGap,
|
||||
ThinThickThinMediumGap,
|
||||
ThinThickLargeGap,
|
||||
ThickThinLargeGap,
|
||||
ThinThickThinLargeGap,
|
||||
}
|
||||
|
||||
impl BorderStyle {
|
||||
@@ -167,15 +121,6 @@ impl BorderStyle {
|
||||
BorderStyle::Triple => "triple",
|
||||
BorderStyle::DotDash => "dotDash",
|
||||
BorderStyle::Wave => "wave",
|
||||
BorderStyle::ThinThickSmallGap => "thinThickSmallGap",
|
||||
BorderStyle::ThickThinSmallGap => "thickThinSmallGap",
|
||||
BorderStyle::ThinThickThinSmallGap => "thinThickThinSmallGap",
|
||||
BorderStyle::ThinThickMediumGap => "thinThickMediumGap",
|
||||
BorderStyle::ThickThinMediumGap => "thickThinMediumGap",
|
||||
BorderStyle::ThinThickThinMediumGap => "thinThickThinMediumGap",
|
||||
BorderStyle::ThinThickLargeGap => "thinThickLargeGap",
|
||||
BorderStyle::ThickThinLargeGap => "thickThinLargeGap",
|
||||
BorderStyle::ThinThickThinLargeGap => "thinThickThinLargeGap",
|
||||
}
|
||||
}
|
||||
pub fn from_ooxml(s: &str) -> BorderStyle {
|
||||
@@ -188,15 +133,6 @@ impl BorderStyle {
|
||||
"triple" => BorderStyle::Triple,
|
||||
"dotDash" | "dotDotDash" => BorderStyle::DotDash,
|
||||
"wave" | "doubleWave" => BorderStyle::Wave,
|
||||
"thinThickSmallGap" => BorderStyle::ThinThickSmallGap,
|
||||
"thickThinSmallGap" => BorderStyle::ThickThinSmallGap,
|
||||
"thinThickThinSmallGap" => BorderStyle::ThinThickThinSmallGap,
|
||||
"thinThickMediumGap" => BorderStyle::ThinThickMediumGap,
|
||||
"thickThinMediumGap" => BorderStyle::ThickThinMediumGap,
|
||||
"thinThickThinMediumGap" => BorderStyle::ThinThickThinMediumGap,
|
||||
"thinThickLargeGap" => BorderStyle::ThinThickLargeGap,
|
||||
"thickThinLargeGap" => BorderStyle::ThickThinLargeGap,
|
||||
"thinThickThinLargeGap" => BorderStyle::ThinThickThinLargeGap,
|
||||
_ => BorderStyle::Single,
|
||||
}
|
||||
}
|
||||
@@ -305,9 +241,6 @@ pub struct TextFrame {
|
||||
pub v_anchor: String,
|
||||
/// `around`, `auto`, `notBeside`, `none`, `tight` or `through`.
|
||||
pub wrap: String,
|
||||
/// Whether the frame stays fixed to its page anchor when its paragraph moves.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub anchor_lock: Option<bool>,
|
||||
/// Distance from the text left and right, and above and below.
|
||||
pub h_space: f32,
|
||||
pub v_space: f32,
|
||||
@@ -362,9 +295,6 @@ pub struct ParaProps {
|
||||
/// outside ones, so they swap sides on even (left-hand) pages.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub mirror_indents: Option<bool>,
|
||||
/// `w:adjustRightInd`: automatically adjust the right indent for the document grid when enabled; keep the specified right indent when disabled.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub adjust_right_indent: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub keep_next: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -438,10 +368,6 @@ pub struct ParaProps {
|
||||
/// `w:snapToGrid`: lines snap to the section's document grid. Unset = on.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub snap_to_grid: Option<bool>,
|
||||
/// `w:textAlignment`: how characters of different sizes line up on a line. Kept for the
|
||||
/// file (not laid out yet); Word sizes a line by it, so dropping it changes Word's pages.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub text_alignment: Option<TextAlignment>,
|
||||
}
|
||||
|
||||
impl ParaProps {
|
||||
@@ -453,7 +379,7 @@ impl ParaProps {
|
||||
|
||||
fn overlay_layout(&mut self, patch: &ParaProps) {
|
||||
overlay_fields!(self, patch; style, align, indent_left, indent_right, indent_first, indent_left_chars, indent_right_chars, indent_first_chars,
|
||||
space_before, space_after, space_before_auto, space_after_auto, line_spacing, contextual_spacing, mirror_indents, adjust_right_indent, tabs, shading, borders);
|
||||
space_before, space_after, space_before_auto, space_after_auto, line_spacing, contextual_spacing, mirror_indents, tabs, shading, borders);
|
||||
}
|
||||
|
||||
fn overlay_pagination(&mut self, patch: &ParaProps) {
|
||||
@@ -462,7 +388,7 @@ impl ParaProps {
|
||||
}
|
||||
|
||||
fn overlay_script_layout(&mut self, patch: &ParaProps) {
|
||||
overlay_fields!(self, patch; bidi, kinsoku, word_wrap, overflow_punct, top_line_punct, auto_space_de, auto_space_dn, text_alignment);
|
||||
overlay_fields!(self, patch; bidi, kinsoku, word_wrap, overflow_punct, top_line_punct, auto_space_de, auto_space_dn);
|
||||
}
|
||||
pub fn overlaid(mut self, patch: &ParaProps) -> ParaProps {
|
||||
self.overlay(patch);
|
||||
|
||||
@@ -153,10 +153,6 @@ impl ResolvedChar {
|
||||
}
|
||||
|
||||
/// Concrete paragraph formatting.
|
||||
fn default_adjust_right_indent() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ResolvedPara {
|
||||
@@ -177,9 +173,6 @@ pub struct ResolvedPara {
|
||||
pub contextual_spacing: bool,
|
||||
/// Left/right indents are inside/outside: they swap on even pages.
|
||||
pub mirror_indents: bool,
|
||||
/// Automatically adjust the right indent for the document grid when enabled; keep the specified right indent when disabled.
|
||||
#[serde(default = "default_adjust_right_indent")]
|
||||
pub adjust_right_indent: bool,
|
||||
pub keep_next: bool,
|
||||
pub keep_lines: bool,
|
||||
pub page_break_before: bool,
|
||||
@@ -271,7 +264,6 @@ impl ResolvedPara {
|
||||
},
|
||||
contextual_spacing: p.contextual_spacing.unwrap_or(false),
|
||||
mirror_indents: p.mirror_indents.unwrap_or(false),
|
||||
adjust_right_indent: p.adjust_right_indent.unwrap_or(true),
|
||||
keep_next: p.keep_next.unwrap_or(false),
|
||||
keep_lines: p.keep_lines.unwrap_or(false),
|
||||
page_break_before: p.page_break_before.unwrap_or(false),
|
||||
|
||||
@@ -115,40 +115,6 @@ pub enum NumFormat {
|
||||
DollarText,
|
||||
}
|
||||
|
||||
/// Chapter separator for page numbers (`w:chapSep`, ECMA-376 §17.18.12).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ChapterSep {
|
||||
Hyphen,
|
||||
Period,
|
||||
Colon,
|
||||
EmDash,
|
||||
EnDash,
|
||||
}
|
||||
|
||||
impl ChapterSep {
|
||||
pub fn from_ooxml(value: &str) -> Option<Self> {
|
||||
match value {
|
||||
"hyphen" => Some(Self::Hyphen),
|
||||
"period" => Some(Self::Period),
|
||||
"colon" => Some(Self::Colon),
|
||||
"emDash" => Some(Self::EmDash),
|
||||
"enDash" => Some(Self::EnDash),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn ooxml(self) -> &'static str {
|
||||
match self {
|
||||
Self::Hyphen => "hyphen",
|
||||
Self::Period => "period",
|
||||
Self::Colon => "colon",
|
||||
Self::EmDash => "emDash",
|
||||
Self::EnDash => "enDash",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl NumFormat {
|
||||
/// Every format, in the order ST_NumberFormat lists them (which is also the order of the
|
||||
/// binary .doc number format codes up to 0x27).
|
||||
@@ -783,9 +749,6 @@ pub struct SectionProps {
|
||||
pub title_page: bool,
|
||||
pub page_num_start: Option<u32>,
|
||||
pub page_num_format: NumFormat,
|
||||
/// Chapter heading level and separator in `w:pgNumType`.
|
||||
pub chapter_style: Option<u8>,
|
||||
pub chapter_sep: Option<ChapterSep>,
|
||||
/// The file carries a `w:pgNumType` element that sets nothing else (no start, decimal
|
||||
/// format), so saving writes it back rather than dropping it.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
@@ -816,13 +779,6 @@ pub struct SectionProps {
|
||||
pub footnote_pr: NoteProps,
|
||||
#[serde(skip_serializing_if = "NoteProps::is_empty")]
|
||||
pub endnote_pr: NoteProps,
|
||||
/// First-page and other-page printer trays (`w:paperSrc`).
|
||||
pub paper_src_first: Option<u16>,
|
||||
pub paper_src_other: Option<u16>,
|
||||
/// Section-specific suppression of endnotes (`w:noEndnote`).
|
||||
pub no_endnote: Option<bool>,
|
||||
/// Independent footnote column count (`w15:footnoteColumns`).
|
||||
pub footnote_columns: Option<u32>,
|
||||
/// Whether its columns end balanced (an ODF section's `text:dont-balance-text-columns`):
|
||||
/// `None` is Word's rule, balanced when a continuous section follows; `Some(true)` also at
|
||||
/// the end of the document, `Some(false)` never.
|
||||
@@ -853,8 +809,6 @@ impl Default for SectionProps {
|
||||
title_page: false,
|
||||
page_num_start: None,
|
||||
page_num_format: NumFormat::Decimal,
|
||||
chapter_style: None,
|
||||
chapter_sep: None,
|
||||
page_num_set: false,
|
||||
line_numbers: None,
|
||||
valign: PageVAlign::Top,
|
||||
@@ -867,10 +821,6 @@ impl Default for SectionProps {
|
||||
doc_grid: None,
|
||||
footnote_pr: NoteProps::default(),
|
||||
endnote_pr: NoteProps::default(),
|
||||
paper_src_first: None,
|
||||
paper_src_other: None,
|
||||
no_endnote: None,
|
||||
footnote_columns: None,
|
||||
balance_columns: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,22 +167,10 @@ pub struct Settings {
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub remove_date_time: bool,
|
||||
/// The document was opened from ODF: the standard's rules that differ from Word's apply as
|
||||
/// it is laid out.
|
||||
/// it is laid out, with the choices it leaves open made as the user's profile made them
|
||||
/// (see [`crate::odf_profile`]).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub odf: Option<OdfLayout>,
|
||||
}
|
||||
|
||||
/// A document opened from ODF, for the layout: rules of the standard that differ from Word's (a
|
||||
/// paragraph's padding takes room on a side without a border, XSL's box model) apply, and what
|
||||
/// the file says about laying it out.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct OdfLayout {
|
||||
/// Tab stops are measured from the paragraph's left indent, default ones too (LibreOffice's
|
||||
/// `TabsRelativeToIndent` in the file; from the margin when it doesn't say). The document's
|
||||
/// explicit stops are held from the margin either way; the default ones are counted from the
|
||||
/// indent.
|
||||
pub tabs_from_indent: bool,
|
||||
pub odf: Option<crate::odf_profile::OdfLayout>,
|
||||
}
|
||||
|
||||
/// A saved zoom: a percentage, or a fit mode (the percentage it last came to).
|
||||
|
||||
@@ -12,8 +12,6 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::props::Rgb;
|
||||
|
||||
/// Most items in a SmartArt graphic.
|
||||
pub const MAX_ITEMS: usize = 100;
|
||||
/// Longest item text, characters.
|
||||
@@ -200,14 +198,11 @@ pub struct SmartArtItem {
|
||||
pub text: String,
|
||||
#[serde(default)]
|
||||
pub level: u8,
|
||||
/// Fill of the item's shape in place of the style's colour.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub color: Option<Rgb>,
|
||||
}
|
||||
|
||||
impl SmartArtItem {
|
||||
pub fn new(text: impl Into<String>, level: u8) -> SmartArtItem {
|
||||
SmartArtItem { text: text.into(), level, color: None }
|
||||
SmartArtItem { text: text.into(), level }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,35 +257,6 @@ impl SmartArtSpec {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether any item has its own colour.
|
||||
pub fn has_custom_colors(&self) -> bool {
|
||||
self.items.iter().any(|it| it.color.is_some())
|
||||
}
|
||||
|
||||
/// Give every item the style's colour again.
|
||||
pub fn clear_custom_colors(&mut self) {
|
||||
for it in &mut self.items {
|
||||
it.color = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// The theme accent (1–6) the style gives item `i`; `None` when the item has no shape of
|
||||
/// its own (it is text inside another item's shape).
|
||||
pub fn style_accent(&self, i: usize) -> Option<usize> {
|
||||
let it = self.items.get(i)?;
|
||||
let tops = || self.items.iter().take(i).filter(|o| o.level == 0).count();
|
||||
match self.layout {
|
||||
SmartArtLayout::Hierarchy => Some(self.colors.accent(i)),
|
||||
SmartArtLayout::Radial if i == 0 => Some(self.colors.accent(0)),
|
||||
SmartArtLayout::Radial => {
|
||||
let parents = self.parents();
|
||||
let spoke = |j: usize| j > 0 && matches!(parents.get(j), Some(Some(0)) | Some(None));
|
||||
spoke(i).then(|| self.colors.accent((1..i).filter(|j| spoke(*j)).count() + 1))
|
||||
}
|
||||
_ => (it.level == 0).then(|| self.colors.accent(tops())),
|
||||
}
|
||||
}
|
||||
|
||||
/// The graphic after `sanitize`.
|
||||
pub fn sanitized(mut self) -> SmartArtSpec {
|
||||
self.sanitize();
|
||||
|
||||
@@ -966,31 +966,6 @@ fn table_styles() -> Vec<Style> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::resolve::ResolvedPara;
|
||||
|
||||
#[test]
|
||||
fn adjust_right_indent_inherits_and_can_be_overridden() {
|
||||
let resolved = StyleSheet::empty().resolve_para(&ParaProps::default());
|
||||
assert!(resolved.adjust_right_indent);
|
||||
|
||||
let mut older_json = serde_json::to_value(&resolved).unwrap();
|
||||
older_json.as_object_mut().unwrap().remove("adjustRightIndent");
|
||||
assert!(serde_json::from_value::<ResolvedPara>(older_json).unwrap().adjust_right_indent);
|
||||
|
||||
let mut s = StyleSheet::empty();
|
||||
s.styles.push(Style {
|
||||
id: "Normal".into(),
|
||||
name: "Normal".into(),
|
||||
para: ParaProps { adjust_right_indent: Some(true), ..Default::default() },
|
||||
..Default::default()
|
||||
});
|
||||
assert!(s.resolve_para(&ParaProps::default()).adjust_right_indent);
|
||||
assert!(!s.resolve_para(&ParaProps { adjust_right_indent: Some(false), ..Default::default() }).adjust_right_indent);
|
||||
|
||||
let json = serde_json::to_value(ParaProps { adjust_right_indent: Some(false), ..Default::default() }).unwrap();
|
||||
assert_eq!(json["adjustRightIndent"], false);
|
||||
assert_eq!(serde_json::from_value::<ParaProps>(json).unwrap().adjust_right_indent, Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtins_present() {
|
||||
|
||||
@@ -19,25 +19,6 @@ fn word_count() {
|
||||
assert_eq!(count_words(" -- "), 0);
|
||||
}
|
||||
|
||||
/// #2797: the status bar's character counts, worded like the Word Count dialog's: characters
|
||||
/// with spaces leave the paragraph breaks out, characters without spaces leave every
|
||||
/// whitespace character out.
|
||||
#[test]
|
||||
fn character_counts() {
|
||||
assert_eq!(count_chars(""), 0);
|
||||
assert_eq!(count_chars("Hello"), 5);
|
||||
// Paragraph breaks join the paragraphs but aren't characters.
|
||||
assert_eq!(count_chars("Hello\nworld\r\n"), 10);
|
||||
assert_eq!(count_chars("a\tb"), 3, "a tab is a character");
|
||||
// Every kind of whitespace goes, spaces, tabs and (non-ASCII) spaces between words.
|
||||
assert_eq!(count_chars_no_spaces("Hello world"), 10);
|
||||
assert_eq!(count_chars_no_spaces("a\tb c\u{a0}d"), 4, "including a non-breaking space");
|
||||
assert_eq!(count_chars_no_spaces(" \n\r\t "), 0);
|
||||
// Accented letters and emoji count as one character each, as they are.
|
||||
assert_eq!(count_chars("café 🚀"), 6);
|
||||
assert_eq!(count_chars_no_spaces("café 🚀"), 5);
|
||||
}
|
||||
|
||||
/// #4547: each Han ideograph and kana counts as a word; punctuation and Hangul spacing don't change.
|
||||
#[test]
|
||||
fn word_count_counts_each_chinese_and_japanese_character() {
|
||||
|
||||
@@ -102,6 +102,18 @@ fn dense(n: usize) -> Document {
|
||||
d
|
||||
}
|
||||
|
||||
// Thread CPU excludes scheduler delays. Missing or nonadvancing counters provide no timing
|
||||
// evidence; the semantic assertions still run on those platforms.
|
||||
fn cpu_cost(f: impl FnOnce()) -> Option<f64> {
|
||||
let ran = || std::fs::read_to_string("/proc/thread-self/schedstat").ok()?.split_whitespace().next()?.parse::<u64>().ok();
|
||||
let before = ran();
|
||||
f();
|
||||
match (before, ran()) {
|
||||
(Some(a), Some(b)) if b > a => Some((b - a) as f64 * 1e-9),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conversion_work_scales_with_reference_and_formatting_runs() {
|
||||
let (small, big) = (dense(2_000), dense(16_000));
|
||||
@@ -116,13 +128,18 @@ fn conversion_work_scales_with_reference_and_formatting_runs() {
|
||||
};
|
||||
convert(&small);
|
||||
convert(&big);
|
||||
// The characters and runs the conversion walks (`crate::work`), not a timing: about 8x for 8x
|
||||
// the references and runs when they are walked together, about 64x when each run rescans the
|
||||
// references.
|
||||
let s1 = crate::work::count(|| convert(&small));
|
||||
let s8 = crate::work::count(|| convert(&big));
|
||||
let ratio = s8 as f64 / s1.max(1) as f64;
|
||||
eprintln!("note conversion steps: {s1} -> {s8}, {ratio:.1}x for 8x references and runs");
|
||||
assert!(s1 > 0, "no steps counted, so the walk this test watches no longer counts them (crate::work)");
|
||||
assert!(ratio <= 12.0, "8x the references and runs cost {ratio:.1}x the steps ({s1} -> {s8})");
|
||||
let (mut t1, mut t8) = (f64::INFINITY, f64::INFINITY);
|
||||
for _ in 0..3 {
|
||||
let a = cpu_cost(|| (0..8).for_each(|_| convert(&small)));
|
||||
let b = cpu_cost(|| convert(&big));
|
||||
if let (Some(a), Some(b)) = (a, b) {
|
||||
t1 = t1.min(a / 8.0);
|
||||
t8 = t8.min(b);
|
||||
}
|
||||
}
|
||||
if t1.is_finite() && t8.is_finite() {
|
||||
let ratio = t8 / t1;
|
||||
eprintln!("note conversion CPU: small={t1:.6}s big={t8:.6}s ratio={ratio:.2}");
|
||||
assert!(ratio < 24.0, "8x the references and runs cost {ratio:.2}x CPU");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,18 +35,6 @@ fn is_cjk_word(c: char) -> bool {
|
||||
) && c.is_alphanumeric()
|
||||
}
|
||||
|
||||
/// Characters in `s`, as the Word Count dialog counts them ("Characters (with spaces)"):
|
||||
/// paragraph breaks aren't characters (#2797).
|
||||
pub fn count_chars(s: &str) -> usize {
|
||||
s.chars().filter(|c| *c != '\n' && *c != '\r').count()
|
||||
}
|
||||
|
||||
/// Characters in `s` ignoring every whitespace character, as the Word Count dialog counts them
|
||||
/// ("Characters (no spaces)"; #2797).
|
||||
pub fn count_chars_no_spaces(s: &str) -> usize {
|
||||
s.chars().filter(|c| !c.is_whitespace()).count()
|
||||
}
|
||||
|
||||
/// How deep in nested tables [`Document::para_paths`] goes: cells below this are skipped.
|
||||
pub(crate) const PARA_WALK_DEPTH: usize = 16;
|
||||
|
||||
@@ -63,7 +51,6 @@ pub(crate) fn walk(bl: &Blocks, prefix: &mut Vec<u32>, out: &mut Vec<Path>, dept
|
||||
return;
|
||||
}
|
||||
for (i, b) in bl.iter().enumerate() {
|
||||
crate::work::steps(1);
|
||||
prefix.push(i as u32);
|
||||
match &**b {
|
||||
Block::Para(_) => out.push(Path(prefix.clone())),
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
//! A count of the characters a paragraph's object lookups walk, the blocks
|
||||
//! [`crate::Document::para_paths`] visits, and each block lookup by path with the table levels it
|
||||
//! descends, for the linear-time tests of the crates that use them: an input 8x the size must cost
|
||||
//! about 8x the steps, which a busy machine can't skew the way it skews a timing. Counted only in
|
||||
//! tests (this crate's, and other crates' through the `test-support` feature, which they turn on
|
||||
//! as a dev-dependency); in the app the count is nothing.
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
thread_local! {
|
||||
/// The steps counted on this thread.
|
||||
static STEPS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// Count `n` steps of a walk (nothing outside tests). Public for the walks other crates do over a
|
||||
/// paragraph's text themselves (Markdown and HTML comment export, for one).
|
||||
#[inline(always)]
|
||||
pub fn steps(n: usize) {
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
STEPS.with(|s| s.set(s.get().saturating_add(n as u64)));
|
||||
#[cfg(not(any(test, feature = "test-support")))]
|
||||
let _ = n;
|
||||
}
|
||||
|
||||
/// The steps `f` takes.
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub fn count(f: impl FnOnce()) -> u64 {
|
||||
let before = STEPS.with(std::cell::Cell::get);
|
||||
f();
|
||||
STEPS.with(std::cell::Cell::get).saturating_sub(before)
|
||||
}
|
||||
@@ -185,7 +185,7 @@ fn leaf(sp: &Sp, w: f32, h: f32, float: Float, b: &Build, pics: &mut Pictures) -
|
||||
let p = &sp.props;
|
||||
if let Some(pib) = p.pib {
|
||||
let media = pics.bse(pib, b.bstore, b.delay)?;
|
||||
return Some(InlineObject::Image { media, w, h, alt: p.alt.clone(), float, crop: p.crop, fx: Default::default(), ole: None });
|
||||
return Some(InlineObject::Image { media, w, h, alt: p.alt.clone(), float, crop: p.crop, ole: None });
|
||||
}
|
||||
let story = b.stories.get(&sp.spid).copied().filter(|_| sp.textbox || sp.spt == 202);
|
||||
let mut kind = match sp.spt {
|
||||
|
||||
+19
-107
@@ -51,14 +51,6 @@ const MAX_PARTS: usize = 50_000;
|
||||
/// headers, notes, text boxes), whatever the file's size. The working budget is tied to the
|
||||
/// WordDocument stream (see [`read`]); this caps even a maximal stream.
|
||||
const MAX_TOTAL_CHARS: u64 = 64 << 20;
|
||||
/// Most paragraph and cell marks across every story walk of one document. The character
|
||||
/// budget does not see them: a mark is one byte of the file but about 1 KB of document, so a
|
||||
/// file of nothing but marks would grow memory about a thousand times faster than its size.
|
||||
/// The cap bounds a flood to about 2 GB while reading (about 4 GB at the very cap, when the
|
||||
/// blocks are assembled). Past it the read fails with [`DocbinError::Limit`]. Real documents stay far below it:
|
||||
/// 50 paragraphs on each of 10,000 pages is 500,000, and a 10,000-page one-column table (46
|
||||
/// rows, each a cell mark plus a row mark) is about 920,000.
|
||||
const MAX_PARAGRAPHS: usize = 2_000_000;
|
||||
/// Bytes of repeated work per document beyond the text itself: piece Prm grpprls (Prcs, up
|
||||
/// to 64 KB each) interpreted (#3692), hyperlink targets (#3708), picture alt text (#3709)
|
||||
/// and floating objects (#3710) copied into every run or anchor. Past it Prms are skipped,
|
||||
@@ -203,12 +195,6 @@ pub fn read(bytes: &[u8]) -> Result<Document, DocbinError> {
|
||||
/// verifier ([`DocbinError::WrongPassword`] when it does not match) and then decrypted. The
|
||||
/// password is ignored for an unprotected file.
|
||||
pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Document, DocbinError> {
|
||||
read_limited(bytes, password, MAX_PARAGRAPHS)
|
||||
}
|
||||
|
||||
/// [`read_with_password`] with the paragraph cap as a parameter, so tests need not build a
|
||||
/// file of [`MAX_PARAGRAPHS`] marks.
|
||||
fn read_limited(bytes: &[u8], password: Option<&str>, max_paragraphs: usize) -> Result<Document, DocbinError> {
|
||||
let mut comp = open_container(bytes)?;
|
||||
let mut word = stream(&mut comp, "WordDocument").ok_or_else(|| DocbinError::NotWord("no WordDocument stream".into()))?;
|
||||
if word.len() as u64 > MAX_STREAM {
|
||||
@@ -284,7 +270,6 @@ fn read_limited(bytes: &[u8], password: Option<&str>, max_paragraphs: usize) ->
|
||||
// tight against aliasing — and never past the absolute ceiling.
|
||||
let budget = std::cell::Cell::new((word.len() as u64).saturating_mul(2).min(MAX_TOTAL_CHARS));
|
||||
let work = Work::new(MAX_WORK);
|
||||
let paragraphs = ParaBudget { left: std::cell::Cell::new(max_paragraphs), exceeded: std::cell::Cell::new(false) };
|
||||
|
||||
let ctx = WalkCtx {
|
||||
word: &word,
|
||||
@@ -300,7 +285,6 @@ fn read_limited(bytes: &[u8], password: Option<&str>, max_paragraphs: usize) ->
|
||||
drawn: &[],
|
||||
sect_ends: &[],
|
||||
budget: &budget,
|
||||
paragraphs: ¶graphs,
|
||||
work: &work,
|
||||
};
|
||||
|
||||
@@ -320,7 +304,7 @@ fn read_limited(bytes: &[u8], password: Option<&str>, max_paragraphs: usize) ->
|
||||
if doc.parts.len() >= MAX_PARTS || tb_stories.contains_key(&spid) {
|
||||
continue;
|
||||
}
|
||||
let blocks = story_blocks(&ctx, a, b, &mut pics, max_paragraphs)?;
|
||||
let blocks = table::assemble(walk(&ctx, a, b, &mut pics));
|
||||
tb_stories.insert(spid, doc.add_part(PartKind::TextBox, blocks));
|
||||
}
|
||||
}
|
||||
@@ -345,7 +329,7 @@ fn read_limited(bytes: &[u8], password: Option<&str>, max_paragraphs: usize) ->
|
||||
0 | 1 | 4 => PartKind::Header,
|
||||
_ => PartKind::Footer,
|
||||
};
|
||||
let blocks = story_blocks(&ctx, a, b, &mut pics, max_paragraphs)?;
|
||||
let blocks = table::assemble(walk(&ctx, a, b, &mut pics));
|
||||
if !blocks.is_empty() {
|
||||
let id = doc.add_part(kind, blocks);
|
||||
hf_ids.push((si, k, id));
|
||||
@@ -358,7 +342,7 @@ fn read_limited(bytes: &[u8], password: Option<&str>, max_paragraphs: usize) ->
|
||||
let mut note_links: Vec<(u32, NoteKind, u32)> = Vec::new();
|
||||
for (cp, ab) in notes::parse_notes(&table, &fib, fib::pair::PLCF_FND_REF, fib::pair::PLCF_FND_TXT) {
|
||||
let Some((a, b)) = story(ftn_base, fib.ccp.ftn, ab) else { continue };
|
||||
let blocks = story_blocks(&ctx, a, b, &mut pics, max_paragraphs)?;
|
||||
let blocks = table::assemble(walk(&ctx, a, b, &mut pics));
|
||||
if !blocks.is_empty() && doc.parts.len() < MAX_PARTS {
|
||||
let id = doc.add_part(PartKind::Footnote, blocks);
|
||||
note_links.push((cp, NoteKind::Footnote, id));
|
||||
@@ -366,7 +350,7 @@ fn read_limited(bytes: &[u8], password: Option<&str>, max_paragraphs: usize) ->
|
||||
}
|
||||
for (cp, ab) in notes::parse_notes(&table, &fib, fib::pair::PLCF_END_REF, fib::pair::PLCF_END_TXT) {
|
||||
let Some((a, b)) = story(edn_base, fib.ccp.edn, ab) else { continue };
|
||||
let blocks = story_blocks(&ctx, a, b, &mut pics, max_paragraphs)?;
|
||||
let blocks = table::assemble(walk(&ctx, a, b, &mut pics));
|
||||
if !blocks.is_empty() && doc.parts.len() < MAX_PARTS {
|
||||
let id = doc.add_part(PartKind::Endnote, blocks);
|
||||
note_links.push((cp, NoteKind::Endnote, id));
|
||||
@@ -409,9 +393,6 @@ fn read_limited(bytes: &[u8], password: Option<&str>, max_paragraphs: usize) ->
|
||||
doc.last_section = l;
|
||||
}
|
||||
|
||||
if paragraphs.exceeded.get() {
|
||||
return Err(paragraph_limit(max_paragraphs));
|
||||
}
|
||||
doc.media.extend(pics.media);
|
||||
doc.body = table::assemble(paras);
|
||||
doc.ensure_nonempty();
|
||||
@@ -419,20 +400,6 @@ fn read_limited(bytes: &[u8], password: Option<&str>, max_paragraphs: usize) ->
|
||||
Ok(doc)
|
||||
}
|
||||
|
||||
fn paragraph_limit(max_paragraphs: usize) -> DocbinError {
|
||||
DocbinError::Limit(format!("the document has more than {max_paragraphs} paragraphs and table cells"))
|
||||
}
|
||||
|
||||
/// Walks one story into blocks. A walk that spent the shared paragraph budget is refused before
|
||||
/// its partial list is assembled, so a flood does not build blocks it will throw away.
|
||||
fn story_blocks(ctx: &WalkCtx<'_>, a: u32, b: u32, pics: &mut media::Pictures, max_paragraphs: usize) -> Result<wordcraft_doc::Blocks, DocbinError> {
|
||||
let paras = walk(ctx, a, b, pics);
|
||||
if ctx.paragraphs.exceeded.get() {
|
||||
return Err(paragraph_limit(max_paragraphs));
|
||||
}
|
||||
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)> {
|
||||
@@ -466,35 +433,10 @@ struct WalkCtx<'a> {
|
||||
/// footnotes, text boxes) are hostile and can each span a whole subdocument, so thousands of
|
||||
/// them could otherwise materialise the same text thousands of times; this bounds the total.
|
||||
budget: &'a std::cell::Cell<u64>,
|
||||
/// Paragraph and cell marks still allowed across every story walk ([`MAX_PARAGRAPHS`]).
|
||||
paragraphs: &'a ParaBudget,
|
||||
/// Repeated-work bytes left ([`MAX_WORK`]), and what they left out.
|
||||
work: &'a Work,
|
||||
}
|
||||
|
||||
/// The paragraph and cell marks still allowed in this document, and whether a walk ran into
|
||||
/// the end of them (the read then fails).
|
||||
struct ParaBudget {
|
||||
left: std::cell::Cell<usize>,
|
||||
exceeded: std::cell::Cell<bool>,
|
||||
}
|
||||
|
||||
impl ParaBudget {
|
||||
/// Spends one mark; false (and the overrun noted) when none are left.
|
||||
fn spend(&self) -> bool {
|
||||
match self.left.get().checked_sub(1) {
|
||||
Some(left) => {
|
||||
self.left.set(left);
|
||||
true
|
||||
}
|
||||
None => {
|
||||
self.exceeded.set(true);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// State while assembling one paragraph from per-CP formatting.
|
||||
#[derive(Default)]
|
||||
struct ParaBuild {
|
||||
@@ -585,9 +527,6 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic
|
||||
let section_mark = c == '\u{C}' && ctx.sect_ends.binary_search(&cp.saturating_add(1)).is_ok();
|
||||
match c {
|
||||
'\r' | '\u{7}' | '\u{C}' if c != '\u{C}' || section_mark => {
|
||||
if !ctx.paragraphs.spend() {
|
||||
break;
|
||||
}
|
||||
// Paragraph mark: its PAPX covers the FC range of the paragraph it ends, so
|
||||
// querying at the mark's own FC finds that paragraph's properties.
|
||||
let fc_mark = ctx.pieces.fc_of_cp(cp).unwrap_or(0);
|
||||
@@ -597,9 +536,6 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic
|
||||
}
|
||||
let mut row = table::decode(papx);
|
||||
row.cell_mark = c == '\u{7}';
|
||||
// Only table marks keep their table context; a plain paragraph would carry
|
||||
// a `RowInfo` (about 300 bytes) it never uses.
|
||||
let row = row.is_table().then(|| Box::new(row));
|
||||
let mut ilvl = 0u8;
|
||||
let mut ilfo = 0i32;
|
||||
for prl in sprm::iter(papx) {
|
||||
@@ -653,7 +589,7 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic
|
||||
&& f.depth == 1
|
||||
&& f.sep.is_none()
|
||||
{
|
||||
f.sep = Some(FieldResult::default());
|
||||
f.sep = Some(Vec::new());
|
||||
}
|
||||
cp = cp.saturating_add(1);
|
||||
continue;
|
||||
@@ -687,7 +623,6 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic
|
||||
alt,
|
||||
float: Default::default(),
|
||||
crop: p.crop,
|
||||
fx: Default::default(),
|
||||
ole: None,
|
||||
});
|
||||
}
|
||||
@@ -733,7 +668,7 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic
|
||||
out.push(ParaOut {
|
||||
para: Paragraph { text: done.text, runs: done.runs, props: done.props, mark: done.mark, objects: done.objects, ..Default::default() },
|
||||
end_cp: cp_end,
|
||||
row: None,
|
||||
row: table::RowInfo::default(),
|
||||
});
|
||||
}
|
||||
out
|
||||
@@ -744,28 +679,7 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic
|
||||
struct FieldBuild {
|
||||
depth: u32,
|
||||
instr: String,
|
||||
sep: Option<FieldResult>,
|
||||
}
|
||||
|
||||
/// A field's cached result: its text and, as runs, the formatting of that text. A run is added
|
||||
/// only when the formatting changes, so a result of millions of equally formatted characters
|
||||
/// costs one `CharProps`, not one per character (365 bytes each).
|
||||
#[derive(Default)]
|
||||
struct FieldResult {
|
||||
text: String,
|
||||
/// (byte length in `text`, formatting) in order; adjacent runs differ.
|
||||
runs: Vec<(usize, CharProps)>,
|
||||
}
|
||||
|
||||
impl FieldResult {
|
||||
fn push(&mut self, c: char, props: CharProps) {
|
||||
let len = c.len_utf8();
|
||||
match self.runs.last_mut() {
|
||||
Some((n, last)) if *last == props => *n += len,
|
||||
_ => self.runs.push((len, props)),
|
||||
}
|
||||
self.text.push(c);
|
||||
}
|
||||
sep: Option<Vec<(char, CharProps)>>,
|
||||
}
|
||||
|
||||
/// Bytes a clone of `obj` copies: the object itself plus a picture's alt text, a group with all
|
||||
@@ -790,26 +704,24 @@ fn finish_field(pb: &mut ParaBuild, f: FieldBuild, work: &Work) {
|
||||
let result = f.sep.unwrap_or_default();
|
||||
if let Some(target) = hyperlink_target(&instr) {
|
||||
let cost = target.len() as u64;
|
||||
let mut chars = result.text.chars();
|
||||
for (len, props) in result.runs {
|
||||
let mut linked = props;
|
||||
let mut run: Option<CharProps> = None;
|
||||
for (c, props) in result {
|
||||
if run.as_ref() == Some(&props) {
|
||||
pb.extend(c);
|
||||
continue;
|
||||
}
|
||||
let mut linked = props.clone();
|
||||
if work.charge(cost, Cost::LinkTarget) {
|
||||
linked.link = Some(target.clone());
|
||||
}
|
||||
let mut left = len;
|
||||
let Some(first) = chars.next() else { break };
|
||||
pb.push(first, linked);
|
||||
left = left.saturating_sub(first.len_utf8());
|
||||
while left > 0 {
|
||||
let Some(c) = chars.next() else { break };
|
||||
pb.extend(c);
|
||||
left = left.saturating_sub(c.len_utf8());
|
||||
}
|
||||
pb.push(c, linked);
|
||||
run = Some(props);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let result_text: String = result.iter().map(|(c, _)| *c).collect();
|
||||
pb.push('\u{FFFC}', CharProps::default());
|
||||
pb.objects.push(InlineObject::Field { instr, result: result.text, locked: false, dirty: false, code: false, form: String::new() });
|
||||
pb.objects.push(InlineObject::Field { instr, result: result_text, locked: false, dirty: false, code: false, form: String::new() });
|
||||
}
|
||||
|
||||
/// `sprmCPicLocation` from a CHPX grpprl: the fc of the picture in the Data stream.
|
||||
@@ -893,7 +805,7 @@ fn push_formatted<'a>(pb: &mut ParaBuild, cp: u32, c: char, ctx: &WalkCtx<'a>, f
|
||||
Some(f) => match f.sep.as_mut() {
|
||||
// Inside a field, characters are recorded for the field object instead of shown;
|
||||
// `finish_field` emits the visible text (hyperlink runs or one object anchor).
|
||||
Some(result) => result.push(c, props),
|
||||
Some(result) => result.push((c, props)),
|
||||
None => f.instr.push(c),
|
||||
},
|
||||
None => pb.push(c, props),
|
||||
|
||||
@@ -83,11 +83,6 @@ pub(crate) struct RowInfo {
|
||||
|
||||
/// Read the table context out of a paragraph mark's PAPX grpprl.
|
||||
impl RowInfo {
|
||||
/// Does the mark belong to a table: inside one, ending a row, or ending a cell?
|
||||
pub(crate) fn is_table(&self) -> bool {
|
||||
self.in_table || self.ttp || self.cell_mark
|
||||
}
|
||||
|
||||
/// The row height from `sprmTDyaRowHeight`, for tests.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn height(&self) -> Option<f32> {
|
||||
@@ -268,8 +263,8 @@ pub(crate) struct ParaOut {
|
||||
pub(crate) para: Paragraph,
|
||||
/// CP just past the terminating mark.
|
||||
pub(crate) end_cp: u32,
|
||||
/// Table context from the mark's PAPX; `None` for a paragraph outside a table.
|
||||
pub(crate) row: Option<Box<RowInfo>>,
|
||||
/// Table context from the mark's PAPX (empty default = not in a table).
|
||||
pub(crate) row: RowInfo,
|
||||
}
|
||||
|
||||
/// Group consecutive in-table paragraphs into [`Table`] blocks; the rest pass through.
|
||||
@@ -374,7 +369,9 @@ pub(crate) fn assemble(paras: Vec<ParaOut>) -> Blocks {
|
||||
}
|
||||
|
||||
for p in paras {
|
||||
let Some(info) = p.row else {
|
||||
let info = p.row;
|
||||
let is_table = info.in_table || info.ttp || info.cell_mark;
|
||||
if !is_table {
|
||||
if open {
|
||||
flush_table(&mut out, &mut rows, &mut cells, &mut cell, &grid, &props, depth_seen > 1);
|
||||
grid.clear();
|
||||
@@ -389,7 +386,7 @@ pub(crate) fn assemble(paras: Vec<ParaOut>) -> Blocks {
|
||||
}
|
||||
out.push(para_block(p.para));
|
||||
continue;
|
||||
};
|
||||
}
|
||||
if !open {
|
||||
open = true;
|
||||
}
|
||||
|
||||
+3
-136
@@ -116,12 +116,6 @@ fn piece_bytes(p: &Piece) -> Vec<u8> {
|
||||
|
||||
/// A document whose main text is the given pieces (extra subdocument text may follow).
|
||||
fn text_doc(main: &[Piece], extra: &[Piece], ccp_hdd: u32) -> Vec<u8> {
|
||||
text_doc_with(main, extra, FibSpec { ccp_hdd, ..Default::default() }, &[])
|
||||
}
|
||||
|
||||
/// [`text_doc`] with the FIB knobs given (`ccp_text` is filled in from `main`; `pairs` must be empty) and extra
|
||||
/// Table-stream data placed after the piece table: `(pair index, bytes)` for each.
|
||||
fn text_doc_with(main: &[Piece], extra: &[Piece], spec: FibSpec, table_data: &[(usize, &[u8])]) -> Vec<u8> {
|
||||
let all: Vec<Piece> = main.iter().chain(extra.iter()).cloned().collect();
|
||||
let ccp_text: u32 = main.iter().map(|p| p.text.chars().count() as u32).sum();
|
||||
let mut word = vec![0u8; TEXT_FC as usize];
|
||||
@@ -134,13 +128,7 @@ fn text_doc_with(main: &[Piece], extra: &[Piece], spec: FibSpec, table_data: &[(
|
||||
clx.extend_from_slice(&plc);
|
||||
let mut table = vec![0u8; CLX_AT as usize];
|
||||
table.extend_from_slice(&clx);
|
||||
let mut pairs = vec![(33, CLX_AT, clx.len() as u32)];
|
||||
for (pair, bytes) in table_data {
|
||||
pairs.push((*pair, table.len() as u32, bytes.len() as u32));
|
||||
table.extend_from_slice(bytes);
|
||||
}
|
||||
debug_assert!(spec.pairs.is_empty(), "text_doc_with sets the pairs itself");
|
||||
let spec = FibSpec { ccp_text, pairs, ..spec };
|
||||
let spec = FibSpec { ccp_text, ccp_hdd, pairs: vec![(33, CLX_AT, clx.len() as u32)], ..Default::default() };
|
||||
word[..fib_bytes(&spec).len()].copy_from_slice(&fib_bytes(&spec));
|
||||
cfb_file(&[("WordDocument", &word), ("0Table", &table)])
|
||||
}
|
||||
@@ -259,77 +247,6 @@ fn empty_paragraphs_kept() {
|
||||
assert_eq!(body_texts(&doc), ["a", "", "b"]);
|
||||
}
|
||||
|
||||
/// A document of `n` copies of `mark` as one compressed piece.
|
||||
fn marks_doc(mark: char, n: usize) -> Vec<u8> {
|
||||
text_doc(&[Piece { compressed: true, text: mark.to_string().repeat(n) }], &[], 0)
|
||||
}
|
||||
|
||||
/// Paragraph marks are one byte each in the file but about 1 KB each in memory, so they have
|
||||
/// their own cap ([`super::MAX_PARAGRAPHS`]); a file past it is refused instead of growing
|
||||
/// until the machine runs out. A document exactly at the cap still opens.
|
||||
#[test]
|
||||
fn a_flood_of_paragraph_marks_is_refused() {
|
||||
let at_cap = super::read_limited(&marks_doc('\r', 1000), None, 1000).expect("opens");
|
||||
assert_eq!(at_cap.body.len(), 1000);
|
||||
let over = super::read_limited(&marks_doc('\r', 1001), None, 1000);
|
||||
assert!(matches!(over, Err(crate::DocbinError::Limit(_))), "{over:?}");
|
||||
}
|
||||
|
||||
/// The paragraph cap is one budget for the whole document, not one per story: marks in the
|
||||
/// body and in a footnote add up. (A walk that got its own budget would open both over-cap files.)
|
||||
#[test]
|
||||
fn the_paragraph_cap_is_shared_by_the_body_and_a_footnote() {
|
||||
// One footnote at CP 0 whose text is `note` marks, stored right after the body in the stream.
|
||||
let doc_of = |body: usize, note: usize| {
|
||||
let cps = |v: &[u32]| v.iter().flat_map(|c| c.to_le_bytes()).collect::<Vec<u8>>();
|
||||
let mut refs = cps(&[0, 1]);
|
||||
refs.extend_from_slice(&[0, 0]); // the one reference's data
|
||||
let txt = cps(&[0, note as u32, note as u32]); // story 0..note, then the limit
|
||||
let spec = FibSpec { ccp_ftn: note as u32, ..Default::default() };
|
||||
text_doc_with(
|
||||
&[Piece { compressed: true, text: "\r".repeat(body) }],
|
||||
&[Piece { compressed: true, text: "\r".repeat(note) }],
|
||||
spec,
|
||||
&[(2, &refs), (3, &txt)],
|
||||
)
|
||||
};
|
||||
let at_cap = super::read_limited(&doc_of(600, 400), None, 1000).expect("opens");
|
||||
assert_eq!(at_cap.body.len(), 600);
|
||||
assert_eq!(at_cap.parts.len(), 1);
|
||||
let over = super::read_limited(&doc_of(600, 401), None, 1000);
|
||||
assert!(matches!(over, Err(crate::DocbinError::Limit(_))), "{over:?}");
|
||||
let over = super::read_limited(&doc_of(601, 400), None, 1000);
|
||||
assert!(matches!(over, Err(crate::DocbinError::Limit(_))), "{over:?}");
|
||||
}
|
||||
|
||||
/// Cell marks are paragraph marks too: a never-closed row of them is refused the same way.
|
||||
#[test]
|
||||
fn a_flood_of_cell_marks_is_refused() {
|
||||
let over = super::read_limited(&marks_doc('\u{7}', 5000), None, 1000);
|
||||
assert!(matches!(over, Err(crate::DocbinError::Limit(_))), "{over:?}");
|
||||
}
|
||||
|
||||
/// The budget counts down across spends and notes the overrun (`walk` receives one shared by
|
||||
/// every story of the document).
|
||||
#[test]
|
||||
fn the_paragraph_budget_counts_down_and_notes_the_overrun() {
|
||||
let budget = super::ParaBudget { left: std::cell::Cell::new(2), exceeded: std::cell::Cell::new(false) };
|
||||
assert!(budget.spend() && budget.spend());
|
||||
assert!(!budget.exceeded.get());
|
||||
assert!(!budget.spend());
|
||||
assert!(budget.exceeded.get());
|
||||
}
|
||||
|
||||
/// The cap is far above any real document (10,000 pages of 50 paragraphs is 500,000), and a
|
||||
/// paragraph outside a table no longer carries a table context (a `ParaOut` was 1,224 bytes).
|
||||
#[test]
|
||||
fn the_paragraph_cap_leaves_room_and_a_paragraph_stays_small() {
|
||||
const { assert!(super::MAX_PARAGRAPHS >= 1_000_000) };
|
||||
assert!(std::mem::size_of::<crate::table::ParaOut>() < 1000, "{}", std::mem::size_of::<crate::table::ParaOut>());
|
||||
let doc = read(&marks_doc('\r', 10)).expect("opens");
|
||||
assert_eq!(doc.body.len(), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_document_gets_a_paragraph() {
|
||||
let f = text_doc(&[Piece { compressed: false, text: "".into() }], &[], 0);
|
||||
@@ -2743,8 +2660,7 @@ fn a_long_hyperlink_over_a_long_result_is_one_linked_run() {
|
||||
assert_eq!(linked.first().map(|r| r.len), Some(n));
|
||||
// One copy of the target for the one run, not one per character (~2.5 s here): counted by
|
||||
// what the copies charge to the work budget, which a busy machine can't skew.
|
||||
let mut result = super::FieldResult::default();
|
||||
(0..n).for_each(|_| result.push('y', wordcraft_doc::props::CharProps::default()));
|
||||
let result = vec![('y', wordcraft_doc::props::CharProps::default()); n];
|
||||
let f = super::FieldBuild { depth: 1, instr: format!("HYPERLINK \"http://{target}\""), sep: Some(result) };
|
||||
let work = super::Work::new(u64::MAX);
|
||||
super::finish_field(&mut super::ParaBuild::default(), f, &work);
|
||||
@@ -2757,8 +2673,7 @@ fn a_long_hyperlink_over_a_long_result_is_one_linked_run() {
|
||||
fn hyperlink_target_copies_are_charged_to_the_work_budget() {
|
||||
use wordcraft_doc::props::CharProps;
|
||||
let bold = CharProps { bold: Some(true), ..Default::default() };
|
||||
let mut result = super::FieldResult::default();
|
||||
(0..1000).for_each(|i| result.push('a', if i % 2 == 0 { bold.clone() } else { CharProps::default() }));
|
||||
let result: Vec<(char, CharProps)> = (0..1000).map(|i| ('a', if i % 2 == 0 { bold.clone() } else { CharProps::default() })).collect();
|
||||
let f = super::FieldBuild { depth: 1, instr: format!("HYPERLINK \"{}\"", "u".repeat(100)), sep: Some(result) };
|
||||
let mut pb = super::ParaBuild::default();
|
||||
let budget = super::Work::new(10_000);
|
||||
@@ -2832,51 +2747,3 @@ fn prm_grpprls_past_the_work_budget_are_skipped() {
|
||||
assert_eq!(work.left.get(), 1);
|
||||
assert_eq!(work.dropped(), [super::Cost::Formatting]);
|
||||
}
|
||||
|
||||
/// A field's cached result keeps one `CharProps` per formatting run, not one per character
|
||||
/// (365 bytes each: 4 million result characters took 1.4 GB).
|
||||
#[test]
|
||||
fn a_field_result_stores_one_props_per_run() {
|
||||
use wordcraft_doc::props::CharProps;
|
||||
let bold = CharProps { bold: Some(true), ..Default::default() };
|
||||
let mut r = super::FieldResult::default();
|
||||
for _ in 0..200_000 {
|
||||
r.push('x', CharProps::default());
|
||||
}
|
||||
assert_eq!(r.runs.len(), 1);
|
||||
assert_eq!(r.runs.first().map(|(n, _)| *n), Some(200_000));
|
||||
r.push('\u{e9}', bold.clone());
|
||||
r.push('y', bold);
|
||||
r.push('z', CharProps::default());
|
||||
assert_eq!(r.runs.iter().map(|(n, _)| *n).collect::<Vec<_>>(), [200_000, 3, 1], "a two-byte char counts its bytes");
|
||||
assert_eq!(r.text.len(), 200_004);
|
||||
}
|
||||
|
||||
/// A hyperlink whose result changes formatting, with multi-byte text, keeps every run's text and
|
||||
/// links all of it; a plain field keeps its result text whole.
|
||||
#[test]
|
||||
fn field_results_keep_their_text_and_runs() {
|
||||
use wordcraft_doc::props::CharProps;
|
||||
let bold = CharProps { bold: Some(true), ..Default::default() };
|
||||
let mut result = super::FieldResult::default();
|
||||
for (c, p) in
|
||||
[('a', CharProps::default()), ('\u{e9}', CharProps::default()), ('\u{20ac}', bold.clone()), ('b', bold), ('c', CharProps::default())]
|
||||
{
|
||||
result.push(c, p);
|
||||
}
|
||||
let f = super::FieldBuild { depth: 1, instr: "HYPERLINK \"http://x\"".into(), sep: Some(result) };
|
||||
let mut pb = super::ParaBuild::default();
|
||||
super::finish_field(&mut pb, f, &super::Work::new(u64::MAX));
|
||||
assert_eq!(pb.text, "a\u{e9}\u{20ac}bc");
|
||||
assert_eq!(pb.runs.iter().map(|r| r.len).collect::<Vec<_>>(), [3, 4, 1]);
|
||||
assert!(pb.runs.iter().all(|r| r.props.link.as_deref() == Some("http://x")));
|
||||
let doc = read(&text_doc(&[Piece { compressed: false, text: "\u{13} PAGE \u{14}1\u{e9}2\u{15}\r".into() }], &[], 0)).expect("opens");
|
||||
let p = doc.body.first().and_then(|b| b.as_para()).expect("paragraph");
|
||||
assert!(
|
||||
p.objects
|
||||
.iter()
|
||||
.any(|o| matches!(o, wordcraft_doc::para::InlineObject::Field { instr, result, .. } if instr == "PAGE" && result == "1\u{e9}2")),
|
||||
"{:?}",
|
||||
p.objects
|
||||
);
|
||||
}
|
||||
|
||||
@@ -391,7 +391,6 @@ fn a_floating_copy_is_charged_its_alt_text() {
|
||||
alt,
|
||||
float: Default::default(),
|
||||
crop: Default::default(),
|
||||
fx: Default::default(),
|
||||
ole: None,
|
||||
};
|
||||
let bare = crate::object_cost(&image(String::new()));
|
||||
|
||||
@@ -32,7 +32,6 @@ getrandom = { workspace = true, features = ["wasm_js"] }
|
||||
|
||||
[dev-dependencies]
|
||||
wordcraft-doc = { workspace = true, features = ["test-support"] }
|
||||
serde_json = { workspace = true }
|
||||
proptest = { workspace = true }
|
||||
image = { workspace = true }
|
||||
zip = { workspace = true }
|
||||
|
||||
@@ -5,8 +5,8 @@ use std::collections::HashMap;
|
||||
use wordcraft_doc::para::cap_alt;
|
||||
use wordcraft_doc::props::{
|
||||
Align, Border, BorderStyle, Borders, CellProps, CharProps, CombineBrackets, EastAsianLayout, HeightRule, Highlight, LineSpacing, NumRef,
|
||||
ParaProps, Rgb, RowProps, TabAlign, TabLeader, TabStop, TableFloat, TableLook, TableProps, TextAlignment, TextColor, TextDirection, Underline,
|
||||
VAlign, VMerge, VertAlign,
|
||||
ParaProps, Rgb, RowProps, TabAlign, TabLeader, TabStop, TableFloat, TableLook, TableProps, 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,
|
||||
@@ -33,7 +33,6 @@ fn text_frame(k: &El) -> wordcraft_doc::props::TextFrame {
|
||||
h_anchor: word("w:hAnchor", &["text", "margin", "page"], "page"),
|
||||
v_anchor: word("w:vAnchor", &["text", "margin", "page"], "page"),
|
||||
wrap: word("w:wrap", &["around", "auto", "notBeside", "none", "tight", "through"], "auto"),
|
||||
anchor_lock: k.attr("w:anchorLock").map(|v| !matches!(v.trim(), "0" | "false" | "off" | "False" | "FALSE")),
|
||||
h_space: len("w:hSpace").unwrap_or(0.0).max(0.0),
|
||||
v_space: len("w:vSpace").unwrap_or(0.0).max(0.0),
|
||||
}
|
||||
@@ -334,7 +333,6 @@ impl PropCtx {
|
||||
"w:widowControl" => p.widow_control = Some(on_off(k)),
|
||||
"w:contextualSpacing" => p.contextual_spacing = Some(on_off(k)),
|
||||
"w:mirrorIndents" => p.mirror_indents = Some(on_off(k)),
|
||||
"w:adjustRightInd" => p.adjust_right_indent = Some(on_off(k)),
|
||||
"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)),
|
||||
@@ -345,7 +343,6 @@ impl PropCtx {
|
||||
"w:autoSpaceDE" => p.auto_space_de = Some(on_off(k)),
|
||||
"w:autoSpaceDN" => p.auto_space_dn = Some(on_off(k)),
|
||||
"w:snapToGrid" => p.snap_to_grid = Some(on_off(k)),
|
||||
"w:textAlignment" => p.text_alignment = k.attr("w:val").and_then(TextAlignment::from_ooxml),
|
||||
"w:framePr" => {
|
||||
if let Some(kind @ ("drop" | "margin")) = k.attr("w:dropCap") {
|
||||
p.drop_cap = Some(k.attr("w:lines").and_then(u32_of).unwrap_or(3).clamp(1, 10) as u8);
|
||||
@@ -765,26 +762,8 @@ pub fn sectpr(pc: &PropCtx, e: &El) -> (SectionProps, Vec<HfRef>) {
|
||||
if let Some(f) = k.attr("w:fmt") {
|
||||
s.page_num_format = NumFormat::from_ooxml(f);
|
||||
}
|
||||
s.chapter_style = k.attr("w:chapStyle").and_then(u32_of).filter(|v| *v <= u8::MAX as u32).map(|v| v as u8);
|
||||
s.chapter_sep = k.attr("w:chapSep").and_then(wordcraft_doc::section::ChapterSep::from_ooxml);
|
||||
s.page_num_set = s.page_num_start.is_none() && s.page_num_format == NumFormat::Decimal;
|
||||
}
|
||||
"w:paperSrc" => {
|
||||
let tray = |name| {
|
||||
let value = k.attr(name)?.trim();
|
||||
let digits = value.strip_prefix('+').unwrap_or(value);
|
||||
if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
u32_of(digits).filter(|v| *v <= u16::MAX as u32).map(|v| v as u16)
|
||||
};
|
||||
s.paper_src_first = tray("w:first");
|
||||
s.paper_src_other = tray("w:other");
|
||||
}
|
||||
"w:noEndnote" => s.no_endnote = Some(on_off(k)),
|
||||
"w15:footnoteColumns" => {
|
||||
s.footnote_columns = k.attr("w:val").and_then(u32_of).filter(|v| (1..=45).contains(v));
|
||||
}
|
||||
"w:lnNumType" => {
|
||||
let mut l = LineNumbering::default();
|
||||
if let Some(v) = k.attr("w:countBy").and_then(u32_of) {
|
||||
|
||||
@@ -31,7 +31,7 @@ impl Reader<'_> {
|
||||
let cap_font = first.runs.first().and_then(|r| r.props.font.clone());
|
||||
let text_font = p.runs.first().and_then(|r| r.props.font.clone());
|
||||
if cap_font.is_some() && cap_font != text_font {
|
||||
for r in first.runs.iter_mut().filter(|r| r.props.font == cap_font) {
|
||||
for r in &mut first.runs {
|
||||
r.props.font = text_font.clone();
|
||||
}
|
||||
props.drop_cap_font = cap_font;
|
||||
|
||||
@@ -263,7 +263,7 @@ impl Reader<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(InlineObject::Image { media, w, h, alt, float, crop, fx: crate::units::blip_fx(blip), ole: None })
|
||||
Some(InlineObject::Image { media, w, h, alt, float, crop, ole: None })
|
||||
}
|
||||
|
||||
/// A `wpg:wgp` group (`w` × `h`): its pictures, shapes and text boxes, nested groups
|
||||
|
||||
@@ -134,10 +134,7 @@ impl Reader<'_> {
|
||||
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).
|
||||
let frac = |k: &str| img.attr(k).and_then(crate::units::vml_number);
|
||||
let fx = wordcraft_doc::picfx::PicFx::from_vml(frac("gain").unwrap_or(1.0), frac("blacklevel").unwrap_or(0.0));
|
||||
return Some(InlineObject::Image { media, w, h, alt, float, crop: [0.0; 4], fx, ole: None });
|
||||
return Some(InlineObject::Image { media, w, h, alt, float, crop: [0.0; 4], ole: None });
|
||||
}
|
||||
// The shapetype the shape is drawn from (its `type`, ECMA-376 Part 4 VML), when written in
|
||||
// the same picture: what the shape leaves out, it takes from there.
|
||||
|
||||
@@ -86,8 +86,6 @@ enum Paint {
|
||||
None,
|
||||
/// Theme accent `n` (1–6), lightened to `tint` (thousandths of a percent kept) when set.
|
||||
Accent(usize, Option<u32>),
|
||||
/// A colour of its own, lightened to `tint` when set.
|
||||
Rgb(Rgb, Option<u32>),
|
||||
/// The theme's light background colour.
|
||||
Light,
|
||||
}
|
||||
@@ -152,22 +150,6 @@ impl Sh {
|
||||
}
|
||||
}
|
||||
|
||||
/// Accent fills and outlines in item `i`'s own colour, when it has one. Light outlines
|
||||
/// keep the theme's background colour so adjacent nodes remain separated.
|
||||
fn recolor(&mut self, spec: &SmartArtSpec, i: usize) {
|
||||
let Some(c) = spec.items.get(i).and_then(|it| it.color) else { return };
|
||||
for p in [&mut self.fill, &mut self.line] {
|
||||
if let Paint::Accent(_, tint) = *p {
|
||||
*p = Paint::Rgb(c, tint);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn item(mut self, spec: &SmartArtSpec, i: usize) -> Sh {
|
||||
self.recolor(spec, i);
|
||||
self
|
||||
}
|
||||
|
||||
fn with_text(mut self, text: Vec<(String, bool)>) -> Sh {
|
||||
self.text = text;
|
||||
self
|
||||
@@ -244,7 +226,7 @@ fn basic_list(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
|
||||
let in_row = if r + 1 == rows { n - r * cols } else { cols };
|
||||
let x0 = (w - (in_row as f32 * bw + (in_row - 1) as f32 * gx)) / 2.0;
|
||||
let b = [x0 + c as f32 * (bw + gx), y0 + r as f32 * (bh + gy), bw, bh];
|
||||
out.push(Sh::node("rect", b, spec.colors.accent(k)).item(spec, i).with_text(paras(spec, i, true)));
|
||||
out.push(Sh::node("rect", b, spec.colors.accent(k)).with_text(paras(spec, i, true)));
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -265,14 +247,11 @@ fn process(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
|
||||
let mut a = Sh::node("rightArrow", [gx + (gap - aw) / 2.0, h / 2.0 - ah / 2.0, aw, ah], spec.colors.accent(k - 1));
|
||||
a.fill = Paint::Accent(spec.colors.accent(k - 1), Some(60_000));
|
||||
a.line = Paint::None;
|
||||
if let Some(&i) = tops.get(k - 1) {
|
||||
a.recolor(spec, i);
|
||||
}
|
||||
out.push(a);
|
||||
}
|
||||
for (k, &i) in tops.iter().enumerate() {
|
||||
let b = [k as f32 * (nw + gap), y, nw, nh];
|
||||
out.push(Sh::node("roundRect", b, spec.colors.accent(k)).item(spec, i).with_text(paras(spec, i, true)));
|
||||
out.push(Sh::node("roundRect", b, spec.colors.accent(k)).with_text(paras(spec, i, true)));
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -315,9 +294,6 @@ fn cycle(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
|
||||
a.rot = (mid + 90.0).rem_euclid(360.0);
|
||||
a.fill = Paint::Accent(spec.colors.accent(k), Some(60_000));
|
||||
a.line = Paint::None;
|
||||
if let Some(&i) = tops.get(k) {
|
||||
a.recolor(spec, i);
|
||||
}
|
||||
out.push(a);
|
||||
}
|
||||
}
|
||||
@@ -325,7 +301,7 @@ fn cycle(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
|
||||
for (k, &i) in tops.iter().enumerate() {
|
||||
let (x, y) = if n == 1 { (cx, cy) } else { polar(cx, cy, r, angle(k, n)) };
|
||||
let b = [x - d / 2.0, y - d / 2.0, d, d];
|
||||
out.push(Sh::node("ellipse", b, spec.colors.accent(k)).item(spec, i).with_text(paras(spec, i, true)).text_box(inset(b, ROUND_INSET)));
|
||||
out.push(Sh::node("ellipse", b, spec.colors.accent(k)).with_text(paras(spec, i, true)).text_box(inset(b, ROUND_INSET)));
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -384,19 +360,19 @@ fn hierarchy(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
|
||||
let (px, pb) = (cx(i), top(i) + nh);
|
||||
let ct = ks.first().map_or(pb, |&k| top(k));
|
||||
let mid = (pb + ct) / 2.0;
|
||||
out.push(Sh::line(px, pb, px, mid, accent).item(spec, i));
|
||||
out.push(Sh::line(px, pb, px, mid, accent));
|
||||
let xs: Vec<f32> = ks.iter().map(|&k| cx(k)).collect();
|
||||
let (lo, hi) = xs.iter().fold((px, px), |(lo, hi), &x| (lo.min(x), hi.max(x)));
|
||||
if hi - lo > 0.5 {
|
||||
out.push(Sh::line(lo, mid, hi, mid, accent).item(spec, i));
|
||||
out.push(Sh::line(lo, mid, hi, mid, accent));
|
||||
}
|
||||
for x in xs {
|
||||
out.push(Sh::line(x, mid, x, ct, accent).item(spec, i));
|
||||
out.push(Sh::line(x, mid, x, ct, accent));
|
||||
}
|
||||
}
|
||||
for i in 0..n {
|
||||
let b = [cx(i) - nw / 2.0, top(i), nw, nh];
|
||||
out.push(Sh::node("roundRect", b, spec.colors.accent(i)).item(spec, i).with_text(paras(spec, i, false)));
|
||||
out.push(Sh::node("roundRect", b, spec.colors.accent(i)).with_text(paras(spec, i, false)));
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -424,7 +400,7 @@ fn pyramid(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
|
||||
let mid_w = (top_w + bot_w) / 2.0 * 0.8;
|
||||
s.text_box([w / 2.0 - mid_w / 2.0, b[1] + bh * 0.08, mid_w, bh * 0.84])
|
||||
};
|
||||
out.push(sh.item(spec, i).with_text(paras(spec, i, true)));
|
||||
out.push(sh.with_text(paras(spec, i, true)));
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -442,7 +418,7 @@ fn radial(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
|
||||
if n == 0 {
|
||||
let d = m * 0.5;
|
||||
let b = [cx - d / 2.0, cy - d / 2.0, d, d];
|
||||
out.push(Sh::node("ellipse", b, spec.colors.accent(0)).item(spec, 0).with_text(paras(spec, 0, false)).text_box(inset(b, ROUND_INSET)));
|
||||
out.push(Sh::node("ellipse", b, spec.colors.accent(0)).with_text(paras(spec, 0, false)).text_box(inset(b, ROUND_INSET)));
|
||||
return out;
|
||||
}
|
||||
let step = if n == 1 { 1.0 } else { (std::f32::consts::PI / n as f32).sin() };
|
||||
@@ -456,14 +432,14 @@ fn radial(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
|
||||
let at = |k: usize| polar(cx, cy, r, angle(k, n));
|
||||
for k in 0..n {
|
||||
let (x, y) = at(k);
|
||||
out.push(Sh::line(cx, cy, x, y, spec.colors.accent(0)).item(spec, 0));
|
||||
out.push(Sh::line(cx, cy, x, y, spec.colors.accent(0)));
|
||||
}
|
||||
let bc = [cx - dc / 2.0, cy - dc / 2.0, dc, dc];
|
||||
out.push(Sh::node("ellipse", bc, spec.colors.accent(0)).item(spec, 0).with_text(paras(spec, 0, false)).text_box(inset(bc, ROUND_INSET)));
|
||||
out.push(Sh::node("ellipse", bc, spec.colors.accent(0)).with_text(paras(spec, 0, false)).text_box(inset(bc, ROUND_INSET)));
|
||||
for (k, &i) in spokes.iter().enumerate() {
|
||||
let (x, y) = at(k);
|
||||
let b = [x - ds / 2.0, y - ds / 2.0, ds, ds];
|
||||
out.push(Sh::node("ellipse", b, spec.colors.accent(k + 1)).item(spec, i).with_text(paras(spec, i, true)).text_box(inset(b, ROUND_INSET)));
|
||||
out.push(Sh::node("ellipse", b, spec.colors.accent(k + 1)).with_text(paras(spec, i, true)).text_box(inset(b, ROUND_INSET)));
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -481,7 +457,7 @@ fn matrix(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
|
||||
for (k, &i) in tops.iter().enumerate() {
|
||||
let (r, c) = (k / cols, k % cols);
|
||||
let b = [x0 + c as f32 * cell + gap / 2.0, y0 + r as f32 * cell + gap / 2.0, cell - gap, cell - gap];
|
||||
out.push(Sh::node("roundRect", b, spec.colors.accent(k)).item(spec, i).with_text(paras(spec, i, true)).text_box(inset(b, 0.1)));
|
||||
out.push(Sh::node("roundRect", b, spec.colors.accent(k)).with_text(paras(spec, i, true)).text_box(inset(b, 0.1)));
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -502,22 +478,22 @@ fn venn(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
|
||||
(x, y, deg)
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for (j, &i) in tops.iter().enumerate() {
|
||||
for (j, _) in tops.iter().enumerate() {
|
||||
let (x, y, _) = place(j);
|
||||
let mut s = Sh::node("ellipse", [x - r, y - r, 2.0 * r, 2.0 * r], spec.colors.accent(j));
|
||||
s.fill = Paint::Accent(spec.colors.accent(j), Some(40_000));
|
||||
s.alpha = Some(60_000);
|
||||
s.line = Paint::None;
|
||||
out.push(s.item(spec, i));
|
||||
out.push(s);
|
||||
}
|
||||
// Every outline over every fill, so each circle shows whole.
|
||||
for (j, &i) in tops.iter().enumerate() {
|
||||
for (j, _) in tops.iter().enumerate() {
|
||||
let (x, y, _) = place(j);
|
||||
let mut s = Sh::node("ellipse", [x - r, y - r, 2.0 * r, 2.0 * r], spec.colors.accent(j));
|
||||
s.fill = Paint::None;
|
||||
s.line = Paint::Accent(spec.colors.accent(j), None);
|
||||
s.line_w = 1.5;
|
||||
out.push(s.item(spec, i));
|
||||
out.push(s);
|
||||
}
|
||||
for (j, &i) in tops.iter().enumerate() {
|
||||
let (x, y, deg) = place(j);
|
||||
@@ -578,23 +554,6 @@ fn paint(w: &mut W, p: Paint, alpha: Option<u32>) {
|
||||
w.empty("a:schemeClr", &[("val", "lt1")]);
|
||||
w.close("a:solidFill");
|
||||
}
|
||||
Paint::Rgb(c, tint) => {
|
||||
w.open("a:solidFill", &[]);
|
||||
let val = c.hex();
|
||||
if tint.is_none() && alpha.is_none() {
|
||||
w.empty("a:srgbClr", &[("val", &val)]);
|
||||
} else {
|
||||
w.open("a:srgbClr", &[("val", &val)]);
|
||||
if let Some(t) = tint {
|
||||
w.empty("a:tint", &[("val", &t.to_string())]);
|
||||
}
|
||||
if let Some(a) = alpha {
|
||||
w.empty("a:alpha", &[("val", &a.to_string())]);
|
||||
}
|
||||
w.close("a:srgbClr");
|
||||
}
|
||||
w.close("a:solidFill");
|
||||
}
|
||||
Paint::Accent(n, tint) => {
|
||||
w.open("a:solidFill", &[]);
|
||||
let val = format!("accent{}", n.clamp(1, 6));
|
||||
@@ -784,16 +743,7 @@ pub fn data_xml(spec: &SmartArtSpec, drawing_rel: &str) -> String {
|
||||
let (node, cxn, par, sib) = item_ids(i);
|
||||
w.open("dgm:pt", &[("modelId", &node)]);
|
||||
w.empty("dgm:prSet", &[]);
|
||||
match it.color {
|
||||
Some(c) => {
|
||||
w.open("dgm:spPr", &[]);
|
||||
w.open("a:solidFill", &[]);
|
||||
w.empty("a:srgbClr", &[("val", &c.hex())]);
|
||||
w.close("a:solidFill");
|
||||
w.close("dgm:spPr");
|
||||
}
|
||||
None => w.empty("dgm:spPr", &[]),
|
||||
}
|
||||
w.empty("dgm:spPr", &[]);
|
||||
w.open("dgm:t", &[]);
|
||||
w.empty("a:bodyPr", &[]);
|
||||
w.empty("a:lstStyle", &[]);
|
||||
@@ -1112,9 +1062,7 @@ pub(crate) fn spec_of(data: &El) -> Option<SmartArtSpec> {
|
||||
.zip(&levels)
|
||||
.map(|(p, l)| {
|
||||
let text: String = p.child("dgm:t")?.child("a:p")?.children("a:r").filter_map(|r| r.child("a:t")).map(|t| t.text()).collect();
|
||||
let color =
|
||||
p.child("dgm:spPr").and_then(|s| s.child("a:solidFill")).and_then(|f| f.child("a:srgbClr")).and_then(|c| Rgb::parse(c.attr("val")?));
|
||||
Some(SmartArtItem { text, level: (*l)?, color })
|
||||
Some(SmartArtItem { text, level: (*l)? })
|
||||
})
|
||||
.collect::<Option<Vec<_>>>()?;
|
||||
let spec = SmartArtSpec { layout, items, colors }.sanitized();
|
||||
@@ -1190,18 +1138,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_items_own_colour_is_saved_drawn_and_read_back() {
|
||||
let mut spec = SmartArtSpec::sample(SmartArtLayout::Process);
|
||||
spec.items[1].color = Some(Rgb(0x12, 0x34, 0x56));
|
||||
let spec = spec.sanitized();
|
||||
assert_eq!(spec_of(&parse(&data_xml(&spec, "rId9"))).as_ref(), Some(&spec));
|
||||
let drawing = drawing_xml(&spec, 400.0, 250.0);
|
||||
assert_eq!(drawing.matches("<a:srgbClr val=\"123456\"").count(), 2, "the step and the arrow after it");
|
||||
let plain = drawing_xml(&SmartArtSpec::sample(SmartArtLayout::Process), 400.0, 250.0);
|
||||
assert!(!plain.contains("srgbClr"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_diagram_with_more_than_the_model_stays_read_only() {
|
||||
let spec = SmartArtSpec::sample(SmartArtLayout::Process);
|
||||
|
||||
@@ -84,29 +84,6 @@ pub fn int(s: &str) -> Option<i64> {
|
||||
v.is_finite().then(|| v.round().clamp(i64::MIN as f64, i64::MAX as f64) as i64)
|
||||
}
|
||||
|
||||
/// A VML number (`gain`, `blacklevel`), unclamped: 16.16 fixed point with an `f` suffix
|
||||
/// (`19661f` is 0.3) or a plain decimal (`0.3`). None for anything else or non-finite.
|
||||
pub fn vml_number(s: &str) -> Option<f64> {
|
||||
let t = s.trim();
|
||||
if t.is_empty() || t.len() > 40 {
|
||||
return None;
|
||||
}
|
||||
let v = match t.strip_suffix('f') {
|
||||
Some(fixed) => fixed.trim().parse::<f64>().ok()? / 65_536.0,
|
||||
None => t.parse::<f64>().ok()?,
|
||||
};
|
||||
v.is_finite().then_some(v)
|
||||
}
|
||||
|
||||
/// A picture's brightness, contrast and transparency: `a:lum` (ECMA-376 §20.1.8.42) and
|
||||
/// `a:alphaModFix` (§20.1.8.4) in its `a:blip` (#1766).
|
||||
pub fn blip_fx(blip: &El) -> wordcraft_doc::picfx::PicFx {
|
||||
let val = |el: Option<&El>, k: &str| el.and_then(|e| e.attr(k)).and_then(int).map(|v| v.clamp(-1_000_000, 1_000_000) as i32);
|
||||
let lum = blip.child("a:lum");
|
||||
let amt = val(blip.child("a:alphaModFix"), "amt").unwrap_or(100_000).clamp(0, 100_000);
|
||||
wordcraft_doc::picfx::PicFx::new(val(lum, "bright").unwrap_or(0), val(lum, "contrast").unwrap_or(0), 100_000 - amt)
|
||||
}
|
||||
|
||||
pub fn u32_of(s: &str) -> Option<u32> {
|
||||
int(s).map(|v| v.clamp(0, u32::MAX as i64) as u32)
|
||||
}
|
||||
|
||||
@@ -317,8 +317,7 @@ impl<'a> Conv<'a> {
|
||||
|
||||
/// A DrawingML picture as a `w:pict` (inline, at its extent); any other drawing is dropped.
|
||||
fn picture(&mut self, drawing: &El, rels: &Rels) -> Vec<Node> {
|
||||
let Some(blip) = drawing.find("a:blip") else { return Vec::new() };
|
||||
let Some(id) = blip.attr("r:embed") else { return Vec::new() };
|
||||
let Some(id) = drawing.find("a:blip").and_then(|b| b.attr("r:embed")) else { return Vec::new() };
|
||||
let Some(target) = rels.by_id(id).filter(|r| !r.external).map(|r| r.target.clone()) else { return Vec::new() };
|
||||
let Some(src) = self.bin_for(&target) else { return Vec::new() };
|
||||
let pt = |k: &str| {
|
||||
@@ -330,19 +329,7 @@ impl<'a> Conv<'a> {
|
||||
let shape_id = format!("_x0000_i{}", self.next_shape);
|
||||
self.next_shape = self.next_shape.saturating_add(1);
|
||||
let mut kids: Vec<Node> = std::mem::take(&mut self.bins).into_iter().map(Node::El).collect();
|
||||
let mut shape = self.shape_frame(&shape_id, &style, &src, &alt);
|
||||
// Brightness and contrast as VML `gain` and `blacklevel` (16.16 fixed point, #1711).
|
||||
// `v:imagedata` has no opacity, so a picture's transparency (`a:alphaModFix`) is dropped.
|
||||
let fx = crate::units::blip_fx(blip);
|
||||
if fx.changes_pixels()
|
||||
&& let Some(Node::El(data)) = shape.kids.iter_mut().find(|k| matches!(k, Node::El(e) if e.name == "v:imagedata"))
|
||||
{
|
||||
let (gain, black) = fx.to_vml();
|
||||
let fixed = |v: f64| format!("{}f", (v * 65_536.0).round().clamp(-1.0e12, 1.0e12) as i64);
|
||||
data.attrs.push(("gain".into(), fixed(gain)));
|
||||
data.attrs.push(("blacklevel".into(), fixed(black)));
|
||||
}
|
||||
kids.push(Node::El(shape));
|
||||
kids.push(Node::El(self.shape_frame(&shape_id, &style, &src, &alt)));
|
||||
vec![Node::El(plain("w:pict", kids))]
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
//! [`wordcraft_doc::math`] tree → Office Math (OMML).
|
||||
|
||||
use wordcraft_doc::math::{
|
||||
Arg, BrkBin, BrkBinSub, ColJc, FracKind, LimLoc, MAX_DEPTH, MAX_MATRIX_COLS, MAX_MATRIX_ROWS, MNode, MRun, MScr, MSty, Math, MathJc, MathProps,
|
||||
ScriptKind, parse_linear,
|
||||
Arg, BrkBin, BrkBinSub, ColJc, FracKind, LimLoc, MAX_DEPTH, MNode, MRun, MScr, MSty, Math, MathJc, MathProps, ScriptKind, parse_linear,
|
||||
};
|
||||
use wordcraft_doc::props::Rgb;
|
||||
|
||||
@@ -182,10 +181,7 @@ fn char_prop(w: &mut W, name: &str, c: Option<char>) {
|
||||
|
||||
fn write_matrix(w: &mut W, n: &MNode, depth: usize) {
|
||||
let MNode::Matrix { rows, col_jc } = n else { return };
|
||||
// Every row is padded to the widest one, so a ragged matrix is clamped to the reader's
|
||||
// caps rather than written as rows × widest cells (#3643).
|
||||
let rows = rows.get(..rows.len().min(MAX_MATRIX_ROWS)).unwrap_or_default();
|
||||
let cols = rows.iter().map(|r| r.len()).max().unwrap_or(0).clamp(1, MAX_MATRIX_COLS);
|
||||
let cols = rows.iter().map(|r| r.len()).max().unwrap_or(0).max(1);
|
||||
w.open("m:m", &[]);
|
||||
w.open("m:mPr", &[]);
|
||||
w.open("m:mcs", &[]);
|
||||
|
||||
@@ -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(t) = p.text_alignment {
|
||||
w.val("w:textAlignment", t.ooxml());
|
||||
}
|
||||
if let Some(l) = p.outline_level {
|
||||
w.val("w:outlineLvl", &n(l.min(9) as i64));
|
||||
}
|
||||
@@ -326,9 +323,6 @@ fn write_frame(w: &mut W, p: &ParaProps, framed: bool) {
|
||||
a.push(("w:vSpace", &vs));
|
||||
}
|
||||
a.push(("w:wrap", &f.wrap));
|
||||
if let Some(anchor_lock) = f.anchor_lock {
|
||||
a.push(("w:anchorLock", if anchor_lock { "1" } else { "0" }));
|
||||
}
|
||||
a.push(("w:vAnchor", &f.v_anchor));
|
||||
a.push(("w:hAnchor", &f.h_anchor));
|
||||
match &f.x_align {
|
||||
@@ -376,7 +370,6 @@ fn write_text_flow(w: &mut W, p: &ParaProps) {
|
||||
toggle(w, "w:autoSpaceDE", p.auto_space_de);
|
||||
toggle(w, "w:autoSpaceDN", p.auto_space_dn);
|
||||
toggle(w, "w:bidi", p.bidi);
|
||||
toggle(w, "w:adjustRightInd", p.adjust_right_indent);
|
||||
toggle(w, "w:snapToGrid", p.snap_to_grid);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ pub(super) struct DrawingInfo<'a> {
|
||||
impl Writer<'_> {
|
||||
/// A picture's `pic:pic` (the image in `media/{file}`), at `off` in its group (or 0, 0).
|
||||
pub(super) fn pic(&mut self, w: &mut W, o: &InlineObject, file: &str, off: (f32, f32), rels: &mut PartRels) {
|
||||
let InlineObject::Image { media, w: iw, h: ih, alt, crop, float, fx, .. } = o else { return };
|
||||
let InlineObject::Image { media, w: iw, h: ih, alt, crop, float, .. } = o else { return };
|
||||
let rid = rels.add(rt::IMAGE, &format!("media/{file}"), false);
|
||||
self.used_media.insert(media.clone());
|
||||
w.open("pic:pic", &[]);
|
||||
@@ -26,21 +26,7 @@ impl Writer<'_> {
|
||||
w.close("pic:cNvPicPr");
|
||||
w.close("pic:nvPicPr");
|
||||
w.open("pic:blipFill", &[]);
|
||||
// Brightness, contrast and transparency (#1711, #1766) as `a:blip` effects (ECMA-376
|
||||
// §20.1.8.13 CT_Blip: an unbounded choice of effects, in any order).
|
||||
let fx = fx.clamped();
|
||||
if fx.is_identity() {
|
||||
w.empty("a:blip", &[("r:embed", &rid)]);
|
||||
} else {
|
||||
w.open("a:blip", &[("r:embed", &rid)]);
|
||||
if fx.transparency != 0 {
|
||||
w.empty("a:alphaModFix", &[("amt", &n(i64::from(100_000 - fx.transparency)))]);
|
||||
}
|
||||
if fx.bright != 0 || fx.contrast != 0 {
|
||||
w.empty("a:lum", &[("bright", &n(i64::from(fx.bright))), ("contrast", &n(i64::from(fx.contrast)))]);
|
||||
}
|
||||
w.close("a:blip");
|
||||
}
|
||||
w.empty("a:blip", &[("r:embed", &rid)]);
|
||||
if crop.iter().any(|c| *c > 0.0) {
|
||||
let c = |v: f32| n((wordcraft_geom::finite(v).clamp(0.0, 1.0) * 100_000.0).round() as i64);
|
||||
w.empty("a:srcRect", &[("l", &c(crop[0])), ("t", &c(crop[1])), ("r", &c(crop[2])), ("b", &c(crop[3]))]);
|
||||
|
||||
@@ -87,7 +87,6 @@ fn sectpr_body(w: &mut W, s: &SectionProps) {
|
||||
("w:gutter", &twips(s.gutter.max(0.0))),
|
||||
],
|
||||
);
|
||||
paper_source(w, s);
|
||||
if let Some(b) = &s.page_borders {
|
||||
borders(w, "w:pgBorders", b, None, &[("w:offsetFrom", "text")]);
|
||||
}
|
||||
@@ -108,7 +107,14 @@ fn sectpr_body(w: &mut W, s: &SectionProps) {
|
||||
let refs: Vec<(&str, &str)> = a.iter().map(|(k, v)| (*k, v.as_str())).collect();
|
||||
w.empty("w:lnNumType", &refs);
|
||||
}
|
||||
page_number_type(w, s);
|
||||
if s.page_num_set || s.page_num_start.is_some() || s.page_num_format != wordcraft_doc::section::NumFormat::Decimal {
|
||||
let mut a = vec![("w:fmt", s.page_num_format.ooxml().to_string())];
|
||||
if let Some(st) = s.page_num_start {
|
||||
a.push(("w:start", st.to_string()));
|
||||
}
|
||||
let refs: Vec<(&str, &str)> = a.iter().map(|(k, v)| (*k, v.as_str())).collect();
|
||||
w.empty("w:pgNumType", &refs);
|
||||
}
|
||||
let c = &s.columns;
|
||||
let count = c.count.clamp(1, 45);
|
||||
let mut ca = vec![("w:space", twips(c.space.max(0.0))), ("w:num", n(count as i64))];
|
||||
@@ -128,9 +134,6 @@ fn sectpr_body(w: &mut W, s: &SectionProps) {
|
||||
let refs: Vec<(&str, &str)> = ca.iter().map(|(k, v)| (*k, v.as_str())).collect();
|
||||
w.empty("w:cols", &refs);
|
||||
}
|
||||
if let Some(count) = s.footnote_columns.filter(|count| *count > 0) {
|
||||
w.empty("w15:footnoteColumns", &[("w:val", &count.clamp(1, 45).to_string())]);
|
||||
}
|
||||
if let Some(on) = s.form_prot {
|
||||
w.empty("w:formProt", if on { &[] } else { &[("w:val", "0")] });
|
||||
}
|
||||
@@ -140,9 +143,6 @@ fn sectpr_body(w: &mut W, s: &SectionProps) {
|
||||
wordcraft_doc::section::PageVAlign::Justify => w.val("w:vAlign", "both"),
|
||||
wordcraft_doc::section::PageVAlign::Bottom => w.val("w:vAlign", "bottom"),
|
||||
}
|
||||
if let Some(on) = s.no_endnote {
|
||||
w.empty("w:noEndnote", if on { &[] } else { &[("w:val", "0")] });
|
||||
}
|
||||
if s.title_page {
|
||||
w.empty("w:titlePg", &[]);
|
||||
}
|
||||
@@ -167,43 +167,6 @@ fn sectpr_body(w: &mut W, s: &SectionProps) {
|
||||
}
|
||||
}
|
||||
|
||||
fn paper_source(w: &mut W, s: &SectionProps) {
|
||||
let mut attrs = Vec::new();
|
||||
if let Some(first) = s.paper_src_first {
|
||||
attrs.push(("w:first", first.to_string()));
|
||||
}
|
||||
if let Some(other) = s.paper_src_other {
|
||||
attrs.push(("w:other", other.to_string()));
|
||||
}
|
||||
if !attrs.is_empty() {
|
||||
let refs: Vec<(&str, &str)> = attrs.iter().map(|(k, v)| (*k, v.as_str())).collect();
|
||||
w.empty("w:paperSrc", &refs);
|
||||
}
|
||||
}
|
||||
|
||||
fn page_number_type(w: &mut W, s: &SectionProps) {
|
||||
if !(s.page_num_set
|
||||
|| s.page_num_start.is_some()
|
||||
|| s.page_num_format != wordcraft_doc::section::NumFormat::Decimal
|
||||
|| s.chapter_style.is_some()
|
||||
|| s.chapter_sep.is_some())
|
||||
{
|
||||
return;
|
||||
}
|
||||
let mut attrs = vec![("w:fmt", s.page_num_format.ooxml().to_string())];
|
||||
if let Some(start) = s.page_num_start {
|
||||
attrs.push(("w:start", start.to_string()));
|
||||
}
|
||||
if let Some(style) = s.chapter_style {
|
||||
attrs.push(("w:chapStyle", style.to_string()));
|
||||
}
|
||||
if let Some(separator) = s.chapter_sep {
|
||||
attrs.push(("w:chapSep", separator.ooxml().to_string()));
|
||||
}
|
||||
let refs: Vec<(&str, &str)> = attrs.iter().map(|(k, v)| (*k, v.as_str())).collect();
|
||||
w.empty("w:pgNumType", &refs);
|
||||
}
|
||||
|
||||
/// The children of a `w:footnotePr` / `w:endnotePr` in schema order (`w:pos`, `w:numFmt`,
|
||||
/// `w:numStart`, `w:numRestart`; ECMA-376 §17.11.11), for the fields that are set.
|
||||
pub(in super::super) fn note_pr_inner(w: &mut W, pr: &NoteProps) {
|
||||
|
||||
@@ -317,16 +317,7 @@ fn a_chunks_pictures_come_with_it() {
|
||||
let mut d = Document::new();
|
||||
let key = d.add_media(vec![1, 2, 3], "png");
|
||||
let mut para = wordcraft_doc::Paragraph::new();
|
||||
let image = InlineObject::Image {
|
||||
media: key,
|
||||
w: 10.0,
|
||||
h: 10.0,
|
||||
alt: String::new(),
|
||||
float: Default::default(),
|
||||
crop: [0.0; 4],
|
||||
ole: None,
|
||||
fx: Default::default(),
|
||||
};
|
||||
let image = InlineObject::Image { media: key, w: 10.0, h: 10.0, alt: String::new(), float: Default::default(), crop: [0.0; 4], ole: None };
|
||||
para.insert_object(0, image, &Default::default()).unwrap();
|
||||
d.body = vec![std::sync::Arc::new(Block::Para(para))];
|
||||
Some(d)
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
//! Compound border lines of thin and thick strokes (`thinThickSmallGap` to
|
||||
//! `thinThickThinLargeGap`, ECMA-376 Part 1 §17.18.2 ST_Border) on paragraphs, table cells and
|
||||
//! pages: written back as read, not as a single line. Fixtures are hand-written from the spec.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
|
||||
use wordcraft_doc::props::BorderStyle;
|
||||
|
||||
const LINES: [(&str, BorderStyle); 9] = [
|
||||
("thinThickSmallGap", BorderStyle::ThinThickSmallGap),
|
||||
("thickThinSmallGap", BorderStyle::ThickThinSmallGap),
|
||||
("thinThickThinSmallGap", BorderStyle::ThinThickThinSmallGap),
|
||||
("thinThickMediumGap", BorderStyle::ThinThickMediumGap),
|
||||
("thickThinMediumGap", BorderStyle::ThickThinMediumGap),
|
||||
("thinThickThinMediumGap", BorderStyle::ThinThickThinMediumGap),
|
||||
("thinThickLargeGap", BorderStyle::ThinThickLargeGap),
|
||||
("thickThinLargeGap", BorderStyle::ThickThinLargeGap),
|
||||
("thinThickThinLargeGap", BorderStyle::ThinThickThinLargeGap),
|
||||
];
|
||||
|
||||
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>"#
|
||||
);
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (name, bytes) in [("_rels/.rels", root.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(bytes: &[u8]) -> String {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut s = String::new();
|
||||
z.by_name("word/document.xml").unwrap().read_to_string(&mut s).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn side(name: &str, val: &str) -> String {
|
||||
format!(r#"<w:{name} w:val="{val}" w:sz="36" w:space="1" w:color="2F5496"/>"#)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compound_paragraph_borders_are_written_back_as_read() {
|
||||
let body: String = LINES
|
||||
.iter()
|
||||
.map(|(val, _)| {
|
||||
format!(r#"<w:p><w:pPr><w:pBdr>{}{}</w:pBdr></w:pPr><w:r><w:t>{val}</w:t></w:r></w:p>"#, side("top", val), side("bottom", val))
|
||||
})
|
||||
.collect();
|
||||
let d = wordcraft_docx::read(&docx(&body)).unwrap();
|
||||
let got: Vec<Option<BorderStyle>> =
|
||||
d.body.iter().filter_map(|b| b.as_para()).map(|p| p.props.borders.and_then(|b| b.top).map(|t| t.style)).collect();
|
||||
assert_eq!(got, LINES.iter().map(|(_, s)| Some(*s)).collect::<Vec<_>>());
|
||||
let xml = document_xml(&wordcraft_docx::write(&d).unwrap());
|
||||
for (val, _) in LINES {
|
||||
assert_eq!(xml.matches(&format!(r#"w:val="{val}""#)).count(), 2, "{val}: {xml}");
|
||||
}
|
||||
assert!(!xml.contains(r#"w:val="single""#), "{xml}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compound_cell_and_page_borders_are_written_back_as_read() {
|
||||
let body = format!(
|
||||
r#"<w:tbl><w:tblPr><w:tblW w:w="0" w:type="auto"/><w:tblBorders>{}</w:tblBorders></w:tblPr><w:tblGrid><w:gridCol w:w="2000"/></w:tblGrid><w:tr><w:tc><w:tcPr><w:tcW w:w="2000" w:type="dxa"/><w:tcBorders>{}</w:tcBorders></w:tcPr><w:p><w:r><w:t>Cell</w:t></w:r></w:p></w:tc></w:tr></w:tbl><w:p/><w:sectPr><w:pgBorders w:offsetFrom="text">{}</w:pgBorders></w:sectPr>"#,
|
||||
side("top", "thickThinLargeGap"),
|
||||
side("left", "thinThickSmallGap"),
|
||||
side("right", "thinThickThinMediumGap")
|
||||
);
|
||||
let xml = document_xml(&wordcraft_docx::write(&wordcraft_docx::read(&docx(&body)).unwrap()).unwrap());
|
||||
for val in ["thickThinLargeGap", "thinThickSmallGap", "thinThickThinMediumGap"] {
|
||||
assert_eq!(xml.matches(&format!(r#"w:val="{val}""#)).count(), 1, "{val}: {xml}");
|
||||
}
|
||||
}
|
||||
@@ -80,32 +80,3 @@ fn automatic_spacing_reads_and_round_trips() {
|
||||
let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||||
assert_eq!(props(&again)[..3], p[..3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adjust_right_indent_is_preserved() {
|
||||
let body = r#"<w:p><w:pPr><w:adjustRightInd w:val="0"/><w:snapToGrid w:val="0"/><w:spacing w:after="120"/><w:ind w:right="120"/></w:pPr><w:r><w:t>false</w:t></w:r></w:p>
|
||||
<w:p><w:pPr><w:adjustRightInd w:val="1"/></w:pPr><w:r><w:t>true</w:t></w:r></w:p>
|
||||
<w:p><w:r><w:t>absent</w:t></w:r></w:p>"#;
|
||||
let mut d = wordcraft_docx::read(&docx(body)).unwrap();
|
||||
assert_eq!(props(&d).iter().map(|p| p.adjust_right_indent).collect::<Vec<_>>(), [Some(false), Some(true), None]);
|
||||
for _ in 0..2 {
|
||||
let out = wordcraft_docx::write(&d).unwrap();
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(out.clone())).unwrap();
|
||||
let mut xml = String::new();
|
||||
z.by_name("word/document.xml").unwrap().read_to_string(&mut xml).unwrap();
|
||||
assert!(xml.contains(r#"<w:adjustRightInd w:val="0""#), "explicit false was lost: {xml}");
|
||||
assert!(xml.contains("<w:adjustRightInd/>"), "explicit true was lost: {xml}");
|
||||
let tags = ["<w:adjustRightInd", "<w:snapToGrid", "<w:spacing", "<w:ind"];
|
||||
let pprs = xml.split("<w:pPr>").skip(1).filter_map(|rest| rest.split("</w:pPr>").next()).collect::<Vec<_>>();
|
||||
let first = pprs.first().expect("paragraph properties must be present");
|
||||
for &tag in &tags {
|
||||
assert!(first.contains(tag), "{tag} missing: {xml}");
|
||||
}
|
||||
for ppr in pprs {
|
||||
let order = tags.iter().filter_map(|tag| ppr.find(*tag)).collect::<Vec<_>>();
|
||||
assert!(order.windows(2).all(|pair| pair[0] < pair[1]), "paragraph properties are out of schema order: {xml}");
|
||||
}
|
||||
d = wordcraft_docx::read(&out).unwrap();
|
||||
assert_eq!(props(&d).iter().map(|p| p.adjust_right_indent).collect::<Vec<_>>(), [Some(false), Some(true), None]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ fn picture() -> InlineObject {
|
||||
alt: String::new(),
|
||||
float: Default::default(),
|
||||
crop: [0.0; 4],
|
||||
fx: Default::default(),
|
||||
ole: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,46 +247,6 @@ fn frame_zero_side_is_automatic() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_frame_anchor_survives_two_saves() {
|
||||
for (lock, expected) in [("", None), (" w:anchorLock=\"1\"", Some(true)), (" w:anchorLock=\"0\"", Some(false))] {
|
||||
let mut document = read_body(&format!(r#"<w:p><w:pPr><w:framePr w:w="1440" w:h="720"{lock}/></w:pPr><w:r><w:t>Locked</w:t></w:r></w:p>"#));
|
||||
let paragraph = paras(&document)[0];
|
||||
assert_eq!(paragraph.props.frame.as_ref().and_then(|f| f.anchor_lock), expected);
|
||||
if expected == Some(true) {
|
||||
let Some(wordcraft_doc::Block::Para(paragraph)) = document.body.get_mut(0).map(std::sync::Arc::make_mut) else {
|
||||
panic!("first block is the framed paragraph");
|
||||
};
|
||||
let frame = paragraph.props.frame.as_mut().unwrap();
|
||||
frame.x = 36.0;
|
||||
frame.y = 72.0;
|
||||
frame.w = Some(108.0);
|
||||
}
|
||||
let json = serde_json::to_vec(&document).unwrap();
|
||||
document = serde_json::from_slice(&json).unwrap();
|
||||
if expected == Some(true) {
|
||||
let frame = paras(&document)[0].props.frame.as_ref().unwrap();
|
||||
assert_eq!((frame.x, frame.y, frame.w), (36.0, 72.0, Some(108.0)));
|
||||
}
|
||||
for pass in 0..2 {
|
||||
let bytes = wordcraft_docx::write(&document).unwrap();
|
||||
let xml = text(&unzip(&bytes), "word/document.xml");
|
||||
let expected_xml = match expected {
|
||||
Some(true) => "w:anchorLock=\"1\"",
|
||||
Some(false) => "w:anchorLock=\"0\"",
|
||||
None => "w:anchorLock=",
|
||||
};
|
||||
assert_eq!(xml.contains(expected_xml), expected.is_some(), "lock changed on save {}: {xml}", pass + 1);
|
||||
document = wordcraft_docx::read(&bytes).unwrap();
|
||||
assert_eq!(paras(&document)[0].props.frame.as_ref().and_then(|f| f.anchor_lock), expected);
|
||||
if expected == Some(true) {
|
||||
let frame = paras(&document)[0].props.frame.as_ref().unwrap();
|
||||
assert_eq!((frame.x, frame.y, frame.w), (36.0, 72.0, Some(108.0)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omitted_paragraph_defaults_can_be_edited() {
|
||||
let styles =
|
||||
@@ -3981,144 +3941,6 @@ fn hyperlink_with_empty_target_is_no_link() {
|
||||
assert_eq!(p.props_of_char(5).link.as_deref(), Some("mailto:a@example.com"));
|
||||
}
|
||||
|
||||
/// Picture brightness, contrast and transparency (#1711, #1766): VML `gain`/`blacklevel` (Word's
|
||||
/// watermark "Washout"), DrawingML `a:lum` and `a:alphaModFix`; saved as DrawingML and read back.
|
||||
#[test]
|
||||
fn picture_brightness_contrast_transparency() {
|
||||
use wordcraft_doc::picfx::PicFx;
|
||||
let png: &[u8] = b"\x89PNG\r\n\x1a\nnot really a png";
|
||||
let vml =
|
||||
|attrs: &str| format!(r#"<w:r><w:pict><v:shape style="width:1in;height:36pt"><v:imagedata r:id="rIdImg" {attrs}/></v:shape></w:pict></w:r>"#);
|
||||
let dml = |blip: &str| {
|
||||
format!(
|
||||
r#"<w:r><w:drawing><wp:inline><wp:extent cx="1270000" cy="635000"/><wp:docPr id="3" name="P"/><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:blipFill><a:blip r:embed="rIdImg">{blip}</a:blip></pic:blipFill></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r>"#
|
||||
)
|
||||
};
|
||||
let cases: Vec<(String, Option<PicFx>)> = vec![
|
||||
// Word's Washout: 0.3 and 0.35 in 16.16 fixed point.
|
||||
(vml(r#"gain="19661f" blacklevel="22938f""#), Some(PicFx::new(70_000, -70_000, 0))),
|
||||
(vml(r#"gain="0.5" blacklevel="0.25""#), Some(PicFx::new(50_000, -50_000, 0))),
|
||||
(vml(r#"gain="131072f""#), Some(PicFx::new(0, 50_000, 0))),
|
||||
(vml(""), Some(PicFx::default())),
|
||||
(dml(r#"<a:lum bright="80000" contrast="-80000"/>"#), Some(PicFx::new(80_000, -80_000, 0))),
|
||||
(dml(r#"<a:alphaModFix amt="40000"/><a:lum bright="-20000"/>"#), Some(PicFx::new(-20_000, 0, 60_000))),
|
||||
(dml(""), Some(PicFx::default())),
|
||||
// Hostile values: clamped, never a panic.
|
||||
(vml(r#"gain="NaNf" blacklevel="infinity""#), None),
|
||||
(vml(r#"gain="-99999999f" blacklevel="99999999999f""#), None),
|
||||
(vml(r#"gain="1e308" blacklevel="-1e308""#), None),
|
||||
(vml(r#"gain="f" blacklevel="--3""#), None),
|
||||
(dml(r#"<a:lum bright="999999999" contrast="-99999999999999999999"/><a:alphaModFix amt="-5"/>"#), None),
|
||||
(dml(r#"<a:alphaModFix amt="250000"/><a:lum bright="x" contrast=""/>"#), None),
|
||||
];
|
||||
let body: String = cases.iter().map(|(x, _)| format!("<w:p>{x}</w:p>")).collect();
|
||||
let doc = format!(r#"<w:document {W_NS}><w:body>{body}</w:body></w:document>"#);
|
||||
let r = rels(&[("rIdImg", "image", "media/pic.png")]);
|
||||
let bytes = zip(&[
|
||||
("_rels/.rels", ROOT_RELS.as_bytes()),
|
||||
("word/document.xml", doc.as_bytes()),
|
||||
("word/_rels/document.xml.rels", r.as_bytes()),
|
||||
("word/media/pic.png", png),
|
||||
]);
|
||||
let d = wordcraft_docx::read(&bytes).unwrap();
|
||||
let fx_of = |d: &Document| -> Vec<PicFx> {
|
||||
paras(d)
|
||||
.iter()
|
||||
.map(|p| match p.objects.first() {
|
||||
Some(InlineObject::Image { fx, .. }) => *fx,
|
||||
o => panic!("{o:?}"),
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let read = fx_of(&d);
|
||||
assert_eq!(read.len(), cases.len());
|
||||
for ((x, want), got) in cases.iter().zip(&read) {
|
||||
assert_eq!(*got, got.clamped(), "{x}");
|
||||
if let Some(want) = want {
|
||||
let near = |a: i32, b: i32| (a - b).abs() <= 2;
|
||||
assert!(near(got.bright, want.bright) && near(got.contrast, want.contrast) && got.transparency == want.transparency, "{x}: {got:?}");
|
||||
}
|
||||
}
|
||||
// Clamped hostile values.
|
||||
assert_eq!(read[11], PicFx::new(100_000, -100_000, 100_000));
|
||||
assert_eq!(read[12], PicFx::default());
|
||||
// Saved as DrawingML (VML pictures too) and read back unchanged.
|
||||
let out = wordcraft_docx::write(&d).unwrap();
|
||||
let back = wordcraft_docx::read(&out).unwrap();
|
||||
assert_eq!(fx_of(&back), read);
|
||||
}
|
||||
|
||||
/// Word's picture watermark with Washout (#1711): a VML picture in a header's Watermarks
|
||||
/// building block keeps its brightness and contrast.
|
||||
#[test]
|
||||
fn washout_picture_watermark_in_header() {
|
||||
let hdr = format!(
|
||||
r##"<w:hdr {W_NS}><w:sdt><w:sdtPr><w:docPartObj><w:docPartGallery w:val="Watermarks"/><w:docPartUnique/></w:docPartObj></w:sdtPr><w:sdtContent><w:p><w:r><w:pict><v:shape id="WordPictureWatermark1" type="#_x0000_t75" style="position:absolute;margin-left:0;margin-top:0;width:200pt;height:200pt;z-index:-251657216;mso-position-horizontal:center;mso-position-horizontal-relative:margin;mso-position-vertical:center;mso-position-vertical-relative:margin"><v:imagedata r:id="rImg" o:title="wm" gain="19661f" blacklevel="22938f"/></v:shape></w:pict></w:r></w:p></w:sdtContent></w:sdt></w:hdr>"##
|
||||
);
|
||||
let hrels = rels(&[("rImg", "image", "media/wm.png")]);
|
||||
let body = r#"<w:p><w:r><w:t>Body</w:t></w:r></w:p><w:sectPr><w:headerReference w:type="default" r:id="rHdr"/></w:sectPr>"#;
|
||||
let d = wordcraft_docx::read(&docx(
|
||||
body,
|
||||
&[("rHdr", "header", "header1.xml")],
|
||||
&[("word/header1.xml", &hdr), ("word/_rels/header1.xml.rels", &hrels), ("word/media/wm.png", "not really a png")],
|
||||
))
|
||||
.unwrap();
|
||||
let id = d.last_section.headers.default.unwrap();
|
||||
let fx = d.parts[&id]
|
||||
.blocks
|
||||
.iter()
|
||||
.filter_map(|b| b.as_para())
|
||||
.flat_map(|p| p.objects.iter())
|
||||
.find_map(|o| if let InlineObject::Image { fx, .. } = o { Some(*fx) } else { None })
|
||||
.expect("watermark picture");
|
||||
assert!((fx.bright - 70_000).abs() <= 2 && (fx.contrast + 70_000).abs() <= 2, "{fx:?}");
|
||||
}
|
||||
|
||||
/// A faded DrawingML picture behind a header's text (#1766; #1961 review): `a:alphaModFix` reads
|
||||
/// and is written back once, as in the body.
|
||||
#[test]
|
||||
fn faded_picture_behind_text_in_header() {
|
||||
let hdr = format!(
|
||||
r#"<w:hdr {W_NS}><w:p><w:r><w:drawing><wp:anchor behindDoc="1" distT="0" distB="0" distL="0" distR="0" simplePos="0" relativeHeight="1" locked="0" layoutInCell="1" allowOverlap="1"><wp:simplePos x="0" y="0"/><wp:positionH relativeFrom="margin"><wp:align>center</wp:align></wp:positionH><wp:positionV relativeFrom="margin"><wp:align>center</wp:align></wp:positionV><wp:extent cx="2540000" cy="2540000"/><wp:wrapNone/><wp:docPr id="7" name="Faded"/><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:blipFill><a:blip r:embed="rImg"><a:alphaModFix amt="20000"/></a:blip></pic:blipFill></pic:pic></a:graphicData></a:graphic></wp:anchor></w:drawing></w:r></w:p></w:hdr>"#
|
||||
);
|
||||
let hrels = rels(&[("rImg", "image", "media/wm.png")]);
|
||||
let body = r#"<w:p><w:r><w:t>Body</w:t></w:r></w:p><w:sectPr><w:headerReference w:type="default" r:id="rHdr"/></w:sectPr>"#;
|
||||
let header_picture = |d: &wordcraft_doc::Document| {
|
||||
let id = d.last_section.headers.default.unwrap();
|
||||
d.parts[&id]
|
||||
.blocks
|
||||
.iter()
|
||||
.filter_map(|b| b.as_para())
|
||||
.flat_map(|p| p.objects.iter())
|
||||
.find_map(|o| if let InlineObject::Image { fx, float, .. } = o { Some((*fx, float.wrap)) } else { None })
|
||||
.expect("header picture")
|
||||
};
|
||||
let d = wordcraft_docx::read(&docx(
|
||||
body,
|
||||
&[("rHdr", "header", "header1.xml")],
|
||||
&[("word/header1.xml", &hdr), ("word/_rels/header1.xml.rels", &hrels), ("word/media/wm.png", "not really a png")],
|
||||
))
|
||||
.unwrap();
|
||||
let (fx, wrap) = header_picture(&d);
|
||||
assert_eq!(fx, wordcraft_doc::picfx::PicFx::new(0, 0, 80_000));
|
||||
assert_eq!(wrap, wordcraft_doc::para::Wrap::BehindText);
|
||||
let saved = wordcraft_docx::write(&d).unwrap();
|
||||
let again = wordcraft_docx::read(&saved).unwrap();
|
||||
assert_eq!(header_picture(&again), (fx, wrap), "the same picture after saving");
|
||||
}
|
||||
|
||||
/// An OLE object's preview keeps the corrections of its picture (#1961 review).
|
||||
#[test]
|
||||
fn ole_preview_keeps_its_corrections() {
|
||||
let body = r##"<w:p><w:r><w:object w:dxaOrig="1440" w:dyaOrig="720"><v:shape id="_x0000_i1025" type="#_x0000_t75" style="width:72pt;height:36pt" o:ole=""><v:imagedata r:id="rIdImg" gain="19661f" blacklevel="22938f"/></v:shape></w:object></w:r></w:p>"##;
|
||||
let d = wordcraft_docx::read(&docx(body, &[("rIdImg", "image", "media/p.png")], &[("word/media/p.png", "not really a png")])).unwrap();
|
||||
let fx = d.body[0]
|
||||
.as_para()
|
||||
.and_then(|p| p.objects.iter().find_map(|o| if let InlineObject::Image { fx, .. } = o { Some(*fx) } else { None }))
|
||||
.expect("the preview");
|
||||
assert!((fx.bright - 70_000).abs() <= 2 && (fx.contrast + 70_000).abs() <= 2, "{fx:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_table_borders_round_trip() {
|
||||
let d = read_body(
|
||||
@@ -4554,20 +4376,6 @@ fn vml_textbox_style_anchor_and_fit() {
|
||||
assert!(tb.body.auto_fit);
|
||||
}
|
||||
|
||||
/// #2601: a drop-cap frame of two letters in two fonts. The first letter's font is the cap's; only
|
||||
/// the runs in that font get the text's font back, the other letter keeps its own.
|
||||
#[test]
|
||||
fn drop_cap_frame_keeps_other_fonts() {
|
||||
let body = r#"<w:p><w:pPr><w:framePr w:dropCap="drop" w:lines="3" w:wrap="around" w:vAnchor="text" w:hAnchor="text"/></w:pPr><w:r><w:rPr><w:rFonts w:ascii="Liberation Mono" w:hAnsi="Liberation Mono"/></w:rPr><w:t>A</w:t></w:r><w:r><w:rPr><w:rFonts w:ascii="Liberation Sans" w:hAnsi="Liberation Sans"/></w:rPr><w:t>b</w:t></w:r></w:p>
|
||||
<w:p><w:r><w:rPr><w:rFonts w:ascii="Liberation Serif" w:hAnsi="Liberation Serif"/></w:rPr><w:t>c tale</w:t></w:r></w:p>"#;
|
||||
let d = read_body(body);
|
||||
let p = paras(&d);
|
||||
assert_eq!(p.len(), 1, "the frame merges into its paragraph");
|
||||
assert_eq!(p[0].props.drop_cap_font.as_deref(), Some("Liberation Mono"));
|
||||
assert_eq!(p[0].props_of_char(0).font.as_deref(), Some("Liberation Serif"), "the cap letter gets the text's font");
|
||||
assert_eq!(p[0].props_of_char(1).font.as_deref(), Some("Liberation Sans"), "the other letter keeps its own");
|
||||
}
|
||||
|
||||
/// A VML horizontal rule (`o:hr`, a web page's `<hr>` pasted into Word) is read as one: the whole
|
||||
/// line when it has no width and no `o:hrpct`, a share of it (`o:hrpct`, tenths of a percent), or
|
||||
/// its own width (`o:hrpct="0"`), placed by `o:hralign`. It saves back as the same VML rule.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
mod alt_chunks;
|
||||
mod bidi;
|
||||
mod bidi_runs;
|
||||
mod border_lines;
|
||||
mod cell_margin_inheritance;
|
||||
mod char_units;
|
||||
mod citations;
|
||||
@@ -29,12 +28,10 @@ mod roundtrip;
|
||||
mod row_property_exceptions;
|
||||
mod run_props;
|
||||
mod section_form_protection;
|
||||
mod section_metadata_preservation;
|
||||
mod section_page_number_type;
|
||||
mod section_text_direction;
|
||||
mod section_valign;
|
||||
mod settings_merge;
|
||||
mod text_alignment;
|
||||
mod text_boxes;
|
||||
mod toc_ranges;
|
||||
mod undefined_style;
|
||||
|
||||
@@ -7,7 +7,7 @@ use wordcraft_doc::numbering::ListKind;
|
||||
use wordcraft_doc::para::{Anchor, Float, FloatAlign, NoteKind, ShapeKind, Wrap};
|
||||
use wordcraft_doc::props::{
|
||||
Align, Border, BorderStyle, Borders, CharProps, HeightRule, Highlight, LineSpacing, NumRef, ParaProps, Rgb, RowProps, TabAlign, TabLeader,
|
||||
TabStop, TableLook, TextAlignment, TextColor, TextDirection, Underline, VAlign, VMerge, VertAlign,
|
||||
TabStop, TableLook, 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};
|
||||
@@ -176,7 +176,6 @@ fn every_para_prop_round_trips() {
|
||||
line_spacing: Some(LineSpacing::Multiple(1.5)),
|
||||
contextual_spacing: Some(true),
|
||||
mirror_indents: Some(true),
|
||||
adjust_right_indent: Some(false),
|
||||
keep_next: Some(true),
|
||||
keep_lines: Some(false),
|
||||
page_break_before: Some(true),
|
||||
@@ -210,7 +209,6 @@ fn every_para_prop_round_trips() {
|
||||
h_anchor: "margin".into(),
|
||||
v_anchor: "text".into(),
|
||||
wrap: "around".into(),
|
||||
anchor_lock: Some(true),
|
||||
h_space: 9.0,
|
||||
v_space: 0.0,
|
||||
}),
|
||||
@@ -223,7 +221,6 @@ fn every_para_prop_round_trips() {
|
||||
num_change: None,
|
||||
fmt_change: None,
|
||||
snap_to_grid: Some(false),
|
||||
text_alignment: Some(TextAlignment::Top),
|
||||
};
|
||||
let variants = [
|
||||
ParaProps { line_spacing: Some(LineSpacing::AtLeast(14.0)), indent_first: Some(24.0), align: Some(Align::Center), ..Default::default() },
|
||||
@@ -431,7 +428,6 @@ fn images_round_trip() {
|
||||
// Room for a shadow: kept through save and load.
|
||||
float: Float { effect: [12.0, 12.0, 27.0, 27.0], ..Default::default() },
|
||||
crop: [0.1, 0.0, 0.25, 0.05],
|
||||
fx: Default::default(),
|
||||
ole: None,
|
||||
};
|
||||
let floating = InlineObject::Image {
|
||||
@@ -441,7 +437,6 @@ fn images_round_trip() {
|
||||
alt: String::new(),
|
||||
float: Float { wrap: Wrap::Square, h_rel: Anchor::Page, v_rel: Anchor::Margin, x: 36.0, y: 12.5, dist: 9.0, ..Default::default() },
|
||||
crop: [0.0; 4],
|
||||
fx: Default::default(),
|
||||
ole: None,
|
||||
};
|
||||
let aligned = InlineObject::Image {
|
||||
@@ -461,7 +456,6 @@ fn images_round_trip() {
|
||||
..Default::default()
|
||||
},
|
||||
crop: [0.0; 4],
|
||||
fx: Default::default(),
|
||||
ole: None,
|
||||
};
|
||||
let mut floats = vec![floating.clone(), aligned];
|
||||
@@ -473,7 +467,6 @@ fn images_round_trip() {
|
||||
alt: String::new(),
|
||||
float: Float { wrap, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, x: 0.0, y: 0.0, dist: 0.0, ..Default::default() },
|
||||
crop: [0.0; 4],
|
||||
fx: Default::default(),
|
||||
ole: None,
|
||||
});
|
||||
}
|
||||
@@ -1196,16 +1189,7 @@ fn groups_round_trip() {
|
||||
let mut d = Document::new();
|
||||
let key = d.add_media(tiny_png(), "png");
|
||||
let story = d.add_part(PartKind::TextBox, vec![para_block(Paragraph::with_text("In the group", CharProps::default()))]);
|
||||
let pic = InlineObject::Image {
|
||||
media: key,
|
||||
w: 60.0,
|
||||
h: 40.0,
|
||||
alt: "A picture".into(),
|
||||
float: Float::default(),
|
||||
crop: [0.0; 4],
|
||||
fx: Default::default(),
|
||||
ole: None,
|
||||
};
|
||||
let pic = InlineObject::Image { media: key, w: 60.0, h: 40.0, alt: "A picture".into(), float: Float::default(), crop: [0.0; 4], ole: None };
|
||||
let oval = InlineObject::Shape {
|
||||
kind: ShapeKind::Ellipse,
|
||||
w: 50.0,
|
||||
@@ -1292,16 +1276,7 @@ fn drawing_canvas_round_trips() {
|
||||
use wordcraft_doc::para::{CanvasStyle, GroupChild};
|
||||
let mut d = Document::new();
|
||||
let key = d.add_media(tiny_png(), "png");
|
||||
let pic = InlineObject::Image {
|
||||
media: key,
|
||||
w: 60.0,
|
||||
h: 40.0,
|
||||
alt: "In the canvas".into(),
|
||||
float: Float::default(),
|
||||
crop: [0.0; 4],
|
||||
fx: Default::default(),
|
||||
ole: None,
|
||||
};
|
||||
let pic = InlineObject::Image { media: key, w: 60.0, h: 40.0, alt: "In the canvas".into(), float: Float::default(), crop: [0.0; 4], ole: None };
|
||||
let star = InlineObject::Shape {
|
||||
kind: ShapeKind::Star,
|
||||
w: 72.0,
|
||||
@@ -1759,16 +1734,7 @@ fn kitchen_sink() -> Document {
|
||||
let mut img = Paragraph::new();
|
||||
img.insert_object(
|
||||
0,
|
||||
InlineObject::Image {
|
||||
media: key,
|
||||
w: 30.0,
|
||||
h: 20.0,
|
||||
alt: "pic".into(),
|
||||
float: Float::default(),
|
||||
crop: [0.0; 4],
|
||||
fx: Default::default(),
|
||||
ole: None,
|
||||
},
|
||||
InlineObject::Image { media: key, w: 30.0, h: 20.0, alt: "pic".into(), float: Float::default(), crop: [0.0; 4], ole: None },
|
||||
&CharProps::default(),
|
||||
)
|
||||
.unwrap();
|
||||
@@ -1863,7 +1829,6 @@ fn hostile_model_values_still_write() {
|
||||
alt: String::new(),
|
||||
float: Float::default(),
|
||||
crop: [f32::NAN; 4],
|
||||
fx: Default::default(),
|
||||
ole: None,
|
||||
},
|
||||
&CharProps::default(),
|
||||
@@ -2134,7 +2099,6 @@ fn rotation_and_flips_round_trip() {
|
||||
alt: String::new(),
|
||||
float: Float { rot: 30.0, effect: [6.0; 4], ..Default::default() },
|
||||
crop: [0.0; 4],
|
||||
fx: Default::default(),
|
||||
ole: None,
|
||||
};
|
||||
let shape = |kind, float| InlineObject::Shape {
|
||||
@@ -2635,7 +2599,6 @@ fn alt_text_title_and_decorative_round_trip_for_every_object() {
|
||||
alt: "A kiln at dusk".into(),
|
||||
float: Float { alt: AltText { title: "Kiln".into(), ..Default::default() }, ..Default::default() },
|
||||
crop: [0.0; 4],
|
||||
fx: Default::default(),
|
||||
ole: None,
|
||||
};
|
||||
let shape = |alt: AltText| InlineObject::Shape {
|
||||
|
||||
@@ -29,30 +29,6 @@ fn part(bytes: &[u8], name: &str) -> String {
|
||||
xml
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_adjust_right_indent_inherits_from_style() {
|
||||
let styles =
|
||||
r#"<w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/><w:pPr><w:adjustRightInd/></w:pPr></w:style>"#;
|
||||
let body = r#"<w:p><w:pPr><w:adjustRightInd w:val="0"/></w:pPr><w:r><w:t>off</w:t></w:r></w:p>
|
||||
<w:p><w:r><w:t>inherited</w:t></w:r></w:p>"#;
|
||||
let mut d = wordcraft_docx::read(&docx(body, styles)).unwrap();
|
||||
let paragraphs: Vec<_> = d.body.iter().filter_map(|b| b.as_para()).collect();
|
||||
assert_eq!(paragraphs[0].props.adjust_right_indent, Some(false));
|
||||
assert!(!d.styles.resolve_para(¶graphs[0].props).adjust_right_indent);
|
||||
assert!(d.styles.resolve_para(¶graphs[1].props).adjust_right_indent);
|
||||
|
||||
for _ in 0..2 {
|
||||
let out = wordcraft_docx::write(&d).unwrap();
|
||||
assert!(part(&out, "word/document.xml").contains(r#"<w:adjustRightInd w:val="0""#));
|
||||
assert!(part(&out, "word/styles.xml").contains("<w:adjustRightInd/>"));
|
||||
d = wordcraft_docx::read(&out).unwrap();
|
||||
let paragraphs: Vec<_> = d.body.iter().filter_map(|b| b.as_para()).collect();
|
||||
assert!(!d.styles.resolve_para(¶graphs[0].props).adjust_right_indent);
|
||||
assert!(d.styles.resolve_para(¶graphs[1].props).adjust_right_indent);
|
||||
assert_eq!(paragraphs[0].props.adjust_right_indent, Some(false));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_and_style_snap_to_grid_and_web_hidden_survive_two_saves() {
|
||||
let styles = r#"
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
//! Section printer, note and chapter-number metadata survives DOCX and native JSON saves.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
|
||||
fn docx(body: &str) -> Vec<u8> {
|
||||
let root = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
|
||||
let doc = format!(
|
||||
r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:w15="http://schemas.microsoft.com/office/word/2012/wordml" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" mc:Ignorable="w15"><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.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(bytes: &[u8]) -> String {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut s = String::new();
|
||||
z.by_name("word/document.xml").unwrap().read_to_string(&mut s).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn section_metadata_survives_native_json_and_two_docx_save_reopen_cycles() {
|
||||
let input = docx(
|
||||
r#"<w:p><w:pPr><w:sectPr><w:paperSrc w:first=" +261 " w:other=" 260 "/><w:noEndnote/><w15:footnoteColumns w:val="2"/><w:pgNumType w:fmt="decimal" w:chapStyle="1" w:chapSep="hyphen"/></w:sectPr></w:pPr><w:r><w:t>First section</w:t></w:r></w:p><w:p><w:r><w:t>Second section</w:t></w:r></w:p><w:sectPr><w:paperSrc w:first="12" w:other="34"/><w:noEndnote w:val="0"/><w15:footnoteColumns w:val="3"/><w:pgNumType w:fmt="upperRoman" w:chapStyle="10" w:chapSep="colon"/></w:sectPr>"#,
|
||||
);
|
||||
let mut document = wordcraft_docx::read(&input).unwrap();
|
||||
let json = serde_json::to_vec(&document).unwrap();
|
||||
document = serde_json::from_slice(&json).unwrap();
|
||||
for cycle in 0..2 {
|
||||
let bytes = wordcraft_docx::write(&document).unwrap();
|
||||
let xml = document_xml(&bytes);
|
||||
assert!(xml.contains(r#"<w:paperSrc w:first="261" w:other="260"/>"#), "cycle {cycle}: {xml}");
|
||||
assert!(xml.contains(r#"<w:paperSrc w:first="12" w:other="34"/>"#), "cycle {cycle}: {xml}");
|
||||
assert!(xml.contains("<w:noEndnote/>"), "cycle {cycle}: {xml}");
|
||||
assert!(xml.contains(r#"<w:noEndnote w:val="0"/>"#), "cycle {cycle}: {xml}");
|
||||
assert!(xml.contains(r#"<w15:footnoteColumns w:val="2"/>"#), "cycle {cycle}: {xml}");
|
||||
assert!(xml.contains(r#"<w15:footnoteColumns w:val="3"/>"#), "cycle {cycle}: {xml}");
|
||||
assert!(xml.contains(r#"w:chapStyle="1" w:chapSep="hyphen""#), "cycle {cycle}: {xml}");
|
||||
assert!(xml.contains(r#"w:chapStyle="10" w:chapSep="colon""#), "cycle {cycle}: {xml}");
|
||||
assert!(xml.contains("xmlns:w15=") && xml.contains("mc:Ignorable=\"w14 w15 wp14\""), "cycle {cycle}: {xml}");
|
||||
document = wordcraft_docx::read(&bytes).unwrap();
|
||||
let json = serde_json::to_vec(&document).unwrap();
|
||||
document = serde_json::from_slice(&json).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_section_metadata_is_ignored_and_missing_values_stay_missing() {
|
||||
let bytes = docx(
|
||||
r#"<w:p><w:r><w:t>Text</w:t></w:r></w:p><w:sectPr><w:paperSrc w:first="65536" w:other="-1"/><w15:footnoteColumns w:val="0"/><w:pgNumType w:chapStyle="256" w:chapSep="invalid"/></w:sectPr>"#,
|
||||
);
|
||||
let mut d = wordcraft_docx::read(&bytes).unwrap();
|
||||
let xml = document_xml(&wordcraft_docx::write(&d).unwrap());
|
||||
assert!(!xml.contains("paperSrc"), "{xml}");
|
||||
assert!(!xml.contains("footnoteColumns"), "{xml}");
|
||||
assert!(!xml.contains("chapStyle") && !xml.contains("chapSep"), "{xml}");
|
||||
assert!(!xml.contains("noEndnote"), "{xml}");
|
||||
|
||||
d.last_section.footnote_columns = Some(0);
|
||||
let xml = document_xml(&wordcraft_docx::write(&d).unwrap());
|
||||
assert!(!xml.contains("footnoteColumns"), "{xml}");
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
//! A paragraph's vertical text alignment (`w:textAlignment`, ECMA-376 Part 1 §17.3.1.39, values
|
||||
//! from ST_TextAlignment): read and written back as read, in the schema's place after `w:jc`.
|
||||
//! Word sizes a line by it when a list label is larger than the text, so a save that drops it
|
||||
//! moves every line after. Fixtures are hand-written from the spec.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
|
||||
use wordcraft_doc::props::TextAlignment;
|
||||
|
||||
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>"#
|
||||
);
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (name, bytes) in [("_rels/.rels", root.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(bytes: &[u8]) -> String {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut s = String::new();
|
||||
z.by_name("word/document.xml").unwrap().read_to_string(&mut s).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_alignment_is_written_back_as_read() {
|
||||
let para = |ppr: &str, text: &str| format!(r#"<w:p><w:pPr>{ppr}</w:pPr><w:r><w:t>{text}</w:t></w:r></w:p>"#);
|
||||
let body = [
|
||||
para(r#"<w:jc w:val="center"/><w:textAlignment w:val="top"/><w:outlineLvl w:val="1"/>"#, "A"),
|
||||
para(r#"<w:textAlignment w:val="center"/>"#, "B"),
|
||||
para(r#"<w:textAlignment w:val="baseline"/>"#, "C"),
|
||||
para(r#"<w:textAlignment w:val="bottom"/>"#, "D"),
|
||||
para(r#"<w:textAlignment w:val="auto"/>"#, "E"),
|
||||
para(r#"<w:textAlignment w:val="sideways"/>"#, "F"),
|
||||
para("", "G"),
|
||||
]
|
||||
.concat();
|
||||
let d = wordcraft_docx::read(&docx(&body)).unwrap();
|
||||
let got: Vec<Option<TextAlignment>> = d.body.iter().filter_map(|b| b.as_para()).map(|p| p.props.text_alignment).collect();
|
||||
let want = vec![
|
||||
Some(TextAlignment::Top),
|
||||
Some(TextAlignment::Center),
|
||||
Some(TextAlignment::Baseline),
|
||||
Some(TextAlignment::Bottom),
|
||||
Some(TextAlignment::Auto),
|
||||
None,
|
||||
None,
|
||||
];
|
||||
assert_eq!(got, want);
|
||||
let out = wordcraft_docx::write(&d).unwrap();
|
||||
let xml = document_xml(&out);
|
||||
assert!(xml.contains(r#"<w:jc w:val="center"/><w:textAlignment w:val="top"/><w:outlineLvl w:val="1"/>"#), "{xml}");
|
||||
for v in ["center", "baseline", "bottom", "auto"] {
|
||||
assert_eq!(xml.matches(&format!(r#"<w:textAlignment w:val="{v}"/>"#)).count(), 1, "{v}: {xml}");
|
||||
}
|
||||
assert_eq!(xml.matches("w:textAlignment").count(), 5, "an unknown value and none write nothing: {xml}");
|
||||
let back = wordcraft_docx::read(&out).unwrap();
|
||||
assert_eq!(back.body.iter().filter_map(|b| b.as_para()).map(|p| p.props.text_alignment).collect::<Vec<_>>(), want);
|
||||
}
|
||||
@@ -215,7 +215,6 @@ fn picture_in_box_keeps_its_frame() {
|
||||
alt: "Synthetic marker".into(),
|
||||
float: Default::default(),
|
||||
crop: [0.0; 4],
|
||||
fx: Default::default(),
|
||||
ole: None,
|
||||
};
|
||||
let mut p = Paragraph::new();
|
||||
|
||||
@@ -106,7 +106,7 @@ fn wordml_2003_sample() -> String {
|
||||
<w:tr><w:tc><w:tcPr><w:tcW w:w="2000" w:type="dxa"/><w:vmerge w:val="restart"/></w:tcPr><w:p><w:r><w:t>A1</w:t></w:r></w:p></w:tc><w:tc><w:tcPr><w:tcW w:w="2000" w:type="dxa"/></w:tcPr><w:p><w:r><w:t>B1</w:t></w:r></w:p></w:tc></w:tr>
|
||||
<w:tr><w:tc><w:tcPr><w:tcW w:w="2000" w:type="dxa"/><w:vmerge/></w:tcPr><w:p/></w:tc><w:tc><w:tcPr><w:tcW w:w="2000" w:type="dxa"/></w:tcPr><w:p><w:r><w:t>B2</w:t></w:r></w:p></w:tc></w:tr>
|
||||
</w:tbl>
|
||||
<w:p><w:r><w:pict><w:binData w:name="wordml://02000001.png">{png}</w:binData><v:shape id="_x0000_i1025" type="#_x0000_t75" style="width:36pt;height:18pt"><v:imagedata src="wordml://02000001.png" o:title="dot" gain="19661f" blacklevel="22938f"/></v:shape></w:pict></w:r></w:p>
|
||||
<w:p><w:r><w:pict><w:binData w:name="wordml://02000001.png">{png}</w:binData><v:shape id="_x0000_i1025" type="#_x0000_t75" style="width:36pt;height:18pt"><v:imagedata src="wordml://02000001.png" o:title="dot"/></v:shape></w:pict></w:r></w:p>
|
||||
<w:sectPr>
|
||||
<w:hdr w:type="odd"><w:p><w:r><w:t>Garden header</w:t></w:r></w:p></w:hdr>
|
||||
<w:pgSz w:w="12240" w:h="15840"/><w:pgMar w:top="1440" w:right="1800" w:bottom="1440" w:left="1800" w:header="720" w:footer="720" w:gutter="0"/>
|
||||
@@ -117,16 +117,6 @@ fn wordml_2003_sample() -> String {
|
||||
)
|
||||
}
|
||||
|
||||
/// Word 2003 XML pictures keep Word's Washout (gain 0.3, black level 0.35) as brightness and
|
||||
/// contrast (#1711).
|
||||
#[test]
|
||||
fn word_2003_xml_picture_keeps_washout() {
|
||||
let doc = read_xml(wordml_2003_sample().as_bytes()).unwrap();
|
||||
let ps = paras(&doc);
|
||||
let pic = ps.iter().flat_map(|p| p.objects.iter()).find(|o| matches!(o, InlineObject::Image { .. }));
|
||||
assert!(matches!(pic, Some(InlineObject::Image { fx, .. }) if (fx.bright - 70_000).abs() <= 2 && (fx.contrast + 70_000).abs() <= 2), "{pic:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imports_word_2003_xml() {
|
||||
let xml = wordml_2003_sample();
|
||||
|
||||
@@ -69,16 +69,7 @@ fn sample() -> Document {
|
||||
let mut img = Paragraph::new();
|
||||
img.insert_object(
|
||||
0,
|
||||
InlineObject::Image {
|
||||
media: key,
|
||||
w: 30.0,
|
||||
h: 20.0,
|
||||
alt: "dot".into(),
|
||||
float: Default::default(),
|
||||
crop: [0.0; 4],
|
||||
fx: Default::default(),
|
||||
ole: None,
|
||||
},
|
||||
InlineObject::Image { media: key, w: 30.0, h: 20.0, alt: "dot".into(), float: Default::default(), crop: [0.0; 4], ole: None },
|
||||
&CharProps::default(),
|
||||
)
|
||||
.unwrap();
|
||||
@@ -189,48 +180,6 @@ fn big_and_deep_documents_write() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Picture brightness and contrast (#1711) survive a Word 2003 XML save as VML `gain` and
|
||||
/// `blacklevel`; transparency has no place on `v:imagedata` and is dropped.
|
||||
#[test]
|
||||
fn picture_brightness_and_contrast_round_trip() {
|
||||
use wordcraft_doc::picfx::PicFx;
|
||||
let cases = [PicFx::new(70_000, -70_000, 0), PicFx::new(-30_000, 50_000, 40_000), PicFx::default()];
|
||||
let mut d = Document::new();
|
||||
let key = d.add_media(tiny_png(), "png");
|
||||
d.body = cases
|
||||
.iter()
|
||||
.map(|fx| {
|
||||
let mut p = Paragraph::new();
|
||||
let pic = InlineObject::Image {
|
||||
media: key.clone(),
|
||||
w: 30.0,
|
||||
h: 20.0,
|
||||
alt: "dot".into(),
|
||||
float: Default::default(),
|
||||
crop: [0.0; 4],
|
||||
fx: *fx,
|
||||
ole: None,
|
||||
};
|
||||
p.insert_object(0, pic, &CharProps::default()).unwrap();
|
||||
para_block(p)
|
||||
})
|
||||
.collect();
|
||||
let xml = write_wordml2003(&d).expect("write");
|
||||
let text = String::from_utf8_lossy(&xml);
|
||||
assert!(text.contains(r#"gain="19661f""#) && text.contains(r#"blacklevel="22938f""#), "Word's Washout values");
|
||||
let back = read_xml(&xml).expect("read");
|
||||
let got: Vec<PicFx> = paras(&back)
|
||||
.iter()
|
||||
.flat_map(|p| p.objects.iter())
|
||||
.filter_map(|o| if let InlineObject::Image { fx, .. } = o { Some(*fx) } else { None })
|
||||
.collect();
|
||||
assert_eq!(got.len(), cases.len());
|
||||
for (g, want) in got.iter().zip(cases) {
|
||||
let near = |a: i32, b: i32| (a - b).abs() <= 2;
|
||||
assert!(near(g.bright, want.bright) && near(g.contrast, want.contrast) && g.transparency == 0, "{g:?} vs {want:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_header_and_repeated_note_are_not_inlined_per_reference() {
|
||||
// #3652: one ~10 KB header shared by 1000 sections and one ~10 KB footnote referenced 500
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
//! A ragged equation matrix doesn't make the saved `document.xml` grow with rows × widest row
|
||||
//! (#3643): one wide row plus many empty rows used to be padded out to a full grid.
|
||||
|
||||
use wordcraft_doc::math::{MNode, MRun, Math};
|
||||
use wordcraft_doc::para::InlineObject;
|
||||
use wordcraft_doc::props::CharProps;
|
||||
use wordcraft_doc::{Document, Paragraph, para_block};
|
||||
|
||||
#[test]
|
||||
fn ragged_matrix_save_stays_small() {
|
||||
let n = 5000;
|
||||
let mut rows = vec![vec![vec![MNode::Run(MRun::new("a"))]; n]];
|
||||
rows.extend(std::iter::repeat_with(Vec::new).take(n));
|
||||
let math = Math { nodes: vec![MNode::Matrix { rows, col_jc: Vec::new() }], ..Default::default() };
|
||||
let mut p = Paragraph::new();
|
||||
p.insert_object(0, InlineObject::Equation { linear: String::new(), display: true, math }, &CharProps::default()).unwrap();
|
||||
let mut doc = Document::new();
|
||||
doc.body = vec![para_block(p)];
|
||||
let bytes = wordcraft_docx::write(&doc).unwrap();
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
|
||||
let size = zip.by_name("word/document.xml").unwrap().size();
|
||||
assert!(size < 1_000_000, "document.xml is {size} bytes");
|
||||
}
|
||||
@@ -44,15 +44,12 @@ getrandom = { workspace = true }
|
||||
# Language packs (CP4.3): downloaded, verified and unpacked by the suite's font store.
|
||||
craft-font-store = { workspace = true }
|
||||
craft-font-catalog = { workspace = true }
|
||||
# The local time zone's offset for Insert ▸ Date & Time and date fields (#1707); the web asks the browser's Date.
|
||||
chrono = { workspace = true, features = ["clock"] }
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
# O_NONBLOCK, so a path swapped for a named pipe can't block `open` (#3666).
|
||||
libc = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
wordcraft-doc = { workspace = true, features = ["test-support"] }
|
||||
encoding_rs = { workspace = true }
|
||||
zip = { workspace = true }
|
||||
cfb = { workspace = true }
|
||||
|
||||
@@ -548,59 +548,6 @@ pub fn now_iso() -> String {
|
||||
iso_from_unix_secs(now_unix())
|
||||
}
|
||||
|
||||
/// The local time zone's offset from UTC in seconds (east positive) at `unix`, daylight saving
|
||||
/// included: the system's zone, or the browser's on the web. 0 (UTC) when it can't be found.
|
||||
pub fn local_offset_secs(unix: u64) -> i64 {
|
||||
system_offset_secs(unix).clamp(-26 * 3600, 26 * 3600)
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn system_offset_secs(unix: u64) -> i64 {
|
||||
use chrono::Offset;
|
||||
let Some(utc) = i64::try_from(unix).ok().and_then(|secs| chrono::DateTime::from_timestamp(secs, 0)) else {
|
||||
return 0;
|
||||
};
|
||||
// chrono's `Local` has internal `expect`s (unreadable zone data, an unmapped Windows year), so
|
||||
// fence it: a failure means UTC, never a crash. A caught panic is still reported by the app's
|
||||
// panic hook (logged, no abort); the document is untouched.
|
||||
std::panic::catch_unwind(|| i64::from(utc.with_timezone(&chrono::Local).offset().fix().local_minus_utc())).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// On the web the browser's `Date` gives the offset directly (chrono isn't used there).
|
||||
#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
|
||||
fn system_offset_secs(unix: u64) -> i64 {
|
||||
let ms = (unix as f64) * 1000.0;
|
||||
if !ms.is_finite() {
|
||||
return 0;
|
||||
}
|
||||
// `getTimezoneOffset` is minutes *west* of UTC (UTC+13 gives -780); NaN for an invalid date.
|
||||
let west_min = js_sys::Date::new(&js_sys::wasm_bindgen::JsValue::from_f64(ms)).get_timezone_offset();
|
||||
if west_min.is_finite() && west_min.abs() <= 26.0 * 60.0 { -(west_min as i64) * 60 } else { 0 }
|
||||
}
|
||||
|
||||
/// Other wasm targets (WASI) have neither chrono's clock nor a browser: UTC.
|
||||
#[cfg(all(target_arch = "wasm32", not(target_os = "unknown")))]
|
||||
fn system_offset_secs(_unix: u64) -> i64 {
|
||||
0
|
||||
}
|
||||
|
||||
/// The local civil date and time now: see [`local_civil`].
|
||||
pub fn local_now() -> (i64, u32, u32, (u32, u32, u32), i64) {
|
||||
let now = now_unix();
|
||||
local_civil(now, local_offset_secs(now))
|
||||
}
|
||||
|
||||
/// Local civil date and time for an instant: (year, month, day, (hour, minute, second), days since
|
||||
/// 1970-01-01 of that local day, for the weekday). `offset_secs` is the local zone's offset from
|
||||
/// UTC (east positive). Saturating, so a hostile offset or clock never overflows.
|
||||
pub fn local_civil(unix: u64, offset_secs: i64) -> (i64, u32, u32, (u32, u32, u32), i64) {
|
||||
let local = i64::try_from(unix).unwrap_or(i64::MAX).saturating_add(offset_secs);
|
||||
let days = local.div_euclid(86_400);
|
||||
let t = local.rem_euclid(86_400) as u32;
|
||||
let (y, m, d) = civil_from_days(days);
|
||||
(y, m, d, (t / 3600, t / 60 % 60, t % 60), days)
|
||||
}
|
||||
|
||||
/// Days since 1970-01-01 → (year, month, day).
|
||||
///
|
||||
/// `NaiveDate` only covers about ±262143 years, but this is reached by the system clock (and by
|
||||
|
||||
@@ -72,7 +72,7 @@ const MAX_WRAPPED: usize = 10_000;
|
||||
/// A generated list (index, bibliography, table of figures or authorities) held as a complex
|
||||
/// field that starts at body block `i` and runs over its entries: its field code and the block
|
||||
/// it ends in. `None` for anything else (a TOC has its own update).
|
||||
fn wrapped_list(s: &Session, i: usize) -> Option<(String, usize, usize, usize)> {
|
||||
fn wrapped_list(s: &Session, i: usize) -> Option<(String, usize, usize)> {
|
||||
let p = s.doc.body.get(i)?.as_para()?;
|
||||
let (k, off, instr) = p.objects_with_offsets().enumerate().find_map(|(k, (off, o))| match o {
|
||||
InlineObject::FieldStart { instr, .. } => {
|
||||
@@ -97,7 +97,7 @@ fn wrapped_list(s: &Session, i: usize) -> Option<(String, usize, usize, usize)>
|
||||
InlineObject::FieldEnd => {
|
||||
depth = depth.saturating_sub(1);
|
||||
if depth == 0 {
|
||||
return Some((instr, off, j, at + wordcraft_doc::para::OBJ.len_utf8()));
|
||||
return Some((instr, j, at + wordcraft_doc::para::OBJ.len_utf8()));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -108,18 +108,13 @@ fn wrapped_list(s: &Session, i: usize) -> Option<(String, usize, usize, usize)>
|
||||
}
|
||||
|
||||
fn list_kind(p: &Paragraph) -> Option<String> {
|
||||
p.objects.iter().find_map(list_field)
|
||||
}
|
||||
|
||||
/// The field code of `o` when it is a generated list's field.
|
||||
fn list_field(o: &InlineObject) -> Option<String> {
|
||||
match o {
|
||||
p.objects.iter().find_map(|o| match o {
|
||||
InlineObject::Field { instr, .. } => {
|
||||
let t = instr.trim_start();
|
||||
(t.starts_with("BIBLIOGRAPHY") || t.starts_with("INDEX") || t.starts_with("TOA") || figures_label(t).is_some()).then(|| t.to_string())
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// The caption label (SEQ identifier) a table-of-figures field lists, `\c` or `\a`.
|
||||
@@ -203,15 +198,12 @@ pub fn update_generated(s: &mut Session) -> Result<(), CmdError> {
|
||||
// field's own paragraph, as WordCraft inserts one, its old entries rebuilt below.
|
||||
// What follows its closing field end, and its first paragraph's properties and section
|
||||
// break, are the document's and stay.
|
||||
// (The field it is made: what comes before it in its paragraph may hold other fields.)
|
||||
let mut converted = None;
|
||||
if let Some((instr, start, last, end)) = wrapped_list(s, i) {
|
||||
if let Some((instr, last, end)) = wrapped_list(s, i) {
|
||||
// The entries between its first and last paragraphs go.
|
||||
for _ in i.saturating_add(1)..last {
|
||||
s.doc.remove_block(StoryRef::Body, &Path::top(i + 1))?;
|
||||
}
|
||||
// Its first paragraph keeps what comes before the field (bookmarks, other fields)
|
||||
// and the field; its last (or, ending in its first, the
|
||||
// Its first paragraph keeps only the field; its last (or, ending in its first, the
|
||||
// same) loses what is up to and through the field end, and goes when nothing is left
|
||||
// of it and it ends no section.
|
||||
let first_end = if last > i {
|
||||
@@ -225,38 +217,28 @@ pub fn update_generated(s: &mut Session) -> Result<(), CmdError> {
|
||||
end
|
||||
};
|
||||
let p = s.doc.para_mut(StoryRef::Body, &Path::top(i))?;
|
||||
p.delete(start, first_end)?;
|
||||
converted = Some(instr.trim_start().to_string());
|
||||
p.delete(0, first_end)?;
|
||||
p.insert_object(
|
||||
start,
|
||||
0,
|
||||
InlineObject::Field { instr, result: String::new(), locked: false, dirty: false, code: false, form: String::new() },
|
||||
&Default::default(),
|
||||
)?;
|
||||
// What followed the field end in its own paragraph comes after the list, in a
|
||||
// paragraph of its own that takes the section break.
|
||||
let field = start + wordcraft_doc::para::OBJ.len_utf8();
|
||||
let field = wordcraft_doc::para::OBJ.len_utf8();
|
||||
if p.len() > field {
|
||||
let rest = p.split_off(field)?;
|
||||
s.doc.insert_block(StoryRef::Body, &Path::top(i + 1), Block::Para(rest))?;
|
||||
}
|
||||
// Another list's field before it would be the paragraph's list on later updates (and
|
||||
// have its shown list cleared with this one's): the list gets a paragraph of its own,
|
||||
// after it, and each is rebuilt in its turn.
|
||||
let p = s.doc.para_mut(StoryRef::Body, &Path::top(i))?;
|
||||
if p.objects_with_offsets().any(|(off, o)| off < start && list_field(o).is_some()) {
|
||||
let list = p.split_off(start)?;
|
||||
s.doc.insert_block(StoryRef::Body, &Path::top(i + 1), Block::Para(list))?;
|
||||
converted = None;
|
||||
}
|
||||
}
|
||||
let kind = converted.or_else(|| s.doc.body.get(i).and_then(|b| b.as_para()).and_then(list_kind));
|
||||
let kind = s.doc.body.get(i).and_then(|b| b.as_para()).and_then(list_kind);
|
||||
let Some(kind) = kind else {
|
||||
i += 1;
|
||||
continue;
|
||||
};
|
||||
add_entry_style(s);
|
||||
if kind.starts_with("INDEX") {
|
||||
crate::cmd::index::rebuild_from(s, i, None, kind)?;
|
||||
crate::cmd::index::rebuild(s, i, None)?;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
@@ -450,7 +432,7 @@ mod tests {
|
||||
p.insert_object(n, InlineObject::FieldEnd, &props).unwrap();
|
||||
}
|
||||
let s = Session::new(d);
|
||||
assert_eq!(wrapped_list(&s, 0).map(|(instr, _, last, _)| (instr, last)), Some(("BIBLIOGRAPHY".to_string(), 1)));
|
||||
assert_eq!(wrapped_list(&s, 0).map(|(instr, last, _)| (instr, last)), Some(("BIBLIOGRAPHY".to_string(), 1)));
|
||||
}
|
||||
|
||||
/// A wrapped list as a session holds it: the field starting paragraph `first`'s text, ending
|
||||
@@ -505,56 +487,4 @@ mod tests {
|
||||
assert_eq!(t.get(1).map(String::as_str), Some(" Then this"), "{t:?}");
|
||||
assert_eq!(t.last().map(String::as_str), Some("Below"));
|
||||
}
|
||||
|
||||
/// What comes before a wrapped list's field in its first paragraph (a bookmark, an empty
|
||||
/// `PAGE` field) stays there, before the rebuilt list's field.
|
||||
#[test]
|
||||
fn what_comes_before_a_wrapped_lists_field_stays() {
|
||||
let mut s = wrapped("Old one\nOld two\nBelow", 0, 1, "Old two".len());
|
||||
let props = CharProps::default();
|
||||
if let Ok(p) = s.doc.para_mut(StoryRef::Body, &Path::top(0)) {
|
||||
p.insert_object(0, InlineObject::FieldEnd, &props).unwrap();
|
||||
p.insert_object(0, InlineObject::FieldStart { instr: "PAGE".into(), locked: false, dirty: false }, &props).unwrap();
|
||||
p.insert_object(0, InlineObject::BookmarkStart { name: "Sources".into() }, &props).unwrap();
|
||||
}
|
||||
update_generated(&mut s).unwrap();
|
||||
let first = s.doc.body.first().and_then(|b| b.as_para());
|
||||
let kinds: Vec<&str> = first
|
||||
.map(|p| {
|
||||
p.objects
|
||||
.iter()
|
||||
.map(|o| match o {
|
||||
InlineObject::BookmarkStart { .. } => "bookmark",
|
||||
InlineObject::FieldStart { instr, .. } if instr == "PAGE" => "page",
|
||||
InlineObject::FieldEnd => "end",
|
||||
InlineObject::Field { instr, .. } if instr.starts_with("BIBLIOGRAPHY") => "list",
|
||||
_ => "other",
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
assert_eq!(kinds, ["bookmark", "page", "end", "list"]);
|
||||
assert_eq!(texts(&s), ["", "Below"]);
|
||||
}
|
||||
|
||||
/// A list field before a wrapped list's field in its paragraph doesn't take its place: the
|
||||
/// wrapped list is the one rebuilt.
|
||||
#[test]
|
||||
fn a_wrapped_list_is_rebuilt_as_itself_after_another_list_field() {
|
||||
let mut s = wrapped("Old one\nOld two\nBelow", 0, 1, "Old two".len());
|
||||
s.doc.sources.push(wordcraft_doc::Source { tag: "a".into(), kind: "book".into(), title: "Listed Source".into(), ..Default::default() });
|
||||
let index =
|
||||
InlineObject::Field { instr: "INDEX".into(), result: String::new(), locked: false, dirty: false, code: false, form: String::new() };
|
||||
if let Ok(p) = s.doc.para_mut(StoryRef::Body, &Path::top(0)) {
|
||||
p.insert_object(0, index, &CharProps::default()).unwrap();
|
||||
}
|
||||
// Updated again, each list stays itself.
|
||||
for _ in 0..2 {
|
||||
update_generated(&mut s).unwrap();
|
||||
let t = texts(&s);
|
||||
assert_eq!(t.iter().filter(|t| t.contains("Listed Source")).count(), 1, "the bibliography was rebuilt: {t:?}");
|
||||
let lists: Vec<Option<String>> = s.doc.body.iter().filter_map(|b| b.as_para()).filter_map(list_kind).map(Some).collect();
|
||||
assert_eq!(lists, [Some("INDEX".to_string()), Some("BIBLIOGRAPHY".to_string())]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,9 +26,6 @@ use super::ink_shape::{ArrowParts, InkShape, arrow_parts, pair_parts, recognise}
|
||||
use super::pos_json;
|
||||
use crate::{CmdError, CmdResult, CommandSpec, Session, p};
|
||||
|
||||
mod page_objects;
|
||||
use page_objects::PageObjects;
|
||||
|
||||
/// What dragging on the page does (Draw tab).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -328,13 +325,12 @@ impl PlacedInk {
|
||||
/// turn and geometry on the page.
|
||||
pub fn ink_on(s: &Session, layout: &DocLayout, page: usize) -> Vec<PlacedInk> {
|
||||
let Some(pg) = layout.pages.get(page) else { return Vec::new() };
|
||||
let mut objects = PageObjects::new(&s.doc);
|
||||
pg.items
|
||||
.iter()
|
||||
.filter_map(|it| match it {
|
||||
Placed::Object { rect, spin, story, path, off, .. } => {
|
||||
let pos = Pos { story: *story, path: path.clone(), off: *off };
|
||||
match objects.get(&pos) {
|
||||
match s.doc.para_at(&pos).and_then(|p| p.object_at(*off)) {
|
||||
Some(InlineObject::Shape { freeform: Some(f), stroke_width, stroke, .. }) if f.is_ink() => {
|
||||
Some(PlacedInk { pos, rect: *rect, spin: *spin, freeform: f.clone(), color: *stroke, width: *stroke_width })
|
||||
}
|
||||
@@ -581,11 +577,10 @@ fn lasso(s: &mut Session, v: &Value) -> CmdResult {
|
||||
return Err(CmdError::Params("a lasso loop needs at least three points".into()));
|
||||
}
|
||||
let mut items = Vec::new();
|
||||
let mut objects = PageObjects::new(&s.doc);
|
||||
for it in &pg.items {
|
||||
let Placed::Object { rect, spin, story, path, off, .. } = it else { continue };
|
||||
let pos = Pos { story: *story, path: path.clone(), off: *off };
|
||||
let Some(obj) = objects.get(&pos) else { continue };
|
||||
let Some(obj) = s.doc.para_at(&pos).and_then(|p| p.object_at(*off)) else { continue };
|
||||
let picked = match obj.ink() {
|
||||
Some(_) if s.view.hide_ink => false,
|
||||
Some(f) => {
|
||||
@@ -776,14 +771,13 @@ fn near(a: Rect, b: Rect) -> bool {
|
||||
fn arrow_pieces(s: &Session, layout: &DocLayout, page: usize, keep: impl Fn(&Pos, &Rect) -> bool) -> Vec<ArrowPiece> {
|
||||
let Some(pg) = layout.pages.get(page) else { return Vec::new() };
|
||||
let mut out = Vec::new();
|
||||
let mut objects = PageObjects::new(&s.doc);
|
||||
for it in &pg.items {
|
||||
let Placed::Object { rect, spin, story, path, off, .. } = it else { continue };
|
||||
let pos = Pos { story: *story, path: path.clone(), off: *off };
|
||||
if !spin.is_identity() || !keep(&pos, rect) {
|
||||
continue;
|
||||
}
|
||||
let Some(InlineObject::Shape { kind, freeform: Some(f), float, .. }) = objects.get(&pos) else { continue };
|
||||
let Some(InlineObject::Shape { kind, freeform: Some(f), float, .. }) = s.doc.para_at(&pos).and_then(|p| p.object_at(*off)) else { continue };
|
||||
let Some(fp) = f.paths.first().filter(|_| f.paths.len() == 1) else { continue };
|
||||
let pen = matches!(f.ink, Some(InkTool::Pen | InkTool::Pencil)) && !s.view.hide_ink;
|
||||
let shape = f.ink.is_none() && *kind == ShapeKind::Freeform && matches!((fp.pts.len(), fp.closed), (2, false) | (3, true));
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
//! Per-call object lookups for drawing-order page walks, which can revisit a paragraph's
|
||||
//! offsets out of text order. Scan only as far as the furthest requested object, once.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use wordcraft_doc::para::OBJ;
|
||||
use wordcraft_doc::{Document, InlineObject, Path, Pos, StoryRef};
|
||||
|
||||
#[derive(Default)]
|
||||
struct ParagraphObjects<'a> {
|
||||
scanned: usize,
|
||||
ordinal: usize,
|
||||
objects: Vec<(usize, &'a InlineObject)>,
|
||||
}
|
||||
|
||||
pub(super) struct PageObjects<'a> {
|
||||
doc: &'a Document,
|
||||
paragraphs: HashMap<(StoryRef, Path), ParagraphObjects<'a>>,
|
||||
}
|
||||
|
||||
impl<'a> PageObjects<'a> {
|
||||
pub(super) fn new(doc: &'a Document) -> Self {
|
||||
Self { doc, paragraphs: HashMap::new() }
|
||||
}
|
||||
|
||||
pub(super) fn get(&mut self, pos: &Pos) -> Option<&'a InlineObject> {
|
||||
let p = self.doc.para_at(pos)?;
|
||||
if !p.text.get(pos.off..)?.starts_with(OBJ) {
|
||||
return None;
|
||||
}
|
||||
let end = pos.off.checked_add(OBJ.len_utf8())?;
|
||||
let cached = self.paragraphs.entry((pos.story, pos.path.clone())).or_default();
|
||||
if end > cached.scanned {
|
||||
// Start after the last scanned marker, not at the paragraph's start. In particular,
|
||||
// a lone object near the start never scans a long text suffix it doesn't need.
|
||||
for (off, c) in p.text.get(cached.scanned..end)?.char_indices() {
|
||||
wordcraft_doc::work::steps(1);
|
||||
if c == OBJ {
|
||||
if let Some(object) = p.objects.get(cached.ordinal) {
|
||||
cached.objects.push((cached.scanned + off, object));
|
||||
}
|
||||
cached.ordinal += 1;
|
||||
}
|
||||
}
|
||||
cached.scanned = end;
|
||||
}
|
||||
let i = cached.objects.binary_search_by_key(&pos.off, |(off, _)| *off).ok()?;
|
||||
cached.objects.get(i).map(|(_, object)| *object)
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,6 @@ use wordcraft_doc::para::{InlineObject, ShapeKind, Wrap};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
mod page_lookup;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.run("document.setText", &json!({"text": "First paragraph of text.\nSecond paragraph of text."})).unwrap();
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
use super::super::{PlacedInk, ink_on};
|
||||
use crate::Session;
|
||||
use std::sync::Arc;
|
||||
use wordcraft_doc::freeform::{Freeform, InkTool};
|
||||
use wordcraft_doc::para::{Float, InlineObject, OBJ, ShapeKind, Wrap};
|
||||
use wordcraft_doc::{CharProps, Document, Paragraph, Path, Pos, StoryRef, para_block};
|
||||
use wordcraft_geom::{Rect, Spin};
|
||||
use wordcraft_layout::{DocLayout, Page, Placed};
|
||||
|
||||
fn stroke(order: u32) -> InlineObject {
|
||||
InlineObject::Shape {
|
||||
kind: ShapeKind::Freeform,
|
||||
w: 20.0,
|
||||
h: 10.0,
|
||||
fill: None,
|
||||
stroke: Some(wordcraft_doc::Rgb(12, 34, 56)),
|
||||
stroke_width: 2.0,
|
||||
float: Float { wrap: Wrap::InFrontOfText, ..Default::default() },
|
||||
story: None,
|
||||
freeform: Some(Arc::new(Freeform { order: Some(order), ..Freeform::ink(InkTool::Pen, 20.0, 10.0, vec![[1.0, 1.0], [19.0, 9.0]]) })),
|
||||
effects: Default::default(),
|
||||
extra: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn placed(story: StoryRef, path: Path, off: usize, x: f32) -> Placed {
|
||||
Placed::Object {
|
||||
rect: Rect::new(x, 30.0, 20.0, 10.0),
|
||||
spin: Spin::default(),
|
||||
story,
|
||||
path,
|
||||
off,
|
||||
text_box: None,
|
||||
wrap: Wrap::InFrontOfText,
|
||||
origin: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn orders(ink: &[PlacedInk]) -> Vec<u32> {
|
||||
ink.iter().map(|k| k.freeform.order.unwrap()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_ink_keeps_item_order_duplicates_story_paths_and_unmatched_storage() {
|
||||
let mut d = Document::new();
|
||||
let mut p = Paragraph::new();
|
||||
p.text = format!("é{OBJ}{OBJ}{OBJ}漢{OBJ}");
|
||||
p.runs = vec![wordcraft_doc::Run { len: p.text.len(), props: CharProps::default() }];
|
||||
p.objects = vec![InlineObject::BookmarkStart { name: "original".into() }, stroke(11), stroke(22)];
|
||||
d.body = vec![para_block(p.clone())];
|
||||
let h = d.add_part(wordcraft_doc::PartKind::Header, vec![para_block(p.clone())]);
|
||||
let mut table = wordcraft_doc::Table::new(1, 1, 100.0);
|
||||
p.objects.push(stroke(33));
|
||||
p.objects.push(stroke(99)); // Surplus storage has no character and is not placed.
|
||||
table.rows[0].cells[0].blocks = vec![para_block(p)];
|
||||
d.body.push(Arc::new(wordcraft_doc::Block::Table(table)));
|
||||
let cell = Path(vec![1, 0, 0, 0]);
|
||||
let mut items = vec![
|
||||
placed(StoryRef::Body, Path::top(0), 8, 50.0),
|
||||
placed(StoryRef::Part(h), Path::top(0), 5, 60.0),
|
||||
placed(StoryRef::Body, cell.clone(), 14, 70.0),
|
||||
placed(StoryRef::Body, Path::top(0), 5, 80.0),
|
||||
placed(StoryRef::Body, Path::top(0), 8, 90.0),
|
||||
];
|
||||
items.extend([0, 1, 2, 3, 4, 11, 14, usize::MAX].map(|off| placed(StoryRef::Body, Path::top(0), off, 0.0)));
|
||||
items.push(placed(StoryRef::Part(999), Path::top(0), 0, 0.0));
|
||||
let l = DocLayout { pages: vec![Page { items, ..Default::default() }], ..Default::default() };
|
||||
let mut s = Session::new(d);
|
||||
let original = s.doc.clone();
|
||||
let got = ink_on(&s, &l, 0);
|
||||
assert_eq!(orders(&got), [22, 11, 33, 11, 22]);
|
||||
assert_eq!(got.iter().map(|k| k.rect.x).collect::<Vec<_>>(), [50.0, 60.0, 70.0, 80.0, 90.0]);
|
||||
assert_eq!(got[1].pos, Pos::new(StoryRef::Part(h), Path::top(0), 5));
|
||||
assert_eq!(got[2].pos.path, cell);
|
||||
assert!(got.iter().all(|k| k.width == 2.0 && k.color == Some(wordcraft_doc::Rgb(12, 34, 56))));
|
||||
assert!(ink_on(&s, &l, 1).is_empty());
|
||||
s.view.hide_ink = true;
|
||||
assert_eq!(orders(&ink_on(&s, &l, 0)), orders(&got), "discovery retains hidden ink; callers decide visibility");
|
||||
assert_eq!(s.doc, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_ink_lookup_work_does_not_multiply_with_paragraph_objects() {
|
||||
fn fixture(n: usize) -> (Session, DocLayout) {
|
||||
let mut d = Document::new();
|
||||
let mut p = Paragraph::new();
|
||||
p.text = OBJ.to_string().repeat(n);
|
||||
p.runs = vec![wordcraft_doc::Run { len: p.text.len(), props: CharProps::default() }];
|
||||
p.objects = (0..n).map(|i| stroke(i as u32)).collect();
|
||||
d.body = vec![para_block(p)];
|
||||
let items = (0..n).map(|i| placed(StoryRef::Body, Path::top(0), i * OBJ.len_utf8(), 50.0)).collect();
|
||||
(Session::new(d), DocLayout { pages: vec![Page { items, ..Default::default() }], ..Default::default() })
|
||||
}
|
||||
let (small, a) = fixture(250);
|
||||
let (big, b) = fixture(2000);
|
||||
assert_eq!(ink_on(&small, &a, 0).len(), 250);
|
||||
assert_eq!(ink_on(&big, &b, 0).len(), 2000);
|
||||
// The characters the object lookups walk (`wordcraft_doc::work`), not a timing: about 8x for
|
||||
// 8x the objects when each paragraph is scanned once, about 64x when every lookup rescans it.
|
||||
let s1 = wordcraft_doc::work::count(|| drop(std::hint::black_box(ink_on(&small, &a, 0))));
|
||||
let s8 = wordcraft_doc::work::count(|| drop(std::hint::black_box(ink_on(&big, &b, 0))));
|
||||
let ratio = s8 as f64 / s1.max(1) as f64;
|
||||
eprintln!("page ink steps: {s1} -> {s8}, {ratio:.1}x for 8x objects");
|
||||
assert!(s1 > 0, "no steps counted, so the walk this test watches no longer counts them (wordcraft_doc::work)");
|
||||
assert!(ratio <= 12.0, "page ink lookup repeats paragraph-prefix work: {ratio:.1}x the steps for 8x objects ({s1} -> {s8})");
|
||||
}
|
||||
@@ -199,21 +199,16 @@ fn copy_with(s: &mut Session, whole: Option<&Path>) -> CmdResult {
|
||||
}
|
||||
|
||||
/// What Copy and Cut return: the clipboard's text, and the copy as HTML (equations as MathML,
|
||||
/// #816) and as RTF for the host to put on the system clipboard too, so other programs keep the
|
||||
/// formatting; and for a copy of one equation alone, its MathML (#1297).
|
||||
/// #816) for the host to put on the system clipboard too, so other programs keep the formatting;
|
||||
/// and for a copy of one equation alone, its MathML (#1297).
|
||||
pub(crate) fn copy_result(s: &mut Session) -> Value {
|
||||
(s.clipboard_html, s.clipboard_rtf) = match &s.clipboard {
|
||||
Some(f) => {
|
||||
let d = f.to_document();
|
||||
(wordcraft_formats::html::export_fragment(&d), wordcraft_formats::rtf::export(&d))
|
||||
}
|
||||
None => (String::new(), String::new()),
|
||||
s.clipboard_html = match &s.clipboard {
|
||||
Some(f) => wordcraft_formats::html::export_fragment(&f.to_document()),
|
||||
None => String::new(),
|
||||
};
|
||||
let mut r = json!({"text": s.clipboard_text});
|
||||
for (k, v) in [("html", &s.clipboard_html), ("rtf", &s.clipboard_rtf)] {
|
||||
if !v.is_empty() {
|
||||
r[k] = json!(v);
|
||||
}
|
||||
if !s.clipboard_html.is_empty() {
|
||||
r["html"] = json!(s.clipboard_html);
|
||||
}
|
||||
// A copy of one equation alone, as MathML for the clipboard formats equation editors read
|
||||
// ("MathML" on Windows, `public.mathml.presentation` on macOS; #1297). Word looks for them
|
||||
|
||||
@@ -260,12 +260,12 @@ fn open(s: &mut Session, v: &Value) -> CmdResult {
|
||||
if bytes.is_empty() {
|
||||
(normal_document(s), false)
|
||||
} else {
|
||||
crate::io::open_bytes_in(path, &bytes, password, &s.region()).map_err(CmdError::Failed)?
|
||||
crate::io::open_bytes_in(path, &bytes, password, &s.region(), &s.prefs.odf).map_err(CmdError::Failed)?
|
||||
}
|
||||
} else if crate::io::is_empty_file(std::path::Path::new(path)) {
|
||||
(normal_document(s), false)
|
||||
} else {
|
||||
crate::io::open_path_in(std::path::Path::new(path), password, &s.region()).map_err(CmdError::Failed)?
|
||||
crate::io::open_path_in(std::path::Path::new(path), password, &s.region(), &s.prefs.odf).map_err(CmdError::Failed)?
|
||||
};
|
||||
// Plain text and Markdown carry no language: they are in the editing language.
|
||||
let ext = std::path::Path::new(path).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("").to_ascii_lowercase();
|
||||
|
||||
@@ -265,12 +265,6 @@ pub fn rebuild(s: &mut Session, i: usize, leader: Option<TabLeader>) -> Result<(
|
||||
}) else {
|
||||
return Ok(());
|
||||
};
|
||||
rebuild_from(s, i, leader, instr)
|
||||
}
|
||||
|
||||
/// [`rebuild`] the index at body block `i` from field code `instr` (that of the index field in
|
||||
/// it, when the paragraph holds more than one).
|
||||
pub fn rebuild_from(s: &mut Session, i: usize, leader: Option<TabLeader>, instr: String) -> Result<(), CmdError> {
|
||||
let f = FieldCode::parse(&instr);
|
||||
// Old entries go; keep their leader and the section their columns had.
|
||||
let mut old_leader = None;
|
||||
|
||||
@@ -15,7 +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, drop_cap_state, format_date, format_date_at, format_date_parts};
|
||||
pub use items::{DropCapState, drop_cap_state, format_date, 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};
|
||||
@@ -124,7 +124,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("insert.pageField", "Insert Page Field", "Insert › Text › Quick Parts", |s, _| field(s, &json!({"instr": "PAGE"})))
|
||||
.key(super::mac_or("Ctrl+Shift+P", "Alt+Shift+P")),
|
||||
CommandSpec::new("insert.dropCap", "Drop Cap", "Insert › Text", drop_cap)
|
||||
.params(r#"{"position"?: "none"|"dropped"|"margin" (default dropped), "lines"?: 1..10 (default 3; 0 = none), "distance"?: pt from the text (0..1584, default 0), "font"?: family (omit for the text's font), "keep"?: bool (true: each selected paragraph that has a drop cap keeps its own lines, distance and font unless given here)}"#)
|
||||
.params(r#"{"position"?: "none"|"dropped"|"margin" (default dropped), "lines"?: 1..10 (default 3; 0 = none), "distance"?: pt from the text (0..1584, default 0), "font"?: family (omit for the text's font)}"#)
|
||||
.when(drop_cap_off),
|
||||
CommandSpec::new("insert.horizontalLine", "Horizontal Line", "Home › Paragraph › Borders", |s, _| {
|
||||
super::para::fmt(s, &|p| {
|
||||
|
||||
@@ -61,16 +61,14 @@ pub(super) fn date_time(s: &mut Session, v: &Value) -> CmdResult {
|
||||
|
||||
/// 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 {
|
||||
// Word shows the computer's local date and time (#1707), not UTC's.
|
||||
let now = crate::cmd::now_unix();
|
||||
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 {
|
||||
let (y, m, d, hms, days) = crate::cmd::local_civil(unix, offset_secs);
|
||||
format_date_parts(fmt, y, m as usize, d, hms, days)
|
||||
let iso = crate::cmd::now_iso();
|
||||
let y: i64 = iso.get(0..4).and_then(|x| x.parse().ok()).unwrap_or(2026);
|
||||
let m: usize = iso.get(5..7).and_then(|x| x.parse().ok()).unwrap_or(1);
|
||||
let d: u32 = iso.get(8..10).and_then(|x| x.parse().ok()).unwrap_or(1);
|
||||
let hh: u32 = iso.get(11..13).and_then(|x| x.parse().ok()).unwrap_or(0);
|
||||
let mm: u32 = iso.get(14..16).and_then(|x| x.parse().ok()).unwrap_or(0);
|
||||
let ss: u32 = iso.get(17..19).and_then(|x| x.parse().ok()).unwrap_or(0);
|
||||
format_date_parts(fmt, y, m, d, (hh, mm, ss), (crate::cmd::now_unix() / 86_400) as i64)
|
||||
}
|
||||
|
||||
/// Format a date and time with a Word picture; `days_since` (days since 1970-01-01) gives the weekday.
|
||||
@@ -241,16 +239,12 @@ pub(super) fn cover_page(s: &mut Session, v: &Value) -> CmdResult {
|
||||
}
|
||||
|
||||
/// Why Insert › Drop Cap is off at the caret, as in Word: in a table cell, in an empty paragraph
|
||||
/// and in one that starts with a space. A selection must be fit throughout (what Dropped and In
|
||||
/// margin need), unless a paragraph in it has a drop cap for None to take away.
|
||||
/// and in one that starts with a space.
|
||||
pub(super) fn drop_cap_off(s: &Session) -> Option<&'static str> {
|
||||
let why = drop_cap_selection_off(s)?;
|
||||
let story = s.sel.focus.story;
|
||||
let has_cap = crate::cmd::selected_paths(s).iter().any(|path| s.doc.para(story, path).is_some_and(|p| p.props.drop_cap.is_some()));
|
||||
(!has_cap).then_some(why)
|
||||
}
|
||||
|
||||
fn drop_cap_first_letter(first: Option<char>) -> Option<&'static str> {
|
||||
if s.sel.focus.path.cell().is_some() {
|
||||
return Some("drop caps can't go in a table");
|
||||
}
|
||||
let first = s.doc.para_at(&s.sel.focus).and_then(|p| p.text.chars().next());
|
||||
match first {
|
||||
None => Some("the paragraph is empty"),
|
||||
Some(c) if c.is_whitespace() => Some("the paragraph starts with a space"),
|
||||
@@ -258,21 +252,6 @@ fn drop_cap_first_letter(first: Option<char>) -> Option<&'static str> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The same check for every selected paragraph, as the command formats them all.
|
||||
fn drop_cap_selection_off(s: &Session) -> Option<&'static str> {
|
||||
let story = s.sel.focus.story;
|
||||
for path in crate::cmd::selected_paths(s) {
|
||||
if path.cell().is_some() {
|
||||
return Some("drop caps can't go in a table");
|
||||
}
|
||||
let first = s.doc.para(story, &path).and_then(|p| p.text.chars().next());
|
||||
if let Some(why) = drop_cap_first_letter(first) {
|
||||
return Some(why);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Insert › Drop Cap and Drop Cap Options: none, or the letter dropped into the text or hung in
|
||||
/// the margin, `lines` tall, `distance` points from the text, in `font` (unset: the text's).
|
||||
/// None takes all of it away, the letter's font with it.
|
||||
@@ -280,27 +259,15 @@ pub(super) fn drop_cap(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let lines = v.get("lines").and_then(Value::as_f64).filter(|l| l.is_finite());
|
||||
let none = p::str(v, "position") == Some("none") || lines == Some(0.0);
|
||||
let margin = p::str(v, "position") == Some("margin");
|
||||
let lines_given = lines.is_some();
|
||||
let lines = lines.map_or(3.0, |l| l.round().clamp(1.0, 10.0)) as u8;
|
||||
let distance = p::f32(v, "distance").map_or(0.0, |d| d.clamp(0.0, wordcraft_doc::resolve::MAX_DROP_CAP_DISTANCE));
|
||||
// A family name, not a paragraph of text.
|
||||
let font = p::str(v, "font").map(str::trim).filter(|f| !f.is_empty()).map(|f| f.chars().take(64).collect::<String>());
|
||||
if !none && let Some(why) = drop_cap_selection_off(s) {
|
||||
return Err(CmdError::Failed(why.into()));
|
||||
}
|
||||
// `keep` (the menu's Dropped / In margin): a paragraph that has a drop cap keeps its own
|
||||
// lines, distance and font unless given here.
|
||||
let keep = !none && v.get("keep").and_then(Value::as_bool) == Some(true);
|
||||
let (distance_given, font_given) = (p::f32(v, "distance").is_some(), v.get("font").is_some());
|
||||
crate::cmd::para::fmt(s, &|p| {
|
||||
let own = keep && p.drop_cap.is_some();
|
||||
p.drop_cap = (!none).then(|| if own && !lines_given { p.drop_cap.unwrap_or(lines) } else { lines });
|
||||
p.drop_cap = (!none).then_some(lines);
|
||||
p.drop_cap_margin = (!none && margin).then_some(true);
|
||||
let d = if own && !distance_given { p.drop_cap_distance.unwrap_or(0.0) } else { distance };
|
||||
p.drop_cap_distance = (!none && d > 0.0).then_some(d);
|
||||
if !(own && !font_given) {
|
||||
p.drop_cap_font = font.clone().filter(|_| !none);
|
||||
}
|
||||
p.drop_cap_distance = (!none && distance > 0.0).then_some(distance);
|
||||
p.drop_cap_font = font.clone().filter(|_| !none);
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -146,16 +146,7 @@ fn place_picture(s: &mut Session, bytes: Vec<u8>, width: Option<f32>, alt: &str)
|
||||
h *= max_w / w;
|
||||
w = max_w;
|
||||
}
|
||||
let obj = InlineObject::Image {
|
||||
media: key.clone(),
|
||||
w,
|
||||
h,
|
||||
alt: alt.to_string(),
|
||||
float: Float::default(),
|
||||
crop: [0.0; 4],
|
||||
fx: Default::default(),
|
||||
ole: None,
|
||||
};
|
||||
let obj = InlineObject::Image { media: key.clone(), w, h, alt: alt.to_string(), float: Float::default(), crop: [0.0; 4], ole: None };
|
||||
// A selected Drawing Canvas takes it in.
|
||||
if let Some(i) = crate::cmd::canvas::add_member(s, obj.clone())? {
|
||||
return Ok(json!({"media": key, "canvasMember": i}));
|
||||
|
||||
@@ -381,38 +381,3 @@ fn date_pictures() {
|
||||
assert_eq!(date_picture(r#"TIME \@ HH:mm"#).as_deref(), Some("HH:mm"));
|
||||
assert_eq!(date_picture("DATE"), None);
|
||||
}
|
||||
|
||||
/// #1707: Insert ▸ Date & Time and DATE/TIME fields use the local civil date, time and
|
||||
/// weekday, not UTC's.
|
||||
#[test]
|
||||
fn format_date_uses_the_local_offset() {
|
||||
// Noon on Saturday 31 October in New Zealand (UTC+13) is 2026-10-30T23:00:00Z.
|
||||
assert_eq!(format_date_at("dddd, MMMM d, yyyy h:mm am/pm", 1_793_401_200, 13 * 3600), "Saturday, October 31, 2026 12:00 PM");
|
||||
assert_eq!(format_date_at("M/d/yyyy H:mm", 1_793_498_400, -5 * 3600), "10/31/2026 21:00");
|
||||
assert_eq!(format_date_at("M/d/yyyy H:mm", 1_793_401_200, 0), "10/30/2026 23:00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_civil_date_follows_the_utc_offset() {
|
||||
let pic = "dddd d MMMM yyyy HH:mm";
|
||||
let show = |unix: u64, off: i64| {
|
||||
let (y, m, d, hms, days) = crate::cmd::local_civil(unix, off);
|
||||
format_date_parts(pic, y, m as usize, d, hms, days)
|
||||
};
|
||||
// 2026-10-30T23:00:00Z in New Zealand (UTC+13) is Saturday 31 October, noon.
|
||||
assert_eq!(show(1_793_401_200, 13 * 3600), "Saturday 31 October 2026 12:00");
|
||||
// 2026-11-01T02:00:00Z at UTC-5 is still Saturday 31 October, 21:00.
|
||||
assert_eq!(show(1_793_498_400, -5 * 3600), "Saturday 31 October 2026 21:00");
|
||||
// Offset 0 is UTC.
|
||||
assert_eq!(show(1_793_401_200, 0), "Friday 30 October 2026 23:00");
|
||||
// Near the epoch with a negative offset: the day before 1970, no overflow.
|
||||
assert_eq!(show(0, -3600), "Wednesday 31 December 1969 23:00");
|
||||
// Hostile values never panic.
|
||||
let _ = crate::cmd::local_civil(u64::MAX, i64::MAX);
|
||||
let _ = crate::cmd::local_civil(u64::MAX, i64::MIN);
|
||||
let _ = crate::cmd::local_civil(0, i64::MIN);
|
||||
// The machine's zone is a real offset (within ±26 h) and asking never panics.
|
||||
for unix in [0, 1_793_401_200, u64::MAX] {
|
||||
assert!(crate::cmd::local_offset_secs(unix).abs() <= 26 * 3600);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ use picture::{clear_background, grown, picture_style};
|
||||
pub(crate) use select::all_objects;
|
||||
pub(crate) use select::{edit_obj, has_shape_or_canvas};
|
||||
#[cfg(test)]
|
||||
use select::{has_group, holds_ink, obj_size, with_obj};
|
||||
use select::{has_group, holds_ink, obj_size};
|
||||
pub use select::{min_size, object_selection, picked_objects, selected, selected_all};
|
||||
#[cfg(test)]
|
||||
use shape::can_link;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use super::select::{obj_size, selected, with_obj};
|
||||
use super::{CmdError, CmdResult, InlineObject, Rgb, Session, Value, Wrap, p};
|
||||
use wordcraft_doc::picfx::PicFx;
|
||||
|
||||
/// `picture.crop`: the crop, and optionally the new display size, as one edit. With a size, a
|
||||
/// floating picture positioned by offsets moves by what the crop on its left/top side on the page
|
||||
@@ -149,33 +148,13 @@ pub fn fit_size(w: f32, h: f32, keep: bool) -> (f32, f32) {
|
||||
pub(super) fn adjust(s: &mut Session, f: impl Fn(image::DynamicImage) -> Result<image::DynamicImage, CmdError>) -> CmdResult {
|
||||
let (_, o) = selected(s).ok_or_else(|| CmdError::Disabled("no picture".into()))?;
|
||||
let InlineObject::Image { media, .. } = o else { return Err(CmdError::Disabled("not a picture".into())) };
|
||||
adjust_media(s, &media, |_| {}, f)
|
||||
}
|
||||
|
||||
/// The selected picture's pre-edit media key (itself when never edited).
|
||||
pub(super) fn original_media(s: &Session) -> Result<String, CmdError> {
|
||||
let (_, o) = selected(s).ok_or_else(|| CmdError::Disabled("no picture".into()))?;
|
||||
let InlineObject::Image { media, .. } = o else { return Err(CmdError::Disabled("not a picture".into())) };
|
||||
Ok(s.originals.get(&media).cloned().unwrap_or(media))
|
||||
}
|
||||
|
||||
/// Drops brightness and contrast read from the file: an edit baked them from the original.
|
||||
pub(super) fn clear_lum(fx: &mut PicFx) {
|
||||
fx.bright = 0;
|
||||
fx.contrast = 0;
|
||||
adjust_media(s, &media, f)
|
||||
}
|
||||
|
||||
/// Like [`adjust`], but decodes `base` (a media key, usually the pre-edit original) instead
|
||||
/// of the picture's current bitmap. The result still chains onto the originals map, so Reset
|
||||
/// Picture keeps working.
|
||||
/// `fx` changes the picture's brightness, contrast and transparency from the file (see
|
||||
/// [`PicFx`]) for an edit that bakes its own.
|
||||
pub(super) fn adjust_media(
|
||||
s: &mut Session,
|
||||
base: &str,
|
||||
fx: impl Fn(&mut PicFx),
|
||||
f: impl Fn(image::DynamicImage) -> Result<image::DynamicImage, CmdError>,
|
||||
) -> CmdResult {
|
||||
pub(super) fn adjust_media(s: &mut Session, base: &str, f: impl Fn(image::DynamicImage) -> Result<image::DynamicImage, CmdError>) -> CmdResult {
|
||||
let bytes = s.doc.media.get(base).cloned().ok_or_else(|| CmdError::Failed("picture data missing".into()))?;
|
||||
// Within the budget pictures are drawn within, checked from the header first (#3644, #3647).
|
||||
let img = wordcraft_geom::picture::decode(&bytes, wordcraft_geom::MAX_DECODED_BYTES).map_err(|e| match e {
|
||||
@@ -200,9 +179,8 @@ pub(super) fn adjust_media(
|
||||
.unwrap_or_else(|| cur.clone().unwrap_or_else(|| key.clone()));
|
||||
s.originals.entry(key.clone()).or_insert(orig);
|
||||
with_obj(s, |o| {
|
||||
if let InlineObject::Image { media, fx: pic_fx, .. } = o {
|
||||
if let InlineObject::Image { media, .. } = o {
|
||||
*media = key.clone();
|
||||
fx(pic_fx);
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -266,25 +244,19 @@ pub(super) fn relook(s: &mut Session, change: impl FnOnce(&mut PictureLook)) ->
|
||||
});
|
||||
}
|
||||
let l = look.clone();
|
||||
// Styles, borders and effects leave the file's brightness, contrast and transparency alone.
|
||||
let out = adjust_media(
|
||||
s,
|
||||
&look.base,
|
||||
|_| {},
|
||||
move |img| {
|
||||
let mut img = match &l.effect {
|
||||
Some(e) => artistic_effect(img, e),
|
||||
None => img,
|
||||
};
|
||||
if let Some(st) = &l.style {
|
||||
img = picture_style(img, st)?;
|
||||
}
|
||||
if let Some((c, w)) = l.border {
|
||||
img = frame(img, w, [c.0, c.1, c.2, 255])?;
|
||||
}
|
||||
Ok(img)
|
||||
},
|
||||
)?;
|
||||
let out = adjust_media(s, &look.base, move |img| {
|
||||
let mut img = match &l.effect {
|
||||
Some(e) => artistic_effect(img, e),
|
||||
None => img,
|
||||
};
|
||||
if let Some(st) = &l.style {
|
||||
img = picture_style(img, st)?;
|
||||
}
|
||||
if let Some((c, w)) = l.border {
|
||||
img = frame(img, w, [c.0, c.1, c.2, 255])?;
|
||||
}
|
||||
Ok(img)
|
||||
})?;
|
||||
if let Some((_, InlineObject::Image { media, .. })) = selected(s)
|
||||
&& let Some(drawn) = s.doc.media.get(&media).cloned()
|
||||
{
|
||||
@@ -327,13 +299,10 @@ pub(super) fn reset(s: &mut Session, _: &Value) -> CmdResult {
|
||||
let (_, o) = selected(s).ok_or_else(|| CmdError::Disabled("no picture".into()))?;
|
||||
let InlineObject::Image { media, .. } = o else { return Err(CmdError::Disabled("not a picture".into())) };
|
||||
let orig = s.originals.get(&media).cloned().unwrap_or(media);
|
||||
// Like Word, Reset Picture discards every correction, those read from the file (`fx`:
|
||||
// brightness, contrast, transparency, #1711, #1766) included.
|
||||
with_obj(s, |o| {
|
||||
if let InlineObject::Image { media, crop, fx, .. } = o {
|
||||
if let InlineObject::Image { media, crop, .. } = o {
|
||||
*media = orig.clone();
|
||||
*crop = [0.0; 4];
|
||||
*fx = PicFx::default();
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
use super::arrange::{MAX_OFFSET, bounds, copy_to, move_with_text, unalign};
|
||||
use super::group::{add_object, group, ungroup};
|
||||
use super::picture::{adjust, adjust_media, clear_lum, crop, original_media, recolor, relook, remove_background, reset, size};
|
||||
use super::picture::{adjust, adjust_media, crop, recolor, relook, remove_background, reset, size};
|
||||
use super::select::{
|
||||
all_objects, alt_text, can_group, edit_obj, has_floating, has_group, has_movable, has_object, has_picture, has_shape, has_shape_or_canvas,
|
||||
has_sizable, obj_size, objects_list, selected, with_float, with_obj,
|
||||
};
|
||||
use super::shape::{NO_TEXT_BOX, can_link, has_text_box, link_text_box, set_text_body, shape_effects, text_box_target};
|
||||
use super::{CmdError, CommandSpec, InlineObject, Pos, Rgb, Selection, Spin, TextVert, VAlign, Wrap, WrapText, json, p, sel_result};
|
||||
use wordcraft_doc::picfx::PicFx;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
@@ -37,9 +36,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let sharpen = p::f32(v, "sharpen").unwrap_or(0.0).clamp(-100.0, 100.0);
|
||||
// Absolute from the original (like transparency): re-setting all three to 0
|
||||
// restores the original pixels instead of stacking on the last pass.
|
||||
// The file's own brightness and contrast (`fx`) are replaced, not stacked on.
|
||||
let base = original_media(s)?;
|
||||
adjust_media(s, &base, clear_lum, move |img| {
|
||||
let (_, o) = selected(s).ok_or_else(|| CmdError::Disabled("no picture".into()))?;
|
||||
let InlineObject::Image { media, .. } = o else { return Err(CmdError::Disabled("not a picture".into())) };
|
||||
let base = s.originals.get(&media).cloned().unwrap_or(media.clone());
|
||||
adjust_media(s, &base, move |img| {
|
||||
let mut img = img;
|
||||
if b != 0.0 {
|
||||
img = image::DynamicImage::ImageRgba8(image::imageops::colorops::brighten(&img, (b * 2.55) as i32));
|
||||
@@ -62,11 +62,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let mode = p::str(v, "mode").unwrap_or("grayscale").to_string();
|
||||
let sat = p::f32(v, "saturation").unwrap_or(100.0);
|
||||
// Absolute from the original (like transparency): saturation 100 restores it.
|
||||
// Color and Corrections are independent, as in Word: only Washout, which Word stores
|
||||
// as brightness and contrast (`a:lum`), replaces the file's own (`fx`).
|
||||
let base = original_media(s)?;
|
||||
let fx: fn(&mut PicFx) = if mode == "washout" { clear_lum } else { |_| {} };
|
||||
adjust_media(s, &base, fx, move |img| Ok(recolor(img, &mode, sat)))
|
||||
let (_, o) = selected(s).ok_or_else(|| CmdError::Disabled("no picture".into()))?;
|
||||
let InlineObject::Image { media, .. } = o else { return Err(CmdError::Disabled("not a picture".into())) };
|
||||
let base = s.originals.get(&media).cloned().unwrap_or(media.clone());
|
||||
adjust_media(s, &base, move |img| Ok(recolor(img, &mode, sat)))
|
||||
})
|
||||
.params(r#"{"mode": "grayscale|sepia|washout|blackAndWhite|saturation|tint", "saturation"?: 0..400}"#)
|
||||
.when(has_picture),
|
||||
@@ -98,10 +97,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
// slider back and forth never fades what a previous pass already faded.
|
||||
// (Combining with other pixel edits then re-setting transparency drops those
|
||||
// edits; a full live-effect stack is future work.)
|
||||
// The file's own transparency (`fx`, `a:alphaModFix`) is replaced too: it's baked
|
||||
// into the pixels from here on.
|
||||
let base = original_media(s)?;
|
||||
adjust_media(s, &base, |fx: &mut PicFx| fx.transparency = 0, move |img| {
|
||||
let (_, o) = selected(s).ok_or_else(|| CmdError::Disabled("no picture".into()))?;
|
||||
let InlineObject::Image { media, .. } = o else { return Err(CmdError::Disabled("not a picture".into())) };
|
||||
let base = s.originals.get(&media).cloned().unwrap_or(media.clone());
|
||||
adjust_media(s, &base, move |img| {
|
||||
let mut rgba = img.to_rgba8();
|
||||
for px in rgba.pixels_mut() {
|
||||
px.0[3] = (px.0[3] as f32 * (1.0 - t / 100.0)) as u8;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use super::*;
|
||||
use wordcraft_doc::picfx::PicFx;
|
||||
|
||||
fn png(w: u32, h: u32) -> Vec<u8> {
|
||||
let img = image::RgbaImage::from_fn(w, h, |x, y| {
|
||||
@@ -455,108 +454,6 @@ fn transparency_is_absolute_from_the_original() {
|
||||
assert_eq!(rgba(&s), orig, "reset still finds the original");
|
||||
}
|
||||
|
||||
/// A picture opened with brightness, contrast and transparency from its file (`fx`, #1711,
|
||||
/// #1766): Transparency and Corrections are absolute and replace the file's values instead of
|
||||
/// stacking on them; Color leaves the file's brightness and contrast alone except Washout
|
||||
/// (itself stored as brightness and contrast); Reset Picture discards every correction, the
|
||||
/// file's included, as in Word.
|
||||
#[test]
|
||||
fn picture_edits_replace_the_file_corrections() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
let data = super::super::insert::base64_encode(&png(20, 10));
|
||||
s.run("insert.picture", &json!({"data": data})).unwrap();
|
||||
let imported = PicFx::new(80_000, -80_000, 50_000);
|
||||
let set_fx = |s: &mut Session, v: PicFx| {
|
||||
with_obj(s, |o| {
|
||||
if let InlineObject::Image { fx, .. } = o {
|
||||
*fx = v;
|
||||
}
|
||||
})
|
||||
.unwrap()
|
||||
};
|
||||
set_fx(&mut s, imported);
|
||||
let state = |s: &Session| {
|
||||
let (_, o) = selected(s).unwrap();
|
||||
let InlineObject::Image { media, fx, .. } = o else { panic!("a picture") };
|
||||
let bytes = s.doc.media.get(&media).cloned().unwrap();
|
||||
(media, fx, image::load_from_memory(&bytes).unwrap().to_rgba8().pixels().map(|p| p.0).collect::<Vec<_>>())
|
||||
};
|
||||
let (orig_media, _, orig) = state(&s);
|
||||
// Transparency 50%: 50% (not 75%), the file's transparency dropped, its lum kept.
|
||||
s.run("picture.transparency", &json!({"percent": 50})).unwrap();
|
||||
let (_, fx, px) = state(&s);
|
||||
assert_eq!(fx, PicFx::new(80_000, -80_000, 0));
|
||||
assert!(px.iter().all(|p| p[3] == 127));
|
||||
// 0%: opaque, no `a:alphaModFix` left to write.
|
||||
s.run("picture.transparency", &json!({"percent": 0})).unwrap();
|
||||
let (_, fx, px) = state(&s);
|
||||
assert_eq!((fx.transparency, px), (0, orig.clone()));
|
||||
// Color other than Washout keeps the file's lum; Washout replaces it.
|
||||
s.run("picture.color", &json!({"mode": "grayscale"})).unwrap();
|
||||
assert_eq!(state(&s).1, PicFx::new(80_000, -80_000, 0));
|
||||
s.run("picture.color", &json!({"mode": "saturation", "saturation": 100})).unwrap();
|
||||
assert_eq!(state(&s).1, PicFx::new(80_000, -80_000, 0));
|
||||
s.run("picture.color", &json!({"mode": "washout"})).unwrap();
|
||||
assert_eq!(state(&s).1, PicFx::default());
|
||||
// Corrections bake from the original and replace the file's lum.
|
||||
set_fx(&mut s, imported);
|
||||
s.run("picture.corrections", &json!({"brightness": 0})).unwrap();
|
||||
let (_, fx, px) = state(&s);
|
||||
assert_eq!((fx, px), (PicFx::new(0, 0, 50_000), orig.clone()));
|
||||
// Reset: the imported bitmap, no corrections at all.
|
||||
set_fx(&mut s, imported);
|
||||
s.run("picture.reset", &json!({})).unwrap();
|
||||
let (media, fx, px) = state(&s);
|
||||
assert_eq!((media, fx, px), (orig_media, PicFx::default(), orig));
|
||||
}
|
||||
|
||||
/// Two pictures showing the same media, one with Washout from its file and one without:
|
||||
/// edits on both bake to the same bytes, and resetting the plain one leaves it without
|
||||
/// corrections (it used to be able to pick up the other's).
|
||||
#[test]
|
||||
fn reset_never_borrows_another_pictures_corrections() {
|
||||
let mut d = wordcraft_doc::Document::new();
|
||||
let media = d.add_media(png(20, 10), "png");
|
||||
let washout = PicFx::new(70_000, -70_000, 0);
|
||||
let mut p = wordcraft_doc::Paragraph::new();
|
||||
for fx in [PicFx::default(), washout] {
|
||||
let pic = InlineObject::Image {
|
||||
media: media.clone(),
|
||||
w: 40.0,
|
||||
h: 20.0,
|
||||
alt: String::new(),
|
||||
float: Default::default(),
|
||||
crop: [0.0; 4],
|
||||
fx,
|
||||
ole: None,
|
||||
};
|
||||
p.insert_object(0, pic, &Default::default()).unwrap();
|
||||
}
|
||||
d.body = vec![wordcraft_doc::para_block(p)];
|
||||
let mut s = Session::new(d);
|
||||
let pick = |s: &mut Session, at: usize| {
|
||||
s.run("select.range", &json!({"anchor": {"block": 0, "off": at}, "focus": {"block": 0, "off": at + 3}})).unwrap();
|
||||
};
|
||||
let fx_at = |s: &mut Session, at: usize| {
|
||||
pick(s, at);
|
||||
match selected(s).unwrap().1 {
|
||||
InlineObject::Image { fx, media, .. } => (fx, media),
|
||||
o => panic!("{o:?}"),
|
||||
}
|
||||
};
|
||||
assert_eq!(fx_at(&mut s, 0).0, washout, "A");
|
||||
assert_eq!(fx_at(&mut s, 3).0, PicFx::default(), "B");
|
||||
for at in [0, 3] {
|
||||
pick(&mut s, at);
|
||||
s.run("picture.transparency", &json!({"percent": 30})).unwrap();
|
||||
}
|
||||
assert_eq!(fx_at(&mut s, 0).1, fx_at(&mut s, 3).1, "the same baked bytes");
|
||||
pick(&mut s, 3);
|
||||
s.run("picture.reset", &json!({})).unwrap();
|
||||
assert_eq!(fx_at(&mut s, 3), (PicFx::default(), media.clone()));
|
||||
assert_eq!(fx_at(&mut s, 0).0, washout, "A keeps its own until reset");
|
||||
}
|
||||
|
||||
/// #2052: a picture style picked after another replaces it (Thick Frame then Simple Frame is
|
||||
/// only the simple frame), a border replaces the border and keeps the style, an artistic
|
||||
/// effect replaces the effect, and transparency 50% then 10% is 10%, as in Word.
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{CmdError, CmdResult, CommandSpec, Prefs, Session, p};
|
||||
use wordcraft_doc::odf_profile::{OdfPreset, OdfProfile};
|
||||
|
||||
/// Options panes, in the order the page lists them (the `pane` param of `file.options`).
|
||||
pub const PANES: [&str; 8] = ["general", "display", "proofing", "save", "language", "accessibility", "advanced", "agents"];
|
||||
@@ -22,7 +23,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("file.options", "Options", "File", options)
|
||||
.params(
|
||||
r#"{"pane"?: "general|display|proofing|save|language|accessibility|advanced|agents", "author"?: string, "initials"?: string, "units"?: "inches|centimeters|millimeters|points|picas|auto" (auto: the region's), "region"?: string (a locale tag like pl-PL, saved: the paper File › New starts on, the default units and the decimal separator; auto: the system's), "typingReplacesSelection"?: bool, "insertKeyOvertype"?: bool, "overtype"?: bool, "marks"?: {"tabs"?: bool, "spaces"?: bool, "paragraphs"?: bool, "hidden"?: bool}, "checkSpelling"?: bool, "markGrammar"?: bool, "ignoreUppercase"?: bool, "ignoreNumbers"?: bool, "ignoreInternet"?: bool, "dictionariesFolder"?: string (a folder of Hunspell .aff/.dic files searched first, saved; auto: the system locations only), "userTemplatesFolder"?: string (the folder File › New lists personal templates from, saved; auto: the app's own Templates folder; not a network folder), "addWords"?: [string], "removeWords"?: [string]} — with any setting it changes them and opens nothing; without, it opens File › Options (at `pane`). Returns the settings."#,
|
||||
r#"{"pane"?: "general|display|proofing|save|language|accessibility|advanced|agents", "author"?: string, "initials"?: string, "units"?: "inches|centimeters|millimeters|points|picas|auto" (auto: the region's), "region"?: string (a locale tag like pl-PL, saved: the paper File › New starts on, the default units and the decimal separator; auto: the system's), "typingReplacesSelection"?: bool, "insertKeyOvertype"?: bool, "overtype"?: bool, "marks"?: {"tabs"?: bool, "spaces"?: bool, "paragraphs"?: bool, "hidden"?: bool}, "checkSpelling"?: bool, "markGrammar"?: bool, "ignoreUppercase"?: bool, "ignoreNumbers"?: bool, "ignoreInternet"?: bool, "dictionariesFolder"?: string (a folder of Hunspell .aff/.dic files searched first, saved; auto: the system locations only), "userTemplatesFolder"?: string (the folder File › New lists personal templates from, saved; auto: the app's own Templates folder; not a network folder), "addWords"?: [string], "removeWords"?: [string], "odf"?: {"preset"?: "word|libreoffice|custom" (word: Microsoft Word style, the default; libreoffice: LibreOffice style), "pageMargin"?: pt, "headerMinHeight"?: pt, "headerHeightIncludesSpacing"?: bool, "dynamicSpacing"?: bool, "tabsFromIndent"?: bool, "columnGap"?: pt, "shapeFill"?: "RRGGBB", "shapeLine"?: "RRGGBB", "hairline"?: pt, "pageFrameParagraphIsPage"?: bool, "inlineInvalidRelationIsBaseline"?: bool, "pictureLinesFitPictures"?: bool, "balanceSectionColumns"?: bool, "sumParagraphSpacing"?: bool, "widowControl"?: bool} (how OpenDocument files opened from now on fill in what their standard leaves open, docs/odf-interpretation.md; a value without a preset makes it custom)} — with any setting it changes them and opens nothing; without, it opens File › Options (at `pane`). Returns the settings."#,
|
||||
)
|
||||
.pure(),
|
||||
CommandSpec::new("text.overtype", "Overtype", "File › Options › Advanced", |s, v| {
|
||||
@@ -145,6 +146,7 @@ fn options(s: &mut Session, v: &Value) -> CmdResult {
|
||||
Some(_) => return Err(CmdError::Params("`marks` must be an object of true/false".into())),
|
||||
}
|
||||
let check_spelling = bool_param(v, "checkSpelling")?;
|
||||
let odf = odf_param(v, &s.prefs.odf)?;
|
||||
let add = words_param(v, "addWords")?;
|
||||
let remove = words_param(v, "removeWords")?;
|
||||
|
||||
@@ -157,6 +159,7 @@ fn options(s: &mut Session, v: &Value) -> CmdResult {
|
||||
|| !flag_values.is_empty()
|
||||
|| !mark_values.is_empty()
|
||||
|| check_spelling.is_some()
|
||||
|| odf.is_some()
|
||||
|| !add.is_empty()
|
||||
|| !remove.is_empty();
|
||||
if let Some(name) = author {
|
||||
@@ -188,6 +191,10 @@ fn options(s: &mut Session, v: &Value) -> CmdResult {
|
||||
s.view.proofing = b;
|
||||
s.prefs.check_spelling = b;
|
||||
}
|
||||
// ODF files opened from now on fill in what their standard leaves open this way.
|
||||
if let Some(o) = odf {
|
||||
s.prefs.odf = o;
|
||||
}
|
||||
for w in &add {
|
||||
wordcraft_proof::add_word(w);
|
||||
}
|
||||
@@ -233,6 +240,9 @@ pub fn current(s: &Session) -> Value {
|
||||
"dictionary": wordcraft_proof::user_dictionary(),
|
||||
"dictionariesFolder": pr.dictionaries_folder,
|
||||
"userTemplatesFolder": pr.user_templates_folder,
|
||||
// How ODF files fill in what the standard leaves open: the chosen preset, the values
|
||||
// that apply, and the custom values kept for when the preset is custom.
|
||||
"odf": {"preset": pr.odf.preset, "effective": pr.odf.effective(), "custom": pr.odf},
|
||||
"dictionaries": wordcraft_proof::installed_dictionaries()
|
||||
.into_iter()
|
||||
.map(|(lang, dic)| json!({"language": lang.tag(), "file": dic.map(|d| d.to_string_lossy().into_owned())}))
|
||||
@@ -240,6 +250,45 @@ pub fn current(s: &Session) -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
/// The `odf` setting: `{"preset"?: "word|libreoffice|custom" (word: Microsoft Word style, the default; libreoffice: LibreOffice style), …case values}` over `current`.
|
||||
/// A case value given without a preset makes the profile custom; the values are kept whatever
|
||||
/// the preset, so choosing custom again brings them back. Lengths are points, at most 22 inches;
|
||||
/// colours `RRGGBB`.
|
||||
fn odf_param(v: &Value, current: &OdfProfile) -> Result<Option<OdfProfile>, CmdError> {
|
||||
let Some(o) = v.get("odf").filter(|o| !o.is_null()) else { return Ok(None) };
|
||||
let obj = o.as_object().ok_or_else(|| CmdError::Params("`odf` must be an object".into()))?;
|
||||
let mut merged = serde_json::to_value(current).map_err(|e| CmdError::Failed(e.to_string()))?;
|
||||
let known: Vec<String> = merged.as_object().map(|m| m.keys().cloned().collect()).unwrap_or_default();
|
||||
let mut values = false;
|
||||
for (k, x) in obj {
|
||||
if !known.contains(k) {
|
||||
return Err(CmdError::Params(format!("`odf.{k}`: not a setting; one of {}", known.join(", "))));
|
||||
}
|
||||
if let Some(n) = x.as_f64().filter(|_| k != "preset")
|
||||
&& !(n.is_finite() && (0.0..=f64::from(wordcraft_doc::odf_profile::MAX_LENGTH)).contains(&n))
|
||||
{
|
||||
return Err(CmdError::Params(format!("`odf.{k}`: a length from 0 to {} points", wordcraft_doc::odf_profile::MAX_LENGTH)));
|
||||
}
|
||||
let x = if k == "preset" {
|
||||
let p = x.as_str().and_then(OdfPreset::from_id).ok_or_else(|| CmdError::Params("`odf.preset`: word, libreoffice or custom".into()))?;
|
||||
json!(p)
|
||||
} else {
|
||||
values = true;
|
||||
x.clone()
|
||||
};
|
||||
if let Some(m) = merged.as_object_mut() {
|
||||
m.insert(k.clone(), x);
|
||||
}
|
||||
}
|
||||
if values
|
||||
&& !obj.contains_key("preset")
|
||||
&& let Some(m) = merged.as_object_mut()
|
||||
{
|
||||
m.insert("preset".into(), json!(OdfPreset::Custom));
|
||||
}
|
||||
serde_json::from_value(merged).map(Some).map_err(|e| CmdError::Params(format!("`odf`: {e}")))
|
||||
}
|
||||
|
||||
/// A folder setting (`dictionariesFolder`, `userTemplatesFolder`): `auto` (or nothing) forgets
|
||||
/// it; otherwise an existing folder, given as an absolute path or one starting with `~/`. With
|
||||
/// `local_only`, a network folder is refused before anything looks at it (#2852): even checking
|
||||
|
||||
@@ -18,7 +18,7 @@ mod styles;
|
||||
mod tabs_borders;
|
||||
|
||||
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};
|
||||
pub use format::{fmt, indent, outdent};
|
||||
pub use lists::{ListButtons, list_buttons, manage_list, touch_all};
|
||||
use lists::{list, restart_numbering};
|
||||
pub(crate) use outline::{ensure_heading_style, set_heading};
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
use super::{Align, CmdError, CmdResult, LineSpacing, NumRef, ParaProps, Pos, Session, Value, json, p, sel_result};
|
||||
|
||||
/// Resolved paragraph props of the selection: those of the paragraph the caret is in, or of the
|
||||
/// last paragraph a selection covers when it ends at the next paragraph's start.
|
||||
pub fn cur(s: &Session) -> wordcraft_doc::resolve::ResolvedPara {
|
||||
/// Resolved paragraph props at the caret.
|
||||
pub(super) fn cur(s: &Session) -> wordcraft_doc::resolve::ResolvedPara {
|
||||
// A selection ending at the next paragraph's start doesn't format that paragraph (#418), so
|
||||
// its state comes from the last paragraph it does cover.
|
||||
let (a, b) = s.sel.ordered();
|
||||
@@ -123,16 +122,8 @@ pub(super) fn set_props(s: &mut Session, v: &Value) -> CmdResult {
|
||||
for path in crate::cmd::selected_paths(s) {
|
||||
let Some(para) = s.doc.para(story, &path) else { continue };
|
||||
let rtl = s.doc.styles.resolve_para(¶.props.clone().overlaid(&props)).bidi;
|
||||
let anchor_lock = props
|
||||
.frame
|
||||
.as_ref()
|
||||
.filter(|frame| frame.anchor_lock.is_none())
|
||||
.and_then(|_| para.props.frame.as_ref().and_then(|frame| frame.anchor_lock));
|
||||
let para = s.doc.para_mut(story, &path)?;
|
||||
para.props.overlay(&props);
|
||||
if let (Some(anchor_lock), Some(frame)) = (anchor_lock, para.props.frame.as_mut()) {
|
||||
frame.anchor_lock = Some(anchor_lock);
|
||||
}
|
||||
if let Some(a) = align {
|
||||
para.props.align = Some(a.visual(rtl));
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
|
||||
|
||||
mod fields;
|
||||
mod notes;
|
||||
mod numbers;
|
||||
mod properties;
|
||||
mod toc;
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use super::numbers::{body_bookmark_paths, para_numbers, ref_number};
|
||||
use super::properties::{
|
||||
bookmark_ends_in_its_paragraph, bookmark_text, bookmark_text_at, count_text, date_picture, doc_property, doc_property_arg, new_property_value,
|
||||
set_summary_property, summary_property, text_result,
|
||||
@@ -336,9 +335,6 @@ pub fn update_fields_in(s: &mut Session, all: bool) -> Result<(), CmdError> {
|
||||
// The document properties as the fields read and set them (`SUBJECT "New subject"`): a field
|
||||
// after one that sets a property shows the new value.
|
||||
let mut core = s.doc.core.clone();
|
||||
// Build each body lookup once, only when a supported numeric REF needs it.
|
||||
let mut numbers = None;
|
||||
let mut bookmark_paths = None;
|
||||
let paths: Vec<(StoryRef, Path)> = stories.into_iter().flat_map(|st| s.doc.para_paths(st).into_iter().map(move |p| (st, p))).collect();
|
||||
// SET <name> "<text>" (ECMA-376 §17.16.5.58): the values it gives, which REF <name> shows; a
|
||||
// REF before the SET shows the last value set, as Word's bookmark does after an update.
|
||||
@@ -378,14 +374,6 @@ pub fn update_fields_in(s: &mut Session, all: bool) -> Result<(), CmdError> {
|
||||
let r = if name == "REF" {
|
||||
if has_switch(instr, 'p') {
|
||||
None
|
||||
} else if let Some(sw) = ['w', 'r', 'n'].into_iter().find(|c| has_switch(instr, *c)) {
|
||||
if has_switch(instr, 'd') || has_switch(instr, 't') {
|
||||
continue; // Preserve cached results for unsupported delimiter/text switches.
|
||||
}
|
||||
let numbers = numbers.get_or_insert_with(|| para_numbers(&s.doc));
|
||||
let from = (story == StoryRef::Body).then_some(&path);
|
||||
let paths = bookmark_paths.get_or_insert_with(|| body_bookmark_paths(&s.doc));
|
||||
paths.get(target).and_then(|tp| ref_number(numbers, tp, from, sw))
|
||||
} else {
|
||||
bookmark_text(&s.doc, target).or_else(|| {
|
||||
let key = target.to_lowercase();
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
//! Numbered REF results, adapted from @Alyosha-dot-shop's patch in #4533.
|
||||
|
||||
use super::{Counters, NumFormat, Path, StoryRef};
|
||||
|
||||
/// What a numbered body paragraph shows: the counter value of each level down to its own, the
|
||||
/// label each of those levels shows at that point (`["14.", "(a)"]` for 14(a)), and the levels
|
||||
/// each label's template shows (`"%1.%2"` shows levels 0 and 1).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(super) struct ParaNumber {
|
||||
/// Relative context is shared only within the same numbering instance.
|
||||
list: u32,
|
||||
values: Vec<u32>,
|
||||
/// Whether each level has counted a paragraph yet: a level the list never used (an `(a)` item
|
||||
/// with no clause above it in its list) shows no number in a reference.
|
||||
started: Vec<bool>,
|
||||
labels: Vec<Option<String>>,
|
||||
shows: Vec<Vec<usize>>,
|
||||
}
|
||||
|
||||
/// Whether the body paragraph at `path` sits in a table cell merged into the one above
|
||||
/// (`w:vMerge` continue), at any nesting depth. The layout shows nothing of such a cell, so its
|
||||
/// paragraphs take no list number.
|
||||
fn in_merged_cell(doc: &wordcraft_doc::Document, path: &Path) -> bool {
|
||||
let p = &path.0;
|
||||
let mut j = 0usize;
|
||||
// [table, row, cell, block, row, cell, block, …]: a table at p[..=j], its row and cell next.
|
||||
while j.saturating_add(3) < p.len() {
|
||||
let (Some(tp), Some(&r), Some(&c)) = (p.get(..=j), p.get(j + 1), p.get(j + 2)) else { return false };
|
||||
let merged = doc
|
||||
.table(StoryRef::Body, &Path(tp.to_vec()))
|
||||
.and_then(|t| t.rows.get(r as usize))
|
||||
.and_then(|row| row.cells.get(c as usize))
|
||||
.is_some_and(|cell| cell.props.vmerge == wordcraft_doc::props::VMerge::Continue);
|
||||
if merged {
|
||||
return true;
|
||||
}
|
||||
j = j.saturating_add(3);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Every numbered body paragraph's number, by path, walking the lists in document order as the
|
||||
/// layout does (cells merged into the one above count nothing). Bullets have no number.
|
||||
pub(super) fn para_numbers(doc: &wordcraft_doc::Document) -> std::collections::HashMap<Path, ParaNumber> {
|
||||
let mut counters = Counters::default();
|
||||
let mut out = std::collections::HashMap::new();
|
||||
for path in doc.para_paths(StoryRef::Body) {
|
||||
if in_merged_cell(doc, &path) {
|
||||
continue;
|
||||
}
|
||||
let Some(p) = doc.para(StoryRef::Body, &path) else { continue };
|
||||
let Some(n) = p.props.numbering.or_else(|| doc.styles.resolve_para(&p.props).numbering).filter(|n| n.num != 0) else { continue };
|
||||
let Some((_, def)) = counters.next_label(&doc.numbering, n.num, n.level) else { continue };
|
||||
if def.format == NumFormat::Bullet {
|
||||
continue;
|
||||
}
|
||||
let level = usize::from(n.level.min(8));
|
||||
let values = counters.values(&doc.numbering, n.num).unwrap_or_default();
|
||||
let started = counters.started(&doc.numbering, n.num).unwrap_or_default();
|
||||
let mut num = ParaNumber { list: n.num, ..Default::default() };
|
||||
for k in 0..=level {
|
||||
num.values.push(values.get(k).copied().unwrap_or(0));
|
||||
num.started.push(started.get(k).copied().unwrap_or(false));
|
||||
num.labels.push(counters.current_label(&doc.numbering, n.num, k as u8));
|
||||
num.shows.push(counters.current_template_levels(&doc.numbering, n.num, k as u8));
|
||||
}
|
||||
out.insert(path, num);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A paragraph number as a cross-reference shows it: without the trailing period ("14." → "14"),
|
||||
/// keeping a closing bracket ("(a)").
|
||||
fn ref_part(label: &str) -> &str {
|
||||
label.trim().trim_end_matches('.').trim_end()
|
||||
}
|
||||
|
||||
/// The number `REF \n`, `\r` or `\w` shows for the bookmarked paragraph `target` (ECMA-376 Part 1
|
||||
/// §17.16.5.51), as Word computes it:
|
||||
/// - `\n` (no context): the paragraph's own label.
|
||||
/// - `\w` (full context): the own label, with the label of each level above it put in front when
|
||||
/// no label already in the result shows that level: under `%1.` / `%1.%2` / `(%3)`, clause (a)
|
||||
/// of 1.1 is `1.1(a)`, not `1.1.1(a)`.
|
||||
/// - `\r` (relative context): like `\w`, but leaving out the leading levels whose counters the
|
||||
/// field's own paragraph (`from`) shares with the target, compared level by level within the same list. From inside clause 14, 14(a) is `(a)`; from clause 5 or an unnumbered paragraph it
|
||||
/// is `14(a)`. Context already displayed by the own or a needed ancestor label stays.
|
||||
///
|
||||
/// A level above the target that its list never used adds nothing (Word shows `(a)` for an item
|
||||
/// whose list has no clause above it, not a made-up `1.1(a)`). `None` when the target paragraph
|
||||
/// has no number.
|
||||
pub(super) fn ref_number(numbers: &std::collections::HashMap<Path, ParaNumber>, target: &Path, from: Option<&Path>, switch: char) -> Option<String> {
|
||||
let t = numbers.get(target)?;
|
||||
let level = t.values.len().checked_sub(1)?;
|
||||
let own = t.labels.get(level)?.as_deref()?;
|
||||
let mut out = ref_part(own).to_string();
|
||||
if switch == 'n' {
|
||||
return Some(out);
|
||||
}
|
||||
let used = |k: usize| t.started.get(k).copied().unwrap_or(false);
|
||||
let first = match (switch, from.and_then(|p| numbers.get(p))) {
|
||||
('r', Some(f)) if f.list == t.list => {
|
||||
(0..level).take_while(|&k| !used(k) || (f.values.get(k).is_some() && f.values.get(k) == t.values.get(k))).count()
|
||||
}
|
||||
_ => 0,
|
||||
};
|
||||
// Relative context suppresses added ancestors, never text already rendered in a label.
|
||||
// Word's measured legal-template cases retain 2.2.2 even when 2 or 2.2 is shared.
|
||||
let mut shown = t.shows.get(level).cloned().unwrap_or_default();
|
||||
for k in (first..level).rev() {
|
||||
if shown.contains(&k) || !used(k) {
|
||||
continue;
|
||||
}
|
||||
if t.labels.get(k).is_some_and(Option::is_some) {
|
||||
let prefix = ref_part(t.labels.get(k)?.as_deref()?);
|
||||
// Without a delimiter, joining separate numeric labels would invent a number.
|
||||
if prefix.chars().next_back().is_some_and(char::is_alphanumeric) && out.chars().next().is_some_and(char::is_alphanumeric) {
|
||||
return None;
|
||||
}
|
||||
out.insert_str(0, prefix);
|
||||
shown.extend(t.shows.get(k).into_iter().flatten().copied());
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Index the first body paragraph for each bookmark name, retaining document-order lookup semantics.
|
||||
pub(super) fn body_bookmark_paths(doc: &wordcraft_doc::Document) -> std::collections::HashMap<String, Path> {
|
||||
let mut paths = std::collections::HashMap::new();
|
||||
for (name, pos) in doc.bookmarks() {
|
||||
if pos.story == StoryRef::Body {
|
||||
paths.entry(name).or_insert(pos.path);
|
||||
}
|
||||
}
|
||||
paths
|
||||
}
|
||||
@@ -4,16 +4,20 @@ pub(super) fn word_count(s: &mut Session, v: &Value) -> CmdResult {
|
||||
if let Some(b) = p::bool(v, "includeTextBoxes") {
|
||||
s.prefs.count_notes = b;
|
||||
}
|
||||
let stories = s.counted_stories();
|
||||
let stories = if s.prefs.count_notes { s.doc.counted_stories() } else { vec![StoryRef::Body] };
|
||||
// A selected picture, shape or text box isn't a text selection: count the document.
|
||||
let text = if s.sel.is_collapsed() || crate::cmd::objects::object_selection(s).is_some() { s.counted_text() } else { s.selected_text() };
|
||||
let text = if s.sel.is_collapsed() || crate::cmd::objects::object_selection(s).is_some() {
|
||||
stories.iter().map(|st| s.doc.plain_text(*st)).collect::<Vec<_>>().join("\n")
|
||||
} else {
|
||||
s.selected_text()
|
||||
};
|
||||
let words = if s.sel.is_collapsed() || crate::cmd::objects::object_selection(s).is_some() {
|
||||
stories.iter().map(|st| s.doc.story_word_count(*st)).sum()
|
||||
} else {
|
||||
wordcraft_doc::count_words(&text)
|
||||
};
|
||||
let chars = wordcraft_doc::count_chars(&text);
|
||||
let chars_no_spaces = wordcraft_doc::count_chars_no_spaces(&text);
|
||||
let chars = text.chars().filter(|c| *c != '\n' && *c != '\r').count();
|
||||
let chars_no_spaces = text.chars().filter(|c| !c.is_whitespace()).count();
|
||||
let paragraphs = text.split('\n').filter(|l| !l.trim().is_empty()).count();
|
||||
let lines: usize = {
|
||||
let l = s.layout();
|
||||
|
||||
@@ -24,12 +24,12 @@ pub const DEFAULT_H: f32 = 252.0;
|
||||
|
||||
const LAYOUTS: &str = "basicList|process|cycle|hierarchy|pyramid|radial|matrix|venn";
|
||||
const COLORS: &str = "accent1|accent2|accent3|accent4|accent5|accent6|colorful";
|
||||
const ITEMS: &str = r#"[string | {"text": string, "level"?: 0-7, "color"?: RRGGBB}] (a string's leading tabs are its level)"#;
|
||||
const ITEMS: &str = r#"[string | {"text": string, "level"?: 0-7}] (a string's leading tabs are its level)"#;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("insert.smartArt", "SmartArt", "Insert › Illustrations", insert).params(
|
||||
r#"{"layout"?: "basicList|process|cycle|hierarchy|pyramid|radial|matrix|venn", "items"?: [string | {"text": string, "level"?: 0-7, "color"?: RRGGBB}], "colors"?: "accent1…accent6|colorful", "width"?: pt, "height"?: pt} (no items: sample items for the layout)"#,
|
||||
r#"{"layout"?: "basicList|process|cycle|hierarchy|pyramid|radial|matrix|venn", "items"?: [string | {"text": string, "level"?: 0-7}], "colors"?: "accent1…accent6|colorful", "width"?: pt, "height"?: pt} (no items: sample items for the layout)"#,
|
||||
),
|
||||
CommandSpec::new("smartArt.items", "Edit Items", "SmartArt Design › Create Graphic", |s, v| {
|
||||
let items = items_of(v.get("items").ok_or_else(|| CmdError::Params(format!("`items` is required: {ITEMS}")))?)?;
|
||||
@@ -38,7 +38,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Ok(())
|
||||
})
|
||||
})
|
||||
.params(r#"{"items": [string | {"text": string, "level"?: 0-7, "color"?: RRGGBB}]} (replaces the outline)"#)
|
||||
.params(r#"{"items": [string | {"text": string, "level"?: 0-7}]} (replaces the outline)"#)
|
||||
.when(has_smart_art),
|
||||
CommandSpec::new("smartArt.textPane", "Text Pane", "SmartArt Design › Create Graphic", |s, v| {
|
||||
let on = p::bool(v, "value").unwrap_or(!s.view.smart_art_pane);
|
||||
@@ -124,36 +124,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let colors = colors_of(v)?.ok_or_else(|| CmdError::Params(format!("`variant` is required: {COLORS}")))?;
|
||||
edit(s, move |g| {
|
||||
g.colors = colors;
|
||||
g.clear_custom_colors();
|
||||
Ok(())
|
||||
})
|
||||
})
|
||||
.params(r#"{"variant": "accent1|accent2|accent3|accent4|accent5|accent6|colorful"} (the items' own colours are dropped)"#)
|
||||
.when(has_smart_art),
|
||||
CommandSpec::new("smartArt.itemColor", "Item Color", "SmartArt Design › SmartArt Styles", |s, v| {
|
||||
let i = index(v, "index")?;
|
||||
let color = match v.get("color") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::String(c)) => Some(Rgb::parse(c).ok_or_else(|| CmdError::Params("`color`: RRGGBB".into()))?),
|
||||
Some(_) => return Err(CmdError::Params("`color`: RRGGBB or null".into())),
|
||||
};
|
||||
edit(s, move |g| {
|
||||
let i = item_at(g, i)?;
|
||||
if g.style_accent(i).is_none() {
|
||||
return Err(CmdError::Failed("this item has no shape of its own: it is text in another item's shape".into()));
|
||||
}
|
||||
if let Some(it) = g.items.get_mut(i) {
|
||||
it.color = color;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
})
|
||||
.params(r#"{"index"?: item index (default: the last), "color"?: "RRGGBB" (none: the style's colour)}"#)
|
||||
.params(r#"{"variant": "accent1|accent2|accent3|accent4|accent5|accent6|colorful"}"#)
|
||||
.when(has_smart_art),
|
||||
CommandSpec::new("smartArt.reset", "Reset Graphic", "SmartArt Design › Reset", |s, _| {
|
||||
edit(s, |g| {
|
||||
g.colors = SmartArtColors::default();
|
||||
g.clear_custom_colors();
|
||||
Ok(())
|
||||
})
|
||||
})
|
||||
@@ -257,13 +235,7 @@ fn items_of(v: &Value) -> Result<Vec<SmartArtItem>, CmdError> {
|
||||
Some(other) => clean_text(&other.to_string()),
|
||||
};
|
||||
let level = p::u64(it, "level").unwrap_or(0).min(u64::from(MAX_LEVEL));
|
||||
let mut item = SmartArtItem::new(text, u8::try_from(level).unwrap_or(MAX_LEVEL));
|
||||
item.color = match it.get("color") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::String(c)) => Some(Rgb::parse(c).ok_or_else(|| CmdError::Params("`color`: RRGGBB".into()))?),
|
||||
Some(_) => return Err(CmdError::Params("`color`: RRGGBB or null".into())),
|
||||
};
|
||||
Ok(item)
|
||||
Ok(SmartArtItem::new(text, u8::try_from(level).unwrap_or(MAX_LEVEL)))
|
||||
}
|
||||
_ => Err(CmdError::Params(format!("`items`: {ITEMS}"))),
|
||||
})
|
||||
|
||||
@@ -924,33 +924,6 @@ mod tests {
|
||||
#[test]
|
||||
fn numbers_read_currency_and_separators_but_not_letters() {
|
||||
assert_eq!(parse_number("$1,200", None), Some(1200.0));
|
||||
assert_eq!(parse_number("B/. 1,234.50", None), Some(1234.5), "the Panamanian balboa");
|
||||
assert_eq!(parse_number("1,234.50 B/.", None), Some(1234.5));
|
||||
assert_eq!(parse_number("(B/. 1,234.50)", None), Some(-1234.5), "an accounting negative");
|
||||
assert_eq!(parse_number("B/.1,234.50", None), None, "Word needs a space after the sign");
|
||||
assert_eq!(parse_number("1/.5", None), None, "a fraction is not a balboa amount");
|
||||
// The formula reads the cell in the document's language. With the document's default text
|
||||
// as es-PA and the editing language as pl-PL the choice is observable: `1,250.50` is a
|
||||
// number under es-PA and not under pl-PL (whose decimal separator is `,`).
|
||||
let balboa = table(&[&["B/. 1,234.50"], &["(B/. 1,234.50)"], &[""]]);
|
||||
let mut doc = wordcraft_doc::Document::new();
|
||||
std::sync::Arc::make_mut(&mut doc.styles).default_chr.lang = Some("es-PA".into());
|
||||
assert_eq!(
|
||||
evaluate_with(&balboa, Some(&doc), Some("pl-PL"), 2, 0, "=SUM(ABOVE)"),
|
||||
Ok(0.0),
|
||||
"a balboa cell sums in the document's language, and its negative in parentheses cancels it",
|
||||
);
|
||||
assert_eq!(evaluate_with(&balboa, Some(&doc), Some("pl-PL"), 2, 0, "=COUNT(ABOVE)"), Ok(2.0), "both balboa cells are numbers",);
|
||||
// And the other way around: a balboa cell written with Polish separators is read only when
|
||||
// the document language reaches the parser, not when it falls back to English.
|
||||
let mut polish = wordcraft_doc::Document::new();
|
||||
std::sync::Arc::make_mut(&mut polish.styles).default_chr.lang = Some("pl-PL".into());
|
||||
let balboa_pl = table(&[&["B/. 1 250,50"], &[""]]);
|
||||
assert_eq!(
|
||||
evaluate_with(&balboa_pl, Some(&polish), Some("en-US"), 1, 0, "=SUM(ABOVE)"),
|
||||
Ok(1250.5),
|
||||
"the cell's language selects the separators; English does not read `1 250,50`",
|
||||
);
|
||||
assert_eq!(parse_number(" 12% ", None), Some(12.0));
|
||||
assert_eq!(parse_number("(5)", None), Some(-5.0));
|
||||
assert_eq!(parse_number("x9", None), None);
|
||||
|
||||
+41
-16
@@ -1,6 +1,7 @@
|
||||
//! File format dispatch by extension. Format crates plug in here.
|
||||
|
||||
use wordcraft_doc::Document;
|
||||
use wordcraft_doc::odf_profile::OdfProfile;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod safe_save;
|
||||
@@ -141,13 +142,13 @@ pub fn can_encrypt(name: &str) -> bool {
|
||||
/// Open a password-protected ODT package (ODF 1.3 Part 2 §3.4). The errors for a missing or
|
||||
/// wrong password are the `.docx` reader's texts, so [`needs_password`] and [`wrong_password`]
|
||||
/// recognise them.
|
||||
fn open_encrypted_odt(bytes: &[u8], password: Option<&str>, page: &wordcraft_doc::SectionProps) -> Result<Document, String> {
|
||||
fn open_encrypted_odt(bytes: &[u8], password: Option<&str>, page: &wordcraft_doc::SectionProps, odf: &OdfProfile) -> Result<Document, String> {
|
||||
let pw = password.ok_or_else(|| format!("ODT: {}", wordcraft_docx::PASSWORD_REQUIRED))?;
|
||||
let plain = wordcraft_formats::odt::decrypt(bytes, pw).map_err(|e| match e {
|
||||
wordcraft_formats::odt::CryptError::WrongPassword => format!("ODT: {}", wordcraft_docx::WRONG_PASSWORD),
|
||||
other => other.to_string(),
|
||||
})?;
|
||||
match crate::io_ext::open("odt", &plain, page) {
|
||||
match crate::io_ext::open("odt", &plain, page, odf) {
|
||||
Some(r) => r,
|
||||
None => Err("ODT: no reader".into()),
|
||||
}
|
||||
@@ -187,7 +188,7 @@ fn open_binary_doc(bytes: &[u8], password: Option<&str>) -> Result<(Document, bo
|
||||
/// whether the file was encrypted. Without the password needed the error satisfies
|
||||
/// [`needs_password`]; with a wrong one, [`wrong_password`].
|
||||
pub fn open_bytes_with(name: &str, bytes: &[u8], password: Option<&str>) -> Result<(Document, bool), String> {
|
||||
open_bytes_in(name, bytes, password, &wordcraft_geom::Region::default())
|
||||
open_bytes_in(name, bytes, password, &wordcraft_geom::Region::default(), &OdfProfile::default())
|
||||
}
|
||||
|
||||
/// The page a file that gives no page size (plain text, Markdown, HTML) opens on: `region`'s
|
||||
@@ -198,10 +199,17 @@ pub fn region_page(region: &wordcraft_geom::Region) -> wordcraft_doc::SectionPro
|
||||
}
|
||||
|
||||
/// [`open_bytes_with`] in `region`: a file that gives no page size opens on the region's paper
|
||||
/// (see [`region_page`]); a document with its own page size keeps it.
|
||||
pub fn open_bytes_in(name: &str, bytes: &[u8], password: Option<&str>, region: &wordcraft_geom::Region) -> Result<(Document, bool), String> {
|
||||
/// (see [`region_page`]); a document with its own page size keeps it. An OpenDocument file fills
|
||||
/// in what its standard leaves open as `odf` says (the user's choice, [`crate::Prefs::odf`]).
|
||||
pub fn open_bytes_in(
|
||||
name: &str,
|
||||
bytes: &[u8],
|
||||
password: Option<&str>,
|
||||
region: &wordcraft_geom::Region,
|
||||
odf: &OdfProfile,
|
||||
) -> Result<(Document, bool), String> {
|
||||
let page = region_page(region);
|
||||
let (doc, encrypted) = read_bytes(name, bytes, password, &page)?;
|
||||
let (doc, encrypted) = read_bytes(name, bytes, password, &page, odf)?;
|
||||
load_embedded_fonts(&doc);
|
||||
Ok((doc, encrypted))
|
||||
}
|
||||
@@ -219,7 +227,13 @@ fn load_embedded_fonts(doc: &Document) {
|
||||
}
|
||||
}
|
||||
|
||||
fn read_bytes(name: &str, bytes: &[u8], password: Option<&str>, page: &wordcraft_doc::SectionProps) -> Result<(Document, bool), String> {
|
||||
fn read_bytes(
|
||||
name: &str,
|
||||
bytes: &[u8],
|
||||
password: Option<&str>,
|
||||
page: &wordcraft_doc::SectionProps,
|
||||
odf: &OdfProfile,
|
||||
) -> Result<(Document, bool), String> {
|
||||
let ext = ext_of(name);
|
||||
if matches!(ext.as_str(), "docx" | "docm" | "dotx" | "dotm" | "doc" | "dot") && wordcraft_docx::is_encrypted(bytes) {
|
||||
let mut doc = read_docx(bytes, password).map_err(|e| e.to_string())?;
|
||||
@@ -227,7 +241,7 @@ fn read_bytes(name: &str, bytes: &[u8], password: Option<&str>, page: &wordcraft
|
||||
return Ok((doc, true));
|
||||
}
|
||||
if (ext == "odt" || ext == "ott") && wordcraft_formats::odt::is_encrypted(bytes) {
|
||||
let mut doc = open_encrypted_odt(bytes, password, page)?;
|
||||
let mut doc = open_encrypted_odt(bytes, password, page, odf)?;
|
||||
opened(&mut doc);
|
||||
return Ok((doc, true));
|
||||
}
|
||||
@@ -247,7 +261,7 @@ fn read_bytes(name: &str, bytes: &[u8], password: Option<&str>, page: &wordcraft
|
||||
d.normalize_runs();
|
||||
d
|
||||
}
|
||||
other => match crate::io_ext::open(other, bytes, page) {
|
||||
other => match crate::io_ext::open(other, bytes, page, odf) {
|
||||
Some(r) => r?,
|
||||
None => return open_by_content(name, other, bytes, password, page),
|
||||
},
|
||||
@@ -421,10 +435,11 @@ fn open_by_content(
|
||||
} else if is_word_xml(bytes) {
|
||||
(wordcraft_docx::read_xml(bytes).map_err(|e| format!("{name}: {e}"))?, false)
|
||||
} else if matches!(ext, "doc" | "dot" | "docx" | "docm" | "dotx" | "dotm" | "rtf") && looks_like_web_page(bytes) {
|
||||
let opened = crate::io_ext::open("html", bytes, page).unwrap_or_else(|| Err(format!("{name}: can't open web pages")));
|
||||
let opened = crate::io_ext::open("html", bytes, page, &OdfProfile::default()).unwrap_or_else(|| Err(format!("{name}: can't open web pages")));
|
||||
(opened?, false)
|
||||
} else if matches!(ext, "doc" | "dot" | "docx" | "docm" | "dotx" | "dotm" | "rtf") && wordcraft_formats::mht::is_mht(bytes) {
|
||||
let opened = crate::io_ext::open("mht", bytes, page).unwrap_or_else(|| Err(format!("{name}: can't open web archives")));
|
||||
let opened =
|
||||
crate::io_ext::open("mht", bytes, page, &OdfProfile::default()).unwrap_or_else(|| Err(format!("{name}: can't open web archives")));
|
||||
(opened?, false)
|
||||
} else if wordcraft_formats::txt::is_text(bytes) {
|
||||
(wordcraft_formats::txt::import_on(bytes, page), false)
|
||||
@@ -477,12 +492,17 @@ pub fn open_path(path: &std::path::Path) -> Result<Document, String> {
|
||||
/// [`open_path`] for a file that may be password-protected (see [`open_bytes_with`]).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn open_path_with(path: &std::path::Path, password: Option<&str>) -> Result<(Document, bool), String> {
|
||||
open_path_in(path, password, &wordcraft_geom::Region::default())
|
||||
open_path_in(path, password, &wordcraft_geom::Region::default(), &OdfProfile::default())
|
||||
}
|
||||
|
||||
/// [`open_path_with`] in `region` (see [`open_bytes_in`]).
|
||||
/// [`open_path_with`] in `region`, OpenDocument files as `odf` says (see [`open_bytes_in`]).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn open_path_in(path: &std::path::Path, password: Option<&str>, region: &wordcraft_geom::Region) -> Result<(Document, bool), String> {
|
||||
pub fn open_path_in(
|
||||
path: &std::path::Path,
|
||||
password: Option<&str>,
|
||||
region: &wordcraft_geom::Region,
|
||||
odf: &OdfProfile,
|
||||
) -> Result<(Document, bool), String> {
|
||||
let bytes = read_document(path)?;
|
||||
let name = path.to_string_lossy();
|
||||
if matches!(ext_of(&name).as_str(), "html" | "htm" | "xhtml") {
|
||||
@@ -494,7 +514,7 @@ pub fn open_path_in(path: &std::path::Path, password: Option<&str>, region: &wor
|
||||
opened(&mut doc);
|
||||
return Ok((doc, false));
|
||||
}
|
||||
open_bytes_in(&name, &bytes, password, region)
|
||||
open_bytes_in(&name, &bytes, password, region, odf)
|
||||
}
|
||||
|
||||
/// Largest document file opened by path.
|
||||
@@ -831,7 +851,12 @@ pub fn open_path_with(path: &std::path::Path, _password: Option<&str>) -> Result
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn open_path_in(path: &std::path::Path, password: Option<&str>, _region: &wordcraft_geom::Region) -> Result<(Document, bool), String> {
|
||||
pub fn open_path_in(
|
||||
path: &std::path::Path,
|
||||
password: Option<&str>,
|
||||
_region: &wordcraft_geom::Region,
|
||||
_odf: &OdfProfile,
|
||||
) -> Result<(Document, bool), String> {
|
||||
open_path_with(path, password)
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user