mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-07 12:12:50 +00:00
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:
co-authored by
Claude Opus 5.5
parent
78d71fa19c
commit
650a97b623
+3
-1
File diff suppressed because one or more lines are too long
@@ -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, ®ion, &odf)?;
|
||||
let (doc, encrypted) = wordcraft_engine::io::open_path_in(path, password, ®ion)?;
|
||||
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", ¶ms).map_err(|e| e.to_string())?;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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""#));
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+50
-74
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+42
-59
@@ -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. |
|
||||
|
||||
Reference in New Issue
Block a user