Compare commits

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 17:18:18 +01:00
158 changed files with 1534 additions and 4356 deletions
-1
View File
@@ -46,4 +46,3 @@ jobs:
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
LINUX_SIGNING_KEY: ${{ secrets.LINUX_SIGNING_KEY }}
Generated
+21 -21
View File
@@ -1081,7 +1081,7 @@ dependencies = [
[[package]]
name = "craft-pdf"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
dependencies = [
"krilla",
"thiserror 2.0.21",
@@ -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
View File
@@ -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
View File
File diff suppressed because one or more lines are too long
+32 -1
View File
@@ -68,6 +68,10 @@ Paper: documents created here, and HTML, Markdown and text files (which give no
the system region's paper (A4, or Letter in the US and other Letter regions), read from
LC_ALL / LC_PAPER / LANG or the platform. Set WORDCRAFT_REGION=en-US (or another tag) for the
same output on every machine.
OpenDocument (.odt, .ott, .fodt) files fill in what their standard leaves to the application in
Microsoft Word's style (WORDCRAFT_ODF=word, the default) or LibreOffice's
(WORDCRAFT_ODF=libreoffice); see docs/odf-interpretation.md.
";
/// The subcommands, in `USAGE` order; `--help` or `-h` after any of them prints `USAGE` (#968).
@@ -86,11 +90,18 @@ fn missing_argument() -> Failure {
/// ([`password_from`]). As in the app, a document opened with one is saved with it again, so
/// `convert` does not quietly write a copy the password no longer protects.
fn open(path: &Path, password: Option<&str>) -> Result<Session, String> {
open_in(path, password, odf_profile()?)
}
/// [`open`] with the OpenDocument style `odf`, which the session keeps: a later `file.open` or
/// `file.new` in a `run` script reads files the same way.
fn open_in(path: &Path, password: Option<&str>, odf: wordcraft_doc::odf_profile::OdfProfile) -> Result<Session, String> {
// A file that gives no page size (HTML, Markdown, plain text) opens on the system region's
// paper, as in the app: A4 in Italy, Letter in the US (#1381).
let region = system_region();
let (doc, encrypted) = wordcraft_engine::io::open_path_in(path, password, &region)?;
let (doc, encrypted) = wordcraft_engine::io::open_path_in(path, password, &region, &odf)?;
let mut s = session(doc);
s.prefs.odf = odf;
s.path = Some(path.into());
if encrypted {
s.password = password.map(wordcraft_engine::io::Password::new);
@@ -172,6 +183,24 @@ fn session(doc: wordcraft_doc::Document) -> Session {
s
}
/// How OpenDocument files fill in what their standard leaves open: `WORDCRAFT_ODF` names a preset
/// (`word`, Microsoft Word's style and the default, or `libreoffice`; `wordcraft` is taken too).
fn odf_profile() -> Result<wordcraft_doc::odf_profile::OdfProfile, String> {
odf_from(std::env::var("WORDCRAFT_ODF").ok())
}
/// [`odf_profile`] from `WORDCRAFT_ODF`'s value, if set.
fn odf_from(var: Option<String>) -> Result<wordcraft_doc::odf_profile::OdfProfile, String> {
use wordcraft_doc::odf_profile::{OdfPreset, OdfProfile};
match var.filter(|v| !v.trim().is_empty()) {
None => Ok(OdfProfile::default()),
Some(v) => match OdfPreset::from_id(&v).filter(|p| *p != OdfPreset::Custom) {
Some(p) => Ok(OdfProfile::of(p)),
None => Err(format!("WORDCRAFT_ODF={v}: one of word, libreoffice")),
},
}
}
/// The system region, read as the app reads it (locale variables, else the platform's locale).
fn system_region() -> wordcraft_engine::Region {
wordcraft_engine::Region::from_locale_vars(|name| std::env::var(name).ok(), sys_locale::get_locale)
@@ -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", &params).map_err(|e| e.to_string())?;
+18
View File
@@ -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);
}
+2 -39
View File
@@ -27,7 +27,7 @@ mod traffic_lights;
use wordcraft_engine::Session;
use wordcraft_ui_egui::{
Services, UiState, WordApp,
window_geometry::{RawWindowState, WindowGeometry, reachable, take_rescue},
window_geometry::{RawWindowState, WindowGeometry, take_rescue},
};
/// The app, the restored window geometry (or, with none, the default: no position) until the
@@ -54,13 +54,7 @@ impl eframe::App for App {
apple_events::poll(&self.5, &mut self.0, ctx);
self.0.logic(ctx);
self.3.show(frame);
let mut window = self.4;
// Only until the off-screen check below has run: it is the one reader.
if self.1.is_some() {
let (on_a_monitor, primary_monitor_rect) = monitor_geometry(frame, ctx.pixels_per_point());
window.on_a_monitor = on_a_monitor;
window.primary_monitor_rect = primary_monitor_rect;
}
let window = self.4;
// A window that doesn't fit on its screen (the default size on a laptop) shrinks to fit
// there, so all of the scroll bar can be dragged (#1215). Checked on the frame that first
// knows the monitor: the off-screen check below takes the pending geometry then.
@@ -132,37 +126,6 @@ impl eframe::App for App {
}
}
/// Whether a grippable part of the window is on a monitor, and the primary monitor bounds.
/// Winit reports physical pixels; egui positions use points. `None` where the platform cannot
/// report the window or primary monitor (Wayland, headless).
fn monitor_geometry(frame: &eframe::Frame, pixels_per_point: f32) -> (Option<bool>, Option<egui::Rect>) {
let Some(w) = frame.winit_window() else {
return (None, None);
};
if !pixels_per_point.is_finite() || pixels_per_point <= 0.0 {
return (None, None);
}
let Some(pos) = w.outer_position().ok() else {
return (None, None);
};
let size = w.outer_size();
let physical_rect = |(x, y): (i32, i32), (width, height): (u32, u32)| {
egui::Rect::from_min_size(egui::pos2(x as f32, y as f32), egui::vec2(width as f32, height as f32))
};
let monitors: Vec<egui::Rect> =
w.available_monitors().map(|m| physical_rect((m.position().x, m.position().y), (m.size().width, m.size().height))).collect();
let on_a_monitor = (!monitors.is_empty()).then(|| reachable(physical_rect((pos.x, pos.y), (size.width, size.height)), &monitors));
let primary_monitor_rect = w.primary_monitor().map(|m| {
let pos = m.position();
let size = m.size();
egui::Rect::from_min_size(
egui::pos2(pos.x as f32, pos.y as f32) / pixels_per_point,
egui::vec2(size.width as f32, size.height as f32) / pixels_per_point,
)
});
(on_a_monitor, primary_monitor_rect)
}
/// Quit the way Command-Q does, on the next turn of the main queue.
///
/// Called when the window is closing. Doing it on the next turn lets the close
+1 -6
View File
@@ -35,9 +35,7 @@ sync_github_name = false
real_name = "Saeed Azarehgar"
[people.vangel76]
real_name = "Valentin Klein"
display_name = "vangel76"
sync_github_name = false
sync_github_name = true
[people.dkhlapov]
real_name = "Dmitrii Khlapov"
@@ -189,6 +187,3 @@ sync_github_name = true
[people.dylanmtaylor]
sync_github_name = true
[people.entropybeater]
display_name = "entropybeater"
+2 -3
View File
@@ -10,9 +10,8 @@ workspace = true
[features]
# Test inputs other crates' tests share (`cfb_guard::repeating_difat_sample`,
# `zip_guard::zip64_entry_count_sample`) and the step count of
# `work::count`; turned on only as a dev-dependency, so never built into
# the app.
# `zip_guard::zip64_entry_count_sample`); turned on only as a
# dev-dependency, so never built into the app.
test-support = []
[dependencies]
+2 -85
View File
@@ -263,9 +263,7 @@ impl KeptXml {
}
/// Whether `doc`'s page setup is still as it was read.
pub fn pages_as_read(&self, doc: &crate::Document) -> bool {
let Some(fp) = self.pages_fp else { return false };
let now = in_scope(doc, |sc| *sc.pages.get_or_insert_with(|| pages_fingerprint(doc))).unwrap_or_else(|| pages_fingerprint(doc));
fp == now
self.pages_fp.is_some_and(|fp| fp == pages_fingerprint(doc))
}
/// Whether `doc`'s style definitions are still as they were read.
pub fn styles_as_read(&self, doc: &crate::Document) -> bool {
@@ -386,62 +384,11 @@ const MAX_REPLIES: usize = 10_000;
/// What comment `root` is with its replies (and theirs): each one's author, date, state and
/// text, and how many they are.
pub fn comments_fingerprint(doc: &crate::Document, root: u32) -> (u64, usize) {
family_fingerprint(doc, root, &scoped_replies(doc))
}
/// What a writer asks of a document's kept markup for many paragraphs, worked out once while
/// [`while_writing`] it: each comment's replies, and its page setup's fingerprint. Without it a
/// document of C comments took C² steps (and its page setup serialized once per note) to save.
struct Scope {
/// The document's address: it can't move or change while borrowed for the writing.
doc: usize,
replies: Option<std::rc::Rc<HashMap<u32, Vec<u32>>>>,
pages: Option<u64>,
}
thread_local! {
static SCOPE: std::cell::RefCell<Option<Scope>> = const { std::cell::RefCell::new(None) };
}
#[cfg(test)]
thread_local! {
/// How many times [`replies`] was built (tests).
static REPLY_BUILDS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// Puts the scope before it back when dropped (when `f` returns or unwinds).
struct Restore(Option<Scope>);
impl Drop for Restore {
fn drop(&mut self) {
let prev = self.0.take();
SCOPE.with(|s| *s.borrow_mut() = prev);
}
}
/// Run `f`, which writes `doc`, with what the kept markup asks of `doc` worked out at most once.
pub fn while_writing<R>(doc: &crate::Document, f: impl FnOnce() -> R) -> R {
let key = std::ptr::from_ref(doc) as usize;
let prev = SCOPE.with(|s| s.replace(Some(Scope { doc: key, replies: None, pages: None })));
let _restore = Restore(prev);
f()
}
/// The scope for `doc`, if it is being written: `f` of it.
fn in_scope<R>(doc: &crate::Document, f: impl FnOnce(&mut Scope) -> R) -> Option<R> {
let key = std::ptr::from_ref(doc) as usize;
SCOPE.with(|s| s.borrow_mut().as_mut().filter(|sc| sc.doc == key).map(f))
}
/// [`replies`], built once while `doc` is being written.
fn scoped_replies(doc: &crate::Document) -> std::rc::Rc<HashMap<u32, Vec<u32>>> {
in_scope(doc, |sc| sc.replies.get_or_insert_with(|| std::rc::Rc::new(replies(doc))).clone()).unwrap_or_else(|| std::rc::Rc::new(replies(doc)))
family_fingerprint(doc, root, &replies(doc))
}
/// Each comment's replies, by the comment's id.
fn replies(doc: &crate::Document) -> HashMap<u32, Vec<u32>> {
#[cfg(test)]
REPLY_BUILDS.with(|n| n.set(n.get() + 1));
let mut out: HashMap<u32, Vec<u32>> = HashMap::new();
for (id, c) in &doc.comments {
if let Some(p) = c.parent {
@@ -747,34 +694,4 @@ mod tests {
assert_eq!(k.group(p), Some((1, 1)));
assert_eq!(k.story_fps.get(&id), Some(&story_fingerprint(&doc, id)));
}
/// Writing a document checks each kept paragraph's comments against what was read: the
/// comments' replies are looked up once for the whole document, not once per comment.
#[test]
fn writing_many_comments_looks_their_replies_up_once() {
use crate::para::InlineObject;
let n = 50u32;
let text = (0..n).map(|i| format!("Para {i}")).collect::<Vec<_>>().join("\n");
let mut doc = crate::Document::from_text(&text);
for i in 0..n {
let part = doc.add_part(crate::PartKind::Comment, vec![crate::para_block(Paragraph::with_text("Note", Default::default()))]);
doc.comments.insert(i, crate::Comment { parent: None, part, ..Default::default() });
doc.para_mut(crate::StoryRef::Body, &crate::Path::top(i as usize))
.unwrap()
.insert_object(0, InlineObject::CommentStart { id: i }, &Default::default())
.unwrap();
}
let mut k = KeptXml { format: "odt".into(), ..Default::default() };
for i in 0..n {
k.comment_fps.insert(i, comments_fingerprint(&doc, i).0);
}
let xml = "<text:p><office:annotation>x</office:annotation></text:p>";
let before = REPLY_BUILDS.with(std::cell::Cell::get);
let all = while_writing(&doc, || doc.body.iter().filter_map(|b| b.as_para()).all(|p| k.comments_as_read(&doc, p, xml)));
assert!(all, "every paragraph's comment is as read");
assert_eq!(REPLY_BUILDS.with(std::cell::Cell::get) - before, 1);
// Outside a writing, nothing is kept between calls.
assert!(doc.body.first().and_then(|b| b.as_para()).is_some_and(|p| k.comments_as_read(&doc, p, xml)));
assert_eq!(REPLY_BUILDS.with(std::cell::Cell::get) - before, 2);
}
}
+2 -3
View File
@@ -39,8 +39,8 @@ pub mod math_latex;
pub mod math_linear;
pub mod math_symbols;
pub mod numbering;
pub mod odf_profile;
pub mod para;
pub mod picfx;
pub mod props;
pub mod resolve;
pub mod section;
@@ -49,7 +49,6 @@ pub mod styles;
pub mod symbol_fonts;
pub mod table;
pub mod wordart;
pub mod work;
pub mod wrap;
pub mod zip_guard;
@@ -87,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;
+255
View File
@@ -0,0 +1,255 @@
//! How WordCraft fills in what the OpenDocument standard leaves to the application.
//!
//! Where ODF 1.3 defines a value (directly, or by referring to XSL for an `fo:` property), that
//! is the rule and nothing here changes it. Where it doesn't (ODF 1.3 Part 3 §16.2: "in all
//! remaining cases an implementation-dependent value is used"), each application chooses, and
//! documents from one application can look different in another. An [`OdfProfile`] is that
//! choice for every such case: Microsoft Word's style ([`OdfPreset::Word`], the default: as
//! WordCraft lays out Word documents), LibreOffice's style ([`OdfPreset::LibreOffice`]), or the
//! user's own ([`OdfPreset::Custom`]). `docs/odf-interpretation.md` lists every case.
use serde::{Deserialize, Serialize};
use crate::props::Rgb;
/// Whose choices fill in what the standard leaves open.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum OdfPreset {
/// Microsoft Word's style: the behaviour WordCraft has for its own and Word documents.
/// (`wordcraft`, its earlier id, is still taken.)
#[default]
#[serde(alias = "wordcraft")]
Word,
/// LibreOffice's (and Collabora's, which share its engine).
LibreOffice,
/// The user's own choice for each case ([`OdfProfile`]'s values).
Custom,
}
impl OdfPreset {
pub const ALL: [OdfPreset; 3] = [OdfPreset::Word, OdfPreset::LibreOffice, OdfPreset::Custom];
pub fn id(self) -> &'static str {
match self {
OdfPreset::Word => "word",
OdfPreset::LibreOffice => "libreoffice",
OdfPreset::Custom => "custom",
}
}
/// The preset named `id` (any case): its id, or `wordcraft` / `msword` for Word's.
pub fn from_id(id: &str) -> Option<OdfPreset> {
let id = id.trim();
if ["wordcraft", "msword", "ms-word"].iter().any(|a| a.eq_ignore_ascii_case(id)) {
return Some(OdfPreset::Word);
}
OdfPreset::ALL.into_iter().find(|p| p.id().eq_ignore_ascii_case(id))
}
/// The two styles a document can be shown in, as the user chooses between them (Custom
/// aside).
pub const STYLES: [OdfPreset; 2] = [OdfPreset::Word, OdfPreset::LibreOffice];
/// Its name as the user sees it (File › Options, English; the UI translates it).
pub fn label(self) -> &'static str {
match self {
OdfPreset::Word => "Microsoft Word style",
OdfPreset::LibreOffice => "LibreOffice style",
OdfPreset::Custom => "Custom",
}
}
}
/// The most a length in a profile may be, points (22 inches).
pub const MAX_LENGTH: f32 = 1584.0;
/// The choice for each case the standard leaves open (see the module docs). With a preset other
/// than [`OdfPreset::Custom`], the preset's values apply whatever these hold
/// ([`OdfProfile::effective`]).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct OdfProfile {
pub preset: OdfPreset,
/// A page margin a page layout doesn't state, points. §20.206–20.209 refer to XSL's margins
/// (§7.10), whose initial value is 0; whether that initial value comes with the reference
/// the standard doesn't say (LibreOffice uses 2 cm).
pub page_margin: f32,
/// The height of a header or footer whose style states none (§20.212.2 `fo:min-height`
/// has no default), points.
pub header_min_height: f32,
/// A header's or footer's minimum height takes in its spacing from the body (`fo:margin-*`
/// of `style:header-footer-properties`; the standard doesn't say), rather than the spacing
/// coming after it.
pub header_height_includes_spacing: bool,
/// What a header or footer whose style doesn't say does with its spacing when its content
/// is taller than its height (§20.264 `style:dynamic-spacing` has no default): grows into it
/// (`true`), or keeps it (`false`, both presets: with no height stated, growing into it would
/// drop the spacing the file states).
pub dynamic_spacing: bool,
/// Paragraph tab stops are measured from the paragraph's indent rather than the margin when
/// the document doesn't say (§19.512.3 `style:position` doesn't say from where; §19.861 `text:relative-tab-stop-position` says so only for indexes; LibreOffice's own
/// `TabsRelativeToIndent` setting, in `settings.xml`, says so when present).
pub tabs_from_indent: bool,
/// The gap between columns whose `style:columns` states neither one nor each column
/// (§19.233 `fo:column-gap` has no default), points.
pub column_gap: f32,
/// A drawing shape's fill colour when no style gives one (§20.119 `draw:fill-color` has no
/// default).
pub shape_fill: Rgb,
/// A drawing shape's line colour when no style gives one (§20.408 `svg:stroke-color` has no default).
pub shape_line: Rgb,
/// A drawing shape's line width when no style gives one, and what a width of 0 draws at
/// (§20.410 `svg:stroke-width` gives neither), points.
pub hairline: f32,
/// A page-anchored frame placed against a paragraph (a combination §20.398 and §20.299 don't
/// define) is placed against the page (`true`), else against the page's text area.
pub page_frame_paragraph_is_page: bool,
/// An in-line frame placed against something an in-line frame can't be (a paragraph or a
/// page, §20.398 Table 23) is placed against the baseline (`true`), else stands on it.
pub inline_invalid_relation_is_baseline: bool,
/// A line holding only in-line pictures is as tall as they are (`true`), else as tall as
/// its text would be too (the standard defines no line-height algorithm).
pub picture_lines_fit_pictures: bool,
/// A columned section that doesn't say whether its columns are balanced (§20.428
/// `text:dont-balance-text-columns` has no default, and its value descriptions contradict
/// its name) ends with balanced columns at the end of the document too (`true`), else only
/// when text follows on the same page, as in Word.
pub balance_section_columns: bool,
/// The space between two paragraphs is the first's space after plus the second's space
/// before (`true`), else the larger of the two, when the document doesn't say (the
/// standard doesn't; LibreOffice's own `AddParaTableSpacing` setting does when present).
pub sum_paragraph_spacing: bool,
/// Widow and orphan control when no style states `fo:widows` or `fo:orphans` (§20.312,
/// §20.244 refer to XSL's, whose initial value is 2 lines; whether that initial value comes
/// with the reference the standard doesn't say, and LibreOffice reads none stated as none).
pub widow_control: bool,
}
impl Default for OdfProfile {
fn default() -> Self {
OdfProfile::word()
}
}
impl OdfProfile {
/// Microsoft Word's style: as WordCraft lays out its own and Word documents.
pub fn word() -> OdfProfile {
OdfProfile {
preset: OdfPreset::Word,
page_margin: 0.0,
header_min_height: 0.0,
header_height_includes_spacing: false,
dynamic_spacing: false,
tabs_from_indent: false,
column_gap: 36.0,
shape_fill: Rgb(0x15, 0x60, 0x82),
shape_line: Rgb(0x0E, 0x40, 0x5A),
hairline: 0.25,
page_frame_paragraph_is_page: false,
inline_invalid_relation_is_baseline: false,
picture_lines_fit_pictures: false,
balance_section_columns: false,
sum_paragraph_spacing: false,
widow_control: true,
}
}
/// LibreOffice's choices, measured in LibreOffice 24 where it doesn't document them.
pub fn libreoffice() -> OdfProfile {
OdfProfile {
preset: OdfPreset::LibreOffice,
page_margin: 2.0 * 72.0 / 2.54,
header_min_height: 0.5 * 72.0 / 2.54,
header_height_includes_spacing: true,
dynamic_spacing: false,
tabs_from_indent: true,
column_gap: 0.0,
shape_fill: Rgb(0x72, 0x9F, 0xCF),
shape_line: Rgb(0x34, 0x65, 0xA4),
hairline: 0.5,
page_frame_paragraph_is_page: true,
inline_invalid_relation_is_baseline: true,
picture_lines_fit_pictures: true,
balance_section_columns: true,
sum_paragraph_spacing: true,
widow_control: false,
}
}
/// A preset's values (a custom profile starts from Word's style).
pub fn of(preset: OdfPreset) -> OdfProfile {
match preset {
OdfPreset::Word | OdfPreset::Custom => OdfProfile { preset, ..OdfProfile::word() },
OdfPreset::LibreOffice => OdfProfile::libreoffice(),
}
}
/// The values that apply: a preset's own, or, for a custom profile, these (lengths made
/// finite and bounded).
pub fn effective(&self) -> OdfProfile {
let length = |v: f32, least: f32| if v.is_finite() { v.clamp(least, MAX_LENGTH) } else { least };
match self.preset {
OdfPreset::Custom => OdfProfile {
page_margin: length(self.page_margin, 0.0),
header_min_height: length(self.header_min_height, 0.0),
column_gap: length(self.column_gap, 0.0),
hairline: length(self.hairline, 0.05),
..self.clone()
},
p => OdfProfile::of(p),
}
}
/// What the layout needs to know of it for a document opened from ODF.
pub fn layout(&self) -> OdfLayout {
let e = self.effective();
OdfLayout { picture_lines_fit_pictures: e.picture_lines_fit_pictures, tabs_from_indent: e.tabs_from_indent }
}
}
/// A document opened from ODF, for the layout: rules of the standard that differ from Word's
/// (a paragraph's padding takes room on a side without a border, XSL's box model) apply, and
/// the profile's choices that are made as the document is laid out.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct OdfLayout {
/// See [`OdfProfile::picture_lines_fit_pictures`].
pub picture_lines_fit_pictures: bool,
/// Tab stops are measured from the paragraph's left indent, default ones too (LibreOffice's
/// `TabsRelativeToIndent`, else [`OdfProfile::tabs_from_indent`]). The document's explicit
/// stops are held from the margin either way; the default ones are counted from the indent.
pub tabs_from_indent: bool,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn presets_and_custom_values() {
assert_eq!(OdfProfile::default().preset, OdfPreset::Word);
assert_eq!(OdfPreset::from_id(" LibreOffice "), Some(OdfPreset::LibreOffice));
assert_eq!(OdfPreset::from_id("google"), None);
// Word's style by its id, and by the id it had before.
for id in ["word", "Word", "wordcraft", "MSWord", "ms-word"] {
assert_eq!(OdfPreset::from_id(id), Some(OdfPreset::Word), "{id}");
}
assert_eq!(serde_json::from_value::<OdfPreset>(serde_json::json!("wordcraft")).unwrap(), OdfPreset::Word);
assert_eq!(serde_json::from_value::<OdfPreset>(serde_json::json!("word")).unwrap(), OdfPreset::Word);
assert_eq!(OdfPreset::STYLES.map(OdfPreset::label), ["Microsoft Word style", "LibreOffice style"]);
// Saved and taken by `file.options` as its id.
for p in OdfPreset::ALL {
assert_eq!(serde_json::to_value(p).unwrap(), serde_json::json!(p.id()));
}
// A preset's values apply whatever the profile holds.
let lo = OdfProfile { column_gap: 99.0, ..OdfProfile::libreoffice() };
assert_eq!(lo.effective(), OdfProfile::libreoffice());
// A custom profile's own, bounded.
let custom = OdfProfile { preset: OdfPreset::Custom, column_gap: f32::NAN, header_min_height: 1e9, ..OdfProfile::word() };
let e = custom.effective();
assert_eq!((e.column_gap, e.header_min_height), (0.0, MAX_LENGTH));
assert!(OdfProfile::libreoffice().layout().picture_lines_fit_pictures);
assert!(!OdfProfile::word().layout().picture_lines_fit_pictures);
}
}
+3 -3
View File
@@ -231,7 +231,7 @@ impl Paragraph {
/// Number of objects in `text[..off]`.
fn objects_before(&self, off: usize) -> usize {
self.text.get(..off).map(|t| t.chars().inspect(|_| crate::work::steps(1)).filter(|c| *c == OBJ).count()).unwrap_or(0)
self.text.get(..off).map(|t| t.chars().filter(|c| *c == OBJ).count()).unwrap_or(0)
}
/// The object at byte `off` (which must hold U+FFFC).
@@ -250,13 +250,13 @@ impl Paragraph {
}
/// Byte offsets of all objects, in order.
pub fn object_offsets(&self) -> Vec<usize> {
self.text.char_indices().inspect(|_| crate::work::steps(1)).filter(|(_, c)| *c == OBJ).map(|(i, _)| i).collect()
self.text.char_indices().filter(|(_, c)| *c == OBJ).map(|(i, _)| i).collect()
}
/// (byte offset, object) for every object, in text order, in one pass. Prefer it to
/// [`Paragraph::object_offsets`] + [`Paragraph::object_at`], which counts the objects before
/// each offset again, so a loop over a paragraph's objects is quadratic (#3614).
pub fn objects_with_offsets(&self) -> impl Iterator<Item = (usize, &InlineObject)> + '_ {
self.text.char_indices().inspect(|_| crate::work::steps(1)).filter(|(_, c)| *c == OBJ).map(|(i, _)| i).zip(self.objects.iter())
self.text.char_indices().filter(|(_, c)| *c == OBJ).map(|(i, _)| i).zip(self.objects.iter())
}
/// Insert plain text (U+FFFC is dropped) at `off` with `props`. Returns the inserted length.
-3
View File
@@ -19,9 +19,6 @@ pub enum InlineObject {
/// Crop (left, top, right, bottom) fractions 0..1.
#[serde(default)]
crop: [f32; 4],
/// Brightness, contrast and transparency read from the file (see [`crate::picfx`]).
#[serde(default, skip_serializing_if = "crate::picfx::PicFx::is_identity")]
fx: crate::picfx::PicFx,
/// The OLE object (embedded or linked file) or Word signature line this picture shows, as
/// read from a file: saving writes the object back, not only its picture. See
/// [`crate::graphic::Embedded`].
-190
View File
@@ -1,190 +0,0 @@
//! Picture colour corrections read from a file: brightness, contrast and transparency
//! (DrawingML `a:lum` and `a:alphaModFix` inside `a:blip`, ECMA-376 §20.1.8.42 and §20.1.8.4;
//! VML `v:imagedata` `gain` and `blacklevel`, which Word's picture watermark "Washout" sets).
//!
//! Values are kept in OOXML units: thousandths of a percent, so 100 000 is 100 %. All zero is
//! the picture as it is.
//!
//! The pixel formula was measured on Word (#1711, #1766): Word bakes the corrected pixels into
//! the picture it embeds in a PDF export, so a synthetic grey and colour ramp exported with
//! 20 `a:lum` combinations and 6 VML `gain`/`blacklevel` pairs gives the curve. Every channel is
//! mapped on its own (no hue or luminance coupling) by one straight line, clamped to 0..=255
//! only at the end:
//!
//! `y = s·(x − 127.5) + 127.5 + 127.5·b·(1 + s)`, with `b = bright / 100 000`, `c = contrast /
//! 100 000`, `s = 1 + c` when `c ≤ 0` and `s = 1 / (1 − c)` when `c > 0`
//!
//! (as if half the brightness shift were applied before the contrast and half after). Measured
//! slope and intercept of Word's output for grey input `x` (PNG pictures, exact to ±1 level):
//! bright 20 000 → 1.00, +51; bright −40 000 → 1.00, −103; contrast 80 000 → 5.00, −510;
//! contrast −80 000 → 0.20, +101.4; (40 000, −40 000) → 0.60, +132.2; (80 000, −80 000) →
//! 0.20, +223.8 (also the #1766 header picture: fitted 0.197..0.201, +223.6..224.0);
//! (−40 000, 40 000) → 1.667, −221.7; (30 000, 60 000) → 2.50, −57.8.
//!
//! VML: `gain` is the slope itself and `blacklevel` half the brightness — Word draws `gain` 0.3,
//! `blacklevel` 0.35 (Washout) as 0.30, +205.3, the same as `a:lum` (70 000, −70 000), and
//! `gain` 1, `blacklevel` 0.2 as the very same picture as `bright="40000"`.
use serde::{Deserialize, Serialize};
/// The largest magnitude of a correction (100 %).
pub const FX_MAX: i32 = 100_000;
/// Brightness, contrast and transparency of a picture (see the module docs).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(default)]
pub struct PicFx {
/// −100 000 (black) ..= 100 000 (white).
pub bright: i32,
/// −100 000 (flat grey) ..= 100 000 (hard threshold).
pub contrast: i32,
/// 0 (opaque) ..= 100 000 (invisible).
pub transparency: i32,
}
impl PicFx {
/// Corrections clamped to their ranges.
pub fn new(bright: i32, contrast: i32, transparency: i32) -> PicFx {
PicFx { bright: bright.clamp(-FX_MAX, FX_MAX), contrast: contrast.clamp(-FX_MAX, FX_MAX), transparency: transparency.clamp(0, FX_MAX) }
}
/// The same corrections, each clamped to its range (for values that came from anywhere).
pub fn clamped(self) -> PicFx {
PicFx::new(self.bright, self.contrast, self.transparency)
}
/// No correction at all.
pub fn is_identity(&self) -> bool {
*self == PicFx::default()
}
/// Brightness or contrast changes the pixels (transparency alone doesn't).
pub fn changes_pixels(&self) -> bool {
let f = self.clamped();
f.bright != 0 || f.contrast != 0
}
/// The picture's opacity, 0..=1 (1 − transparency).
pub fn opacity(&self) -> f32 {
1.0 - self.transparency.clamp(0, FX_MAX) as f32 / FX_MAX as f32
}
/// The corrections a VML `v:imagedata` gives with `gain` and `blacklevel` (already parsed to
/// plain numbers, 1 and 0 when missing): contrast from the slope `gain`, brightness twice the
/// black level (see the module docs). Non-finite values count as missing.
pub fn from_vml(gain: f64, blacklevel: f64) -> PicFx {
let gain = if gain.is_finite() { gain.clamp(0.0, 1.0e6) } else { 1.0 };
let black = if blacklevel.is_finite() { blacklevel.clamp(-0.5, 0.5) } else { 0.0 };
// s = 1 + c (c ≤ 0) or 1 / (1 − c) (c > 0).
let c = if gain <= 1.0 { gain - 1.0 } else { 1.0 - 1.0 / gain };
let fx = |v: f64| (v * FX_MAX as f64).round().clamp(-(FX_MAX as f64), FX_MAX as f64) as i32;
PicFx::new(fx(2.0 * black), fx(c), 0)
}
/// `gain` and `blacklevel` for VML (the inverse of [`PicFx::from_vml`]).
pub fn to_vml(&self) -> (f64, f64) {
(self.slope(), self.clamped().bright as f64 / FX_MAX as f64 / 2.0)
}
/// The contrast slope `s` (capped at 255: contrast 100 % is a threshold).
fn slope(&self) -> f64 {
let c = self.contrast.clamp(-FX_MAX, FX_MAX) as f64 / FX_MAX as f64;
if c <= 0.0 { 1.0 + c } else { (1.0 / (1.0 - c).max(1.0 / 255.0)).min(255.0) }
}
/// The lookup table every colour channel goes through.
pub fn table(&self) -> [u8; 256] {
let s = self.slope();
let b = self.bright.clamp(-FX_MAX, FX_MAX) as f64 / FX_MAX as f64;
let mut t = [0u8; 256];
for (x, slot) in t.iter_mut().enumerate() {
let y = s * (x as f64 - 127.5) + 127.5 + 127.5 * b * (1.0 + s);
*slot = if y.is_finite() { y.round().clamp(0.0, 255.0) as u8 } else { 0 };
}
t
}
/// Apply brightness and contrast to straight (not premultiplied) RGBA8 pixels in place. The
/// alpha byte is left alone (transparency is drawn as an opacity, see [`PicFx::opacity`]); a
/// trailing partial pixel is ignored.
pub fn apply_rgba(&self, rgba: &mut [u8]) {
if !self.changes_pixels() {
return;
}
let t = self.table();
for px in rgba.as_chunks_mut::<4>().0 {
for c in px.iter_mut().take(3) {
*c = t.get(usize::from(*c)).copied().unwrap_or(*c);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Word's own output for grey input 0, 128 and 255 (PNG pictures in a PDF export).
#[test]
fn matches_word() {
let cases: [(PicFx, [u8; 3]); 6] = [
(PicFx::new(40_000, -40_000, 0), [132, 209, 255]),
(PicFx::new(80_000, -80_000, 0), [224, 249, 255]),
(PicFx::new(-40_000, 40_000, 0), [0, 0, 203]),
(PicFx::new(0, 80_000, 0), [0, 130, 255]),
(PicFx::new(20_000, 0, 0), [51, 179, 255]),
(PicFx::from_vml(19661.0 / 65536.0, 22938.0 / 65536.0), [205, 244, 255]),
];
for (fx, want) in cases {
let t = fx.table();
for (x, w) in [0usize, 128, 255].into_iter().zip(want) {
let got = t[x];
assert!(got.abs_diff(w) <= 2, "{fx:?}: {x} → {got}, Word {w}");
}
}
}
#[test]
fn channels_apart_alpha_kept() {
let fx = PicFx::new(80_000, -80_000, 0);
let mut px = [255, 0, 0, 77, 0, 128, 255, 255];
fx.apply_rgba(&mut px);
// Word: red (255, 0, 0) → (255, 224, 224); 128 → 249 (the formula gives 250.0).
assert_eq!(px, [255, 224, 224, 77, 224, 250, 255, 255]);
let mut odd = [0u8; 6];
fx.apply_rgba(&mut odd);
assert_eq!(odd[4..], [0, 0]);
}
#[test]
fn vml_mapping() {
// 16.16 fixed point: 19661f and 22938f are 0.3 and 0.35 to within 1e-5.
let washout = PicFx::from_vml(19661.0 / 65536.0, 22938.0 / 65536.0);
assert!((washout.bright - 70_000).abs() <= 2 && (washout.contrast + 70_000).abs() <= 2, "{washout:?}");
assert_eq!(PicFx::from_vml(1.0, 0.2), PicFx::new(40_000, 0, 0));
assert_eq!(PicFx::from_vml(2.0, 0.0), PicFx::new(0, 50_000, 0));
assert!(PicFx::from_vml(1.0, 0.0).is_identity());
for (g, b) in [(f64::NAN, f64::INFINITY), (-1.0e30, 1.0e30), (1.0e300, -7.0), (0.0, 0.0)] {
let fx = PicFx::from_vml(g, b);
assert_eq!(fx, fx.clamped(), "{g} {b}");
let _ = fx.table();
}
}
#[test]
fn hostile_values_clamp() {
for v in [i32::MIN, -100_001, 0, 100_001, i32::MAX] {
let fx = PicFx { bright: v, contrast: v, transparency: v };
let _ = fx.table();
assert!((0.0..=1.0).contains(&fx.opacity()));
let mut px = [1u8, 2, 3, 4];
fx.apply_rgba(&mut px);
assert_eq!(px[3], 4);
}
assert_eq!(PicFx::new(0, 0, 25_000).opacity(), 0.75);
assert!(!PicFx::new(0, 0, 25_000).changes_pixels());
// Contrast 100 %: a threshold at mid-grey, no NaN.
let t = PicFx::new(0, FX_MAX, 0).table();
assert_eq!((t[0], t[127], t[128], t[255]), (0, 0, 255, 255));
}
}
+1 -3
View File
@@ -26,9 +26,7 @@ pub use char_props::{
CharProps, CombineBrackets, DEFAULT_FONT_SIZE, EastAsianLayout, EmphasisMark, Highlight, Ligatures, NumChange, PropChange, Underline, VertAlign,
};
pub use color::{Rgb, TextColor};
pub use para_props::{
Align, Border, BorderStyle, Borders, LineSpacing, NumRef, ParaProps, TAB_POS_EPS, TabAlign, TabLeader, TabStop, TextAlignment, TextFrame,
};
pub use para_props::{Align, Border, BorderStyle, Borders, LineSpacing, NumRef, ParaProps, TAB_POS_EPS, TabAlign, TabLeader, TabStop, TextFrame};
pub use table_props::{
CellPicture, CellProps, HeightRule, PictureRepeat, RowProps, TableFloat, TableLook, TableProps, TextDirection, TextVert, VAlign, VMerge,
anchor_ooxml, valign_from_anchor,
+1 -69
View File
@@ -26,40 +26,6 @@ impl Align {
}
}
/// Vertical alignment of characters of different sizes on a line (`w:textAlignment`,
/// ECMA-376 §17.3.1.39).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum TextAlignment {
Auto,
Top,
Center,
Baseline,
Bottom,
}
impl TextAlignment {
pub fn ooxml(self) -> &'static str {
match self {
TextAlignment::Auto => "auto",
TextAlignment::Top => "top",
TextAlignment::Center => "center",
TextAlignment::Baseline => "baseline",
TextAlignment::Bottom => "bottom",
}
}
pub fn from_ooxml(s: &str) -> Option<TextAlignment> {
match s {
"auto" => Some(TextAlignment::Auto),
"top" => Some(TextAlignment::Top),
"center" => Some(TextAlignment::Center),
"baseline" => Some(TextAlignment::Baseline),
"bottom" => Some(TextAlignment::Bottom),
_ => None,
}
}
}
/// Line spacing rule.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "rule", content = "value")]
@@ -141,18 +107,6 @@ pub enum BorderStyle {
Triple,
DotDash,
Wave,
/// The compound lines of thin and thick strokes (`thinThickSmallGap` to
/// `thinThickThinLargeGap`), kept so a file writes back the line it read; drawn as a single
/// line of the border's width.
ThinThickSmallGap,
ThickThinSmallGap,
ThinThickThinSmallGap,
ThinThickMediumGap,
ThickThinMediumGap,
ThinThickThinMediumGap,
ThinThickLargeGap,
ThickThinLargeGap,
ThinThickThinLargeGap,
}
impl BorderStyle {
@@ -167,15 +121,6 @@ impl BorderStyle {
BorderStyle::Triple => "triple",
BorderStyle::DotDash => "dotDash",
BorderStyle::Wave => "wave",
BorderStyle::ThinThickSmallGap => "thinThickSmallGap",
BorderStyle::ThickThinSmallGap => "thickThinSmallGap",
BorderStyle::ThinThickThinSmallGap => "thinThickThinSmallGap",
BorderStyle::ThinThickMediumGap => "thinThickMediumGap",
BorderStyle::ThickThinMediumGap => "thickThinMediumGap",
BorderStyle::ThinThickThinMediumGap => "thinThickThinMediumGap",
BorderStyle::ThinThickLargeGap => "thinThickLargeGap",
BorderStyle::ThickThinLargeGap => "thickThinLargeGap",
BorderStyle::ThinThickThinLargeGap => "thinThickThinLargeGap",
}
}
pub fn from_ooxml(s: &str) -> BorderStyle {
@@ -188,15 +133,6 @@ impl BorderStyle {
"triple" => BorderStyle::Triple,
"dotDash" | "dotDotDash" => BorderStyle::DotDash,
"wave" | "doubleWave" => BorderStyle::Wave,
"thinThickSmallGap" => BorderStyle::ThinThickSmallGap,
"thickThinSmallGap" => BorderStyle::ThickThinSmallGap,
"thinThickThinSmallGap" => BorderStyle::ThinThickThinSmallGap,
"thinThickMediumGap" => BorderStyle::ThinThickMediumGap,
"thickThinMediumGap" => BorderStyle::ThickThinMediumGap,
"thinThickThinMediumGap" => BorderStyle::ThinThickThinMediumGap,
"thinThickLargeGap" => BorderStyle::ThinThickLargeGap,
"thickThinLargeGap" => BorderStyle::ThickThinLargeGap,
"thinThickThinLargeGap" => BorderStyle::ThinThickThinLargeGap,
_ => BorderStyle::Single,
}
}
@@ -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);
+3 -15
View File
@@ -167,22 +167,10 @@ pub struct Settings {
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub remove_date_time: bool,
/// The document was opened from ODF: the standard's rules that differ from Word's apply as
/// it is laid out.
/// it is laid out, with the choices it leaves open made as the user's profile made them
/// (see [`crate::odf_profile`]).
#[serde(skip_serializing_if = "Option::is_none")]
pub odf: Option<OdfLayout>,
}
/// A document opened from ODF, for the layout: rules of the standard that differ from Word's (a
/// paragraph's padding takes room on a side without a border, XSL's box model) apply, and what
/// the file says about laying it out.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct OdfLayout {
/// Tab stops are measured from the paragraph's left indent, default ones too (LibreOffice's
/// `TabsRelativeToIndent` in the file; from the margin when it doesn't say). The document's
/// explicit stops are held from the margin either way; the default ones are counted from the
/// indent.
pub tabs_from_indent: bool,
pub odf: Option<crate::odf_profile::OdfLayout>,
}
/// A saved zoom: a percentage, or a fit mode (the percentage it last came to).
+1 -35
View File
@@ -12,8 +12,6 @@
use serde::{Deserialize, Serialize};
use crate::props::Rgb;
/// Most items in a SmartArt graphic.
pub const MAX_ITEMS: usize = 100;
/// Longest item text, characters.
@@ -200,14 +198,11 @@ pub struct SmartArtItem {
pub text: String,
#[serde(default)]
pub level: u8,
/// Fill of the item's shape in place of the style's colour.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub color: Option<Rgb>,
}
impl SmartArtItem {
pub fn new(text: impl Into<String>, level: u8) -> SmartArtItem {
SmartArtItem { text: text.into(), level, color: None }
SmartArtItem { text: text.into(), level }
}
}
@@ -262,35 +257,6 @@ impl SmartArtSpec {
}
}
/// Whether any item has its own colour.
pub fn has_custom_colors(&self) -> bool {
self.items.iter().any(|it| it.color.is_some())
}
/// Give every item the style's colour again.
pub fn clear_custom_colors(&mut self) {
for it in &mut self.items {
it.color = None;
}
}
/// The theme accent (1–6) the style gives item `i`; `None` when the item has no shape of
/// its own (it is text inside another item's shape).
pub fn style_accent(&self, i: usize) -> Option<usize> {
let it = self.items.get(i)?;
let tops = || self.items.iter().take(i).filter(|o| o.level == 0).count();
match self.layout {
SmartArtLayout::Hierarchy => Some(self.colors.accent(i)),
SmartArtLayout::Radial if i == 0 => Some(self.colors.accent(0)),
SmartArtLayout::Radial => {
let parents = self.parents();
let spoke = |j: usize| j > 0 && matches!(parents.get(j), Some(Some(0)) | Some(None));
spoke(i).then(|| self.colors.accent((1..i).filter(|j| spoke(*j)).count() + 1))
}
_ => (it.level == 0).then(|| self.colors.accent(tops())),
}
}
/// The graphic after `sanitize`.
pub fn sanitized(mut self) -> SmartArtSpec {
self.sanitize();
-28
View File
@@ -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)
}
+1 -1
View File
@@ -185,7 +185,7 @@ fn leaf(sp: &Sp, w: f32, h: f32, float: Float, b: &Build, pics: &mut Pictures) -
let p = &sp.props;
if let Some(pib) = p.pib {
let media = pics.bse(pib, b.bstore, b.delay)?;
return Some(InlineObject::Image { media, w, h, alt: p.alt.clone(), float, crop: p.crop, fx: Default::default(), ole: None });
return Some(InlineObject::Image { media, w, h, alt: p.alt.clone(), float, crop: p.crop, ole: None });
}
let story = b.stories.get(&sp.spid).copied().filter(|_| sp.textbox || sp.spt == 202);
let mut kind = match sp.spt {
-1
View File
@@ -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,
});
}
-1
View File
@@ -391,7 +391,6 @@ fn a_floating_copy_is_charged_its_alt_text() {
alt,
float: Default::default(),
crop: Default::default(),
fx: Default::default(),
ole: None,
};
let bare = crate::object_cost(&image(String::new()));
+2 -3
View File
@@ -5,8 +5,8 @@ use std::collections::HashMap;
use wordcraft_doc::para::cap_alt;
use wordcraft_doc::props::{
Align, Border, BorderStyle, Borders, CellProps, CharProps, CombineBrackets, EastAsianLayout, HeightRule, Highlight, LineSpacing, NumRef,
ParaProps, Rgb, RowProps, TabAlign, TabLeader, TabStop, TableFloat, TableLook, TableProps, TextAlignment, TextColor, TextDirection, Underline,
VAlign, VMerge, VertAlign,
ParaProps, Rgb, RowProps, TabAlign, TabLeader, TabStop, TableFloat, TableLook, TableProps, TextColor, TextDirection, Underline, VAlign, VMerge,
VertAlign,
};
use wordcraft_doc::section::{
Columns, DocGrid, DocGridType, LineNumberRestart, LineNumbering, MAX_CHAR_SPACE, MAX_GRID_PITCH, NotePos, NoteProps, NoteRestart, NumFormat,
@@ -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);
+1 -1
View File
@@ -31,7 +31,7 @@ impl Reader<'_> {
let cap_font = first.runs.first().and_then(|r| r.props.font.clone());
let text_font = p.runs.first().and_then(|r| r.props.font.clone());
if cap_font.is_some() && cap_font != text_font {
for r in first.runs.iter_mut().filter(|r| r.props.font == cap_font) {
for r in &mut first.runs {
r.props.font = text_font.clone();
}
props.drop_cap_font = cap_font;
+1 -1
View File
@@ -263,7 +263,7 @@ impl Reader<'_> {
}
}
}
Some(InlineObject::Image { media, w, h, alt, float, crop, fx: crate::units::blip_fx(blip), ole: None })
Some(InlineObject::Image { media, w, h, alt, float, crop, ole: None })
}
/// A `wpg:wgp` group (`w` × `h`): its pictures, shapes and text boxes, nested groups
+1 -4
View File
@@ -134,10 +134,7 @@ impl Reader<'_> {
let id = img.attr("r:id").or_else(|| img.attr("r:pict"))?;
let media = self.media_for(rels, id)?;
let alt = shape.attr("alt").or_else(|| img.attr("o:title")).unwrap_or("").to_string();
// Brightness and contrast (Word's picture watermark "Washout" sets them, #1711).
let frac = |k: &str| img.attr(k).and_then(crate::units::vml_number);
let fx = wordcraft_doc::picfx::PicFx::from_vml(frac("gain").unwrap_or(1.0), frac("blacklevel").unwrap_or(0.0));
return Some(InlineObject::Image { media, w, h, alt, float, crop: [0.0; 4], fx, ole: None });
return Some(InlineObject::Image { media, w, h, alt, float, crop: [0.0; 4], ole: None });
}
// The shapetype the shape is drawn from (its `type`, ECMA-376 Part 4 VML), when written in
// the same picture: what the shape leaves out, it takes from there.
+19 -83
View File
@@ -86,8 +86,6 @@ enum Paint {
None,
/// Theme accent `n` (1–6), lightened to `tint` (thousandths of a percent kept) when set.
Accent(usize, Option<u32>),
/// A colour of its own, lightened to `tint` when set.
Rgb(Rgb, Option<u32>),
/// The theme's light background colour.
Light,
}
@@ -152,22 +150,6 @@ impl Sh {
}
}
/// Accent fills and outlines in item `i`'s own colour, when it has one. Light outlines
/// keep the theme's background colour so adjacent nodes remain separated.
fn recolor(&mut self, spec: &SmartArtSpec, i: usize) {
let Some(c) = spec.items.get(i).and_then(|it| it.color) else { return };
for p in [&mut self.fill, &mut self.line] {
if let Paint::Accent(_, tint) = *p {
*p = Paint::Rgb(c, tint);
}
}
}
fn item(mut self, spec: &SmartArtSpec, i: usize) -> Sh {
self.recolor(spec, i);
self
}
fn with_text(mut self, text: Vec<(String, bool)>) -> Sh {
self.text = text;
self
@@ -244,7 +226,7 @@ fn basic_list(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
let in_row = if r + 1 == rows { n - r * cols } else { cols };
let x0 = (w - (in_row as f32 * bw + (in_row - 1) as f32 * gx)) / 2.0;
let b = [x0 + c as f32 * (bw + gx), y0 + r as f32 * (bh + gy), bw, bh];
out.push(Sh::node("rect", b, spec.colors.accent(k)).item(spec, i).with_text(paras(spec, i, true)));
out.push(Sh::node("rect", b, spec.colors.accent(k)).with_text(paras(spec, i, true)));
}
out
}
@@ -265,14 +247,11 @@ fn process(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
let mut a = Sh::node("rightArrow", [gx + (gap - aw) / 2.0, h / 2.0 - ah / 2.0, aw, ah], spec.colors.accent(k - 1));
a.fill = Paint::Accent(spec.colors.accent(k - 1), Some(60_000));
a.line = Paint::None;
if let Some(&i) = tops.get(k - 1) {
a.recolor(spec, i);
}
out.push(a);
}
for (k, &i) in tops.iter().enumerate() {
let b = [k as f32 * (nw + gap), y, nw, nh];
out.push(Sh::node("roundRect", b, spec.colors.accent(k)).item(spec, i).with_text(paras(spec, i, true)));
out.push(Sh::node("roundRect", b, spec.colors.accent(k)).with_text(paras(spec, i, true)));
}
out
}
@@ -315,9 +294,6 @@ fn cycle(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
a.rot = (mid + 90.0).rem_euclid(360.0);
a.fill = Paint::Accent(spec.colors.accent(k), Some(60_000));
a.line = Paint::None;
if let Some(&i) = tops.get(k) {
a.recolor(spec, i);
}
out.push(a);
}
}
@@ -325,7 +301,7 @@ fn cycle(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
for (k, &i) in tops.iter().enumerate() {
let (x, y) = if n == 1 { (cx, cy) } else { polar(cx, cy, r, angle(k, n)) };
let b = [x - d / 2.0, y - d / 2.0, d, d];
out.push(Sh::node("ellipse", b, spec.colors.accent(k)).item(spec, i).with_text(paras(spec, i, true)).text_box(inset(b, ROUND_INSET)));
out.push(Sh::node("ellipse", b, spec.colors.accent(k)).with_text(paras(spec, i, true)).text_box(inset(b, ROUND_INSET)));
}
out
}
@@ -384,19 +360,19 @@ fn hierarchy(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
let (px, pb) = (cx(i), top(i) + nh);
let ct = ks.first().map_or(pb, |&k| top(k));
let mid = (pb + ct) / 2.0;
out.push(Sh::line(px, pb, px, mid, accent).item(spec, i));
out.push(Sh::line(px, pb, px, mid, accent));
let xs: Vec<f32> = ks.iter().map(|&k| cx(k)).collect();
let (lo, hi) = xs.iter().fold((px, px), |(lo, hi), &x| (lo.min(x), hi.max(x)));
if hi - lo > 0.5 {
out.push(Sh::line(lo, mid, hi, mid, accent).item(spec, i));
out.push(Sh::line(lo, mid, hi, mid, accent));
}
for x in xs {
out.push(Sh::line(x, mid, x, ct, accent).item(spec, i));
out.push(Sh::line(x, mid, x, ct, accent));
}
}
for i in 0..n {
let b = [cx(i) - nw / 2.0, top(i), nw, nh];
out.push(Sh::node("roundRect", b, spec.colors.accent(i)).item(spec, i).with_text(paras(spec, i, false)));
out.push(Sh::node("roundRect", b, spec.colors.accent(i)).with_text(paras(spec, i, false)));
}
out
}
@@ -424,7 +400,7 @@ fn pyramid(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
let mid_w = (top_w + bot_w) / 2.0 * 0.8;
s.text_box([w / 2.0 - mid_w / 2.0, b[1] + bh * 0.08, mid_w, bh * 0.84])
};
out.push(sh.item(spec, i).with_text(paras(spec, i, true)));
out.push(sh.with_text(paras(spec, i, true)));
}
out
}
@@ -442,7 +418,7 @@ fn radial(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
if n == 0 {
let d = m * 0.5;
let b = [cx - d / 2.0, cy - d / 2.0, d, d];
out.push(Sh::node("ellipse", b, spec.colors.accent(0)).item(spec, 0).with_text(paras(spec, 0, false)).text_box(inset(b, ROUND_INSET)));
out.push(Sh::node("ellipse", b, spec.colors.accent(0)).with_text(paras(spec, 0, false)).text_box(inset(b, ROUND_INSET)));
return out;
}
let step = if n == 1 { 1.0 } else { (std::f32::consts::PI / n as f32).sin() };
@@ -456,14 +432,14 @@ fn radial(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
let at = |k: usize| polar(cx, cy, r, angle(k, n));
for k in 0..n {
let (x, y) = at(k);
out.push(Sh::line(cx, cy, x, y, spec.colors.accent(0)).item(spec, 0));
out.push(Sh::line(cx, cy, x, y, spec.colors.accent(0)));
}
let bc = [cx - dc / 2.0, cy - dc / 2.0, dc, dc];
out.push(Sh::node("ellipse", bc, spec.colors.accent(0)).item(spec, 0).with_text(paras(spec, 0, false)).text_box(inset(bc, ROUND_INSET)));
out.push(Sh::node("ellipse", bc, spec.colors.accent(0)).with_text(paras(spec, 0, false)).text_box(inset(bc, ROUND_INSET)));
for (k, &i) in spokes.iter().enumerate() {
let (x, y) = at(k);
let b = [x - ds / 2.0, y - ds / 2.0, ds, ds];
out.push(Sh::node("ellipse", b, spec.colors.accent(k + 1)).item(spec, i).with_text(paras(spec, i, true)).text_box(inset(b, ROUND_INSET)));
out.push(Sh::node("ellipse", b, spec.colors.accent(k + 1)).with_text(paras(spec, i, true)).text_box(inset(b, ROUND_INSET)));
}
out
}
@@ -481,7 +457,7 @@ fn matrix(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
for (k, &i) in tops.iter().enumerate() {
let (r, c) = (k / cols, k % cols);
let b = [x0 + c as f32 * cell + gap / 2.0, y0 + r as f32 * cell + gap / 2.0, cell - gap, cell - gap];
out.push(Sh::node("roundRect", b, spec.colors.accent(k)).item(spec, i).with_text(paras(spec, i, true)).text_box(inset(b, 0.1)));
out.push(Sh::node("roundRect", b, spec.colors.accent(k)).with_text(paras(spec, i, true)).text_box(inset(b, 0.1)));
}
out
}
@@ -502,22 +478,22 @@ fn venn(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
(x, y, deg)
};
let mut out = Vec::new();
for (j, &i) in tops.iter().enumerate() {
for (j, _) in tops.iter().enumerate() {
let (x, y, _) = place(j);
let mut s = Sh::node("ellipse", [x - r, y - r, 2.0 * r, 2.0 * r], spec.colors.accent(j));
s.fill = Paint::Accent(spec.colors.accent(j), Some(40_000));
s.alpha = Some(60_000);
s.line = Paint::None;
out.push(s.item(spec, i));
out.push(s);
}
// Every outline over every fill, so each circle shows whole.
for (j, &i) in tops.iter().enumerate() {
for (j, _) in tops.iter().enumerate() {
let (x, y, _) = place(j);
let mut s = Sh::node("ellipse", [x - r, y - r, 2.0 * r, 2.0 * r], spec.colors.accent(j));
s.fill = Paint::None;
s.line = Paint::Accent(spec.colors.accent(j), None);
s.line_w = 1.5;
out.push(s.item(spec, i));
out.push(s);
}
for (j, &i) in tops.iter().enumerate() {
let (x, y, deg) = place(j);
@@ -578,23 +554,6 @@ fn paint(w: &mut W, p: Paint, alpha: Option<u32>) {
w.empty("a:schemeClr", &[("val", "lt1")]);
w.close("a:solidFill");
}
Paint::Rgb(c, tint) => {
w.open("a:solidFill", &[]);
let val = c.hex();
if tint.is_none() && alpha.is_none() {
w.empty("a:srgbClr", &[("val", &val)]);
} else {
w.open("a:srgbClr", &[("val", &val)]);
if let Some(t) = tint {
w.empty("a:tint", &[("val", &t.to_string())]);
}
if let Some(a) = alpha {
w.empty("a:alpha", &[("val", &a.to_string())]);
}
w.close("a:srgbClr");
}
w.close("a:solidFill");
}
Paint::Accent(n, tint) => {
w.open("a:solidFill", &[]);
let val = format!("accent{}", n.clamp(1, 6));
@@ -784,16 +743,7 @@ pub fn data_xml(spec: &SmartArtSpec, drawing_rel: &str) -> String {
let (node, cxn, par, sib) = item_ids(i);
w.open("dgm:pt", &[("modelId", &node)]);
w.empty("dgm:prSet", &[]);
match it.color {
Some(c) => {
w.open("dgm:spPr", &[]);
w.open("a:solidFill", &[]);
w.empty("a:srgbClr", &[("val", &c.hex())]);
w.close("a:solidFill");
w.close("dgm:spPr");
}
None => w.empty("dgm:spPr", &[]),
}
w.empty("dgm:spPr", &[]);
w.open("dgm:t", &[]);
w.empty("a:bodyPr", &[]);
w.empty("a:lstStyle", &[]);
@@ -1112,9 +1062,7 @@ pub(crate) fn spec_of(data: &El) -> Option<SmartArtSpec> {
.zip(&levels)
.map(|(p, l)| {
let text: String = p.child("dgm:t")?.child("a:p")?.children("a:r").filter_map(|r| r.child("a:t")).map(|t| t.text()).collect();
let color =
p.child("dgm:spPr").and_then(|s| s.child("a:solidFill")).and_then(|f| f.child("a:srgbClr")).and_then(|c| Rgb::parse(c.attr("val")?));
Some(SmartArtItem { text, level: (*l)?, color })
Some(SmartArtItem { text, level: (*l)? })
})
.collect::<Option<Vec<_>>>()?;
let spec = SmartArtSpec { layout, items, colors }.sanitized();
@@ -1190,18 +1138,6 @@ mod tests {
}
}
#[test]
fn an_items_own_colour_is_saved_drawn_and_read_back() {
let mut spec = SmartArtSpec::sample(SmartArtLayout::Process);
spec.items[1].color = Some(Rgb(0x12, 0x34, 0x56));
let spec = spec.sanitized();
assert_eq!(spec_of(&parse(&data_xml(&spec, "rId9"))).as_ref(), Some(&spec));
let drawing = drawing_xml(&spec, 400.0, 250.0);
assert_eq!(drawing.matches("<a:srgbClr val=\"123456\"").count(), 2, "the step and the arrow after it");
let plain = drawing_xml(&SmartArtSpec::sample(SmartArtLayout::Process), 400.0, 250.0);
assert!(!plain.contains("srgbClr"));
}
#[test]
fn a_diagram_with_more_than_the_model_stays_read_only() {
let spec = SmartArtSpec::sample(SmartArtLayout::Process);
-23
View File
@@ -84,29 +84,6 @@ pub fn int(s: &str) -> Option<i64> {
v.is_finite().then(|| v.round().clamp(i64::MIN as f64, i64::MAX as f64) as i64)
}
/// A VML number (`gain`, `blacklevel`), unclamped: 16.16 fixed point with an `f` suffix
/// (`19661f` is 0.3) or a plain decimal (`0.3`). None for anything else or non-finite.
pub fn vml_number(s: &str) -> Option<f64> {
let t = s.trim();
if t.is_empty() || t.len() > 40 {
return None;
}
let v = match t.strip_suffix('f') {
Some(fixed) => fixed.trim().parse::<f64>().ok()? / 65_536.0,
None => t.parse::<f64>().ok()?,
};
v.is_finite().then_some(v)
}
/// A picture's brightness, contrast and transparency: `a:lum` (ECMA-376 §20.1.8.42) and
/// `a:alphaModFix` (§20.1.8.4) in its `a:blip` (#1766).
pub fn blip_fx(blip: &El) -> wordcraft_doc::picfx::PicFx {
let val = |el: Option<&El>, k: &str| el.and_then(|e| e.attr(k)).and_then(int).map(|v| v.clamp(-1_000_000, 1_000_000) as i32);
let lum = blip.child("a:lum");
let amt = val(blip.child("a:alphaModFix"), "amt").unwrap_or(100_000).clamp(0, 100_000);
wordcraft_doc::picfx::PicFx::new(val(lum, "bright").unwrap_or(0), val(lum, "contrast").unwrap_or(0), 100_000 - amt)
}
pub fn u32_of(s: &str) -> Option<u32> {
int(s).map(|v| v.clamp(0, u32::MAX as i64) as u32)
}
+2 -15
View File
@@ -317,8 +317,7 @@ impl<'a> Conv<'a> {
/// A DrawingML picture as a `w:pict` (inline, at its extent); any other drawing is dropped.
fn picture(&mut self, drawing: &El, rels: &Rels) -> Vec<Node> {
let Some(blip) = drawing.find("a:blip") else { return Vec::new() };
let Some(id) = blip.attr("r:embed") else { return Vec::new() };
let Some(id) = drawing.find("a:blip").and_then(|b| b.attr("r:embed")) else { return Vec::new() };
let Some(target) = rels.by_id(id).filter(|r| !r.external).map(|r| r.target.clone()) else { return Vec::new() };
let Some(src) = self.bin_for(&target) else { return Vec::new() };
let pt = |k: &str| {
@@ -330,19 +329,7 @@ impl<'a> Conv<'a> {
let shape_id = format!("_x0000_i{}", self.next_shape);
self.next_shape = self.next_shape.saturating_add(1);
let mut kids: Vec<Node> = std::mem::take(&mut self.bins).into_iter().map(Node::El).collect();
let mut shape = self.shape_frame(&shape_id, &style, &src, &alt);
// Brightness and contrast as VML `gain` and `blacklevel` (16.16 fixed point, #1711).
// `v:imagedata` has no opacity, so a picture's transparency (`a:alphaModFix`) is dropped.
let fx = crate::units::blip_fx(blip);
if fx.changes_pixels()
&& let Some(Node::El(data)) = shape.kids.iter_mut().find(|k| matches!(k, Node::El(e) if e.name == "v:imagedata"))
{
let (gain, black) = fx.to_vml();
let fixed = |v: f64| format!("{}f", (v * 65_536.0).round().clamp(-1.0e12, 1.0e12) as i64);
data.attrs.push(("gain".into(), fixed(gain)));
data.attrs.push(("blacklevel".into(), fixed(black)));
}
kids.push(Node::El(shape));
kids.push(Node::El(self.shape_frame(&shape_id, &style, &src, &alt)));
vec![Node::El(plain("w:pict", kids))]
}
-3
View File
@@ -287,9 +287,6 @@ pub fn ppr_inner(w: &mut W, p: &ParaProps, framed: bool, num_change: Option<&Cha
if let Some(a) = p.align {
w.val("w:jc", align_val(a));
}
if let Some(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));
}
+2 -16
View File
@@ -15,7 +15,7 @@ pub(super) struct DrawingInfo<'a> {
impl Writer<'_> {
/// A picture's `pic:pic` (the image in `media/{file}`), at `off` in its group (or 0, 0).
pub(super) fn pic(&mut self, w: &mut W, o: &InlineObject, file: &str, off: (f32, f32), rels: &mut PartRels) {
let InlineObject::Image { media, w: iw, h: ih, alt, crop, float, fx, .. } = o else { return };
let InlineObject::Image { media, w: iw, h: ih, alt, crop, float, .. } = o else { return };
let rid = rels.add(rt::IMAGE, &format!("media/{file}"), false);
self.used_media.insert(media.clone());
w.open("pic:pic", &[]);
@@ -26,21 +26,7 @@ impl Writer<'_> {
w.close("pic:cNvPicPr");
w.close("pic:nvPicPr");
w.open("pic:blipFill", &[]);
// Brightness, contrast and transparency (#1711, #1766) as `a:blip` effects (ECMA-376
// §20.1.8.13 CT_Blip: an unbounded choice of effects, in any order).
let fx = fx.clamped();
if fx.is_identity() {
w.empty("a:blip", &[("r:embed", &rid)]);
} else {
w.open("a:blip", &[("r:embed", &rid)]);
if fx.transparency != 0 {
w.empty("a:alphaModFix", &[("amt", &n(i64::from(100_000 - fx.transparency)))]);
}
if fx.bright != 0 || fx.contrast != 0 {
w.empty("a:lum", &[("bright", &n(i64::from(fx.bright))), ("contrast", &n(i64::from(fx.contrast)))]);
}
w.close("a:blip");
}
w.empty("a:blip", &[("r:embed", &rid)]);
if crop.iter().any(|c| *c > 0.0) {
let c = |v: f32| n((wordcraft_geom::finite(v).clamp(0.0, 1.0) * 100_000.0).round() as i64);
w.empty("a:srcRect", &[("l", &c(crop[0])), ("t", &c(crop[1])), ("r", &c(crop[2])), ("b", &c(crop[3]))]);
+1 -10
View File
@@ -317,16 +317,7 @@ fn a_chunks_pictures_come_with_it() {
let mut d = Document::new();
let key = d.add_media(vec![1, 2, 3], "png");
let mut para = wordcraft_doc::Paragraph::new();
let image = InlineObject::Image {
media: key,
w: 10.0,
h: 10.0,
alt: String::new(),
float: Default::default(),
crop: [0.0; 4],
ole: None,
fx: Default::default(),
};
let image = InlineObject::Image { media: key, w: 10.0, h: 10.0, alt: String::new(), float: Default::default(), crop: [0.0; 4], ole: None };
para.insert_object(0, image, &Default::default()).unwrap();
d.body = vec![std::sync::Arc::new(Block::Para(para))];
Some(d)
-76
View File
@@ -1,76 +0,0 @@
//! Compound border lines of thin and thick strokes (`thinThickSmallGap` to
//! `thinThickThinLargeGap`, ECMA-376 Part 1 §17.18.2 ST_Border) on paragraphs, table cells and
//! pages: written back as read, not as a single line. Fixtures are hand-written from the spec.
use std::io::{Read, Write};
use wordcraft_doc::props::BorderStyle;
const LINES: [(&str, BorderStyle); 9] = [
("thinThickSmallGap", BorderStyle::ThinThickSmallGap),
("thickThinSmallGap", BorderStyle::ThickThinSmallGap),
("thinThickThinSmallGap", BorderStyle::ThinThickThinSmallGap),
("thinThickMediumGap", BorderStyle::ThinThickMediumGap),
("thickThinMediumGap", BorderStyle::ThickThinMediumGap),
("thinThickThinMediumGap", BorderStyle::ThinThickThinMediumGap),
("thinThickLargeGap", BorderStyle::ThinThickLargeGap),
("thickThinLargeGap", BorderStyle::ThickThinLargeGap),
("thinThickThinLargeGap", BorderStyle::ThinThickThinLargeGap),
];
fn docx(body: &str) -> Vec<u8> {
let root = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
let doc = format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>{body}</w:body></w:document>"#
);
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, bytes) in [("_rels/.rels", root.as_bytes()), ("word/document.xml", doc.as_bytes())] {
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
zw.write_all(bytes).unwrap();
}
zw.finish().unwrap().into_inner()
}
fn document_xml(bytes: &[u8]) -> String {
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut s = String::new();
z.by_name("word/document.xml").unwrap().read_to_string(&mut s).unwrap();
s
}
fn side(name: &str, val: &str) -> String {
format!(r#"<w:{name} w:val="{val}" w:sz="36" w:space="1" w:color="2F5496"/>"#)
}
#[test]
fn compound_paragraph_borders_are_written_back_as_read() {
let body: String = LINES
.iter()
.map(|(val, _)| {
format!(r#"<w:p><w:pPr><w:pBdr>{}{}</w:pBdr></w:pPr><w:r><w:t>{val}</w:t></w:r></w:p>"#, side("top", val), side("bottom", val))
})
.collect();
let d = wordcraft_docx::read(&docx(&body)).unwrap();
let got: Vec<Option<BorderStyle>> =
d.body.iter().filter_map(|b| b.as_para()).map(|p| p.props.borders.and_then(|b| b.top).map(|t| t.style)).collect();
assert_eq!(got, LINES.iter().map(|(_, s)| Some(*s)).collect::<Vec<_>>());
let xml = document_xml(&wordcraft_docx::write(&d).unwrap());
for (val, _) in LINES {
assert_eq!(xml.matches(&format!(r#"w:val="{val}""#)).count(), 2, "{val}: {xml}");
}
assert!(!xml.contains(r#"w:val="single""#), "{xml}");
}
#[test]
fn compound_cell_and_page_borders_are_written_back_as_read() {
let body = format!(
r#"<w:tbl><w:tblPr><w:tblW w:w="0" w:type="auto"/><w:tblBorders>{}</w:tblBorders></w:tblPr><w:tblGrid><w:gridCol w:w="2000"/></w:tblGrid><w:tr><w:tc><w:tcPr><w:tcW w:w="2000" w:type="dxa"/><w:tcBorders>{}</w:tcBorders></w:tcPr><w:p><w:r><w:t>Cell</w:t></w:r></w:p></w:tc></w:tr></w:tbl><w:p/><w:sectPr><w:pgBorders w:offsetFrom="text">{}</w:pgBorders></w:sectPr>"#,
side("top", "thickThinLargeGap"),
side("left", "thinThickSmallGap"),
side("right", "thinThickThinMediumGap")
);
let xml = document_xml(&wordcraft_docx::write(&wordcraft_docx::read(&docx(&body)).unwrap()).unwrap());
for val in ["thickThinLargeGap", "thinThickSmallGap", "thinThickThinMediumGap"] {
assert_eq!(xml.matches(&format!(r#"w:val="{val}""#)).count(), 1, "{val}: {xml}");
}
}
-1
View File
@@ -22,7 +22,6 @@ fn picture() -> InlineObject {
alt: String::new(),
float: Default::default(),
crop: [0.0; 4],
fx: Default::default(),
ole: None,
}
}
-152
View File
@@ -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.
-2
View File
@@ -1,7 +1,6 @@
mod alt_chunks;
mod bidi;
mod bidi_runs;
mod border_lines;
mod cell_margin_inheritance;
mod char_units;
mod citations;
@@ -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;
+4 -39
View File
@@ -7,7 +7,7 @@ use wordcraft_doc::numbering::ListKind;
use wordcraft_doc::para::{Anchor, Float, FloatAlign, NoteKind, ShapeKind, Wrap};
use wordcraft_doc::props::{
Align, Border, BorderStyle, Borders, CharProps, HeightRule, Highlight, LineSpacing, NumRef, ParaProps, Rgb, RowProps, TabAlign, TabLeader,
TabStop, TableLook, TextAlignment, TextColor, TextDirection, Underline, VAlign, VMerge, VertAlign,
TabStop, TableLook, TextColor, TextDirection, Underline, VAlign, VMerge, VertAlign,
};
use wordcraft_doc::section::{Columns, DocGrid, DocGridType, LineNumberRestart, LineNumbering, NumFormat, PageVAlign, SectionProps, SectionStart};
use wordcraft_doc::styles::{Style, StyleKind};
@@ -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 {
-64
View File
@@ -1,64 +0,0 @@
//! A paragraph's vertical text alignment (`w:textAlignment`, ECMA-376 Part 1 §17.3.1.39, values
//! from ST_TextAlignment): read and written back as read, in the schema's place after `w:jc`.
//! Word sizes a line by it when a list label is larger than the text, so a save that drops it
//! moves every line after. Fixtures are hand-written from the spec.
use std::io::{Read, Write};
use wordcraft_doc::props::TextAlignment;
fn docx(body: &str) -> Vec<u8> {
let root = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
let doc = format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>{body}</w:body></w:document>"#
);
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, bytes) in [("_rels/.rels", root.as_bytes()), ("word/document.xml", doc.as_bytes())] {
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
zw.write_all(bytes).unwrap();
}
zw.finish().unwrap().into_inner()
}
fn document_xml(bytes: &[u8]) -> String {
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut s = String::new();
z.by_name("word/document.xml").unwrap().read_to_string(&mut s).unwrap();
s
}
#[test]
fn text_alignment_is_written_back_as_read() {
let para = |ppr: &str, text: &str| format!(r#"<w:p><w:pPr>{ppr}</w:pPr><w:r><w:t>{text}</w:t></w:r></w:p>"#);
let body = [
para(r#"<w:jc w:val="center"/><w:textAlignment w:val="top"/><w:outlineLvl w:val="1"/>"#, "A"),
para(r#"<w:textAlignment w:val="center"/>"#, "B"),
para(r#"<w:textAlignment w:val="baseline"/>"#, "C"),
para(r#"<w:textAlignment w:val="bottom"/>"#, "D"),
para(r#"<w:textAlignment w:val="auto"/>"#, "E"),
para(r#"<w:textAlignment w:val="sideways"/>"#, "F"),
para("", "G"),
]
.concat();
let d = wordcraft_docx::read(&docx(&body)).unwrap();
let got: Vec<Option<TextAlignment>> = d.body.iter().filter_map(|b| b.as_para()).map(|p| p.props.text_alignment).collect();
let want = vec![
Some(TextAlignment::Top),
Some(TextAlignment::Center),
Some(TextAlignment::Baseline),
Some(TextAlignment::Bottom),
Some(TextAlignment::Auto),
None,
None,
];
assert_eq!(got, want);
let out = wordcraft_docx::write(&d).unwrap();
let xml = document_xml(&out);
assert!(xml.contains(r#"<w:jc w:val="center"/><w:textAlignment w:val="top"/><w:outlineLvl w:val="1"/>"#), "{xml}");
for v in ["center", "baseline", "bottom", "auto"] {
assert_eq!(xml.matches(&format!(r#"<w:textAlignment w:val="{v}"/>"#)).count(), 1, "{v}: {xml}");
}
assert_eq!(xml.matches("w:textAlignment").count(), 5, "an unknown value and none write nothing: {xml}");
let back = wordcraft_docx::read(&out).unwrap();
assert_eq!(back.body.iter().filter_map(|b| b.as_para()).map(|p| p.props.text_alignment).collect::<Vec<_>>(), want);
}
-1
View File
@@ -215,7 +215,6 @@ fn picture_in_box_keeps_its_frame() {
alt: "Synthetic marker".into(),
float: Default::default(),
crop: [0.0; 4],
fx: Default::default(),
ole: None,
};
let mut p = Paragraph::new();
+1 -11
View File
@@ -106,7 +106,7 @@ fn wordml_2003_sample() -> String {
<w:tr><w:tc><w:tcPr><w:tcW w:w="2000" w:type="dxa"/><w:vmerge w:val="restart"/></w:tcPr><w:p><w:r><w:t>A1</w:t></w:r></w:p></w:tc><w:tc><w:tcPr><w:tcW w:w="2000" w:type="dxa"/></w:tcPr><w:p><w:r><w:t>B1</w:t></w:r></w:p></w:tc></w:tr>
<w:tr><w:tc><w:tcPr><w:tcW w:w="2000" w:type="dxa"/><w:vmerge/></w:tcPr><w:p/></w:tc><w:tc><w:tcPr><w:tcW w:w="2000" w:type="dxa"/></w:tcPr><w:p><w:r><w:t>B2</w:t></w:r></w:p></w:tc></w:tr>
</w:tbl>
<w:p><w:r><w:pict><w:binData w:name="wordml://02000001.png">{png}</w:binData><v:shape id="_x0000_i1025" type="#_x0000_t75" style="width:36pt;height:18pt"><v:imagedata src="wordml://02000001.png" o:title="dot" gain="19661f" blacklevel="22938f"/></v:shape></w:pict></w:r></w:p>
<w:p><w:r><w:pict><w:binData w:name="wordml://02000001.png">{png}</w:binData><v:shape id="_x0000_i1025" type="#_x0000_t75" style="width:36pt;height:18pt"><v:imagedata src="wordml://02000001.png" o:title="dot"/></v:shape></w:pict></w:r></w:p>
<w:sectPr>
<w:hdr w:type="odd"><w:p><w:r><w:t>Garden header</w:t></w:r></w:p></w:hdr>
<w:pgSz w:w="12240" w:h="15840"/><w:pgMar w:top="1440" w:right="1800" w:bottom="1440" w:left="1800" w:header="720" w:footer="720" w:gutter="0"/>
@@ -117,16 +117,6 @@ fn wordml_2003_sample() -> String {
)
}
/// Word 2003 XML pictures keep Word's Washout (gain 0.3, black level 0.35) as brightness and
/// contrast (#1711).
#[test]
fn word_2003_xml_picture_keeps_washout() {
let doc = read_xml(wordml_2003_sample().as_bytes()).unwrap();
let ps = paras(&doc);
let pic = ps.iter().flat_map(|p| p.objects.iter()).find(|o| matches!(o, InlineObject::Image { .. }));
assert!(matches!(pic, Some(InlineObject::Image { fx, .. }) if (fx.bright - 70_000).abs() <= 2 && (fx.contrast + 70_000).abs() <= 2), "{pic:?}");
}
#[test]
fn imports_word_2003_xml() {
let xml = wordml_2003_sample();
+1 -52
View File
@@ -69,16 +69,7 @@ fn sample() -> Document {
let mut img = Paragraph::new();
img.insert_object(
0,
InlineObject::Image {
media: key,
w: 30.0,
h: 20.0,
alt: "dot".into(),
float: Default::default(),
crop: [0.0; 4],
fx: Default::default(),
ole: None,
},
InlineObject::Image { media: key, w: 30.0, h: 20.0, alt: "dot".into(), float: Default::default(), crop: [0.0; 4], ole: None },
&CharProps::default(),
)
.unwrap();
@@ -189,48 +180,6 @@ fn big_and_deep_documents_write() {
}
}
/// Picture brightness and contrast (#1711) survive a Word 2003 XML save as VML `gain` and
/// `blacklevel`; transparency has no place on `v:imagedata` and is dropped.
#[test]
fn picture_brightness_and_contrast_round_trip() {
use wordcraft_doc::picfx::PicFx;
let cases = [PicFx::new(70_000, -70_000, 0), PicFx::new(-30_000, 50_000, 40_000), PicFx::default()];
let mut d = Document::new();
let key = d.add_media(tiny_png(), "png");
d.body = cases
.iter()
.map(|fx| {
let mut p = Paragraph::new();
let pic = InlineObject::Image {
media: key.clone(),
w: 30.0,
h: 20.0,
alt: "dot".into(),
float: Default::default(),
crop: [0.0; 4],
fx: *fx,
ole: None,
};
p.insert_object(0, pic, &CharProps::default()).unwrap();
para_block(p)
})
.collect();
let xml = write_wordml2003(&d).expect("write");
let text = String::from_utf8_lossy(&xml);
assert!(text.contains(r#"gain="19661f""#) && text.contains(r#"blacklevel="22938f""#), "Word's Washout values");
let back = read_xml(&xml).expect("read");
let got: Vec<PicFx> = paras(&back)
.iter()
.flat_map(|p| p.objects.iter())
.filter_map(|o| if let InlineObject::Image { fx, .. } = o { Some(*fx) } else { None })
.collect();
assert_eq!(got.len(), cases.len());
for (g, want) in got.iter().zip(cases) {
let near = |a: i32, b: i32| (a - b).abs() <= 2;
assert!(near(g.bright, want.bright) && near(g.contrast, want.contrast) && g.transparency == 0, "{g:?} vs {want:?}");
}
}
#[test]
fn shared_header_and_repeated_note_are_not_inlined_per_reference() {
// #3652: one ~10 KB header shared by 1000 sections and one ~10 KB footnote referenced 500
-2
View File
@@ -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).
-53
View File
@@ -548,59 +548,6 @@ pub fn now_iso() -> String {
iso_from_unix_secs(now_unix())
}
/// The local time zone's offset from UTC in seconds (east positive) at `unix`, daylight saving
/// included: the system's zone, or the browser's on the web. 0 (UTC) when it can't be found.
pub fn local_offset_secs(unix: u64) -> i64 {
system_offset_secs(unix).clamp(-26 * 3600, 26 * 3600)
}
#[cfg(not(target_arch = "wasm32"))]
fn system_offset_secs(unix: u64) -> i64 {
use chrono::Offset;
let Some(utc) = i64::try_from(unix).ok().and_then(|secs| chrono::DateTime::from_timestamp(secs, 0)) else {
return 0;
};
// chrono's `Local` has internal `expect`s (unreadable zone data, an unmapped Windows year), so
// fence it: a failure means UTC, never a crash. A caught panic is still reported by the app's
// panic hook (logged, no abort); the document is untouched.
std::panic::catch_unwind(|| i64::from(utc.with_timezone(&chrono::Local).offset().fix().local_minus_utc())).unwrap_or(0)
}
/// On the web the browser's `Date` gives the offset directly (chrono isn't used there).
#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
fn system_offset_secs(unix: u64) -> i64 {
let ms = (unix as f64) * 1000.0;
if !ms.is_finite() {
return 0;
}
// `getTimezoneOffset` is minutes *west* of UTC (UTC+13 gives -780); NaN for an invalid date.
let west_min = js_sys::Date::new(&js_sys::wasm_bindgen::JsValue::from_f64(ms)).get_timezone_offset();
if west_min.is_finite() && west_min.abs() <= 26.0 * 60.0 { -(west_min as i64) * 60 } else { 0 }
}
/// Other wasm targets (WASI) have neither chrono's clock nor a browser: UTC.
#[cfg(all(target_arch = "wasm32", not(target_os = "unknown")))]
fn system_offset_secs(_unix: u64) -> i64 {
0
}
/// The local civil date and time now: see [`local_civil`].
pub fn local_now() -> (i64, u32, u32, (u32, u32, u32), i64) {
let now = now_unix();
local_civil(now, local_offset_secs(now))
}
/// Local civil date and time for an instant: (year, month, day, (hour, minute, second), days since
/// 1970-01-01 of that local day, for the weekday). `offset_secs` is the local zone's offset from
/// UTC (east positive). Saturating, so a hostile offset or clock never overflows.
pub fn local_civil(unix: u64, offset_secs: i64) -> (i64, u32, u32, (u32, u32, u32), i64) {
let local = i64::try_from(unix).unwrap_or(i64::MAX).saturating_add(offset_secs);
let days = local.div_euclid(86_400);
let t = local.rem_euclid(86_400) as u32;
let (y, m, d) = civil_from_days(days);
(y, m, d, (t / 3600, t / 60 % 60, t % 60), days)
}
/// Days since 1970-01-01 → (year, month, day).
///
/// `NaiveDate` only covers about ±262143 years, but this is reached by the system clock (and by
+12 -82
View File
@@ -72,7 +72,7 @@ const MAX_WRAPPED: usize = 10_000;
/// A generated list (index, bibliography, table of figures or authorities) held as a complex
/// field that starts at body block `i` and runs over its entries: its field code and the block
/// it ends in. `None` for anything else (a TOC has its own update).
fn wrapped_list(s: &Session, i: usize) -> Option<(String, usize, usize, usize)> {
fn wrapped_list(s: &Session, i: usize) -> Option<(String, usize, usize)> {
let p = s.doc.body.get(i)?.as_para()?;
let (k, off, instr) = p.objects_with_offsets().enumerate().find_map(|(k, (off, o))| match o {
InlineObject::FieldStart { instr, .. } => {
@@ -97,7 +97,7 @@ fn wrapped_list(s: &Session, i: usize) -> Option<(String, usize, usize, usize)>
InlineObject::FieldEnd => {
depth = depth.saturating_sub(1);
if depth == 0 {
return Some((instr, off, j, at + wordcraft_doc::para::OBJ.len_utf8()));
return Some((instr, j, at + wordcraft_doc::para::OBJ.len_utf8()));
}
}
_ => {}
@@ -108,18 +108,13 @@ fn wrapped_list(s: &Session, i: usize) -> Option<(String, usize, usize, usize)>
}
fn list_kind(p: &Paragraph) -> Option<String> {
p.objects.iter().find_map(list_field)
}
/// The field code of `o` when it is a generated list's field.
fn list_field(o: &InlineObject) -> Option<String> {
match o {
p.objects.iter().find_map(|o| match o {
InlineObject::Field { instr, .. } => {
let t = instr.trim_start();
(t.starts_with("BIBLIOGRAPHY") || t.starts_with("INDEX") || t.starts_with("TOA") || figures_label(t).is_some()).then(|| t.to_string())
}
_ => None,
}
})
}
/// The caption label (SEQ identifier) a table-of-figures field lists, `\c` or `\a`.
@@ -203,15 +198,12 @@ pub fn update_generated(s: &mut Session) -> Result<(), CmdError> {
// field's own paragraph, as WordCraft inserts one, its old entries rebuilt below.
// What follows its closing field end, and its first paragraph's properties and section
// break, are the document's and stay.
// (The field it is made: what comes before it in its paragraph may hold other fields.)
let mut converted = None;
if let Some((instr, start, last, end)) = wrapped_list(s, i) {
if let Some((instr, last, end)) = wrapped_list(s, i) {
// The entries between its first and last paragraphs go.
for _ in i.saturating_add(1)..last {
s.doc.remove_block(StoryRef::Body, &Path::top(i + 1))?;
}
// Its first paragraph keeps what comes before the field (bookmarks, other fields)
// and the field; its last (or, ending in its first, the
// Its first paragraph keeps only the field; its last (or, ending in its first, the
// same) loses what is up to and through the field end, and goes when nothing is left
// of it and it ends no section.
let first_end = if last > i {
@@ -225,38 +217,28 @@ pub fn update_generated(s: &mut Session) -> Result<(), CmdError> {
end
};
let p = s.doc.para_mut(StoryRef::Body, &Path::top(i))?;
p.delete(start, first_end)?;
converted = Some(instr.trim_start().to_string());
p.delete(0, first_end)?;
p.insert_object(
start,
0,
InlineObject::Field { instr, result: String::new(), locked: false, dirty: false, code: false, form: String::new() },
&Default::default(),
)?;
// What followed the field end in its own paragraph comes after the list, in a
// paragraph of its own that takes the section break.
let field = start + wordcraft_doc::para::OBJ.len_utf8();
let field = wordcraft_doc::para::OBJ.len_utf8();
if p.len() > field {
let rest = p.split_off(field)?;
s.doc.insert_block(StoryRef::Body, &Path::top(i + 1), Block::Para(rest))?;
}
// Another list's field before it would be the paragraph's list on later updates (and
// have its shown list cleared with this one's): the list gets a paragraph of its own,
// after it, and each is rebuilt in its turn.
let p = s.doc.para_mut(StoryRef::Body, &Path::top(i))?;
if p.objects_with_offsets().any(|(off, o)| off < start && list_field(o).is_some()) {
let list = p.split_off(start)?;
s.doc.insert_block(StoryRef::Body, &Path::top(i + 1), Block::Para(list))?;
converted = None;
}
}
let kind = converted.or_else(|| s.doc.body.get(i).and_then(|b| b.as_para()).and_then(list_kind));
let kind = s.doc.body.get(i).and_then(|b| b.as_para()).and_then(list_kind);
let Some(kind) = kind else {
i += 1;
continue;
};
add_entry_style(s);
if kind.starts_with("INDEX") {
crate::cmd::index::rebuild_from(s, i, None, kind)?;
crate::cmd::index::rebuild(s, i, None)?;
i += 1;
continue;
}
@@ -450,7 +432,7 @@ mod tests {
p.insert_object(n, InlineObject::FieldEnd, &props).unwrap();
}
let s = Session::new(d);
assert_eq!(wrapped_list(&s, 0).map(|(instr, _, last, _)| (instr, last)), Some(("BIBLIOGRAPHY".to_string(), 1)));
assert_eq!(wrapped_list(&s, 0).map(|(instr, last, _)| (instr, last)), Some(("BIBLIOGRAPHY".to_string(), 1)));
}
/// A wrapped list as a session holds it: the field starting paragraph `first`'s text, ending
@@ -505,56 +487,4 @@ mod tests {
assert_eq!(t.get(1).map(String::as_str), Some(" Then this"), "{t:?}");
assert_eq!(t.last().map(String::as_str), Some("Below"));
}
/// What comes before a wrapped list's field in its first paragraph (a bookmark, an empty
/// `PAGE` field) stays there, before the rebuilt list's field.
#[test]
fn what_comes_before_a_wrapped_lists_field_stays() {
let mut s = wrapped("Old one\nOld two\nBelow", 0, 1, "Old two".len());
let props = CharProps::default();
if let Ok(p) = s.doc.para_mut(StoryRef::Body, &Path::top(0)) {
p.insert_object(0, InlineObject::FieldEnd, &props).unwrap();
p.insert_object(0, InlineObject::FieldStart { instr: "PAGE".into(), locked: false, dirty: false }, &props).unwrap();
p.insert_object(0, InlineObject::BookmarkStart { name: "Sources".into() }, &props).unwrap();
}
update_generated(&mut s).unwrap();
let first = s.doc.body.first().and_then(|b| b.as_para());
let kinds: Vec<&str> = first
.map(|p| {
p.objects
.iter()
.map(|o| match o {
InlineObject::BookmarkStart { .. } => "bookmark",
InlineObject::FieldStart { instr, .. } if instr == "PAGE" => "page",
InlineObject::FieldEnd => "end",
InlineObject::Field { instr, .. } if instr.starts_with("BIBLIOGRAPHY") => "list",
_ => "other",
})
.collect()
})
.unwrap_or_default();
assert_eq!(kinds, ["bookmark", "page", "end", "list"]);
assert_eq!(texts(&s), ["", "Below"]);
}
/// A list field before a wrapped list's field in its paragraph doesn't take its place: the
/// wrapped list is the one rebuilt.
#[test]
fn a_wrapped_list_is_rebuilt_as_itself_after_another_list_field() {
let mut s = wrapped("Old one\nOld two\nBelow", 0, 1, "Old two".len());
s.doc.sources.push(wordcraft_doc::Source { tag: "a".into(), kind: "book".into(), title: "Listed Source".into(), ..Default::default() });
let index =
InlineObject::Field { instr: "INDEX".into(), result: String::new(), locked: false, dirty: false, code: false, form: String::new() };
if let Ok(p) = s.doc.para_mut(StoryRef::Body, &Path::top(0)) {
p.insert_object(0, index, &CharProps::default()).unwrap();
}
// Updated again, each list stays itself.
for _ in 0..2 {
update_generated(&mut s).unwrap();
let t = texts(&s);
assert_eq!(t.iter().filter(|t| t.contains("Listed Source")).count(), 1, "the bibliography was rebuilt: {t:?}");
let lists: Vec<Option<String>> = s.doc.body.iter().filter_map(|b| b.as_para()).filter_map(list_kind).map(Some).collect();
assert_eq!(lists, [Some("INDEX".to_string()), Some("BIBLIOGRAPHY".to_string())]);
}
}
}
+2 -2
View File
@@ -260,12 +260,12 @@ fn open(s: &mut Session, v: &Value) -> CmdResult {
if bytes.is_empty() {
(normal_document(s), false)
} else {
crate::io::open_bytes_in(path, &bytes, password, &s.region()).map_err(CmdError::Failed)?
crate::io::open_bytes_in(path, &bytes, password, &s.region(), &s.prefs.odf).map_err(CmdError::Failed)?
}
} else if crate::io::is_empty_file(std::path::Path::new(path)) {
(normal_document(s), false)
} else {
crate::io::open_path_in(std::path::Path::new(path), password, &s.region()).map_err(CmdError::Failed)?
crate::io::open_path_in(std::path::Path::new(path), password, &s.region(), &s.prefs.odf).map_err(CmdError::Failed)?
};
// Plain text and Markdown carry no language: they are in the editing language.
let ext = std::path::Path::new(path).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("").to_ascii_lowercase();
-6
View File
@@ -265,12 +265,6 @@ pub fn rebuild(s: &mut Session, i: usize, leader: Option<TabLeader>) -> Result<(
}) else {
return Ok(());
};
rebuild_from(s, i, leader, instr)
}
/// [`rebuild`] the index at body block `i` from field code `instr` (that of the index field in
/// it, when the paragraph holds more than one).
pub fn rebuild_from(s: &mut Session, i: usize, leader: Option<TabLeader>, instr: String) -> Result<(), CmdError> {
let f = FieldCode::parse(&instr);
// Old entries go; keep their leader and the section their columns had.
let mut old_leader = None;
+2 -2
View File
@@ -15,7 +15,7 @@ mod objects;
pub(crate) use header_footer::saved_copy;
use header_footer::{create_hf, edit_hf, header_footer, new_empty_headers, strip_headers};
pub use items::{DropCapState, drop_cap_state, format_date, format_date_at, format_date_parts};
pub use items::{DropCapState, drop_cap_state, format_date, format_date_parts};
use items::{cover_page, date_time, drop_cap, drop_cap_off, field, page_number, symbol};
use links::{bookmark, delete_bookmark, link, link_targets};
pub use links::{bookmark_name_ok, bookmark_range, bookmark_span};
@@ -124,7 +124,7 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("insert.pageField", "Insert Page Field", "Insert › Text › Quick Parts", |s, _| field(s, &json!({"instr": "PAGE"})))
.key(super::mac_or("Ctrl+Shift+P", "Alt+Shift+P")),
CommandSpec::new("insert.dropCap", "Drop Cap", "Insert › Text", drop_cap)
.params(r#"{"position"?: "none"|"dropped"|"margin" (default dropped), "lines"?: 1..10 (default 3; 0 = none), "distance"?: pt from the text (0..1584, default 0), "font"?: family (omit for the text's font), "keep"?: bool (true: each selected paragraph that has a drop cap keeps its own lines, distance and font unless given here)}"#)
.params(r#"{"position"?: "none"|"dropped"|"margin" (default dropped), "lines"?: 1..10 (default 3; 0 = none), "distance"?: pt from the text (0..1584, default 0), "font"?: family (omit for the text's font)}"#)
.when(drop_cap_off),
CommandSpec::new("insert.horizontalLine", "Horizontal Line", "Home › Paragraph › Borders", |s, _| {
super::para::fmt(s, &|p| {
+16 -49
View File
@@ -61,16 +61,14 @@ pub(super) fn date_time(s: &mut Session, v: &Value) -> CmdResult {
/// Format today's date with a Word picture (`M/d/yyyy`, `MMMM d, yyyy`, `yyyy-MM-dd`, `dddd`…).
pub fn format_date(fmt: &str) -> String {
// Word shows the computer's local date and time (#1707), not UTC's.
let now = crate::cmd::now_unix();
format_date_at(fmt, now, crate::cmd::local_offset_secs(now))
}
/// Format the instant `unix` (seconds since the epoch, UTC) with a Word picture, in the zone
/// `offset_secs` east of UTC.
pub fn format_date_at(fmt: &str, unix: u64, offset_secs: i64) -> String {
let (y, m, d, hms, days) = crate::cmd::local_civil(unix, offset_secs);
format_date_parts(fmt, y, m as usize, d, hms, days)
let iso = crate::cmd::now_iso();
let y: i64 = iso.get(0..4).and_then(|x| x.parse().ok()).unwrap_or(2026);
let m: usize = iso.get(5..7).and_then(|x| x.parse().ok()).unwrap_or(1);
let d: u32 = iso.get(8..10).and_then(|x| x.parse().ok()).unwrap_or(1);
let hh: u32 = iso.get(11..13).and_then(|x| x.parse().ok()).unwrap_or(0);
let mm: u32 = iso.get(14..16).and_then(|x| x.parse().ok()).unwrap_or(0);
let ss: u32 = iso.get(17..19).and_then(|x| x.parse().ok()).unwrap_or(0);
format_date_parts(fmt, y, m, d, (hh, mm, ss), (crate::cmd::now_unix() / 86_400) as i64)
}
/// Format a date and time with a Word picture; `days_since` (days since 1970-01-01) gives the weekday.
@@ -241,16 +239,12 @@ pub(super) fn cover_page(s: &mut Session, v: &Value) -> CmdResult {
}
/// Why Insert › Drop Cap is off at the caret, as in Word: in a table cell, in an empty paragraph
/// and in one that starts with a space. A selection must be fit throughout (what Dropped and In
/// margin need), unless a paragraph in it has a drop cap for None to take away.
/// and in one that starts with a space.
pub(super) fn drop_cap_off(s: &Session) -> Option<&'static str> {
let why = drop_cap_selection_off(s)?;
let story = s.sel.focus.story;
let has_cap = crate::cmd::selected_paths(s).iter().any(|path| s.doc.para(story, path).is_some_and(|p| p.props.drop_cap.is_some()));
(!has_cap).then_some(why)
}
fn drop_cap_first_letter(first: Option<char>) -> Option<&'static str> {
if s.sel.focus.path.cell().is_some() {
return Some("drop caps can't go in a table");
}
let first = s.doc.para_at(&s.sel.focus).and_then(|p| p.text.chars().next());
match first {
None => Some("the paragraph is empty"),
Some(c) if c.is_whitespace() => Some("the paragraph starts with a space"),
@@ -258,21 +252,6 @@ fn drop_cap_first_letter(first: Option<char>) -> Option<&'static str> {
}
}
/// The same check for every selected paragraph, as the command formats them all.
fn drop_cap_selection_off(s: &Session) -> Option<&'static str> {
let story = s.sel.focus.story;
for path in crate::cmd::selected_paths(s) {
if path.cell().is_some() {
return Some("drop caps can't go in a table");
}
let first = s.doc.para(story, &path).and_then(|p| p.text.chars().next());
if let Some(why) = drop_cap_first_letter(first) {
return Some(why);
}
}
None
}
/// Insert › Drop Cap and Drop Cap Options: none, or the letter dropped into the text or hung in
/// the margin, `lines` tall, `distance` points from the text, in `font` (unset: the text's).
/// None takes all of it away, the letter's font with it.
@@ -280,27 +259,15 @@ pub(super) fn drop_cap(s: &mut Session, v: &Value) -> CmdResult {
let lines = v.get("lines").and_then(Value::as_f64).filter(|l| l.is_finite());
let none = p::str(v, "position") == Some("none") || lines == Some(0.0);
let margin = p::str(v, "position") == Some("margin");
let lines_given = lines.is_some();
let lines = lines.map_or(3.0, |l| l.round().clamp(1.0, 10.0)) as u8;
let distance = p::f32(v, "distance").map_or(0.0, |d| d.clamp(0.0, wordcraft_doc::resolve::MAX_DROP_CAP_DISTANCE));
// A family name, not a paragraph of text.
let font = p::str(v, "font").map(str::trim).filter(|f| !f.is_empty()).map(|f| f.chars().take(64).collect::<String>());
if !none && let Some(why) = drop_cap_selection_off(s) {
return Err(CmdError::Failed(why.into()));
}
// `keep` (the menu's Dropped / In margin): a paragraph that has a drop cap keeps its own
// lines, distance and font unless given here.
let keep = !none && v.get("keep").and_then(Value::as_bool) == Some(true);
let (distance_given, font_given) = (p::f32(v, "distance").is_some(), v.get("font").is_some());
crate::cmd::para::fmt(s, &|p| {
let own = keep && p.drop_cap.is_some();
p.drop_cap = (!none).then(|| if own && !lines_given { p.drop_cap.unwrap_or(lines) } else { lines });
p.drop_cap = (!none).then_some(lines);
p.drop_cap_margin = (!none && margin).then_some(true);
let d = if own && !distance_given { p.drop_cap_distance.unwrap_or(0.0) } else { distance };
p.drop_cap_distance = (!none && d > 0.0).then_some(d);
if !(own && !font_given) {
p.drop_cap_font = font.clone().filter(|_| !none);
}
p.drop_cap_distance = (!none && distance > 0.0).then_some(distance);
p.drop_cap_font = font.clone().filter(|_| !none);
})
}
+1 -10
View File
@@ -146,16 +146,7 @@ fn place_picture(s: &mut Session, bytes: Vec<u8>, width: Option<f32>, alt: &str)
h *= max_w / w;
w = max_w;
}
let obj = InlineObject::Image {
media: key.clone(),
w,
h,
alt: alt.to_string(),
float: Float::default(),
crop: [0.0; 4],
fx: Default::default(),
ole: None,
};
let obj = InlineObject::Image { media: key.clone(), w, h, alt: alt.to_string(), float: Float::default(), crop: [0.0; 4], ole: None };
// A selected Drawing Canvas takes it in.
if let Some(i) = crate::cmd::canvas::add_member(s, obj.clone())? {
return Ok(json!({"media": key, "canvasMember": i}));
-35
View File
@@ -381,38 +381,3 @@ fn date_pictures() {
assert_eq!(date_picture(r#"TIME \@ HH:mm"#).as_deref(), Some("HH:mm"));
assert_eq!(date_picture("DATE"), None);
}
/// #1707: Insert ▸ Date & Time and DATE/TIME fields use the local civil date, time and
/// weekday, not UTC's.
#[test]
fn format_date_uses_the_local_offset() {
// Noon on Saturday 31 October in New Zealand (UTC+13) is 2026-10-30T23:00:00Z.
assert_eq!(format_date_at("dddd, MMMM d, yyyy h:mm am/pm", 1_793_401_200, 13 * 3600), "Saturday, October 31, 2026 12:00 PM");
assert_eq!(format_date_at("M/d/yyyy H:mm", 1_793_498_400, -5 * 3600), "10/31/2026 21:00");
assert_eq!(format_date_at("M/d/yyyy H:mm", 1_793_401_200, 0), "10/30/2026 23:00");
}
#[test]
fn local_civil_date_follows_the_utc_offset() {
let pic = "dddd d MMMM yyyy HH:mm";
let show = |unix: u64, off: i64| {
let (y, m, d, hms, days) = crate::cmd::local_civil(unix, off);
format_date_parts(pic, y, m as usize, d, hms, days)
};
// 2026-10-30T23:00:00Z in New Zealand (UTC+13) is Saturday 31 October, noon.
assert_eq!(show(1_793_401_200, 13 * 3600), "Saturday 31 October 2026 12:00");
// 2026-11-01T02:00:00Z at UTC-5 is still Saturday 31 October, 21:00.
assert_eq!(show(1_793_498_400, -5 * 3600), "Saturday 31 October 2026 21:00");
// Offset 0 is UTC.
assert_eq!(show(1_793_401_200, 0), "Friday 30 October 2026 23:00");
// Near the epoch with a negative offset: the day before 1970, no overflow.
assert_eq!(show(0, -3600), "Wednesday 31 December 1969 23:00");
// Hostile values never panic.
let _ = crate::cmd::local_civil(u64::MAX, i64::MAX);
let _ = crate::cmd::local_civil(u64::MAX, i64::MIN);
let _ = crate::cmd::local_civil(0, i64::MIN);
// The machine's zone is a real offset (within ±26 h) and asking never panics.
for unix in [0, 1_793_401_200, u64::MAX] {
assert!(crate::cmd::local_offset_secs(unix).abs() <= 26 * 3600);
}
}
+1 -1
View File
@@ -31,7 +31,7 @@ use picture::{clear_background, grown, picture_style};
pub(crate) use select::all_objects;
pub(crate) use select::{edit_obj, has_shape_or_canvas};
#[cfg(test)]
use select::{has_group, holds_ink, obj_size, with_obj};
use select::{has_group, holds_ink, obj_size};
pub use select::{min_size, object_selection, picked_objects, selected, selected_all};
#[cfg(test)]
use shape::can_link;
+17 -48
View File
@@ -1,6 +1,5 @@
use super::select::{obj_size, selected, with_obj};
use super::{CmdError, CmdResult, InlineObject, Rgb, Session, Value, Wrap, p};
use wordcraft_doc::picfx::PicFx;
/// `picture.crop`: the crop, and optionally the new display size, as one edit. With a size, a
/// floating picture positioned by offsets moves by what the crop on its left/top side on the page
@@ -149,33 +148,13 @@ pub fn fit_size(w: f32, h: f32, keep: bool) -> (f32, f32) {
pub(super) fn adjust(s: &mut Session, f: impl Fn(image::DynamicImage) -> Result<image::DynamicImage, CmdError>) -> CmdResult {
let (_, o) = selected(s).ok_or_else(|| CmdError::Disabled("no picture".into()))?;
let InlineObject::Image { media, .. } = o else { return Err(CmdError::Disabled("not a picture".into())) };
adjust_media(s, &media, |_| {}, f)
}
/// The selected picture's pre-edit media key (itself when never edited).
pub(super) fn original_media(s: &Session) -> Result<String, CmdError> {
let (_, o) = selected(s).ok_or_else(|| CmdError::Disabled("no picture".into()))?;
let InlineObject::Image { media, .. } = o else { return Err(CmdError::Disabled("not a picture".into())) };
Ok(s.originals.get(&media).cloned().unwrap_or(media))
}
/// Drops brightness and contrast read from the file: an edit baked them from the original.
pub(super) fn clear_lum(fx: &mut PicFx) {
fx.bright = 0;
fx.contrast = 0;
adjust_media(s, &media, f)
}
/// Like [`adjust`], but decodes `base` (a media key, usually the pre-edit original) instead
/// of the picture's current bitmap. The result still chains onto the originals map, so Reset
/// Picture keeps working.
/// `fx` changes the picture's brightness, contrast and transparency from the file (see
/// [`PicFx`]) for an edit that bakes its own.
pub(super) fn adjust_media(
s: &mut Session,
base: &str,
fx: impl Fn(&mut PicFx),
f: impl Fn(image::DynamicImage) -> Result<image::DynamicImage, CmdError>,
) -> CmdResult {
pub(super) fn adjust_media(s: &mut Session, base: &str, f: impl Fn(image::DynamicImage) -> Result<image::DynamicImage, CmdError>) -> CmdResult {
let bytes = s.doc.media.get(base).cloned().ok_or_else(|| CmdError::Failed("picture data missing".into()))?;
// Within the budget pictures are drawn within, checked from the header first (#3644, #3647).
let img = wordcraft_geom::picture::decode(&bytes, wordcraft_geom::MAX_DECODED_BYTES).map_err(|e| match e {
@@ -200,9 +179,8 @@ pub(super) fn adjust_media(
.unwrap_or_else(|| cur.clone().unwrap_or_else(|| key.clone()));
s.originals.entry(key.clone()).or_insert(orig);
with_obj(s, |o| {
if let InlineObject::Image { media, fx: pic_fx, .. } = o {
if let InlineObject::Image { media, .. } = o {
*media = key.clone();
fx(pic_fx);
}
})
}
@@ -266,25 +244,19 @@ pub(super) fn relook(s: &mut Session, change: impl FnOnce(&mut PictureLook)) ->
});
}
let l = look.clone();
// Styles, borders and effects leave the file's brightness, contrast and transparency alone.
let out = adjust_media(
s,
&look.base,
|_| {},
move |img| {
let mut img = match &l.effect {
Some(e) => artistic_effect(img, e),
None => img,
};
if let Some(st) = &l.style {
img = picture_style(img, st)?;
}
if let Some((c, w)) = l.border {
img = frame(img, w, [c.0, c.1, c.2, 255])?;
}
Ok(img)
},
)?;
let out = adjust_media(s, &look.base, move |img| {
let mut img = match &l.effect {
Some(e) => artistic_effect(img, e),
None => img,
};
if let Some(st) = &l.style {
img = picture_style(img, st)?;
}
if let Some((c, w)) = l.border {
img = frame(img, w, [c.0, c.1, c.2, 255])?;
}
Ok(img)
})?;
if let Some((_, InlineObject::Image { media, .. })) = selected(s)
&& let Some(drawn) = s.doc.media.get(&media).cloned()
{
@@ -327,13 +299,10 @@ pub(super) fn reset(s: &mut Session, _: &Value) -> CmdResult {
let (_, o) = selected(s).ok_or_else(|| CmdError::Disabled("no picture".into()))?;
let InlineObject::Image { media, .. } = o else { return Err(CmdError::Disabled("not a picture".into())) };
let orig = s.originals.get(&media).cloned().unwrap_or(media);
// Like Word, Reset Picture discards every correction, those read from the file (`fx`:
// brightness, contrast, transparency, #1711, #1766) included.
with_obj(s, |o| {
if let InlineObject::Image { media, crop, fx, .. } = o {
if let InlineObject::Image { media, crop, .. } = o {
*media = orig.clone();
*crop = [0.0; 4];
*fx = PicFx::default();
}
})
}
+13 -14
View File
@@ -1,13 +1,12 @@
use super::arrange::{MAX_OFFSET, bounds, copy_to, move_with_text, unalign};
use super::group::{add_object, group, ungroup};
use super::picture::{adjust, adjust_media, clear_lum, crop, original_media, recolor, relook, remove_background, reset, size};
use super::picture::{adjust, adjust_media, crop, recolor, relook, remove_background, reset, size};
use super::select::{
all_objects, alt_text, can_group, edit_obj, has_floating, has_group, has_movable, has_object, has_picture, has_shape, has_shape_or_canvas,
has_sizable, obj_size, objects_list, selected, with_float, with_obj,
};
use super::shape::{NO_TEXT_BOX, can_link, has_text_box, link_text_box, set_text_body, shape_effects, text_box_target};
use super::{CmdError, CommandSpec, InlineObject, Pos, Rgb, Selection, Spin, TextVert, VAlign, Wrap, WrapText, json, p, sel_result};
use wordcraft_doc::picfx::PicFx;
pub fn specs() -> Vec<CommandSpec> {
vec![
@@ -37,9 +36,10 @@ pub fn specs() -> Vec<CommandSpec> {
let sharpen = p::f32(v, "sharpen").unwrap_or(0.0).clamp(-100.0, 100.0);
// Absolute from the original (like transparency): re-setting all three to 0
// restores the original pixels instead of stacking on the last pass.
// The file's own brightness and contrast (`fx`) are replaced, not stacked on.
let base = original_media(s)?;
adjust_media(s, &base, clear_lum, move |img| {
let (_, o) = selected(s).ok_or_else(|| CmdError::Disabled("no picture".into()))?;
let InlineObject::Image { media, .. } = o else { return Err(CmdError::Disabled("not a picture".into())) };
let base = s.originals.get(&media).cloned().unwrap_or(media.clone());
adjust_media(s, &base, move |img| {
let mut img = img;
if b != 0.0 {
img = image::DynamicImage::ImageRgba8(image::imageops::colorops::brighten(&img, (b * 2.55) as i32));
@@ -62,11 +62,10 @@ pub fn specs() -> Vec<CommandSpec> {
let mode = p::str(v, "mode").unwrap_or("grayscale").to_string();
let sat = p::f32(v, "saturation").unwrap_or(100.0);
// Absolute from the original (like transparency): saturation 100 restores it.
// Color and Corrections are independent, as in Word: only Washout, which Word stores
// as brightness and contrast (`a:lum`), replaces the file's own (`fx`).
let base = original_media(s)?;
let fx: fn(&mut PicFx) = if mode == "washout" { clear_lum } else { |_| {} };
adjust_media(s, &base, fx, move |img| Ok(recolor(img, &mode, sat)))
let (_, o) = selected(s).ok_or_else(|| CmdError::Disabled("no picture".into()))?;
let InlineObject::Image { media, .. } = o else { return Err(CmdError::Disabled("not a picture".into())) };
let base = s.originals.get(&media).cloned().unwrap_or(media.clone());
adjust_media(s, &base, move |img| Ok(recolor(img, &mode, sat)))
})
.params(r#"{"mode": "grayscale|sepia|washout|blackAndWhite|saturation|tint", "saturation"?: 0..400}"#)
.when(has_picture),
@@ -98,10 +97,10 @@ pub fn specs() -> Vec<CommandSpec> {
// slider back and forth never fades what a previous pass already faded.
// (Combining with other pixel edits then re-setting transparency drops those
// edits; a full live-effect stack is future work.)
// The file's own transparency (`fx`, `a:alphaModFix`) is replaced too: it's baked
// into the pixels from here on.
let base = original_media(s)?;
adjust_media(s, &base, |fx: &mut PicFx| fx.transparency = 0, move |img| {
let (_, o) = selected(s).ok_or_else(|| CmdError::Disabled("no picture".into()))?;
let InlineObject::Image { media, .. } = o else { return Err(CmdError::Disabled("not a picture".into())) };
let base = s.originals.get(&media).cloned().unwrap_or(media.clone());
adjust_media(s, &base, move |img| {
let mut rgba = img.to_rgba8();
for px in rgba.pixels_mut() {
px.0[3] = (px.0[3] as f32 * (1.0 - t / 100.0)) as u8;
-103
View File
@@ -1,5 +1,4 @@
use super::*;
use wordcraft_doc::picfx::PicFx;
fn png(w: u32, h: u32) -> Vec<u8> {
let img = image::RgbaImage::from_fn(w, h, |x, y| {
@@ -455,108 +454,6 @@ fn transparency_is_absolute_from_the_original() {
assert_eq!(rgba(&s), orig, "reset still finds the original");
}
/// A picture opened with brightness, contrast and transparency from its file (`fx`, #1711,
/// #1766): Transparency and Corrections are absolute and replace the file's values instead of
/// stacking on them; Color leaves the file's brightness and contrast alone except Washout
/// (itself stored as brightness and contrast); Reset Picture discards every correction, the
/// file's included, as in Word.
#[test]
fn picture_edits_replace_the_file_corrections() {
let mut s = Session::new(wordcraft_doc::Document::new());
let data = super::super::insert::base64_encode(&png(20, 10));
s.run("insert.picture", &json!({"data": data})).unwrap();
let imported = PicFx::new(80_000, -80_000, 50_000);
let set_fx = |s: &mut Session, v: PicFx| {
with_obj(s, |o| {
if let InlineObject::Image { fx, .. } = o {
*fx = v;
}
})
.unwrap()
};
set_fx(&mut s, imported);
let state = |s: &Session| {
let (_, o) = selected(s).unwrap();
let InlineObject::Image { media, fx, .. } = o else { panic!("a picture") };
let bytes = s.doc.media.get(&media).cloned().unwrap();
(media, fx, image::load_from_memory(&bytes).unwrap().to_rgba8().pixels().map(|p| p.0).collect::<Vec<_>>())
};
let (orig_media, _, orig) = state(&s);
// Transparency 50%: 50% (not 75%), the file's transparency dropped, its lum kept.
s.run("picture.transparency", &json!({"percent": 50})).unwrap();
let (_, fx, px) = state(&s);
assert_eq!(fx, PicFx::new(80_000, -80_000, 0));
assert!(px.iter().all(|p| p[3] == 127));
// 0%: opaque, no `a:alphaModFix` left to write.
s.run("picture.transparency", &json!({"percent": 0})).unwrap();
let (_, fx, px) = state(&s);
assert_eq!((fx.transparency, px), (0, orig.clone()));
// Color other than Washout keeps the file's lum; Washout replaces it.
s.run("picture.color", &json!({"mode": "grayscale"})).unwrap();
assert_eq!(state(&s).1, PicFx::new(80_000, -80_000, 0));
s.run("picture.color", &json!({"mode": "saturation", "saturation": 100})).unwrap();
assert_eq!(state(&s).1, PicFx::new(80_000, -80_000, 0));
s.run("picture.color", &json!({"mode": "washout"})).unwrap();
assert_eq!(state(&s).1, PicFx::default());
// Corrections bake from the original and replace the file's lum.
set_fx(&mut s, imported);
s.run("picture.corrections", &json!({"brightness": 0})).unwrap();
let (_, fx, px) = state(&s);
assert_eq!((fx, px), (PicFx::new(0, 0, 50_000), orig.clone()));
// Reset: the imported bitmap, no corrections at all.
set_fx(&mut s, imported);
s.run("picture.reset", &json!({})).unwrap();
let (media, fx, px) = state(&s);
assert_eq!((media, fx, px), (orig_media, PicFx::default(), orig));
}
/// Two pictures showing the same media, one with Washout from its file and one without:
/// edits on both bake to the same bytes, and resetting the plain one leaves it without
/// corrections (it used to be able to pick up the other's).
#[test]
fn reset_never_borrows_another_pictures_corrections() {
let mut d = wordcraft_doc::Document::new();
let media = d.add_media(png(20, 10), "png");
let washout = PicFx::new(70_000, -70_000, 0);
let mut p = wordcraft_doc::Paragraph::new();
for fx in [PicFx::default(), washout] {
let pic = InlineObject::Image {
media: media.clone(),
w: 40.0,
h: 20.0,
alt: String::new(),
float: Default::default(),
crop: [0.0; 4],
fx,
ole: None,
};
p.insert_object(0, pic, &Default::default()).unwrap();
}
d.body = vec![wordcraft_doc::para_block(p)];
let mut s = Session::new(d);
let pick = |s: &mut Session, at: usize| {
s.run("select.range", &json!({"anchor": {"block": 0, "off": at}, "focus": {"block": 0, "off": at + 3}})).unwrap();
};
let fx_at = |s: &mut Session, at: usize| {
pick(s, at);
match selected(s).unwrap().1 {
InlineObject::Image { fx, media, .. } => (fx, media),
o => panic!("{o:?}"),
}
};
assert_eq!(fx_at(&mut s, 0).0, washout, "A");
assert_eq!(fx_at(&mut s, 3).0, PicFx::default(), "B");
for at in [0, 3] {
pick(&mut s, at);
s.run("picture.transparency", &json!({"percent": 30})).unwrap();
}
assert_eq!(fx_at(&mut s, 0).1, fx_at(&mut s, 3).1, "the same baked bytes");
pick(&mut s, 3);
s.run("picture.reset", &json!({})).unwrap();
assert_eq!(fx_at(&mut s, 3), (PicFx::default(), media.clone()));
assert_eq!(fx_at(&mut s, 0).0, washout, "A keeps its own until reset");
}
/// #2052: a picture style picked after another replaces it (Thick Frame then Simple Frame is
/// only the simple frame), a border replaces the border and keeps the style, an artistic
/// effect replaces the effect, and transparency 50% then 10% is 10%, as in Word.
+50 -1
View File
@@ -5,6 +5,7 @@
use serde_json::{Value, json};
use crate::{CmdError, CmdResult, CommandSpec, Prefs, Session, p};
use wordcraft_doc::odf_profile::{OdfPreset, OdfProfile};
/// Options panes, in the order the page lists them (the `pane` param of `file.options`).
pub const PANES: [&str; 8] = ["general", "display", "proofing", "save", "language", "accessibility", "advanced", "agents"];
@@ -22,7 +23,7 @@ pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("file.options", "Options", "File", options)
.params(
r#"{"pane"?: "general|display|proofing|save|language|accessibility|advanced|agents", "author"?: string, "initials"?: string, "units"?: "inches|centimeters|millimeters|points|picas|auto" (auto: the region's), "region"?: string (a locale tag like pl-PL, saved: the paper File › New starts on, the default units and the decimal separator; auto: the system's), "typingReplacesSelection"?: bool, "insertKeyOvertype"?: bool, "overtype"?: bool, "marks"?: {"tabs"?: bool, "spaces"?: bool, "paragraphs"?: bool, "hidden"?: bool}, "checkSpelling"?: bool, "markGrammar"?: bool, "ignoreUppercase"?: bool, "ignoreNumbers"?: bool, "ignoreInternet"?: bool, "dictionariesFolder"?: string (a folder of Hunspell .aff/.dic files searched first, saved; auto: the system locations only), "userTemplatesFolder"?: string (the folder File › New lists personal templates from, saved; auto: the app's own Templates folder; not a network folder), "addWords"?: [string], "removeWords"?: [string]} — with any setting it changes them and opens nothing; without, it opens File › Options (at `pane`). Returns the settings."#,
r#"{"pane"?: "general|display|proofing|save|language|accessibility|advanced|agents", "author"?: string, "initials"?: string, "units"?: "inches|centimeters|millimeters|points|picas|auto" (auto: the region's), "region"?: string (a locale tag like pl-PL, saved: the paper File › New starts on, the default units and the decimal separator; auto: the system's), "typingReplacesSelection"?: bool, "insertKeyOvertype"?: bool, "overtype"?: bool, "marks"?: {"tabs"?: bool, "spaces"?: bool, "paragraphs"?: bool, "hidden"?: bool}, "checkSpelling"?: bool, "markGrammar"?: bool, "ignoreUppercase"?: bool, "ignoreNumbers"?: bool, "ignoreInternet"?: bool, "dictionariesFolder"?: string (a folder of Hunspell .aff/.dic files searched first, saved; auto: the system locations only), "userTemplatesFolder"?: string (the folder File › New lists personal templates from, saved; auto: the app's own Templates folder; not a network folder), "addWords"?: [string], "removeWords"?: [string], "odf"?: {"preset"?: "word|libreoffice|custom" (word: Microsoft Word style, the default; libreoffice: LibreOffice style), "pageMargin"?: pt, "headerMinHeight"?: pt, "headerHeightIncludesSpacing"?: bool, "dynamicSpacing"?: bool, "tabsFromIndent"?: bool, "columnGap"?: pt, "shapeFill"?: "RRGGBB", "shapeLine"?: "RRGGBB", "hairline"?: pt, "pageFrameParagraphIsPage"?: bool, "inlineInvalidRelationIsBaseline"?: bool, "pictureLinesFitPictures"?: bool, "balanceSectionColumns"?: bool, "sumParagraphSpacing"?: bool, "widowControl"?: bool} (how OpenDocument files opened from now on fill in what their standard leaves open, docs/odf-interpretation.md; a value without a preset makes it custom)} — with any setting it changes them and opens nothing; without, it opens File › Options (at `pane`). Returns the settings."#,
)
.pure(),
CommandSpec::new("text.overtype", "Overtype", "File › Options › Advanced", |s, v| {
@@ -145,6 +146,7 @@ fn options(s: &mut Session, v: &Value) -> CmdResult {
Some(_) => return Err(CmdError::Params("`marks` must be an object of true/false".into())),
}
let check_spelling = bool_param(v, "checkSpelling")?;
let odf = odf_param(v, &s.prefs.odf)?;
let add = words_param(v, "addWords")?;
let remove = words_param(v, "removeWords")?;
@@ -157,6 +159,7 @@ fn options(s: &mut Session, v: &Value) -> CmdResult {
|| !flag_values.is_empty()
|| !mark_values.is_empty()
|| check_spelling.is_some()
|| odf.is_some()
|| !add.is_empty()
|| !remove.is_empty();
if let Some(name) = author {
@@ -188,6 +191,10 @@ fn options(s: &mut Session, v: &Value) -> CmdResult {
s.view.proofing = b;
s.prefs.check_spelling = b;
}
// ODF files opened from now on fill in what their standard leaves open this way.
if let Some(o) = odf {
s.prefs.odf = o;
}
for w in &add {
wordcraft_proof::add_word(w);
}
@@ -233,6 +240,9 @@ pub fn current(s: &Session) -> Value {
"dictionary": wordcraft_proof::user_dictionary(),
"dictionariesFolder": pr.dictionaries_folder,
"userTemplatesFolder": pr.user_templates_folder,
// How ODF files fill in what the standard leaves open: the chosen preset, the values
// that apply, and the custom values kept for when the preset is custom.
"odf": {"preset": pr.odf.preset, "effective": pr.odf.effective(), "custom": pr.odf},
"dictionaries": wordcraft_proof::installed_dictionaries()
.into_iter()
.map(|(lang, dic)| json!({"language": lang.tag(), "file": dic.map(|d| d.to_string_lossy().into_owned())}))
@@ -240,6 +250,45 @@ pub fn current(s: &Session) -> Value {
})
}
/// The `odf` setting: `{"preset"?: "word|libreoffice|custom" (word: Microsoft Word style, the default; libreoffice: LibreOffice style), …case values}` over `current`.
/// A case value given without a preset makes the profile custom; the values are kept whatever
/// the preset, so choosing custom again brings them back. Lengths are points, at most 22 inches;
/// colours `RRGGBB`.
fn odf_param(v: &Value, current: &OdfProfile) -> Result<Option<OdfProfile>, CmdError> {
let Some(o) = v.get("odf").filter(|o| !o.is_null()) else { return Ok(None) };
let obj = o.as_object().ok_or_else(|| CmdError::Params("`odf` must be an object".into()))?;
let mut merged = serde_json::to_value(current).map_err(|e| CmdError::Failed(e.to_string()))?;
let known: Vec<String> = merged.as_object().map(|m| m.keys().cloned().collect()).unwrap_or_default();
let mut values = false;
for (k, x) in obj {
if !known.contains(k) {
return Err(CmdError::Params(format!("`odf.{k}`: not a setting; one of {}", known.join(", "))));
}
if let Some(n) = x.as_f64().filter(|_| k != "preset")
&& !(n.is_finite() && (0.0..=f64::from(wordcraft_doc::odf_profile::MAX_LENGTH)).contains(&n))
{
return Err(CmdError::Params(format!("`odf.{k}`: a length from 0 to {} points", wordcraft_doc::odf_profile::MAX_LENGTH)));
}
let x = if k == "preset" {
let p = x.as_str().and_then(OdfPreset::from_id).ok_or_else(|| CmdError::Params("`odf.preset`: word, libreoffice or custom".into()))?;
json!(p)
} else {
values = true;
x.clone()
};
if let Some(m) = merged.as_object_mut() {
m.insert(k.clone(), x);
}
}
if values
&& !obj.contains_key("preset")
&& let Some(m) = merged.as_object_mut()
{
m.insert("preset".into(), json!(OdfPreset::Custom));
}
serde_json::from_value(merged).map(Some).map_err(|e| CmdError::Params(format!("`odf`: {e}")))
}
/// A folder setting (`dictionariesFolder`, `userTemplatesFolder`): `auto` (or nothing) forgets
/// it; otherwise an existing folder, given as an absolute path or one starting with `~/`. With
/// `local_only`, a network folder is refused before anything looks at it (#2852): even checking
+5 -33
View File
@@ -24,12 +24,12 @@ pub const DEFAULT_H: f32 = 252.0;
const LAYOUTS: &str = "basicList|process|cycle|hierarchy|pyramid|radial|matrix|venn";
const COLORS: &str = "accent1|accent2|accent3|accent4|accent5|accent6|colorful";
const ITEMS: &str = r#"[string | {"text": string, "level"?: 0-7, "color"?: RRGGBB}] (a string's leading tabs are its level)"#;
const ITEMS: &str = r#"[string | {"text": string, "level"?: 0-7}] (a string's leading tabs are its level)"#;
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("insert.smartArt", "SmartArt", "Insert › Illustrations", insert).params(
r#"{"layout"?: "basicList|process|cycle|hierarchy|pyramid|radial|matrix|venn", "items"?: [string | {"text": string, "level"?: 0-7, "color"?: RRGGBB}], "colors"?: "accent1…accent6|colorful", "width"?: pt, "height"?: pt} (no items: sample items for the layout)"#,
r#"{"layout"?: "basicList|process|cycle|hierarchy|pyramid|radial|matrix|venn", "items"?: [string | {"text": string, "level"?: 0-7}], "colors"?: "accent1…accent6|colorful", "width"?: pt, "height"?: pt} (no items: sample items for the layout)"#,
),
CommandSpec::new("smartArt.items", "Edit Items", "SmartArt Design › Create Graphic", |s, v| {
let items = items_of(v.get("items").ok_or_else(|| CmdError::Params(format!("`items` is required: {ITEMS}")))?)?;
@@ -38,7 +38,7 @@ pub fn specs() -> Vec<CommandSpec> {
Ok(())
})
})
.params(r#"{"items": [string | {"text": string, "level"?: 0-7, "color"?: RRGGBB}]} (replaces the outline)"#)
.params(r#"{"items": [string | {"text": string, "level"?: 0-7}]} (replaces the outline)"#)
.when(has_smart_art),
CommandSpec::new("smartArt.textPane", "Text Pane", "SmartArt Design › Create Graphic", |s, v| {
let on = p::bool(v, "value").unwrap_or(!s.view.smart_art_pane);
@@ -124,36 +124,14 @@ pub fn specs() -> Vec<CommandSpec> {
let colors = colors_of(v)?.ok_or_else(|| CmdError::Params(format!("`variant` is required: {COLORS}")))?;
edit(s, move |g| {
g.colors = colors;
g.clear_custom_colors();
Ok(())
})
})
.params(r#"{"variant": "accent1|accent2|accent3|accent4|accent5|accent6|colorful"} (the items' own colours are dropped)"#)
.when(has_smart_art),
CommandSpec::new("smartArt.itemColor", "Item Color", "SmartArt Design › SmartArt Styles", |s, v| {
let i = index(v, "index")?;
let color = match v.get("color") {
None | Some(Value::Null) => None,
Some(Value::String(c)) => Some(Rgb::parse(c).ok_or_else(|| CmdError::Params("`color`: RRGGBB".into()))?),
Some(_) => return Err(CmdError::Params("`color`: RRGGBB or null".into())),
};
edit(s, move |g| {
let i = item_at(g, i)?;
if g.style_accent(i).is_none() {
return Err(CmdError::Failed("this item has no shape of its own: it is text in another item's shape".into()));
}
if let Some(it) = g.items.get_mut(i) {
it.color = color;
}
Ok(())
})
})
.params(r#"{"index"?: item index (default: the last), "color"?: "RRGGBB" (none: the style's colour)}"#)
.params(r#"{"variant": "accent1|accent2|accent3|accent4|accent5|accent6|colorful"}"#)
.when(has_smart_art),
CommandSpec::new("smartArt.reset", "Reset Graphic", "SmartArt Design › Reset", |s, _| {
edit(s, |g| {
g.colors = SmartArtColors::default();
g.clear_custom_colors();
Ok(())
})
})
@@ -257,13 +235,7 @@ fn items_of(v: &Value) -> Result<Vec<SmartArtItem>, CmdError> {
Some(other) => clean_text(&other.to_string()),
};
let level = p::u64(it, "level").unwrap_or(0).min(u64::from(MAX_LEVEL));
let mut item = SmartArtItem::new(text, u8::try_from(level).unwrap_or(MAX_LEVEL));
item.color = match it.get("color") {
None | Some(Value::Null) => None,
Some(Value::String(c)) => Some(Rgb::parse(c).ok_or_else(|| CmdError::Params("`color`: RRGGBB".into()))?),
Some(_) => return Err(CmdError::Params("`color`: RRGGBB or null".into())),
};
Ok(item)
Ok(SmartArtItem::new(text, u8::try_from(level).unwrap_or(MAX_LEVEL)))
}
_ => Err(CmdError::Params(format!("`items`: {ITEMS}"))),
})
+41 -16
View File
@@ -1,6 +1,7 @@
//! File format dispatch by extension. Format crates plug in here.
use wordcraft_doc::Document;
use wordcraft_doc::odf_profile::OdfProfile;
#[cfg(not(target_arch = "wasm32"))]
mod safe_save;
@@ -141,13 +142,13 @@ pub fn can_encrypt(name: &str) -> bool {
/// Open a password-protected ODT package (ODF 1.3 Part 2 §3.4). The errors for a missing or
/// wrong password are the `.docx` reader's texts, so [`needs_password`] and [`wrong_password`]
/// recognise them.
fn open_encrypted_odt(bytes: &[u8], password: Option<&str>, page: &wordcraft_doc::SectionProps) -> Result<Document, String> {
fn open_encrypted_odt(bytes: &[u8], password: Option<&str>, page: &wordcraft_doc::SectionProps, odf: &OdfProfile) -> Result<Document, String> {
let pw = password.ok_or_else(|| format!("ODT: {}", wordcraft_docx::PASSWORD_REQUIRED))?;
let plain = wordcraft_formats::odt::decrypt(bytes, pw).map_err(|e| match e {
wordcraft_formats::odt::CryptError::WrongPassword => format!("ODT: {}", wordcraft_docx::WRONG_PASSWORD),
other => other.to_string(),
})?;
match crate::io_ext::open("odt", &plain, page) {
match crate::io_ext::open("odt", &plain, page, odf) {
Some(r) => r,
None => Err("ODT: no reader".into()),
}
@@ -187,7 +188,7 @@ fn open_binary_doc(bytes: &[u8], password: Option<&str>) -> Result<(Document, bo
/// whether the file was encrypted. Without the password needed the error satisfies
/// [`needs_password`]; with a wrong one, [`wrong_password`].
pub fn open_bytes_with(name: &str, bytes: &[u8], password: Option<&str>) -> Result<(Document, bool), String> {
open_bytes_in(name, bytes, password, &wordcraft_geom::Region::default())
open_bytes_in(name, bytes, password, &wordcraft_geom::Region::default(), &OdfProfile::default())
}
/// The page a file that gives no page size (plain text, Markdown, HTML) opens on: `region`'s
@@ -198,10 +199,17 @@ pub fn region_page(region: &wordcraft_geom::Region) -> wordcraft_doc::SectionPro
}
/// [`open_bytes_with`] in `region`: a file that gives no page size opens on the region's paper
/// (see [`region_page`]); a document with its own page size keeps it.
pub fn open_bytes_in(name: &str, bytes: &[u8], password: Option<&str>, region: &wordcraft_geom::Region) -> Result<(Document, bool), String> {
/// (see [`region_page`]); a document with its own page size keeps it. An OpenDocument file fills
/// in what its standard leaves open as `odf` says (the user's choice, [`crate::Prefs::odf`]).
pub fn open_bytes_in(
name: &str,
bytes: &[u8],
password: Option<&str>,
region: &wordcraft_geom::Region,
odf: &OdfProfile,
) -> Result<(Document, bool), String> {
let page = region_page(region);
let (doc, encrypted) = read_bytes(name, bytes, password, &page)?;
let (doc, encrypted) = read_bytes(name, bytes, password, &page, odf)?;
load_embedded_fonts(&doc);
Ok((doc, encrypted))
}
@@ -219,7 +227,13 @@ fn load_embedded_fonts(doc: &Document) {
}
}
fn read_bytes(name: &str, bytes: &[u8], password: Option<&str>, page: &wordcraft_doc::SectionProps) -> Result<(Document, bool), String> {
fn read_bytes(
name: &str,
bytes: &[u8],
password: Option<&str>,
page: &wordcraft_doc::SectionProps,
odf: &OdfProfile,
) -> Result<(Document, bool), String> {
let ext = ext_of(name);
if matches!(ext.as_str(), "docx" | "docm" | "dotx" | "dotm" | "doc" | "dot") && wordcraft_docx::is_encrypted(bytes) {
let mut doc = read_docx(bytes, password).map_err(|e| e.to_string())?;
@@ -227,7 +241,7 @@ fn read_bytes(name: &str, bytes: &[u8], password: Option<&str>, page: &wordcraft
return Ok((doc, true));
}
if (ext == "odt" || ext == "ott") && wordcraft_formats::odt::is_encrypted(bytes) {
let mut doc = open_encrypted_odt(bytes, password, page)?;
let mut doc = open_encrypted_odt(bytes, password, page, odf)?;
opened(&mut doc);
return Ok((doc, true));
}
@@ -247,7 +261,7 @@ fn read_bytes(name: &str, bytes: &[u8], password: Option<&str>, page: &wordcraft
d.normalize_runs();
d
}
other => match crate::io_ext::open(other, bytes, page) {
other => match crate::io_ext::open(other, bytes, page, odf) {
Some(r) => r?,
None => return open_by_content(name, other, bytes, password, page),
},
@@ -421,10 +435,11 @@ fn open_by_content(
} else if is_word_xml(bytes) {
(wordcraft_docx::read_xml(bytes).map_err(|e| format!("{name}: {e}"))?, false)
} else if matches!(ext, "doc" | "dot" | "docx" | "docm" | "dotx" | "dotm" | "rtf") && looks_like_web_page(bytes) {
let opened = crate::io_ext::open("html", bytes, page).unwrap_or_else(|| Err(format!("{name}: can't open web pages")));
let opened = crate::io_ext::open("html", bytes, page, &OdfProfile::default()).unwrap_or_else(|| Err(format!("{name}: can't open web pages")));
(opened?, false)
} else if matches!(ext, "doc" | "dot" | "docx" | "docm" | "dotx" | "dotm" | "rtf") && wordcraft_formats::mht::is_mht(bytes) {
let opened = crate::io_ext::open("mht", bytes, page).unwrap_or_else(|| Err(format!("{name}: can't open web archives")));
let opened =
crate::io_ext::open("mht", bytes, page, &OdfProfile::default()).unwrap_or_else(|| Err(format!("{name}: can't open web archives")));
(opened?, false)
} else if wordcraft_formats::txt::is_text(bytes) {
(wordcraft_formats::txt::import_on(bytes, page), false)
@@ -477,12 +492,17 @@ pub fn open_path(path: &std::path::Path) -> Result<Document, String> {
/// [`open_path`] for a file that may be password-protected (see [`open_bytes_with`]).
#[cfg(not(target_arch = "wasm32"))]
pub fn open_path_with(path: &std::path::Path, password: Option<&str>) -> Result<(Document, bool), String> {
open_path_in(path, password, &wordcraft_geom::Region::default())
open_path_in(path, password, &wordcraft_geom::Region::default(), &OdfProfile::default())
}
/// [`open_path_with`] in `region` (see [`open_bytes_in`]).
/// [`open_path_with`] in `region`, OpenDocument files as `odf` says (see [`open_bytes_in`]).
#[cfg(not(target_arch = "wasm32"))]
pub fn open_path_in(path: &std::path::Path, password: Option<&str>, region: &wordcraft_geom::Region) -> Result<(Document, bool), String> {
pub fn open_path_in(
path: &std::path::Path,
password: Option<&str>,
region: &wordcraft_geom::Region,
odf: &OdfProfile,
) -> Result<(Document, bool), String> {
let bytes = read_document(path)?;
let name = path.to_string_lossy();
if matches!(ext_of(&name).as_str(), "html" | "htm" | "xhtml") {
@@ -494,7 +514,7 @@ pub fn open_path_in(path: &std::path::Path, password: Option<&str>, region: &wor
opened(&mut doc);
return Ok((doc, false));
}
open_bytes_in(&name, &bytes, password, region)
open_bytes_in(&name, &bytes, password, region, odf)
}
/// Largest document file opened by path.
@@ -831,7 +851,12 @@ pub fn open_path_with(path: &std::path::Path, _password: Option<&str>) -> Result
}
#[cfg(target_arch = "wasm32")]
pub fn open_path_in(path: &std::path::Path, password: Option<&str>, _region: &wordcraft_geom::Region) -> Result<(Document, bool), String> {
pub fn open_path_in(
path: &std::path::Path,
password: Option<&str>,
_region: &wordcraft_geom::Region,
_odf: &OdfProfile,
) -> Result<(Document, bool), String> {
open_path_with(path, password)
}
+7 -2
View File
@@ -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);
+12
View File
@@ -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
View File
@@ -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();
+1 -8
View File
@@ -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,
+1 -2
View File
@@ -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(),
+13 -2
View File
@@ -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),
+5 -8
View File
@@ -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.
+1 -10
View File
@@ -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.
+2 -2
View File
@@ -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};
+8 -3
View File
@@ -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();
+9 -3
View File
@@ -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);
+9 -4
View File
@@ -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
+21 -30
View File
@@ -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() {
+58 -48
View File
@@ -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;
}
}
+1 -1
View File
@@ -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).
+3
View File
@@ -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,
+18 -5
View File
@@ -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)
+11 -3
View File
@@ -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]
+1 -1
View File
@@ -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);
}
+34 -19
View File
@@ -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""#));
+26 -8
View File
@@ -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]
@@ -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);
}
}
+1 -1
View File
@@ -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),
+21 -27
View File
@@ -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);
}
-9
View File
@@ -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",
}
}
-31
View File
@@ -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);
}
+3 -12
View File
@@ -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,
+3 -21
View File
@@ -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.
-3
View File
@@ -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"
+3 -7
View File
@@ -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]
-3
View File
@@ -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,
+5 -13
View File
@@ -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,
+7 -21
View File
@@ -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));
+2 -2
View File
@@ -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 };
+1 -9
View File
@@ -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.
+18 -13
View File
@@ -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 {
-2
View File
@@ -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