ODT: two display styles (Microsoft Word style, LibreOffice style) and review fixes (gap 19)

- odf.preset: word (Microsoft Word style, default) or libreoffice; wordcraft still read as word
- Options, CLI (WORDCRAFT_ODF), docs and ROADMAP follow the two styles
- CodeRabbit: kept groups and story fingerprints follow fill_objects; padding serde default;
  list field depth; literal doubled quote; ASK/SET results not exported; dc:date offsets;
  first_pages order; unique picture names; finite raise clamp; PDF rotated-text guard;
  .ott in the password message; flat element bounded by depth; cell tile fallback;
  ROADMAP and docs reconciled
- Remove clean-room wording from the ODT tests
This commit is contained in:
FredApps committed 2026-11-04 11:55:49 +01:00
1 parent 56c85834f1
commit 53bccd5442
38 files changed
+463 -100

No files matched your search

+8 -4
View File
@@ -4,7 +4,7 @@
**Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** trivial (progress log: ODT sections inside text boxes); previously trivial (progress log: ODT definitions sharing a name, sections around tables, stacking order); previously trivial (progress log: ODT forms and indexes written as read); previously trivial (progress log: ODT table styles saved, WordCraft-origin documents checked in LibreOffice); previously trivial (progress log: ODT interpretation profile); previously trivial (progress log: ODT controls, custom shapes, in-line placement); previously trivial (progress log: ODT charts kept, frame outlines, header height); previously trivial (progress log: ODT header rows); previously trivial (progress log: ODT layout setting, padding, columns); previously trivial (progress log: ODT tables, header spacing, picture paragraphs); previously trivial (progress log: ODT round trips keep their page count); previously trivial (progress log: emphasis marks, SET fields, ODT page backgrounds and tables); previously trivial (progress log: ODT checked against LibreOffice's templates); previously trivial (progress log: ODT at ~90%); previously trivial (progress log: ODT read and written nearly whole); previously trivial (progress log: ODT text boxes); previously trivial (progress log: ODT layout settings); previously trivial (progress log: ODT character styles); previously trivial (progress log: ODT custom styles); previously trivial (progress log: ODT equations saved); previously trivial (progress log: ODT floating pictures); previously trivial (progress log: ODT paragraph borders); previously trivial (progress log: ODT heading numbering); previously trivial (progress log: ODT table borders); previously trivial (progress log: ODT heading styles); previously trivial (progress log: ODT tab stops); previously trivial (progress log: ODT character formatting); previously trivial (progress log: ODT lists); previously trivial (progress log: ODT comments); previously trivial (progress log: ODT footnotes and endnotes); previously trivial (progress log: ODT headers, footers and fields); previously trivial (progress log: ODT paragraph spacing); previously trivial (progress log: Options › Language redesign, CP4.3); previously trivial (progress log: the missing-pack bar on a fresh install, the caret's typing language, and the spelling menu heading in the interface language, CP4.3); previously trivial (progress log: Language pack languages in every language list, CP4.3); previously trivial (progress log: craft-proof pin, French elision and Valencian, CP4.3); previously trivial (progress log: a long comment's card and balloon stop at ten lines, #3382); previously trivial (progress log: a pasted text box story is charged for the formatting it copies); previously trivial (progress log: a .doc table row keeps one override per cell); previously trivial (progress log: a .doc file's sections find their paragraphs by index); previously minor (Arabic interface language with mirrored UI, #1195); previously trivial (progress log: Replace and Remove Table of Contents with Word's title above a section break, #4041); previously trivial (progress log: a password-protected file named on the command line asks for its password after the startup document is replaced, #5299); previously trivial (progress log: language-packs-2 published and pinned, 75 Language packs); previously trivial (progress log: language-packs-1 published and pinned, CP4.4); previously trivial (progress log: Language packs, CP4);; previously trivial (DOCX table rows and cells inside selected alternate-content branches survive open and repeated saves (#5219); broader corpus qualification and readiness estimates unchanged); previously trivial (progress log: a list inside a Markdown block quote stays quoted); previously trivial (progress log: arrowheads keep their kind, width and length and are drawn at Word's sizes); previously trivial (progress log: the line between columns for selected text, #5178); previously trivial (progress log: .doc standard font, #5066); previously trivial (progress log: a table row doesn't split off a picture that doesn't fit, #4272); previously trivial (progress log: a middle or bottom cell of a row split across pages lines up on the first page); previously trivial (progress log: tab leader dots spaced and placed as in Word); previously trivial (progress log: a list level above counts its first number at the first deeper item); previously trivial (progress log: paragraph side borders lie outside the text, #5036); previously trivial (progress log: a paragraph naming an undefined style is formatted as Normal, #5077); previously trivial (progress log: TOC page numbers as PAGEREF fields, #5097); previously trivial (progress log: Backstage keytips, #4960); previously trivial (Progress log: only the printer's PDF leaves out links; Save these pages as PDF keeps them, #4734); previously trivial (progress log: Chinese and Japanese text couLine truncated
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** trivial (progress log: ODT element audit, ODT checked against 220 files from other applications, ODT display styles); previously trivial (progress log: ODT sections inside text boxes); previously trivial (progress log: ODT definitions sharing a name, sections around tables, stacking order); previously trivial (progress log: ODT forms and indexes written as read); previously trivial (progress log: ODT table styles saved, WordCraft-origin documents checked in LibreOffice); previously trivial (progress log: ODT interpretation profile); previously trivial (progress log: ODT controls, custom shapes, in-line placement); previously trivial (progress log: ODT charts kept, frame outlines, header height); previously trivial (progress log: ODT header rows); previously trivial (progress log: ODT layout setting, padding, columns); previously trivial (progress log: ODT tables, header spacing, picture paragraphs); previously trivial (progress log: ODT round trips keep their page count); previously trivial (progress log: emphasis marks, SET fields, ODT page backgrounds and tables); previously trivial (progress log: ODT checked against LibreOffice's templates); previously trivial (progress log: ODT at ~90%); previously trivial (progress log: ODT read and written nearly whole); previously trivial (progress log: ODT text boxes); previously trivial (progress log: ODT layout settings); previously trivial (progress log: ODT character styles); previously trivial (progress log: ODT custom styles); previously trivial (progress log: ODT equations saved); previously trivial (progress log: ODT floating pictures); previously trivial (progress log: ODT paragraph borders); previously trivial (progress log: ODT heading numbering); previously trivial (progress log: ODT table borders); previously trivial (progress log: ODT heading styles); previously trivial (progress log: ODT tab stops); previously trivial (progress log: ODT character formatting); previously trivial (progress log: ODT lists); previously trivial (progress log: ODT comments); previously trivial (progress log: ODT footnotes and endnotes); previously trivial (progress log: ODT headers, footers and fields); previously trivial (progress log: ODT paragraph spacing); previously trivial (progress log: Options › Language redesign, CP4.3); previously trivial (progress log: the missing-pack bar on a fresh install, the caret's typing language, and the spelling menu heading in the interface language, CP4.3); previously trivial (progress log: Language pack languages in every language list, CP4.3); previously trivial (progress log: craft-proof pin, French elision and Valencian, CP4.3); previously trivial (progress log: a long comment's card and balloon stop at ten lines, #3382); previously trivial (progress log: a pasted text box story is charged for the formatting it copies); previously trivial (progress log: a .doc table row keeps one override per cell); previously trivial (progress log: a .doc file's sections find their paragraphs by index); previously minor (Arabic interface language with mirrored UI, #1195); previously trivial (progress log: Replace and Remove Table of Contents with Word's title above a section break, #4041); previously trivial (progress log: a password-protected file named on the command line asks for its password after the startup document is replaced, #5299); previously trivial (progress log: language-packs-2 published and pinned, 75 Language packs); previously trivial (progress log: language-packs-1 published and pinned, CP4.4); previously trivial (progress log: Language packs, CP4);; previously trivial (DOCX table rows and cells inside selected alternate-content branches survive open and repeated saves (#5219); broader corpus qualification and readiness estimates unchanged); previously trivial (progress log: a list inside a Markdown block quote stays quoted); previously trivial (progress log: arrowheads keep their kind, width and length and are drawn at Word's sizes); previously trivial (progress log: the line between columns for selected text, #5178); previously trivial (progress log: .doc standard font, #5066); previously trivial (progress log: a table row doesn't split off a picture that doesn't fit, #4272); previously trivial (progress log: a middle or bottom cell of a row split across pages lines up on the first page); previously trivial (progress log: tab leader dots spaced and placed as in Word); previously trivial (progress log: a list level above counts its first number at the first deeper item); previously trivial (progress log: paragraph side borders lie outside the text, #5036); previously trivial (progress log: a paragraph naming an undefined style is formatted as Normal, #5077); previously trivial (progress log: TOC page numbers as PAGEREF fields, #5097); previously trivial (progress log: Backstage keytips, #4960); previously trivial (Progress log: only the printer's Line truncated
> **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below.
WordCraft aims for complete parity with Microsoft Word, then goes further on speed, openness and
@@ -139,8 +139,9 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
## Progress log
- 2026-11-04: ODT element audit (gap 19, [docs/odt-element-audit.md](docs/odt-element-audit.md)): every element of the ODF 1.3 schema checked against 365 files and a document of our own for each one no file held; all open and save, and a save without edits keeps them, but for password and grid form controls. Over the 220 files from other applications, 175 round-trip pixel-identical and 199 with the same text.
- 2026-11-04: ODT checked against 220 files from other applications (Microsoft Office, OpenOffice.org, StarOffice, Calligra, Lotus Symphony; local only): every file LibreOffice opens opens again after a WordCraft save (a manifest entry was missing), 166 round-trip pixel-identical and 194 with the same text; hidden sections stay hidden and unedited lists are written as read (gap 19).
- 2026-11-04: OpenDocument files are shown in one of two styles (gap 19): Microsoft Word style, the default, or LibreOffice style (File › Options › Advanced › OpenDocument files; `file.options` `{"odf": {"preset": "word"}}` or `"libreoffice"`; `WORDCRAFT_ODF=word` or `WORDCRAFT_ODF=libreoffice`). Custom stays for one's own value per case; `wordcraft`, the earlier id of Word's style, is still taken.
- 2026-11-04: ODT element audit (gap 19, [docs/odt-element-audit.md](docs/odt-element-audit.md)): every element of the ODF 1.3 schema checked against 365 files and a document of our own for each one no file held; all open and save, and a save without edits keeps them (password, number, time and grid form controls included). Over the 220 files from other applications, 177 round-trip pixel-identical, 201 with the same text and 218 with the same page count (the latest measurement, after the groups and form-control work).
- 2026-11-04: ODT checked against 220 files from other applications (Microsoft Office, OpenOffice.org, StarOffice, Calligra, Lotus Symphony; local only): every file LibreOffice opens opens again after a WordCraft save (a manifest entry was missing), 166 round-trip pixel-identical and 194 with the same text at the time (an earlier measurement; the element-audit entry above has the current figures); hidden sections stay hidden and unedited lists are written as read (gap 19).
- 2026-11-03: ODT paragraphs holding bookmarks, notes, formulas and objects, and the page-anchored frames before a paragraph, are written as read while unchanged (gap 19); styles of different families sharing a name are kept apart. Over the corpus 123 of 133 files round-trip pixel-identical (118), 132 with the same text, and the attributes and values a save loses fall from 539 to under 315.
- 2026-11-03: ODT paragraphs showing text boxes and shapes holding text are written as read while that text is unchanged (gap 19). Over the corpus 111 of 133 files now round-trip pixel-identical (103), the round-trip difference falls from 44k to 39k pixels, and the element kinds a save loses fall from 16 to 8.
- 2026-11-03: ODT paragraphs holding comments or tracked changes are written as read while those are unchanged (gap 19): a comment edited, resolved or answered, or a change accepted, sends its paragraph to the model; the file's change regions are kept beside WordCraft's own. Over the corpus the words a save loses fall from 29 to 4.
@@ -151,7 +152,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
- 2026-11-03: ODT saves without edits lose (almost) nothing (gap 19): tables, page layouts and master pages, named sections, document properties and declarations WordCraft doesn't model are written back as read, and a test saves seven samples of our own and finds nothing missing. Over the 133-file corpus 95 files come back pixel-identical (62 before) and the round-trip difference falls to 51k pixels (191k)
- 2026-11-03: ODT saves what WordCraft doesn't model (gap 19): a paragraph nobody edited is written back with its own markup, and while no style changed, the file's own style definitions are written, so unknown elements, attributes, unused list styles and note settings survive a save that doesn't touch them. Over the 133-file corpus (LibreOffice, Google Docs and ONLYOFFICE files) the round-trip difference falls by 64% and 62 files come back pixel-identical (46 before)
- 2026-11-03: ODT checked the other way round too (gap 19): 42 documents made in WordCraft (its templates, themes, and one per feature) are saved as ODT and opened in LibreOffice; every one opens with the same page count and passes the ODF 1.3 schema. Found and fixed: table styles were left out of the saved file (now written into each cell), ODT tables were given Word's Table Grid style on opening (which changed their spacing), section columns now say whether they end balanced, and SmartArt diagrams are saved as a group of their shapes and text. The LibreOffice round-trip corpus is at its best yet (40k differing pixels over 125 documents, 42 identical).
- 2026-11-02: ODT files follow their standard wherever it says something, and a setting wherever it doesn't (gap 19). File › Options › Advanced › OpenDocument files (and `file.options` `odf`) chooses WordCraft's own behaviour (the default), LibreOffice's, or custom values for each of twelve cases the standard leaves open: header heights and spacing, column gaps, shape colours and hairlines, where tab stops are measured from, frame relations the standard doesn't define, and lines of only pictures. A page layout that states no margins follows the setting too: 0 for WordCraft (XSL's initial value), 2 cm for LibreOffice. Every case is listed in docs/odf-interpretation.md; Google Docs and ONLYOFFICE files checked, and neither needs a preset of its own yet.
- 2026-11-02: ODT files follow their standard wherever it says something, and a setting wherever it doesn't (gap 19). File › Options › Advanced › OpenDocument files (and `file.options` `odf`) chooses between two display styles, Microsoft Word style (the default) and LibreOffice style, or custom values for each of twelve cases the standard leaves open: header heights and spacing, column gaps, shape colours and hairlines, where tab stops are measured from, frame relations the standard doesn't define, and lines of only pictures. A page layout that states no margins follows the setting too: 0 in Microsoft Word style (XSL's initial value), 2 cm for LibreOffice. Every case is listed in docs/odf-interpretation.md; Google Docs and ONLYOFFICE files checked, and neither needs a preset of its own yet.
- 2026-11-02: ODT form controls keep their place on the line and multi-line text fields their lines; custom shapes keep their own geometry (LibreOffice draws its own shape after a save). In-line pictures placed against the text are raised from its size, and saved standing on the baseline. The corpus round-trip difference falls to 41k pixels, 90% below this morning's 408k; 42 of 125 documents round-trip pixel-identical (gap 19). Readiness unchanged.
- 2026-11-02: ODT charts keep their whole look through a save: the chart object as read is written back while the chart is unchanged (edited, it's written from WordCraft's chart). Frames outlined by a drawn stroke keep it, and a header with no stated height is LibreOffice's 0.5 cm. The corpus round-trip difference falls to 47k pixels (408k this morning); 41 of 125 documents round-trip pixel-identical (gap 19). Readiness unchanged.
- 2026-11-02: ODT: saving no longer makes a table's first row repeat on every page (a table style's header row had been taken for Repeat as header row), so LibreOffice's 16 agenda templates round-trip with their text the same; 91 of the 125 corpus documents now do, and the round-trip difference falls to 60k pixels (408k this morning) (gap 19). Readiness unchanged.
@@ -751,6 +752,9 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps
| Date | Change | Summary |
|---|---|---|
| 2026-11-04 | trivial | Progress log: ODT element audit (gap 19) |
| 2026-11-04 | trivial | Progress log: ODT checked against 220 files from other applications (gap 19) |
| 2026-11-04 | trivial | Progress log: OpenDocument files shown in Microsoft Word style or LibreOffice style (gap 19) |
| 2026-11-03 | trivial | Progress log: Language pack languages in every language list (CP4.3) |
| 2026-11-03 | trivial | Progress log: the missing-pack bar on a fresh install, the caret's typing language, and the spelling menu heading in the interface language (CP4.3) |
| 2026-11-03 | trivial | Progress log: Options › Language and Proofing redesign (CP4.3) |
+5 -5
View File
@@ -69,9 +69,9 @@ the system region's paper (A4, or Letter in the US and other Letter regions), re
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 with
WordCraft's own choices; set WORDCRAFT_ODF=libreoffice for LibreOffice's (see
docs/odf-interpretation.md).
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).
@@ -177,14 +177,14 @@ fn session(doc: wordcraft_doc::Document) -> Session {
}
/// How OpenDocument files fill in what their standard leaves open: `WORDCRAFT_ODF` names a preset
/// (`wordcraft`, the default, or `libreoffice`).
/// (`word`, Microsoft Word's style and the default, or `libreoffice`; `wordcraft` is taken too).
fn odf_profile() -> Result<wordcraft_doc::odf_profile::OdfProfile, String> {
use wordcraft_doc::odf_profile::{OdfPreset, OdfProfile};
match std::env::var("WORDCRAFT_ODF").ok().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 wordcraft, libreoffice")),
None => Err(format!("WORDCRAFT_ODF={v}: one of word, libreoffice")),
},
}
}
+15
View File
@@ -741,6 +741,19 @@ impl Document {
/// drawing from its data), which doesn't change the document: the markup kept as read for
/// the paragraphs and tables holding them (see [`crate::kept`]) follows them.
pub fn fill_objects(&mut self, wants: &dyn Fn(&InlineObject) -> bool, f: &mut dyn FnMut(&mut InlineObject)) {
// The stories (text boxes', notes') as read before, when filling changes one: they stay
// as read after it, so the paragraphs showing them keep their markup.
let holds = |b: &Arc<Block>| {
let mut any = false;
edit::each_para(b, 0, &mut |p| any |= p.objects.iter().any(wants));
any
};
let as_read: Vec<u32> = match self.kept.get() {
Some(k) if !k.story_fps.is_empty() && self.parts.values().any(|p| p.blocks.iter().any(holds)) => {
k.story_fps.iter().filter(|(id, fp)| **fp == crate::kept::story_fingerprint(self, **id)).map(|(id, _)| *id).collect()
}
_ => Vec::new(),
};
let mut moves: Vec<crate::kept::Moved> = Vec::new();
let mut stories: Vec<&mut Blocks> = vec![&mut self.body];
stories.extend(self.parts.values_mut().map(|p| &mut p.blocks));
@@ -761,10 +774,12 @@ impl Document {
moves.push((before, crate::kept::identities(b)));
}
}
let refreshed: Vec<(u32, u64)> = as_read.into_iter().map(|id| (id, crate::kept::story_fingerprint(self, id))).collect();
if let Some(k) = self.kept.get_mut() {
for (before, after) in moves {
k.follow(&before, &after);
}
k.story_fps.extend(refreshed);
}
}
+35
View File
@@ -324,6 +324,10 @@ impl KeptXml {
if let Some(w) = self.twin_wraps.remove(&(*fp, *rev)) {
self.twin_wraps.entry((*nfp, *nrev)).or_insert(w);
}
// Its group too: all a group's paragraphs are written as read, or none.
if let Some(g) = self.groups.remove(fp) {
self.groups.entry(*nfp).or_insert(g);
}
}
if let (Some(t), Some(nt)) = (before.table, after.table)
&& t != nt
@@ -659,4 +663,35 @@ mod tests {
k.keep_pending(2, big);
assert_eq!(k.pending.len(), 1);
}
/// Filling in what objects show (`Document::fill_objects`, a chart drawn from its data)
/// doesn't change the document: a paragraph's group and a text box's story stay as read.
#[test]
fn filling_objects_keeps_groups_and_stories_as_read() {
use crate::para::InlineObject;
let field =
|| InlineObject::Field { instr: "X".into(), result: String::new(), locked: false, dirty: false, code: false, form: String::new() };
let mut doc = crate::Document::from_text("Body");
let mut boxed = Paragraph::with_text("In the box", Default::default());
boxed.insert_object(0, field(), &Default::default()).unwrap();
let id = doc.add_part(crate::PartKind::TextBox, vec![crate::para_block(boxed)]);
doc.para_mut(crate::StoryRef::Body, &crate::Path::top(0)).unwrap().insert_object(0, field(), &Default::default()).unwrap();
let fp = fingerprint(doc.body[0].as_para().unwrap());
let mut k = KeptXml { format: "odt".into(), ..Default::default() };
k.paras.insert(fp, Arc::from("<text:p>Body</text:p>"));
k.groups.insert(fp, (1, 1));
k.story_fps.insert(id, story_fingerprint(&doc, id));
doc.kept = Kept::new(k);
doc.fill_objects(&|o| matches!(o, InlineObject::Field { result, .. } if result.is_empty()), &mut |o| {
if let InlineObject::Field { result, .. } = o {
*result = "filled".into();
}
});
let p = doc.body[0].as_para().unwrap();
assert_ne!(fingerprint(p), fp, "the paragraph's fingerprint moved");
let k = doc.kept.get().unwrap();
assert!(k.para(p).is_some());
assert_eq!(k.group(p), Some((1, 1)));
assert_eq!(k.story_fps.get(&id), Some(&story_fingerprint(&doc, id)));
}
}
+44 -17
View File
@@ -4,9 +4,9 @@
//! 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: WordCraft's own ([`OdfPreset::WordCraft`], the default), the
//! choices LibreOffice makes ([`OdfPreset::LibreOffice`]), or the user's own
//! ([`OdfPreset::Custom`]). `docs/odf-interpretation.md` lists every case.
//! 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};
@@ -16,9 +16,11 @@ use crate::props::Rgb;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum OdfPreset {
/// WordCraft's own: the behaviour it has for its own and Word documents.
/// Microsoft Word's style: the behaviour WordCraft has for its own and Word documents.
/// (`wordcraft`, its earlier id, is still taken.)
#[default]
WordCraft,
#[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).
@@ -26,18 +28,36 @@ pub enum OdfPreset {
}
impl OdfPreset {
pub const ALL: [OdfPreset; 3] = [OdfPreset::WordCraft, OdfPreset::LibreOffice, OdfPreset::Custom];
pub const ALL: [OdfPreset; 3] = [OdfPreset::Word, OdfPreset::LibreOffice, OdfPreset::Custom];
pub fn id(self) -> &'static str {
match self {
OdfPreset::WordCraft => "wordcraft",
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> {
OdfPreset::ALL.into_iter().find(|p| p.id().eq_ignore_ascii_case(id.trim()))
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",
}
}
}
@@ -108,15 +128,15 @@ pub struct OdfProfile {
impl Default for OdfProfile {
fn default() -> Self {
OdfProfile::wordcraft()
OdfProfile::word()
}
}
impl OdfProfile {
/// WordCraft's own choices: as it lays out its own and Word documents.
pub fn wordcraft() -> OdfProfile {
/// Microsoft Word's style: as WordCraft lays out its own and Word documents.
pub fn word() -> OdfProfile {
OdfProfile {
preset: OdfPreset::WordCraft,
preset: OdfPreset::Word,
page_margin: 0.0,
header_min_height: 0.0,
header_height_includes_spacing: false,
@@ -157,10 +177,10 @@ impl OdfProfile {
}
}
/// A preset's values (a custom profile starts from WordCraft's).
/// A preset's values (a custom profile starts from Word's style).
pub fn of(preset: OdfPreset) -> OdfProfile {
match preset {
OdfPreset::WordCraft | OdfPreset::Custom => OdfProfile { preset, ..OdfProfile::wordcraft() },
OdfPreset::Word | OdfPreset::Custom => OdfProfile { preset, ..OdfProfile::word() },
OdfPreset::LibreOffice => OdfProfile::libreoffice(),
}
}
@@ -208,9 +228,16 @@ mod tests {
#[test]
fn presets_and_custom_values() {
assert_eq!(OdfProfile::default().preset, OdfPreset::WordCraft);
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()));
@@ -219,10 +246,10 @@ mod tests {
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::wordcraft() };
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::wordcraft().layout().picture_lines_fit_pictures);
assert!(!OdfProfile::word().layout().picture_lines_fit_pictures);
}
}
+14 -2
View File
@@ -182,8 +182,9 @@ pub struct ResolvedPara {
pub tabs: Vec<TabStop>,
pub shading: Option<Rgb>,
pub borders: Option<Borders>,
/// Padding on the sides without a line (top, left, bottom, right), points: ODF's, which
/// ODF's (XSL's box model) keeps for documents opened from ODF (`Settings::odf`); Word drops it.
/// Padding on the sides without a line (top, left, bottom, right), points. ODF's box model
/// (XSL's) keeps it for documents opened from ODF (`Settings::odf`); Word drops it.
#[serde(default)]
pub padding: [f32; 4],
pub bidi: bool,
pub drop_cap: u8,
@@ -577,4 +578,15 @@ mod tests {
assert_eq!(p.tabs.len(), 3);
assert_eq!(p.tabs[0].pos, 100.0);
}
/// Resolved formatting saved before paragraph padding existed still reads: no padding.
#[test]
fn resolved_para_without_padding_reads() {
let p = StyleSheet::builtin().resolve_para(&ParaProps::default());
let mut v = serde_json::to_value(&p).unwrap();
v.as_object_mut().unwrap().remove("padding");
let back: ResolvedPara = serde_json::from_value(v).unwrap();
assert_eq!(back.padding, [0.0; 4]);
assert_eq!(back, ResolvedPara { padding: [0.0; 4], ..p });
}
}
+29 -4
View File
@@ -73,11 +73,11 @@ const MAX_WRAPPED: usize = 10_000;
/// it ends in. `None` for anything else (a TOC has its own update).
fn wrapped_list(s: &Session, i: usize) -> Option<(String, usize)> {
let p = s.doc.body.get(i)?.as_para()?;
let (off, instr) = p.objects_with_offsets().find_map(|(off, o)| match o {
let (k, off, instr) = p.objects_with_offsets().enumerate().find_map(|(k, (off, o))| match o {
InlineObject::FieldStart { instr, .. } => {
let t = instr.trim_start();
(t.starts_with("BIBLIOGRAPHY") || t.starts_with("INDEX") || t.starts_with("TOA") || figures_label(t).is_some())
.then(|| (off, t.to_string()))
.then(|| (k, off, t.to_string()))
}
_ => None,
})?;
@@ -85,11 +85,12 @@ fn wrapped_list(s: &Session, i: usize) -> Option<(String, usize)> {
if !p.text.get(..off).is_some_and(|t| t.chars().all(|c| c.is_whitespace() || c == wordcraft_doc::para::OBJ)) {
return None;
}
// Where it ends: the field end that closes it, nested fields counted.
// Where it ends: the field end that closes it, nested fields counted from its own start (a
// field before it in its paragraph, an empty `PAGE`…, closes before it and isn't it).
let mut depth = 0usize;
for (j, b) in s.doc.body.iter().enumerate().skip(i).take(MAX_WRAPPED) {
let Some(q) = b.as_para() else { continue };
for o in &q.objects {
for o in q.objects.iter().skip(if j == i { k } else { 0 }) {
match o {
InlineObject::FieldStart { .. } => depth += 1,
InlineObject::FieldEnd => {
@@ -388,3 +389,27 @@ fn entries_for(s: &mut Session, kind: &str) -> Vec<(String, bool)> {
}
out
}
#[cfg(test)]
mod tests {
use super::*;
/// A field before a list's in its paragraph (an empty `PAGE`) closes first: the list still
/// ends at its own field end, paragraphs below, not in its first paragraph.
#[test]
fn a_wrapped_list_ends_at_its_own_field_end() {
let mut d = Document::from_text("Old one\nOld two\nAfter");
let props = CharProps::default();
if let Ok(p) = d.para_mut(StoryRef::Body, &Path::top(0)) {
p.insert_object(0, InlineObject::FieldStart { instr: "BIBLIOGRAPHY".into(), locked: false, dirty: false }, &props).unwrap();
p.insert_object(0, InlineObject::FieldEnd, &props).unwrap();
p.insert_object(0, InlineObject::FieldStart { instr: "PAGE".into(), locked: false, dirty: false }, &props).unwrap();
}
if let Ok(p) = d.para_mut(StoryRef::Body, &Path::top(1)) {
let n = p.len();
p.insert_object(n, InlineObject::FieldEnd, &props).unwrap();
}
let s = Session::new(d);
assert_eq!(wrapped_list(&s, 0), Some(("BIBLIOGRAPHY".to_string(), 1)));
}
}
+1 -1
View File
@@ -346,7 +346,7 @@ fn save(s: &mut Session, v: &Value) -> CmdResult {
};
if !crate::io::can_encrypt(&path.to_string_lossy()) {
return Err(CmdError::Params(
"only Word documents (.docx, .docm, .dotx, .dotm) and ODT documents (.odt) can be saved with a password".into(),
"only Word documents (.docx, .docm, .dotx, .dotm) and OpenDocument text files (.odt, .ott) can be saved with a password".into(),
));
}
wordcraft_docx::check_password(pw).map_err(|e| CmdError::Params(e.to_string()))?;
+7 -1
View File
@@ -83,7 +83,13 @@ pub fn format_date_parts(fmt: &str, y: i64, m: usize, d: u32, (hh, mm, ss): (u32
let mut i = 0;
while i < chars.len() {
let c = chars.get(i).copied().unwrap_or(' ');
// Text in single quotes is shown as it is; two quotes in a row are one.
// Text in single quotes is shown as it is; two quotes in a row are one, inside quoted
// text or out of it.
if c == '\'' && chars.get(i + 1) == Some(&'\'') {
out.push('\'');
i += 2;
continue;
}
if c == '\'' {
i += 1;
while let Some(&q) = chars.get(i) {
+3
View File
@@ -341,6 +341,9 @@ fn date_pictures_keep_quoted_text() {
assert_eq!(format_date_parts("'Day' d 'at' h", 2026, 10, 21, (17, 0, 0), 0), "Day 21 at 5");
// An unclosed quote runs to the end.
assert_eq!(format_date_parts("yyyy 'and so", 2026, 10, 21, (0, 0, 0), 0), "2026 and so");
// Two quotes outside quoted text are one too (an apostrophe), not an empty quoted text.
assert_eq!(format_date_parts("yyyy '' MM", 2026, 10, 21, (0, 0, 0), 0), "2026 ' 10");
assert_eq!(format_date_parts("d''MM", 2026, 10, 21, (0, 0, 0), 0), "21'10");
}
#[test]
+3 -4
View File
@@ -23,7 +23,7 @@ pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("file.options", "Options", "File", options)
.params(
r#"{"pane"?: "general|display|proofing|save|language|accessibility|advanced|agents", "author"?: string, "initials"?: string, "units"?: "inches|centimeters|millimeters|points|picas|auto" (auto: the region's), "region"?: string (a locale tag like pl-PL, saved: the paper File › New starts on, the default units and the decimal separator; auto: the system's), "typingReplacesSelection"?: bool, "insertKeyOvertype"?: bool, "overtype"?: bool, "marks"?: {"tabs"?: bool, "spaces"?: bool, "paragraphs"?: bool, "hidden"?: bool}, "checkSpelling"?: bool, "markGrammar"?: bool, "ignoreUppercase"?: bool, "ignoreNumbers"?: bool, "ignoreInternet"?: bool, "dictionariesFolder"?: string (a folder of Hunspell .aff/.dic files searched first, saved; auto: the system locations only), "userTemplatesFolder"?: string (the folder File › New lists personal templates from, saved; auto: the app's own Templates folder; not a network folder), "addWords"?: [string], "removeWords"?: [string], "odf"?: {"preset"?: "wordcraft|libreoffice|custom", "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], "odf"?: {"preset"?: "word|libreoffice|custom" (word: Microsoft Word style, the default; libreoffice: LibreOffice style), "pageMargin"?: pt, "headerMinHeight"?: pt, "headerHeightIncludesSpacing"?: bool, "dynamicSpacing"?: bool, "tabsFromIndent"?: bool, "columnGap"?: pt, "shapeFill"?: "RRGGBB", "shapeLine"?: "RRGGBB", "hairline"?: pt, "pageFrameParagraphIsPage"?: bool, "inlineInvalidRelationIsBaseline"?: bool, "pictureLinesFitPictures"?: bool, "balanceSectionColumns"?: bool, "sumParagraphSpacing"?: bool, "widowControl"?: bool} (how OpenDocument files opened from now on fill in what their standard leaves open, docs/odf-interpretation.md; a value without a preset makes it custom)} — with any setting it changes them and opens nothing; without, it opens File › Options (at `pane`). Returns the settings."#,
)
.pure(),
CommandSpec::new("text.overtype", "Overtype", "File › Options › Advanced", |s, v| {
@@ -250,7 +250,7 @@ pub fn current(s: &Session) -> Value {
})
}
/// The `odf` setting: `{"preset"?: "wordcraft|libreoffice|custom", …case values}` over `current`.
/// 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`.
@@ -270,8 +270,7 @@ fn odf_param(v: &Value, current: &OdfProfile) -> Result<Option<OdfProfile>, CmdE
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`: wordcraft, libreoffice or custom".into()))?;
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;
+9 -1
View File
@@ -11812,10 +11812,18 @@ fn emphasis_marks_are_set_and_drawn() {
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::wordcraft(), "WordCraft's own is the default");
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 [
+2
View File
@@ -145,6 +145,8 @@ pub fn inlines_html(inlines: &[Inline]) -> String {
| Inline::FieldStart(_)
| Inline::FieldEnd
| Inline::AnchorEnd(_) => {}
// ASK and SET show nothing (see `model::shows_nothing`).
Inline::Field { instr, .. } if model::shows_nothing(instr) => {}
Inline::Field { result, fmt, .. } | Inline::Control { text: result, fmt, .. } => {
let (open, close) = fmt_tags(fmt);
let at_start = o.is_empty();
+2
View File
@@ -250,6 +250,8 @@ impl Writer {
| Inline::FieldStart(_)
| Inline::FieldEnd
| Inline::AnchorEnd(_) => {}
// ASK and SET show nothing (see `model::shows_nothing`).
Inline::Field { instr, .. } if model::shows_nothing(instr) => {}
Inline::Field { result, fmt, .. } | Inline::Control { text: result, fmt, .. } => {
let s = self.span(result, fmt);
push_apart(&mut o, &s);
+2
View File
@@ -136,6 +136,8 @@ fn inlines_md_impl(inlines: &[Inline], in_table: bool) -> String {
pieces.push((String::new(), Fmt::default(), Some(format!("![{}]()", escape(alt)))))
}
Inline::Equation { .. } => {}
// ASK and SET show nothing (see `model::shows_nothing`).
Inline::Field { instr, .. } if model::shows_nothing(instr) => {}
Inline::Field { result, fmt, .. } | Inline::Control { text: result, fmt, .. } => pieces.push((result.clone(), fmt.clone(), None)),
Inline::Note { .. }
| Inline::Comment { .. }
+38
View File
@@ -31,6 +31,7 @@ pub use types::{
Affix, BoxLook, CODE_STYLE, Cell, CellPic, Chain, Chart, Conn, FBlock, FComment, FTable, Flow, FlowOpts, Fmt, FmtChange, Group, Img, Inline,
Kind, Label, ListInfo, MAX_DEPTH, MAX_FONT_NAME, MAX_GROUP_DEPTH, MAX_LINK, MONO_FONT, Member, Meta, OdfObject, Para, RUBY_BASE_SIZE, RevMark,
Spacing, TableExtra, clean_text, equations_as_text, flatten_tables, kept_font, kept_link, ranged_anchors, ruby_as_text, ruby_object,
shows_nothing,
};
pub(crate) use types::{Dropped, begin_import, dropped};
@@ -45,6 +46,43 @@ thread_local! {
mod tests {
use super::*;
/// ASK and SET show nothing (layout shows no result for them): no text export writes the
/// result they hold, from the document or from a flow with fields; ODT keeps it.
#[test]
fn ask_and_set_fields_export_no_text() {
let mut doc = wordcraft_doc::Document::from_text("Before after");
if let Ok(p) = doc.para_mut(wordcraft_doc::StoryRef::Body, &wordcraft_doc::Path::top(0)) {
for instr in ["SET name other", "ASK other \"Prompt\""] {
let field = wordcraft_doc::para::InlineObject::Field {
instr: instr.into(),
result: "SECRETVALUE".into(),
locked: false,
dirty: false,
code: false,
form: String::new(),
};
p.insert_object(7, field, &Default::default()).unwrap();
}
}
for (name, out) in [
("markdown", crate::markdown::export(&doc)),
("html", crate::html::export(&doc)),
("latex", crate::latex::export(&doc)),
("rtf", crate::rtf::export(&doc)),
] {
assert!(out.contains("Before"), "{name}: {out}");
assert!(!out.contains("SECRETVALUE"), "{name}: {out}");
}
let flow = from_doc_with(&doc, FlowOpts { fields: true, ..Default::default() });
assert!(!crate::html::export_flow(&flow, "en").contains("SECRETVALUE"));
// The flow keeps the result for ODT.
let kept = flow
.blocks
.iter()
.any(|b| matches!(b, FBlock::Para(p) if p.inlines.iter().any(|i| matches!(i, Inline::Field { result, .. } if result == "SECRETVALUE"))));
assert!(kept);
}
#[test]
fn percent_decode_requires_hex_digits() {
assert_eq!(percent_decode("%+A"), b"%+A".to_vec());
+9 -1
View File
@@ -442,6 +442,14 @@ fn direct_spacing(p: &wordcraft_doc::props::ParaProps) -> Spacing {
/// A document paragraph as flow paragraphs: a page break character inside the text ends the
/// paragraph, and the text after it starts a new one that carries `page_break`.
/// An object's text in the flow: ASK and SET show nothing, whatever result they hold.
fn shown_text(o: &InlineObject) -> &str {
match o {
InlineObject::Field { instr, .. } if super::shows_nothing(instr) => "",
o => o.plain_text(),
}
}
pub fn flow_paras(doc: &Document, p: &Paragraph) -> Vec<Para> {
flow_paras_in(doc, p, FlowOpts::default())
}
@@ -599,7 +607,7 @@ fn flow_paras_in(doc: &Document, p: &Paragraph, opts: FlowOpts) -> Vec<Para> {
out.inlines.push(Inline::Anchor(name.clone()));
}
Some(o @ (InlineObject::ControlStart { .. } | InlineObject::ControlEnd)) if control.object(o, opts, &mut buf, &mut out, &f) => {}
Some(o) => buf.push_str(o.plain_text()),
Some(o) => buf.push_str(shown_text(o)),
None => {}
}
raise_drawings(&mut out.inlines, from, props.position.filter(|_| opts.paras));
+9
View File
@@ -428,6 +428,14 @@ pub struct FComment {
pub blocks: Vec<FBlock>,
}
/// A field that never shows its result: `ASK` and `SET` only assign text to a bookmark
/// (ECMA-376 §17.16.5.4, §17.16.5.60), as layout shows them. Its stored result stays in the
/// flow (ODT writes it back); text exports write nothing for it.
pub fn shows_nothing(instr: &str) -> bool {
let name = instr.split_whitespace().next().unwrap_or("");
name.eq_ignore_ascii_case("ASK") || name.eq_ignore_ascii_case("SET")
}
/// What [`super::from_doc_with`] keeps besides text and formatting.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct FlowOpts {
@@ -769,6 +777,7 @@ impl Para {
Some(font) if wordcraft_doc::symbol_fonts::is_symbol_font(font) => s.push_str(&wordcraft_doc::symbol_fonts::map_text(font, t)),
_ => s.push_str(t),
},
Inline::Field { instr, .. } if shows_nothing(instr) => {}
Inline::Equation { linear: t, .. }
| Inline::Ruby { base: t, .. }
| Inline::Field { result: t, .. }
+22 -2
View File
@@ -62,12 +62,13 @@ pub(super) fn picture(parts: &[Part]) -> String {
Part::AmPm => s.push_str("AM/PM"),
Part::Text(t) => {
let t: String = t.chars().filter(|c| !matches!(c, '"' | '\\')).collect();
if t.chars().any(|c| c.is_alphabetic() || c == '\'') {
// Letters are quoted; an apostrophe is two, inside quoted text or out of it.
if t.chars().any(char::is_alphabetic) {
s.push('\'');
s.push_str(&t.replace('\'', "''"));
s.push('\'');
} else {
s.push_str(&t);
s.push_str(&t.replace('\'', "''"));
}
}
}
@@ -87,6 +88,12 @@ fn parts_of(pic: &str) -> Vec<Part> {
}
};
while let Some(&c) = chars.get(i) {
// Two quotes outside quoted text are an apostrophe, as inside it.
if c == '\'' && chars.get(i + 1) == Some(&'\'') {
text.push('\'');
i += 2;
continue;
}
if c == '\'' {
i += 1;
while let Some(&q) = chars.get(i) {
@@ -337,4 +344,17 @@ mod value_tests {
assert_eq!(value_shown("dd/MM/yyyy", ""), None);
assert_eq!(value_shown("", "anything"), None);
}
/// An apostrophe in a date style's text is two quotes in the picture, quoted or not, and
/// reads back as one.
#[test]
fn an_apostrophe_in_a_date_styles_text_round_trips() {
use super::{Part, parts_of, picture};
for text in ["'", "''", "o'clock", "'tis", " ' "] {
let parts = vec![Part::Text(text.to_string())];
let pic = picture(&parts);
assert_eq!(parts_of(&pic), parts, "{text:?} as {pic:?}");
}
assert_eq!(picture(&[Part::Text("'".into())]), "''");
}
}
+28 -4
View File
@@ -40,11 +40,14 @@ fn element<'a>(xml: &'a str, name: &str) -> &'a str {
let close = format!("</{name}>");
let mut depth = 0usize;
let mut at = tag_end;
// Nesting is bounded like any XML here: deeper is malformed.
for _ in 0..super::limits::MAX_XML_DEPTH.saturating_mul(2) {
// Every step moves past a tag, so this ends; nesting is bounded like any XML here (deeper is
// malformed), however many elements of the name sit side by side within.
loop {
let rest = xml.get(at..).unwrap_or("");
let next_close = rest.find(&close);
let next_open = rest.match_indices(&open).map(|(i, _)| i).find(|&i| {
// Only opens before that close count (and only they are looked for).
let before = next_close.and_then(|c| rest.get(..c)).unwrap_or(rest);
let next_open = before.match_indices(&open).map(|(i, _)| i).find(|&i| {
let after = rest.get(i + open.len()..).unwrap_or("");
after.chars().next().is_some_and(|c| c == '>' || c == '/' || c.is_whitespace())
// Not self-closed.
@@ -53,6 +56,9 @@ fn element<'a>(xml: &'a str, name: &str) -> &'a str {
match (next_open, next_close) {
(Some(o), Some(c)) if o < c => {
depth += 1;
if depth > super::limits::MAX_XML_DEPTH {
return xml.get(start..).unwrap_or("");
}
at += o + open.len();
}
(_, Some(c)) if depth > 0 => {
@@ -64,7 +70,6 @@ fn element<'a>(xml: &'a str, name: &str) -> &'a str {
(_, None) => return xml.get(start..).unwrap_or(""),
}
}
xml.get(start..).unwrap_or("")
}
/// What is inside element `el` (an element string as [`element`] gives).
@@ -401,3 +406,22 @@ fn namespaces(xml: &str) -> Vec<&str> {
}
out
}
#[cfg(test)]
mod element_tests {
use super::element;
/// An element ends at its own close tag however many elements of its name it holds side by
/// side (inline objects' own `office:body`s), not after a count of tags.
#[test]
fn an_element_holding_many_of_its_name_ends_at_its_own_close() {
let inner = "<office:body><text:p>x</text:p></office:body>".repeat(1200);
let outer = format!("<office:body>{inner}</office:body>");
let xml = format!("<office:document>{outer}<office:settings/></office:document>");
assert_eq!(element(&xml, "office:body"), outer);
// Nested deeper than any XML here: malformed, to the end, as an unclosed one.
let deep = format!("{}{}", "<office:body>".repeat(600), "</office:body>".repeat(600));
assert_eq!(element(&deep, "office:body"), deep);
assert_eq!(element("<office:body><x/>", "office:body"), "<office:body><x/>");
}
}
+7 -6
View File
@@ -77,11 +77,9 @@ pub(super) fn read_changes(xml: &[u8]) -> (String, String, u32) {
},
});
let date = date.trim();
let date = if date.is_empty() || date.ends_with(['Z', 'z']) || date.contains('+') {
date.to_string()
} else {
format!("{}Z", date.split('.').next().unwrap_or(""))
};
// Zoned already: `Z`, or an offset (`+hh:mm`, or `-hh:mm` in the time part), as `read_meta`.
let zoned = date.ends_with(['Z', 'z']) || date.contains('+') || date.split_once('T').is_some_and(|(_, time)| time.contains('-'));
let date = if date.is_empty() || zoned { date.to_string() } else { format!("{}Z", date.split('.').next().unwrap_or("")) };
(by.trim().to_string(), date, cycles.trim().parse::<u32>().unwrap_or(0))
}
@@ -1243,8 +1241,11 @@ fn read_pages(xml: &[u8], paper: (f32, f32), profile: &OdfProfile) -> Pages {
/// page size and margins are its own (the first page's).
fn first_pages(pages: &mut Pages) {
let names: Vec<(String, String)> = pages.next.iter().map(|(a, b)| (a.clone(), b.clone())).collect();
// The masters as read: a master the loop has already changed (Left and Right pages naming each
// other) gives the next one its own headers, whatever order the map yields them in.
let read = pages.masters.clone();
for (name, next) in names {
let Some(after) = pages.masters.get(&next).cloned() else { continue };
let Some(after) = read.get(&next).cloned() else { continue };
let Some(sec) = pages.masters.get_mut(&name) else { continue };
let (own_header, own_footer) = (sec.headers.default, sec.footers.default);
sec.headers = after.headers;
+12
View File
@@ -1313,3 +1313,15 @@ fn a_paragraph_starting_a_column_starts_with_a_column_break() {
assert!(out.contains(r#"fo:break-before="column""#) && !out.contains('\u{e}'), "{out}");
assert_eq!(text(&import(&export(&doc).unwrap()).unwrap()), ["Side", "\u{e}Education"]);
}
#[test]
fn meta_modified_date_keeps_a_negative_offset() {
let meta = |date: &str| format!("<office:document-meta><office:meta><dc:date>{date}</dc:date></office:meta></office:document-meta>");
// A `-hh:mm` offset is a zone: no `Z` after it (an invalid xsd:dateTime written back on save).
assert_eq!(super::import::read_changes(meta("2024-03-05T13:45:00-05:00").as_bytes()).1, "2024-03-05T13:45:00-05:00");
assert_eq!(super::import::read_changes(meta("2024-03-05T13:45:00+01:00").as_bytes()).1, "2024-03-05T13:45:00+01:00");
assert_eq!(super::import::read_changes(meta("2024-03-05T13:45:00Z").as_bytes()).1, "2024-03-05T13:45:00Z");
// Zoneless (the date's own `-`s are not an offset): UTC, fraction dropped.
assert_eq!(super::import::read_changes(meta("2024-03-05T13:45:00.25").as_bytes()).1, "2024-03-05T13:45:00Z");
assert_eq!(super::import::read_changes(meta("").as_bytes()).1, "");
}
+2 -2
View File
@@ -154,7 +154,7 @@ fn a_page_anchored_frames_paragraph_relations_are_the_pages() {
};
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::wordcraft());
let f = paragraph(wordcraft_doc::odf_profile::OdfProfile::word());
assert_eq!((f.h_rel, f.v_rel), (Anchor::Margin, Anchor::Margin));
let content = |profile| {
float(&open_as(
@@ -165,7 +165,7 @@ fn a_page_anchored_frames_paragraph_relations_are_the_pages() {
};
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::wordcraft());
let f = content(wordcraft_doc::odf_profile::OdfProfile::word());
assert_eq!((f.h_rel, f.v_rel), (Anchor::Margin, Anchor::Margin));
// Anchored to a paragraph, they stay the paragraph's.
let f = float(&open(r#"style:horizontal-rel="paragraph-content" style:vertical-rel="char""#, r#"text:anchor-type="char""#));
+27 -1
View File
@@ -175,7 +175,7 @@ fn a_headers_spacing_stays_below_content_taller_than_it() {
let grow = wordcraft_doc::odf_profile::OdfProfile {
preset: wordcraft_doc::odf_profile::OdfPreset::Custom,
dynamic_spacing: true,
..wordcraft_doc::odf_profile::OdfProfile::wordcraft()
..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));
@@ -250,6 +250,32 @@ fn a_first_page_master_followed_by_another_is_a_different_first_page() {
assert!(first.trim().starts_with("<text:p") && !first.contains("Running head") && s.contains("Running head"), "{s}");
}
#[test]
fn master_pages_naming_each_other_take_the_next_ones_own_headers() {
// LibreOffice's Left Page and Right Page name each other as next: the pages after the first
// take the other master's own header, whatever order the masters are gone through in (each
// import hashes them afresh, so a few imports cover both orders).
let ns = crate::odt::tests::NS_T;
let styles = format!(
r#"<office:document-styles {ns}><office:styles><style:style style:name="Opening" style:family="paragraph" style:master-page-name="Right_20_Page"/></office:styles><office:automatic-styles><style:page-layout style:name="pm1"><style:page-layout-properties fo:page-width="8.5in" fo:page-height="11in"/></style:page-layout></office:automatic-styles><office:master-styles><style:master-page style:name="Left_20_Page" style:page-layout-name="pm1" style:next-style-name="Right_20_Page"><style:header><text:p>Left head</text:p></style:header></style:master-page><style:master-page style:name="Right_20_Page" style:page-layout-name="pm1" style:next-style-name="Left_20_Page"><style:header><text:p>Right head</text:p></style:header></style:master-page></office:master-styles></office:document-styles>"#
);
let content = format!(
r#"<office:document-content {ns}><office:body><office:text><text:p text:style-name="Opening">Recto.</text:p><text:p>More.</text:p></office:text></office:body></office:document-content>"#
);
let file = crate::odt::tests::package(&content, Some(&styles));
for _ in 0..24 {
let d = import(&file).unwrap();
let text = |id: Option<u32>| -> String {
id.and_then(|id| d.parts.get(&id))
.map(|p| p.blocks.iter().filter_map(|b| b.as_para()).map(|p| p.text.clone()).collect())
.unwrap_or_default()
};
let sec = &d.last_section;
assert_eq!(text(sec.headers.first), "Right head", "the first page is the Right Page's own");
assert_eq!(text(sec.headers.default), "Left head", "the pages after it the Left Page's");
}
}
#[test]
fn a_watermark_is_a_shape_in_the_header_and_reads_back_as_one() {
// Written as LibreOffice and Word write theirs (a fontwork shape behind the text in every
+1 -1
View File
@@ -4,7 +4,7 @@
//!
//! Every sample here (and any sample file added for the no-edit loss gate) is written by us for
//! the purpose, with our own text: never a file or text taken from LibreOffice, a bug tracker,
//! another application's templates or anyone else's document (CLAUDE.md, clean room).
//! another application's templates or anyone else's document, so every sample can be committed.
use crate::odt::tests::{NS_T, content_of, package, styles_of};
use crate::odt::{export, import};
+11 -12
View File
@@ -47,10 +47,10 @@ fn values_a_file_states_are_kept_under_every_preset() {
shape_fill: Rgb(1, 2, 3),
shape_line: Rgb(4, 5, 6),
hairline: 9.0,
..OdfProfile::wordcraft()
..OdfProfile::word()
};
let near = |a: f32, b: f32| (a - b).abs() < 0.05;
for profile in [OdfProfile::wordcraft(), OdfProfile::libreoffice(), odd] {
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);
@@ -79,15 +79,15 @@ fn a_sections_column_balance_is_its_own_else_the_profiles_and_is_written() {
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::wordcraft(), OdfProfile::libreoffice()] {
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::wordcraft()), None);
assert_eq!(balance("", &OdfProfile::word()), None);
// Written as WordCraft lays it out, so every reader balances it the same.
for (props, written) in [("", "true"), (r#"text:dont-balance-text-columns="false""#, "false")] {
let d = import_as(&doc(props), &OdfProfile::wordcraft());
let d = import_as(&doc(props), &OdfProfile::word());
let saved = crate::odt::export(&d).unwrap();
let content = crate::odt::tests::entry(&saved, "content.xml");
assert!(content.contains(&format!(r#"text:dont-balance-text-columns="{written}""#)), "{props}: {content}");
@@ -115,14 +115,14 @@ fn paragraph_spacing_adds_up_as_the_document_says_else_as_the_profile_does() {
}
z.finish().unwrap().into_inner()
};
for p in [OdfProfile::wordcraft(), OdfProfile::libreoffice()] {
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::wordcraft()).settings.sum_paragraph_spacing);
assert!(!import_as(&with(None), &OdfProfile::word()).settings.sum_paragraph_spacing);
for (add, written) in [(Some(true), "true"), (None, "false")] {
let d = import_as(&with(add), &OdfProfile::wordcraft());
let d = import_as(&with(add), &OdfProfile::word());
let saved = crate::odt::export(&d).unwrap();
let settings = crate::odt::tests::entry(&saved, "settings.xml");
assert!(settings.contains(&format!(r#"config:name="AddParaTableSpacing" config:type="boolean">{written}<"#)), "{add:?}: {settings}");
@@ -148,14 +148,13 @@ 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::wordcraft(), OdfProfile::libreoffice()] {
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::wordcraft()));
assert!(on(&doc(""), &OdfProfile::word()));
assert!(!on(&doc(""), &OdfProfile::libreoffice()));
for (p, written) in [(OdfProfile::wordcraft(), r#"fo:widows="2" fo:orphans="2""#), (OdfProfile::libreoffice(), r#"fo:widows="0" fo:orphans="0""#)]
{
for (p, written) in [(OdfProfile::word(), r#"fo:widows="2" fo:orphans="2""#), (OdfProfile::libreoffice(), r#"fo:widows="0" fo:orphans="0""#)] {
let saved = crate::odt::tests::export_model(&import_as(&doc(""), &p)).unwrap();
let styles = crate::odt::tests::entry(&saved, "styles.xml");
assert!(styles.contains(written), "{:?}: {styles}", p.preset);
+4 -4
View File
@@ -67,12 +67,12 @@ fn tabs_relative_to_the_indent_move_with_it() {
let d = import_as(&with_settings(&content(r#"fo:margin-left="0.5in""#), relative), profile);
tabs(&d).first().map(|t| t.pos)
};
for profile in [OdfProfile::libreoffice(), OdfProfile::wordcraft()] {
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::wordcraft()), Some(72.0));
assert_eq!(first(None, &OdfProfile::word()), Some(72.0));
}
#[test]
@@ -163,7 +163,7 @@ fn tabs_measured_from_the_indent_are_written_so() {
// A file measuring tabs from the indent (LibreOffice's own) is written back so, its stops
// from the indent again: LibreOffice's default stops then count from the indent as before
// (measured: with the setting false they moved to the margin's grid).
let d = import_as(&with_settings(&content(r#"fo:margin-left="0.5in""#), Some(true)), &OdfProfile::wordcraft());
let d = import_as(&with_settings(&content(r#"fo:margin-left="0.5in""#), Some(true)), &OdfProfile::word());
assert!(d.settings.odf.is_some_and(|o| o.tabs_from_indent));
let saved = export(&d).unwrap();
let s = crate::odt::tests::entry(&saved, "settings.xml");
@@ -173,7 +173,7 @@ fn tabs_measured_from_the_indent_are_written_so() {
let back = import(&saved).unwrap();
assert_eq!(tabs(&back).first().map(|t| t.pos), Some(108.0));
// One measuring them from the margin stays so.
let d = import_as(&with_settings(&content(r#"fo:margin-left="0.5in""#), Some(false)), &OdfProfile::wordcraft());
let d = import_as(&with_settings(&content(r#"fo:margin-left="0.5in""#), Some(false)), &OdfProfile::word());
let s = crate::odt::tests::entry(&export(&d).unwrap(), "settings.xml");
assert!(s.contains(r#"config:name="TabsRelativeToIndent" config:type="boolean">false<"#), "{s}");
}
+29 -2
View File
@@ -189,10 +189,11 @@ pub(in crate::odt) const DEFAULT_BOX_INSET: [f32; 4] = [7.2, 3.6, 7.2, 3.6];
impl Writer {
pub(in crate::odt) fn picture(&mut self, img: &model::Img) -> String {
let ext = if img.ext == "jpeg" { "jpg".to_string() } else { img.ext.clone() };
// Not a name the file's own pictures, kept as read, have.
// Not a name the file's own pictures, kept as read, have, nor one an earlier picture
// took (after skipping a reserved name, the count is behind the names taken).
let mut n = self.pictures.len() + 1;
let mut name = format!("Pictures/{}image{n}.{ext}", self.prefix);
while self.reserved.contains(&name) && n < usize::MAX {
while (self.reserved.contains(&name) || self.pictures.iter().any(|(taken, _, _)| *taken == name)) && n < usize::MAX {
n += 1;
name = format!("Pictures/{}image{n}.{ext}", self.prefix);
}
@@ -618,3 +619,29 @@ impl Writer {
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_picture_never_takes_a_name_an_earlier_one_took() {
// Re-saving a file WordCraft saved: its `wcimage1.png` is kept as read, so the first new
// picture skips to 2, and the second must not take 2 again (a duplicate zip entry fails
// the save).
let mut w = Writer::with_prefix("wc");
w.reserved = std::sync::Arc::new(std::iter::once("Pictures/wcimage1.png".to_string()).collect());
let img = model::Img {
data: std::sync::Arc::new(vec![1, 2, 3]),
ext: "png".into(),
w: 10.0,
h: 10.0,
alt: String::new(),
float: None,
object: None,
crop: [0.0; 4],
};
let names: Vec<String> = (0..3).map(|_| w.picture(&img)).collect();
assert_eq!(names, ["Pictures/wcimage2.png", "Pictures/wcimage3.png", "Pictures/wcimage4.png"]);
}
}
+2
View File
@@ -399,6 +399,8 @@ impl Writer {
}
// RTF keeps no ruby yet: its base text, in the run's formatting.
Inline::Ruby { base, fmt, .. } => self.run(base, fmt, out),
// ASK and SET show nothing (see `model::shows_nothing`).
Inline::Field { instr, .. } if model::shows_nothing(instr) => {}
Inline::Field { result, fmt, .. } | Inline::Control { text: result, fmt, .. } => self.run(result, fmt, out),
Inline::Figure(_)
| Inline::Chart(_)
+4 -2
View File
@@ -361,8 +361,10 @@ impl<'a> Shaped<'a> {
let [el, et, er, eb] = float.effect_extent();
let (px, py) = float.spin_pad(w * s, h * s);
let tall = h * s + et + eb + 2.0 * py;
// Lowered at most until its top is on the baseline.
let raise = if raise.is_finite() { raise.clamp(-tall, 1584.0) } else { 0.0 };
// Lowered at most until its top is on the baseline. The bound is made finite and
// not above `clamp`'s top whatever the extents give (`clamp` panics otherwise).
let floor = if tall.is_finite() { -tall.max(0.0) } else { 0.0 };
let raise = if raise.is_finite() { raise.clamp(floor, 1584.0) } else { 0.0 };
self.b.clusters.push(slot.cluster(ClKind::Object(k), w * s + el + er + 2.0 * px, (tall + raise, -raise)));
} else {
self.push(slot, ClKind::Object(k), 0.0, 0.0);
+8 -2
View File
@@ -1269,10 +1269,16 @@ fn cell_picture(pic: &wordcraft_doc::props::CellPicture, rect: Rect, story: Stor
match pic.repeat {
PictureRepeat::Stretch => vec![image(rect)],
PictureRepeat::Tile if sized => {
let (cols, rows) = ((rect.w / w).ceil().clamp(1.0, 400.0) as usize, (rect.h / h).ceil().clamp(1.0, 400.0) as usize);
let (cols, rows) = ((rect.w / w).ceil().max(1.0), (rect.h / h).ceil().max(1.0));
// Too many tiles to draw (or a cell of no finite size): one picture stretched over
// the cell, as a page's picture is, so the cell is always covered.
let tiles = cols * rows;
if !tiles.is_finite() || tiles > MAX_TILES as f32 {
return vec![image(rect)];
}
let (cols, rows) = (cols as usize, rows as usize);
(0..rows)
.flat_map(|r| (0..cols).map(move |c| (r, c)))
.take(MAX_TILES)
.map(|(r, c)| image(Rect::new(rect.x + c as f32 * w, rect.y + r as f32 * h, w, h)))
.collect()
}
+15
View File
@@ -12903,6 +12903,21 @@ fn a_cells_background_picture_is_drawn_clipped_to_the_cell() {
let (cell, images) = &c[0];
let want = (cell.w / 30.0).ceil() as usize * (cell.h / 6.0).ceil() as usize;
assert_eq!(images.len(), want, "{cell:?}");
// More tiles than are drawn (a small pattern in a big cell): stretched over the whole cell,
// not tiled over its top rows only.
let mut t = wordcraft_doc::Table::new(1, 1, 400.0);
t.rows[0].cells[0].props.picture =
Some(Box::new(CellPicture { media: media.clone(), size: [2.0, 1.0], repeat: PictureRepeat::Tile, align: [0.0, 0.0] }));
d.body = vec![std::sync::Arc::new(wordcraft_doc::Block::Table(t)), wordcraft_doc::para_block(wordcraft_doc::Paragraph::new())];
let c = clips(&lay(&d));
let (cell, images) = &c[0];
assert!((cell.w / 2.0).ceil() * (cell.h / 1.0).ceil() > 400.0, "{cell:?}");
assert_eq!(images.len(), 1, "{cell:?}");
let i = images[0];
assert!(
(i.x - cell.x).abs() < 0.01 && (i.y - cell.y).abs() < 0.01 && (i.w - cell.w).abs() < 0.01 && (i.h - cell.h).abs() < 0.01,
"{i:?} in {cell:?}"
);
}
#[test]
+6 -3
View File
@@ -3,7 +3,7 @@ use super::glyphs::{baseline, cluster_ends, glyph_id, glyph_outlines, left_to_ri
use super::options::{PdfError, PdfOptions};
use super::paint::{append_path, fill, grown, norm, ok, paint_fill, pdf_line_cap, rect_path, shape_effects, shape_path};
use super::picture::{Vector, build_vector, load_image, paint_of, vector_picture};
use super::tags::{Role, TagEntry, figure_alt, graphic_alt, holds_picture};
use super::tags::{Role, TagEntry, figure_alt, graphic_alt, holds_picture, holds_text};
use super::{
Annotation, Arc, Artifact, ArtifactType, ClKind, ContentTag, DisplayOptions, DocLayout, DocPath, Document, Draw, FaceRef, Fill, FillRule, Font,
HashMap, HashSet, Identifier, Image, InlineObject, ItemDisplay, KrillaGlyph, LineCap, LineEnd, LineJoin, LineStyle, LinkAnnotation, Node,
@@ -223,8 +223,11 @@ impl Exporter<'_> {
// A rotated inline picture, or a rotated group holding pictures: turned as a
// whole, each picture tagged on its own (see `rotated_pictures`).
Draw::Rotated { items, .. } if items.iter().any(holds_picture) => self.rotated_pictures(s, d),
// A turned text box's line: still the paragraph's text.
Draw::Rotated { .. } => self.tagged(s, Role::Para(idx), None, |me, s| me.draw(s, d)),
// A turned text box's line: still the paragraph's text. A rotated shape
// without text falls to the artifacts below.
Draw::Rotated { items, .. } if items.iter().any(holds_text) => {
self.tagged(s, Role::Para(idx), None, |me, s| me.draw(s, d))
}
// An inline chart, diagram or described shape: its text is part of the figure, not the paragraph.
Draw::Figure { alt, kind, draws: inner } => self.tagged(s, Role::Figure, Some(figure_alt(alt, *kind)), |me, s| {
for d in inner {
+25
View File
@@ -14,6 +14,15 @@ pub(crate) fn holds_picture(d: &Draw) -> bool {
}
}
/// Text, or a rotation (or clip) holding it at any depth: a turned text box's lines.
pub(crate) fn holds_text(d: &Draw) -> bool {
match d {
Draw::Glyphs { .. } | Draw::Turned { .. } => true,
Draw::Rotated { items, .. } | Draw::Clip { items, .. } => items.iter().any(holds_text),
_ => false,
}
}
/// The alt text of an inline figure: a graphic's (see [`graphic_alt`]) or a described shape's.
pub(crate) fn figure_alt(alt: &str, kind: Option<GraphicKind>) -> &str {
match kind {
@@ -38,3 +47,19 @@ pub(crate) enum Role {
/// A floating picture (its own Figure).
Figure,
}
#[cfg(test)]
mod holds_tests {
use super::{Draw, holds_text};
/// A rotated shape with no text isn't the paragraph's text; a turned text box's lines are.
#[test]
fn only_a_rotation_holding_text_holds_text() {
let turned = |items| Draw::Rotated { cx: 0.0, cy: 0.0, spin: Default::default(), items };
let text = Draw::Turned { x: 0.0, y: 0.0, turn: Default::default(), items: Vec::new() };
assert!(!holds_text(&turned(Vec::new())));
assert!(!holds_text(&turned(vec![turned(Vec::new())])));
assert!(holds_text(&turned(vec![text.clone()])));
assert!(holds_text(&turned(vec![Draw::Clip { rect: Default::default(), items: vec![turned(vec![text])] }])));
}
}
+1 -1
View File
@@ -141,7 +141,7 @@ impl WordApp {
&& let Some(pw) = params.get("password").filter(|v| !v.is_null())
{
if !wordcraft_engine::io::can_encrypt(&name) || pw.as_str().is_none_or(str::is_empty) {
return Err("only Word documents (.docx, .docm, .dotx, .dotm) and ODT documents (.odt) can be saved with a password, and it can't be empty".into());
return Err("only Word documents (.docx, .docm, .dotx, .dotm) and OpenDocument text files (.odt, .ott) can be saved with a password, and it can't be empty".into());
}
self.session.run("file.encrypt", &json!({"password": pw})).map_err(|e| e.to_string())?;
}
+8 -7
View File
@@ -671,20 +671,21 @@ 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): WordCraft's choices, LibreOffice's, or the user's
/// own, one setting per case.
/// 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.
let presets = [(OdfPreset::WordCraft, "WordCraft"), (OdfPreset::LibreOffice, "LibreOffice"), (OdfPreset::Custom, "Custom")];
let shown = presets.iter().find(|(p, _)| *p == profile.preset).map_or("WordCraft", |(_, l)| l);
// 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(160.0).show_ui(ui, |ui| {
for (preset, name) in presets {
if ui.selectable_label(profile.preset == preset, tl!(name)).clicked() && profile.preset != preset {
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);
}
}
+7 -5
View File
@@ -509,9 +509,10 @@ fn advanced_opendocument_presets_and_custom_cases() {
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()
};
assert_eq!(preset(&h).as_deref(), Some("WordCraft"));
pick(&mut h, "LibreOffice");
assert_eq!(preset(&h).as_deref(), Some("LibreOffice"));
// 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;
@@ -520,6 +521,7 @@ fn advanced_opendocument_presets_and_custom_cases() {
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, "WordCraft");
assert_eq!(h.state().session.prefs.odf.effective(), OdfProfile::wordcraft());
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());
}
+9 -6
View File
@@ -17,8 +17,10 @@ knows of, so none gets lost.
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`):
- **WordCraft**, the default: what WordCraft does for its own and Word documents.
- **LibreOffice**: what LibreOffice (and Collabora, which shares its engine) does. Measured
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
@@ -39,13 +41,13 @@ Where to set it:
| Interface | How |
|---|---|
| App | File › Options › Advanced › OpenDocument files |
| Command | `file.options` `{"odf": {"preset": "libreoffice"}}`, or one case's value: `{"odf": {"columnGap": 18}}` (this makes the profile custom) |
| 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=wordcraft\|libreoffice` |
| CLI | `WORDCRAFT_ODF=word` (the default) or `WORDCRAFT_ODF=libreoffice` |
## Cases the standard leaves open (the profile)
| Case | Standard | Setting (`file.options` `odf.*`) | WordCraft | LibreOffice |
| 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 |
@@ -167,7 +169,7 @@ from public bug reports and ONLYOFFICE's converter test snapshots):
Nothing in these files depends on the profile beyond what the LibreOffice preset covers.
- **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 WordCraft's default.
(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.
@@ -176,6 +178,7 @@ one more `OdfPreset` with its values, and the cases above stay the same.
| Date | Change |
|---|---|
| 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. |
| 2026-11-03 | More written as read (tables, pages, sections, properties, declarations); files saved as ODF 1.3 with older markup brought to 1.3's meaning; drawings state `style:flow-with-text`. |
| 2026-11-03 | Unedited content written as read (see How WordCraft writes ODF). |