Compare commits

..
Author SHA1 Message Date
FredAppsandClaude Opus 5.5 d4fee0ef1d Refit credits tables on what they measure, not the fitted widths
Comparing the fitted widths missed the case it was meant for. Name is the
only growing column, so a longer name takes its extra width from the
spare room and the fit comes out the same: a dragged Name width stayed,
clipping the longer name.

It could also drop dragged widths by accident. The fit comes from the
available width inside the scroll area, which scrolling can nudge, so
scrolling a widened table could reset it.

The table now compares the natural widths and the clip rect's width,
which scrolling does not move.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 18:44:26 +01:00
FredAppsandClaude Opus 5.5 f691fe9f11 Let every credits column be dragged, and refit changed tables
The last column was egui's remainder column, which has no drag handle, so
it could only be resized from the edge before it. It is now an ordinary
column with its own handle.

A table keeps its column widths, so switching to a longer name left the
Name column at its old width and clipped the text. The table now goes
back to the opening fit whenever that fit changes: a longer widest cell
or a resized window. Dragged widths are dropped at that point.

fit_widths could overflow the room when one column hit the minimum,
because the other columns did not take up its share. Columns now give
way in proportion to their width above the minimum.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 17:18:18 +01:00
240 changed files with 1875 additions and 10163 deletions
+1 -20
View File
@@ -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)"
-1
View File
@@ -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
View File
@@ -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
View File
@@ -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" }
+1 -12
View File
@@ -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
View File
File diff suppressed because one or more lines are too long
+33 -7
View File
@@ -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, &region)?;
let (doc, encrypted) = wordcraft_engine::io::open_path_in(path, password, &region, &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", &params).map_err(|e| e.to_string())?;
+18 -16
View File
@@ -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);
}
+1 -3
View File
@@ -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
View File
@@ -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();
}
+2 -4
View File
@@ -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))]
+3 -6
View File
@@ -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();
+3 -7
View File
@@ -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
View File
@@ -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) {}
}
+3 -50
View File
@@ -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
View File
@@ -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]
+1 -11
View File
@@ -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"
+2 -3
View File
@@ -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]
-2
View File
@@ -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;
-2
View File
@@ -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
View File
@@ -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);
}
}
+2 -45
View File
@@ -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("&lt;", "<").replace("&gt;", ">").replace("&quot;", "\"").replace("&apos;", "'").replace("&amp;", "&")
}
#[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="N&#x61;me"/><w:ddList><w:listEntry w:val="A&#10;B&#9;C&#13;D&#x1F600;&amp;#10;&lt;&gt;&quot;&apos;"/></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😀&#10;<>\"'"));
let malformed = r#"<w:ffData><w:ddList><w:listEntry w:val="&#0;&#xD800;&#x110000;&#-1;&#xZZ;&unknown;&amp"/></w:ddList></w:ffData>"#;
assert_eq!(LegacyForm::parse(malformed).unwrap().shown_text(), Some("&#0;&#xD800;&#x110000;&#-1;&#xZZ;&unknown;&amp"));
}
#[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>"#);
+3 -4
View File
@@ -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)]
+2 -51
View File
@@ -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));
-6
View File
@@ -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
View File
@@ -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();
+255
View File
@@ -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);
}
}
+3 -3
View File
@@ -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.
-3
View File
@@ -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`].
-190
View File
@@ -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));
}
}
+1 -3
View File
@@ -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,
+2 -76
View File
@@ -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);
-8
View File
@@ -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),
-50
View File
@@ -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,
}
}
+3 -15
View File
@@ -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).
+1 -35
View File
@@ -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();
-25
View File
@@ -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
View File
@@ -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() {
+26 -9
View File
@@ -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");
}
}
-13
View File
@@ -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())),
-30
View File
@@ -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)
}
+1 -1
View File
@@ -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
View File
@@ -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: &paragraphs,
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),
+6 -9
View File
@@ -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
View File
@@ -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
);
}
-1
View File
@@ -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()));
-1
View File
@@ -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 }
+2 -23
View File
@@ -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) {
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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
+1 -4
View File
@@ -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.
+19 -83
View File
@@ -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);
-23
View File
@@ -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)
}
+2 -15
View File
@@ -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))]
}
+2 -6
View File
@@ -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", &[]);
-7
View File
@@ -287,9 +287,6 @@ pub fn ppr_inner(w: &mut W, p: &ParaProps, framed: bool, num_change: Option<&Cha
if let Some(a) = p.align {
w.val("w:jc", align_val(a));
}
if let Some(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);
}
+2 -16
View File
@@ -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]))]);
+8 -45
View File
@@ -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) {
+1 -10
View File
@@ -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)
-76
View File
@@ -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}");
}
}
-29
View File
@@ -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]);
}
}
-1
View File
@@ -22,7 +22,6 @@ fn picture() -> InlineObject {
alt: String::new(),
float: Default::default(),
crop: [0.0; 4],
fx: Default::default(),
ole: None,
}
}
-192
View File
@@ -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.
-3
View File
@@ -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;
+4 -41
View File
@@ -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 {
-24
View File
@@ -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(&paragraphs[0].props).adjust_right_indent);
assert!(d.styles.resolve_para(&paragraphs[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(&paragraphs[0].props).adjust_right_indent);
assert!(d.styles.resolve_para(&paragraphs[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}");
}
-64
View File
@@ -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);
}
-1
View File
@@ -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();
+1 -11
View File
@@ -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();
+1 -52
View File
@@ -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
-23
View File
@@ -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");
}
-3
View File
@@ -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 }
-53
View File
@@ -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
+12 -82
View File
@@ -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())]);
}
}
}
+3 -9
View File
@@ -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)
}
}
-2
View File
@@ -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})");
}
+7 -12
View File
@@ -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
+2 -2
View File
@@ -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();
-6
View File
@@ -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;
+2 -2
View File
@@ -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| {
+16 -49
View File
@@ -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);
})
}
+1 -10
View File
@@ -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}));
-35
View File
@@ -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);
}
}
+1 -1
View File
@@ -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;
+17 -48
View File
@@ -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();
}
})
}
+13 -14
View File
@@ -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;
-103
View File
@@ -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.
+50 -1
View File
@@ -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
+1 -1
View File
@@ -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};
+2 -11
View File
@@ -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(&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));
}
-1
View File
@@ -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();
-135
View File
@@ -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
}
+8 -4
View File
@@ -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();
+5 -33
View File
@@ -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}"))),
})
-27
View File
@@ -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
View File
@@ -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