mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-05 17:59:58 -06:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4fee0ef1d | ||
|
|
f691fe9f11 |
@@ -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
+21
-21
@@ -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",
|
||||
@@ -2960,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",
|
||||
@@ -6785,7 +6785,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft"
|
||||
version = "0.15.2"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"chrono",
|
||||
@@ -6820,7 +6820,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-cli"
|
||||
version = "0.15.2"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"sys-locale",
|
||||
@@ -6833,11 +6833,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-control-key"
|
||||
version = "0.15.2"
|
||||
version = "0.15.1"
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-doc"
|
||||
version = "0.15.2"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"log",
|
||||
@@ -6851,7 +6851,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-docbin"
|
||||
version = "0.15.2"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"cfb",
|
||||
"chrono",
|
||||
@@ -6868,7 +6868,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-docx"
|
||||
version = "0.15.2"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"base64 0.23.1",
|
||||
@@ -6892,7 +6892,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-engine"
|
||||
version = "0.15.2"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"cfb",
|
||||
"chrono",
|
||||
@@ -6925,7 +6925,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-fonts"
|
||||
version = "0.15.2"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"brotli-decompressor",
|
||||
"harfrust 0.13.3",
|
||||
@@ -6939,7 +6939,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-formats"
|
||||
version = "0.15.2"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"base64 0.23.1",
|
||||
@@ -6966,7 +6966,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-geom"
|
||||
version = "0.15.2"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"serde",
|
||||
@@ -6974,7 +6974,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-layout"
|
||||
version = "0.15.2"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"craft-proof",
|
||||
"image",
|
||||
@@ -6994,7 +6994,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-mcp"
|
||||
version = "0.15.2"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde_json",
|
||||
@@ -7006,7 +7006,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-metafile"
|
||||
version = "0.15.2"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"thiserror 2.0.21",
|
||||
@@ -7014,7 +7014,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-pdf"
|
||||
version = "0.15.2"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"craft-pdf",
|
||||
@@ -7035,7 +7035,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-render"
|
||||
version = "0.15.2"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -7049,7 +7049,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-ui-egui"
|
||||
version = "0.15.2"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"accesskit_consumer",
|
||||
"cfb",
|
||||
@@ -7082,7 +7082,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-web"
|
||||
version = "0.15.2"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -7101,7 +7101,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-zotero"
|
||||
version = "0.15.2"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -7264,7 +7264,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.15.2"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+3
-5
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/wordcraft", "apps/wordcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.15.2"
|
||||
version = "0.15.1"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -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
-10
File diff suppressed because one or more lines are too long
@@ -68,6 +68,10 @@ Paper: documents created here, and HTML, Markdown and text files (which give no
|
||||
the system region's paper (A4, or Letter in the US and other Letter regions), read from
|
||||
LC_ALL / LC_PAPER / LANG or the platform. Set WORDCRAFT_REGION=en-US (or another tag) for the
|
||||
same output on every machine.
|
||||
|
||||
OpenDocument (.odt, .ott, .fodt) files fill in what their standard leaves to the application in
|
||||
Microsoft Word's style (WORDCRAFT_ODF=word, the default) or LibreOffice's
|
||||
(WORDCRAFT_ODF=libreoffice); see docs/odf-interpretation.md.
|
||||
";
|
||||
|
||||
/// The subcommands, in `USAGE` order; `--help` or `-h` after any of them prints `USAGE` (#968).
|
||||
@@ -86,11 +90,18 @@ fn missing_argument() -> Failure {
|
||||
/// ([`password_from`]). As in the app, a document opened with one is saved with it again, so
|
||||
/// `convert` does not quietly write a copy the password no longer protects.
|
||||
fn open(path: &Path, password: Option<&str>) -> Result<Session, String> {
|
||||
open_in(path, password, odf_profile()?)
|
||||
}
|
||||
|
||||
/// [`open`] with the OpenDocument style `odf`, which the session keeps: a later `file.open` or
|
||||
/// `file.new` in a `run` script reads files the same way.
|
||||
fn open_in(path: &Path, password: Option<&str>, odf: wordcraft_doc::odf_profile::OdfProfile) -> Result<Session, String> {
|
||||
// A file that gives no page size (HTML, Markdown, plain text) opens on the system region's
|
||||
// paper, as in the app: A4 in Italy, Letter in the US (#1381).
|
||||
let region = system_region();
|
||||
let (doc, encrypted) = wordcraft_engine::io::open_path_in(path, password, ®ion)?;
|
||||
let (doc, encrypted) = wordcraft_engine::io::open_path_in(path, password, ®ion, &odf)?;
|
||||
let mut s = session(doc);
|
||||
s.prefs.odf = odf;
|
||||
s.path = Some(path.into());
|
||||
if encrypted {
|
||||
s.password = password.map(wordcraft_engine::io::Password::new);
|
||||
@@ -172,6 +183,24 @@ fn session(doc: wordcraft_doc::Document) -> Session {
|
||||
s
|
||||
}
|
||||
|
||||
/// How OpenDocument files fill in what their standard leaves open: `WORDCRAFT_ODF` names a preset
|
||||
/// (`word`, Microsoft Word's style and the default, or `libreoffice`; `wordcraft` is taken too).
|
||||
fn odf_profile() -> Result<wordcraft_doc::odf_profile::OdfProfile, String> {
|
||||
odf_from(std::env::var("WORDCRAFT_ODF").ok())
|
||||
}
|
||||
|
||||
/// [`odf_profile`] from `WORDCRAFT_ODF`'s value, if set.
|
||||
fn odf_from(var: Option<String>) -> Result<wordcraft_doc::odf_profile::OdfProfile, String> {
|
||||
use wordcraft_doc::odf_profile::{OdfPreset, OdfProfile};
|
||||
match var.filter(|v| !v.trim().is_empty()) {
|
||||
None => Ok(OdfProfile::default()),
|
||||
Some(v) => match OdfPreset::from_id(&v).filter(|p| *p != OdfPreset::Custom) {
|
||||
Some(p) => Ok(OdfProfile::of(p)),
|
||||
None => Err(format!("WORDCRAFT_ODF={v}: one of word, libreoffice")),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The system region, read as the app reads it (locale variables, else the platform's locale).
|
||||
fn system_region() -> wordcraft_engine::Region {
|
||||
wordcraft_engine::Region::from_locale_vars(|name| std::env::var(name).ok(), sys_locale::get_locale)
|
||||
@@ -504,6 +533,8 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
|
||||
// run starts with one open document.
|
||||
(None, t) => {
|
||||
let mut s = session(wordcraft_doc::Document::new());
|
||||
// The script's own `file.open`s read OpenDocument files in `WORDCRAFT_ODF`'s style.
|
||||
s.prefs.odf = odf_profile()?;
|
||||
let first = s.document_id();
|
||||
let params = t.map_or_else(|| json!({}), |t| json!({"template": t}));
|
||||
s.run("file.new", ¶ms).map_err(|e| e.to_string())?;
|
||||
|
||||
@@ -543,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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -189,6 +187,3 @@ sync_github_name = true
|
||||
|
||||
[people.dylanmtaylor]
|
||||
sync_github_name = true
|
||||
|
||||
[people.entropybeater]
|
||||
display_name = "entropybeater"
|
||||
|
||||
@@ -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
-85
@@ -263,9 +263,7 @@ impl KeptXml {
|
||||
}
|
||||
/// Whether `doc`'s page setup is still as it was read.
|
||||
pub fn pages_as_read(&self, doc: &crate::Document) -> bool {
|
||||
let Some(fp) = self.pages_fp else { return false };
|
||||
let now = in_scope(doc, |sc| *sc.pages.get_or_insert_with(|| pages_fingerprint(doc))).unwrap_or_else(|| pages_fingerprint(doc));
|
||||
fp == now
|
||||
self.pages_fp.is_some_and(|fp| fp == pages_fingerprint(doc))
|
||||
}
|
||||
/// Whether `doc`'s style definitions are still as they were read.
|
||||
pub fn styles_as_read(&self, doc: &crate::Document) -> bool {
|
||||
@@ -386,62 +384,11 @@ const MAX_REPLIES: usize = 10_000;
|
||||
/// What comment `root` is with its replies (and theirs): each one's author, date, state and
|
||||
/// text, and how many they are.
|
||||
pub fn comments_fingerprint(doc: &crate::Document, root: u32) -> (u64, usize) {
|
||||
family_fingerprint(doc, root, &scoped_replies(doc))
|
||||
}
|
||||
|
||||
/// What a writer asks of a document's kept markup for many paragraphs, worked out once while
|
||||
/// [`while_writing`] it: each comment's replies, and its page setup's fingerprint. Without it a
|
||||
/// document of C comments took C² steps (and its page setup serialized once per note) to save.
|
||||
struct Scope {
|
||||
/// The document's address: it can't move or change while borrowed for the writing.
|
||||
doc: usize,
|
||||
replies: Option<std::rc::Rc<HashMap<u32, Vec<u32>>>>,
|
||||
pages: Option<u64>,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static SCOPE: std::cell::RefCell<Option<Scope>> = const { std::cell::RefCell::new(None) };
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// How many times [`replies`] was built (tests).
|
||||
static REPLY_BUILDS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// Puts the scope before it back when dropped (when `f` returns or unwinds).
|
||||
struct Restore(Option<Scope>);
|
||||
|
||||
impl Drop for Restore {
|
||||
fn drop(&mut self) {
|
||||
let prev = self.0.take();
|
||||
SCOPE.with(|s| *s.borrow_mut() = prev);
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `f`, which writes `doc`, with what the kept markup asks of `doc` worked out at most once.
|
||||
pub fn while_writing<R>(doc: &crate::Document, f: impl FnOnce() -> R) -> R {
|
||||
let key = std::ptr::from_ref(doc) as usize;
|
||||
let prev = SCOPE.with(|s| s.replace(Some(Scope { doc: key, replies: None, pages: None })));
|
||||
let _restore = Restore(prev);
|
||||
f()
|
||||
}
|
||||
|
||||
/// The scope for `doc`, if it is being written: `f` of it.
|
||||
fn in_scope<R>(doc: &crate::Document, f: impl FnOnce(&mut Scope) -> R) -> Option<R> {
|
||||
let key = std::ptr::from_ref(doc) as usize;
|
||||
SCOPE.with(|s| s.borrow_mut().as_mut().filter(|sc| sc.doc == key).map(f))
|
||||
}
|
||||
|
||||
/// [`replies`], built once while `doc` is being written.
|
||||
fn scoped_replies(doc: &crate::Document) -> std::rc::Rc<HashMap<u32, Vec<u32>>> {
|
||||
in_scope(doc, |sc| sc.replies.get_or_insert_with(|| std::rc::Rc::new(replies(doc))).clone()).unwrap_or_else(|| std::rc::Rc::new(replies(doc)))
|
||||
family_fingerprint(doc, root, &replies(doc))
|
||||
}
|
||||
|
||||
/// Each comment's replies, by the comment's id.
|
||||
fn replies(doc: &crate::Document) -> HashMap<u32, Vec<u32>> {
|
||||
#[cfg(test)]
|
||||
REPLY_BUILDS.with(|n| n.set(n.get() + 1));
|
||||
let mut out: HashMap<u32, Vec<u32>> = HashMap::new();
|
||||
for (id, c) in &doc.comments {
|
||||
if let Some(p) = c.parent {
|
||||
@@ -747,34 +694,4 @@ mod tests {
|
||||
assert_eq!(k.group(p), Some((1, 1)));
|
||||
assert_eq!(k.story_fps.get(&id), Some(&story_fingerprint(&doc, id)));
|
||||
}
|
||||
|
||||
/// Writing a document checks each kept paragraph's comments against what was read: the
|
||||
/// comments' replies are looked up once for the whole document, not once per comment.
|
||||
#[test]
|
||||
fn writing_many_comments_looks_their_replies_up_once() {
|
||||
use crate::para::InlineObject;
|
||||
let n = 50u32;
|
||||
let text = (0..n).map(|i| format!("Para {i}")).collect::<Vec<_>>().join("\n");
|
||||
let mut doc = crate::Document::from_text(&text);
|
||||
for i in 0..n {
|
||||
let part = doc.add_part(crate::PartKind::Comment, vec![crate::para_block(Paragraph::with_text("Note", Default::default()))]);
|
||||
doc.comments.insert(i, crate::Comment { parent: None, part, ..Default::default() });
|
||||
doc.para_mut(crate::StoryRef::Body, &crate::Path::top(i as usize))
|
||||
.unwrap()
|
||||
.insert_object(0, InlineObject::CommentStart { id: i }, &Default::default())
|
||||
.unwrap();
|
||||
}
|
||||
let mut k = KeptXml { format: "odt".into(), ..Default::default() };
|
||||
for i in 0..n {
|
||||
k.comment_fps.insert(i, comments_fingerprint(&doc, i).0);
|
||||
}
|
||||
let xml = "<text:p><office:annotation>x</office:annotation></text:p>";
|
||||
let before = REPLY_BUILDS.with(std::cell::Cell::get);
|
||||
let all = while_writing(&doc, || doc.body.iter().filter_map(|b| b.as_para()).all(|p| k.comments_as_read(&doc, p, xml)));
|
||||
assert!(all, "every paragraph's comment is as read");
|
||||
assert_eq!(REPLY_BUILDS.with(std::cell::Cell::get) - before, 1);
|
||||
// Outside a writing, nothing is kept between calls.
|
||||
assert!(doc.body.first().and_then(|b| b.as_para()).is_some_and(|p| k.comments_as_read(&doc, p, xml)));
|
||||
assert_eq!(REPLY_BUILDS.with(std::cell::Cell::get) - before, 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,7 +86,7 @@ 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;
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
//! How WordCraft fills in what the OpenDocument standard leaves to the application.
|
||||
//!
|
||||
//! Where ODF 1.3 defines a value (directly, or by referring to XSL for an `fo:` property), that
|
||||
//! is the rule and nothing here changes it. Where it doesn't (ODF 1.3 Part 3 §16.2: "in all
|
||||
//! remaining cases an implementation-dependent value is used"), each application chooses, and
|
||||
//! documents from one application can look different in another. An [`OdfProfile`] is that
|
||||
//! choice for every such case: Microsoft Word's style ([`OdfPreset::Word`], the default: as
|
||||
//! WordCraft lays out Word documents), LibreOffice's style ([`OdfPreset::LibreOffice`]), or the
|
||||
//! user's own ([`OdfPreset::Custom`]). `docs/odf-interpretation.md` lists every case.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::props::Rgb;
|
||||
|
||||
/// Whose choices fill in what the standard leaves open.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum OdfPreset {
|
||||
/// Microsoft Word's style: the behaviour WordCraft has for its own and Word documents.
|
||||
/// (`wordcraft`, its earlier id, is still taken.)
|
||||
#[default]
|
||||
#[serde(alias = "wordcraft")]
|
||||
Word,
|
||||
/// LibreOffice's (and Collabora's, which share its engine).
|
||||
LibreOffice,
|
||||
/// The user's own choice for each case ([`OdfProfile`]'s values).
|
||||
Custom,
|
||||
}
|
||||
|
||||
impl OdfPreset {
|
||||
pub const ALL: [OdfPreset; 3] = [OdfPreset::Word, OdfPreset::LibreOffice, OdfPreset::Custom];
|
||||
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
OdfPreset::Word => "word",
|
||||
OdfPreset::LibreOffice => "libreoffice",
|
||||
OdfPreset::Custom => "custom",
|
||||
}
|
||||
}
|
||||
|
||||
/// The preset named `id` (any case): its id, or `wordcraft` / `msword` for Word's.
|
||||
pub fn from_id(id: &str) -> Option<OdfPreset> {
|
||||
let id = id.trim();
|
||||
if ["wordcraft", "msword", "ms-word"].iter().any(|a| a.eq_ignore_ascii_case(id)) {
|
||||
return Some(OdfPreset::Word);
|
||||
}
|
||||
OdfPreset::ALL.into_iter().find(|p| p.id().eq_ignore_ascii_case(id))
|
||||
}
|
||||
|
||||
/// The two styles a document can be shown in, as the user chooses between them (Custom
|
||||
/// aside).
|
||||
pub const STYLES: [OdfPreset; 2] = [OdfPreset::Word, OdfPreset::LibreOffice];
|
||||
|
||||
/// Its name as the user sees it (File › Options, English; the UI translates it).
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
OdfPreset::Word => "Microsoft Word style",
|
||||
OdfPreset::LibreOffice => "LibreOffice style",
|
||||
OdfPreset::Custom => "Custom",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The most a length in a profile may be, points (22 inches).
|
||||
pub const MAX_LENGTH: f32 = 1584.0;
|
||||
|
||||
/// The choice for each case the standard leaves open (see the module docs). With a preset other
|
||||
/// than [`OdfPreset::Custom`], the preset's values apply whatever these hold
|
||||
/// ([`OdfProfile::effective`]).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct OdfProfile {
|
||||
pub preset: OdfPreset,
|
||||
/// A page margin a page layout doesn't state, points. §20.206–20.209 refer to XSL's margins
|
||||
/// (§7.10), whose initial value is 0; whether that initial value comes with the reference
|
||||
/// the standard doesn't say (LibreOffice uses 2 cm).
|
||||
pub page_margin: f32,
|
||||
/// The height of a header or footer whose style states none (§20.212.2 `fo:min-height`
|
||||
/// has no default), points.
|
||||
pub header_min_height: f32,
|
||||
/// A header's or footer's minimum height takes in its spacing from the body (`fo:margin-*`
|
||||
/// of `style:header-footer-properties`; the standard doesn't say), rather than the spacing
|
||||
/// coming after it.
|
||||
pub header_height_includes_spacing: bool,
|
||||
/// What a header or footer whose style doesn't say does with its spacing when its content
|
||||
/// is taller than its height (§20.264 `style:dynamic-spacing` has no default): grows into it
|
||||
/// (`true`), or keeps it (`false`, both presets: with no height stated, growing into it would
|
||||
/// drop the spacing the file states).
|
||||
pub dynamic_spacing: bool,
|
||||
/// Paragraph tab stops are measured from the paragraph's indent rather than the margin when
|
||||
/// the document doesn't say (§19.512.3 `style:position` doesn't say from where; §19.861 `text:relative-tab-stop-position` says so only for indexes; LibreOffice's own
|
||||
/// `TabsRelativeToIndent` setting, in `settings.xml`, says so when present).
|
||||
pub tabs_from_indent: bool,
|
||||
/// The gap between columns whose `style:columns` states neither one nor each column
|
||||
/// (§19.233 `fo:column-gap` has no default), points.
|
||||
pub column_gap: f32,
|
||||
/// A drawing shape's fill colour when no style gives one (§20.119 `draw:fill-color` has no
|
||||
/// default).
|
||||
pub shape_fill: Rgb,
|
||||
/// A drawing shape's line colour when no style gives one (§20.408 `svg:stroke-color` has no default).
|
||||
pub shape_line: Rgb,
|
||||
/// A drawing shape's line width when no style gives one, and what a width of 0 draws at
|
||||
/// (§20.410 `svg:stroke-width` gives neither), points.
|
||||
pub hairline: f32,
|
||||
/// A page-anchored frame placed against a paragraph (a combination §20.398 and §20.299 don't
|
||||
/// define) is placed against the page (`true`), else against the page's text area.
|
||||
pub page_frame_paragraph_is_page: bool,
|
||||
/// An in-line frame placed against something an in-line frame can't be (a paragraph or a
|
||||
/// page, §20.398 Table 23) is placed against the baseline (`true`), else stands on it.
|
||||
pub inline_invalid_relation_is_baseline: bool,
|
||||
/// A line holding only in-line pictures is as tall as they are (`true`), else as tall as
|
||||
/// its text would be too (the standard defines no line-height algorithm).
|
||||
pub picture_lines_fit_pictures: bool,
|
||||
/// A columned section that doesn't say whether its columns are balanced (§20.428
|
||||
/// `text:dont-balance-text-columns` has no default, and its value descriptions contradict
|
||||
/// its name) ends with balanced columns at the end of the document too (`true`), else only
|
||||
/// when text follows on the same page, as in Word.
|
||||
pub balance_section_columns: bool,
|
||||
/// The space between two paragraphs is the first's space after plus the second's space
|
||||
/// before (`true`), else the larger of the two, when the document doesn't say (the
|
||||
/// standard doesn't; LibreOffice's own `AddParaTableSpacing` setting does when present).
|
||||
pub sum_paragraph_spacing: bool,
|
||||
/// Widow and orphan control when no style states `fo:widows` or `fo:orphans` (§20.312,
|
||||
/// §20.244 refer to XSL's, whose initial value is 2 lines; whether that initial value comes
|
||||
/// with the reference the standard doesn't say, and LibreOffice reads none stated as none).
|
||||
pub widow_control: bool,
|
||||
}
|
||||
|
||||
impl Default for OdfProfile {
|
||||
fn default() -> Self {
|
||||
OdfProfile::word()
|
||||
}
|
||||
}
|
||||
|
||||
impl OdfProfile {
|
||||
/// Microsoft Word's style: as WordCraft lays out its own and Word documents.
|
||||
pub fn word() -> OdfProfile {
|
||||
OdfProfile {
|
||||
preset: OdfPreset::Word,
|
||||
page_margin: 0.0,
|
||||
header_min_height: 0.0,
|
||||
header_height_includes_spacing: false,
|
||||
dynamic_spacing: false,
|
||||
tabs_from_indent: false,
|
||||
column_gap: 36.0,
|
||||
shape_fill: Rgb(0x15, 0x60, 0x82),
|
||||
shape_line: Rgb(0x0E, 0x40, 0x5A),
|
||||
hairline: 0.25,
|
||||
page_frame_paragraph_is_page: false,
|
||||
inline_invalid_relation_is_baseline: false,
|
||||
picture_lines_fit_pictures: false,
|
||||
balance_section_columns: false,
|
||||
sum_paragraph_spacing: false,
|
||||
widow_control: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// LibreOffice's choices, measured in LibreOffice 24 where it doesn't document them.
|
||||
pub fn libreoffice() -> OdfProfile {
|
||||
OdfProfile {
|
||||
preset: OdfPreset::LibreOffice,
|
||||
page_margin: 2.0 * 72.0 / 2.54,
|
||||
header_min_height: 0.5 * 72.0 / 2.54,
|
||||
header_height_includes_spacing: true,
|
||||
dynamic_spacing: false,
|
||||
tabs_from_indent: true,
|
||||
column_gap: 0.0,
|
||||
shape_fill: Rgb(0x72, 0x9F, 0xCF),
|
||||
shape_line: Rgb(0x34, 0x65, 0xA4),
|
||||
hairline: 0.5,
|
||||
page_frame_paragraph_is_page: true,
|
||||
inline_invalid_relation_is_baseline: true,
|
||||
picture_lines_fit_pictures: true,
|
||||
balance_section_columns: true,
|
||||
sum_paragraph_spacing: true,
|
||||
widow_control: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// A preset's values (a custom profile starts from Word's style).
|
||||
pub fn of(preset: OdfPreset) -> OdfProfile {
|
||||
match preset {
|
||||
OdfPreset::Word | OdfPreset::Custom => OdfProfile { preset, ..OdfProfile::word() },
|
||||
OdfPreset::LibreOffice => OdfProfile::libreoffice(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The values that apply: a preset's own, or, for a custom profile, these (lengths made
|
||||
/// finite and bounded).
|
||||
pub fn effective(&self) -> OdfProfile {
|
||||
let length = |v: f32, least: f32| if v.is_finite() { v.clamp(least, MAX_LENGTH) } else { least };
|
||||
match self.preset {
|
||||
OdfPreset::Custom => OdfProfile {
|
||||
page_margin: length(self.page_margin, 0.0),
|
||||
header_min_height: length(self.header_min_height, 0.0),
|
||||
column_gap: length(self.column_gap, 0.0),
|
||||
hairline: length(self.hairline, 0.05),
|
||||
..self.clone()
|
||||
},
|
||||
p => OdfProfile::of(p),
|
||||
}
|
||||
}
|
||||
|
||||
/// What the layout needs to know of it for a document opened from ODF.
|
||||
pub fn layout(&self) -> OdfLayout {
|
||||
let e = self.effective();
|
||||
OdfLayout { picture_lines_fit_pictures: e.picture_lines_fit_pictures, tabs_from_indent: e.tabs_from_indent }
|
||||
}
|
||||
}
|
||||
|
||||
/// A document opened from ODF, for the layout: rules of the standard that differ from Word's
|
||||
/// (a paragraph's padding takes room on a side without a border, XSL's box model) apply, and
|
||||
/// the profile's choices that are made as the document is laid out.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct OdfLayout {
|
||||
/// See [`OdfProfile::picture_lines_fit_pictures`].
|
||||
pub picture_lines_fit_pictures: bool,
|
||||
/// Tab stops are measured from the paragraph's left indent, default ones too (LibreOffice's
|
||||
/// `TabsRelativeToIndent`, else [`OdfProfile::tabs_from_indent`]). The document's explicit
|
||||
/// stops are held from the margin either way; the default ones are counted from the indent.
|
||||
pub tabs_from_indent: bool,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn presets_and_custom_values() {
|
||||
assert_eq!(OdfProfile::default().preset, OdfPreset::Word);
|
||||
assert_eq!(OdfPreset::from_id(" LibreOffice "), Some(OdfPreset::LibreOffice));
|
||||
assert_eq!(OdfPreset::from_id("google"), None);
|
||||
// Word's style by its id, and by the id it had before.
|
||||
for id in ["word", "Word", "wordcraft", "MSWord", "ms-word"] {
|
||||
assert_eq!(OdfPreset::from_id(id), Some(OdfPreset::Word), "{id}");
|
||||
}
|
||||
assert_eq!(serde_json::from_value::<OdfPreset>(serde_json::json!("wordcraft")).unwrap(), OdfPreset::Word);
|
||||
assert_eq!(serde_json::from_value::<OdfPreset>(serde_json::json!("word")).unwrap(), OdfPreset::Word);
|
||||
assert_eq!(OdfPreset::STYLES.map(OdfPreset::label), ["Microsoft Word style", "LibreOffice style"]);
|
||||
// Saved and taken by `file.options` as its id.
|
||||
for p in OdfPreset::ALL {
|
||||
assert_eq!(serde_json::to_value(p).unwrap(), serde_json::json!(p.id()));
|
||||
}
|
||||
// A preset's values apply whatever the profile holds.
|
||||
let lo = OdfProfile { column_gap: 99.0, ..OdfProfile::libreoffice() };
|
||||
assert_eq!(lo.effective(), OdfProfile::libreoffice());
|
||||
// A custom profile's own, bounded.
|
||||
let custom = OdfProfile { preset: OdfPreset::Custom, column_gap: f32::NAN, header_min_height: 1e9, ..OdfProfile::word() };
|
||||
let e = custom.effective();
|
||||
assert_eq!((e.column_gap, e.header_min_height), (0.0, MAX_LENGTH));
|
||||
assert!(OdfProfile::libreoffice().layout().picture_lines_fit_pictures);
|
||||
assert!(!OdfProfile::word().layout().picture_lines_fit_pictures);
|
||||
}
|
||||
}
|
||||
@@ -231,7 +231,7 @@ impl Paragraph {
|
||||
|
||||
/// Number of objects in `text[..off]`.
|
||||
fn objects_before(&self, off: usize) -> usize {
|
||||
self.text.get(..off).map(|t| t.chars().inspect(|_| crate::work::steps(1)).filter(|c| *c == OBJ).count()).unwrap_or(0)
|
||||
self.text.get(..off).map(|t| t.chars().filter(|c| *c == OBJ).count()).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// The object at byte `off` (which must hold U+FFFC).
|
||||
@@ -250,13 +250,13 @@ impl Paragraph {
|
||||
}
|
||||
/// Byte offsets of all objects, in order.
|
||||
pub fn object_offsets(&self) -> Vec<usize> {
|
||||
self.text.char_indices().inspect(|_| crate::work::steps(1)).filter(|(_, c)| *c == OBJ).map(|(i, _)| i).collect()
|
||||
self.text.char_indices().filter(|(_, c)| *c == OBJ).map(|(i, _)| i).collect()
|
||||
}
|
||||
/// (byte offset, object) for every object, in text order, in one pass. Prefer it to
|
||||
/// [`Paragraph::object_offsets`] + [`Paragraph::object_at`], which counts the objects before
|
||||
/// each offset again, so a loop over a paragraph's objects is quadratic (#3614).
|
||||
pub fn objects_with_offsets(&self) -> impl Iterator<Item = (usize, &InlineObject)> + '_ {
|
||||
self.text.char_indices().inspect(|_| crate::work::steps(1)).filter(|(_, c)| *c == OBJ).map(|(i, _)| i).zip(self.objects.iter())
|
||||
self.text.char_indices().filter(|(_, c)| *c == OBJ).map(|(i, _)| i).zip(self.objects.iter())
|
||||
}
|
||||
|
||||
/// Insert plain text (U+FFFC is dropped) at `off` with `props`. Returns the inserted length.
|
||||
|
||||
@@ -19,9 +19,6 @@ pub enum InlineObject {
|
||||
/// Crop (left, top, right, bottom) fractions 0..1.
|
||||
#[serde(default)]
|
||||
crop: [f32; 4],
|
||||
/// Brightness, contrast and transparency read from the file (see [`crate::picfx`]).
|
||||
#[serde(default, skip_serializing_if = "crate::picfx::PicFx::is_identity")]
|
||||
fx: crate::picfx::PicFx,
|
||||
/// The OLE object (embedded or linked file) or Word signature line this picture shows, as
|
||||
/// read from a file: saving writes the object back, not only its picture. See
|
||||
/// [`crate::graphic::Embedded`].
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
//! Picture colour corrections read from a file: brightness, contrast and transparency
|
||||
//! (DrawingML `a:lum` and `a:alphaModFix` inside `a:blip`, ECMA-376 §20.1.8.42 and §20.1.8.4;
|
||||
//! VML `v:imagedata` `gain` and `blacklevel`, which Word's picture watermark "Washout" sets).
|
||||
//!
|
||||
//! Values are kept in OOXML units: thousandths of a percent, so 100 000 is 100 %. All zero is
|
||||
//! the picture as it is.
|
||||
//!
|
||||
//! The pixel formula was measured on Word (#1711, #1766): Word bakes the corrected pixels into
|
||||
//! the picture it embeds in a PDF export, so a synthetic grey and colour ramp exported with
|
||||
//! 20 `a:lum` combinations and 6 VML `gain`/`blacklevel` pairs gives the curve. Every channel is
|
||||
//! mapped on its own (no hue or luminance coupling) by one straight line, clamped to 0..=255
|
||||
//! only at the end:
|
||||
//!
|
||||
//! `y = s·(x − 127.5) + 127.5 + 127.5·b·(1 + s)`, with `b = bright / 100 000`, `c = contrast /
|
||||
//! 100 000`, `s = 1 + c` when `c ≤ 0` and `s = 1 / (1 − c)` when `c > 0`
|
||||
//!
|
||||
//! (as if half the brightness shift were applied before the contrast and half after). Measured
|
||||
//! slope and intercept of Word's output for grey input `x` (PNG pictures, exact to ±1 level):
|
||||
//! bright 20 000 → 1.00, +51; bright −40 000 → 1.00, −103; contrast 80 000 → 5.00, −510;
|
||||
//! contrast −80 000 → 0.20, +101.4; (40 000, −40 000) → 0.60, +132.2; (80 000, −80 000) →
|
||||
//! 0.20, +223.8 (also the #1766 header picture: fitted 0.197..0.201, +223.6..224.0);
|
||||
//! (−40 000, 40 000) → 1.667, −221.7; (30 000, 60 000) → 2.50, −57.8.
|
||||
//!
|
||||
//! VML: `gain` is the slope itself and `blacklevel` half the brightness — Word draws `gain` 0.3,
|
||||
//! `blacklevel` 0.35 (Washout) as 0.30, +205.3, the same as `a:lum` (70 000, −70 000), and
|
||||
//! `gain` 1, `blacklevel` 0.2 as the very same picture as `bright="40000"`.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The largest magnitude of a correction (100 %).
|
||||
pub const FX_MAX: i32 = 100_000;
|
||||
|
||||
/// Brightness, contrast and transparency of a picture (see the module docs).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct PicFx {
|
||||
/// −100 000 (black) ..= 100 000 (white).
|
||||
pub bright: i32,
|
||||
/// −100 000 (flat grey) ..= 100 000 (hard threshold).
|
||||
pub contrast: i32,
|
||||
/// 0 (opaque) ..= 100 000 (invisible).
|
||||
pub transparency: i32,
|
||||
}
|
||||
|
||||
impl PicFx {
|
||||
/// Corrections clamped to their ranges.
|
||||
pub fn new(bright: i32, contrast: i32, transparency: i32) -> PicFx {
|
||||
PicFx { bright: bright.clamp(-FX_MAX, FX_MAX), contrast: contrast.clamp(-FX_MAX, FX_MAX), transparency: transparency.clamp(0, FX_MAX) }
|
||||
}
|
||||
|
||||
/// The same corrections, each clamped to its range (for values that came from anywhere).
|
||||
pub fn clamped(self) -> PicFx {
|
||||
PicFx::new(self.bright, self.contrast, self.transparency)
|
||||
}
|
||||
|
||||
/// No correction at all.
|
||||
pub fn is_identity(&self) -> bool {
|
||||
*self == PicFx::default()
|
||||
}
|
||||
|
||||
/// Brightness or contrast changes the pixels (transparency alone doesn't).
|
||||
pub fn changes_pixels(&self) -> bool {
|
||||
let f = self.clamped();
|
||||
f.bright != 0 || f.contrast != 0
|
||||
}
|
||||
|
||||
/// The picture's opacity, 0..=1 (1 − transparency).
|
||||
pub fn opacity(&self) -> f32 {
|
||||
1.0 - self.transparency.clamp(0, FX_MAX) as f32 / FX_MAX as f32
|
||||
}
|
||||
|
||||
/// The corrections a VML `v:imagedata` gives with `gain` and `blacklevel` (already parsed to
|
||||
/// plain numbers, 1 and 0 when missing): contrast from the slope `gain`, brightness twice the
|
||||
/// black level (see the module docs). Non-finite values count as missing.
|
||||
pub fn from_vml(gain: f64, blacklevel: f64) -> PicFx {
|
||||
let gain = if gain.is_finite() { gain.clamp(0.0, 1.0e6) } else { 1.0 };
|
||||
let black = if blacklevel.is_finite() { blacklevel.clamp(-0.5, 0.5) } else { 0.0 };
|
||||
// s = 1 + c (c ≤ 0) or 1 / (1 − c) (c > 0).
|
||||
let c = if gain <= 1.0 { gain - 1.0 } else { 1.0 - 1.0 / gain };
|
||||
let fx = |v: f64| (v * FX_MAX as f64).round().clamp(-(FX_MAX as f64), FX_MAX as f64) as i32;
|
||||
PicFx::new(fx(2.0 * black), fx(c), 0)
|
||||
}
|
||||
|
||||
/// `gain` and `blacklevel` for VML (the inverse of [`PicFx::from_vml`]).
|
||||
pub fn to_vml(&self) -> (f64, f64) {
|
||||
(self.slope(), self.clamped().bright as f64 / FX_MAX as f64 / 2.0)
|
||||
}
|
||||
|
||||
/// The contrast slope `s` (capped at 255: contrast 100 % is a threshold).
|
||||
fn slope(&self) -> f64 {
|
||||
let c = self.contrast.clamp(-FX_MAX, FX_MAX) as f64 / FX_MAX as f64;
|
||||
if c <= 0.0 { 1.0 + c } else { (1.0 / (1.0 - c).max(1.0 / 255.0)).min(255.0) }
|
||||
}
|
||||
|
||||
/// The lookup table every colour channel goes through.
|
||||
pub fn table(&self) -> [u8; 256] {
|
||||
let s = self.slope();
|
||||
let b = self.bright.clamp(-FX_MAX, FX_MAX) as f64 / FX_MAX as f64;
|
||||
let mut t = [0u8; 256];
|
||||
for (x, slot) in t.iter_mut().enumerate() {
|
||||
let y = s * (x as f64 - 127.5) + 127.5 + 127.5 * b * (1.0 + s);
|
||||
*slot = if y.is_finite() { y.round().clamp(0.0, 255.0) as u8 } else { 0 };
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
/// Apply brightness and contrast to straight (not premultiplied) RGBA8 pixels in place. The
|
||||
/// alpha byte is left alone (transparency is drawn as an opacity, see [`PicFx::opacity`]); a
|
||||
/// trailing partial pixel is ignored.
|
||||
pub fn apply_rgba(&self, rgba: &mut [u8]) {
|
||||
if !self.changes_pixels() {
|
||||
return;
|
||||
}
|
||||
let t = self.table();
|
||||
for px in rgba.as_chunks_mut::<4>().0 {
|
||||
for c in px.iter_mut().take(3) {
|
||||
*c = t.get(usize::from(*c)).copied().unwrap_or(*c);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Word's own output for grey input 0, 128 and 255 (PNG pictures in a PDF export).
|
||||
#[test]
|
||||
fn matches_word() {
|
||||
let cases: [(PicFx, [u8; 3]); 6] = [
|
||||
(PicFx::new(40_000, -40_000, 0), [132, 209, 255]),
|
||||
(PicFx::new(80_000, -80_000, 0), [224, 249, 255]),
|
||||
(PicFx::new(-40_000, 40_000, 0), [0, 0, 203]),
|
||||
(PicFx::new(0, 80_000, 0), [0, 130, 255]),
|
||||
(PicFx::new(20_000, 0, 0), [51, 179, 255]),
|
||||
(PicFx::from_vml(19661.0 / 65536.0, 22938.0 / 65536.0), [205, 244, 255]),
|
||||
];
|
||||
for (fx, want) in cases {
|
||||
let t = fx.table();
|
||||
for (x, w) in [0usize, 128, 255].into_iter().zip(want) {
|
||||
let got = t[x];
|
||||
assert!(got.abs_diff(w) <= 2, "{fx:?}: {x} → {got}, Word {w}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channels_apart_alpha_kept() {
|
||||
let fx = PicFx::new(80_000, -80_000, 0);
|
||||
let mut px = [255, 0, 0, 77, 0, 128, 255, 255];
|
||||
fx.apply_rgba(&mut px);
|
||||
// Word: red (255, 0, 0) → (255, 224, 224); 128 → 249 (the formula gives 250.0).
|
||||
assert_eq!(px, [255, 224, 224, 77, 224, 250, 255, 255]);
|
||||
let mut odd = [0u8; 6];
|
||||
fx.apply_rgba(&mut odd);
|
||||
assert_eq!(odd[4..], [0, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vml_mapping() {
|
||||
// 16.16 fixed point: 19661f and 22938f are 0.3 and 0.35 to within 1e-5.
|
||||
let washout = PicFx::from_vml(19661.0 / 65536.0, 22938.0 / 65536.0);
|
||||
assert!((washout.bright - 70_000).abs() <= 2 && (washout.contrast + 70_000).abs() <= 2, "{washout:?}");
|
||||
assert_eq!(PicFx::from_vml(1.0, 0.2), PicFx::new(40_000, 0, 0));
|
||||
assert_eq!(PicFx::from_vml(2.0, 0.0), PicFx::new(0, 50_000, 0));
|
||||
assert!(PicFx::from_vml(1.0, 0.0).is_identity());
|
||||
for (g, b) in [(f64::NAN, f64::INFINITY), (-1.0e30, 1.0e30), (1.0e300, -7.0), (0.0, 0.0)] {
|
||||
let fx = PicFx::from_vml(g, b);
|
||||
assert_eq!(fx, fx.clamped(), "{g} {b}");
|
||||
let _ = fx.table();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_values_clamp() {
|
||||
for v in [i32::MIN, -100_001, 0, 100_001, i32::MAX] {
|
||||
let fx = PicFx { bright: v, contrast: v, transparency: v };
|
||||
let _ = fx.table();
|
||||
assert!((0.0..=1.0).contains(&fx.opacity()));
|
||||
let mut px = [1u8, 2, 3, 4];
|
||||
fx.apply_rgba(&mut px);
|
||||
assert_eq!(px[3], 4);
|
||||
}
|
||||
assert_eq!(PicFx::new(0, 0, 25_000).opacity(), 0.75);
|
||||
assert!(!PicFx::new(0, 0, 25_000).changes_pixels());
|
||||
// Contrast 100 %: a threshold at mid-grey, no NaN.
|
||||
let t = PicFx::new(0, FX_MAX, 0).table();
|
||||
assert_eq!((t[0], t[127], t[128], t[255]), (0, 0, 255, 255));
|
||||
}
|
||||
}
|
||||
@@ -26,9 +26,7 @@ pub use char_props::{
|
||||
CharProps, CombineBrackets, DEFAULT_FONT_SIZE, EastAsianLayout, EmphasisMark, Highlight, Ligatures, NumChange, PropChange, Underline, VertAlign,
|
||||
};
|
||||
pub use color::{Rgb, TextColor};
|
||||
pub use para_props::{
|
||||
Align, Border, BorderStyle, Borders, LineSpacing, NumRef, ParaProps, TAB_POS_EPS, TabAlign, TabLeader, TabStop, TextAlignment, TextFrame,
|
||||
};
|
||||
pub use para_props::{Align, Border, BorderStyle, Borders, LineSpacing, NumRef, ParaProps, TAB_POS_EPS, TabAlign, TabLeader, TabStop, TextFrame};
|
||||
pub use table_props::{
|
||||
CellPicture, CellProps, HeightRule, PictureRepeat, RowProps, TableFloat, TableLook, TableProps, TextDirection, TextVert, VAlign, VMerge,
|
||||
anchor_ooxml, valign_from_anchor,
|
||||
|
||||
@@ -26,40 +26,6 @@ impl Align {
|
||||
}
|
||||
}
|
||||
|
||||
/// Vertical alignment of characters of different sizes on a line (`w:textAlignment`,
|
||||
/// ECMA-376 §17.3.1.39).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum TextAlignment {
|
||||
Auto,
|
||||
Top,
|
||||
Center,
|
||||
Baseline,
|
||||
Bottom,
|
||||
}
|
||||
|
||||
impl TextAlignment {
|
||||
pub fn ooxml(self) -> &'static str {
|
||||
match self {
|
||||
TextAlignment::Auto => "auto",
|
||||
TextAlignment::Top => "top",
|
||||
TextAlignment::Center => "center",
|
||||
TextAlignment::Baseline => "baseline",
|
||||
TextAlignment::Bottom => "bottom",
|
||||
}
|
||||
}
|
||||
pub fn from_ooxml(s: &str) -> Option<TextAlignment> {
|
||||
match s {
|
||||
"auto" => Some(TextAlignment::Auto),
|
||||
"top" => Some(TextAlignment::Top),
|
||||
"center" => Some(TextAlignment::Center),
|
||||
"baseline" => Some(TextAlignment::Baseline),
|
||||
"bottom" => Some(TextAlignment::Bottom),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Line spacing rule.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "rule", content = "value")]
|
||||
@@ -141,18 +107,6 @@ pub enum BorderStyle {
|
||||
Triple,
|
||||
DotDash,
|
||||
Wave,
|
||||
/// The compound lines of thin and thick strokes (`thinThickSmallGap` to
|
||||
/// `thinThickThinLargeGap`), kept so a file writes back the line it read; drawn as a single
|
||||
/// line of the border's width.
|
||||
ThinThickSmallGap,
|
||||
ThickThinSmallGap,
|
||||
ThinThickThinSmallGap,
|
||||
ThinThickMediumGap,
|
||||
ThickThinMediumGap,
|
||||
ThinThickThinMediumGap,
|
||||
ThinThickLargeGap,
|
||||
ThickThinLargeGap,
|
||||
ThinThickThinLargeGap,
|
||||
}
|
||||
|
||||
impl BorderStyle {
|
||||
@@ -167,15 +121,6 @@ impl BorderStyle {
|
||||
BorderStyle::Triple => "triple",
|
||||
BorderStyle::DotDash => "dotDash",
|
||||
BorderStyle::Wave => "wave",
|
||||
BorderStyle::ThinThickSmallGap => "thinThickSmallGap",
|
||||
BorderStyle::ThickThinSmallGap => "thickThinSmallGap",
|
||||
BorderStyle::ThinThickThinSmallGap => "thinThickThinSmallGap",
|
||||
BorderStyle::ThinThickMediumGap => "thinThickMediumGap",
|
||||
BorderStyle::ThickThinMediumGap => "thickThinMediumGap",
|
||||
BorderStyle::ThinThickThinMediumGap => "thinThickThinMediumGap",
|
||||
BorderStyle::ThinThickLargeGap => "thinThickLargeGap",
|
||||
BorderStyle::ThickThinLargeGap => "thickThinLargeGap",
|
||||
BorderStyle::ThinThickThinLargeGap => "thinThickThinLargeGap",
|
||||
}
|
||||
}
|
||||
pub fn from_ooxml(s: &str) -> BorderStyle {
|
||||
@@ -188,15 +133,6 @@ impl BorderStyle {
|
||||
"triple" => BorderStyle::Triple,
|
||||
"dotDash" | "dotDotDash" => BorderStyle::DotDash,
|
||||
"wave" | "doubleWave" => BorderStyle::Wave,
|
||||
"thinThickSmallGap" => BorderStyle::ThinThickSmallGap,
|
||||
"thickThinSmallGap" => BorderStyle::ThickThinSmallGap,
|
||||
"thinThickThinSmallGap" => BorderStyle::ThinThickThinSmallGap,
|
||||
"thinThickMediumGap" => BorderStyle::ThinThickMediumGap,
|
||||
"thickThinMediumGap" => BorderStyle::ThickThinMediumGap,
|
||||
"thinThickThinMediumGap" => BorderStyle::ThinThickThinMediumGap,
|
||||
"thinThickLargeGap" => BorderStyle::ThinThickLargeGap,
|
||||
"thickThinLargeGap" => BorderStyle::ThickThinLargeGap,
|
||||
"thinThickThinLargeGap" => BorderStyle::ThinThickThinLargeGap,
|
||||
_ => BorderStyle::Single,
|
||||
}
|
||||
}
|
||||
@@ -432,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 {
|
||||
@@ -456,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);
|
||||
|
||||
@@ -167,22 +167,10 @@ pub struct Settings {
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub remove_date_time: bool,
|
||||
/// The document was opened from ODF: the standard's rules that differ from Word's apply as
|
||||
/// it is laid out.
|
||||
/// it is laid out, with the choices it leaves open made as the user's profile made them
|
||||
/// (see [`crate::odf_profile`]).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub odf: Option<OdfLayout>,
|
||||
}
|
||||
|
||||
/// A document opened from ODF, for the layout: rules of the standard that differ from Word's (a
|
||||
/// paragraph's padding takes room on a side without a border, XSL's box model) apply, and what
|
||||
/// the file says about laying it out.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct OdfLayout {
|
||||
/// Tab stops are measured from the paragraph's left indent, default ones too (LibreOffice's
|
||||
/// `TabsRelativeToIndent` in the file; from the margin when it doesn't say). The document's
|
||||
/// explicit stops are held from the margin either way; the default ones are counted from the
|
||||
/// indent.
|
||||
pub tabs_from_indent: bool,
|
||||
pub odf: Option<crate::odf_profile::OdfLayout>,
|
||||
}
|
||||
|
||||
/// A saved zoom: a percentage, or a fit mode (the percentage it last came to).
|
||||
|
||||
@@ -12,8 +12,6 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::props::Rgb;
|
||||
|
||||
/// Most items in a SmartArt graphic.
|
||||
pub const MAX_ITEMS: usize = 100;
|
||||
/// Longest item text, characters.
|
||||
@@ -200,14 +198,11 @@ pub struct SmartArtItem {
|
||||
pub text: String,
|
||||
#[serde(default)]
|
||||
pub level: u8,
|
||||
/// Fill of the item's shape in place of the style's colour.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub color: Option<Rgb>,
|
||||
}
|
||||
|
||||
impl SmartArtItem {
|
||||
pub fn new(text: impl Into<String>, level: u8) -> SmartArtItem {
|
||||
SmartArtItem { text: text.into(), level, color: None }
|
||||
SmartArtItem { text: text.into(), level }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,35 +257,6 @@ impl SmartArtSpec {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether any item has its own colour.
|
||||
pub fn has_custom_colors(&self) -> bool {
|
||||
self.items.iter().any(|it| it.color.is_some())
|
||||
}
|
||||
|
||||
/// Give every item the style's colour again.
|
||||
pub fn clear_custom_colors(&mut self) {
|
||||
for it in &mut self.items {
|
||||
it.color = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// The theme accent (1–6) the style gives item `i`; `None` when the item has no shape of
|
||||
/// its own (it is text inside another item's shape).
|
||||
pub fn style_accent(&self, i: usize) -> Option<usize> {
|
||||
let it = self.items.get(i)?;
|
||||
let tops = || self.items.iter().take(i).filter(|o| o.level == 0).count();
|
||||
match self.layout {
|
||||
SmartArtLayout::Hierarchy => Some(self.colors.accent(i)),
|
||||
SmartArtLayout::Radial if i == 0 => Some(self.colors.accent(0)),
|
||||
SmartArtLayout::Radial => {
|
||||
let parents = self.parents();
|
||||
let spoke = |j: usize| j > 0 && matches!(parents.get(j), Some(Some(0)) | Some(None));
|
||||
spoke(i).then(|| self.colors.accent((1..i).filter(|j| spoke(*j)).count() + 1))
|
||||
}
|
||||
_ => (it.level == 0).then(|| self.colors.accent(tops())),
|
||||
}
|
||||
}
|
||||
|
||||
/// The graphic after `sanitize`.
|
||||
pub fn sanitized(mut self) -> SmartArtSpec {
|
||||
self.sanitize();
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
//! A count of the characters a paragraph's object lookups walk, 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).
|
||||
#[inline(always)]
|
||||
pub(crate) fn steps(n: usize) {
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
STEPS.with(|s| s.set(s.get().saturating_add(n as u64)));
|
||||
#[cfg(not(any(test, feature = "test-support")))]
|
||||
let _ = n;
|
||||
}
|
||||
|
||||
/// The steps `f` takes.
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub fn count(f: impl FnOnce()) -> u64 {
|
||||
let before = STEPS.with(std::cell::Cell::get);
|
||||
f();
|
||||
STEPS.with(std::cell::Cell::get).saturating_sub(before)
|
||||
}
|
||||
@@ -185,7 +185,7 @@ fn leaf(sp: &Sp, w: f32, h: f32, float: Float, b: &Build, pics: &mut Pictures) -
|
||||
let p = &sp.props;
|
||||
if let Some(pib) = p.pib {
|
||||
let media = pics.bse(pib, b.bstore, b.delay)?;
|
||||
return Some(InlineObject::Image { media, w, h, alt: p.alt.clone(), float, crop: p.crop, fx: Default::default(), ole: None });
|
||||
return Some(InlineObject::Image { media, w, h, alt: p.alt.clone(), float, crop: p.crop, ole: None });
|
||||
}
|
||||
let story = b.stories.get(&sp.spid).copied().filter(|_| sp.textbox || sp.spt == 202);
|
||||
let mut kind = match sp.spt {
|
||||
|
||||
@@ -623,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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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,
|
||||
@@ -343,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);
|
||||
|
||||
@@ -31,7 +31,7 @@ impl Reader<'_> {
|
||||
let cap_font = first.runs.first().and_then(|r| r.props.font.clone());
|
||||
let text_font = p.runs.first().and_then(|r| r.props.font.clone());
|
||||
if cap_font.is_some() && cap_font != text_font {
|
||||
for r in first.runs.iter_mut().filter(|r| r.props.font == cap_font) {
|
||||
for r in &mut first.runs {
|
||||
r.props.font = text_font.clone();
|
||||
}
|
||||
props.drop_cap_font = cap_font;
|
||||
|
||||
@@ -263,7 +263,7 @@ impl Reader<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(InlineObject::Image { media, w, h, alt, float, crop, fx: crate::units::blip_fx(blip), ole: None })
|
||||
Some(InlineObject::Image { media, w, h, alt, float, crop, ole: None })
|
||||
}
|
||||
|
||||
/// A `wpg:wgp` group (`w` × `h`): its pictures, shapes and text boxes, nested groups
|
||||
|
||||
@@ -134,10 +134,7 @@ impl Reader<'_> {
|
||||
let id = img.attr("r:id").or_else(|| img.attr("r:pict"))?;
|
||||
let media = self.media_for(rels, id)?;
|
||||
let alt = shape.attr("alt").or_else(|| img.attr("o:title")).unwrap_or("").to_string();
|
||||
// Brightness and contrast (Word's picture watermark "Washout" sets them, #1711).
|
||||
let frac = |k: &str| img.attr(k).and_then(crate::units::vml_number);
|
||||
let fx = wordcraft_doc::picfx::PicFx::from_vml(frac("gain").unwrap_or(1.0), frac("blacklevel").unwrap_or(0.0));
|
||||
return Some(InlineObject::Image { media, w, h, alt, float, crop: [0.0; 4], fx, ole: None });
|
||||
return Some(InlineObject::Image { media, w, h, alt, float, crop: [0.0; 4], ole: None });
|
||||
}
|
||||
// The shapetype the shape is drawn from (its `type`, ECMA-376 Part 4 VML), when written in
|
||||
// the same picture: what the shape leaves out, it takes from there.
|
||||
|
||||
@@ -86,8 +86,6 @@ enum Paint {
|
||||
None,
|
||||
/// Theme accent `n` (1–6), lightened to `tint` (thousandths of a percent kept) when set.
|
||||
Accent(usize, Option<u32>),
|
||||
/// A colour of its own, lightened to `tint` when set.
|
||||
Rgb(Rgb, Option<u32>),
|
||||
/// The theme's light background colour.
|
||||
Light,
|
||||
}
|
||||
@@ -152,22 +150,6 @@ impl Sh {
|
||||
}
|
||||
}
|
||||
|
||||
/// Accent fills and outlines in item `i`'s own colour, when it has one. Light outlines
|
||||
/// keep the theme's background colour so adjacent nodes remain separated.
|
||||
fn recolor(&mut self, spec: &SmartArtSpec, i: usize) {
|
||||
let Some(c) = spec.items.get(i).and_then(|it| it.color) else { return };
|
||||
for p in [&mut self.fill, &mut self.line] {
|
||||
if let Paint::Accent(_, tint) = *p {
|
||||
*p = Paint::Rgb(c, tint);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn item(mut self, spec: &SmartArtSpec, i: usize) -> Sh {
|
||||
self.recolor(spec, i);
|
||||
self
|
||||
}
|
||||
|
||||
fn with_text(mut self, text: Vec<(String, bool)>) -> Sh {
|
||||
self.text = text;
|
||||
self
|
||||
@@ -244,7 +226,7 @@ fn basic_list(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
|
||||
let in_row = if r + 1 == rows { n - r * cols } else { cols };
|
||||
let x0 = (w - (in_row as f32 * bw + (in_row - 1) as f32 * gx)) / 2.0;
|
||||
let b = [x0 + c as f32 * (bw + gx), y0 + r as f32 * (bh + gy), bw, bh];
|
||||
out.push(Sh::node("rect", b, spec.colors.accent(k)).item(spec, i).with_text(paras(spec, i, true)));
|
||||
out.push(Sh::node("rect", b, spec.colors.accent(k)).with_text(paras(spec, i, true)));
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -265,14 +247,11 @@ fn process(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
|
||||
let mut a = Sh::node("rightArrow", [gx + (gap - aw) / 2.0, h / 2.0 - ah / 2.0, aw, ah], spec.colors.accent(k - 1));
|
||||
a.fill = Paint::Accent(spec.colors.accent(k - 1), Some(60_000));
|
||||
a.line = Paint::None;
|
||||
if let Some(&i) = tops.get(k - 1) {
|
||||
a.recolor(spec, i);
|
||||
}
|
||||
out.push(a);
|
||||
}
|
||||
for (k, &i) in tops.iter().enumerate() {
|
||||
let b = [k as f32 * (nw + gap), y, nw, nh];
|
||||
out.push(Sh::node("roundRect", b, spec.colors.accent(k)).item(spec, i).with_text(paras(spec, i, true)));
|
||||
out.push(Sh::node("roundRect", b, spec.colors.accent(k)).with_text(paras(spec, i, true)));
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -315,9 +294,6 @@ fn cycle(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
|
||||
a.rot = (mid + 90.0).rem_euclid(360.0);
|
||||
a.fill = Paint::Accent(spec.colors.accent(k), Some(60_000));
|
||||
a.line = Paint::None;
|
||||
if let Some(&i) = tops.get(k) {
|
||||
a.recolor(spec, i);
|
||||
}
|
||||
out.push(a);
|
||||
}
|
||||
}
|
||||
@@ -325,7 +301,7 @@ fn cycle(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
|
||||
for (k, &i) in tops.iter().enumerate() {
|
||||
let (x, y) = if n == 1 { (cx, cy) } else { polar(cx, cy, r, angle(k, n)) };
|
||||
let b = [x - d / 2.0, y - d / 2.0, d, d];
|
||||
out.push(Sh::node("ellipse", b, spec.colors.accent(k)).item(spec, i).with_text(paras(spec, i, true)).text_box(inset(b, ROUND_INSET)));
|
||||
out.push(Sh::node("ellipse", b, spec.colors.accent(k)).with_text(paras(spec, i, true)).text_box(inset(b, ROUND_INSET)));
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -384,19 +360,19 @@ fn hierarchy(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
|
||||
let (px, pb) = (cx(i), top(i) + nh);
|
||||
let ct = ks.first().map_or(pb, |&k| top(k));
|
||||
let mid = (pb + ct) / 2.0;
|
||||
out.push(Sh::line(px, pb, px, mid, accent).item(spec, i));
|
||||
out.push(Sh::line(px, pb, px, mid, accent));
|
||||
let xs: Vec<f32> = ks.iter().map(|&k| cx(k)).collect();
|
||||
let (lo, hi) = xs.iter().fold((px, px), |(lo, hi), &x| (lo.min(x), hi.max(x)));
|
||||
if hi - lo > 0.5 {
|
||||
out.push(Sh::line(lo, mid, hi, mid, accent).item(spec, i));
|
||||
out.push(Sh::line(lo, mid, hi, mid, accent));
|
||||
}
|
||||
for x in xs {
|
||||
out.push(Sh::line(x, mid, x, ct, accent).item(spec, i));
|
||||
out.push(Sh::line(x, mid, x, ct, accent));
|
||||
}
|
||||
}
|
||||
for i in 0..n {
|
||||
let b = [cx(i) - nw / 2.0, top(i), nw, nh];
|
||||
out.push(Sh::node("roundRect", b, spec.colors.accent(i)).item(spec, i).with_text(paras(spec, i, false)));
|
||||
out.push(Sh::node("roundRect", b, spec.colors.accent(i)).with_text(paras(spec, i, false)));
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -424,7 +400,7 @@ fn pyramid(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
|
||||
let mid_w = (top_w + bot_w) / 2.0 * 0.8;
|
||||
s.text_box([w / 2.0 - mid_w / 2.0, b[1] + bh * 0.08, mid_w, bh * 0.84])
|
||||
};
|
||||
out.push(sh.item(spec, i).with_text(paras(spec, i, true)));
|
||||
out.push(sh.with_text(paras(spec, i, true)));
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -442,7 +418,7 @@ fn radial(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
|
||||
if n == 0 {
|
||||
let d = m * 0.5;
|
||||
let b = [cx - d / 2.0, cy - d / 2.0, d, d];
|
||||
out.push(Sh::node("ellipse", b, spec.colors.accent(0)).item(spec, 0).with_text(paras(spec, 0, false)).text_box(inset(b, ROUND_INSET)));
|
||||
out.push(Sh::node("ellipse", b, spec.colors.accent(0)).with_text(paras(spec, 0, false)).text_box(inset(b, ROUND_INSET)));
|
||||
return out;
|
||||
}
|
||||
let step = if n == 1 { 1.0 } else { (std::f32::consts::PI / n as f32).sin() };
|
||||
@@ -456,14 +432,14 @@ fn radial(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
|
||||
let at = |k: usize| polar(cx, cy, r, angle(k, n));
|
||||
for k in 0..n {
|
||||
let (x, y) = at(k);
|
||||
out.push(Sh::line(cx, cy, x, y, spec.colors.accent(0)).item(spec, 0));
|
||||
out.push(Sh::line(cx, cy, x, y, spec.colors.accent(0)));
|
||||
}
|
||||
let bc = [cx - dc / 2.0, cy - dc / 2.0, dc, dc];
|
||||
out.push(Sh::node("ellipse", bc, spec.colors.accent(0)).item(spec, 0).with_text(paras(spec, 0, false)).text_box(inset(bc, ROUND_INSET)));
|
||||
out.push(Sh::node("ellipse", bc, spec.colors.accent(0)).with_text(paras(spec, 0, false)).text_box(inset(bc, ROUND_INSET)));
|
||||
for (k, &i) in spokes.iter().enumerate() {
|
||||
let (x, y) = at(k);
|
||||
let b = [x - ds / 2.0, y - ds / 2.0, ds, ds];
|
||||
out.push(Sh::node("ellipse", b, spec.colors.accent(k + 1)).item(spec, i).with_text(paras(spec, i, true)).text_box(inset(b, ROUND_INSET)));
|
||||
out.push(Sh::node("ellipse", b, spec.colors.accent(k + 1)).with_text(paras(spec, i, true)).text_box(inset(b, ROUND_INSET)));
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -481,7 +457,7 @@ fn matrix(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
|
||||
for (k, &i) in tops.iter().enumerate() {
|
||||
let (r, c) = (k / cols, k % cols);
|
||||
let b = [x0 + c as f32 * cell + gap / 2.0, y0 + r as f32 * cell + gap / 2.0, cell - gap, cell - gap];
|
||||
out.push(Sh::node("roundRect", b, spec.colors.accent(k)).item(spec, i).with_text(paras(spec, i, true)).text_box(inset(b, 0.1)));
|
||||
out.push(Sh::node("roundRect", b, spec.colors.accent(k)).with_text(paras(spec, i, true)).text_box(inset(b, 0.1)));
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -502,22 +478,22 @@ fn venn(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
|
||||
(x, y, deg)
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for (j, &i) in tops.iter().enumerate() {
|
||||
for (j, _) in tops.iter().enumerate() {
|
||||
let (x, y, _) = place(j);
|
||||
let mut s = Sh::node("ellipse", [x - r, y - r, 2.0 * r, 2.0 * r], spec.colors.accent(j));
|
||||
s.fill = Paint::Accent(spec.colors.accent(j), Some(40_000));
|
||||
s.alpha = Some(60_000);
|
||||
s.line = Paint::None;
|
||||
out.push(s.item(spec, i));
|
||||
out.push(s);
|
||||
}
|
||||
// Every outline over every fill, so each circle shows whole.
|
||||
for (j, &i) in tops.iter().enumerate() {
|
||||
for (j, _) in tops.iter().enumerate() {
|
||||
let (x, y, _) = place(j);
|
||||
let mut s = Sh::node("ellipse", [x - r, y - r, 2.0 * r, 2.0 * r], spec.colors.accent(j));
|
||||
s.fill = Paint::None;
|
||||
s.line = Paint::Accent(spec.colors.accent(j), None);
|
||||
s.line_w = 1.5;
|
||||
out.push(s.item(spec, i));
|
||||
out.push(s);
|
||||
}
|
||||
for (j, &i) in tops.iter().enumerate() {
|
||||
let (x, y, deg) = place(j);
|
||||
@@ -578,23 +554,6 @@ fn paint(w: &mut W, p: Paint, alpha: Option<u32>) {
|
||||
w.empty("a:schemeClr", &[("val", "lt1")]);
|
||||
w.close("a:solidFill");
|
||||
}
|
||||
Paint::Rgb(c, tint) => {
|
||||
w.open("a:solidFill", &[]);
|
||||
let val = c.hex();
|
||||
if tint.is_none() && alpha.is_none() {
|
||||
w.empty("a:srgbClr", &[("val", &val)]);
|
||||
} else {
|
||||
w.open("a:srgbClr", &[("val", &val)]);
|
||||
if let Some(t) = tint {
|
||||
w.empty("a:tint", &[("val", &t.to_string())]);
|
||||
}
|
||||
if let Some(a) = alpha {
|
||||
w.empty("a:alpha", &[("val", &a.to_string())]);
|
||||
}
|
||||
w.close("a:srgbClr");
|
||||
}
|
||||
w.close("a:solidFill");
|
||||
}
|
||||
Paint::Accent(n, tint) => {
|
||||
w.open("a:solidFill", &[]);
|
||||
let val = format!("accent{}", n.clamp(1, 6));
|
||||
@@ -784,16 +743,7 @@ pub fn data_xml(spec: &SmartArtSpec, drawing_rel: &str) -> String {
|
||||
let (node, cxn, par, sib) = item_ids(i);
|
||||
w.open("dgm:pt", &[("modelId", &node)]);
|
||||
w.empty("dgm:prSet", &[]);
|
||||
match it.color {
|
||||
Some(c) => {
|
||||
w.open("dgm:spPr", &[]);
|
||||
w.open("a:solidFill", &[]);
|
||||
w.empty("a:srgbClr", &[("val", &c.hex())]);
|
||||
w.close("a:solidFill");
|
||||
w.close("dgm:spPr");
|
||||
}
|
||||
None => w.empty("dgm:spPr", &[]),
|
||||
}
|
||||
w.empty("dgm:spPr", &[]);
|
||||
w.open("dgm:t", &[]);
|
||||
w.empty("a:bodyPr", &[]);
|
||||
w.empty("a:lstStyle", &[]);
|
||||
@@ -1112,9 +1062,7 @@ pub(crate) fn spec_of(data: &El) -> Option<SmartArtSpec> {
|
||||
.zip(&levels)
|
||||
.map(|(p, l)| {
|
||||
let text: String = p.child("dgm:t")?.child("a:p")?.children("a:r").filter_map(|r| r.child("a:t")).map(|t| t.text()).collect();
|
||||
let color =
|
||||
p.child("dgm:spPr").and_then(|s| s.child("a:solidFill")).and_then(|f| f.child("a:srgbClr")).and_then(|c| Rgb::parse(c.attr("val")?));
|
||||
Some(SmartArtItem { text, level: (*l)?, color })
|
||||
Some(SmartArtItem { text, level: (*l)? })
|
||||
})
|
||||
.collect::<Option<Vec<_>>>()?;
|
||||
let spec = SmartArtSpec { layout, items, colors }.sanitized();
|
||||
@@ -1190,18 +1138,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_items_own_colour_is_saved_drawn_and_read_back() {
|
||||
let mut spec = SmartArtSpec::sample(SmartArtLayout::Process);
|
||||
spec.items[1].color = Some(Rgb(0x12, 0x34, 0x56));
|
||||
let spec = spec.sanitized();
|
||||
assert_eq!(spec_of(&parse(&data_xml(&spec, "rId9"))).as_ref(), Some(&spec));
|
||||
let drawing = drawing_xml(&spec, 400.0, 250.0);
|
||||
assert_eq!(drawing.matches("<a:srgbClr val=\"123456\"").count(), 2, "the step and the arrow after it");
|
||||
let plain = drawing_xml(&SmartArtSpec::sample(SmartArtLayout::Process), 400.0, 250.0);
|
||||
assert!(!plain.contains("srgbClr"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_diagram_with_more_than_the_model_stays_read_only() {
|
||||
let spec = SmartArtSpec::sample(SmartArtLayout::Process);
|
||||
|
||||
@@ -84,29 +84,6 @@ pub fn int(s: &str) -> Option<i64> {
|
||||
v.is_finite().then(|| v.round().clamp(i64::MIN as f64, i64::MAX as f64) as i64)
|
||||
}
|
||||
|
||||
/// A VML number (`gain`, `blacklevel`), unclamped: 16.16 fixed point with an `f` suffix
|
||||
/// (`19661f` is 0.3) or a plain decimal (`0.3`). None for anything else or non-finite.
|
||||
pub fn vml_number(s: &str) -> Option<f64> {
|
||||
let t = s.trim();
|
||||
if t.is_empty() || t.len() > 40 {
|
||||
return None;
|
||||
}
|
||||
let v = match t.strip_suffix('f') {
|
||||
Some(fixed) => fixed.trim().parse::<f64>().ok()? / 65_536.0,
|
||||
None => t.parse::<f64>().ok()?,
|
||||
};
|
||||
v.is_finite().then_some(v)
|
||||
}
|
||||
|
||||
/// A picture's brightness, contrast and transparency: `a:lum` (ECMA-376 §20.1.8.42) and
|
||||
/// `a:alphaModFix` (§20.1.8.4) in its `a:blip` (#1766).
|
||||
pub fn blip_fx(blip: &El) -> wordcraft_doc::picfx::PicFx {
|
||||
let val = |el: Option<&El>, k: &str| el.and_then(|e| e.attr(k)).and_then(int).map(|v| v.clamp(-1_000_000, 1_000_000) as i32);
|
||||
let lum = blip.child("a:lum");
|
||||
let amt = val(blip.child("a:alphaModFix"), "amt").unwrap_or(100_000).clamp(0, 100_000);
|
||||
wordcraft_doc::picfx::PicFx::new(val(lum, "bright").unwrap_or(0), val(lum, "contrast").unwrap_or(0), 100_000 - amt)
|
||||
}
|
||||
|
||||
pub fn u32_of(s: &str) -> Option<u32> {
|
||||
int(s).map(|v| v.clamp(0, u32::MAX as i64) as u32)
|
||||
}
|
||||
|
||||
@@ -317,8 +317,7 @@ impl<'a> Conv<'a> {
|
||||
|
||||
/// A DrawingML picture as a `w:pict` (inline, at its extent); any other drawing is dropped.
|
||||
fn picture(&mut self, drawing: &El, rels: &Rels) -> Vec<Node> {
|
||||
let Some(blip) = drawing.find("a:blip") else { return Vec::new() };
|
||||
let Some(id) = blip.attr("r:embed") else { return Vec::new() };
|
||||
let Some(id) = drawing.find("a:blip").and_then(|b| b.attr("r:embed")) else { return Vec::new() };
|
||||
let Some(target) = rels.by_id(id).filter(|r| !r.external).map(|r| r.target.clone()) else { return Vec::new() };
|
||||
let Some(src) = self.bin_for(&target) else { return Vec::new() };
|
||||
let pt = |k: &str| {
|
||||
@@ -330,19 +329,7 @@ impl<'a> Conv<'a> {
|
||||
let shape_id = format!("_x0000_i{}", self.next_shape);
|
||||
self.next_shape = self.next_shape.saturating_add(1);
|
||||
let mut kids: Vec<Node> = std::mem::take(&mut self.bins).into_iter().map(Node::El).collect();
|
||||
let mut shape = self.shape_frame(&shape_id, &style, &src, &alt);
|
||||
// Brightness and contrast as VML `gain` and `blacklevel` (16.16 fixed point, #1711).
|
||||
// `v:imagedata` has no opacity, so a picture's transparency (`a:alphaModFix`) is dropped.
|
||||
let fx = crate::units::blip_fx(blip);
|
||||
if fx.changes_pixels()
|
||||
&& let Some(Node::El(data)) = shape.kids.iter_mut().find(|k| matches!(k, Node::El(e) if e.name == "v:imagedata"))
|
||||
{
|
||||
let (gain, black) = fx.to_vml();
|
||||
let fixed = |v: f64| format!("{}f", (v * 65_536.0).round().clamp(-1.0e12, 1.0e12) as i64);
|
||||
data.attrs.push(("gain".into(), fixed(gain)));
|
||||
data.attrs.push(("blacklevel".into(), fixed(black)));
|
||||
}
|
||||
kids.push(Node::El(shape));
|
||||
kids.push(Node::El(self.shape_frame(&shape_id, &style, &src, &alt)));
|
||||
vec![Node::El(plain("w:pict", kids))]
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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]))]);
|
||||
|
||||
@@ -317,16 +317,7 @@ fn a_chunks_pictures_come_with_it() {
|
||||
let mut d = Document::new();
|
||||
let key = d.add_media(vec![1, 2, 3], "png");
|
||||
let mut para = wordcraft_doc::Paragraph::new();
|
||||
let image = InlineObject::Image {
|
||||
media: key,
|
||||
w: 10.0,
|
||||
h: 10.0,
|
||||
alt: String::new(),
|
||||
float: Default::default(),
|
||||
crop: [0.0; 4],
|
||||
ole: None,
|
||||
fx: Default::default(),
|
||||
};
|
||||
let image = InlineObject::Image { media: key, w: 10.0, h: 10.0, alt: String::new(), float: Default::default(), crop: [0.0; 4], ole: None };
|
||||
para.insert_object(0, image, &Default::default()).unwrap();
|
||||
d.body = vec![std::sync::Arc::new(Block::Para(para))];
|
||||
Some(d)
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
//! Compound border lines of thin and thick strokes (`thinThickSmallGap` to
|
||||
//! `thinThickThinLargeGap`, ECMA-376 Part 1 §17.18.2 ST_Border) on paragraphs, table cells and
|
||||
//! pages: written back as read, not as a single line. Fixtures are hand-written from the spec.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
|
||||
use wordcraft_doc::props::BorderStyle;
|
||||
|
||||
const LINES: [(&str, BorderStyle); 9] = [
|
||||
("thinThickSmallGap", BorderStyle::ThinThickSmallGap),
|
||||
("thickThinSmallGap", BorderStyle::ThickThinSmallGap),
|
||||
("thinThickThinSmallGap", BorderStyle::ThinThickThinSmallGap),
|
||||
("thinThickMediumGap", BorderStyle::ThinThickMediumGap),
|
||||
("thickThinMediumGap", BorderStyle::ThickThinMediumGap),
|
||||
("thinThickThinMediumGap", BorderStyle::ThinThickThinMediumGap),
|
||||
("thinThickLargeGap", BorderStyle::ThinThickLargeGap),
|
||||
("thickThinLargeGap", BorderStyle::ThickThinLargeGap),
|
||||
("thinThickThinLargeGap", BorderStyle::ThinThickThinLargeGap),
|
||||
];
|
||||
|
||||
fn docx(body: &str) -> Vec<u8> {
|
||||
let root = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
|
||||
let doc = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>{body}</w:body></w:document>"#
|
||||
);
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (name, bytes) in [("_rels/.rels", root.as_bytes()), ("word/document.xml", doc.as_bytes())] {
|
||||
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zw.write_all(bytes).unwrap();
|
||||
}
|
||||
zw.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
fn document_xml(bytes: &[u8]) -> String {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut s = String::new();
|
||||
z.by_name("word/document.xml").unwrap().read_to_string(&mut s).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn side(name: &str, val: &str) -> String {
|
||||
format!(r#"<w:{name} w:val="{val}" w:sz="36" w:space="1" w:color="2F5496"/>"#)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compound_paragraph_borders_are_written_back_as_read() {
|
||||
let body: String = LINES
|
||||
.iter()
|
||||
.map(|(val, _)| {
|
||||
format!(r#"<w:p><w:pPr><w:pBdr>{}{}</w:pBdr></w:pPr><w:r><w:t>{val}</w:t></w:r></w:p>"#, side("top", val), side("bottom", val))
|
||||
})
|
||||
.collect();
|
||||
let d = wordcraft_docx::read(&docx(&body)).unwrap();
|
||||
let got: Vec<Option<BorderStyle>> =
|
||||
d.body.iter().filter_map(|b| b.as_para()).map(|p| p.props.borders.and_then(|b| b.top).map(|t| t.style)).collect();
|
||||
assert_eq!(got, LINES.iter().map(|(_, s)| Some(*s)).collect::<Vec<_>>());
|
||||
let xml = document_xml(&wordcraft_docx::write(&d).unwrap());
|
||||
for (val, _) in LINES {
|
||||
assert_eq!(xml.matches(&format!(r#"w:val="{val}""#)).count(), 2, "{val}: {xml}");
|
||||
}
|
||||
assert!(!xml.contains(r#"w:val="single""#), "{xml}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compound_cell_and_page_borders_are_written_back_as_read() {
|
||||
let body = format!(
|
||||
r#"<w:tbl><w:tblPr><w:tblW w:w="0" w:type="auto"/><w:tblBorders>{}</w:tblBorders></w:tblPr><w:tblGrid><w:gridCol w:w="2000"/></w:tblGrid><w:tr><w:tc><w:tcPr><w:tcW w:w="2000" w:type="dxa"/><w:tcBorders>{}</w:tcBorders></w:tcPr><w:p><w:r><w:t>Cell</w:t></w:r></w:p></w:tc></w:tr></w:tbl><w:p/><w:sectPr><w:pgBorders w:offsetFrom="text">{}</w:pgBorders></w:sectPr>"#,
|
||||
side("top", "thickThinLargeGap"),
|
||||
side("left", "thinThickSmallGap"),
|
||||
side("right", "thinThickThinMediumGap")
|
||||
);
|
||||
let xml = document_xml(&wordcraft_docx::write(&wordcraft_docx::read(&docx(&body)).unwrap()).unwrap());
|
||||
for val in ["thickThinLargeGap", "thinThickSmallGap", "thinThickThinMediumGap"] {
|
||||
assert_eq!(xml.matches(&format!(r#"w:val="{val}""#)).count(), 1, "{val}: {xml}");
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,6 @@ fn picture() -> InlineObject {
|
||||
alt: String::new(),
|
||||
float: Default::default(),
|
||||
crop: [0.0; 4],
|
||||
fx: Default::default(),
|
||||
ole: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3941,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(
|
||||
@@ -4514,20 +4376,6 @@ fn vml_textbox_style_anchor_and_fit() {
|
||||
assert!(tb.body.auto_fit);
|
||||
}
|
||||
|
||||
/// #2601: a drop-cap frame of two letters in two fonts. The first letter's font is the cap's; only
|
||||
/// the runs in that font get the text's font back, the other letter keeps its own.
|
||||
#[test]
|
||||
fn drop_cap_frame_keeps_other_fonts() {
|
||||
let body = r#"<w:p><w:pPr><w:framePr w:dropCap="drop" w:lines="3" w:wrap="around" w:vAnchor="text" w:hAnchor="text"/></w:pPr><w:r><w:rPr><w:rFonts w:ascii="Liberation Mono" w:hAnsi="Liberation Mono"/></w:rPr><w:t>A</w:t></w:r><w:r><w:rPr><w:rFonts w:ascii="Liberation Sans" w:hAnsi="Liberation Sans"/></w:rPr><w:t>b</w:t></w:r></w:p>
|
||||
<w:p><w:r><w:rPr><w:rFonts w:ascii="Liberation Serif" w:hAnsi="Liberation Serif"/></w:rPr><w:t>c tale</w:t></w:r></w:p>"#;
|
||||
let d = read_body(body);
|
||||
let p = paras(&d);
|
||||
assert_eq!(p.len(), 1, "the frame merges into its paragraph");
|
||||
assert_eq!(p[0].props.drop_cap_font.as_deref(), Some("Liberation Mono"));
|
||||
assert_eq!(p[0].props_of_char(0).font.as_deref(), Some("Liberation Serif"), "the cap letter gets the text's font");
|
||||
assert_eq!(p[0].props_of_char(1).font.as_deref(), Some("Liberation Sans"), "the other letter keeps its own");
|
||||
}
|
||||
|
||||
/// A VML horizontal rule (`o:hr`, a web page's `<hr>` pasted into Word) is read as one: the whole
|
||||
/// line when it has no width and no `o:hrpct`, a share of it (`o:hrpct`, tenths of a percent), or
|
||||
/// its own width (`o:hrpct="0"`), placed by `o:hralign`. It saves back as the same VML rule.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
mod alt_chunks;
|
||||
mod bidi;
|
||||
mod bidi_runs;
|
||||
mod border_lines;
|
||||
mod cell_margin_inheritance;
|
||||
mod char_units;
|
||||
mod citations;
|
||||
@@ -33,7 +32,6 @@ mod section_page_number_type;
|
||||
mod section_text_direction;
|
||||
mod section_valign;
|
||||
mod settings_merge;
|
||||
mod text_alignment;
|
||||
mod text_boxes;
|
||||
mod toc_ranges;
|
||||
mod undefined_style;
|
||||
|
||||
@@ -7,7 +7,7 @@ use wordcraft_doc::numbering::ListKind;
|
||||
use wordcraft_doc::para::{Anchor, Float, FloatAlign, NoteKind, ShapeKind, Wrap};
|
||||
use wordcraft_doc::props::{
|
||||
Align, Border, BorderStyle, Borders, CharProps, HeightRule, Highlight, LineSpacing, NumRef, ParaProps, Rgb, RowProps, TabAlign, TabLeader,
|
||||
TabStop, TableLook, TextAlignment, TextColor, TextDirection, Underline, VAlign, VMerge, VertAlign,
|
||||
TabStop, TableLook, TextColor, TextDirection, Underline, VAlign, VMerge, VertAlign,
|
||||
};
|
||||
use wordcraft_doc::section::{Columns, DocGrid, DocGridType, LineNumberRestart, LineNumbering, NumFormat, PageVAlign, SectionProps, SectionStart};
|
||||
use wordcraft_doc::styles::{Style, StyleKind};
|
||||
@@ -221,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() },
|
||||
@@ -429,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 {
|
||||
@@ -439,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 {
|
||||
@@ -459,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];
|
||||
@@ -471,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,
|
||||
});
|
||||
}
|
||||
@@ -1194,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,
|
||||
@@ -1290,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,
|
||||
@@ -1757,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();
|
||||
@@ -1861,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(),
|
||||
@@ -2132,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 {
|
||||
@@ -2633,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 {
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
//! A paragraph's vertical text alignment (`w:textAlignment`, ECMA-376 Part 1 §17.3.1.39, values
|
||||
//! from ST_TextAlignment): read and written back as read, in the schema's place after `w:jc`.
|
||||
//! Word sizes a line by it when a list label is larger than the text, so a save that drops it
|
||||
//! moves every line after. Fixtures are hand-written from the spec.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
|
||||
use wordcraft_doc::props::TextAlignment;
|
||||
|
||||
fn docx(body: &str) -> Vec<u8> {
|
||||
let root = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
|
||||
let doc = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>{body}</w:body></w:document>"#
|
||||
);
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (name, bytes) in [("_rels/.rels", root.as_bytes()), ("word/document.xml", doc.as_bytes())] {
|
||||
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zw.write_all(bytes).unwrap();
|
||||
}
|
||||
zw.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
fn document_xml(bytes: &[u8]) -> String {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut s = String::new();
|
||||
z.by_name("word/document.xml").unwrap().read_to_string(&mut s).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_alignment_is_written_back_as_read() {
|
||||
let para = |ppr: &str, text: &str| format!(r#"<w:p><w:pPr>{ppr}</w:pPr><w:r><w:t>{text}</w:t></w:r></w:p>"#);
|
||||
let body = [
|
||||
para(r#"<w:jc w:val="center"/><w:textAlignment w:val="top"/><w:outlineLvl w:val="1"/>"#, "A"),
|
||||
para(r#"<w:textAlignment w:val="center"/>"#, "B"),
|
||||
para(r#"<w:textAlignment w:val="baseline"/>"#, "C"),
|
||||
para(r#"<w:textAlignment w:val="bottom"/>"#, "D"),
|
||||
para(r#"<w:textAlignment w:val="auto"/>"#, "E"),
|
||||
para(r#"<w:textAlignment w:val="sideways"/>"#, "F"),
|
||||
para("", "G"),
|
||||
]
|
||||
.concat();
|
||||
let d = wordcraft_docx::read(&docx(&body)).unwrap();
|
||||
let got: Vec<Option<TextAlignment>> = d.body.iter().filter_map(|b| b.as_para()).map(|p| p.props.text_alignment).collect();
|
||||
let want = vec![
|
||||
Some(TextAlignment::Top),
|
||||
Some(TextAlignment::Center),
|
||||
Some(TextAlignment::Baseline),
|
||||
Some(TextAlignment::Bottom),
|
||||
Some(TextAlignment::Auto),
|
||||
None,
|
||||
None,
|
||||
];
|
||||
assert_eq!(got, want);
|
||||
let out = wordcraft_docx::write(&d).unwrap();
|
||||
let xml = document_xml(&out);
|
||||
assert!(xml.contains(r#"<w:jc w:val="center"/><w:textAlignment w:val="top"/><w:outlineLvl w:val="1"/>"#), "{xml}");
|
||||
for v in ["center", "baseline", "bottom", "auto"] {
|
||||
assert_eq!(xml.matches(&format!(r#"<w:textAlignment w:val="{v}"/>"#)).count(), 1, "{v}: {xml}");
|
||||
}
|
||||
assert_eq!(xml.matches("w:textAlignment").count(), 5, "an unknown value and none write nothing: {xml}");
|
||||
let back = wordcraft_docx::read(&out).unwrap();
|
||||
assert_eq!(back.body.iter().filter_map(|b| b.as_para()).map(|p| p.props.text_alignment).collect::<Vec<_>>(), want);
|
||||
}
|
||||
@@ -215,7 +215,6 @@ fn picture_in_box_keeps_its_frame() {
|
||||
alt: "Synthetic marker".into(),
|
||||
float: Default::default(),
|
||||
crop: [0.0; 4],
|
||||
fx: Default::default(),
|
||||
ole: None,
|
||||
};
|
||||
let mut p = Paragraph::new();
|
||||
|
||||
@@ -106,7 +106,7 @@ fn wordml_2003_sample() -> String {
|
||||
<w:tr><w:tc><w:tcPr><w:tcW w:w="2000" w:type="dxa"/><w:vmerge w:val="restart"/></w:tcPr><w:p><w:r><w:t>A1</w:t></w:r></w:p></w:tc><w:tc><w:tcPr><w:tcW w:w="2000" w:type="dxa"/></w:tcPr><w:p><w:r><w:t>B1</w:t></w:r></w:p></w:tc></w:tr>
|
||||
<w:tr><w:tc><w:tcPr><w:tcW w:w="2000" w:type="dxa"/><w:vmerge/></w:tcPr><w:p/></w:tc><w:tc><w:tcPr><w:tcW w:w="2000" w:type="dxa"/></w:tcPr><w:p><w:r><w:t>B2</w:t></w:r></w:p></w:tc></w:tr>
|
||||
</w:tbl>
|
||||
<w:p><w:r><w:pict><w:binData w:name="wordml://02000001.png">{png}</w:binData><v:shape id="_x0000_i1025" type="#_x0000_t75" style="width:36pt;height:18pt"><v:imagedata src="wordml://02000001.png" o:title="dot" gain="19661f" blacklevel="22938f"/></v:shape></w:pict></w:r></w:p>
|
||||
<w:p><w:r><w:pict><w:binData w:name="wordml://02000001.png">{png}</w:binData><v:shape id="_x0000_i1025" type="#_x0000_t75" style="width:36pt;height:18pt"><v:imagedata src="wordml://02000001.png" o:title="dot"/></v:shape></w:pict></w:r></w:p>
|
||||
<w:sectPr>
|
||||
<w:hdr w:type="odd"><w:p><w:r><w:t>Garden header</w:t></w:r></w:p></w:hdr>
|
||||
<w:pgSz w:w="12240" w:h="15840"/><w:pgMar w:top="1440" w:right="1800" w:bottom="1440" w:left="1800" w:header="720" w:footer="720" w:gutter="0"/>
|
||||
@@ -117,16 +117,6 @@ fn wordml_2003_sample() -> String {
|
||||
)
|
||||
}
|
||||
|
||||
/// Word 2003 XML pictures keep Word's Washout (gain 0.3, black level 0.35) as brightness and
|
||||
/// contrast (#1711).
|
||||
#[test]
|
||||
fn word_2003_xml_picture_keeps_washout() {
|
||||
let doc = read_xml(wordml_2003_sample().as_bytes()).unwrap();
|
||||
let ps = paras(&doc);
|
||||
let pic = ps.iter().flat_map(|p| p.objects.iter()).find(|o| matches!(o, InlineObject::Image { .. }));
|
||||
assert!(matches!(pic, Some(InlineObject::Image { fx, .. }) if (fx.bright - 70_000).abs() <= 2 && (fx.contrast + 70_000).abs() <= 2), "{pic:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imports_word_2003_xml() {
|
||||
let xml = wordml_2003_sample();
|
||||
|
||||
@@ -69,16 +69,7 @@ fn sample() -> Document {
|
||||
let mut img = Paragraph::new();
|
||||
img.insert_object(
|
||||
0,
|
||||
InlineObject::Image {
|
||||
media: key,
|
||||
w: 30.0,
|
||||
h: 20.0,
|
||||
alt: "dot".into(),
|
||||
float: Default::default(),
|
||||
crop: [0.0; 4],
|
||||
fx: Default::default(),
|
||||
ole: None,
|
||||
},
|
||||
InlineObject::Image { media: key, w: 30.0, h: 20.0, alt: "dot".into(), float: Default::default(), crop: [0.0; 4], ole: None },
|
||||
&CharProps::default(),
|
||||
)
|
||||
.unwrap();
|
||||
@@ -189,48 +180,6 @@ fn big_and_deep_documents_write() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Picture brightness and contrast (#1711) survive a Word 2003 XML save as VML `gain` and
|
||||
/// `blacklevel`; transparency has no place on `v:imagedata` and is dropped.
|
||||
#[test]
|
||||
fn picture_brightness_and_contrast_round_trip() {
|
||||
use wordcraft_doc::picfx::PicFx;
|
||||
let cases = [PicFx::new(70_000, -70_000, 0), PicFx::new(-30_000, 50_000, 40_000), PicFx::default()];
|
||||
let mut d = Document::new();
|
||||
let key = d.add_media(tiny_png(), "png");
|
||||
d.body = cases
|
||||
.iter()
|
||||
.map(|fx| {
|
||||
let mut p = Paragraph::new();
|
||||
let pic = InlineObject::Image {
|
||||
media: key.clone(),
|
||||
w: 30.0,
|
||||
h: 20.0,
|
||||
alt: "dot".into(),
|
||||
float: Default::default(),
|
||||
crop: [0.0; 4],
|
||||
fx: *fx,
|
||||
ole: None,
|
||||
};
|
||||
p.insert_object(0, pic, &CharProps::default()).unwrap();
|
||||
para_block(p)
|
||||
})
|
||||
.collect();
|
||||
let xml = write_wordml2003(&d).expect("write");
|
||||
let text = String::from_utf8_lossy(&xml);
|
||||
assert!(text.contains(r#"gain="19661f""#) && text.contains(r#"blacklevel="22938f""#), "Word's Washout values");
|
||||
let back = read_xml(&xml).expect("read");
|
||||
let got: Vec<PicFx> = paras(&back)
|
||||
.iter()
|
||||
.flat_map(|p| p.objects.iter())
|
||||
.filter_map(|o| if let InlineObject::Image { fx, .. } = o { Some(*fx) } else { None })
|
||||
.collect();
|
||||
assert_eq!(got.len(), cases.len());
|
||||
for (g, want) in got.iter().zip(cases) {
|
||||
let near = |a: i32, b: i32| (a - b).abs() <= 2;
|
||||
assert!(near(g.bright, want.bright) && near(g.contrast, want.contrast) && g.transparency == 0, "{g:?} vs {want:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_header_and_repeated_note_are_not_inlined_per_reference() {
|
||||
// #3652: one ~10 KB header shared by 1000 sections and one ~10 KB footnote referenced 500
|
||||
|
||||
@@ -44,8 +44,6 @@ 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).
|
||||
|
||||
@@ -548,59 +548,6 @@ pub fn now_iso() -> String {
|
||||
iso_from_unix_secs(now_unix())
|
||||
}
|
||||
|
||||
/// The local time zone's offset from UTC in seconds (east positive) at `unix`, daylight saving
|
||||
/// included: the system's zone, or the browser's on the web. 0 (UTC) when it can't be found.
|
||||
pub fn local_offset_secs(unix: u64) -> i64 {
|
||||
system_offset_secs(unix).clamp(-26 * 3600, 26 * 3600)
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn system_offset_secs(unix: u64) -> i64 {
|
||||
use chrono::Offset;
|
||||
let Some(utc) = i64::try_from(unix).ok().and_then(|secs| chrono::DateTime::from_timestamp(secs, 0)) else {
|
||||
return 0;
|
||||
};
|
||||
// chrono's `Local` has internal `expect`s (unreadable zone data, an unmapped Windows year), so
|
||||
// fence it: a failure means UTC, never a crash. A caught panic is still reported by the app's
|
||||
// panic hook (logged, no abort); the document is untouched.
|
||||
std::panic::catch_unwind(|| i64::from(utc.with_timezone(&chrono::Local).offset().fix().local_minus_utc())).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// On the web the browser's `Date` gives the offset directly (chrono isn't used there).
|
||||
#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
|
||||
fn system_offset_secs(unix: u64) -> i64 {
|
||||
let ms = (unix as f64) * 1000.0;
|
||||
if !ms.is_finite() {
|
||||
return 0;
|
||||
}
|
||||
// `getTimezoneOffset` is minutes *west* of UTC (UTC+13 gives -780); NaN for an invalid date.
|
||||
let west_min = js_sys::Date::new(&js_sys::wasm_bindgen::JsValue::from_f64(ms)).get_timezone_offset();
|
||||
if west_min.is_finite() && west_min.abs() <= 26.0 * 60.0 { -(west_min as i64) * 60 } else { 0 }
|
||||
}
|
||||
|
||||
/// Other wasm targets (WASI) have neither chrono's clock nor a browser: UTC.
|
||||
#[cfg(all(target_arch = "wasm32", not(target_os = "unknown")))]
|
||||
fn system_offset_secs(_unix: u64) -> i64 {
|
||||
0
|
||||
}
|
||||
|
||||
/// The local civil date and time now: see [`local_civil`].
|
||||
pub fn local_now() -> (i64, u32, u32, (u32, u32, u32), i64) {
|
||||
let now = now_unix();
|
||||
local_civil(now, local_offset_secs(now))
|
||||
}
|
||||
|
||||
/// Local civil date and time for an instant: (year, month, day, (hour, minute, second), days since
|
||||
/// 1970-01-01 of that local day, for the weekday). `offset_secs` is the local zone's offset from
|
||||
/// UTC (east positive). Saturating, so a hostile offset or clock never overflows.
|
||||
pub fn local_civil(unix: u64, offset_secs: i64) -> (i64, u32, u32, (u32, u32, u32), i64) {
|
||||
let local = i64::try_from(unix).unwrap_or(i64::MAX).saturating_add(offset_secs);
|
||||
let days = local.div_euclid(86_400);
|
||||
let t = local.rem_euclid(86_400) as u32;
|
||||
let (y, m, d) = civil_from_days(days);
|
||||
(y, m, d, (t / 3600, t / 60 % 60, t % 60), days)
|
||||
}
|
||||
|
||||
/// Days since 1970-01-01 → (year, month, day).
|
||||
///
|
||||
/// `NaiveDate` only covers about ±262143 years, but this is reached by the system clock (and by
|
||||
|
||||
@@ -72,7 +72,7 @@ const MAX_WRAPPED: usize = 10_000;
|
||||
/// A generated list (index, bibliography, table of figures or authorities) held as a complex
|
||||
/// field that starts at body block `i` and runs over its entries: its field code and the block
|
||||
/// it ends in. `None` for anything else (a TOC has its own update).
|
||||
fn wrapped_list(s: &Session, i: usize) -> Option<(String, usize, usize, usize)> {
|
||||
fn wrapped_list(s: &Session, i: usize) -> Option<(String, usize, usize)> {
|
||||
let p = s.doc.body.get(i)?.as_para()?;
|
||||
let (k, off, instr) = p.objects_with_offsets().enumerate().find_map(|(k, (off, o))| match o {
|
||||
InlineObject::FieldStart { instr, .. } => {
|
||||
@@ -97,7 +97,7 @@ fn wrapped_list(s: &Session, i: usize) -> Option<(String, usize, usize, usize)>
|
||||
InlineObject::FieldEnd => {
|
||||
depth = depth.saturating_sub(1);
|
||||
if depth == 0 {
|
||||
return Some((instr, off, j, at + wordcraft_doc::para::OBJ.len_utf8()));
|
||||
return Some((instr, j, at + wordcraft_doc::para::OBJ.len_utf8()));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -108,18 +108,13 @@ fn wrapped_list(s: &Session, i: usize) -> Option<(String, usize, usize, usize)>
|
||||
}
|
||||
|
||||
fn list_kind(p: &Paragraph) -> Option<String> {
|
||||
p.objects.iter().find_map(list_field)
|
||||
}
|
||||
|
||||
/// The field code of `o` when it is a generated list's field.
|
||||
fn list_field(o: &InlineObject) -> Option<String> {
|
||||
match o {
|
||||
p.objects.iter().find_map(|o| match o {
|
||||
InlineObject::Field { instr, .. } => {
|
||||
let t = instr.trim_start();
|
||||
(t.starts_with("BIBLIOGRAPHY") || t.starts_with("INDEX") || t.starts_with("TOA") || figures_label(t).is_some()).then(|| t.to_string())
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// The caption label (SEQ identifier) a table-of-figures field lists, `\c` or `\a`.
|
||||
@@ -203,15 +198,12 @@ pub fn update_generated(s: &mut Session) -> Result<(), CmdError> {
|
||||
// field's own paragraph, as WordCraft inserts one, its old entries rebuilt below.
|
||||
// What follows its closing field end, and its first paragraph's properties and section
|
||||
// break, are the document's and stay.
|
||||
// (The field it is made: what comes before it in its paragraph may hold other fields.)
|
||||
let mut converted = None;
|
||||
if let Some((instr, start, last, end)) = wrapped_list(s, i) {
|
||||
if let Some((instr, last, end)) = wrapped_list(s, i) {
|
||||
// The entries between its first and last paragraphs go.
|
||||
for _ in i.saturating_add(1)..last {
|
||||
s.doc.remove_block(StoryRef::Body, &Path::top(i + 1))?;
|
||||
}
|
||||
// Its first paragraph keeps what comes before the field (bookmarks, other fields)
|
||||
// and the field; its last (or, ending in its first, the
|
||||
// Its first paragraph keeps only the field; its last (or, ending in its first, the
|
||||
// same) loses what is up to and through the field end, and goes when nothing is left
|
||||
// of it and it ends no section.
|
||||
let first_end = if last > i {
|
||||
@@ -225,38 +217,28 @@ pub fn update_generated(s: &mut Session) -> Result<(), CmdError> {
|
||||
end
|
||||
};
|
||||
let p = s.doc.para_mut(StoryRef::Body, &Path::top(i))?;
|
||||
p.delete(start, first_end)?;
|
||||
converted = Some(instr.trim_start().to_string());
|
||||
p.delete(0, first_end)?;
|
||||
p.insert_object(
|
||||
start,
|
||||
0,
|
||||
InlineObject::Field { instr, result: String::new(), locked: false, dirty: false, code: false, form: String::new() },
|
||||
&Default::default(),
|
||||
)?;
|
||||
// What followed the field end in its own paragraph comes after the list, in a
|
||||
// paragraph of its own that takes the section break.
|
||||
let field = start + wordcraft_doc::para::OBJ.len_utf8();
|
||||
let field = wordcraft_doc::para::OBJ.len_utf8();
|
||||
if p.len() > field {
|
||||
let rest = p.split_off(field)?;
|
||||
s.doc.insert_block(StoryRef::Body, &Path::top(i + 1), Block::Para(rest))?;
|
||||
}
|
||||
// Another list's field before it would be the paragraph's list on later updates (and
|
||||
// have its shown list cleared with this one's): the list gets a paragraph of its own,
|
||||
// after it, and each is rebuilt in its turn.
|
||||
let p = s.doc.para_mut(StoryRef::Body, &Path::top(i))?;
|
||||
if p.objects_with_offsets().any(|(off, o)| off < start && list_field(o).is_some()) {
|
||||
let list = p.split_off(start)?;
|
||||
s.doc.insert_block(StoryRef::Body, &Path::top(i + 1), Block::Para(list))?;
|
||||
converted = None;
|
||||
}
|
||||
}
|
||||
let kind = converted.or_else(|| s.doc.body.get(i).and_then(|b| b.as_para()).and_then(list_kind));
|
||||
let kind = s.doc.body.get(i).and_then(|b| b.as_para()).and_then(list_kind);
|
||||
let Some(kind) = kind else {
|
||||
i += 1;
|
||||
continue;
|
||||
};
|
||||
add_entry_style(s);
|
||||
if kind.starts_with("INDEX") {
|
||||
crate::cmd::index::rebuild_from(s, i, None, kind)?;
|
||||
crate::cmd::index::rebuild(s, i, None)?;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
@@ -450,7 +432,7 @@ mod tests {
|
||||
p.insert_object(n, InlineObject::FieldEnd, &props).unwrap();
|
||||
}
|
||||
let s = Session::new(d);
|
||||
assert_eq!(wrapped_list(&s, 0).map(|(instr, _, last, _)| (instr, last)), Some(("BIBLIOGRAPHY".to_string(), 1)));
|
||||
assert_eq!(wrapped_list(&s, 0).map(|(instr, last, _)| (instr, last)), Some(("BIBLIOGRAPHY".to_string(), 1)));
|
||||
}
|
||||
|
||||
/// A wrapped list as a session holds it: the field starting paragraph `first`'s text, ending
|
||||
@@ -505,56 +487,4 @@ mod tests {
|
||||
assert_eq!(t.get(1).map(String::as_str), Some(" Then this"), "{t:?}");
|
||||
assert_eq!(t.last().map(String::as_str), Some("Below"));
|
||||
}
|
||||
|
||||
/// What comes before a wrapped list's field in its first paragraph (a bookmark, an empty
|
||||
/// `PAGE` field) stays there, before the rebuilt list's field.
|
||||
#[test]
|
||||
fn what_comes_before_a_wrapped_lists_field_stays() {
|
||||
let mut s = wrapped("Old one\nOld two\nBelow", 0, 1, "Old two".len());
|
||||
let props = CharProps::default();
|
||||
if let Ok(p) = s.doc.para_mut(StoryRef::Body, &Path::top(0)) {
|
||||
p.insert_object(0, InlineObject::FieldEnd, &props).unwrap();
|
||||
p.insert_object(0, InlineObject::FieldStart { instr: "PAGE".into(), locked: false, dirty: false }, &props).unwrap();
|
||||
p.insert_object(0, InlineObject::BookmarkStart { name: "Sources".into() }, &props).unwrap();
|
||||
}
|
||||
update_generated(&mut s).unwrap();
|
||||
let first = s.doc.body.first().and_then(|b| b.as_para());
|
||||
let kinds: Vec<&str> = first
|
||||
.map(|p| {
|
||||
p.objects
|
||||
.iter()
|
||||
.map(|o| match o {
|
||||
InlineObject::BookmarkStart { .. } => "bookmark",
|
||||
InlineObject::FieldStart { instr, .. } if instr == "PAGE" => "page",
|
||||
InlineObject::FieldEnd => "end",
|
||||
InlineObject::Field { instr, .. } if instr.starts_with("BIBLIOGRAPHY") => "list",
|
||||
_ => "other",
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
assert_eq!(kinds, ["bookmark", "page", "end", "list"]);
|
||||
assert_eq!(texts(&s), ["", "Below"]);
|
||||
}
|
||||
|
||||
/// A list field before a wrapped list's field in its paragraph doesn't take its place: the
|
||||
/// wrapped list is the one rebuilt.
|
||||
#[test]
|
||||
fn a_wrapped_list_is_rebuilt_as_itself_after_another_list_field() {
|
||||
let mut s = wrapped("Old one\nOld two\nBelow", 0, 1, "Old two".len());
|
||||
s.doc.sources.push(wordcraft_doc::Source { tag: "a".into(), kind: "book".into(), title: "Listed Source".into(), ..Default::default() });
|
||||
let index =
|
||||
InlineObject::Field { instr: "INDEX".into(), result: String::new(), locked: false, dirty: false, code: false, form: String::new() };
|
||||
if let Ok(p) = s.doc.para_mut(StoryRef::Body, &Path::top(0)) {
|
||||
p.insert_object(0, index, &CharProps::default()).unwrap();
|
||||
}
|
||||
// Updated again, each list stays itself.
|
||||
for _ in 0..2 {
|
||||
update_generated(&mut s).unwrap();
|
||||
let t = texts(&s);
|
||||
assert_eq!(t.iter().filter(|t| t.contains("Listed Source")).count(), 1, "the bibliography was rebuilt: {t:?}");
|
||||
let lists: Vec<Option<String>> = s.doc.body.iter().filter_map(|b| b.as_para()).filter_map(list_kind).map(Some).collect();
|
||||
assert_eq!(lists, [Some("INDEX".to_string()), Some("BIBLIOGRAPHY".to_string())]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,12 +260,12 @@ fn open(s: &mut Session, v: &Value) -> CmdResult {
|
||||
if bytes.is_empty() {
|
||||
(normal_document(s), false)
|
||||
} else {
|
||||
crate::io::open_bytes_in(path, &bytes, password, &s.region()).map_err(CmdError::Failed)?
|
||||
crate::io::open_bytes_in(path, &bytes, password, &s.region(), &s.prefs.odf).map_err(CmdError::Failed)?
|
||||
}
|
||||
} else if crate::io::is_empty_file(std::path::Path::new(path)) {
|
||||
(normal_document(s), false)
|
||||
} else {
|
||||
crate::io::open_path_in(std::path::Path::new(path), password, &s.region()).map_err(CmdError::Failed)?
|
||||
crate::io::open_path_in(std::path::Path::new(path), password, &s.region(), &s.prefs.odf).map_err(CmdError::Failed)?
|
||||
};
|
||||
// Plain text and Markdown carry no language: they are in the editing language.
|
||||
let ext = std::path::Path::new(path).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("").to_ascii_lowercase();
|
||||
|
||||
@@ -265,12 +265,6 @@ pub fn rebuild(s: &mut Session, i: usize, leader: Option<TabLeader>) -> Result<(
|
||||
}) else {
|
||||
return Ok(());
|
||||
};
|
||||
rebuild_from(s, i, leader, instr)
|
||||
}
|
||||
|
||||
/// [`rebuild`] the index at body block `i` from field code `instr` (that of the index field in
|
||||
/// it, when the paragraph holds more than one).
|
||||
pub fn rebuild_from(s: &mut Session, i: usize, leader: Option<TabLeader>, instr: String) -> Result<(), CmdError> {
|
||||
let f = FieldCode::parse(&instr);
|
||||
// Old entries go; keep their leader and the section their columns had.
|
||||
let mut old_leader = None;
|
||||
|
||||
@@ -15,7 +15,7 @@ mod objects;
|
||||
|
||||
pub(crate) use header_footer::saved_copy;
|
||||
use header_footer::{create_hf, edit_hf, header_footer, new_empty_headers, strip_headers};
|
||||
pub use items::{DropCapState, drop_cap_state, format_date, format_date_at, format_date_parts};
|
||||
pub use items::{DropCapState, drop_cap_state, format_date, format_date_parts};
|
||||
use items::{cover_page, date_time, drop_cap, drop_cap_off, field, page_number, symbol};
|
||||
use links::{bookmark, delete_bookmark, link, link_targets};
|
||||
pub use links::{bookmark_name_ok, bookmark_range, bookmark_span};
|
||||
@@ -124,7 +124,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("insert.pageField", "Insert Page Field", "Insert › Text › Quick Parts", |s, _| field(s, &json!({"instr": "PAGE"})))
|
||||
.key(super::mac_or("Ctrl+Shift+P", "Alt+Shift+P")),
|
||||
CommandSpec::new("insert.dropCap", "Drop Cap", "Insert › Text", drop_cap)
|
||||
.params(r#"{"position"?: "none"|"dropped"|"margin" (default dropped), "lines"?: 1..10 (default 3; 0 = none), "distance"?: pt from the text (0..1584, default 0), "font"?: family (omit for the text's font), "keep"?: bool (true: each selected paragraph that has a drop cap keeps its own lines, distance and font unless given here)}"#)
|
||||
.params(r#"{"position"?: "none"|"dropped"|"margin" (default dropped), "lines"?: 1..10 (default 3; 0 = none), "distance"?: pt from the text (0..1584, default 0), "font"?: family (omit for the text's font)}"#)
|
||||
.when(drop_cap_off),
|
||||
CommandSpec::new("insert.horizontalLine", "Horizontal Line", "Home › Paragraph › Borders", |s, _| {
|
||||
super::para::fmt(s, &|p| {
|
||||
|
||||
@@ -61,16 +61,14 @@ pub(super) fn date_time(s: &mut Session, v: &Value) -> CmdResult {
|
||||
|
||||
/// Format today's date with a Word picture (`M/d/yyyy`, `MMMM d, yyyy`, `yyyy-MM-dd`, `dddd`…).
|
||||
pub fn format_date(fmt: &str) -> String {
|
||||
// Word shows the computer's local date and time (#1707), not UTC's.
|
||||
let now = crate::cmd::now_unix();
|
||||
format_date_at(fmt, now, crate::cmd::local_offset_secs(now))
|
||||
}
|
||||
|
||||
/// Format the instant `unix` (seconds since the epoch, UTC) with a Word picture, in the zone
|
||||
/// `offset_secs` east of UTC.
|
||||
pub fn format_date_at(fmt: &str, unix: u64, offset_secs: i64) -> String {
|
||||
let (y, m, d, hms, days) = crate::cmd::local_civil(unix, offset_secs);
|
||||
format_date_parts(fmt, y, m as usize, d, hms, days)
|
||||
let iso = crate::cmd::now_iso();
|
||||
let y: i64 = iso.get(0..4).and_then(|x| x.parse().ok()).unwrap_or(2026);
|
||||
let m: usize = iso.get(5..7).and_then(|x| x.parse().ok()).unwrap_or(1);
|
||||
let d: u32 = iso.get(8..10).and_then(|x| x.parse().ok()).unwrap_or(1);
|
||||
let hh: u32 = iso.get(11..13).and_then(|x| x.parse().ok()).unwrap_or(0);
|
||||
let mm: u32 = iso.get(14..16).and_then(|x| x.parse().ok()).unwrap_or(0);
|
||||
let ss: u32 = iso.get(17..19).and_then(|x| x.parse().ok()).unwrap_or(0);
|
||||
format_date_parts(fmt, y, m, d, (hh, mm, ss), (crate::cmd::now_unix() / 86_400) as i64)
|
||||
}
|
||||
|
||||
/// Format a date and time with a Word picture; `days_since` (days since 1970-01-01) gives the weekday.
|
||||
@@ -241,16 +239,12 @@ pub(super) fn cover_page(s: &mut Session, v: &Value) -> CmdResult {
|
||||
}
|
||||
|
||||
/// Why Insert › Drop Cap is off at the caret, as in Word: in a table cell, in an empty paragraph
|
||||
/// and in one that starts with a space. A selection must be fit throughout (what Dropped and In
|
||||
/// margin need), unless a paragraph in it has a drop cap for None to take away.
|
||||
/// and in one that starts with a space.
|
||||
pub(super) fn drop_cap_off(s: &Session) -> Option<&'static str> {
|
||||
let why = drop_cap_selection_off(s)?;
|
||||
let story = s.sel.focus.story;
|
||||
let has_cap = crate::cmd::selected_paths(s).iter().any(|path| s.doc.para(story, path).is_some_and(|p| p.props.drop_cap.is_some()));
|
||||
(!has_cap).then_some(why)
|
||||
}
|
||||
|
||||
fn drop_cap_first_letter(first: Option<char>) -> Option<&'static str> {
|
||||
if s.sel.focus.path.cell().is_some() {
|
||||
return Some("drop caps can't go in a table");
|
||||
}
|
||||
let first = s.doc.para_at(&s.sel.focus).and_then(|p| p.text.chars().next());
|
||||
match first {
|
||||
None => Some("the paragraph is empty"),
|
||||
Some(c) if c.is_whitespace() => Some("the paragraph starts with a space"),
|
||||
@@ -258,21 +252,6 @@ fn drop_cap_first_letter(first: Option<char>) -> Option<&'static str> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The same check for every selected paragraph, as the command formats them all.
|
||||
fn drop_cap_selection_off(s: &Session) -> Option<&'static str> {
|
||||
let story = s.sel.focus.story;
|
||||
for path in crate::cmd::selected_paths(s) {
|
||||
if path.cell().is_some() {
|
||||
return Some("drop caps can't go in a table");
|
||||
}
|
||||
let first = s.doc.para(story, &path).and_then(|p| p.text.chars().next());
|
||||
if let Some(why) = drop_cap_first_letter(first) {
|
||||
return Some(why);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Insert › Drop Cap and Drop Cap Options: none, or the letter dropped into the text or hung in
|
||||
/// the margin, `lines` tall, `distance` points from the text, in `font` (unset: the text's).
|
||||
/// None takes all of it away, the letter's font with it.
|
||||
@@ -280,27 +259,15 @@ pub(super) fn drop_cap(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let lines = v.get("lines").and_then(Value::as_f64).filter(|l| l.is_finite());
|
||||
let none = p::str(v, "position") == Some("none") || lines == Some(0.0);
|
||||
let margin = p::str(v, "position") == Some("margin");
|
||||
let lines_given = lines.is_some();
|
||||
let lines = lines.map_or(3.0, |l| l.round().clamp(1.0, 10.0)) as u8;
|
||||
let distance = p::f32(v, "distance").map_or(0.0, |d| d.clamp(0.0, wordcraft_doc::resolve::MAX_DROP_CAP_DISTANCE));
|
||||
// A family name, not a paragraph of text.
|
||||
let font = p::str(v, "font").map(str::trim).filter(|f| !f.is_empty()).map(|f| f.chars().take(64).collect::<String>());
|
||||
if !none && let Some(why) = drop_cap_selection_off(s) {
|
||||
return Err(CmdError::Failed(why.into()));
|
||||
}
|
||||
// `keep` (the menu's Dropped / In margin): a paragraph that has a drop cap keeps its own
|
||||
// lines, distance and font unless given here.
|
||||
let keep = !none && v.get("keep").and_then(Value::as_bool) == Some(true);
|
||||
let (distance_given, font_given) = (p::f32(v, "distance").is_some(), v.get("font").is_some());
|
||||
crate::cmd::para::fmt(s, &|p| {
|
||||
let own = keep && p.drop_cap.is_some();
|
||||
p.drop_cap = (!none).then(|| if own && !lines_given { p.drop_cap.unwrap_or(lines) } else { lines });
|
||||
p.drop_cap = (!none).then_some(lines);
|
||||
p.drop_cap_margin = (!none && margin).then_some(true);
|
||||
let d = if own && !distance_given { p.drop_cap_distance.unwrap_or(0.0) } else { distance };
|
||||
p.drop_cap_distance = (!none && d > 0.0).then_some(d);
|
||||
if !(own && !font_given) {
|
||||
p.drop_cap_font = font.clone().filter(|_| !none);
|
||||
}
|
||||
p.drop_cap_distance = (!none && distance > 0.0).then_some(distance);
|
||||
p.drop_cap_font = font.clone().filter(|_| !none);
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -146,16 +146,7 @@ fn place_picture(s: &mut Session, bytes: Vec<u8>, width: Option<f32>, alt: &str)
|
||||
h *= max_w / w;
|
||||
w = max_w;
|
||||
}
|
||||
let obj = InlineObject::Image {
|
||||
media: key.clone(),
|
||||
w,
|
||||
h,
|
||||
alt: alt.to_string(),
|
||||
float: Float::default(),
|
||||
crop: [0.0; 4],
|
||||
fx: Default::default(),
|
||||
ole: None,
|
||||
};
|
||||
let obj = InlineObject::Image { media: key.clone(), w, h, alt: alt.to_string(), float: Float::default(), crop: [0.0; 4], ole: None };
|
||||
// A selected Drawing Canvas takes it in.
|
||||
if let Some(i) = crate::cmd::canvas::add_member(s, obj.clone())? {
|
||||
return Ok(json!({"media": key, "canvasMember": i}));
|
||||
|
||||
@@ -381,38 +381,3 @@ fn date_pictures() {
|
||||
assert_eq!(date_picture(r#"TIME \@ HH:mm"#).as_deref(), Some("HH:mm"));
|
||||
assert_eq!(date_picture("DATE"), None);
|
||||
}
|
||||
|
||||
/// #1707: Insert ▸ Date & Time and DATE/TIME fields use the local civil date, time and
|
||||
/// weekday, not UTC's.
|
||||
#[test]
|
||||
fn format_date_uses_the_local_offset() {
|
||||
// Noon on Saturday 31 October in New Zealand (UTC+13) is 2026-10-30T23:00:00Z.
|
||||
assert_eq!(format_date_at("dddd, MMMM d, yyyy h:mm am/pm", 1_793_401_200, 13 * 3600), "Saturday, October 31, 2026 12:00 PM");
|
||||
assert_eq!(format_date_at("M/d/yyyy H:mm", 1_793_498_400, -5 * 3600), "10/31/2026 21:00");
|
||||
assert_eq!(format_date_at("M/d/yyyy H:mm", 1_793_401_200, 0), "10/30/2026 23:00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_civil_date_follows_the_utc_offset() {
|
||||
let pic = "dddd d MMMM yyyy HH:mm";
|
||||
let show = |unix: u64, off: i64| {
|
||||
let (y, m, d, hms, days) = crate::cmd::local_civil(unix, off);
|
||||
format_date_parts(pic, y, m as usize, d, hms, days)
|
||||
};
|
||||
// 2026-10-30T23:00:00Z in New Zealand (UTC+13) is Saturday 31 October, noon.
|
||||
assert_eq!(show(1_793_401_200, 13 * 3600), "Saturday 31 October 2026 12:00");
|
||||
// 2026-11-01T02:00:00Z at UTC-5 is still Saturday 31 October, 21:00.
|
||||
assert_eq!(show(1_793_498_400, -5 * 3600), "Saturday 31 October 2026 21:00");
|
||||
// Offset 0 is UTC.
|
||||
assert_eq!(show(1_793_401_200, 0), "Friday 30 October 2026 23:00");
|
||||
// Near the epoch with a negative offset: the day before 1970, no overflow.
|
||||
assert_eq!(show(0, -3600), "Wednesday 31 December 1969 23:00");
|
||||
// Hostile values never panic.
|
||||
let _ = crate::cmd::local_civil(u64::MAX, i64::MAX);
|
||||
let _ = crate::cmd::local_civil(u64::MAX, i64::MIN);
|
||||
let _ = crate::cmd::local_civil(0, i64::MIN);
|
||||
// The machine's zone is a real offset (within ±26 h) and asking never panics.
|
||||
for unix in [0, 1_793_401_200, u64::MAX] {
|
||||
assert!(crate::cmd::local_offset_secs(unix).abs() <= 26 * 3600);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ use picture::{clear_background, grown, picture_style};
|
||||
pub(crate) use select::all_objects;
|
||||
pub(crate) use select::{edit_obj, has_shape_or_canvas};
|
||||
#[cfg(test)]
|
||||
use select::{has_group, holds_ink, obj_size, with_obj};
|
||||
use select::{has_group, holds_ink, obj_size};
|
||||
pub use select::{min_size, object_selection, picked_objects, selected, selected_all};
|
||||
#[cfg(test)]
|
||||
use shape::can_link;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use super::select::{obj_size, selected, with_obj};
|
||||
use super::{CmdError, CmdResult, InlineObject, Rgb, Session, Value, Wrap, p};
|
||||
use wordcraft_doc::picfx::PicFx;
|
||||
|
||||
/// `picture.crop`: the crop, and optionally the new display size, as one edit. With a size, a
|
||||
/// floating picture positioned by offsets moves by what the crop on its left/top side on the page
|
||||
@@ -149,33 +148,13 @@ pub fn fit_size(w: f32, h: f32, keep: bool) -> (f32, f32) {
|
||||
pub(super) fn adjust(s: &mut Session, f: impl Fn(image::DynamicImage) -> Result<image::DynamicImage, CmdError>) -> CmdResult {
|
||||
let (_, o) = selected(s).ok_or_else(|| CmdError::Disabled("no picture".into()))?;
|
||||
let InlineObject::Image { media, .. } = o else { return Err(CmdError::Disabled("not a picture".into())) };
|
||||
adjust_media(s, &media, |_| {}, f)
|
||||
}
|
||||
|
||||
/// The selected picture's pre-edit media key (itself when never edited).
|
||||
pub(super) fn original_media(s: &Session) -> Result<String, CmdError> {
|
||||
let (_, o) = selected(s).ok_or_else(|| CmdError::Disabled("no picture".into()))?;
|
||||
let InlineObject::Image { media, .. } = o else { return Err(CmdError::Disabled("not a picture".into())) };
|
||||
Ok(s.originals.get(&media).cloned().unwrap_or(media))
|
||||
}
|
||||
|
||||
/// Drops brightness and contrast read from the file: an edit baked them from the original.
|
||||
pub(super) fn clear_lum(fx: &mut PicFx) {
|
||||
fx.bright = 0;
|
||||
fx.contrast = 0;
|
||||
adjust_media(s, &media, f)
|
||||
}
|
||||
|
||||
/// Like [`adjust`], but decodes `base` (a media key, usually the pre-edit original) instead
|
||||
/// of the picture's current bitmap. The result still chains onto the originals map, so Reset
|
||||
/// Picture keeps working.
|
||||
/// `fx` changes the picture's brightness, contrast and transparency from the file (see
|
||||
/// [`PicFx`]) for an edit that bakes its own.
|
||||
pub(super) fn adjust_media(
|
||||
s: &mut Session,
|
||||
base: &str,
|
||||
fx: impl Fn(&mut PicFx),
|
||||
f: impl Fn(image::DynamicImage) -> Result<image::DynamicImage, CmdError>,
|
||||
) -> CmdResult {
|
||||
pub(super) fn adjust_media(s: &mut Session, base: &str, f: impl Fn(image::DynamicImage) -> Result<image::DynamicImage, CmdError>) -> CmdResult {
|
||||
let bytes = s.doc.media.get(base).cloned().ok_or_else(|| CmdError::Failed("picture data missing".into()))?;
|
||||
// Within the budget pictures are drawn within, checked from the header first (#3644, #3647).
|
||||
let img = wordcraft_geom::picture::decode(&bytes, wordcraft_geom::MAX_DECODED_BYTES).map_err(|e| match e {
|
||||
@@ -200,9 +179,8 @@ pub(super) fn adjust_media(
|
||||
.unwrap_or_else(|| cur.clone().unwrap_or_else(|| key.clone()));
|
||||
s.originals.entry(key.clone()).or_insert(orig);
|
||||
with_obj(s, |o| {
|
||||
if let InlineObject::Image { media, fx: pic_fx, .. } = o {
|
||||
if let InlineObject::Image { media, .. } = o {
|
||||
*media = key.clone();
|
||||
fx(pic_fx);
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -266,25 +244,19 @@ pub(super) fn relook(s: &mut Session, change: impl FnOnce(&mut PictureLook)) ->
|
||||
});
|
||||
}
|
||||
let l = look.clone();
|
||||
// Styles, borders and effects leave the file's brightness, contrast and transparency alone.
|
||||
let out = adjust_media(
|
||||
s,
|
||||
&look.base,
|
||||
|_| {},
|
||||
move |img| {
|
||||
let mut img = match &l.effect {
|
||||
Some(e) => artistic_effect(img, e),
|
||||
None => img,
|
||||
};
|
||||
if let Some(st) = &l.style {
|
||||
img = picture_style(img, st)?;
|
||||
}
|
||||
if let Some((c, w)) = l.border {
|
||||
img = frame(img, w, [c.0, c.1, c.2, 255])?;
|
||||
}
|
||||
Ok(img)
|
||||
},
|
||||
)?;
|
||||
let out = adjust_media(s, &look.base, move |img| {
|
||||
let mut img = match &l.effect {
|
||||
Some(e) => artistic_effect(img, e),
|
||||
None => img,
|
||||
};
|
||||
if let Some(st) = &l.style {
|
||||
img = picture_style(img, st)?;
|
||||
}
|
||||
if let Some((c, w)) = l.border {
|
||||
img = frame(img, w, [c.0, c.1, c.2, 255])?;
|
||||
}
|
||||
Ok(img)
|
||||
})?;
|
||||
if let Some((_, InlineObject::Image { media, .. })) = selected(s)
|
||||
&& let Some(drawn) = s.doc.media.get(&media).cloned()
|
||||
{
|
||||
@@ -327,13 +299,10 @@ pub(super) fn reset(s: &mut Session, _: &Value) -> CmdResult {
|
||||
let (_, o) = selected(s).ok_or_else(|| CmdError::Disabled("no picture".into()))?;
|
||||
let InlineObject::Image { media, .. } = o else { return Err(CmdError::Disabled("not a picture".into())) };
|
||||
let orig = s.originals.get(&media).cloned().unwrap_or(media);
|
||||
// Like Word, Reset Picture discards every correction, those read from the file (`fx`:
|
||||
// brightness, contrast, transparency, #1711, #1766) included.
|
||||
with_obj(s, |o| {
|
||||
if let InlineObject::Image { media, crop, fx, .. } = o {
|
||||
if let InlineObject::Image { media, crop, .. } = o {
|
||||
*media = orig.clone();
|
||||
*crop = [0.0; 4];
|
||||
*fx = PicFx::default();
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
use super::arrange::{MAX_OFFSET, bounds, copy_to, move_with_text, unalign};
|
||||
use super::group::{add_object, group, ungroup};
|
||||
use super::picture::{adjust, adjust_media, clear_lum, crop, original_media, recolor, relook, remove_background, reset, size};
|
||||
use super::picture::{adjust, adjust_media, crop, recolor, relook, remove_background, reset, size};
|
||||
use super::select::{
|
||||
all_objects, alt_text, can_group, edit_obj, has_floating, has_group, has_movable, has_object, has_picture, has_shape, has_shape_or_canvas,
|
||||
has_sizable, obj_size, objects_list, selected, with_float, with_obj,
|
||||
};
|
||||
use super::shape::{NO_TEXT_BOX, can_link, has_text_box, link_text_box, set_text_body, shape_effects, text_box_target};
|
||||
use super::{CmdError, CommandSpec, InlineObject, Pos, Rgb, Selection, Spin, TextVert, VAlign, Wrap, WrapText, json, p, sel_result};
|
||||
use wordcraft_doc::picfx::PicFx;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
@@ -37,9 +36,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let sharpen = p::f32(v, "sharpen").unwrap_or(0.0).clamp(-100.0, 100.0);
|
||||
// Absolute from the original (like transparency): re-setting all three to 0
|
||||
// restores the original pixels instead of stacking on the last pass.
|
||||
// The file's own brightness and contrast (`fx`) are replaced, not stacked on.
|
||||
let base = original_media(s)?;
|
||||
adjust_media(s, &base, clear_lum, move |img| {
|
||||
let (_, o) = selected(s).ok_or_else(|| CmdError::Disabled("no picture".into()))?;
|
||||
let InlineObject::Image { media, .. } = o else { return Err(CmdError::Disabled("not a picture".into())) };
|
||||
let base = s.originals.get(&media).cloned().unwrap_or(media.clone());
|
||||
adjust_media(s, &base, move |img| {
|
||||
let mut img = img;
|
||||
if b != 0.0 {
|
||||
img = image::DynamicImage::ImageRgba8(image::imageops::colorops::brighten(&img, (b * 2.55) as i32));
|
||||
@@ -62,11 +62,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let mode = p::str(v, "mode").unwrap_or("grayscale").to_string();
|
||||
let sat = p::f32(v, "saturation").unwrap_or(100.0);
|
||||
// Absolute from the original (like transparency): saturation 100 restores it.
|
||||
// Color and Corrections are independent, as in Word: only Washout, which Word stores
|
||||
// as brightness and contrast (`a:lum`), replaces the file's own (`fx`).
|
||||
let base = original_media(s)?;
|
||||
let fx: fn(&mut PicFx) = if mode == "washout" { clear_lum } else { |_| {} };
|
||||
adjust_media(s, &base, fx, move |img| Ok(recolor(img, &mode, sat)))
|
||||
let (_, o) = selected(s).ok_or_else(|| CmdError::Disabled("no picture".into()))?;
|
||||
let InlineObject::Image { media, .. } = o else { return Err(CmdError::Disabled("not a picture".into())) };
|
||||
let base = s.originals.get(&media).cloned().unwrap_or(media.clone());
|
||||
adjust_media(s, &base, move |img| Ok(recolor(img, &mode, sat)))
|
||||
})
|
||||
.params(r#"{"mode": "grayscale|sepia|washout|blackAndWhite|saturation|tint", "saturation"?: 0..400}"#)
|
||||
.when(has_picture),
|
||||
@@ -98,10 +97,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
// slider back and forth never fades what a previous pass already faded.
|
||||
// (Combining with other pixel edits then re-setting transparency drops those
|
||||
// edits; a full live-effect stack is future work.)
|
||||
// The file's own transparency (`fx`, `a:alphaModFix`) is replaced too: it's baked
|
||||
// into the pixels from here on.
|
||||
let base = original_media(s)?;
|
||||
adjust_media(s, &base, |fx: &mut PicFx| fx.transparency = 0, move |img| {
|
||||
let (_, o) = selected(s).ok_or_else(|| CmdError::Disabled("no picture".into()))?;
|
||||
let InlineObject::Image { media, .. } = o else { return Err(CmdError::Disabled("not a picture".into())) };
|
||||
let base = s.originals.get(&media).cloned().unwrap_or(media.clone());
|
||||
adjust_media(s, &base, move |img| {
|
||||
let mut rgba = img.to_rgba8();
|
||||
for px in rgba.pixels_mut() {
|
||||
px.0[3] = (px.0[3] as f32 * (1.0 - t / 100.0)) as u8;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use super::*;
|
||||
use wordcraft_doc::picfx::PicFx;
|
||||
|
||||
fn png(w: u32, h: u32) -> Vec<u8> {
|
||||
let img = image::RgbaImage::from_fn(w, h, |x, y| {
|
||||
@@ -455,108 +454,6 @@ fn transparency_is_absolute_from_the_original() {
|
||||
assert_eq!(rgba(&s), orig, "reset still finds the original");
|
||||
}
|
||||
|
||||
/// A picture opened with brightness, contrast and transparency from its file (`fx`, #1711,
|
||||
/// #1766): Transparency and Corrections are absolute and replace the file's values instead of
|
||||
/// stacking on them; Color leaves the file's brightness and contrast alone except Washout
|
||||
/// (itself stored as brightness and contrast); Reset Picture discards every correction, the
|
||||
/// file's included, as in Word.
|
||||
#[test]
|
||||
fn picture_edits_replace_the_file_corrections() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
let data = super::super::insert::base64_encode(&png(20, 10));
|
||||
s.run("insert.picture", &json!({"data": data})).unwrap();
|
||||
let imported = PicFx::new(80_000, -80_000, 50_000);
|
||||
let set_fx = |s: &mut Session, v: PicFx| {
|
||||
with_obj(s, |o| {
|
||||
if let InlineObject::Image { fx, .. } = o {
|
||||
*fx = v;
|
||||
}
|
||||
})
|
||||
.unwrap()
|
||||
};
|
||||
set_fx(&mut s, imported);
|
||||
let state = |s: &Session| {
|
||||
let (_, o) = selected(s).unwrap();
|
||||
let InlineObject::Image { media, fx, .. } = o else { panic!("a picture") };
|
||||
let bytes = s.doc.media.get(&media).cloned().unwrap();
|
||||
(media, fx, image::load_from_memory(&bytes).unwrap().to_rgba8().pixels().map(|p| p.0).collect::<Vec<_>>())
|
||||
};
|
||||
let (orig_media, _, orig) = state(&s);
|
||||
// Transparency 50%: 50% (not 75%), the file's transparency dropped, its lum kept.
|
||||
s.run("picture.transparency", &json!({"percent": 50})).unwrap();
|
||||
let (_, fx, px) = state(&s);
|
||||
assert_eq!(fx, PicFx::new(80_000, -80_000, 0));
|
||||
assert!(px.iter().all(|p| p[3] == 127));
|
||||
// 0%: opaque, no `a:alphaModFix` left to write.
|
||||
s.run("picture.transparency", &json!({"percent": 0})).unwrap();
|
||||
let (_, fx, px) = state(&s);
|
||||
assert_eq!((fx.transparency, px), (0, orig.clone()));
|
||||
// Color other than Washout keeps the file's lum; Washout replaces it.
|
||||
s.run("picture.color", &json!({"mode": "grayscale"})).unwrap();
|
||||
assert_eq!(state(&s).1, PicFx::new(80_000, -80_000, 0));
|
||||
s.run("picture.color", &json!({"mode": "saturation", "saturation": 100})).unwrap();
|
||||
assert_eq!(state(&s).1, PicFx::new(80_000, -80_000, 0));
|
||||
s.run("picture.color", &json!({"mode": "washout"})).unwrap();
|
||||
assert_eq!(state(&s).1, PicFx::default());
|
||||
// Corrections bake from the original and replace the file's lum.
|
||||
set_fx(&mut s, imported);
|
||||
s.run("picture.corrections", &json!({"brightness": 0})).unwrap();
|
||||
let (_, fx, px) = state(&s);
|
||||
assert_eq!((fx, px), (PicFx::new(0, 0, 50_000), orig.clone()));
|
||||
// Reset: the imported bitmap, no corrections at all.
|
||||
set_fx(&mut s, imported);
|
||||
s.run("picture.reset", &json!({})).unwrap();
|
||||
let (media, fx, px) = state(&s);
|
||||
assert_eq!((media, fx, px), (orig_media, PicFx::default(), orig));
|
||||
}
|
||||
|
||||
/// Two pictures showing the same media, one with Washout from its file and one without:
|
||||
/// edits on both bake to the same bytes, and resetting the plain one leaves it without
|
||||
/// corrections (it used to be able to pick up the other's).
|
||||
#[test]
|
||||
fn reset_never_borrows_another_pictures_corrections() {
|
||||
let mut d = wordcraft_doc::Document::new();
|
||||
let media = d.add_media(png(20, 10), "png");
|
||||
let washout = PicFx::new(70_000, -70_000, 0);
|
||||
let mut p = wordcraft_doc::Paragraph::new();
|
||||
for fx in [PicFx::default(), washout] {
|
||||
let pic = InlineObject::Image {
|
||||
media: media.clone(),
|
||||
w: 40.0,
|
||||
h: 20.0,
|
||||
alt: String::new(),
|
||||
float: Default::default(),
|
||||
crop: [0.0; 4],
|
||||
fx,
|
||||
ole: None,
|
||||
};
|
||||
p.insert_object(0, pic, &Default::default()).unwrap();
|
||||
}
|
||||
d.body = vec![wordcraft_doc::para_block(p)];
|
||||
let mut s = Session::new(d);
|
||||
let pick = |s: &mut Session, at: usize| {
|
||||
s.run("select.range", &json!({"anchor": {"block": 0, "off": at}, "focus": {"block": 0, "off": at + 3}})).unwrap();
|
||||
};
|
||||
let fx_at = |s: &mut Session, at: usize| {
|
||||
pick(s, at);
|
||||
match selected(s).unwrap().1 {
|
||||
InlineObject::Image { fx, media, .. } => (fx, media),
|
||||
o => panic!("{o:?}"),
|
||||
}
|
||||
};
|
||||
assert_eq!(fx_at(&mut s, 0).0, washout, "A");
|
||||
assert_eq!(fx_at(&mut s, 3).0, PicFx::default(), "B");
|
||||
for at in [0, 3] {
|
||||
pick(&mut s, at);
|
||||
s.run("picture.transparency", &json!({"percent": 30})).unwrap();
|
||||
}
|
||||
assert_eq!(fx_at(&mut s, 0).1, fx_at(&mut s, 3).1, "the same baked bytes");
|
||||
pick(&mut s, 3);
|
||||
s.run("picture.reset", &json!({})).unwrap();
|
||||
assert_eq!(fx_at(&mut s, 3), (PicFx::default(), media.clone()));
|
||||
assert_eq!(fx_at(&mut s, 0).0, washout, "A keeps its own until reset");
|
||||
}
|
||||
|
||||
/// #2052: a picture style picked after another replaces it (Thick Frame then Simple Frame is
|
||||
/// only the simple frame), a border replaces the border and keeps the style, an artistic
|
||||
/// effect replaces the effect, and transparency 50% then 10% is 10%, as in Word.
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{CmdError, CmdResult, CommandSpec, Prefs, Session, p};
|
||||
use wordcraft_doc::odf_profile::{OdfPreset, OdfProfile};
|
||||
|
||||
/// Options panes, in the order the page lists them (the `pane` param of `file.options`).
|
||||
pub const PANES: [&str; 8] = ["general", "display", "proofing", "save", "language", "accessibility", "advanced", "agents"];
|
||||
@@ -22,7 +23,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("file.options", "Options", "File", options)
|
||||
.params(
|
||||
r#"{"pane"?: "general|display|proofing|save|language|accessibility|advanced|agents", "author"?: string, "initials"?: string, "units"?: "inches|centimeters|millimeters|points|picas|auto" (auto: the region's), "region"?: string (a locale tag like pl-PL, saved: the paper File › New starts on, the default units and the decimal separator; auto: the system's), "typingReplacesSelection"?: bool, "insertKeyOvertype"?: bool, "overtype"?: bool, "marks"?: {"tabs"?: bool, "spaces"?: bool, "paragraphs"?: bool, "hidden"?: bool}, "checkSpelling"?: bool, "markGrammar"?: bool, "ignoreUppercase"?: bool, "ignoreNumbers"?: bool, "ignoreInternet"?: bool, "dictionariesFolder"?: string (a folder of Hunspell .aff/.dic files searched first, saved; auto: the system locations only), "userTemplatesFolder"?: string (the folder File › New lists personal templates from, saved; auto: the app's own Templates folder; not a network folder), "addWords"?: [string], "removeWords"?: [string]} — with any setting it changes them and opens nothing; without, it opens File › Options (at `pane`). Returns the settings."#,
|
||||
r#"{"pane"?: "general|display|proofing|save|language|accessibility|advanced|agents", "author"?: string, "initials"?: string, "units"?: "inches|centimeters|millimeters|points|picas|auto" (auto: the region's), "region"?: string (a locale tag like pl-PL, saved: the paper File › New starts on, the default units and the decimal separator; auto: the system's), "typingReplacesSelection"?: bool, "insertKeyOvertype"?: bool, "overtype"?: bool, "marks"?: {"tabs"?: bool, "spaces"?: bool, "paragraphs"?: bool, "hidden"?: bool}, "checkSpelling"?: bool, "markGrammar"?: bool, "ignoreUppercase"?: bool, "ignoreNumbers"?: bool, "ignoreInternet"?: bool, "dictionariesFolder"?: string (a folder of Hunspell .aff/.dic files searched first, saved; auto: the system locations only), "userTemplatesFolder"?: string (the folder File › New lists personal templates from, saved; auto: the app's own Templates folder; not a network folder), "addWords"?: [string], "removeWords"?: [string], "odf"?: {"preset"?: "word|libreoffice|custom" (word: Microsoft Word style, the default; libreoffice: LibreOffice style), "pageMargin"?: pt, "headerMinHeight"?: pt, "headerHeightIncludesSpacing"?: bool, "dynamicSpacing"?: bool, "tabsFromIndent"?: bool, "columnGap"?: pt, "shapeFill"?: "RRGGBB", "shapeLine"?: "RRGGBB", "hairline"?: pt, "pageFrameParagraphIsPage"?: bool, "inlineInvalidRelationIsBaseline"?: bool, "pictureLinesFitPictures"?: bool, "balanceSectionColumns"?: bool, "sumParagraphSpacing"?: bool, "widowControl"?: bool} (how OpenDocument files opened from now on fill in what their standard leaves open, docs/odf-interpretation.md; a value without a preset makes it custom)} — with any setting it changes them and opens nothing; without, it opens File › Options (at `pane`). Returns the settings."#,
|
||||
)
|
||||
.pure(),
|
||||
CommandSpec::new("text.overtype", "Overtype", "File › Options › Advanced", |s, v| {
|
||||
@@ -145,6 +146,7 @@ fn options(s: &mut Session, v: &Value) -> CmdResult {
|
||||
Some(_) => return Err(CmdError::Params("`marks` must be an object of true/false".into())),
|
||||
}
|
||||
let check_spelling = bool_param(v, "checkSpelling")?;
|
||||
let odf = odf_param(v, &s.prefs.odf)?;
|
||||
let add = words_param(v, "addWords")?;
|
||||
let remove = words_param(v, "removeWords")?;
|
||||
|
||||
@@ -157,6 +159,7 @@ fn options(s: &mut Session, v: &Value) -> CmdResult {
|
||||
|| !flag_values.is_empty()
|
||||
|| !mark_values.is_empty()
|
||||
|| check_spelling.is_some()
|
||||
|| odf.is_some()
|
||||
|| !add.is_empty()
|
||||
|| !remove.is_empty();
|
||||
if let Some(name) = author {
|
||||
@@ -188,6 +191,10 @@ fn options(s: &mut Session, v: &Value) -> CmdResult {
|
||||
s.view.proofing = b;
|
||||
s.prefs.check_spelling = b;
|
||||
}
|
||||
// ODF files opened from now on fill in what their standard leaves open this way.
|
||||
if let Some(o) = odf {
|
||||
s.prefs.odf = o;
|
||||
}
|
||||
for w in &add {
|
||||
wordcraft_proof::add_word(w);
|
||||
}
|
||||
@@ -233,6 +240,9 @@ pub fn current(s: &Session) -> Value {
|
||||
"dictionary": wordcraft_proof::user_dictionary(),
|
||||
"dictionariesFolder": pr.dictionaries_folder,
|
||||
"userTemplatesFolder": pr.user_templates_folder,
|
||||
// How ODF files fill in what the standard leaves open: the chosen preset, the values
|
||||
// that apply, and the custom values kept for when the preset is custom.
|
||||
"odf": {"preset": pr.odf.preset, "effective": pr.odf.effective(), "custom": pr.odf},
|
||||
"dictionaries": wordcraft_proof::installed_dictionaries()
|
||||
.into_iter()
|
||||
.map(|(lang, dic)| json!({"language": lang.tag(), "file": dic.map(|d| d.to_string_lossy().into_owned())}))
|
||||
@@ -240,6 +250,45 @@ pub fn current(s: &Session) -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
/// The `odf` setting: `{"preset"?: "word|libreoffice|custom" (word: Microsoft Word style, the default; libreoffice: LibreOffice style), …case values}` over `current`.
|
||||
/// A case value given without a preset makes the profile custom; the values are kept whatever
|
||||
/// the preset, so choosing custom again brings them back. Lengths are points, at most 22 inches;
|
||||
/// colours `RRGGBB`.
|
||||
fn odf_param(v: &Value, current: &OdfProfile) -> Result<Option<OdfProfile>, CmdError> {
|
||||
let Some(o) = v.get("odf").filter(|o| !o.is_null()) else { return Ok(None) };
|
||||
let obj = o.as_object().ok_or_else(|| CmdError::Params("`odf` must be an object".into()))?;
|
||||
let mut merged = serde_json::to_value(current).map_err(|e| CmdError::Failed(e.to_string()))?;
|
||||
let known: Vec<String> = merged.as_object().map(|m| m.keys().cloned().collect()).unwrap_or_default();
|
||||
let mut values = false;
|
||||
for (k, x) in obj {
|
||||
if !known.contains(k) {
|
||||
return Err(CmdError::Params(format!("`odf.{k}`: not a setting; one of {}", known.join(", "))));
|
||||
}
|
||||
if let Some(n) = x.as_f64().filter(|_| k != "preset")
|
||||
&& !(n.is_finite() && (0.0..=f64::from(wordcraft_doc::odf_profile::MAX_LENGTH)).contains(&n))
|
||||
{
|
||||
return Err(CmdError::Params(format!("`odf.{k}`: a length from 0 to {} points", wordcraft_doc::odf_profile::MAX_LENGTH)));
|
||||
}
|
||||
let x = if k == "preset" {
|
||||
let p = x.as_str().and_then(OdfPreset::from_id).ok_or_else(|| CmdError::Params("`odf.preset`: word, libreoffice or custom".into()))?;
|
||||
json!(p)
|
||||
} else {
|
||||
values = true;
|
||||
x.clone()
|
||||
};
|
||||
if let Some(m) = merged.as_object_mut() {
|
||||
m.insert(k.clone(), x);
|
||||
}
|
||||
}
|
||||
if values
|
||||
&& !obj.contains_key("preset")
|
||||
&& let Some(m) = merged.as_object_mut()
|
||||
{
|
||||
m.insert("preset".into(), json!(OdfPreset::Custom));
|
||||
}
|
||||
serde_json::from_value(merged).map(Some).map_err(|e| CmdError::Params(format!("`odf`: {e}")))
|
||||
}
|
||||
|
||||
/// A folder setting (`dictionariesFolder`, `userTemplatesFolder`): `auto` (or nothing) forgets
|
||||
/// it; otherwise an existing folder, given as an absolute path or one starting with `~/`. With
|
||||
/// `local_only`, a network folder is refused before anything looks at it (#2852): even checking
|
||||
|
||||
@@ -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}"))),
|
||||
})
|
||||
|
||||
+41
-16
@@ -1,6 +1,7 @@
|
||||
//! File format dispatch by extension. Format crates plug in here.
|
||||
|
||||
use wordcraft_doc::Document;
|
||||
use wordcraft_doc::odf_profile::OdfProfile;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod safe_save;
|
||||
@@ -141,13 +142,13 @@ pub fn can_encrypt(name: &str) -> bool {
|
||||
/// Open a password-protected ODT package (ODF 1.3 Part 2 §3.4). The errors for a missing or
|
||||
/// wrong password are the `.docx` reader's texts, so [`needs_password`] and [`wrong_password`]
|
||||
/// recognise them.
|
||||
fn open_encrypted_odt(bytes: &[u8], password: Option<&str>, page: &wordcraft_doc::SectionProps) -> Result<Document, String> {
|
||||
fn open_encrypted_odt(bytes: &[u8], password: Option<&str>, page: &wordcraft_doc::SectionProps, odf: &OdfProfile) -> Result<Document, String> {
|
||||
let pw = password.ok_or_else(|| format!("ODT: {}", wordcraft_docx::PASSWORD_REQUIRED))?;
|
||||
let plain = wordcraft_formats::odt::decrypt(bytes, pw).map_err(|e| match e {
|
||||
wordcraft_formats::odt::CryptError::WrongPassword => format!("ODT: {}", wordcraft_docx::WRONG_PASSWORD),
|
||||
other => other.to_string(),
|
||||
})?;
|
||||
match crate::io_ext::open("odt", &plain, page) {
|
||||
match crate::io_ext::open("odt", &plain, page, odf) {
|
||||
Some(r) => r,
|
||||
None => Err("ODT: no reader".into()),
|
||||
}
|
||||
@@ -187,7 +188,7 @@ fn open_binary_doc(bytes: &[u8], password: Option<&str>) -> Result<(Document, bo
|
||||
/// whether the file was encrypted. Without the password needed the error satisfies
|
||||
/// [`needs_password`]; with a wrong one, [`wrong_password`].
|
||||
pub fn open_bytes_with(name: &str, bytes: &[u8], password: Option<&str>) -> Result<(Document, bool), String> {
|
||||
open_bytes_in(name, bytes, password, &wordcraft_geom::Region::default())
|
||||
open_bytes_in(name, bytes, password, &wordcraft_geom::Region::default(), &OdfProfile::default())
|
||||
}
|
||||
|
||||
/// The page a file that gives no page size (plain text, Markdown, HTML) opens on: `region`'s
|
||||
@@ -198,10 +199,17 @@ pub fn region_page(region: &wordcraft_geom::Region) -> wordcraft_doc::SectionPro
|
||||
}
|
||||
|
||||
/// [`open_bytes_with`] in `region`: a file that gives no page size opens on the region's paper
|
||||
/// (see [`region_page`]); a document with its own page size keeps it.
|
||||
pub fn open_bytes_in(name: &str, bytes: &[u8], password: Option<&str>, region: &wordcraft_geom::Region) -> Result<(Document, bool), String> {
|
||||
/// (see [`region_page`]); a document with its own page size keeps it. An OpenDocument file fills
|
||||
/// in what its standard leaves open as `odf` says (the user's choice, [`crate::Prefs::odf`]).
|
||||
pub fn open_bytes_in(
|
||||
name: &str,
|
||||
bytes: &[u8],
|
||||
password: Option<&str>,
|
||||
region: &wordcraft_geom::Region,
|
||||
odf: &OdfProfile,
|
||||
) -> Result<(Document, bool), String> {
|
||||
let page = region_page(region);
|
||||
let (doc, encrypted) = read_bytes(name, bytes, password, &page)?;
|
||||
let (doc, encrypted) = read_bytes(name, bytes, password, &page, odf)?;
|
||||
load_embedded_fonts(&doc);
|
||||
Ok((doc, encrypted))
|
||||
}
|
||||
@@ -219,7 +227,13 @@ fn load_embedded_fonts(doc: &Document) {
|
||||
}
|
||||
}
|
||||
|
||||
fn read_bytes(name: &str, bytes: &[u8], password: Option<&str>, page: &wordcraft_doc::SectionProps) -> Result<(Document, bool), String> {
|
||||
fn read_bytes(
|
||||
name: &str,
|
||||
bytes: &[u8],
|
||||
password: Option<&str>,
|
||||
page: &wordcraft_doc::SectionProps,
|
||||
odf: &OdfProfile,
|
||||
) -> Result<(Document, bool), String> {
|
||||
let ext = ext_of(name);
|
||||
if matches!(ext.as_str(), "docx" | "docm" | "dotx" | "dotm" | "doc" | "dot") && wordcraft_docx::is_encrypted(bytes) {
|
||||
let mut doc = read_docx(bytes, password).map_err(|e| e.to_string())?;
|
||||
@@ -227,7 +241,7 @@ fn read_bytes(name: &str, bytes: &[u8], password: Option<&str>, page: &wordcraft
|
||||
return Ok((doc, true));
|
||||
}
|
||||
if (ext == "odt" || ext == "ott") && wordcraft_formats::odt::is_encrypted(bytes) {
|
||||
let mut doc = open_encrypted_odt(bytes, password, page)?;
|
||||
let mut doc = open_encrypted_odt(bytes, password, page, odf)?;
|
||||
opened(&mut doc);
|
||||
return Ok((doc, true));
|
||||
}
|
||||
@@ -247,7 +261,7 @@ fn read_bytes(name: &str, bytes: &[u8], password: Option<&str>, page: &wordcraft
|
||||
d.normalize_runs();
|
||||
d
|
||||
}
|
||||
other => match crate::io_ext::open(other, bytes, page) {
|
||||
other => match crate::io_ext::open(other, bytes, page, odf) {
|
||||
Some(r) => r?,
|
||||
None => return open_by_content(name, other, bytes, password, page),
|
||||
},
|
||||
@@ -421,10 +435,11 @@ fn open_by_content(
|
||||
} else if is_word_xml(bytes) {
|
||||
(wordcraft_docx::read_xml(bytes).map_err(|e| format!("{name}: {e}"))?, false)
|
||||
} else if matches!(ext, "doc" | "dot" | "docx" | "docm" | "dotx" | "dotm" | "rtf") && looks_like_web_page(bytes) {
|
||||
let opened = crate::io_ext::open("html", bytes, page).unwrap_or_else(|| Err(format!("{name}: can't open web pages")));
|
||||
let opened = crate::io_ext::open("html", bytes, page, &OdfProfile::default()).unwrap_or_else(|| Err(format!("{name}: can't open web pages")));
|
||||
(opened?, false)
|
||||
} else if matches!(ext, "doc" | "dot" | "docx" | "docm" | "dotx" | "dotm" | "rtf") && wordcraft_formats::mht::is_mht(bytes) {
|
||||
let opened = crate::io_ext::open("mht", bytes, page).unwrap_or_else(|| Err(format!("{name}: can't open web archives")));
|
||||
let opened =
|
||||
crate::io_ext::open("mht", bytes, page, &OdfProfile::default()).unwrap_or_else(|| Err(format!("{name}: can't open web archives")));
|
||||
(opened?, false)
|
||||
} else if wordcraft_formats::txt::is_text(bytes) {
|
||||
(wordcraft_formats::txt::import_on(bytes, page), false)
|
||||
@@ -477,12 +492,17 @@ pub fn open_path(path: &std::path::Path) -> Result<Document, String> {
|
||||
/// [`open_path`] for a file that may be password-protected (see [`open_bytes_with`]).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn open_path_with(path: &std::path::Path, password: Option<&str>) -> Result<(Document, bool), String> {
|
||||
open_path_in(path, password, &wordcraft_geom::Region::default())
|
||||
open_path_in(path, password, &wordcraft_geom::Region::default(), &OdfProfile::default())
|
||||
}
|
||||
|
||||
/// [`open_path_with`] in `region` (see [`open_bytes_in`]).
|
||||
/// [`open_path_with`] in `region`, OpenDocument files as `odf` says (see [`open_bytes_in`]).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn open_path_in(path: &std::path::Path, password: Option<&str>, region: &wordcraft_geom::Region) -> Result<(Document, bool), String> {
|
||||
pub fn open_path_in(
|
||||
path: &std::path::Path,
|
||||
password: Option<&str>,
|
||||
region: &wordcraft_geom::Region,
|
||||
odf: &OdfProfile,
|
||||
) -> Result<(Document, bool), String> {
|
||||
let bytes = read_document(path)?;
|
||||
let name = path.to_string_lossy();
|
||||
if matches!(ext_of(&name).as_str(), "html" | "htm" | "xhtml") {
|
||||
@@ -494,7 +514,7 @@ pub fn open_path_in(path: &std::path::Path, password: Option<&str>, region: &wor
|
||||
opened(&mut doc);
|
||||
return Ok((doc, false));
|
||||
}
|
||||
open_bytes_in(&name, &bytes, password, region)
|
||||
open_bytes_in(&name, &bytes, password, region, odf)
|
||||
}
|
||||
|
||||
/// Largest document file opened by path.
|
||||
@@ -831,7 +851,12 @@ pub fn open_path_with(path: &std::path::Path, _password: Option<&str>) -> Result
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn open_path_in(path: &std::path::Path, password: Option<&str>, _region: &wordcraft_geom::Region) -> Result<(Document, bool), String> {
|
||||
pub fn open_path_in(
|
||||
path: &std::path::Path,
|
||||
password: Option<&str>,
|
||||
_region: &wordcraft_geom::Region,
|
||||
_odf: &OdfProfile,
|
||||
) -> Result<(Document, bool), String> {
|
||||
open_path_with(path, password)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,14 +4,19 @@ use wordcraft_doc::Document;
|
||||
|
||||
/// Open a format other than plain text / JSON; files that give no page size (Markdown, HTML)
|
||||
/// start on `page`.
|
||||
pub fn open(ext: &str, bytes: &[u8], page: &wordcraft_doc::SectionProps) -> Option<Result<Document, String>> {
|
||||
pub fn open(
|
||||
ext: &str,
|
||||
bytes: &[u8],
|
||||
page: &wordcraft_doc::SectionProps,
|
||||
odf: &wordcraft_doc::odf_profile::OdfProfile,
|
||||
) -> Option<Result<Document, String>> {
|
||||
match ext {
|
||||
"docx" | "docm" | "dotx" | "dotm" => Some(crate::io::read_docx(bytes, None).map_err(|e| e.to_string())),
|
||||
"doc" | "dot" => Some(wordcraft_docbin::read(bytes).map_err(|e| e.to_string())),
|
||||
// Word XML Document (Flat OPC) or Word 2003 XML, told apart by the root element.
|
||||
"xml" => Some(wordcraft_docx::read_xml(bytes).map_err(|e| e.to_string())),
|
||||
// HTML, Markdown, ODT, RTF…: pictures fit the text width (see `io::fit_pictures`).
|
||||
other => wordcraft_formats::import_on(other, bytes, page).map(|r| {
|
||||
other => wordcraft_formats::import_with(other, bytes, page, odf).map(|r| {
|
||||
r.map(|mut doc| {
|
||||
crate::io::fit_pictures(&mut doc);
|
||||
draw_charts(&mut doc);
|
||||
|
||||
@@ -59,6 +59,17 @@ pub struct Prefs {
|
||||
/// in Word). A damaged saved value reads as the defaults.
|
||||
#[serde(deserialize_with = "crate::cmd::tools::lenient_autocorrect")]
|
||||
pub autocorrect: crate::cmd::tools::AutoCorrectPrefs,
|
||||
/// How OpenDocument files fill in what their standard leaves to the application: WordCraft's
|
||||
/// choices, LibreOffice's, or the user's own (File › Options › Advanced, `file.options`'s
|
||||
/// `odf`; see `docs/odf-interpretation.md`). A damaged saved value reads as WordCraft's.
|
||||
#[serde(deserialize_with = "lenient_odf")]
|
||||
pub odf: wordcraft_doc::odf_profile::OdfProfile,
|
||||
}
|
||||
|
||||
/// A saved ODF profile that doesn't read (an older or damaged one) is WordCraft's.
|
||||
fn lenient_odf<'de, D: serde::Deserializer<'de>>(d: D) -> Result<wordcraft_doc::odf_profile::OdfProfile, D::Error> {
|
||||
let v = serde_json::Value::deserialize(d)?;
|
||||
Ok(serde_json::from_value(v).unwrap_or_default())
|
||||
}
|
||||
|
||||
impl Default for Prefs {
|
||||
@@ -84,6 +95,7 @@ impl Default for Prefs {
|
||||
detect_language: true,
|
||||
default_language: None,
|
||||
autocorrect: Default::default(),
|
||||
odf: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+51
-150
@@ -8175,16 +8175,8 @@ fn convert_text_to_table_keeps_inline_content() {
|
||||
p.format(0, 4, &|c| c.bold = Some(true)).unwrap();
|
||||
p.format(6, 10, &|c| c.link = Some("https://example.com".into())).unwrap();
|
||||
p.format(11, 14, &|c| c.del = Some(1)).unwrap();
|
||||
let img = InlineObject::Image {
|
||||
media: "pic".into(),
|
||||
w: 10.0,
|
||||
h: 10.0,
|
||||
alt: String::new(),
|
||||
float: Default::default(),
|
||||
crop: [0.0; 4],
|
||||
fx: Default::default(),
|
||||
ole: None,
|
||||
};
|
||||
let img =
|
||||
InlineObject::Image { media: "pic".into(), w: 10.0, h: 10.0, alt: String::new(), float: Default::default(), crop: [0.0; 4], ole: None };
|
||||
p.insert_object(5, img, &Default::default()).unwrap();
|
||||
}
|
||||
run(&mut s, "select.all", json!({}));
|
||||
@@ -9659,47 +9651,6 @@ fn smart_art_edits_undo_one_step_each() {
|
||||
run(&mut s, "picture.size", json!({"width": 200, "height": 120, "lockAspect": false}));
|
||||
}
|
||||
|
||||
/// An item's own colour survives item edits and layout changes, and a style colour drops it.
|
||||
#[test]
|
||||
fn smart_art_item_colors_stay_until_a_style_is_picked() {
|
||||
let mut s = s();
|
||||
run(&mut s, "insert.smartArt", json!({"layout": "process"}));
|
||||
let colors = |s: &Session| cmd::smart_art::selected_smart_art(s).unwrap().items.iter().map(|i| i.color.map(|c| c.hex())).collect::<Vec<_>>();
|
||||
assert!(!cmd::smart_art::selected_smart_art(&s).unwrap().has_custom_colors());
|
||||
run(&mut s, "smartArt.itemColor", json!({"index": 1, "color": "FF8800"}));
|
||||
assert_eq!(colors(&s), [None, Some("FF8800".to_string()), None, None]);
|
||||
run(&mut s, "smartArt.items", json!({"items": [{"text": "A"}, {"text": "B", "color": "#ff8800"}]}));
|
||||
run(&mut s, "smartArt.layout", json!({"layout": "cycle"}));
|
||||
run(&mut s, "smartArt.addShape", json!({"after": 0}));
|
||||
assert_eq!(colors(&s), [None, None, Some("FF8800".to_string())]);
|
||||
assert!(s.run("smartArt.itemColor", &json!({"index": 0, "color": "nope"})).is_err());
|
||||
run(&mut s, "smartArt.itemColor", json!({"index": 2}));
|
||||
assert!(!cmd::smart_art::selected_smart_art(&s).unwrap().has_custom_colors());
|
||||
run(&mut s, "smartArt.itemColor", json!({"index": 0, "color": "00FF00"}));
|
||||
run(&mut s, "smartArt.colors", json!({"variant": "accent2"}));
|
||||
assert!(!cmd::smart_art::selected_smart_art(&s).unwrap().has_custom_colors());
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(cmd::smart_art::selected_smart_art(&s).unwrap().has_custom_colors(), "undo brings the colours back");
|
||||
}
|
||||
|
||||
/// A malformed colour must not silently replace a coloured item with its style colour.
|
||||
#[test]
|
||||
fn smart_art_item_colors_reject_invalid_values_without_changing_the_graphic() {
|
||||
let mut s = s();
|
||||
run(&mut s, "insert.smartArt", json!({"items": [{"text": "Keep", "color": "123456"}]}));
|
||||
let before = cmd::smart_art::selected_smart_art(&s).unwrap();
|
||||
for command in ["insert.smartArt", "smartArt.items"] {
|
||||
for color in [json!("nope"), json!(42), json!(true), json!([]), json!({})] {
|
||||
assert!(s.run(command, &json!({"items": [{"text": "Replace", "color": color}]})).is_err(), "{command}: {color}");
|
||||
assert_eq!(cmd::smart_art::selected_smart_art(&s).unwrap(), before);
|
||||
}
|
||||
run(&mut s, command, json!({"items": [{"text": "Default", "color": null}, {"text": "Omitted"}]}));
|
||||
assert!(!cmd::smart_art::selected_smart_art(&s).unwrap().has_custom_colors());
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(cmd::smart_art::selected_smart_art(&s).unwrap(), before);
|
||||
}
|
||||
}
|
||||
|
||||
/// #497: hostile SmartArt params are capped or refused, never a panic.
|
||||
#[test]
|
||||
fn smart_art_hostile_params_are_capped() {
|
||||
@@ -9721,7 +9672,6 @@ fn smart_art_hostile_params_are_capped() {
|
||||
"smartArt.demote",
|
||||
"smartArt.layout",
|
||||
"smartArt.colors",
|
||||
"smartArt.itemColor",
|
||||
"smartArt.reset",
|
||||
"smartArt.textPane",
|
||||
] {
|
||||
@@ -11678,87 +11628,6 @@ fn drop_cap_options_set_position_lines_distance_and_font() {
|
||||
run(&mut s, "text.insert", json!({"text": "Cell"}));
|
||||
assert!(s.sel.focus.path.cell().is_some());
|
||||
assert!(s.run("insert.dropCap", &json!({})).is_err(), "off in a table cell");
|
||||
|
||||
// A selection is as good as its worst paragraph: an empty one or one starting with a space
|
||||
// refuses a drop cap for all (the button greys out); None works while there is one to take away.
|
||||
let mut s = self::s();
|
||||
run(&mut s, "text.insert", json!({"text": "First"}));
|
||||
run(&mut s, "text.newParagraph", json!({}));
|
||||
run(&mut s, "text.newParagraph", json!({}));
|
||||
run(&mut s, "text.insert", json!({"text": "Third"}));
|
||||
assert_eq!(s.doc.body.len(), 3);
|
||||
run(&mut s, "select.all", json!({}));
|
||||
assert!(s.run("insert.dropCap", &json!({"position": "dropped"})).is_err(), "an empty paragraph in the selection");
|
||||
assert!(s.doc.body.iter().filter_map(|b| b.as_para()).all(|p| p.props.drop_cap.is_none()), "nothing changed");
|
||||
assert!(s.why_disabled("insert.dropCap").is_some(), "greyed out: nothing can take a drop cap or lose one");
|
||||
run(&mut s, "select.range", json!({"anchor": {"block": 2, "off": 0}, "focus": {"block": 2, "off": 0}}));
|
||||
run(&mut s, "insert.dropCap", json!({"position": "dropped"}));
|
||||
// From the drop cap up into the empty paragraph (the caret there): None takes it away, the
|
||||
// other positions are refused, as the enabled check says.
|
||||
run(&mut s, "select.range", json!({"anchor": {"block": 2, "off": 5}, "focus": {"block": 1, "off": 0}}));
|
||||
assert_eq!(s.why_disabled("insert.dropCap"), None, "a drop cap to take away");
|
||||
assert!(s.run("insert.dropCap", &json!({"position": "margin"})).is_err(), "the empty paragraph can't take one");
|
||||
assert!(s.run("insert.dropCap", &json!({"position": "none"})).is_ok(), "None takes it away");
|
||||
assert!(s.doc.body.iter().filter_map(|b| b.as_para()).all(|p| p.props.drop_cap.is_none()), "taken away");
|
||||
let mut s = self::s();
|
||||
run(&mut s, "text.insert", json!({"text": " first"}));
|
||||
run(&mut s, "text.newParagraph", json!({}));
|
||||
run(&mut s, "text.insert", json!({"text": "Second"}));
|
||||
run(&mut s, "select.all", json!({}));
|
||||
assert!(s.run("insert.dropCap", &json!({})).is_err(), "a paragraph starting with a space in the selection");
|
||||
}
|
||||
|
||||
/// #2601: `keep` moves a whole selection to Dropped / In margin without flattening it: each
|
||||
/// paragraph keeps its own lines, distance and font, one without a drop cap gets the defaults,
|
||||
/// and it is one undo step. Without `keep` the options not given are reset, as before.
|
||||
#[test]
|
||||
fn drop_cap_keep_leaves_each_paragraphs_options() {
|
||||
let caps = |s: &Session| {
|
||||
s.doc
|
||||
.body
|
||||
.iter()
|
||||
.filter_map(|b| b.as_para())
|
||||
.map(|p| (p.props.drop_cap, p.props.drop_cap_margin, p.props.drop_cap_distance, p.props.drop_cap_font.clone()))
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": "First"}));
|
||||
for text in ["Second", "Third"] {
|
||||
run(&mut s, "text.newParagraph", json!({}));
|
||||
run(&mut s, "text.insert", json!({"text": text}));
|
||||
}
|
||||
run(&mut s, "select.range", json!({"anchor": {"block": 0, "off": 0}, "focus": {"block": 0, "off": 0}}));
|
||||
run(&mut s, "insert.dropCap", json!({"position": "dropped", "lines": 5, "distance": 7.0, "font": "Liberation Mono"}));
|
||||
run(&mut s, "select.range", json!({"anchor": {"block": 1, "off": 0}, "focus": {"block": 1, "off": 0}}));
|
||||
run(&mut s, "insert.dropCap", json!({"position": "dropped", "lines": 2, "distance": 3.0}));
|
||||
let before = caps(&s);
|
||||
run(&mut s, "select.all", json!({}));
|
||||
run(&mut s, "insert.dropCap", json!({"position": "margin", "keep": true}));
|
||||
assert_eq!(
|
||||
caps(&s),
|
||||
vec![
|
||||
(Some(5), Some(true), Some(7.0), Some("Liberation Mono".to_string())),
|
||||
(Some(2), Some(true), Some(3.0), None),
|
||||
(Some(3), Some(true), None, None),
|
||||
],
|
||||
"each keeps its own options; the one without gets the defaults"
|
||||
);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(caps(&s), before, "one undo step");
|
||||
|
||||
// Options given explicitly win over the kept ones; a junk `keep` is false.
|
||||
run(&mut s, "insert.dropCap", json!({"position": "dropped", "keep": true, "lines": 4}));
|
||||
assert_eq!(caps(&s).iter().map(|c| c.0).collect::<Vec<_>>(), vec![Some(4); 3]);
|
||||
assert_eq!(caps(&s).first().map(|c| (c.2, c.3.clone())), Some((Some(7.0), Some("Liberation Mono".to_string()))), "distance and font still kept");
|
||||
// A blank font given with `keep` clears the kept font (the text's own again).
|
||||
run(&mut s, "insert.dropCap", json!({"position": "dropped", "keep": true, "font": " "}));
|
||||
assert_eq!(caps(&s).first().map(|c| c.3.clone()), Some(None), "blank font clears the kept one");
|
||||
run(&mut s, "insert.dropCap", json!({"position": "dropped", "lines": 5, "distance": 7.0, "font": "Liberation Mono"}));
|
||||
run(&mut s, "insert.dropCap", json!({"position": "margin", "keep": "yes"}));
|
||||
assert_eq!(caps(&s), vec![(Some(3), Some(true), None, None); 3], "junk keep: reset what isn't given");
|
||||
// None ignores keep.
|
||||
run(&mut s, "insert.dropCap", json!({"position": "none", "keep": true}));
|
||||
assert_eq!(caps(&s), vec![(None, None, None, None); 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -12146,14 +12015,54 @@ fn emphasis_marks_are_set_and_drawn() {
|
||||
assert!(s.run("format.emphasisMark", &json!({"mark": "sparkles"})).is_err());
|
||||
}
|
||||
|
||||
/// Preferences saved while OpenDocument files had their own settings still read, without them,
|
||||
/// as does a document saved with what those settings kept for its layout.
|
||||
/// File › Options › Advanced › OpenDocument files: a preset, or one case's value (which makes the
|
||||
/// profile custom); junk is refused; files opened afterwards follow it.
|
||||
#[test]
|
||||
fn what_the_opendocument_settings_saved_still_reads() {
|
||||
let prefs: crate::Prefs = serde_json::from_value(json!({"countNotes": false, "odf": {"preset": "libreoffice", "columnGap": 0.0}})).unwrap();
|
||||
assert!(!prefs.count_notes);
|
||||
let layout: wordcraft_doc::OdfLayout = serde_json::from_value(json!({"pictureLinesFitPictures": true, "tabsFromIndent": true})).unwrap();
|
||||
assert!(layout.tabs_from_indent);
|
||||
fn odf_interpretation_is_an_option_files_follow() {
|
||||
use wordcraft_doc::odf_profile::{OdfPreset, OdfProfile};
|
||||
let mut s = s();
|
||||
assert_eq!(s.prefs.odf, OdfProfile::word(), "Microsoft Word's style is the default");
|
||||
let r = run(&mut s, "file.options", json!({"odf": {"preset": "LibreOffice"}}));
|
||||
assert_eq!(r["odf"]["preset"], json!("libreoffice"));
|
||||
assert_eq!(s.prefs.odf.effective(), OdfProfile::libreoffice());
|
||||
// The two display styles, back and forth; Word's by its earlier id too.
|
||||
let r = run(&mut s, "file.options", json!({"odf": {"preset": "word"}}));
|
||||
assert_eq!(r["odf"]["preset"], json!("word"));
|
||||
assert_eq!(s.prefs.odf.effective(), OdfProfile::word());
|
||||
run(&mut s, "file.options", json!({"odf": {"preset": "libreoffice"}}));
|
||||
let r = run(&mut s, "file.options", json!({"odf": {"preset": "wordcraft"}}));
|
||||
assert_eq!(r["odf"]["preset"], json!("word"));
|
||||
run(&mut s, "file.options", json!({"odf": {"preset": "libreoffice"}}));
|
||||
run(&mut s, "file.options", json!({"odf": {"columnGap": 18.0}}));
|
||||
assert_eq!((s.prefs.odf.preset, s.prefs.odf.effective().column_gap), (OdfPreset::Custom, 18.0));
|
||||
for junk in [
|
||||
json!({"odf": {"nope": 1}}),
|
||||
json!({"odf": {"columnGap": -1}}),
|
||||
json!({"odf": {"hairline": 1e9}}),
|
||||
json!({"odf": 3}),
|
||||
json!({"odf": {"preset": "google"}}),
|
||||
] {
|
||||
assert!(s.run("file.options", &junk).is_err(), "{junk}");
|
||||
}
|
||||
assert_eq!(s.prefs.odf.effective().column_gap, 18.0, "a refused change leaves it");
|
||||
|
||||
// A tab stop in an indented paragraph of a file that doesn't say how tabs are measured.
|
||||
let content = r#"<office:document-content xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0"><office:automatic-styles><style:style style:name="P1" style:family="paragraph"><style:paragraph-properties fo:margin-left="0.5in"><style:tab-stops><style:tab-stop style:position="1in"/></style:tab-stops></style:paragraph-properties></style:style></office:automatic-styles><office:body><office:text><text:p text:style-name="P1">a</text:p></office:text></office:body></office:document-content>"#;
|
||||
let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||||
for (name, data) in [("mimetype", "application/vnd.oasis.opendocument.text"), ("content.xml", content)] {
|
||||
z.start_file(name, o).unwrap();
|
||||
std::io::Write::write_all(&mut z, data.as_bytes()).unwrap();
|
||||
}
|
||||
let bytes = z.finish().unwrap().into_inner();
|
||||
let tab = |s: &Session| {
|
||||
let (d, _) = crate::io::open_bytes_in("t.odt", &bytes, None, &s.region(), &s.prefs.odf).unwrap();
|
||||
d.body[0].as_para().unwrap().props.tabs.clone().unwrap_or_default().first().map(|t| t.pos)
|
||||
};
|
||||
run(&mut s, "file.options", json!({"odf": {"preset": "wordcraft"}}));
|
||||
assert_eq!(tab(&s), Some(72.0), "WordCraft: from the margin");
|
||||
run(&mut s, "file.options", json!({"odf": {"preset": "libreoffice"}}));
|
||||
assert_eq!(tab(&s), Some(108.0), "LibreOffice: from the indent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -12266,16 +12175,8 @@ fn text_from_file_brings_the_files_pictures() {
|
||||
src.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().props.numbering =
|
||||
Some(wordcraft_doc::props::NumRef { num: list, level: 0 });
|
||||
let key = src.doc.add_media(tiny_png(), "png");
|
||||
let image = wordcraft_doc::InlineObject::Image {
|
||||
media: key,
|
||||
w: 20.0,
|
||||
h: 20.0,
|
||||
alt: String::new(),
|
||||
float: Default::default(),
|
||||
crop: [0.0; 4],
|
||||
ole: None,
|
||||
fx: Default::default(),
|
||||
};
|
||||
let image =
|
||||
wordcraft_doc::InlineObject::Image { media: key, w: 20.0, h: 20.0, alt: String::new(), float: Default::default(), crop: [0.0; 4], ole: None };
|
||||
src.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().insert_object(0, image, &Default::default()).unwrap();
|
||||
let path = dir.join("pictures.docx");
|
||||
std::fs::write(&path, wordcraft_docx::write(&src.doc).unwrap()).unwrap();
|
||||
|
||||
@@ -14,9 +14,7 @@ fn commented() -> Session {
|
||||
}
|
||||
|
||||
fn invalid_ids_leave_state_untouched(command: &str) {
|
||||
// Past `u32`: 2^32 truncates to comment 0 and 2^32 + 5 to 5, and `u64::MAX` is the far end.
|
||||
for id in [json!(-1), json!(0.5), json!(0.0), json!("0"), Value::Null, json!(false), json!(4294967296u64), json!(4294967301u64), json!(u64::MAX)]
|
||||
{
|
||||
for id in [json!(-1), json!(0.5), json!(0.0), json!("0"), Value::Null, json!(false), json!(4294967296u64)] {
|
||||
for at_comment in [true, false] {
|
||||
let mut s = commented();
|
||||
if !at_comment {
|
||||
@@ -30,11 +28,6 @@ fn invalid_ids_leave_state_untouched(command: &str) {
|
||||
let before = (s.doc.clone(), s.sel.clone(), s.undo_labels(), s.redo_label().map(str::to_owned), s.dirty);
|
||||
let result = s.run(command, &json!({"id": id, "text": "Replacement"}));
|
||||
assert!(matches!(result, Err(CmdError::Params(_))), "{command}, id={id}, at_comment={at_comment}: {result:?}");
|
||||
// Past `u32` it is the range check that refuses, not a lookup of whatever the id
|
||||
// truncated to: 2^32 + 5 names no comment here, so "no such comment" would hide a cast.
|
||||
if id.as_u64().is_some_and(|n| n > u64::from(u32::MAX)) {
|
||||
assert!(format!("{result:?}").contains("32-bit"), "{command}, id={id}: {result:?}");
|
||||
}
|
||||
assert_eq!(
|
||||
(s.doc.clone(), s.sel.clone(), s.undo_labels(), s.redo_label().map(str::to_owned), s.dirty),
|
||||
before,
|
||||
|
||||
@@ -15,8 +15,7 @@ fn odt() -> Vec<u8> {
|
||||
let content = format!(
|
||||
r#"<office:document-content {NS}><office:body><office:text><text:p>Sales <draw:frame svg:width="4in" svg:height="2.5in"><draw:object xlink:href="./Object 1"/></draw:frame></text:p></office:text></office:body></office:document-content>"#
|
||||
);
|
||||
// A well-formed chart part, its prefixes declared as LibreOffice writes them.
|
||||
let chart = r#"<office:document-content xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:chart="urn:oasis:names:tc:opendocument:xmlns:chart:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0"><office:body><office:chart><chart:chart chart:class="chart:line"><chart:title><text:p>Visits</text:p></chart:title><chart:plot-area><chart:axis chart:dimension="x"><chart:categories table:cell-range-address="local-table.$A$2:.$A$4"/></chart:axis><chart:series chart:values-cell-range-address="local-table.$B$2:.$B$4" chart:label-cell-address="local-table.$B$1"/></chart:plot-area><table:table table:name="local-table"><table:table-header-rows><table:table-row><table:table-cell/><table:table-cell office:value-type="string"><text:p>Site</text:p></table:table-cell></table:table-row></table:table-header-rows><table:table-rows><table:table-row><table:table-cell office:value-type="string"><text:p>Mon</text:p></table:table-cell><table:table-cell office:value-type="float" office:value="3"/></table:table-row><table:table-row><table:table-cell office:value-type="string"><text:p>Tue</text:p></table:table-cell><table:table-cell office:value-type="float" office:value="5"/></table:table-row><table:table-row><table:table-cell office:value-type="string"><text:p>Wed</text:p></table:table-cell><table:table-cell office:value-type="float" office:value="4"/></table:table-row></table:table-rows></table:table></chart:chart></office:chart></office:body></office:document-content>"#;
|
||||
let chart = r#"<office:document-content><office:body><office:chart><chart:chart chart:class="chart:line"><chart:title><text:p>Visits</text:p></chart:title><chart:plot-area><chart:axis chart:dimension="x"><chart:categories table:cell-range-address="local-table.$A$2:.$A$4"/></chart:axis><chart:series chart:values-cell-range-address="local-table.$B$2:.$B$4" chart:label-cell-address="local-table.$B$1"/></chart:plot-area><table:table table:name="local-table"><table:table-header-rows><table:table-row><table:table-cell/><table:table-cell office:value-type="string"><text:p>Site</text:p></table:table-cell></table:table-row></table:table-header-rows><table:table-rows><table:table-row><table:table-cell office:value-type="string"><text:p>Mon</text:p></table:table-cell><table:table-cell office:value-type="float" office:value="3"/></table:table-row><table:table-row><table:table-cell office:value-type="string"><text:p>Tue</text:p></table:table-cell><table:table-cell office:value-type="float" office:value="5"/></table:table-row><table:table-row><table:table-cell office:value-type="string"><text:p>Wed</text:p></table:table-cell><table:table-cell office:value-type="float" office:value="4"/></table:table-row></table:table-rows></table:table></chart:chart></office:chart></office:body></office:document-content>"#;
|
||||
let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||||
for (name, data) in [("mimetype", "application/vnd.oasis.opendocument.text"), ("content.xml", content.as_str()), ("Object 1/content.xml", chart)]
|
||||
|
||||
@@ -29,7 +29,6 @@ fn complete_document() -> Document {
|
||||
alt: "Original synthetic picture".into(),
|
||||
float: Default::default(),
|
||||
crop: Default::default(),
|
||||
fx: Default::default(),
|
||||
ole: Some(source.clone()),
|
||||
},
|
||||
&CharProps::default(),
|
||||
|
||||
@@ -56,10 +56,21 @@ pub fn import(ext: &str, bytes: &[u8]) -> Option<Result<Document, String>> {
|
||||
/// plain text, Markdown, HTML and MHT start on it, and their tables size to it (#1381). The other
|
||||
/// formats keep the page their file gives.
|
||||
pub fn import_on(ext: &str, bytes: &[u8], page: &wordcraft_doc::SectionProps) -> Option<Result<Document, String>> {
|
||||
import_with(ext, bytes, page, &wordcraft_doc::odf_profile::OdfProfile::default())
|
||||
}
|
||||
|
||||
/// [`import_on`], OpenDocument files filling in what their standard leaves open as `odf` says
|
||||
/// (see [`wordcraft_doc::odf_profile`]).
|
||||
pub fn import_with(
|
||||
ext: &str,
|
||||
bytes: &[u8],
|
||||
page: &wordcraft_doc::SectionProps,
|
||||
odf: &wordcraft_doc::odf_profile::OdfProfile,
|
||||
) -> Option<Result<Document, String>> {
|
||||
let ext = ext.trim_start_matches('.').to_ascii_lowercase();
|
||||
let mut doc = match ext.as_str() {
|
||||
"odt" | "ott" => return Some(odt::import_on(bytes, page)),
|
||||
"fodt" => return Some(odt::import_flat_on(bytes, page)),
|
||||
"odt" | "ott" => return Some(odt::import_with(bytes, page, odf)),
|
||||
"fodt" => return Some(odt::import_flat_with(bytes, page, odf)),
|
||||
"txt" | "text" => txt::import_on(bytes, page),
|
||||
"md" | "markdown" => markdown::import_on(&txt::decode(bytes), page),
|
||||
"html" | "htm" | "xhtml" => html::import_on(&html::decode(bytes), &|_| None, page),
|
||||
|
||||
@@ -164,7 +164,8 @@ pub fn is_custom_style(doc: &Document, id: &str) -> bool {
|
||||
}
|
||||
|
||||
/// Whether `doc`'s tab stops are measured from each paragraph's indent (an ODF document whose
|
||||
/// file says so): [`Para::extra`]'s tab stops then are too.
|
||||
/// file says so, or that was opened with a profile saying so): [`Para::extra`]'s tab stops then
|
||||
/// are too.
|
||||
pub fn tabs_from_indent(doc: &Document) -> bool {
|
||||
doc.settings.odf.is_some_and(|o| o.tabs_from_indent)
|
||||
}
|
||||
@@ -493,7 +494,6 @@ fn flow_paras_in(doc: &Document, p: &Paragraph, opts: FlowOpts) -> Vec<Para> {
|
||||
let hidden = props.hidden.unwrap_or(false) || props.del.is_some();
|
||||
// Formats that keep hidden text get it, marked hidden.
|
||||
let hidden_text = (props.hidden.unwrap_or(false) && !opts.chars) || (props.del.is_some() && !tracked);
|
||||
let hidden_obj = (props.hidden.unwrap_or(false) && !opts.chars) || props.del.is_some();
|
||||
let mut f = run_fmt(doc, props, in_code, opts);
|
||||
if tracked {
|
||||
(f.ins, f.del) = rev_marks(doc, props);
|
||||
@@ -541,9 +541,8 @@ fn flow_paras_in(doc: &Document, p: &Paragraph, opts: FlowOpts) -> Vec<Para> {
|
||||
let obj = p.objects.get(k);
|
||||
k += 1;
|
||||
control.interrupt(obj, &mut out, &f);
|
||||
// Hidden text a format leaves out, a deleted object, or a hidden drawing: not in the
|
||||
// copy (a format keeping hidden text keeps its objects too, as it keeps the text).
|
||||
if hidden_obj || obj.and_then(InlineObject::frame).is_some_and(|(_, _, f)| f.hidden) {
|
||||
// Hidden text, or a hidden drawing: not in the copy.
|
||||
if hidden || obj.and_then(InlineObject::frame).is_some_and(|(_, _, f)| f.hidden) {
|
||||
continue;
|
||||
}
|
||||
let from = out.inlines.len();
|
||||
@@ -1147,9 +1146,7 @@ pub fn from_doc_tracked(doc: &Document) -> Flow {
|
||||
|
||||
/// [`from_doc`] keeping what `opts` asks for.
|
||||
pub fn from_doc_with(doc: &Document, opts: FlowOpts) -> Flow {
|
||||
// Paragraphs kept as read are checked against the document's comments and page setup:
|
||||
// worked out once for the whole document, not once per paragraph.
|
||||
wordcraft_doc::kept::while_writing(doc, || flow_of(doc, opts))
|
||||
flow_of(doc, opts)
|
||||
}
|
||||
|
||||
/// `blocks` of `doc` (a header, footer or note) as flow blocks, keeping what `opts` asks for.
|
||||
|
||||
@@ -367,16 +367,7 @@ impl<'a> Builder<'a> {
|
||||
// A crop of finite fractions that leaves something of the picture.
|
||||
let crop = img.crop.map(|c| if c.is_finite() { c.clamp(0.0, 0.99) } else { 0.0 });
|
||||
let crop = if crop[0] + crop[2] < 0.99 && crop[1] + crop[3] < 0.99 { crop } else { [0.0; 4] };
|
||||
Some(InlineObject::Image {
|
||||
media: key,
|
||||
w,
|
||||
h,
|
||||
alt: img.alt.clone(),
|
||||
float: float.unwrap_or_default(),
|
||||
crop,
|
||||
fx: Default::default(),
|
||||
ole,
|
||||
})
|
||||
Some(InlineObject::Image { media: key, w, h, alt: img.alt.clone(), float: float.unwrap_or_default(), crop, ole })
|
||||
}
|
||||
// Its drawing is made from the chart by the program that opens the document
|
||||
// (`wordcraft_engine`'s chart drawing), which has the theme's colours.
|
||||
|
||||
@@ -63,11 +63,11 @@ mod xml;
|
||||
|
||||
use body::Body;
|
||||
pub use crypt::{CryptError, decrypt, encrypt, is_encrypted};
|
||||
pub use flat::{export_flat, import_flat, import_flat_on};
|
||||
pub use flat::{export_flat, import_flat, import_flat_with};
|
||||
use import::Pages;
|
||||
#[cfg(test)]
|
||||
use import::{Pictures, apply_page_layout, read_entry, read_meta};
|
||||
pub use import::{import, import_on, parse};
|
||||
pub use import::{import, import_on, import_with, parse};
|
||||
#[cfg(test)]
|
||||
use limits::{MAX_EXTRA_SPACES, MAX_XML_DEPTH};
|
||||
pub use package::{TEMPLATE_MIME, TEXT_MIME, export, export_template};
|
||||
|
||||
@@ -1324,7 +1324,7 @@ impl Body<'_> {
|
||||
let h = get(a, "svg:height").and_then(length);
|
||||
self.frame = Some((w, h, None, String::new(), None, None, None));
|
||||
let g = get(a, "draw:style-name").map(|s| self.styles.graphic(s)).unwrap_or_default();
|
||||
self.frame_float = Some(super::frames::place(a, w.unwrap_or(0.0), h.unwrap_or(0.0), &g, self.text_size()));
|
||||
self.frame_float = Some(super::frames::place(a, w.unwrap_or(0.0), h.unwrap_or(0.0), &g, self.text_size(), &self.styles.defs.profile));
|
||||
self.member_start(a, w.unwrap_or(0.0), h.unwrap_or(0.0), None);
|
||||
// Without a height, a text box in it grows to its text (`fo:min-height` below).
|
||||
self.frame_look = g.look(h.is_none(), &self.styles.defs);
|
||||
@@ -1343,7 +1343,12 @@ impl Body<'_> {
|
||||
// section in a one-column page) is no new section.
|
||||
.filter(|c| c.count != self.sec.columns.count || (c.count > 1 && *c != self.sec.columns))
|
||||
{
|
||||
let balance = get(a, "text:style-name").and_then(|s| self.styles.section_balance(s));
|
||||
let balance = get(a, "text:style-name").and_then(|s| self.styles.section_balance(s)).or(self
|
||||
.styles
|
||||
.defs
|
||||
.profile
|
||||
.balance_section_columns
|
||||
.then_some(true));
|
||||
let next = SectionProps {
|
||||
start: SectionStart::Continuous,
|
||||
page_num_start: None,
|
||||
@@ -1402,7 +1407,7 @@ impl Body<'_> {
|
||||
control.form_size = Some([w, h]);
|
||||
// Where it stands on the line (§20.387), as an in-line frame does.
|
||||
let g = self.styles.shape_graphic(get(a, "draw:style-name"));
|
||||
let raise = super::frames::place(a, w, h, &g, self.text_size()).y;
|
||||
let raise = super::frames::place(a, w, h, &g, self.text_size(), &self.styles.defs.profile).y;
|
||||
control.form_raise = (raise.is_finite() && raise != 0.0).then_some(raise);
|
||||
}
|
||||
let fmt = self.cur_fmt();
|
||||
|
||||
@@ -338,7 +338,13 @@ impl Body<'_> {
|
||||
}
|
||||
let members = g.members.into_iter().map(|m| model::Member { x: fin(m.x) - x0, y: fin(m.y) - y0, ..m }).collect();
|
||||
let graphic = self.styles.shape_graphic(g.style.as_deref());
|
||||
let float = crate::odt::frames::float_of(g.anchor.as_deref(), Some(&crate::odt::writer::pt(x0)), Some(&crate::odt::writer::pt(y0)), &graphic);
|
||||
let float = crate::odt::frames::float_of(
|
||||
g.anchor.as_deref(),
|
||||
Some(&crate::odt::writer::pt(x0)),
|
||||
Some(&crate::odt::writer::pt(y0)),
|
||||
&graphic,
|
||||
&self.styles.defs.profile,
|
||||
);
|
||||
let float = (float.wrap != wordcraft_doc::para::Wrap::Inline).then(|| Box::new(float));
|
||||
let fit = |v: f32| if v.is_finite() { v.clamp(1.0, MAX_LEN) } else { 1.0 };
|
||||
self.push_inline(Inline::Group { w: fit(x1 - x0), h: fit(y1 - y0), float, members });
|
||||
@@ -356,11 +362,11 @@ impl Body<'_> {
|
||||
let mut float = if kind.is_open() {
|
||||
// A line is placed by its ends.
|
||||
let at = |v: Option<f32>| v.map(crate::odt::writer::pt);
|
||||
let mut f = crate::odt::frames::float_of(get(a, "text:anchor-type"), at(x).as_deref(), at(y).as_deref(), &g);
|
||||
let mut f = crate::odt::frames::float_of(get(a, "text:anchor-type"), at(x).as_deref(), at(y).as_deref(), &g, &self.styles.defs.profile);
|
||||
f.z = crate::odt::frames::z_of(a);
|
||||
f
|
||||
} else {
|
||||
crate::odt::frames::place(a, w, h, &g, self.text_size())
|
||||
crate::odt::frames::place(a, w, h, &g, self.text_size(), &self.styles.defs.profile)
|
||||
};
|
||||
(float.flip_h, float.flip_v) = (flip_h, flip_v);
|
||||
self.shape_flip = (false, false);
|
||||
|
||||
@@ -188,11 +188,16 @@ fn take_out(xml: &str, files: &mut Vec<(String, Vec<u8>)>) -> String {
|
||||
|
||||
/// Open a flat OpenDocument text document.
|
||||
pub fn import_flat(bytes: &[u8]) -> Result<Document, String> {
|
||||
import_flat_on(bytes, &wordcraft_doc::SectionProps::default())
|
||||
import_flat_with(bytes, &wordcraft_doc::SectionProps::default(), &wordcraft_doc::odf_profile::OdfProfile::default())
|
||||
}
|
||||
|
||||
/// [`import_flat`] onto `page` (see [`super::import_on`]).
|
||||
pub fn import_flat_on(bytes: &[u8], page: &wordcraft_doc::SectionProps) -> Result<Document, String> {
|
||||
/// [`import_flat`] onto `page`, filling in what the standard leaves open as `profile` says (see
|
||||
/// [`super::import_with`]).
|
||||
pub fn import_flat_with(
|
||||
bytes: &[u8],
|
||||
page: &wordcraft_doc::SectionProps,
|
||||
profile: &wordcraft_doc::odf_profile::OdfProfile,
|
||||
) -> Result<Document, String> {
|
||||
let xml = std::str::from_utf8(bytes).map_err(|_| "FODT: not UTF-8 text".to_string())?;
|
||||
let root = element(xml, "office:document");
|
||||
let [content, styles, meta, settings] = package_parts(root).ok_or("FODT: no office:document")?;
|
||||
@@ -216,7 +221,7 @@ pub fn import_flat_on(bytes: &[u8], page: &wordcraft_doc::SectionProps) -> Resul
|
||||
z.write_all(data).map_err(|e| format!("FODT: {e}"))?;
|
||||
}
|
||||
let package = z.finish().map_err(|e| format!("FODT: {e}"))?.into_inner();
|
||||
super::import_on(&package, page)
|
||||
super::import_with(&package, page, profile)
|
||||
}
|
||||
|
||||
/// Save `doc` as flat OpenDocument text: its package's files in one `office:document`, the
|
||||
|
||||
@@ -10,6 +10,7 @@ use super::xml::{Attrs, get, length};
|
||||
use crate::model::{BoxLook, parse_color};
|
||||
use wordcraft_doc::fill::Fill;
|
||||
use wordcraft_doc::line_dash::LineDash;
|
||||
use wordcraft_doc::odf_profile::OdfProfile;
|
||||
use wordcraft_doc::para::{Anchor, Float, FloatAlign, ShapeKind, Wrap, WrapText};
|
||||
use wordcraft_doc::props::BorderStyle;
|
||||
use wordcraft_doc::props::Rgb;
|
||||
@@ -17,18 +18,6 @@ use wordcraft_doc::props::Rgb;
|
||||
/// Largest offset or distance read, points (22 inches).
|
||||
const MAX_LEN: f32 = 1584.0;
|
||||
|
||||
/// A drawing shape's fill colour when no style gives one (§20.119 `draw:fill-color` has no
|
||||
/// default): WordCraft's shape blue.
|
||||
const SHAPE_FILL: Rgb = Rgb(0x15, 0x60, 0x82);
|
||||
|
||||
/// A drawing shape's line colour when no style gives one (§20.408 `svg:stroke-color` has no
|
||||
/// default).
|
||||
const SHAPE_LINE: Rgb = Rgb(0x0E, 0x40, 0x5A);
|
||||
|
||||
/// A drawing shape's line width when no style gives one (§20.410 `svg:stroke-width` has no
|
||||
/// default), and what a stated width of 0 (a hairline) draws at, points.
|
||||
const HAIRLINE: f32 = 0.25;
|
||||
|
||||
/// The placement a graphic style states; each part `None` where it says nothing (its parent's
|
||||
/// then counts).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
@@ -255,23 +244,24 @@ impl Graphic {
|
||||
}
|
||||
|
||||
/// The look of drawing shape `kind` in this style: a solid fill and line where the styles
|
||||
/// say nothing (the standard gives `draw:fill` and `draw:stroke` no default), in WordCraft's
|
||||
/// colours and line width (nor do `draw:fill-color`, `svg:stroke-color` and
|
||||
/// say nothing (the standard gives `draw:fill` and `draw:stroke` no default), in the
|
||||
/// profile's colours and line width (nor do `draw:fill-color`, `svg:stroke-color` and
|
||||
/// `svg:stroke-width` have defaults); a line has no fill.
|
||||
pub(super) fn shape_look(&self, kind: ShapeKind, defs: &Defs) -> BoxLook {
|
||||
let profile = &defs.profile;
|
||||
let fill = if kind == ShapeKind::Line {
|
||||
None
|
||||
} else {
|
||||
let mut g = self.clone();
|
||||
g.fill_color = g.fill_color.or(Some(SHAPE_FILL));
|
||||
g.fill_color = g.fill_color.or(Some(profile.shape_fill));
|
||||
g.fill_with(Some("solid"), defs)
|
||||
};
|
||||
let line = match self.stroke.as_deref() {
|
||||
Some("none") => None,
|
||||
_ => Some((self.stroke_color.unwrap_or(SHAPE_LINE), self.stroke_width.filter(|w| *w > 0.0).unwrap_or(HAIRLINE))),
|
||||
_ => Some((self.stroke_color.unwrap_or(profile.shape_line), self.stroke_width.filter(|w| *w > 0.0).unwrap_or(profile.hairline))),
|
||||
};
|
||||
// Only a shape that says it grows to its text does: WordCraft's fit shrinks it too.
|
||||
let (dash, arrows) = self.stroke_extra(line.map_or(HAIRLINE, |l| l.1), defs);
|
||||
let (dash, arrows) = self.stroke_extra(line.map_or(profile.hairline, |l| l.1), defs);
|
||||
BoxLook {
|
||||
fill,
|
||||
line,
|
||||
@@ -293,8 +283,8 @@ impl Graphic {
|
||||
// (`draw:stroke`, as LibreOffice's letter logo box has), a 0 width a hairline.
|
||||
let stroke = || {
|
||||
matches!(self.stroke.as_deref(), Some("solid" | "dash")).then(|| {
|
||||
let width = self.stroke_width.filter(|w| *w > 0.0).unwrap_or(HAIRLINE);
|
||||
(self.stroke_color.unwrap_or(SHAPE_LINE), width)
|
||||
let width = self.stroke_width.filter(|w| *w > 0.0).unwrap_or(defs.profile.hairline);
|
||||
(self.stroke_color.unwrap_or(defs.profile.shape_line), width)
|
||||
})
|
||||
};
|
||||
BoxLook {
|
||||
@@ -318,7 +308,7 @@ impl Graphic {
|
||||
/// The [`Float`] of a frame anchored as `anchor` (`text:anchor-type`) at `x`, `y` (`svg:x`,
|
||||
/// `svg:y`) with graphic style placement `g`. A frame in line with the text (`as-char`, or no
|
||||
/// anchor stated) doesn't float.
|
||||
pub(super) fn float_of(anchor: Option<&str>, x: Option<&str>, y: Option<&str>, g: &Graphic) -> Float {
|
||||
pub(super) fn float_of(anchor: Option<&str>, x: Option<&str>, y: Option<&str>, g: &Graphic, profile: &OdfProfile) -> Float {
|
||||
// What the frame leaves unsaid, its style says.
|
||||
let anchor = anchor.or(g.anchor.as_deref()).map(str::trim);
|
||||
let (x, y) = (x.or(g.x.as_deref()), y.or(g.y.as_deref()));
|
||||
@@ -349,12 +339,12 @@ pub(super) fn float_of(anchor: Option<&str>, x: Option<&str>, y: Option<&str>, g
|
||||
Some("page-start-margin") => Anchor::LeftMargin,
|
||||
Some("page-end-margin") => Anchor::RightMargin,
|
||||
Some("page-content") => Anchor::Margin,
|
||||
// A page has no paragraph (§20.299: no such combination): relations to one are the
|
||||
// page's text area.
|
||||
// A page has no paragraph (§20.299: no such combination): its content relations are the
|
||||
// page's text area, its own the page or the text area as the profile says.
|
||||
Some("paragraph-content") if page => Anchor::Margin,
|
||||
Some("paragraph-start-margin") if page => Anchor::LeftMargin,
|
||||
Some("paragraph-end-margin") if page => Anchor::RightMargin,
|
||||
Some("paragraph" | "char") if page => Anchor::Margin,
|
||||
Some("paragraph" | "char") if page && !profile.page_frame_paragraph_is_page => Anchor::Margin,
|
||||
Some("paragraph-content") => Anchor::Column,
|
||||
Some("char") if !page => Anchor::Character,
|
||||
Some(_) | None if page => Anchor::Page,
|
||||
@@ -365,7 +355,7 @@ pub(super) fn float_of(anchor: Option<&str>, x: Option<&str>, y: Option<&str>, g
|
||||
Some("page") => Anchor::Page,
|
||||
Some("page-content") => Anchor::Margin,
|
||||
Some("paragraph-content") if page => Anchor::Margin,
|
||||
Some("paragraph" | "char" | "text" | "line" | "baseline") if page => Anchor::Margin,
|
||||
Some("paragraph" | "char" | "text" | "line" | "baseline") if page && !profile.page_frame_paragraph_is_page => Anchor::Margin,
|
||||
Some("line") | Some("char") | Some("text") | Some("baseline") if !page => Anchor::Line,
|
||||
Some(_) | None if page => Anchor::Page,
|
||||
Some(_) => Anchor::Paragraph,
|
||||
@@ -406,19 +396,19 @@ pub(super) fn float_of(anchor: Option<&str>, x: Option<&str>, y: Option<&str>, g
|
||||
/// Where a frame or shape `w` × `h` (points) sits: anchored as its `text:anchor-type` says, at
|
||||
/// its `svg:x`/`svg:y`, or turned and moved by its `draw:transform`, in graphic style `g`, in
|
||||
/// text `size` points tall.
|
||||
pub(super) fn place(a: &Attrs, w: f32, h: f32, g: &Graphic, size: f32) -> Float {
|
||||
pub(super) fn place(a: &Attrs, w: f32, h: f32, g: &Graphic, size: f32, profile: &OdfProfile) -> Float {
|
||||
let turned = get(a, "draw:transform").and_then(|t| transform(t, w, h));
|
||||
let (x, y) = match turned {
|
||||
Some((x, y, _)) => (Some(pt(x)), Some(pt(y))),
|
||||
None => (get(a, "svg:x").map(String::from), get(a, "svg:y").map(String::from)),
|
||||
};
|
||||
let mut f = float_of(get(a, "text:anchor-type"), x.as_deref(), y.as_deref(), g);
|
||||
let mut f = float_of(get(a, "text:anchor-type"), x.as_deref(), y.as_deref(), g, profile);
|
||||
if let Some((.., rot)) = turned {
|
||||
f.rot = rot;
|
||||
}
|
||||
f.z = z_of(a);
|
||||
if f.wrap == Wrap::Inline {
|
||||
f.y = inline_raise(g, y.as_deref().and_then(length), h, size);
|
||||
f.y = inline_raise(g, y.as_deref().and_then(length), h, size, profile);
|
||||
}
|
||||
f
|
||||
}
|
||||
@@ -443,7 +433,7 @@ pub(crate) fn z_index(float: Option<&Float>) -> u32 {
|
||||
/// its descent to its ascent (a serif face's, as LibreOffice's templates use): `middle` centred
|
||||
/// on it (measured in LibreOffice), `top` at its top, `bottom` at its bottom. Placement relative
|
||||
/// to the line stays on the baseline.
|
||||
fn inline_raise(g: &Graphic, y: Option<f32>, h: f32, size: f32) -> f32 {
|
||||
fn inline_raise(g: &Graphic, y: Option<f32>, h: f32, size: f32, profile: &OdfProfile) -> f32 {
|
||||
if !h.is_finite() || g.vrel.as_deref() == Some("line") {
|
||||
return 0.0;
|
||||
}
|
||||
@@ -459,8 +449,9 @@ fn inline_raise(g: &Graphic, y: Option<f32>, h: f32, size: f32) -> f32 {
|
||||
return raise.clamp(-MAX_RAISE, MAX_RAISE);
|
||||
}
|
||||
// A relation an in-line frame can't have (§20.398 Table 23: a paragraph's or a page's, from
|
||||
// a parent style for anchored frames): it stands on the baseline.
|
||||
if !matches!(g.vrel.as_deref(), None | Some("baseline")) {
|
||||
// a parent style for anchored frames): against the baseline, or standing on it, as the
|
||||
// profile says.
|
||||
if !matches!(g.vrel.as_deref(), None | Some("baseline")) && !profile.inline_invalid_relation_is_baseline {
|
||||
return 0.0;
|
||||
}
|
||||
let raise = match g.vpos.as_deref() {
|
||||
|
||||
@@ -4,6 +4,7 @@ use super::styles::{Styles, column_sep, columns_of};
|
||||
use super::xml::{Attrs, Ev, events, get, length, local};
|
||||
use super::{Columns, Cursor, Document, FBlock, Flow, HashMap, Meta, Para, Read, SectionProps, model, page_parts};
|
||||
use std::sync::Arc;
|
||||
use wordcraft_doc::odf_profile::OdfProfile;
|
||||
|
||||
pub(super) fn read_entry(z: &mut zip::ZipArchive<Cursor<&[u8]>>, name: &str) -> Option<Vec<u8>> {
|
||||
let f = z.by_name(name).ok()?;
|
||||
@@ -421,7 +422,7 @@ pub fn parse(bytes: &[u8]) -> Result<Flow, String> {
|
||||
|
||||
/// [`parse`], with the last section's properties (the others are on the paragraphs that end them).
|
||||
fn parse_full(bytes: &[u8]) -> Result<(Flow, SectionProps), String> {
|
||||
parse_tracked(bytes, default_paper()).map(|p| (p.flow, p.last))
|
||||
parse_tracked(bytes, default_paper(), &OdfProfile::default()).map(|p| (p.flow, p.last))
|
||||
}
|
||||
|
||||
/// The paragraphs kept as read that their links tie together (see `model::Group`), in `blocks`:
|
||||
@@ -705,7 +706,7 @@ fn default_paper() -> (f32, f32) {
|
||||
|
||||
/// [`parse_full`], plus what the document holds outside the flow. A page layout that states no
|
||||
/// size (§20.215 `fo:page-width` is optional) is `paper` (width, height) in size.
|
||||
fn parse_tracked(bytes: &[u8], paper: (f32, f32)) -> Result<Parsed, String> {
|
||||
fn parse_tracked(bytes: &[u8], paper: (f32, f32), profile: &OdfProfile) -> Result<Parsed, String> {
|
||||
wordcraft_doc::zip_guard::check(bytes, crate::MAX_ZIP_ENTRIES).map_err(|e| format!("ODT: {e}"))?;
|
||||
let mut z = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| format!("ODT: not a valid package ({e})"))?;
|
||||
if let Some(mt) = read_entry(&mut z, "mimetype") {
|
||||
@@ -724,24 +725,34 @@ fn parse_tracked(bytes: &[u8], paper: (f32, f32)) -> Result<Parsed, String> {
|
||||
None if styles_xml.is_some() => EMPTY_CONTENT.as_bytes().to_vec(),
|
||||
None => return Err("ODT: content.xml is missing".into()),
|
||||
};
|
||||
let mut styles = Styles::default();
|
||||
let profile = profile.effective();
|
||||
let mut styles = Styles::with_profile(&profile);
|
||||
let mut pages = Pages::default();
|
||||
if let Some(s) = &styles_xml {
|
||||
styles.read(s, false);
|
||||
pages = read_pages(s, paper);
|
||||
pages = read_pages(s, paper, &profile);
|
||||
}
|
||||
// No page layout at all (no styles.xml, or no master page).
|
||||
// No page layout at all (no styles.xml, or no master page): the default paper, with the
|
||||
// profile's margins.
|
||||
if pages.first.is_none() {
|
||||
pages.first = Some(unstated_page(paper));
|
||||
pages.first = Some(SectionProps {
|
||||
page_w: paper.0,
|
||||
page_h: paper.1,
|
||||
margin_top: profile.page_margin,
|
||||
margin_bottom: profile.page_margin,
|
||||
margin_left: profile.page_margin,
|
||||
margin_right: profile.page_margin,
|
||||
..SectionProps::default()
|
||||
});
|
||||
}
|
||||
styles.read(&content, true);
|
||||
let settings = read_entry(&mut z, "settings.xml");
|
||||
// Where tab stops are measured from: as the document's settings say (LibreOffice's own), else
|
||||
// the margin, as Word (the standard ties it only to indexes, §19.512.3).
|
||||
styles.tabs_from_indent = settings.as_deref().and_then(|s| setting(s, "TabsRelativeToIndent")).unwrap_or(false);
|
||||
// the profile's choice (the standard ties it only to indexes, §19.512.3).
|
||||
styles.tabs_from_indent = settings.as_deref().and_then(|s| setting(s, "TabsRelativeToIndent")).unwrap_or(profile.tabs_from_indent);
|
||||
// How the space between two paragraphs adds up: as LibreOffice's `AddParaTableSpacing` says
|
||||
// (false: the larger of the two, as Word), else the larger (the standard doesn't say).
|
||||
let sum_spacing = settings.as_deref().and_then(|s| setting(s, "AddParaTableSpacing")).unwrap_or(false);
|
||||
// (false: the larger of the two, as Word), else the profile's choice (the standard doesn't say).
|
||||
let sum_spacing = settings.as_deref().and_then(|s| setting(s, "AddParaTableSpacing")).unwrap_or(profile.sum_paragraph_spacing);
|
||||
let meta_xml = read_entry(&mut z, "meta.xml");
|
||||
let meta = meta_xml.as_deref().map(read_meta).unwrap_or_default();
|
||||
let changes = meta_xml.as_deref().map(read_changes).unwrap_or_default();
|
||||
@@ -876,6 +887,12 @@ pub fn import(bytes: &[u8]) -> Result<Document, String> {
|
||||
/// [`import`], with `page` the default page (the user's region's): a page layout that states no
|
||||
/// size is `page`'s size, as LibreOffice makes it its default paper's.
|
||||
pub fn import_on(bytes: &[u8], page: &SectionProps) -> Result<Document, String> {
|
||||
import_with(bytes, page, &OdfProfile::default())
|
||||
}
|
||||
|
||||
/// [`import_on`], filling in what the standard leaves open as `profile` says (see
|
||||
/// [`wordcraft_doc::odf_profile`]).
|
||||
pub fn import_with(bytes: &[u8], page: &SectionProps, profile: &OdfProfile) -> Result<Document, String> {
|
||||
model::begin_import();
|
||||
let Parsed {
|
||||
flow,
|
||||
@@ -910,7 +927,7 @@ pub fn import_on(bytes: &[u8], page: &SectionProps) -> Result<Document, String>
|
||||
kept_defs,
|
||||
kept_files,
|
||||
section_masters,
|
||||
} = parse_tracked(bytes, (page.page_w, page.page_h))?;
|
||||
} = parse_tracked(bytes, (page.page_w, page.page_h), profile)?;
|
||||
let mut doc = model::to_doc(&flow);
|
||||
kept.store(&mut doc);
|
||||
// Elements can't hold a NUL: it parts them.
|
||||
@@ -936,8 +953,8 @@ pub fn import_on(bytes: &[u8], page: &SectionProps) -> Result<Document, String>
|
||||
doc.settings.default_tab = t;
|
||||
}
|
||||
doc.settings.page_color = page_doc.page_color;
|
||||
// Laid out with the standard's rules, its tab stops measured as the file says.
|
||||
doc.settings.odf = Some(wordcraft_doc::OdfLayout { tabs_from_indent });
|
||||
// Laid out with the standard's rules, and the profile's choices made as it is laid out.
|
||||
doc.settings.odf = Some(wordcraft_doc::odf_profile::OdfLayout { tabs_from_indent, ..profile.layout() });
|
||||
doc.settings.sum_paragraph_spacing = sum_spacing;
|
||||
// The page background picture (Design › Page Color › Fill Effects › Picture).
|
||||
if let Some((bytes, fill)) = page_image
|
||||
@@ -955,8 +972,8 @@ pub fn import_on(bytes: &[u8], page: &SectionProps) -> Result<Document, String>
|
||||
let d = &mut Arc::make_mut(&mut doc.styles).default_para;
|
||||
(d.space_before, d.space_after, d.line_spacing) = (spacing.before, spacing.after, spacing.line);
|
||||
(d.indent_left, d.indent_right, d.indent_first) = (spacing.left, spacing.right, spacing.first);
|
||||
// Unstated: on, XSL's initial value of 2 lines (§20.312 and §20.244 refer to it), and Word's.
|
||||
d.widow_control = Some(widows.unwrap_or(true));
|
||||
// Unstated, as the profile says (`widowControl`).
|
||||
d.widow_control = Some(widows.unwrap_or(profile.widow_control));
|
||||
for (kind, look) in &looks {
|
||||
apply_look(&mut doc, *kind, look);
|
||||
}
|
||||
@@ -1063,24 +1080,23 @@ fn line_numbering(a: &Attrs) -> Option<wordcraft_doc::section::LineNumbering> {
|
||||
/// §16.6–16.7): its minimum height and its spacing from the text, in points.
|
||||
#[derive(Clone, Copy, Default)]
|
||||
struct Band {
|
||||
/// Its height (`fo:min-height`), when stated: the standard gives no default (see
|
||||
/// [`Band::room`]).
|
||||
/// Its height (`fo:min-height`), when stated: the standard gives no default (the profile's
|
||||
/// [`OdfProfile::header_min_height`]).
|
||||
height: Option<f32>,
|
||||
spacing: f32,
|
||||
/// Content taller than the band takes its spacing first (§20.264 `style:dynamic-spacing`,
|
||||
/// as Word does), instead of keeping it below; `None` when unstated (§20.264 has no default;
|
||||
/// see [`Band::room`]).
|
||||
/// as Word does), instead of keeping it below; `None` when unstated (the profile's
|
||||
/// [`OdfProfile::dynamic_spacing`]).
|
||||
dynamic: Option<bool>,
|
||||
}
|
||||
|
||||
impl Band {
|
||||
/// Where the text starts below the band's start (the page margin), and the gap kept below
|
||||
/// content taller than the band. What the standard leaves open: the spacing comes after the
|
||||
/// height, an unstated height is none (as tall as its text), and unstated dynamic spacing is
|
||||
/// off (on, with no height stated, it would always drop the spacing the file states).
|
||||
fn room(&self) -> (f32, f32) {
|
||||
let room = self.height.unwrap_or(0.0) + self.spacing;
|
||||
let gap = if self.dynamic.unwrap_or(false) { 0.0 } else { self.spacing };
|
||||
/// content taller than the band, as `profile` reads what the standard leaves open.
|
||||
fn room(&self, profile: &OdfProfile) -> (f32, f32) {
|
||||
let height = self.height.unwrap_or(profile.header_min_height);
|
||||
let room = if profile.header_height_includes_spacing { height.max(self.spacing) } else { height + self.spacing };
|
||||
let gap = if self.dynamic.unwrap_or(profile.dynamic_spacing) { 0.0 } else { self.spacing };
|
||||
(room, gap)
|
||||
}
|
||||
}
|
||||
@@ -1097,22 +1113,7 @@ struct Layout {
|
||||
footer: Band,
|
||||
}
|
||||
|
||||
/// A page stating nothing, `paper` (width, height) in size: no margins (§20.206–20.209 refer to
|
||||
/// XSL's margins, whose initial value is 0; whether it comes with the reference the standard
|
||||
/// doesn't say).
|
||||
fn unstated_page(paper: (f32, f32)) -> SectionProps {
|
||||
SectionProps {
|
||||
page_w: paper.0,
|
||||
page_h: paper.1,
|
||||
margin_top: 0.0,
|
||||
margin_bottom: 0.0,
|
||||
margin_left: 0.0,
|
||||
margin_right: 0.0,
|
||||
..SectionProps::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn read_pages(xml: &[u8], paper: (f32, f32)) -> Pages {
|
||||
fn read_pages(xml: &[u8], paper: (f32, f32), profile: &OdfProfile) -> Pages {
|
||||
let mut layouts: HashMap<String, Layout> = HashMap::new();
|
||||
let mut cur: Option<String> = None;
|
||||
// Inside a layout's footer style (else its header style).
|
||||
@@ -1175,7 +1176,7 @@ fn read_pages(xml: &[u8], paper: (f32, f32)) -> Pages {
|
||||
}
|
||||
"columns" if q.starts_with("style:") => {
|
||||
if let Some(l) = cur.as_ref().and_then(|n| layouts.get_mut(n)) {
|
||||
l.columns = Some(columns_of(&a));
|
||||
l.columns = Some(columns_of(&a, profile.column_gap));
|
||||
}
|
||||
}
|
||||
"column" if q.starts_with("style:") => {
|
||||
@@ -1224,8 +1225,17 @@ fn read_pages(xml: &[u8], paper: (f32, f32)) -> Pages {
|
||||
let mut pages = Pages { doc, next: nexts, ..Default::default() };
|
||||
for (name, layout, header, footer) in masters {
|
||||
let Some(Layout { attrs: a, columns: cols, column_specs, header: hb, footer: fb, image }) = layouts.get(&layout) else { continue };
|
||||
// What a layout leaves unsaid: the default paper, and no margins.
|
||||
let mut sec = unstated_page(paper);
|
||||
// What a layout leaves unsaid: the default paper, and the profile's margins (§20.206–20.209
|
||||
// refer to XSL's margins; whether XSL's initial 0 comes with them the standard doesn't say).
|
||||
let mut sec = SectionProps {
|
||||
page_w: paper.0,
|
||||
page_h: paper.1,
|
||||
margin_top: profile.page_margin,
|
||||
margin_bottom: profile.page_margin,
|
||||
margin_left: profile.page_margin,
|
||||
margin_right: profile.page_margin,
|
||||
..SectionProps::default()
|
||||
};
|
||||
apply_layout(a, &mut sec);
|
||||
sec.line_numbers.clone_from(&lines);
|
||||
if pages.first.is_none() {
|
||||
@@ -1235,17 +1245,17 @@ fn read_pages(xml: &[u8], paper: (f32, f32)) -> Pages {
|
||||
// ODF starts a header at the page margin and the text after the header's band; Word
|
||||
// measures the header's distance and the text's margin from the edge. How the band's
|
||||
// height and spacing add up, and what an unstated height or spacing rule is, the
|
||||
// standard leaves open (see `Band::room`).
|
||||
// standard leaves open: the profile's (see `Band::room`).
|
||||
let limit = sec.page_w.max(sec.page_h);
|
||||
if header {
|
||||
let (room, gap) = hb.room();
|
||||
let (room, gap) = hb.room(profile);
|
||||
sec.header = sec.margin_top;
|
||||
sec.margin_top = (sec.margin_top + room).min(limit);
|
||||
// Content taller than the band pushes the text down, the spacing below it kept or not.
|
||||
sec.header_gap = gap;
|
||||
}
|
||||
if footer {
|
||||
let (room, gap) = fb.room();
|
||||
let (room, gap) = fb.room(profile);
|
||||
sec.footer = sec.margin_bottom;
|
||||
sec.margin_bottom = (sec.margin_bottom + room).min(limit);
|
||||
sec.footer_gap = gap;
|
||||
@@ -1290,7 +1300,7 @@ fn first_pages(pages: &mut Pages) {
|
||||
/// Replace `sec` with the page setup of the first master page in `styles.xml`, if any.
|
||||
#[cfg(test)]
|
||||
pub(super) fn apply_page_layout(xml: &[u8], sec: &mut SectionProps) {
|
||||
if let Some(first) = read_pages(xml, default_paper()).first {
|
||||
if let Some(first) = read_pages(xml, default_paper(), &OdfProfile::default()).first {
|
||||
*sec = first;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,7 +350,7 @@ fn styles_xml(
|
||||
spacing_props(defaults),
|
||||
x(&body_font),
|
||||
// Widow and orphan control as Normal has it, said either way (the standard leaves it open
|
||||
// when unsaid, and readers differ: docs/odf-interpretation.md).
|
||||
// when unsaid: see `OdfProfile::widow_control`).
|
||||
widows = if normal.widow_control { " fo:widows=\"2\" fo:orphans=\"2\"" } else { " fo:widows=\"0\" fo:orphans=\"0\"" },
|
||||
size = pt(size),
|
||||
// The document's text colour (a colour, not automatic).
|
||||
|
||||
@@ -259,6 +259,9 @@ const MAX_DEFS: usize = 10_000;
|
||||
pub(super) struct Defs {
|
||||
dashes: std::collections::HashMap<String, Dash>,
|
||||
gradients: std::collections::HashMap<String, Fill>,
|
||||
/// How what the standard leaves open is filled in (shapes' default colours and lines among
|
||||
/// it), with the effective values.
|
||||
pub(super) profile: wordcraft_doc::odf_profile::OdfProfile,
|
||||
/// The part read is older than ODF 1.3: unitless angles are tenths of a degree (see
|
||||
/// [`legacy_angles`]).
|
||||
pub(super) legacy_angles: bool,
|
||||
|
||||
@@ -262,6 +262,8 @@ struct StyleInfo {
|
||||
columns: Option<Columns>,
|
||||
/// The section style's `style:column`s, as [`with_widths`] gives them widths.
|
||||
column_specs: Vec<ColumnSpec>,
|
||||
/// Its `style:columns` states its gap (else the profile's, see [`Styles::columns`]).
|
||||
column_gap_stated: bool,
|
||||
/// `text:dont-balance-text-columns` of `style:section-properties`, as stated.
|
||||
dont_balance: Option<bool>,
|
||||
/// Margins, first-line indent and line spacing of `style:paragraph-properties`.
|
||||
@@ -634,7 +636,8 @@ fn style_child(name: &str, q: &str, a: &Attrs, s: &mut StyleInfo) -> bool {
|
||||
s.dont_balance = get(a, "text:dont-balance-text-columns").map(|v| v.trim() == "true");
|
||||
}
|
||||
"columns" if q.starts_with("style:") => {
|
||||
s.columns = Some(columns_of(a));
|
||||
s.columns = Some(columns_of(a, 0.0));
|
||||
s.column_gap_stated = get(a, "fo:column-gap").is_some();
|
||||
}
|
||||
"column" if q.starts_with("style:") && s.columns.is_some() => {
|
||||
if let Some(spec) = column_spec(a).filter(|_| s.column_specs.len() < MAX_COLUMN_SPECS) {
|
||||
@@ -711,9 +714,9 @@ fn cell_borders_and_padding(a: &Attrs, s: &mut StyleInfo) {
|
||||
/// Columns of a `style:columns` element (ODF 1.3 §17.12): count and gap, clamped as the RTF and
|
||||
/// DOCX readers do (each column's own width comes from its `style:column`, see [`with_widths`]).
|
||||
/// Without `fo:column-gap`, whose default the standard doesn't give (§19.233), the gap is
|
||||
/// [`Columns::default`]'s half inch, Word's.
|
||||
pub(super) fn columns_of(a: &Attrs) -> Columns {
|
||||
let mut c = Columns::default();
|
||||
/// `default_gap` (the profile's).
|
||||
pub(super) fn columns_of(a: &Attrs, default_gap: f32) -> Columns {
|
||||
let mut c = Columns { space: if default_gap.is_finite() { default_gap.clamp(0.0, 1584.0) } else { 0.0 }, ..Columns::default() };
|
||||
if let Some(n) = get(a, "fo:column-count").and_then(|v| v.trim().parse::<u64>().ok()) {
|
||||
c.count = n.clamp(1, 45) as u32;
|
||||
}
|
||||
@@ -1295,7 +1298,10 @@ impl Styles {
|
||||
/// Columns of a section style (whole chain).
|
||||
/// A section style's columns in a text `tw` points wide (whole chain).
|
||||
pub(super) fn columns(&self, name: &str, tw: f32) -> Option<Columns> {
|
||||
self.chain(name).iter().find_map(|(_, s)| s.columns.clone().map(|c| with_widths(c, &s.column_specs, tw)))
|
||||
self.chain(name).iter().find_map(|(_, s)| {
|
||||
let gap = if s.column_gap_stated { None } else { Some(self.defs.profile.column_gap) };
|
||||
s.columns.clone().map(|c| with_widths(Columns { space: gap.unwrap_or(c.space), ..c }, &s.column_specs, tw))
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether a section style's columns are balanced at its end, when it says (the attribute's
|
||||
@@ -1305,6 +1311,13 @@ impl Styles {
|
||||
self.chain(name).iter().find_map(|(_, s)| s.dont_balance).map(|d| !d)
|
||||
}
|
||||
|
||||
/// Styles to be read, filling in what the standard leaves open as `profile` says.
|
||||
pub(super) fn with_profile(profile: &wordcraft_doc::odf_profile::OdfProfile) -> Styles {
|
||||
let mut s = Styles::default();
|
||||
s.defs.profile = profile.clone();
|
||||
s
|
||||
}
|
||||
|
||||
/// Width in points of a table-column style (whole chain).
|
||||
pub(super) fn column_width(&self, name: &str) -> Option<f32> {
|
||||
self.chain(name).iter().find_map(|(_, s)| s.column_width)
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
use super::*;
|
||||
use wordcraft_doc::odf_profile::OdfProfile;
|
||||
|
||||
/// Open ODT `bytes` filling in what the standard leaves open as `profile` says.
|
||||
pub(crate) fn import_as(bytes: &[u8], profile: &OdfProfile) -> Document {
|
||||
super::import_with(bytes, &wordcraft_doc::SectionProps::default(), profile).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escaper_drops_noncharacters() {
|
||||
@@ -298,7 +304,7 @@ fn page_layout_follows_the_first_master_page() {
|
||||
let mut sec = wordcraft_doc::SectionProps::default();
|
||||
apply_page_layout(xml, &mut sec);
|
||||
assert!(sec.landscape);
|
||||
// A margin left unsaid is XSL's initial value, 0.
|
||||
// A margin left unsaid is XSL's initial value, 0 (the standard's, whatever the profile).
|
||||
assert_eq!((sec.page_w, sec.page_h, sec.margin_left, sec.margin_top), (720.0, 360.0, 72.0, 0.0));
|
||||
}
|
||||
|
||||
@@ -1331,14 +1337,16 @@ fn unequal_columns_keep_their_widths_and_columns_without_a_gap_have_none() {
|
||||
let out = entry(&export(&doc).unwrap(), "styles.xml");
|
||||
assert!(out.contains(r#"<style:columns fo:column-count="2"><style:column style:rel-width"#), "{out}");
|
||||
assert_eq!(widths(&import(&export(&doc).unwrap()).unwrap()), [(148.8, 14.4), (304.8, 0.0)]);
|
||||
// Equal shares: equal columns with their gap. No gap stated and no shares: WordCraft's half
|
||||
// inch (the standard gives none).
|
||||
// Equal shares: equal columns with their gap. No gap stated and no shares: the profile's gap
|
||||
// (the standard gives none): WordCraft's half inch, LibreOffice's none.
|
||||
let equal = styles.replace(r#"rel-width="2*""#, r#"rel-width="1*""#);
|
||||
let c = import(&package(&content, Some(&equal))).unwrap().last_section.columns;
|
||||
assert!(c.widths.is_empty() && (c.space - 14.4).abs() < 0.01, "{c:?}");
|
||||
let bare = styles.replace(r#"<style:column style:rel-width="1*" fo:start-indent="0in" fo:end-indent="0.1in"/><style:column style:rel-width="2*" fo:start-indent="0.1in" fo:end-indent="0in"/>"#, "");
|
||||
let c = import(&package(&content, Some(&bare))).unwrap().last_section.columns;
|
||||
assert_eq!((c.count, c.space, c.widths.len()), (2, 36.0, 0));
|
||||
let c = import_as(&package(&content, Some(&bare)), &OdfProfile::libreoffice()).last_section.columns;
|
||||
assert_eq!((c.count, c.space, c.widths.len()), (2, 0.0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -34,6 +34,7 @@ mod para_borders;
|
||||
mod parts;
|
||||
mod placeholders;
|
||||
mod polygons;
|
||||
mod profile;
|
||||
mod rotation;
|
||||
mod rtl;
|
||||
mod ruby;
|
||||
@@ -46,4 +47,3 @@ mod tables;
|
||||
mod tabs;
|
||||
mod text_boxes;
|
||||
mod text_direction;
|
||||
mod unstated;
|
||||
|
||||
@@ -71,21 +71,3 @@ fn what_a_character_style_turns_off_stays_off() {
|
||||
let plain = s.split(r#"style:name="Plain""#).nth(1).and_then(|r| r.split("</style:style>").next()).unwrap_or("missing");
|
||||
assert!(plain.contains(r#"fo:font-weight="normal""#) && plain.contains(r#"fo:font-style="normal""#), "{plain}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_character_style_based_on_a_missing_one_keeps_its_own_look() {
|
||||
// "Orphan" names a parent the file doesn't define; "Tilted" comes after it.
|
||||
let styles = format!(
|
||||
r#"<office:document-styles {NS_T}><office:styles><style:style style:name="Orphan" style:family="text" style:parent-style-name="Nowhere"><style:text-properties fo:font-weight="bold"/></style:style><style:style style:name="Tilted" style:family="text"><style:text-properties fo:font-style="italic"/></style:style></office:styles></office:document-styles>"#
|
||||
);
|
||||
let content = format!(
|
||||
r#"<office:document-content {NS_T}><office:body><office:text><text:p><text:span text:style-name="Orphan">aa</text:span> <text:span text:style-name="Tilted">bb</text:span></text:p></office:text></office:body></office:document-content>"#
|
||||
);
|
||||
let d = import(&package(&content, Some(&styles))).unwrap();
|
||||
let r = runs(&d);
|
||||
assert_eq!(
|
||||
r.iter().map(|x| (x.0.as_str(), x.1.as_deref(), x.2, x.3)).collect::<Vec<_>>(),
|
||||
[("aa", Some("Orphan"), true, false), ("bb", Some("Tilted"), false, true)]
|
||||
);
|
||||
assert!(d.styles.get("Nowhere").is_none(), "no style for the missing parent");
|
||||
}
|
||||
|
||||
@@ -268,19 +268,3 @@ fn emphasis_marks_are_read_and_written() {
|
||||
let back: Vec<Option<E>> = back.body[0].as_para().unwrap().runs.iter().map(|r| r.props.emphasis).collect();
|
||||
assert_eq!(back, [Some(E::Dot), Some(E::Comma), Some(E::Circle), Some(E::UnderDot), Some(E::Dot), None]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn objects_in_hidden_text_are_kept() {
|
||||
// Hidden text is written, marked hidden: so are the bookmarks and fields it holds.
|
||||
let hidden = CharProps { hidden: Some(true), ..Default::default() };
|
||||
let mut p = Paragraph::with_text("Shown. Hidden words.", CharProps::default());
|
||||
let n = p.len();
|
||||
p.format(7, n, &|c| c.hidden = Some(true)).unwrap();
|
||||
p.insert_object(14, wordcraft_doc::para::InlineObject::BookmarkStart { name: "Secret".into() }, &hidden).unwrap();
|
||||
let mut d = Document::new();
|
||||
d.body = vec![para_block(p)];
|
||||
let back = import(&export(&d).unwrap()).unwrap();
|
||||
let p = back.body[0].as_para().unwrap();
|
||||
assert!(p.text.contains("Hidden"), "{:?}", p.text);
|
||||
assert!(p.objects.iter().any(|o| matches!(o, wordcraft_doc::para::InlineObject::BookmarkStart { name } if name == "Secret")), "{:?}", p.objects);
|
||||
}
|
||||
|
||||
@@ -10,11 +10,16 @@ use wordcraft_doc::para::{Anchor, Float, FloatAlign, InlineObject, Wrap, WrapTex
|
||||
const NS_D: &str = r#"xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" xmlns:xlink="http://www.w3.org/1999/xlink""#;
|
||||
|
||||
fn open(graphic: &str, frame: &str) -> Document {
|
||||
open_as(graphic, frame, &wordcraft_doc::odf_profile::OdfProfile::default())
|
||||
}
|
||||
|
||||
/// [`open`], filling in what the standard leaves open as `profile` says.
|
||||
fn open_as(graphic: &str, frame: &str, profile: &wordcraft_doc::odf_profile::OdfProfile) -> Document {
|
||||
let png = [b"\x89PNG\r\n\x1a\n".as_slice(), &[7u8; 64]].concat();
|
||||
let content = format!(
|
||||
r#"<office:document-content {NS_T} {NS_D}><office:automatic-styles><style:style style:name="fr1" style:family="graphic"><style:graphic-properties {graphic}/></style:style></office:automatic-styles><office:body><office:text><text:p><draw:frame draw:style-name="fr1" svg:width="2in" svg:height="1in" {frame}><draw:image xlink:href="Pictures/a.png"/></draw:frame>Text beside.</text:p></office:text></office:body></office:document-content>"#
|
||||
);
|
||||
crate::odt::import(&zip_of(&[("content.xml", content.as_bytes()), ("Pictures/a.png", &png)])).unwrap()
|
||||
crate::odt::tests::import_as(&zip_of(&[("content.xml", content.as_bytes()), ("Pictures/a.png", &png)]), profile)
|
||||
}
|
||||
|
||||
fn float(d: &Document) -> Float {
|
||||
@@ -104,7 +109,7 @@ fn float_doc_inline() -> Document {
|
||||
fn a_picture_alone_in_its_paragraph_keeps_its_raise_and_the_paragraphs_size() {
|
||||
// LibreOffice's elegant agenda: a rule raised 0.2 in above the baseline in a 9 pt paragraph.
|
||||
// Its parent style's `vertical-rel="paragraph"` is no in-line frame's (the standard doesn't
|
||||
// define it): it stands on the baseline. Placed against the baseline, it is raised.
|
||||
// define it): LibreOffice places it from the baseline anyway, WordCraft stands it on it.
|
||||
let pic = {
|
||||
let img = image::RgbaImage::from_fn(4, 4, |_, _| image::Rgba([10, 20, 30, 255]));
|
||||
let mut png = Vec::new();
|
||||
@@ -126,31 +131,41 @@ fn a_picture_alone_in_its_paragraph_keeps_its_raise_and_the_paragraphs_size() {
|
||||
let props = p.props_at(off + wordcraft_doc::para::OBJ.len_utf8());
|
||||
(props.position.map(|v| (v * 10.0).round() / 10.0), props.size, p.mark.size)
|
||||
};
|
||||
let doc = crate::odt::import(&pkg).unwrap();
|
||||
assert_eq!(look(&doc), (None, Some(9.0), Some(9.0)));
|
||||
let baseline = styles.replace(r#"style:vertical-rel="paragraph""#, r#"style:vertical-rel="baseline""#);
|
||||
let pkg = crate::odt::tests::zip_of(&[("content.xml", content.as_bytes()), ("styles.xml", baseline.as_bytes()), ("Pictures/p.png", &pic)]);
|
||||
let doc = crate::odt::import(&pkg).unwrap();
|
||||
let doc = crate::odt::tests::import_as(&pkg, &wordcraft_doc::odf_profile::OdfProfile::libreoffice());
|
||||
assert_eq!(look(&doc), (Some(10.8), Some(9.0), Some(9.0)));
|
||||
// Saved, it says so in terms every reader takes the same way.
|
||||
let again = crate::odt::import(&crate::odt::tests::export_model(&doc).unwrap()).unwrap();
|
||||
assert_eq!(look(&again), (Some(10.8), Some(9.0), Some(9.0)));
|
||||
let doc = crate::odt::import(&pkg).unwrap();
|
||||
assert_eq!(look(&doc), (None, Some(9.0), Some(9.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_page_anchored_frames_paragraph_relations_are_its_text_area() {
|
||||
// LibreOffice's letter templates: a page-anchored frame "from the paragraph" or "from the
|
||||
// paragraph's content" (combinations the standard doesn't define) is placed from the page's
|
||||
// text area.
|
||||
let f = float(&open(
|
||||
r#"style:wrap="run-through" style:horizontal-pos="from-left" style:horizontal-rel="paragraph" style:vertical-pos="from-top" style:vertical-rel="paragraph""#,
|
||||
r#"text:anchor-type="page" svg:x="2cm" svg:y="2cm""#,
|
||||
));
|
||||
fn a_page_anchored_frames_paragraph_relations_are_the_pages() {
|
||||
// LibreOffice's letter templates: a page-anchored frame "from the paragraph" (a combination
|
||||
// the standard doesn't define) is placed from the page's edge in LibreOffice, from the
|
||||
// page's text area in WordCraft; "from the paragraph's content" from its text area in both.
|
||||
let paragraph = |profile| {
|
||||
float(&open_as(
|
||||
r#"style:wrap="run-through" style:horizontal-pos="from-left" style:horizontal-rel="paragraph" style:vertical-pos="from-top" style:vertical-rel="paragraph""#,
|
||||
r#"text:anchor-type="page" svg:x="2cm" svg:y="2cm""#,
|
||||
&profile,
|
||||
))
|
||||
};
|
||||
let f = paragraph(wordcraft_doc::odf_profile::OdfProfile::libreoffice());
|
||||
assert_eq!((f.h_rel, f.v_rel), (Anchor::Page, Anchor::Page));
|
||||
let f = paragraph(wordcraft_doc::odf_profile::OdfProfile::word());
|
||||
assert_eq!((f.h_rel, f.v_rel), (Anchor::Margin, Anchor::Margin));
|
||||
let f = float(&open(
|
||||
r#"style:wrap="run-through" style:horizontal-pos="from-left" style:horizontal-rel="paragraph-content" style:vertical-rel="char""#,
|
||||
r#"text:anchor-type="page" svg:x="2cm""#,
|
||||
));
|
||||
let content = |profile| {
|
||||
float(&open_as(
|
||||
r#"style:wrap="run-through" style:horizontal-pos="from-left" style:horizontal-rel="paragraph-content" style:vertical-rel="char""#,
|
||||
r#"text:anchor-type="page" svg:x="2cm""#,
|
||||
&profile,
|
||||
))
|
||||
};
|
||||
let f = content(wordcraft_doc::odf_profile::OdfProfile::libreoffice());
|
||||
assert_eq!((f.h_rel, f.v_rel), (Anchor::Margin, Anchor::Page));
|
||||
let f = content(wordcraft_doc::odf_profile::OdfProfile::word());
|
||||
assert_eq!((f.h_rel, f.v_rel), (Anchor::Margin, Anchor::Margin));
|
||||
// Anchored to a paragraph, they stay the paragraph's.
|
||||
let f = float(&open(r#"style:horizontal-rel="paragraph-content" style:vertical-rel="char""#, r#"text:anchor-type="char""#));
|
||||
|
||||
@@ -120,8 +120,8 @@ fn cm(v: f32) -> f32 {
|
||||
fn a_header_starts_at_the_page_margin_and_the_text_after_it() {
|
||||
// ODF puts the header at the page margin and the text below the header's band; Word measures
|
||||
// both from the page edge. Whether the band's height takes in its spacing the standard
|
||||
// doesn't say: WordCraft adds it (a 2 cm header with 1 cm spacing puts the text 3 cm below
|
||||
// the margin).
|
||||
// doesn't say: LibreOffice's does (a 2 cm header with 1 cm spacing puts the text 2 cm below
|
||||
// the margin), WordCraft's adds it.
|
||||
let content = format!(
|
||||
r#"<office:document-content {}><office:body><office:text><text:p>x</text:p></office:text></office:body></office:document-content>"#,
|
||||
crate::odt::tests::NS_T
|
||||
@@ -130,7 +130,10 @@ fn a_header_starts_at_the_page_margin_and_the_text_after_it() {
|
||||
&content,
|
||||
Some(&styles_with_header("<style:header><text:p>H</text:p></style:header><style:footer><text:p>F</text:p></style:footer>")),
|
||||
);
|
||||
let s = crate::odt::tests::import_as(&with, &wordcraft_doc::odf_profile::OdfProfile::libreoffice()).last_section;
|
||||
let near = |a: f32, b: f32| (a - b).abs() < 0.05;
|
||||
assert!(near(s.header, cm(2.0)) && near(s.margin_top, cm(2.5)), "{} {}", s.header, s.margin_top);
|
||||
assert!(near(s.footer, cm(1.5)) && near(s.margin_bottom, cm(2.5)), "{} {}", s.footer, s.margin_bottom);
|
||||
let s = import(&with).unwrap().last_section;
|
||||
assert!(near(s.header, cm(2.0)) && near(s.margin_top, cm(2.75)), "{} {}", s.header, s.margin_top);
|
||||
assert!(near(s.footer, cm(1.5)) && near(s.margin_bottom, cm(3.0)), "{} {}", s.footer, s.margin_bottom);
|
||||
@@ -145,30 +148,43 @@ fn a_header_starts_at_the_page_margin_and_the_text_after_it() {
|
||||
fn a_headers_spacing_stays_below_content_taller_than_it() {
|
||||
// LibreOffice's report templates: a header taller than its band keeps its spacing below it,
|
||||
// unless the spacing is dynamic (taken by the content first, as in Word). A style that
|
||||
// doesn't say keeps it.
|
||||
// doesn't say is the profile's: both presets keep it; a custom profile may grow into it.
|
||||
let content = format!(
|
||||
r#"<office:document-content {}><office:body><office:text><text:p>x</text:p></office:text></office:body></office:document-content>"#,
|
||||
crate::odt::tests::NS_T
|
||||
);
|
||||
let hf = "<style:header><text:p>H</text:p></style:header><style:footer><text:p>F</text:p></style:footer>";
|
||||
let d = import(&crate::odt::tests::package(&content, Some(&styles_with_header(hf)))).unwrap();
|
||||
let lo = wordcraft_doc::odf_profile::OdfProfile::libreoffice();
|
||||
let d = crate::odt::tests::import_as(&crate::odt::tests::package(&content, Some(&styles_with_header(hf))), &lo);
|
||||
let near = |a: f32, b: f32| (a - b).abs() < 0.05;
|
||||
let s = &d.last_section;
|
||||
assert!(near(s.header_gap, cm(0.25)) && near(s.footer_gap, cm(0.5)), "{} {}", s.header_gap, s.footer_gap);
|
||||
// Written back as the band's spacing, saying it is kept: the same whatever reads it.
|
||||
let saved = export(&d).unwrap();
|
||||
assert!(crate::odt::tests::entry(&saved, "styles.xml").contains(r#"style:dynamic-spacing="false""#));
|
||||
let back = import(&saved).unwrap().last_section;
|
||||
let back = crate::odt::tests::import_as(&saved, &lo).last_section;
|
||||
assert!(near(back.header_gap, cm(0.25)) && near(back.footer_gap, cm(0.5)) && near(back.margin_top, s.margin_top), "{back:?}");
|
||||
let back = import(&saved).unwrap().last_section;
|
||||
assert!(near(back.header_gap, cm(0.25)) && near(back.footer_gap, cm(0.5)), "{back:?}");
|
||||
let dynamic = styles_with_header(hf).replace(r#"fo:margin-bottom="0.25cm""#, r#"fo:margin-bottom="0.25cm" style:dynamic-spacing="true""#);
|
||||
let s = import(&crate::odt::tests::package(&content, Some(&dynamic))).unwrap().last_section;
|
||||
let s = crate::odt::tests::import_as(&crate::odt::tests::package(&content, Some(&dynamic)), &lo).last_section;
|
||||
assert_eq!((s.header_gap, near(s.footer_gap, cm(0.5))), (0.0, true));
|
||||
// Unstated: WordCraft's keeps it too; a custom profile can grow into it.
|
||||
let s = import(&crate::odt::tests::package(&content, Some(&styles_with_header(hf)))).unwrap().last_section;
|
||||
assert!(near(s.header_gap, cm(0.25)) && near(s.footer_gap, cm(0.5)), "{s:?}");
|
||||
let grow = wordcraft_doc::odf_profile::OdfProfile {
|
||||
preset: wordcraft_doc::odf_profile::OdfPreset::Custom,
|
||||
dynamic_spacing: true,
|
||||
..wordcraft_doc::odf_profile::OdfProfile::word()
|
||||
};
|
||||
let s = crate::odt::tests::import_as(&crate::odt::tests::package(&content, Some(&styles_with_header(hf))), &grow).last_section;
|
||||
assert_eq!((s.header_gap, s.footer_gap), (0.0, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_header_without_a_stated_height_has_no_band() {
|
||||
fn a_header_without_a_stated_height_is_half_a_centimetre() {
|
||||
// LibreOffice's letter templates: a header style stating no height, or none at all (the
|
||||
// standard gives none), has no band: the header is as tall as its text.
|
||||
// standard gives none), is LibreOffice's default 0.5 cm band (measured); WordCraft's is none.
|
||||
let content = format!(
|
||||
r#"<office:document-content {}><office:body><office:text><text:p>x</text:p></office:text></office:body></office:document-content>"#,
|
||||
crate::odt::tests::NS_T
|
||||
@@ -181,6 +197,8 @@ fn a_header_without_a_stated_height_has_no_band() {
|
||||
crate::odt::tests::NS_T
|
||||
);
|
||||
let pkg = crate::odt::tests::package(&content, Some(&styles));
|
||||
let s = crate::odt::tests::import_as(&pkg, &wordcraft_doc::odf_profile::OdfProfile::libreoffice()).last_section;
|
||||
assert!(near(s.header, cm(2.0)) && near(s.margin_top, cm(2.5)), "{band}: {} {}", s.header, s.margin_top);
|
||||
let s = import(&pkg).unwrap().last_section;
|
||||
assert!(near(s.header, cm(2.0)) && near(s.margin_top, cm(2.0)), "{band}: {} {}", s.header, s.margin_top);
|
||||
}
|
||||
|
||||
@@ -143,10 +143,10 @@ fn a_page_layout_without_a_size_is_the_default_pages_size() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_document_without_a_page_layout_has_the_default_paper_and_no_margins() {
|
||||
fn a_document_without_a_page_layout_has_the_default_paper_and_the_profiles_margins() {
|
||||
// No styles.xml at all: the region's paper (the standard leaves the size to the
|
||||
// application), and no margins (the standard leaves them open too; 0 is XSL's initial value).
|
||||
// It is laid out as an ODF document.
|
||||
// application), and the profile's margins (the standard leaves them open too): WordCraft's
|
||||
// 0, XSL's initial value; LibreOffice's 2 cm. It is laid out as an ODF document.
|
||||
let content = format!(
|
||||
"<office:document-content {}><office:body><office:text><text:p>x</text:p></office:text></office:body></office:document-content>",
|
||||
crate::odt::tests::NS_T
|
||||
@@ -157,12 +157,17 @@ fn a_document_without_a_page_layout_has_the_default_paper_and_no_margins() {
|
||||
assert_eq!((s.page_w, s.page_h), (595.3, 841.9));
|
||||
assert_eq!([s.margin_top, s.margin_bottom, s.margin_left, s.margin_right], [0.0; 4], "{s:?}");
|
||||
assert!(d.settings.odf.is_some());
|
||||
let lo = crate::import_with("odt", &crate::odt::tests::package(&content, None), &a4, &wordcraft_doc::odf_profile::OdfProfile::libreoffice())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let cm2 = 2.0 * 72.0 / 2.54;
|
||||
assert_eq!([lo.last_section.margin_top, lo.last_section.margin_left], [cm2; 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_page_layout_stating_nothing_has_no_margins() {
|
||||
// LibreOffice's CV template: a page layout with no properties at all. Its margins are XSL's
|
||||
// initial value, 0.
|
||||
fn a_page_layout_stating_nothing_has_the_profiles_margins() {
|
||||
// LibreOffice's CV template: a page layout with no properties at all. Its margins are the
|
||||
// profile's: WordCraft's 0 (XSL's initial value), LibreOffice's 2 cm.
|
||||
let ns = crate::odt::tests::NS_T;
|
||||
let styles = format!(
|
||||
r#"<office:document-styles {ns}><office:automatic-styles><style:page-layout style:name="Mpm1"><style:header-style/><style:footer-style/></style:page-layout></office:automatic-styles><office:master-styles><style:master-page style:name="Standard" style:page-layout-name="Mpm1"/></office:master-styles></office:document-styles>"#
|
||||
@@ -176,6 +181,15 @@ fn a_page_layout_stating_nothing_has_no_margins() {
|
||||
let one = styles.replace("<style:header-style/>", r#"<style:page-layout-properties fo:margin-left="1in"/><style:header-style/>"#);
|
||||
let d = crate::import("odt", &crate::odt::tests::package(&content, Some(&one))).unwrap().unwrap();
|
||||
assert_eq!((d.last_section.margin_left, d.last_section.margin_top), (72.0, 0.0));
|
||||
let lo = |styles: &str| {
|
||||
crate::odt::tests::import_as(&crate::odt::tests::package(&content, Some(styles)), &wordcraft_doc::odf_profile::OdfProfile::libreoffice())
|
||||
.last_section
|
||||
};
|
||||
let cm2 = 2.0 * 72.0 / 2.54;
|
||||
let s = lo(&styles);
|
||||
assert_eq!([s.margin_top, s.margin_bottom, s.margin_left, s.margin_right], [cm2; 4]);
|
||||
let s = lo(&one);
|
||||
assert_eq!((s.margin_left, s.margin_top), (72.0, cm2), "the stated one is the file's under every preset");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+74
-50
@@ -1,8 +1,8 @@
|
||||
//! WordCraft's choices where the standard leaves something open (`docs/odf-interpretation.md`)
|
||||
//! fill in only what a file leaves unsaid: a value the file states is the file's.
|
||||
//! The ODF interpretation profile (`docs/odf-interpretation.md`) fills in only what a file leaves
|
||||
//! unsaid: whatever the preset, a value the file states is the file's.
|
||||
|
||||
use crate::odt::import;
|
||||
use crate::odt::tests::NS_T;
|
||||
use crate::odt::tests::{NS_T, import_as};
|
||||
use wordcraft_doc::odf_profile::{OdfPreset, OdfProfile};
|
||||
use wordcraft_doc::para::InlineObject;
|
||||
use wordcraft_doc::props::Rgb;
|
||||
|
||||
@@ -14,9 +14,9 @@ fn cm(v: f32) -> f32 {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn values_a_file_states_are_kept() {
|
||||
// A file that states every value WordCraft could fill in: header height and spacing and
|
||||
// whether it is dynamic, page columns and their gap, tabs from the indent, a shape's fill and
|
||||
fn values_a_file_states_are_kept_under_every_preset() {
|
||||
// A file that states every value a preset could fill in: header height and spacing and
|
||||
// whether it is dynamic, page columns and their gap, tabs from the margin, a shape's fill and
|
||||
// line.
|
||||
let styles = format!(
|
||||
r##"<office:document-styles {NS_T} {NS_D}><office:automatic-styles><style:page-layout style:name="pm1"><style:page-layout-properties fo:page-width="21cm" fo:page-height="29.7cm" fo:margin-top="2cm" fo:margin-bottom="2cm" fo:margin-left="2cm" fo:margin-right="2cm"><style:columns fo:column-count="2" fo:column-gap="1cm"/></style:page-layout-properties><style:header-style><style:header-footer-properties fo:min-height="1cm" fo:margin-bottom="0.5cm" style:dynamic-spacing="false"/></style:header-style></style:page-layout></office:automatic-styles><office:master-styles><style:master-page style:name="Standard" style:page-layout-name="pm1"><style:header><text:p>H</text:p></style:header></style:master-page></office:master-styles></office:document-styles>"##
|
||||
@@ -26,7 +26,7 @@ fn values_a_file_states_are_kept() {
|
||||
);
|
||||
let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||||
let settings = r#"<office:document-settings xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:config="urn:oasis:names:tc:opendocument:xmlns:config:1.0"><office:settings><config:config-item-set config:name="ooo:configuration-settings"><config:config-item config:name="TabsRelativeToIndent" config:type="boolean">true</config:config-item></config:config-item-set></office:settings></office:document-settings>"#;
|
||||
let settings = r#"<office:document-settings xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:config="urn:oasis:names:tc:opendocument:xmlns:config:1.0"><office:settings><config:config-item-set config:name="ooo:configuration-settings"><config:config-item config:name="TabsRelativeToIndent" config:type="boolean">false</config:config-item></config:config-item-set></office:settings></office:document-settings>"#;
|
||||
for (name, data) in [
|
||||
("mimetype", "application/vnd.oasis.opendocument.text"),
|
||||
("content.xml", content.as_str()),
|
||||
@@ -37,40 +37,57 @@ fn values_a_file_states_are_kept() {
|
||||
std::io::Write::write_all(&mut z, data.as_bytes()).unwrap();
|
||||
}
|
||||
let bytes = z.finish().unwrap().into_inner();
|
||||
let odd = OdfProfile {
|
||||
preset: OdfPreset::Custom,
|
||||
page_margin: 99.0,
|
||||
header_min_height: 99.0,
|
||||
dynamic_spacing: true,
|
||||
tabs_from_indent: true,
|
||||
column_gap: 99.0,
|
||||
shape_fill: Rgb(1, 2, 3),
|
||||
shape_line: Rgb(4, 5, 6),
|
||||
hairline: 9.0,
|
||||
..OdfProfile::word()
|
||||
};
|
||||
let near = |a: f32, b: f32| (a - b).abs() < 0.05;
|
||||
let d = import(&bytes).unwrap();
|
||||
let s = &d.last_section;
|
||||
assert!(near(s.header_gap, cm(0.5)), "the stated spacing, kept: {}", s.header_gap);
|
||||
assert!(near(s.margin_top, cm(3.5)), "the stated height and spacing under the margin: {}", s.margin_top);
|
||||
assert!(near(s.margin_left, cm(2.0)) && near(s.margin_bottom, cm(2.0)), "the stated margins");
|
||||
assert_eq!(s.columns.count, 2);
|
||||
assert!(near(s.columns.space, cm(1.0)), "{}", s.columns.space);
|
||||
let p = d.body[0].as_para().unwrap();
|
||||
assert_eq!(p.props.tabs.clone().unwrap_or_default().first().map(|t| t.pos), Some(108.0));
|
||||
let shape = p.objects.iter().find_map(|o| match o {
|
||||
InlineObject::Shape { fill, stroke, stroke_width, .. } => Some((fill.as_ref().map(|f| f.main_color()), *stroke, *stroke_width)),
|
||||
_ => None,
|
||||
});
|
||||
assert_eq!(shape, Some((Some(Rgb(0x11, 0x22, 0x33)), Some(Rgb(0x44, 0x55, 0x66)), 2.0)));
|
||||
for profile in [OdfProfile::word(), OdfProfile::libreoffice(), odd] {
|
||||
let d = import_as(&bytes, &profile);
|
||||
let s = &d.last_section;
|
||||
assert!(near(s.header_gap, cm(0.5)), "{:?}: the stated spacing, kept: {}", profile.preset, s.header_gap);
|
||||
assert!(s.margin_top >= cm(3.0) - 0.05, "{:?}: at least the stated height under the margin: {}", profile.preset, s.margin_top);
|
||||
assert!(near(s.margin_left, cm(2.0)) && near(s.margin_bottom, cm(2.0)), "{:?}: the stated margins", profile.preset);
|
||||
assert_eq!(s.columns.count, 2);
|
||||
assert!(near(s.columns.space, cm(1.0)), "{:?}: {}", profile.preset, s.columns.space);
|
||||
let p = d.body[0].as_para().unwrap();
|
||||
assert_eq!(p.props.tabs.clone().unwrap_or_default().first().map(|t| t.pos), Some(72.0), "{:?}", profile.preset);
|
||||
let shape = p.objects.iter().find_map(|o| match o {
|
||||
InlineObject::Shape { fill, stroke, stroke_width, .. } => Some((fill.as_ref().map(|f| f.main_color()), *stroke, *stroke_width)),
|
||||
_ => None,
|
||||
});
|
||||
assert_eq!(shape, Some((Some(Rgb(0x11, 0x22, 0x33)), Some(Rgb(0x44, 0x55, 0x66)), 2.0)), "{:?}", profile.preset);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sections_column_balance_is_its_own_and_is_written() {
|
||||
// §20.428 `text:dont-balance-text-columns`: read as its name says; unstated, as Word: balanced
|
||||
// only when text follows on the page.
|
||||
fn a_sections_column_balance_is_its_own_else_the_profiles_and_is_written() {
|
||||
// §20.428 `text:dont-balance-text-columns`: read as its name says; unstated, the profile's
|
||||
// (LibreOffice balances; WordCraft, as Word, only when text follows on the page).
|
||||
let doc = |props: &str| {
|
||||
let content = format!(
|
||||
r#"<office:document-content {NS_T}><office:automatic-styles><style:style style:name="S" style:family="section"><style:section-properties {props}><style:columns fo:column-count="2" fo:column-gap="0.5cm"/></style:section-properties></style:style></office:automatic-styles><office:body><office:text><text:p>a</text:p><text:section text:name="S1" text:style-name="S"><text:p>b</text:p></text:section></office:text></office:body></office:document-content>"#
|
||||
);
|
||||
crate::odt::tests::package(&content, None)
|
||||
};
|
||||
let balance = |props: &str| import(&doc(props)).unwrap().last_section.balance_columns;
|
||||
assert_eq!(balance(r#"text:dont-balance-text-columns="true""#), Some(false));
|
||||
assert_eq!(balance(r#"text:dont-balance-text-columns="false""#), Some(true));
|
||||
assert_eq!(balance(""), None);
|
||||
let balance = |props: &str, profile: &OdfProfile| import_as(&doc(props), profile).last_section.balance_columns;
|
||||
for p in [OdfProfile::word(), OdfProfile::libreoffice()] {
|
||||
assert_eq!(balance(r#"text:dont-balance-text-columns="true""#, &p), Some(false));
|
||||
assert_eq!(balance(r#"text:dont-balance-text-columns="false""#, &p), Some(true));
|
||||
}
|
||||
assert_eq!(balance("", &OdfProfile::libreoffice()), Some(true));
|
||||
assert_eq!(balance("", &OdfProfile::word()), None);
|
||||
// Written as WordCraft lays it out, so every reader balances it the same.
|
||||
for (props, written) in [("", "true"), (r#"text:dont-balance-text-columns="false""#, "false")] {
|
||||
let d = import(&doc(props)).unwrap();
|
||||
let d = import_as(&doc(props), &OdfProfile::word());
|
||||
let saved = crate::odt::export(&d).unwrap();
|
||||
let content = crate::odt::tests::entry(&saved, "content.xml");
|
||||
assert!(content.contains(&format!(r#"text:dont-balance-text-columns="{written}""#)), "{props}: {content}");
|
||||
@@ -78,10 +95,11 @@ fn a_sections_column_balance_is_its_own_and_is_written() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_spacing_adds_up_as_the_document_says_else_takes_the_larger() {
|
||||
fn paragraph_spacing_adds_up_as_the_document_says_else_as_the_profile_does() {
|
||||
// LibreOffice's `AddParaTableSpacing` (settings.xml; false: the larger of the two, observed)
|
||||
// says how the space between paragraphs adds up; without it, the larger, as Word. Written back
|
||||
// as WordCraft lays the document out, in a set LibreOffice reads (the `ooo` namespace declared).
|
||||
// says how the space between paragraphs adds up; without it, the profile decides
|
||||
// (LibreOffice adds them, WordCraft as Word takes the larger). Written back as WordCraft lays
|
||||
// the document out, in a set LibreOffice reads (the `ooo` namespace declared).
|
||||
let content =
|
||||
format!("<office:document-content {NS_T}><office:body><office:text><text:p>a</text:p></office:text></office:body></office:document-content>");
|
||||
let with = |add: Option<bool>| {
|
||||
@@ -97,11 +115,14 @@ fn paragraph_spacing_adds_up_as_the_document_says_else_takes_the_larger() {
|
||||
}
|
||||
z.finish().unwrap().into_inner()
|
||||
};
|
||||
assert!(import(&with(Some(true))).unwrap().settings.sum_paragraph_spacing);
|
||||
assert!(!import(&with(Some(false))).unwrap().settings.sum_paragraph_spacing);
|
||||
assert!(!import(&with(None)).unwrap().settings.sum_paragraph_spacing);
|
||||
for p in [OdfProfile::word(), OdfProfile::libreoffice()] {
|
||||
assert!(import_as(&with(Some(true)), &p).settings.sum_paragraph_spacing);
|
||||
assert!(!import_as(&with(Some(false)), &p).settings.sum_paragraph_spacing);
|
||||
}
|
||||
assert!(import_as(&with(None), &OdfProfile::libreoffice()).settings.sum_paragraph_spacing);
|
||||
assert!(!import_as(&with(None), &OdfProfile::word()).settings.sum_paragraph_spacing);
|
||||
for (add, written) in [(Some(true), "true"), (None, "false")] {
|
||||
let d = import(&with(add)).unwrap();
|
||||
let d = import_as(&with(add), &OdfProfile::word());
|
||||
let saved = crate::odt::export(&d).unwrap();
|
||||
let settings = crate::odt::tests::entry(&saved, "settings.xml");
|
||||
assert!(settings.contains(&format!(r#"config:name="AddParaTableSpacing" config:type="boolean">{written}<"#)), "{add:?}: {settings}");
|
||||
@@ -111,11 +132,12 @@ fn paragraph_spacing_adds_up_as_the_document_says_else_takes_the_larger() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn widow_control_is_the_files_else_on() {
|
||||
fn widow_control_is_the_files_else_the_profiles() {
|
||||
// `fo:widows`/`fo:orphans` refer to XSL's (initial value 2); LibreOffice reads none stated as
|
||||
// none. Stated in the default style (or Standard), it is the file's; unstated, on; written
|
||||
// from the model (after an edit), stated either way, so every reader takes it the same. An
|
||||
// unedited file keeps its own statement, or its silence (see `kept`).
|
||||
// none, so a first line alone at a page's foot stayed there in LibreOffice and moved on after
|
||||
// a save. Stated in the default style (or Standard), it is the file's under every preset;
|
||||
// written from the model (after an edit), stated either way. An unedited file keeps its
|
||||
// own statement, or its silence (see `kept`).
|
||||
let doc = |default: &str| {
|
||||
let styles = format!(
|
||||
r#"<office:document-styles {NS_T}><office:styles><style:default-style style:family="paragraph"><style:paragraph-properties {default}/></style:default-style></office:styles></office:document-styles>"#
|
||||
@@ -125,15 +147,17 @@ fn widow_control_is_the_files_else_on() {
|
||||
);
|
||||
crate::odt::tests::package(&content, Some(&styles))
|
||||
};
|
||||
let on = |bytes: &[u8]| import(bytes).unwrap().styles.resolve_para(&Default::default()).widow_control;
|
||||
let stated_off = r#"fo:widows="0" fo:orphans="0""#;
|
||||
assert!(on(&doc(r#"fo:widows="2" fo:orphans="2""#)));
|
||||
assert!(!on(&doc(stated_off)));
|
||||
assert!(on(&doc("")));
|
||||
for (default, written, widows) in [("", r#"fo:widows="2" fo:orphans="2""#, true), (stated_off, stated_off, false)] {
|
||||
let saved = crate::odt::tests::export_model(&import(&doc(default)).unwrap()).unwrap();
|
||||
let on = |bytes: &[u8], p: &OdfProfile| import_as(bytes, p).styles.resolve_para(&Default::default()).widow_control;
|
||||
for p in [OdfProfile::word(), OdfProfile::libreoffice()] {
|
||||
assert!(on(&doc(r#"fo:widows="2" fo:orphans="2""#), &p));
|
||||
assert!(!on(&doc(r#"fo:widows="0" fo:orphans="0""#), &p));
|
||||
}
|
||||
assert!(on(&doc(""), &OdfProfile::word()));
|
||||
assert!(!on(&doc(""), &OdfProfile::libreoffice()));
|
||||
for (p, written) in [(OdfProfile::word(), r#"fo:widows="2" fo:orphans="2""#), (OdfProfile::libreoffice(), r#"fo:widows="0" fo:orphans="0""#)] {
|
||||
let saved = crate::odt::tests::export_model(&import_as(&doc(""), &p)).unwrap();
|
||||
let styles = crate::odt::tests::entry(&saved, "styles.xml");
|
||||
assert!(styles.contains(written), "{default}: {styles}");
|
||||
assert_eq!(on(&saved), widows);
|
||||
assert!(styles.contains(written), "{:?}: {styles}", p.preset);
|
||||
assert_eq!(on(&saved, &OdfProfile::of(OdfPreset::Custom)), p.widow_control);
|
||||
}
|
||||
}
|
||||
@@ -56,7 +56,7 @@ fn open() -> Document {
|
||||
#[test]
|
||||
fn shapes_open_with_their_kind_size_look_and_text() {
|
||||
let d = open();
|
||||
// A line width no style states (the standard gives none): WordCraft's 0.25 pt.
|
||||
// A line width no style states is the profile's (the standard gives none): WordCraft's 0.25 pt.
|
||||
let blue = Some((Rgb(0x34, 0x65, 0xa4), 0.25));
|
||||
assert_eq!(
|
||||
shapes(&d),
|
||||
|
||||
@@ -1,26 +1,22 @@
|
||||
//! Tab stops (ODF 1.3 §17.7–17.8 `style:tab-stops`): position, alignment and leader, read from
|
||||
//! paragraph styles and written back. A file whose `TabsRelativeToIndent` setting is on measures
|
||||
//! them from the paragraph's indent (LibreOffice's default); otherwise from the margin, as Word.
|
||||
//! paragraph styles and written back. LibreOffice measures them from the paragraph's indent when
|
||||
//! its `TabsRelativeToIndent` setting is on (its default); WordCraft and Word from the margin.
|
||||
|
||||
use crate::odt::tests::{NS_T, package};
|
||||
use crate::odt::tests::{NS_T, import_as, package};
|
||||
use crate::odt::{export, import};
|
||||
use wordcraft_doc::odf_profile::OdfProfile;
|
||||
use wordcraft_doc::props::{ParaProps, TabAlign, TabLeader, TabStop};
|
||||
use wordcraft_doc::{Document, Paragraph, para_block};
|
||||
|
||||
const TABS: &str = r#"<style:tab-stops><style:tab-stop style:position="1in"/><style:tab-stop style:position="2in" style:type="center" style:leader-style="dotted" style:leader-text="."/><style:tab-stop style:position="3in" style:type="char" style:char="."/><style:tab-stop style:position="4in" style:type="right" style:leader-style="solid" style:leader-text="_"/><style:tab-stop style:position="5in" style:leader-text="-"/></style:tab-stops>"#;
|
||||
|
||||
/// A `settings.xml` saying whether tab stops are measured from the indent.
|
||||
fn settings_xml(relative: bool) -> String {
|
||||
format!(
|
||||
r#"<office:document-settings xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:config="urn:oasis:names:tc:opendocument:xmlns:config:1.0"><office:settings><config:config-item-set config:name="ooo:configuration-settings"><config:config-item config:name="TabsRelativeToIndent" config:type="boolean">{relative}</config:config-item></config:config-item-set></office:settings></office:document-settings>"#
|
||||
)
|
||||
}
|
||||
|
||||
fn with_settings(content: &str, relative: Option<bool>) -> Vec<u8> {
|
||||
let mut bytes = package(content, None);
|
||||
if let Some(r) = relative {
|
||||
// Re-pack with a settings.xml.
|
||||
let settings = settings_xml(r);
|
||||
let settings = format!(
|
||||
r#"<office:document-settings xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:config="urn:oasis:names:tc:opendocument:xmlns:config:1.0"><office:settings><config:config-item-set config:name="ooo:configuration-settings"><config:config-item config:name="TabsRelativeToIndent" config:type="boolean">{r}</config:config-item></config:config-item-set></office:settings></office:document-settings>"#
|
||||
);
|
||||
let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||||
for (name, data) in [("mimetype", "application/vnd.oasis.opendocument.text"), ("content.xml", content), ("settings.xml", settings.as_str())] {
|
||||
@@ -65,14 +61,18 @@ fn tab_stops_are_read() {
|
||||
|
||||
#[test]
|
||||
fn tabs_relative_to_the_indent_move_with_it() {
|
||||
// The document's own setting rules; without one (the standard doesn't say) from the margin.
|
||||
let first = |relative: Option<bool>| {
|
||||
let d = import(&with_settings(&content(r#"fo:margin-left="0.5in""#), relative)).unwrap();
|
||||
// The document's own setting rules; without one (the standard doesn't say) the profile's:
|
||||
// LibreOffice's from the paragraph's indent, WordCraft's from the margin.
|
||||
let first = |relative: Option<bool>, profile: &OdfProfile| {
|
||||
let d = import_as(&with_settings(&content(r#"fo:margin-left="0.5in""#), relative), profile);
|
||||
tabs(&d).first().map(|t| t.pos)
|
||||
};
|
||||
assert_eq!(first(Some(true)), Some(108.0));
|
||||
assert_eq!(first(Some(false)), Some(72.0));
|
||||
assert_eq!(first(None), Some(72.0));
|
||||
for profile in [OdfProfile::libreoffice(), OdfProfile::word()] {
|
||||
assert_eq!(first(Some(true), &profile), Some(108.0));
|
||||
assert_eq!(first(Some(false), &profile), Some(72.0));
|
||||
}
|
||||
assert_eq!(first(None, &OdfProfile::libreoffice()), Some(108.0));
|
||||
assert_eq!(first(None, &OdfProfile::word()), Some(72.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -131,14 +131,8 @@ fn tabs_are_measured_from_the_indent_a_style_gives() {
|
||||
let content = format!(
|
||||
r#"<office:document-content {NS_T}><office:automatic-styles><style:style style:name="P1" style:family="paragraph" style:parent-style-name="Indented"><style:paragraph-properties><style:tab-stops><style:tab-stop style:position="3in"/></style:tab-stops></style:paragraph-properties></style:style></office:automatic-styles><office:body><office:text><text:p text:style-name="P1">a<text:tab/>b</text:p><text:p text:style-name="Indented">c<text:tab/>d</text:p></office:text></office:body></office:document-content>"#
|
||||
);
|
||||
// Measured from the indent, as the document says.
|
||||
let settings = settings_xml(true);
|
||||
let d = import(&crate::odt::tests::zip_of(&[
|
||||
("content.xml", content.as_bytes()),
|
||||
("styles.xml", styles.as_bytes()),
|
||||
("settings.xml", settings.as_bytes()),
|
||||
]))
|
||||
.unwrap();
|
||||
// LibreOffice's measure (its default when the document doesn't say).
|
||||
let d = import_as(&package(&content, Some(&styles)), &OdfProfile::libreoffice());
|
||||
let own = d.body[0].as_para().unwrap().props.tabs.clone().unwrap_or_default();
|
||||
assert_eq!(own.iter().map(|t| t.pos).collect::<Vec<_>>(), [252.0], "3in from a 0.5in indent");
|
||||
let style = d.styles.get("Indented").unwrap().para.tabs.clone().unwrap_or_default();
|
||||
@@ -169,7 +163,7 @@ fn tabs_measured_from_the_indent_are_written_so() {
|
||||
// A file measuring tabs from the indent (LibreOffice's own) is written back so, its stops
|
||||
// from the indent again: LibreOffice's default stops then count from the indent as before
|
||||
// (measured: with the setting false they moved to the margin's grid).
|
||||
let d = import(&with_settings(&content(r#"fo:margin-left="0.5in""#), Some(true))).unwrap();
|
||||
let d = import_as(&with_settings(&content(r#"fo:margin-left="0.5in""#), Some(true)), &OdfProfile::word());
|
||||
assert!(d.settings.odf.is_some_and(|o| o.tabs_from_indent));
|
||||
let saved = export(&d).unwrap();
|
||||
let s = crate::odt::tests::entry(&saved, "settings.xml");
|
||||
@@ -179,7 +173,7 @@ fn tabs_measured_from_the_indent_are_written_so() {
|
||||
let back = import(&saved).unwrap();
|
||||
assert_eq!(tabs(&back).first().map(|t| t.pos), Some(108.0));
|
||||
// One measuring them from the margin stays so.
|
||||
let d = import(&with_settings(&content(r#"fo:margin-left="0.5in""#), Some(false))).unwrap();
|
||||
let d = import_as(&with_settings(&content(r#"fo:margin-left="0.5in""#), Some(false)), &OdfProfile::word());
|
||||
let s = crate::odt::tests::entry(&export(&d).unwrap(), "settings.xml");
|
||||
assert!(s.contains(r#"config:name="TabsRelativeToIndent" config:type="boolean">false<"#), "{s}");
|
||||
}
|
||||
|
||||
@@ -316,12 +316,12 @@ fn a_frames_drawn_stroke_is_its_line_when_it_has_no_border() {
|
||||
let content = format!(
|
||||
r#"<office:document-content {NS_T} {NS_D}><office:automatic-styles><style:style style:name="g" style:family="graphic"><style:graphic-properties {props}/></style:style></office:automatic-styles><office:body><office:text><text:p><draw:frame draw:style-name="g" svg:width="2in" svg:height="1in" text:anchor-type="paragraph"><draw:text-box><text:p>Logo</text:p></draw:text-box></draw:frame></text:p></office:text></office:body></office:document-content>"#
|
||||
);
|
||||
import(&package(&content, None)).unwrap()
|
||||
crate::odt::tests::import_as(&package(&content, None), &wordcraft_doc::odf_profile::OdfProfile::libreoffice())
|
||||
};
|
||||
let line = |d: &Document| looks(d).first().map(|l| l.1).expect("a box");
|
||||
let stroke = r##"draw:stroke="solid" svg:stroke-width="0cm" svg:stroke-color="#000000""##;
|
||||
// A 0 width draws at a hairline, 0.25 pt.
|
||||
assert_eq!(line(&open_with(stroke)), Some((wordcraft_doc::props::Rgb::BLACK, 0.25)));
|
||||
// A 0 width draws at the profile's hairline: LibreOffice's 0.5 pt (WordCraft's is 0.25).
|
||||
assert_eq!(line(&open_with(stroke)), Some((wordcraft_doc::props::Rgb::BLACK, 0.5)));
|
||||
assert_eq!(line(&open_with(&format!(r#"{stroke} fo:border="none""#))), None);
|
||||
assert_eq!(line(&open_with("")), None);
|
||||
}
|
||||
|
||||
@@ -153,15 +153,6 @@ fn rtf_border_style(s: wordcraft_doc::props::BorderStyle) -> &'static str {
|
||||
BorderStyle::Triple => "\\brdrtriple",
|
||||
BorderStyle::DotDash => "\\brdrdashd",
|
||||
BorderStyle::Wave => "\\brdrwavy",
|
||||
BorderStyle::ThinThickSmallGap => "\\brdrtnthsg",
|
||||
BorderStyle::ThickThinSmallGap => "\\brdrthtnsg",
|
||||
BorderStyle::ThinThickThinSmallGap => "\\brdrtnthtnsg",
|
||||
BorderStyle::ThinThickMediumGap => "\\brdrtnthmg",
|
||||
BorderStyle::ThickThinMediumGap => "\\brdrthtnmg",
|
||||
BorderStyle::ThinThickThinMediumGap => "\\brdrtnthtnmg",
|
||||
BorderStyle::ThinThickLargeGap => "\\brdrtnthlg",
|
||||
BorderStyle::ThickThinLargeGap => "\\brdrthtnlg",
|
||||
BorderStyle::ThinThickThinLargeGap => "\\brdrtnthtnlg",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1672,34 +1672,3 @@ fn rtf_backslash_space_and_special_breaks() {
|
||||
let p = doc.body.first().unwrap().as_para().unwrap();
|
||||
assert_eq!(p.text, "A\u{00A0}B\u{00A0}C\u{200B}D\nE\u{000C}F");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compound_borders_round_trip() {
|
||||
use wordcraft_doc::props::BorderStyle;
|
||||
let lines = [
|
||||
("brdrtnthsg", BorderStyle::ThinThickSmallGap),
|
||||
("brdrthtnsg", BorderStyle::ThickThinSmallGap),
|
||||
("brdrtnthtnsg", BorderStyle::ThinThickThinSmallGap),
|
||||
("brdrtnthmg", BorderStyle::ThinThickMediumGap),
|
||||
("brdrthtnmg", BorderStyle::ThickThinMediumGap),
|
||||
("brdrtnthtnmg", BorderStyle::ThinThickThinMediumGap),
|
||||
("brdrtnthlg", BorderStyle::ThinThickLargeGap),
|
||||
("brdrthtnlg", BorderStyle::ThickThinLargeGap),
|
||||
("brdrtnthtnlg", BorderStyle::ThinThickThinLargeGap),
|
||||
];
|
||||
let body: String = lines.iter().map(|(w, _)| format!("\\pard\\brdrt\\{w}\\brdrw30 {w}\\par\n")).collect();
|
||||
let rtf = format!("{{\\rtf1\\ansi\n{body}}}");
|
||||
let tops = |d: &wordcraft_doc::Document| -> Vec<Option<BorderStyle>> {
|
||||
d.body
|
||||
.iter()
|
||||
.filter_map(|b| b.as_para())
|
||||
.filter(|p| !p.plain_text().is_empty())
|
||||
.map(|p| p.props.borders.and_then(|b| b.top).map(|t| t.style))
|
||||
.collect()
|
||||
};
|
||||
let want: Vec<Option<BorderStyle>> = lines.iter().map(|(_, s)| Some(*s)).collect();
|
||||
let d = import(rtf.as_bytes()).unwrap();
|
||||
assert_eq!(tops(&d), want);
|
||||
let back = import(export(&d).as_bytes()).unwrap();
|
||||
assert_eq!(tops(&back), want);
|
||||
}
|
||||
|
||||
@@ -206,24 +206,15 @@ impl Reader {
|
||||
"clbrdrb" => self.cell_side = Some('b'),
|
||||
"clbrdrr" => self.cell_side = Some('r'),
|
||||
"brdrs" | "brdrth" | "brdrsh" | "brdrdb" | "brdrdot" | "brdrdash" | "brdrdashsm" | "brdrdashd" | "brdrdashdd" | "brdrhair"
|
||||
| "brdrtriple" | "brdrthtnsg" | "brdrtnthsg" | "brdrtnthtnsg" | "brdrthtnmg" | "brdrtnthmg" | "brdrtnthtnmg" | "brdrthtnlg"
|
||||
| "brdrtnthlg" | "brdrtnthtnlg" | "brdrwavy" | "brdrwavydb" | "brdrnone" | "brdrnil" | "brdrtbl"
|
||||
| "brdrtriple" | "brdrthtnsg" | "brdrtnthsg" | "brdrthtnmg" | "brdrtnthmg" | "brdrwavy" | "brdrwavydb" | "brdrnone" | "brdrnil"
|
||||
| "brdrtbl"
|
||||
if self.cell_side.is_some() || self.st.border_side.is_some() =>
|
||||
{
|
||||
use wordcraft_doc::props::{Border, BorderStyle};
|
||||
let style = match w {
|
||||
"brdrnone" | "brdrnil" | "brdrtbl" => BorderStyle::None,
|
||||
"brdrth" => BorderStyle::Thick,
|
||||
"brdrdb" | "brdrwavydb" => BorderStyle::Double,
|
||||
"brdrtnthsg" => BorderStyle::ThinThickSmallGap,
|
||||
"brdrthtnsg" => BorderStyle::ThickThinSmallGap,
|
||||
"brdrtnthtnsg" => BorderStyle::ThinThickThinSmallGap,
|
||||
"brdrtnthmg" => BorderStyle::ThinThickMediumGap,
|
||||
"brdrthtnmg" => BorderStyle::ThickThinMediumGap,
|
||||
"brdrtnthtnmg" => BorderStyle::ThinThickThinMediumGap,
|
||||
"brdrtnthlg" => BorderStyle::ThinThickLargeGap,
|
||||
"brdrthtnlg" => BorderStyle::ThickThinLargeGap,
|
||||
"brdrtnthtnlg" => BorderStyle::ThinThickThinLargeGap,
|
||||
"brdrdb" | "brdrthtnsg" | "brdrtnthsg" | "brdrthtnmg" | "brdrtnthmg" | "brdrwavydb" => BorderStyle::Double,
|
||||
"brdrdot" => BorderStyle::Dotted,
|
||||
"brdrdash" | "brdrdashsm" => BorderStyle::Dashed,
|
||||
"brdrdashd" | "brdrdashdd" => BorderStyle::DotDash,
|
||||
|
||||
@@ -65,16 +65,7 @@ fn sample() -> Document {
|
||||
let n = ip.len();
|
||||
ip.insert_object(
|
||||
n,
|
||||
InlineObject::Image {
|
||||
media: key,
|
||||
w: 60.0,
|
||||
h: 30.0,
|
||||
alt: "tiny".into(),
|
||||
float: Default::default(),
|
||||
crop: [0.0; 4],
|
||||
fx: Default::default(),
|
||||
ole: None,
|
||||
},
|
||||
InlineObject::Image { media: key, w: 60.0, h: 30.0, alt: "tiny".into(), float: Default::default(), crop: [0.0; 4], ole: None },
|
||||
&CharProps::default(),
|
||||
)
|
||||
.unwrap();
|
||||
@@ -697,7 +688,7 @@ fn decorative_picture_exports_with_empty_alt_text() {
|
||||
let mut d = Document::new();
|
||||
let media = d.add_media(png(), "png");
|
||||
let float = Float { alt: AltText { decorative, ..Default::default() }, ..Default::default() };
|
||||
let pic = InlineObject::Image { media, w: 40.0, h: 20.0, alt: "Blue bar".into(), float, crop: [0.0; 4], fx: Default::default(), ole: None };
|
||||
let pic = InlineObject::Image { media, w: 40.0, h: 20.0, alt: "Blue bar".into(), float, crop: [0.0; 4], ole: None };
|
||||
let mut p = Paragraph::with_text("Logo: ", CharProps::default());
|
||||
let n = p.len();
|
||||
p.insert_object(n, pic, &CharProps::default()).unwrap();
|
||||
@@ -900,16 +891,7 @@ fn hidden_drawings_are_left_out_of_exports() {
|
||||
let key = d.add_media(vec![0x89, b'P', b'N', b'G'], "png");
|
||||
for hidden in [false, true] {
|
||||
let float = wordcraft_doc::para::Float { hidden, ..Default::default() };
|
||||
let image = wordcraft_doc::InlineObject::Image {
|
||||
media: key.clone(),
|
||||
w: 10.0,
|
||||
h: 10.0,
|
||||
alt: "Logo".into(),
|
||||
float,
|
||||
crop: [0.0; 4],
|
||||
fx: Default::default(),
|
||||
ole: None,
|
||||
};
|
||||
let image = wordcraft_doc::InlineObject::Image { media: key.clone(), w: 10.0, h: 10.0, alt: "Logo".into(), float, crop: [0.0; 4], ole: None };
|
||||
d.insert_object(&wordcraft_doc::Pos::body(0, 0), image, &Default::default()).unwrap();
|
||||
}
|
||||
// Two pictures, one hidden: one comes out.
|
||||
|
||||
@@ -23,9 +23,6 @@ serde_json = { workspace = true }
|
||||
image = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
wordcraft-doc = { workspace = true, features = ["test-support"] }
|
||||
|
||||
[target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies]
|
||||
# The browser clock (`SystemTime::now()`/`Instant::now()` panic on wasm32-unknown-unknown).
|
||||
js-sys = "0.3"
|
||||
|
||||
@@ -54,13 +54,9 @@ fn anchors_walk_dense_unrelated_markers_in_linear_work() {
|
||||
const N: usize = 2_000;
|
||||
let (small, big) = (dense_markers(N), dense_markers(crate::tests::SCALE * N));
|
||||
let check = |doc: &Document, n| assert_eq!(anchors(doc), vec![(0, Pos::body(0, n * wordcraft_doc::para::OBJ.len_utf8()))]);
|
||||
// The characters the paragraph's object lookups walk (`wordcraft_doc::work`), not a timing:
|
||||
// about 8x for 8x the markers when the walk is linear, about 64x when it is quadratic.
|
||||
let (s, b) = (wordcraft_doc::work::count(|| check(&small, N)), wordcraft_doc::work::count(|| check(&big, crate::tests::SCALE * N)));
|
||||
let ratio = b as f64 / s.max(1) as f64;
|
||||
eprintln!("balloon anchor steps: {s} -> {b}, {ratio:.1}x for 8x markers");
|
||||
assert!(s > 0, "no steps counted, so the walk this test watches no longer counts them (wordcraft_doc::work)");
|
||||
assert!(ratio <= 1.5 * crate::tests::SCALE as f64, "balloon anchor work grew {ratio:.1}x for 8x markers ({s} -> {b} steps)");
|
||||
let (s, b, ratio) = crate::tests::cost_ratio(crate::tests::SCALE, || check(&small, N), || check(&big, crate::tests::SCALE * N));
|
||||
eprintln!("balloon anchor CPU: {s:.6}s -> {b:.6}s, {ratio:.2}x for 8x markers");
|
||||
assert!(ratio < 24.0, "balloon anchor work grew {ratio:.2}x for 8x markers ({s:.6}s -> {b:.6}s)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -81,12 +81,9 @@ pub enum Draw {
|
||||
rect: Rect,
|
||||
media: String,
|
||||
crop: [f32; 4],
|
||||
/// Opacity, the picture's own transparency folded in.
|
||||
alpha: f32,
|
||||
/// The picture's alt text (empty for a floating or diagram picture, which tag themselves).
|
||||
alt: String,
|
||||
/// Brightness and contrast to apply to the pixels (transparency is in `alpha`).
|
||||
fx: wordcraft_doc::picfx::PicFx,
|
||||
/// Marked decorative (Alt Text › Mark as decorative): an artifact in tagged PDF, which
|
||||
/// screen readers skip. Always `false` for a floating or diagram picture, as `alt`.
|
||||
decorative: bool,
|
||||
|
||||
@@ -36,15 +36,9 @@ pub(super) fn graphic_draws(doc: &Document, g: &Graphic, rect: Rect, alpha: f32)
|
||||
})
|
||||
}
|
||||
GraphicItem::Text { rect: r, .. } => out.extend(text_glyphs(doc, inside(r), it, alpha, k)),
|
||||
GraphicItem::Image { rect: r, media } => out.push(Draw::Image {
|
||||
rect: inside(r),
|
||||
media: media.clone(),
|
||||
crop: [0.0; 4],
|
||||
alpha,
|
||||
alt: String::new(),
|
||||
fx: Default::default(),
|
||||
decorative: false,
|
||||
}),
|
||||
GraphicItem::Image { rect: r, media } => {
|
||||
out.push(Draw::Image { rect: inside(r), media: media.clone(), crop: [0.0; 4], alpha, alt: String::new(), decorative: false })
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
@@ -398,10 +392,9 @@ pub(super) fn spun(spin: Spin, rect: Rect, items: Vec<Draw>, out: &mut Vec<Draw>
|
||||
pub(super) fn object_draws(o: &InlineObject, rect: Rect, outer: Spin, alpha: f32, opts: &DisplayOptions, out: &mut Vec<Draw>) {
|
||||
let own = o.frame().map(|(_, _, f)| f.spin()).unwrap_or_default();
|
||||
match o {
|
||||
InlineObject::Image { media, crop, alt, fx, .. } => {
|
||||
let alpha = alpha * fx.opacity();
|
||||
InlineObject::Image { media, crop, alt, .. } => {
|
||||
let decorative = o.is_decorative();
|
||||
spun(own, rect, vec![Draw::Image { rect, media: media.clone(), crop: *crop, alpha, alt: alt.clone(), fx: *fx, decorative }], out)
|
||||
spun(own, rect, vec![Draw::Image { rect, media: media.clone(), crop: *crop, alpha, alt: alt.clone(), decorative }], out)
|
||||
}
|
||||
InlineObject::Shape { kind, fill, stroke, stroke_width, effects, freeform, extra, .. } => {
|
||||
let effects = effects_in(*effects, own.within(outer));
|
||||
@@ -458,7 +451,6 @@ pub(super) fn object_draws(o: &InlineObject, rect: Rect, outer: Spin, alpha: f32
|
||||
crop: [0.0; 4],
|
||||
alpha,
|
||||
alt: String::new(),
|
||||
fx: Default::default(),
|
||||
decorative: false,
|
||||
}),
|
||||
_ => None,
|
||||
|
||||
@@ -133,11 +133,8 @@ fn stories_display(
|
||||
if hidden.as_ref().is_some_and(|h| h.hides(it)) {
|
||||
continue;
|
||||
}
|
||||
// A line in a clipped cell is drawn in its clip; in a turned text box, with the box's
|
||||
// other lines (clipped there), so it is drawn once.
|
||||
if let Placed::Lines { story, path, .. } = it
|
||||
&& let Some(rect) = cell_clip(clips, *story, &path.0)
|
||||
&& turned_frame(&frames, it).is_none()
|
||||
{
|
||||
let mut inner = Vec::new();
|
||||
item(doc, it, opts, authors, a, &mut inner);
|
||||
@@ -169,15 +166,7 @@ fn stories_display(
|
||||
if let Some((_, rect, spin)) = frames.get(k) {
|
||||
let mut inner = Vec::new();
|
||||
for line in items.iter().filter(|i| turned_frame(&frames, i) == Some(k)) {
|
||||
if let Placed::Lines { story, path, .. } = line
|
||||
&& let Some(rect) = cell_clip(clips, *story, &path.0)
|
||||
{
|
||||
let mut clipped = Vec::new();
|
||||
item(doc, line, opts, authors, a, &mut clipped);
|
||||
inner.push(Draw::Clip { rect, items: clipped });
|
||||
} else {
|
||||
item(doc, line, opts, authors, a, &mut inner);
|
||||
}
|
||||
item(doc, line, opts, authors, a, &mut inner);
|
||||
}
|
||||
super::objects::spun(*spin, *rect, inner, out);
|
||||
}
|
||||
@@ -436,15 +425,12 @@ fn item_in(doc: &Document, it: &Placed, opts: &DisplayOptions, authors: &Authors
|
||||
match it {
|
||||
Placed::Fill { rect, color } => out.push(Draw::Fill { rect: *rect, color: *color, alpha, text_bg: false }),
|
||||
Placed::Rule { x0, y0, x1, y1, border } => out.push(rule(*x0, *y0, *x1, *y1, border, alpha)),
|
||||
Placed::Image { rect, media, crop, spin, fx, .. } => {
|
||||
let alpha = alpha * fx.opacity();
|
||||
spun(
|
||||
*spin,
|
||||
*rect,
|
||||
vec![Draw::Image { rect: *rect, media: media.clone(), crop: *crop, alpha, alt: String::new(), fx: *fx, decorative: false }],
|
||||
out,
|
||||
)
|
||||
}
|
||||
Placed::Image { rect, media, crop, spin, .. } => spun(
|
||||
*spin,
|
||||
*rect,
|
||||
vec![Draw::Image { rect: *rect, media: media.clone(), crop: *crop, alpha, alt: String::new(), decorative: false }],
|
||||
out,
|
||||
),
|
||||
Placed::Shape { rect, kind, fill, stroke, stroke_width, effects, freeform, spin, extra, .. } => {
|
||||
let mut items = Vec::new();
|
||||
let fx = effects_in(*effects, spin.within(outer));
|
||||
|
||||
@@ -244,8 +244,8 @@ fn float_items(o: &InlineObject, rect: Rect, outer: (Spin, Point), story: StoryR
|
||||
let rect = carried(rect, ospin, oc);
|
||||
let spin = own.within(ospin);
|
||||
match o {
|
||||
InlineObject::Image { media, crop, fx, .. } => {
|
||||
vec![Placed::Image { rect, media: media.clone(), crop: *crop, story, path: Path(path.to_vec()), off, spin, fx: *fx }]
|
||||
InlineObject::Image { media, crop, .. } => {
|
||||
vec![Placed::Image { rect, media: media.clone(), crop: *crop, story, path: Path(path.to_vec()), off, spin }]
|
||||
}
|
||||
InlineObject::Shape { kind, fill, stroke, stroke_width, effects, freeform, extra, .. } => {
|
||||
let object = wordcraft_doc::Pos { story, path: Path(path.to_vec()), off };
|
||||
|
||||
@@ -296,18 +296,10 @@ impl PageBuilder<'_> {
|
||||
self.page_items_start = self.pages.last().map_or(0, |p| p.items.len());
|
||||
true
|
||||
}
|
||||
/// Finish the current page: the lines between its columns, its vertical alignment and its
|
||||
/// footnotes, placed with the section state in effect (margins, gutter, separators).
|
||||
/// `new_page` starts with this; after it has run, `new_page` finds nothing left to finish.
|
||||
pub(crate) fn finish_page(&mut self) {
|
||||
pub(crate) fn new_page(&mut self, first_block: usize, body_top: f32) {
|
||||
self.close_column_lines();
|
||||
self.apply_valign();
|
||||
// The page is aligned once: finishing it again before it is left moves nothing twice.
|
||||
self.page_valign = wordcraft_doc::section::PageVAlign::Top;
|
||||
self.flush_notes();
|
||||
}
|
||||
pub(crate) fn new_page(&mut self, first_block: usize, body_top: f32) {
|
||||
self.finish_page();
|
||||
self.beneath = self.sect_beneath;
|
||||
self.excl.clear();
|
||||
// Floats placed above their anchors further down this page are known from its start.
|
||||
|
||||
@@ -42,6 +42,9 @@ struct Breaker<'e> {
|
||||
rtl_para: bool,
|
||||
/// The document grid's line pitch, for paragraphs that snap to it.
|
||||
grid: Option<Grid>,
|
||||
/// A line of only pictures is as tall as they are (an ODF document opened with that choice,
|
||||
/// [`wordcraft_doc::odf_profile::OdfProfile::picture_lines_fit_pictures`]).
|
||||
picture_lines_fit: bool,
|
||||
bidi: bool,
|
||||
est_h: f32,
|
||||
table_h: f32,
|
||||
@@ -196,6 +199,7 @@ impl<'e> Breaker<'e> {
|
||||
widths: word_widths(&pl.clusters, &pl.hyph_after),
|
||||
rtl_para: rp.bidi,
|
||||
grid: Some(env.grid).filter(|g| g.line.is_some() && rp.snap_to_grid),
|
||||
picture_lines_fit: settings.odf.is_some_and(|o| o.picture_lines_fit_pictures),
|
||||
bidi: rp.bidi || !pl.bidi_levels.is_empty(),
|
||||
est_h,
|
||||
// Exact spacing fixes the table-contact boundary even when the mark has other metrics.
|
||||
@@ -409,6 +413,9 @@ impl<'e> Breaker<'e> {
|
||||
let rp = &self.rp;
|
||||
let mut ex = Extents { asc: 0.0, desc: 0.0, obj_asc: 0.0 };
|
||||
let mut any = false;
|
||||
// Only pictures on the line, and the document lays such lines out to fit them.
|
||||
let mut only_pictures = self.picture_lines_fit;
|
||||
let mut pictures = Extents { asc: 0.0, desc: 0.0, obj_asc: 0.0 };
|
||||
let dropped = pl.drop_cap.map_or(0, |d| d.0);
|
||||
for (k, c) in pl.clusters.get(c0..c1).into_iter().flatten().enumerate() {
|
||||
// Spaces and tabs never make a line taller, whatever their size: Word sizes a line by
|
||||
@@ -424,15 +431,19 @@ impl<'e> Breaker<'e> {
|
||||
// the line keeps that font's ascent and descent, and grows to fit it.
|
||||
let (a, d) = if matches!(c.kind, ClKind::Object(_)) && c.obj_h + c.obj_d > 0.0 {
|
||||
if pl.maths.iter().any(|(k, _)| c.kind == ClKind::Object(*k)) {
|
||||
only_pictures = false;
|
||||
// Exact spacing keeps the run's baseline; tall math may overflow the
|
||||
// fixed line box without moving adjacent text up by its denominator.
|
||||
if matches!(rp.line_spacing, LineSpacing::Exactly(_)) { (st.ascent, st.descent) } else { (c.obj_h, c.obj_d) }
|
||||
} else {
|
||||
ex.obj_asc = ex.obj_asc.max(c.obj_h);
|
||||
pictures.asc = pictures.asc.max(c.obj_h);
|
||||
pictures.desc = pictures.desc.max(c.obj_d);
|
||||
// A lowered picture reaches below the baseline: the line's descent grows.
|
||||
(st.ascent, st.descent.max(c.obj_d))
|
||||
}
|
||||
} else {
|
||||
only_pictures = false;
|
||||
// `obj_h` is set on text clusters only under ruby text, which the line grows to fit.
|
||||
let (a, d) = st.line_extent();
|
||||
(a.max(c.obj_h + st.shift), d)
|
||||
@@ -441,6 +452,10 @@ impl<'e> Breaker<'e> {
|
||||
ex.desc = ex.desc.max(d);
|
||||
any = true;
|
||||
}
|
||||
if any && only_pictures && pictures.asc + pictures.desc > 0.0 {
|
||||
pictures.obj_asc = pictures.asc;
|
||||
return pictures;
|
||||
}
|
||||
if (!any || (ex.asc == 0.0 && ex.desc == 0.0))
|
||||
&& let Some(st) = pl.styles.get(self.mark_style as usize)
|
||||
{
|
||||
@@ -569,13 +584,6 @@ impl<'e> Breaker<'e> {
|
||||
(LineSpacing::AtLeast(v), None) => natural.max(v),
|
||||
(LineSpacing::Exactly(v), _) => v,
|
||||
};
|
||||
// The empty line's caret follows the alignment, as an empty paragraph's does.
|
||||
let slack = (self.base_right - rp.indent_left).max(0.0);
|
||||
let shift = match rp.align {
|
||||
Align::Center => slack / 2.0,
|
||||
Align::Right => slack,
|
||||
_ => 0.0,
|
||||
};
|
||||
let (width, x0) = (self.width, rp.indent_left);
|
||||
let (x0, left, right) = if self.rtl_para { (width - x0, width - self.base_right, width - x0) } else { (x0, x0, self.base_right) };
|
||||
self.lines.push(Line {
|
||||
@@ -586,7 +594,7 @@ impl<'e> Breaker<'e> {
|
||||
text_desc: desc,
|
||||
c0: self.n,
|
||||
c1: self.n,
|
||||
xs: vec![if self.rtl_para { x0 - shift } else { x0 + shift }],
|
||||
xs: vec![x0],
|
||||
leaders: Vec::new(),
|
||||
end: LineEnd::Para,
|
||||
start: pl.text_len,
|
||||
@@ -610,11 +618,8 @@ impl<'e> Breaker<'e> {
|
||||
let short = lines.len() < dl as usize;
|
||||
let dy = if short { (dl as f32 - 1.0) * l0.height } else { ln.baseline - l0.baseline };
|
||||
let lead = self.first_x0.unwrap_or(0.0) - self.first_left.max(rp.indent_left);
|
||||
// In margin, the letter's right edge stays the distance left of the text column (or of a
|
||||
// hanging first line), whatever the alignment or first-line indent: always in the margin.
|
||||
let edge = self.first_left.min(rp.indent_left);
|
||||
let in_margin = (self.first_x0.unwrap_or(0.0) - edge).max(0.0) + pl.drop_cap_w + rp.drop_cap_distance;
|
||||
let dx = if rp.drop_cap_margin { in_margin } else { dw.min(lead) };
|
||||
// In margin, the letter's right edge stays the distance away from the text's edge.
|
||||
let dx = if rp.drop_cap_margin { lead.max(0.0) + pl.drop_cap_w + rp.drop_cap_distance } else { dw.min(lead) };
|
||||
// Toward the start edge: left, or right (and from the letter's right edge) when mirrored.
|
||||
let dx = if self.rtl_para { pl.drop_cap_w - dx } else { dx };
|
||||
if short {
|
||||
|
||||
@@ -266,8 +266,6 @@ impl<'a> Shaped<'a> {
|
||||
big.size = (((lines - 1.0) * line_h + rc.size * 0.7) / 0.7).min(1000.0).max(rc.size);
|
||||
if let Some(f) = &rp.drop_cap_font {
|
||||
big.font = f.clone();
|
||||
big.font_cs = f.clone();
|
||||
big.font_ea = Some(f.clone());
|
||||
}
|
||||
let before = self.b.clusters.len();
|
||||
self.b.shape(first, 0, &Arc::new(big), None);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user