ODT: remove the display settings; one choice for each open case (#5609)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aleksander Helgaker
2026-11-04 23:08:09 +01:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 78d71fa19c
commit 650a97b623
40 changed files with 324 additions and 1043 deletions
+3 -1
View File
File diff suppressed because one or more lines are too long
+1 -32
View File
@@ -68,10 +68,6 @@ 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).
@@ -90,18 +86,11 @@ 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, &odf)?;
let (doc, encrypted) = wordcraft_engine::io::open_path_in(path, password, &region)?;
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);
@@ -183,24 +172,6 @@ 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)
@@ -533,8 +504,6 @@ 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,21 +543,3 @@ fn script_parse_resolves_paths_against_the_base() {
assert_eq!(s.save, vec!["o.docx".to_string()]);
assert_eq!(s.commands.len(), 1);
}
/// `WORDCRAFT_ODF` names a display style, and a session opened in one keeps it, so a `run`
/// script's later `file.open` reads OpenDocument files the same way.
#[test]
fn the_odf_style_is_kept_by_the_session() {
use wordcraft_doc::odf_profile::OdfProfile;
assert_eq!(odf_from(None), Ok(OdfProfile::default()));
assert_eq!(odf_from(Some("libreoffice".into())), Ok(OdfProfile::libreoffice()));
assert_eq!(odf_from(Some("word".into())), Ok(OdfProfile::word()));
assert!(odf_from(Some("custom".into())).is_err());
let dir = std::env::temp_dir().join(format!("wc-cli-odf-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("a.txt");
std::fs::write(&path, "Hello").unwrap();
let s = open_in(&path, None, OdfProfile::libreoffice()).unwrap();
assert_eq!(s.prefs.odf, OdfProfile::libreoffice());
let _ = std::fs::remove_dir_all(&dir);
}
+1 -2
View File
@@ -39,7 +39,6 @@ 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;
@@ -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, PagePicture, Settings, Source,
COMPAT_MODE_CURRENT, CoreProps, CustomProp, DEFAULT_GRID, DEFAULT_HYPHENATION_ZONE, LEGACY_COMPAT_MODE, OdfLayout, PagePicture, Settings, Source,
THEME_COLORS, Watermark, Zoom, ZoomFit,
};
pub use text_walk::count_words;
-255
View File
@@ -1,255 +0,0 @@
//! 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);
}
}
+15 -3
View File
@@ -167,10 +167,22 @@ 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, with the choices it leaves open made as the user's profile made them
/// (see [`crate::odf_profile`]).
/// it is laid out.
#[serde(skip_serializing_if = "Option::is_none")]
pub odf: Option<crate::odf_profile::OdfLayout>,
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,
}
/// A saved zoom: a percentage, or a fit mode (the percentage it last came to).
+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(), &s.prefs.odf).map_err(CmdError::Failed)?
crate::io::open_bytes_in(path, &bytes, password, &s.region()).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(), &s.prefs.odf).map_err(CmdError::Failed)?
crate::io::open_path_in(std::path::Path::new(path), password, &s.region()).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();
+1 -50
View File
@@ -5,7 +5,6 @@
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"];
@@ -23,7 +22,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], "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."#,
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."#,
)
.pure(),
CommandSpec::new("text.overtype", "Overtype", "File › Options › Advanced", |s, v| {
@@ -146,7 +145,6 @@ 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")?;
@@ -159,7 +157,6 @@ 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 {
@@ -191,10 +188,6 @@ 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);
}
@@ -240,9 +233,6 @@ 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())}))
@@ -250,45 +240,6 @@ 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
+16 -41
View File
@@ -1,7 +1,6 @@
//! 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;
@@ -142,13 +141,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, odf: &OdfProfile) -> Result<Document, String> {
fn open_encrypted_odt(bytes: &[u8], password: Option<&str>, page: &wordcraft_doc::SectionProps) -> 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, odf) {
match crate::io_ext::open("odt", &plain, page) {
Some(r) => r,
None => Err("ODT: no reader".into()),
}
@@ -188,7 +187,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(), &OdfProfile::default())
open_bytes_in(name, bytes, password, &wordcraft_geom::Region::default())
}
/// The page a file that gives no page size (plain text, Markdown, HTML) opens on: `region`'s
@@ -199,17 +198,10 @@ 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. 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> {
/// (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> {
let page = region_page(region);
let (doc, encrypted) = read_bytes(name, bytes, password, &page, odf)?;
let (doc, encrypted) = read_bytes(name, bytes, password, &page)?;
load_embedded_fonts(&doc);
Ok((doc, encrypted))
}
@@ -227,13 +219,7 @@ fn load_embedded_fonts(doc: &Document) {
}
}
fn read_bytes(
name: &str,
bytes: &[u8],
password: Option<&str>,
page: &wordcraft_doc::SectionProps,
odf: &OdfProfile,
) -> Result<(Document, bool), String> {
fn read_bytes(name: &str, bytes: &[u8], password: Option<&str>, page: &wordcraft_doc::SectionProps) -> 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())?;
@@ -241,7 +227,7 @@ fn read_bytes(
return Ok((doc, true));
}
if (ext == "odt" || ext == "ott") && wordcraft_formats::odt::is_encrypted(bytes) {
let mut doc = open_encrypted_odt(bytes, password, page, odf)?;
let mut doc = open_encrypted_odt(bytes, password, page)?;
opened(&mut doc);
return Ok((doc, true));
}
@@ -261,7 +247,7 @@ fn read_bytes(
d.normalize_runs();
d
}
other => match crate::io_ext::open(other, bytes, page, odf) {
other => match crate::io_ext::open(other, bytes, page) {
Some(r) => r?,
None => return open_by_content(name, other, bytes, password, page),
},
@@ -435,11 +421,10 @@ 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, &OdfProfile::default()).unwrap_or_else(|| Err(format!("{name}: can't open web pages")));
let opened = crate::io_ext::open("html", bytes, page).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, &OdfProfile::default()).unwrap_or_else(|| Err(format!("{name}: can't open web archives")));
let opened = crate::io_ext::open("mht", bytes, page).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)
@@ -492,17 +477,12 @@ 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(), &OdfProfile::default())
open_path_in(path, password, &wordcraft_geom::Region::default())
}
/// [`open_path_with`] in `region`, OpenDocument files as `odf` says (see [`open_bytes_in`]).
/// [`open_path_with`] in `region` (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,
odf: &OdfProfile,
) -> Result<(Document, bool), String> {
pub fn open_path_in(path: &std::path::Path, password: Option<&str>, region: &wordcraft_geom::Region) -> 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") {
@@ -514,7 +494,7 @@ pub fn open_path_in(
opened(&mut doc);
return Ok((doc, false));
}
open_bytes_in(&name, &bytes, password, region, odf)
open_bytes_in(&name, &bytes, password, region)
}
/// Largest document file opened by path.
@@ -851,12 +831,7 @@ 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,
_odf: &OdfProfile,
) -> Result<(Document, bool), String> {
pub fn open_path_in(path: &std::path::Path, password: Option<&str>, _region: &wordcraft_geom::Region) -> Result<(Document, bool), String> {
open_path_with(path, password)
}
+2 -7
View File
@@ -4,19 +4,14 @@ 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,
odf: &wordcraft_doc::odf_profile::OdfProfile,
) -> Option<Result<Document, String>> {
pub fn open(ext: &str, bytes: &[u8], page: &wordcraft_doc::SectionProps) -> 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_with(other, bytes, page, odf).map(|r| {
other => wordcraft_formats::import_on(other, bytes, page).map(|r| {
r.map(|mut doc| {
crate::io::fit_pictures(&mut doc);
draw_charts(&mut doc);
-12
View File
@@ -59,17 +59,6 @@ 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 {
@@ -95,7 +84,6 @@ impl Default for Prefs {
detect_language: true,
default_language: None,
autocorrect: Default::default(),
odf: Default::default(),
}
}
}
+7 -47
View File
@@ -12023,54 +12023,14 @@ fn emphasis_marks_are_set_and_drawn() {
assert!(s.run("format.emphasisMark", &json!({"mark": "sparkles"})).is_err());
}
/// 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.
/// 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.
#[test]
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");
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);
}
#[test]
+2 -13
View File
@@ -56,21 +56,10 @@ 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_with(bytes, page, odf)),
"fodt" => return Some(odt::import_flat_with(bytes, page, odf)),
"odt" | "ott" => return Some(odt::import_on(bytes, page)),
"fodt" => return Some(odt::import_flat_on(bytes, page)),
"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),
+1 -2
View File
@@ -164,8 +164,7 @@ 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, or that was opened with a profile saying so): [`Para::extra`]'s tab stops then
/// are too.
/// file says 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)
}
+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_with};
pub use flat::{export_flat, import_flat, import_flat_on};
use import::Pages;
#[cfg(test)]
use import::{Pictures, apply_page_layout, read_entry, read_meta};
pub use import::{import, import_on, import_with, parse};
pub use import::{import, import_on, parse};
#[cfg(test)]
use limits::{MAX_EXTRA_SPACES, MAX_XML_DEPTH};
pub use package::{TEMPLATE_MIME, TEXT_MIME, export, export_template};
+3 -8
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.styles.defs.profile));
self.frame_float = Some(super::frames::place(a, w.unwrap_or(0.0), h.unwrap_or(0.0), &g, self.text_size()));
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,12 +1343,7 @@ 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)).or(self
.styles
.defs
.profile
.balance_section_columns
.then_some(true));
let balance = get(a, "text:style-name").and_then(|s| self.styles.section_balance(s));
let next = SectionProps {
start: SectionStart::Continuous,
page_num_start: None,
@@ -1407,7 +1402,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(), &self.styles.defs.profile).y;
let raise = super::frames::place(a, w, h, &g, self.text_size()).y;
control.form_raise = (raise.is_finite() && raise != 0.0).then_some(raise);
}
let fmt = self.cur_fmt();
+3 -9
View File
@@ -338,13 +338,7 @@ 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,
&self.styles.defs.profile,
);
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 = (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 });
@@ -362,11 +356,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, &self.styles.defs.profile);
let mut f = crate::odt::frames::float_of(get(a, "text:anchor-type"), at(x).as_deref(), at(y).as_deref(), &g);
f.z = crate::odt::frames::z_of(a);
f
} else {
crate::odt::frames::place(a, w, h, &g, self.text_size(), &self.styles.defs.profile)
crate::odt::frames::place(a, w, h, &g, self.text_size())
};
(float.flip_h, float.flip_v) = (flip_h, flip_v);
self.shape_flip = (false, false);
+4 -9
View File
@@ -188,16 +188,11 @@ 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_with(bytes, &wordcraft_doc::SectionProps::default(), &wordcraft_doc::odf_profile::OdfProfile::default())
import_flat_on(bytes, &wordcraft_doc::SectionProps::default())
}
/// [`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> {
/// [`import_flat`] onto `page` (see [`super::import_on`]).
pub fn import_flat_on(bytes: &[u8], page: &wordcraft_doc::SectionProps) -> 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")?;
@@ -221,7 +216,7 @@ pub fn import_flat_with(
z.write_all(data).map_err(|e| format!("FODT: {e}"))?;
}
let package = z.finish().map_err(|e| format!("FODT: {e}"))?.into_inner();
super::import_with(&package, page, profile)
super::import_on(&package, page)
}
/// Save `doc` as flat OpenDocument text: its package's files in one `office:document`, the
+30 -21
View File
@@ -10,7 +10,6 @@ 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;
@@ -18,6 +17,18 @@ 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)]
@@ -244,24 +255,23 @@ 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 the
/// profile'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 WordCraft'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(profile.shape_fill));
g.fill_color = g.fill_color.or(Some(SHAPE_FILL));
g.fill_with(Some("solid"), defs)
};
let line = match self.stroke.as_deref() {
Some("none") => None,
_ => Some((self.stroke_color.unwrap_or(profile.shape_line), self.stroke_width.filter(|w| *w > 0.0).unwrap_or(profile.hairline))),
_ => Some((self.stroke_color.unwrap_or(SHAPE_LINE), self.stroke_width.filter(|w| *w > 0.0).unwrap_or(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(profile.hairline, |l| l.1), defs);
let (dash, arrows) = self.stroke_extra(line.map_or(HAIRLINE, |l| l.1), defs);
BoxLook {
fill,
line,
@@ -283,8 +293,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(defs.profile.hairline);
(self.stroke_color.unwrap_or(defs.profile.shape_line), width)
let width = self.stroke_width.filter(|w| *w > 0.0).unwrap_or(HAIRLINE);
(self.stroke_color.unwrap_or(SHAPE_LINE), width)
})
};
BoxLook {
@@ -308,7 +318,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, profile: &OdfProfile) -> Float {
pub(super) fn float_of(anchor: Option<&str>, x: Option<&str>, y: Option<&str>, g: &Graphic) -> 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()));
@@ -339,12 +349,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): its content relations are the
// page's text area, its own the page or the text area as the profile says.
// A page has no paragraph (§20.299: no such combination): relations to one are the
// page's text area.
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 && !profile.page_frame_paragraph_is_page => Anchor::Margin,
Some("paragraph" | "char") if page => Anchor::Margin,
Some("paragraph-content") => Anchor::Column,
Some("char") if !page => Anchor::Character,
Some(_) | None if page => Anchor::Page,
@@ -355,7 +365,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 && !profile.page_frame_paragraph_is_page => Anchor::Margin,
Some("paragraph" | "char" | "text" | "line" | "baseline") if page => Anchor::Margin,
Some("line") | Some("char") | Some("text") | Some("baseline") if !page => Anchor::Line,
Some(_) | None if page => Anchor::Page,
Some(_) => Anchor::Paragraph,
@@ -396,19 +406,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, profile: &OdfProfile) -> Float {
pub(super) fn place(a: &Attrs, w: f32, h: f32, g: &Graphic, size: f32) -> 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, profile);
let mut f = float_of(get(a, "text:anchor-type"), x.as_deref(), y.as_deref(), g);
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, profile);
f.y = inline_raise(g, y.as_deref().and_then(length), h, size);
}
f
}
@@ -433,7 +443,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, profile: &OdfProfile) -> f32 {
fn inline_raise(g: &Graphic, y: Option<f32>, h: f32, size: f32) -> f32 {
if !h.is_finite() || g.vrel.as_deref() == Some("line") {
return 0.0;
}
@@ -449,9 +459,8 @@ fn inline_raise(g: &Graphic, y: Option<f32>, h: f32, size: f32, profile: &OdfPro
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): 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 {
// a parent style for anchored frames): it stands on the baseline.
if !matches!(g.vrel.as_deref(), None | Some("baseline")) {
return 0.0;
}
let raise = match g.vpos.as_deref() {
+48 -58
View File
@@ -4,7 +4,6 @@ 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()?;
@@ -422,7 +421,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(), &OdfProfile::default()).map(|p| (p.flow, p.last))
parse_tracked(bytes, default_paper()).map(|p| (p.flow, p.last))
}
/// The paragraphs kept as read that their links tie together (see `model::Group`), in `blocks`:
@@ -706,7 +705,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), profile: &OdfProfile) -> Result<Parsed, String> {
fn parse_tracked(bytes: &[u8], paper: (f32, f32)) -> 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") {
@@ -725,34 +724,24 @@ fn parse_tracked(bytes: &[u8], paper: (f32, f32), profile: &OdfProfile) -> Resul
None if styles_xml.is_some() => EMPTY_CONTENT.as_bytes().to_vec(),
None => return Err("ODT: content.xml is missing".into()),
};
let profile = profile.effective();
let mut styles = Styles::with_profile(&profile);
let mut styles = Styles::default();
let mut pages = Pages::default();
if let Some(s) = &styles_xml {
styles.read(s, false);
pages = read_pages(s, paper, &profile);
pages = read_pages(s, paper);
}
// No page layout at all (no styles.xml, or no master page): the default paper, with the
// profile's margins.
// No page layout at all (no styles.xml, or no master page).
if pages.first.is_none() {
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()
});
pages.first = Some(unstated_page(paper));
}
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 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);
// 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);
// How the space between two paragraphs adds up: as LibreOffice's `AddParaTableSpacing` says
// (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);
// (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);
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();
@@ -887,12 +876,6 @@ 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,
@@ -927,7 +910,7 @@ pub fn import_with(bytes: &[u8], page: &SectionProps, profile: &OdfProfile) -> R
kept_defs,
kept_files,
section_masters,
} = parse_tracked(bytes, (page.page_w, page.page_h), profile)?;
} = parse_tracked(bytes, (page.page_w, page.page_h))?;
let mut doc = model::to_doc(&flow);
kept.store(&mut doc);
// Elements can't hold a NUL: it parts them.
@@ -953,8 +936,8 @@ pub fn import_with(bytes: &[u8], page: &SectionProps, profile: &OdfProfile) -> R
doc.settings.default_tab = t;
}
doc.settings.page_color = page_doc.page_color;
// 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() });
// 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 });
doc.settings.sum_paragraph_spacing = sum_spacing;
// The page background picture (Design › Page Color › Fill Effects › Picture).
if let Some((bytes, fill)) = page_image
@@ -972,8 +955,8 @@ pub fn import_with(bytes: &[u8], page: &SectionProps, profile: &OdfProfile) -> R
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, as the profile says (`widowControl`).
d.widow_control = Some(widows.unwrap_or(profile.widow_control));
// 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));
for (kind, look) in &looks {
apply_look(&mut doc, *kind, look);
}
@@ -1080,23 +1063,24 @@ 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 (the profile's
/// [`OdfProfile::header_min_height`]).
/// Its height (`fo:min-height`), when stated: the standard gives no default (see
/// [`Band::room`]).
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 (the profile's
/// [`OdfProfile::dynamic_spacing`]).
/// as Word does), instead of keeping it below; `None` when unstated (§20.264 has no default;
/// see [`Band::room`]).
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, 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 };
/// 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 };
(room, gap)
}
}
@@ -1113,7 +1097,22 @@ struct Layout {
footer: Band,
}
fn read_pages(xml: &[u8], paper: (f32, f32), profile: &OdfProfile) -> Pages {
/// 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 {
let mut layouts: HashMap<String, Layout> = HashMap::new();
let mut cur: Option<String> = None;
// Inside a layout's footer style (else its header style).
@@ -1176,7 +1175,7 @@ fn read_pages(xml: &[u8], paper: (f32, f32), profile: &OdfProfile) -> 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, profile.column_gap));
l.columns = Some(columns_of(&a));
}
}
"column" if q.starts_with("style:") => {
@@ -1225,17 +1224,8 @@ fn read_pages(xml: &[u8], paper: (f32, f32), profile: &OdfProfile) -> 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 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()
};
// What a layout leaves unsaid: the default paper, and no margins.
let mut sec = unstated_page(paper);
apply_layout(a, &mut sec);
sec.line_numbers.clone_from(&lines);
if pages.first.is_none() {
@@ -1245,17 +1235,17 @@ fn read_pages(xml: &[u8], paper: (f32, f32), profile: &OdfProfile) -> 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: the profile's (see `Band::room`).
// standard leaves open (see `Band::room`).
let limit = sec.page_w.max(sec.page_h);
if header {
let (room, gap) = hb.room(profile);
let (room, gap) = hb.room();
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(profile);
let (room, gap) = fb.room();
sec.footer = sec.margin_bottom;
sec.margin_bottom = (sec.margin_bottom + room).min(limit);
sec.footer_gap = gap;
@@ -1300,7 +1290,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(), &OdfProfile::default()).first {
if let Some(first) = read_pages(xml, default_paper()).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: see `OdfProfile::widow_control`).
// when unsaid, and readers differ: docs/odf-interpretation.md).
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,9 +259,6 @@ 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,
+5 -18
View File
@@ -262,8 +262,6 @@ 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`.
@@ -636,8 +634,7 @@ 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, 0.0));
s.column_gap_stated = get(a, "fo:column-gap").is_some();
s.columns = Some(columns_of(a));
}
"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) {
@@ -714,9 +711,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
/// `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() };
/// [`Columns::default`]'s half inch, Word's.
pub(super) fn columns_of(a: &Attrs) -> Columns {
let mut c = 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;
}
@@ -1298,10 +1295,7 @@ 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)| {
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))
})
self.chain(name).iter().find_map(|(_, s)| s.columns.clone().map(|c| with_widths(c, &s.column_specs, tw)))
}
/// Whether a section style's columns are balanced at its end, when it says (the attribute's
@@ -1311,13 +1305,6 @@ 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)
+3 -11
View File
@@ -1,10 +1,4 @@
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() {
@@ -304,7 +298,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 (the standard's, whatever the profile).
// A margin left unsaid is XSL's initial value, 0.
assert_eq!((sec.page_w, sec.page_h, sec.margin_left, sec.margin_top), (720.0, 360.0, 72.0, 0.0));
}
@@ -1337,16 +1331,14 @@ 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: the profile's gap
// (the standard gives none): WordCraft's half inch, LibreOffice's none.
// Equal shares: equal columns with their gap. No gap stated and no shares: WordCraft's half
// inch (the standard gives 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,7 +34,6 @@ mod para_borders;
mod parts;
mod placeholders;
mod polygons;
mod profile;
mod rotation;
mod rtl;
mod ruby;
@@ -47,3 +46,4 @@ mod tables;
mod tabs;
mod text_boxes;
mod text_direction;
mod unstated;
+19 -34
View File
@@ -10,16 +10,11 @@ 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::tests::import_as(&zip_of(&[("content.xml", content.as_bytes()), ("Pictures/a.png", &png)]), profile)
crate::odt::import(&zip_of(&[("content.xml", content.as_bytes()), ("Pictures/a.png", &png)])).unwrap()
}
fn float(d: &Document) -> Float {
@@ -109,7 +104,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): LibreOffice places it from the baseline anyway, WordCraft stands it on it.
// define it): it stands on the baseline. Placed against the baseline, it is raised.
let pic = {
let img = image::RgbaImage::from_fn(4, 4, |_, _| image::Rgba([10, 20, 30, 255]));
let mut png = Vec::new();
@@ -131,41 +126,31 @@ 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::tests::import_as(&pkg, &wordcraft_doc::odf_profile::OdfProfile::libreoffice());
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();
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_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());
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""#,
));
assert_eq!((f.h_rel, f.v_rel), (Anchor::Margin, Anchor::Margin));
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());
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""#,
));
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""#));
+8 -26
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: LibreOffice's does (a 2 cm header with 1 cm spacing puts the text 2 cm below
// the margin), WordCraft's adds it.
// doesn't say: WordCraft adds it (a 2 cm header with 1 cm spacing puts the text 3 cm below
// the margin).
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,10 +130,7 @@ 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);
@@ -148,43 +145,30 @@ 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 is the profile's: both presets keep it; a custom profile may grow into it.
// doesn't say keeps 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 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 d = import(&crate::odt::tests::package(&content, Some(&styles_with_header(hf)))).unwrap();
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 = 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:?}");
assert!(near(back.header_gap, cm(0.25)) && near(back.footer_gap, cm(0.5)) && near(back.margin_top, s.margin_top), "{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 = crate::odt::tests::import_as(&crate::odt::tests::package(&content, Some(&dynamic)), &lo).last_section;
let s = import(&crate::odt::tests::package(&content, Some(&dynamic))).unwrap().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_is_half_a_centimetre() {
fn a_header_without_a_stated_height_has_no_band() {
// LibreOffice's letter templates: a header style stating no height, or none at all (the
// standard gives none), is LibreOffice's default 0.5 cm band (measured); WordCraft's is none.
// standard gives none), has no band: the header is as tall as its text.
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
@@ -197,8 +181,6 @@ fn a_header_without_a_stated_height_is_half_a_centimetre() {
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_the_profiles_margins() {
fn a_document_without_a_page_layout_has_the_default_paper_and_no_margins() {
// No styles.xml at all: the region's paper (the standard leaves the size to the
// 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.
// application), and no margins (the standard leaves them open too; 0 is XSL's initial value).
// 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,17 +157,12 @@ fn a_document_without_a_page_layout_has_the_default_paper_and_the_profiles_margi
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_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.
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.
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>"#
@@ -181,15 +176,6 @@ fn a_page_layout_stating_nothing_has_the_profiles_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 -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 is the profile's (the standard gives none): WordCraft's 0.25 pt.
// A line width no style states (the standard gives none): WordCraft's 0.25 pt.
let blue = Some((Rgb(0x34, 0x65, 0xa4), 0.25));
assert_eq!(
shapes(&d),
+27 -21
View File
@@ -1,22 +1,26 @@
//! Tab stops (ODF 1.3 §17.7–17.8 `style:tab-stops`): position, alignment and leader, read from
//! 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.
//! 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.
use crate::odt::tests::{NS_T, import_as, package};
use crate::odt::tests::{NS_T, 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 = 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 settings = settings_xml(r);
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())] {
@@ -61,18 +65,14 @@ 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) 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);
// 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();
tabs(&d).first().map(|t| t.pos)
};
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));
assert_eq!(first(Some(true)), Some(108.0));
assert_eq!(first(Some(false)), Some(72.0));
assert_eq!(first(None), Some(72.0));
}
#[test]
@@ -131,8 +131,14 @@ 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>"#
);
// LibreOffice's measure (its default when the document doesn't say).
let d = import_as(&package(&content, Some(&styles)), &OdfProfile::libreoffice());
// 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();
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();
@@ -163,7 +169,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_as(&with_settings(&content(r#"fo:margin-left="0.5in""#), Some(true)), &OdfProfile::word());
let d = import(&with_settings(&content(r#"fo:margin-left="0.5in""#), Some(true))).unwrap();
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");
@@ -173,7 +179,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_as(&with_settings(&content(r#"fo:margin-left="0.5in""#), Some(false)), &OdfProfile::word());
let d = import(&with_settings(&content(r#"fo:margin-left="0.5in""#), Some(false))).unwrap();
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>"#
);
crate::odt::tests::import_as(&package(&content, None), &wordcraft_doc::odf_profile::OdfProfile::libreoffice())
import(&package(&content, None)).unwrap()
};
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 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)));
// A 0 width draws at a hairline, 0.25 pt.
assert_eq!(line(&open_with(stroke)), Some((wordcraft_doc::props::Rgb::BLACK, 0.25)));
assert_eq!(line(&open_with(&format!(r#"{stroke} fo:border="none""#))), None);
assert_eq!(line(&open_with("")), None);
}
@@ -1,8 +1,8 @@
//! 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.
//! 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.
use crate::odt::tests::{NS_T, import_as};
use wordcraft_doc::odf_profile::{OdfPreset, OdfProfile};
use crate::odt::import;
use crate::odt::tests::NS_T;
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_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
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
// 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_under_every_preset() {
);
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">false</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">true</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,57 +37,40 @@ fn values_a_file_states_are_kept_under_every_preset() {
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;
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);
}
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)));
}
#[test]
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).
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.
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, 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);
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);
// 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_as(&doc(props), &OdfProfile::word());
let d = import(&doc(props)).unwrap();
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}");
@@ -95,11 +78,10 @@ fn a_sections_column_balance_is_its_own_else_the_profiles_and_is_written() {
}
#[test]
fn paragraph_spacing_adds_up_as_the_document_says_else_as_the_profile_does() {
fn paragraph_spacing_adds_up_as_the_document_says_else_takes_the_larger() {
// LibreOffice's `AddParaTableSpacing` (settings.xml; false: the larger of the two, observed)
// 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).
// 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).
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>| {
@@ -115,14 +97,11 @@ fn paragraph_spacing_adds_up_as_the_document_says_else_as_the_profile_does() {
}
z.finish().unwrap().into_inner()
};
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);
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 (add, written) in [(Some(true), "true"), (None, "false")] {
let d = import_as(&with(add), &OdfProfile::word());
let d = import(&with(add)).unwrap();
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}");
@@ -132,12 +111,11 @@ fn paragraph_spacing_adds_up_as_the_document_says_else_as_the_profile_does() {
}
#[test]
fn widow_control_is_the_files_else_the_profiles() {
fn widow_control_is_the_files_else_on() {
// `fo:widows`/`fo:orphans` refer to XSL's (initial value 2); LibreOffice reads none stated as
// 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`).
// 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`).
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>"#
@@ -147,17 +125,15 @@ fn widow_control_is_the_files_else_the_profiles() {
);
crate::odt::tests::package(&content, Some(&styles))
};
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 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 styles = crate::odt::tests::entry(&saved, "styles.xml");
assert!(styles.contains(written), "{:?}: {styles}", p.preset);
assert_eq!(on(&saved, &OdfProfile::of(OdfPreset::Custom)), p.widow_control);
assert!(styles.contains(written), "{default}: {styles}");
assert_eq!(on(&saved), widows);
}
}
-15
View File
@@ -42,9 +42,6 @@ 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,
@@ -199,7 +196,6 @@ 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.
@@ -413,9 +409,6 @@ 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
@@ -431,19 +424,15 @@ 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)
@@ -452,10 +441,6 @@ 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)
{
+9 -21
View File
@@ -5211,12 +5211,12 @@ fn list_label_does_not_inflate_an_inline_picture_floor() {
}
#[test]
fn a_line_of_only_pictures_fits_them_in_odt_documents() {
// LibreOffice (ODT documents): a line holding only a picture is as tall as the picture;
// Word keeps the font's ascent and descent around it.
let height = |fit: bool, h: f32| {
fn a_line_of_only_pictures_keeps_its_fonts_height_in_odt_documents() {
// The standard has no line-height algorithm: as in Word, the line keeps the font's height, and
// its descent below a taller picture.
let height = |h: f32| {
let mut d = Document::from_text("");
d.settings.odf = Some(wordcraft_doc::odf_profile::OdfLayout { picture_lines_fit_pictures: fit, ..Default::default() });
d.settings.odf = Some(wordcraft_doc::OdfLayout::default());
if let Some(wordcraft_doc::Block::Para(p)) = d.body.get_mut(0).map(std::sync::Arc::make_mut) {
p.props.space_after = Some(0.0);
p.props.line_spacing = Some(wordcraft_doc::props::LineSpacing::Multiple(1.0));
@@ -5226,20 +5226,8 @@ fn a_line_of_only_pictures_fits_them_in_odt_documents() {
let Some(Placed::Lines { para, .. }) = l.pages[0].items.iter().find(|i| matches!(i, Placed::Lines { .. })) else { panic!() };
para.lines[0].height
};
assert!((height(true, 3.5) - 3.5).abs() < 0.01, "{}", height(true, 3.5));
assert!((height(true, 40.0) - 40.0).abs() < 0.01, "{}", height(true, 40.0));
// Word's: the font's height at least, and its descent below a taller picture.
assert!(height(false, 3.5) > 10.0);
assert!(height(false, 40.0) > 42.0);
// Text on the line: its font counts either way.
let mut d = Document::from_text("Text");
d.settings.odf = Some(wordcraft_doc::odf_profile::OdfLayout { picture_lines_fit_pictures: true, ..Default::default() });
if let Some(wordcraft_doc::Block::Para(p)) = d.body.get_mut(0).map(std::sync::Arc::make_mut) {
p.insert_object(0, picture(20.0, 3.5, Default::default()), &Default::default()).unwrap();
}
let l = lay(&d);
let Some(Placed::Lines { para, .. }) = l.pages[0].items.iter().find(|i| matches!(i, Placed::Lines { .. })) else { panic!() };
assert!(para.lines[0].height > 10.0);
assert!(height(3.5) > 10.0);
assert!(height(40.0) > 42.0);
}
#[test]
@@ -5247,7 +5235,7 @@ fn padding_without_a_border_takes_room_in_odt_documents() {
// LibreOffice keeps a paragraph's padding on a side without a line; Word drops it.
let place = |odf: bool| {
let mut d = Document::from_text("Above\nPadded\nBelow");
d.settings.odf = odf.then_some(wordcraft_doc::odf_profile::OdfLayout::default());
d.settings.odf = odf.then_some(wordcraft_doc::OdfLayout::default());
for b in d.body.iter_mut() {
if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(b) {
(p.props.space_before, p.props.space_after) = (Some(0.0), Some(0.0));
@@ -13037,7 +13025,7 @@ fn default_tab_stops_count_from_the_indent_in_odt_documents_that_say_so() {
let x_of_b = |from_indent: bool, left: f32, first: f32| {
let mut d = Document::from_text("a\tb");
d.settings.default_tab = 36.0;
d.settings.odf = Some(wordcraft_doc::odf_profile::OdfLayout { tabs_from_indent: from_indent, ..Default::default() });
d.settings.odf = Some(wordcraft_doc::OdfLayout { tabs_from_indent: from_indent });
d.format_paragraphs(&Pos::body(0, 0), &Pos::body(0, 0), &|p| {
p.indent_left = Some(left);
p.indent_first = Some(first);
-95
View File
@@ -652,9 +652,6 @@ fn advanced(app: &mut WordApp, ui: &mut Ui) {
templates_folder(app, ui);
}
ui.add_space(8.0);
section(ui, "OpenDocument files");
odf_files(app, ui);
ui.add_space(8.0);
section(ui, "Customize");
ui.horizontal(|ui| {
ui.label(tl!("Keyboard shortcuts:"));
@@ -670,98 +667,6 @@ fn advanced(app: &mut WordApp, ui: &mut Ui) {
});
}
/// How OpenDocument files opened from now on fill in what the standard leaves to the
/// application (docs/odf-interpretation.md): in Microsoft Word's style, in LibreOffice's, or with
/// the user's own choice for each case.
fn odf_files(app: &mut WordApp, ui: &mut Ui) {
use wordcraft_doc::odf_profile::{MAX_LENGTH, OdfPreset};
let profile = app.session.prefs.odf.clone();
// The preset as a drop-down, like the units and region above.
// The two display styles, then Custom for the user's own choices.
let presets = [OdfPreset::Word, OdfPreset::LibreOffice, OdfPreset::Custom];
let shown = profile.preset.label();
let mut chosen = None;
ui.horizontal(|ui| {
let label = ui.label(tl!("Where the standard leaves the look open, follow:"));
let combo = craft_ui::ComboBox::from_id_salt("options_odf_preset").selected_text(tl!(shown)).width(200.0).show_ui(ui, |ui| {
for preset in presets {
if ui.selectable_label(profile.preset == preset, tl!(preset.label())).clicked() && profile.preset != preset {
chosen = Some(preset);
}
}
});
combo.response.labelled_by(label.id);
});
if let Some(preset) = chosen {
// Custom starts from the choices in use.
let mut odf = if preset == OdfPreset::Custom { serde_json::to_value(profile.effective()).unwrap_or_default() } else { json!({}) };
if let Some(o) = odf.as_object_mut() {
o.insert("preset".into(), json!(preset.id()));
}
set(app, json!({ "odf": odf }));
}
note(ui, "Applies to OpenDocument files opened from now on. What the standard defines is always followed.");
if profile.preset != OdfPreset::Custom {
return;
}
let e = profile.effective();
let u = unit();
let k = u.pt_per_unit();
egui::Grid::new("options_odf").num_columns(2).spacing(vec2(8.0, 6.0)).show(ui, |ui| {
for (key, label, pt) in [
("pageMargin", "Page margins:", e.page_margin),
("headerMinHeight", "Header and footer height:", e.header_min_height),
("columnGap", "Gap between columns:", e.column_gap),
] {
let id = ui.label(tl!(label)).id;
let mut v = pt / k;
if ui.add(egui::DragValue::new(&mut v).speed(0.5 / k).range(0.0..=MAX_LENGTH / k).measure(u).max_decimals(2)).labelled_by(id).changed() {
set(app, json!({"odf": {key: (v * k).clamp(0.0, MAX_LENGTH)}}));
}
ui.end_row();
}
// Line widths in points, as the ribbon shows them: a hairline is a fraction of a point.
let id = ui.label(tl!("Shape line width:")).id;
let mut w = e.hairline;
if ui
.add(egui::DragValue::new(&mut w).speed(0.05).range(0.05..=24.0).max_decimals(2).suffix(format!(" {}", tl!("pt"))))
.labelled_by(id)
.changed()
{
set(app, json!({"odf": {"hairline": w}}));
}
ui.end_row();
for (key, label, rgb) in [("shapeFill", "Shape fill colour:", e.shape_fill), ("shapeLine", "Shape line colour:", e.shape_line)] {
let id = ui.label(tl!(label)).id;
let mut c = [rgb.0, rgb.1, rgb.2];
if egui::color_picker::color_edit_button_srgb(ui, &mut c).labelled_by(id).changed() {
set(app, json!({"odf": {key: wordcraft_doc::props::Rgb(c[0], c[1], c[2]).hex()}}));
}
ui.end_row();
}
});
for (key, label, value) in [
("headerHeightIncludesSpacing", "A header's height includes its spacing from the text", e.header_height_includes_spacing),
("dynamicSpacing", "A tall header uses up its spacing from the text", e.dynamic_spacing),
("tabsFromIndent", "Tab stops are measured from the paragraph's indent", e.tabs_from_indent),
("pageFrameParagraphIsPage", "A page frame placed against a paragraph is placed against the page edge", e.page_frame_paragraph_is_page),
(
"inlineInvalidRelationIsBaseline",
"An in-line frame placed against a paragraph or the page is placed against the baseline",
e.inline_invalid_relation_is_baseline,
),
("pictureLinesFitPictures", "A line holding only pictures is as tall as the pictures", e.picture_lines_fit_pictures),
("balanceSectionColumns", "A section's columns end balanced at the end of the document too", e.balance_section_columns),
("sumParagraphSpacing", "The space between paragraphs is the space after plus the space before", e.sum_paragraph_spacing),
("widowControl", "Widow and orphan control when no style says", e.widow_control),
] {
let mut v = value;
if ui.checkbox(&mut v, tl!(label)).changed() {
set(app, json!({"odf": {key: v}}));
}
}
}
/// The user templates folder (#2852): where File › New finds personal templates and Save as Word
/// Template starts. Empty is the app's own folder, shown as the hint.
fn templates_folder(app: &mut WordApp, ui: &mut Ui) {
-43
View File
@@ -482,46 +482,3 @@ fn length_units_follow_the_interface_language() {
h.run_steps(4);
assert_eq!(length(72.0), "25,4 mm", "English keeps the English names");
}
/// File › Options › Advanced › OpenDocument files: the preset drop-down is `file.options` `odf`, and
/// Custom starts from the choices in use and shows each case.
#[test]
fn advanced_opendocument_presets_and_custom_cases() {
use wordcraft_doc::odf_profile::{OdfPreset, OdfProfile};
let mut a = app();
a.run("file.options", json!({"pane": "advanced"})).unwrap();
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 1600.0)).build_ui_state(
|ui, app: &mut WordApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
a,
);
h.run_steps(4);
assert!(h.query_by_label("Tab stops are measured from the paragraph's indent").is_none(), "a preset shows no cases");
let pick = |h: &mut egui_kittest::Harness<'static, WordApp>, name: &str| {
h.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Where the standard leaves the look open, follow:").click();
h.run_steps(4);
h.get_by_label(name).click();
h.run_steps(4);
};
let preset = |h: &egui_kittest::Harness<'static, WordApp>| {
h.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Where the standard leaves the look open, follow:").value()
};
// Two display styles: Microsoft Word's (the default) and LibreOffice's.
assert_eq!(preset(&h).as_deref(), Some("Microsoft Word style"));
pick(&mut h, "LibreOffice style");
assert_eq!(preset(&h).as_deref(), Some("LibreOffice style"));
assert_eq!(h.state().session.prefs.odf.effective(), OdfProfile::libreoffice());
pick(&mut h, "Custom");
let odf = &h.state().session.prefs.odf;
assert_eq!(odf.preset, OdfPreset::Custom);
assert_eq!(OdfProfile { preset: OdfPreset::LibreOffice, ..odf.effective() }, OdfProfile::libreoffice(), "starting from LibreOffice's");
h.get_by_label("Tab stops are measured from the paragraph's indent").click();
h.run_steps(4);
assert!(!h.state().session.prefs.odf.effective().tabs_from_indent, "a case turned off");
pick(&mut h, "Microsoft Word style");
assert_eq!(preset(&h).as_deref(), Some("Microsoft Word style"));
assert_eq!(h.state().session.prefs.odf.effective(), OdfProfile::word());
}
File diff suppressed because one or more lines are too long
+3 -2
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+42 -59
View File
@@ -1,6 +1,6 @@
# How WordCraft interprets OpenDocument files
> **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-04 · **Change:** trivial (what is written as read: unchanged paragraphs with drawings, comments, changes or marks too); previously minor (the two presets are Microsoft Word style and LibreOffice style; CLI values listed one by one); previously minor (deviations from the standard listed; ODF 1.2 → 1.3 checked); previously major (new: the ODF interpretation profile and the list of every case)
> **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-04 · **Change:** major (no more settings: one choice for each case the standard leaves open); previously trivial (what is written as read: unchanged paragraphs with drawings, comments, changes or marks too); previously minor (the two presets are Microsoft Word style and LibreOffice style; CLI values listed one by one); previously minor (deviations from the standard listed; ODF 1.2 → 1.3 checked); previously major (new: the ODF interpretation profile and the list of every case)
OpenDocument (ODF 1.3, OASIS) leaves some of a document's look to the application. Part 3 §16.2
says that when no style sets a property and the standard gives no default, "an
@@ -10,62 +10,47 @@ knows of, so none gets lost.
## The rule
1. **What the standard defines is law.** WordCraft follows it whatever the settings say. That
includes `fo:` properties, which the standard defines by referring to XSL 1.0, for their
meaning. Whether such a reference also brings XSL's initial value as the default the standard
doesn't say; where an application's choice differs from XSL's (page margins), the case is a
setting, with XSL's value as WordCraft's.
2. **What the standard leaves open is a setting.** Each such case is one value of the ODF
interpretation profile (`OdfProfile`, `crates/doc/src/odf_profile.rs`):
Two of them are the styles a document can be shown in:
- **Microsoft Word style** (`word`), the default: what WordCraft does for its own and Word
documents. (`wordcraft`, its earlier id, is still taken.)
- **LibreOffice style** (`libreoffice`): what LibreOffice (and Collabora, which shares its engine) does. Measured
black-box in LibreOffice 24 where it isn't documented.
- **Custom**: the user's own value for each case. It starts from the preset that was in use.
3. **A value the file states is the file's.** A preset never replaces it: the profile only fills
in what the file (its styles, defaults and `settings.xml`) leaves unsaid. Three cases are not
missing values but stated ones whose meaning the standard leaves open: how a header's stated
height and spacing combine, what a stated width of 0 draws at, and a frame position stated
against something the standard doesn't define for its anchor. They are marked *(meaning)*
below. `formats::odt::tests_more::profile` checks that stated values survive every preset.
1. **What the standard defines is law.** WordCraft follows it. That includes `fo:` properties,
which the standard defines by referring to XSL 1.0, for their meaning. Whether such a reference
also brings XSL's initial value as the default the standard doesn't say; where an application's
choice differs from XSL's (page margins), the case is listed below, with XSL's value as
WordCraft's.
2. **What the standard leaves open, WordCraft decides once.** Each case listed below has one
choice, the one WordCraft makes for its own and Word documents, and no setting. LibreOffice's
choice (and Collabora's, which shares its engine) is listed beside it, measured black-box in
LibreOffice 24 where it isn't documented, because a file made there can look different here.
3. **A value the file states is the file's.** WordCraft's choices fill in only what the file (its
styles, defaults and `settings.xml`) leaves unsaid. Three cases are not missing values but
stated ones whose meaning the standard leaves open: how a header's stated height and spacing
combine, what a stated width of 0 draws at, and a frame position stated against something the
standard doesn't define for its anchor. They are marked *(meaning)* below.
`formats::odt::tests_more::unstated` checks that stated values are kept.
4. **A deviation from the standard is made only out of necessity**: where the files in
circulation contradict it and following it would damage every such file. Each one is listed
under "Deviations from the standard, out of necessity" below, with the standard's rule, what
WordCraft does instead and why; WordCraft never writes the deviating form.
5. The profile applies to ODF files (`.odt`, `.ott`, `.fodt`) *as they are opened*. A document
keeps what it was opened with. Changing the setting affects the next file opened.
Where to set it:
## Cases the standard leaves open
| Interface | How |
|---|---|
| App | File › Options › Advanced › OpenDocument files |
| Command | `file.options` `{"odf": {"preset": "word"}}` or `{"odf": {"preset": "libreoffice"}}`, or one case's value: `{"odf": {"columnGap": 18}}` (this makes the profile custom) |
| MCP | the same command |
| CLI | `WORDCRAFT_ODF=word` (the default) or `WORDCRAFT_ODF=libreoffice` |
| Case | Standard | What WordCraft does | What LibreOffice does |
|---|---|---|---|
| Page margins a page layout doesn't state | §20.206–20.209 refer to XSL §7.10 (initial value 0); whether that default comes with the reference is not said | 0 (XSL's initial value) | 2 cm (its own files always state theirs; some of its templates don't) |
| Height of a header or footer whose style states none | §20.212.2 `fo:min-height`: no default | 0 (as tall as its text) | 0.5 cm |
| *(meaning)* Whether a header's stated minimum height includes its stated spacing from the body | §20.212.2, §20.207: not said | no: spacing comes after the height | yes |
| A header taller than its height with `style:dynamic-spacing` unstated | §20.264: no default | off: the spacing is kept (on, with no height stated, would always drop the spacing the file states) | off |
| Where paragraph tab stops, default ones too, are measured from when `settings.xml` doesn't say | §19.512.3 `style:position` doesn't say; §19.861 covers only indexes | the margin (as Word; ONLYOFFICE's files mean this too) | the paragraph's left indent (its `TabsRelativeToIndent` default): explicit stops, the default stops every interval and the hanging indent's stop all move with the indent (measured) |
| Gap between columns when `style:columns` states none | §19.233 `fo:column-gap`: no default (and explicitly not XSL's) | 0.5 in (Word's) | 0 |
| A drawing shape's fill colour when no style gives one | §20.119 `draw:fill-color`: no default | `#156082` (WordCraft's shape blue) | `#729FCF` |
| A drawing shape's line colour when no style gives one | §20.408 `svg:stroke-color`: no default | `#0E405A` | `#3465A4` |
| A shape's line width when unstated, and *(meaning)* what a stated width of 0 (a hairline) draws at | §20.410 `svg:stroke-width`: no default | 0.25 pt | 0.5 pt |
| *(meaning)* A page-anchored frame positioned against a paragraph or character | §20.299, §20.398 Table 23: combination not defined | the page's text area | the page's edge |
| *(meaning)* An in-line frame positioned against a paragraph or page | §20.398 Table 23: combination not defined | stands on the baseline | positioned against the baseline (raised by its offset) |
| Height of a line holding only in-line pictures | no line-height algorithm in the standard | as tall as its text would be too | as tall as the pictures |
| How the space between two paragraphs adds up when the document doesn't say | not defined (`fo:margin-top`/`-bottom` refer to XSL's margins; whether XSL's space resolution, which keeps the larger, comes with them is not said); LibreOffice's `AddParaTableSpacing` setting says when present (false: the larger) | the larger of the two (as Word) | their sum |
| Widow and orphan control when neither the default paragraph style, Standard nor the paragraph's style states `fo:widows`/`fo:orphans` | §20.312, §20.244 refer to XSL's widows and orphans (initial value 2); whether that default comes with the reference is not said | on: 2 lines (XSL's initial value, and Word's) | off (a first or last line can stand alone at a page's edge) |
| *(meaning)* Whether a columned section's columns end balanced when nothing follows it on the page, and when it doesn't say | §20.428 `text:dont-balance-text-columns`: no default, and its value descriptions contradict its name (WordCraft reads the name; so does LibreOffice) | as Word: only when text follows on the same page | balanced |
## Cases the standard leaves open (the profile)
| Case | Standard | Setting (`file.options` `odf.*`) | Microsoft Word style | LibreOffice style |
|---|---|---|---|---|
| Page margins a page layout doesn't state | §20.206–20.209 refer to XSL §7.10 (initial value 0); whether that default comes with the reference is not said | `pageMargin` | 0 (XSL's initial value) | 2 cm (its own files always state theirs; some of its templates don't) |
| Height of a header or footer whose style states none | §20.212.2 `fo:min-height`: no default | `headerMinHeight` | 0 (as tall as its text) | 0.5 cm |
| *(meaning)* Whether a header's stated minimum height includes its stated spacing from the body | §20.212.2, §20.207: not said | `headerHeightIncludesSpacing` | no: spacing comes after the height | yes |
| A header taller than its height with `style:dynamic-spacing` unstated | §20.264: no default | `dynamicSpacing` | off: the spacing is kept (on, with no height stated, would always drop the spacing the file states) | off |
| Where paragraph tab stops, default ones too, are measured from when `settings.xml` doesn't say | §19.512.3 `style:position` doesn't say; §19.861 covers only indexes | `tabsFromIndent` | the margin (as Word; ONLYOFFICE's files mean this too) | the paragraph's left indent (its `TabsRelativeToIndent` default): explicit stops, the default stops every interval and the hanging indent's stop all move with the indent (measured) |
| Gap between columns when `style:columns` states none | §19.233 `fo:column-gap`: no default (and explicitly not XSL's) | `columnGap` | 0.5 in (Word's) | 0 |
| A drawing shape's fill colour when no style gives one | §20.119 `draw:fill-color`: no default | `shapeFill` | `#156082` (WordCraft's shape blue) | `#729FCF` |
| A drawing shape's line colour when no style gives one | §20.408 `svg:stroke-color`: no default | `shapeLine` | `#0E405A` | `#3465A4` |
| A shape's line width when unstated, and *(meaning)* what a stated width of 0 (a hairline) draws at | §20.410 `svg:stroke-width`: no default | `hairline` | 0.25 pt | 0.5 pt |
| *(meaning)* A page-anchored frame positioned against a paragraph or character | §20.299, §20.398 Table 23: combination not defined | `pageFrameParagraphIsPage` | the page's text area | the page's edge |
| *(meaning)* An in-line frame positioned against a paragraph or page | §20.398 Table 23: combination not defined | `inlineInvalidRelationIsBaseline` | stands on the baseline | positioned against the baseline (raised by its offset) |
| Height of a line holding only in-line pictures | no line-height algorithm in the standard | `pictureLinesFitPictures` | as tall as its text would be too | as tall as the pictures |
| How the space between two paragraphs adds up when the document doesn't say | not defined (`fo:margin-top`/`-bottom` refer to XSL's margins; whether XSL's space resolution, which keeps the larger, comes with them is not said); LibreOffice's `AddParaTableSpacing` setting says when present (false: the larger) | `sumParagraphSpacing` | the larger of the two (as Word) | their sum |
| Widow and orphan control when neither the default paragraph style, Standard nor the paragraph's style states `fo:widows`/`fo:orphans` | §20.312, §20.244 refer to XSL's widows and orphans (initial value 2); whether that default comes with the reference is not said | `widowControl` | on: 2 lines (XSL's initial value, and Word's) | off (a first or last line can stand alone at a page's edge) |
| *(meaning)* Whether a columned section's columns end balanced when nothing follows it on the page, and when it doesn't say | §20.428 `text:dont-balance-text-columns`: no default, and its value descriptions contradict its name (WordCraft reads the name; so does LibreOffice) | `balanceSectionColumns` | as Word: only when text follows on the same page | balanced |
## Law: what the standard defines and WordCraft follows whatever the profile
## Law: what the standard defines and WordCraft follows
| Case | Rule | Note |
|---|---|---|
@@ -73,7 +58,7 @@ Where to set it:
| A frame's `style:run-through` when unstated | §20.351: `foreground` | |
| `style:dynamic-spacing="true"/"false"` when stated | §20.264 | WordCraft writes `false` when a header or footer has spacing, so every reader keeps it. |
## Open in the standard, and the same in every preset
## Other cases open in the standard
These are documented so they aren't mistaken for law:
@@ -81,7 +66,7 @@ These are documented so they aren't mistaken for law:
|---|---|---|
| Paper size when no page layout states one | not defined | the region's paper (Letter in the US, A4 elsewhere), as for new documents |
| `style:wrap` unstated on a frame | §20.400: no default | text wraps on both sides (LibreOffice too) |
| A drawing shape's `draw:fill` / `draw:stroke` unstated | §20.118, §20.167: no default | solid (LibreOffice too); a text frame's are none; the colours are in the profile |
| A drawing shape's `draw:fill` / `draw:stroke` unstated | §20.118, §20.167: no default | solid (LibreOffice too); a text frame's are none; the colours are listed above |
| A document's default font and size when no default style gives them | not defined | WordCraft's defaults |
## How WordCraft writes ODF
@@ -136,7 +121,7 @@ such file. Each is kept as narrow as possible, and WordCraft never writes the de
## Known differences between WordCraft's and LibreOffice's layout of the same file
Found by saving WordCraft's own documents as ODT and opening them in LibreOffice. These aren't
choices the file can state, so the presets don't cover them:
choices the file can state:
- **Footers.** Word, and WordCraft for its own documents, places a footer's text so it ends at the
footer's distance from the bottom of the page. ODF places it at the top of the footer area. A
@@ -150,7 +135,7 @@ choices the file can state, so the presets don't cover them:
small gap in WordCraft.
- **Line breaks after a slash.** LibreOffice breaks a long path after a `/`; WordCraft, as Word,
keeps the text after a slash with it and breaks such a word where it must. The standard
doesn't define line breaking; this could become a setting.
doesn't define line breaking.
- **Objects that may not overlap text** (`style:wrap="none"` with several on one paragraph):
LibreOffice moves them down by its own rules (`draw:wrap-influence-on-position`).
@@ -168,18 +153,16 @@ from public bug reports and ONLYOFFICE's converter test snapshots):
- **Google Docs** exports through a LibreOffice build (its generator reads `LibreOfficeDev/6.0`).
It states its page size and margins, writes `TabsRelativeToIndent=false`, and its default
graphic style states LibreOffice's colours. Its headers are empty styles with no content.
Nothing in these files depends on the profile beyond what the LibreOffice preset covers.
Nothing in these files depends on the cases above more than LibreOffice's own files do.
- **ONLYOFFICE** states page size and margins only. It has no header styles and no default
graphic style. It writes tab stops with no `TabsRelativeToIndent` and means "from the margin"
(its documents come from Word's model), which is the Microsoft Word style, the default.
So neither needs a preset of its own yet. If later files show cases where they differ, a preset is
one more `OdfPreset` with its values, and the cases above stay the same.
(its documents come from Word's model), which is WordCraft's choice too.
## Revision history
| Date | Change |
|---|---|
| 2026-11-04 | The settings removed: File › Options › Advanced › OpenDocument files, `file.options` `odf`, `WORDCRAFT_ODF`, the LibreOffice style and custom values. WordCraft makes one choice for each case the standard leaves open, the one the default made, so nothing changes for a document opened with the default. What a file states is still followed, its `TabsRelativeToIndent` and `AddParaTableSpacing` among it. LibreOffice's choices stay listed beside WordCraft's. |
| 2026-11-04 | How WordCraft writes ODF: unchanged paragraphs and tables with drawings, comments, tracked changes, notes or marks are written as read too (the text said from the model); what is edited, and the cases left in the element audit, from the model. |
| 2026-11-04 | The two presets are the two styles a document can be shown in: Microsoft Word style (`word`, the default; `wordcraft`, its earlier id, still taken) and LibreOffice style (`libreoffice`). The CLI's values listed one by one. |
| 2026-11-03 | Deviations from the standard listed: unitless gradient and hatch angles in files older than ODF 1.3 are read as tenths of a degree, as their producers wrote them, out of necessity (in ODF 1.3 files, degrees, as the standard says). The ODF 1.2 → 1.3 changes checked. |