Skip to main content

common/parser_tools/
odt_options.rs

1//! Page geometry + base typography for ODT (OpenDocument Text) export.
2//!
3//! Mirrors [`super::docx_options::DocxExportOptions`] field-for-field and unit-for-unit: **every
4//! length here is still in DOCX-style units** — twips (1/1440 inch) for lengths, half-points for
5//! font size — even though ODF's own native vocabulary is centimeters/points with a unit suffix
6//! baked into every attribute value. That is a deliberate choice, not an oversight: keeping one
7//! shared unit convention across every export writer's *options* struct is what lets a caller
8//! (e.g. Skribisto's compiler, which already produces twips for `DocxExportOptions`) hand the
9//! same numbers to both writers without a second conversion table of its own. The twips→ODF
10//! (`fo:*="…pt"`) conversion happens once, inside the ODT writer itself
11//! (`document_io::use_cases::export_odt_uc`), which is the only place that needs to know ODF's
12//! own unit spelling.
13//!
14//! Per-block **RTL is not an option here**, for the same reason as DOCX: it is read from each
15//! block's own `fmt_direction` and emitted as a paragraph-level `style:writing-mode="rl-tb"` (the
16//! ODF analog of `<w:bidi/>`). A document that mixes LTR and RTL scenes is therefore handled per
17//! paragraph, independently of these options.
18
19use crate::entities::Alignment;
20use serde::{Deserialize, Serialize};
21
22/// How one heading level's paragraph style is **defined** in the output.
23///
24/// The ODF analog of [`super::docx_options::DocxHeadingStyle`] — same fields, same reasoning:
25/// a `<text:h text:outline-level="N">` carries its level as an explicit attribute (so, unlike
26/// OOXML, a reader never has to *guess* the level from a style name), but what that heading
27/// **looks like** — size, weight, spacing, whether it starts a new page — still has to be said
28/// somewhere, or every heading opens looking like plain body text with a reader's arbitrary
29/// built-in substituted in its place.
30#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
31pub struct OdtHeadingStyle {
32    /// Size in half-points (24 = 12 pt). `None` ⇒ the document's body size.
33    pub size_half_points: Option<usize>,
34    pub bold: bool,
35    pub italic: bool,
36    /// Paragraph alignment. `None` ⇒ inherit (left, or right in an RTL paragraph).
37    pub alignment: Option<Alignment>,
38    /// Space above, in twips (pt × 20).
39    pub space_before_twips: Option<i32>,
40    /// Space below, in twips.
41    pub space_after_twips: Option<i32>,
42    /// Keep the heading on the same page as what follows it, so a chapter title can never
43    /// be left stranded alone at the foot of a page. Emitted as `fo:keep-with-next="always"`.
44    pub keep_with_next: bool,
45    /// Start the heading on a new page. This is the *style-level* rule ("every heading at
46    /// this level opens a page"); a single block can also ask for it through
47    /// `Block::fmt_page_break_before`, and either one is enough.
48    pub page_break_before: bool,
49}
50
51impl Default for OdtHeadingStyle {
52    fn default() -> Self {
53        Self {
54            size_half_points: None,
55            bold: true,
56            italic: false,
57            alignment: None,
58            space_before_twips: None,
59            space_after_twips: None,
60            keep_with_next: true,
61            page_break_before: false,
62        }
63    }
64}
65
66impl OdtHeadingStyle {
67    /// The conventional six-level ramp, scaled off `body_half_points` — byte-for-byte the
68    /// same numbers [`super::docx_options::DocxHeadingStyle::default_ramp`] produces, so a
69    /// document exported to both DOCX and ODT with default options looks the same in either
70    /// reader. Deliberately close to what a reader's own built-in headings look like, because
71    /// this exists to make the file *say* what it was already silently relying on.
72    pub fn default_ramp(body_half_points: usize) -> Vec<Self> {
73        // (size multiple, space above in points, space below in points)
74        const RAMP: [(f32, f32, f32); 6] = [
75            (1.80, 24.0, 12.0),
76            (1.50, 18.0, 9.0),
77            (1.25, 14.0, 7.0),
78            (1.10, 12.0, 6.0),
79            (1.00, 12.0, 6.0),
80            (1.00, 12.0, 6.0),
81        ];
82        RAMP.iter()
83            .enumerate()
84            .map(|(i, &(scale, before_pt, after_pt))| Self {
85                size_half_points: Some(((body_half_points as f32 * scale).round() as usize).max(2)),
86                bold: true,
87                // Level 6 is the one conventionally set apart by slope rather than size,
88                // since it is already at body size and cannot get smaller.
89                italic: i == 5,
90                alignment: None,
91                space_before_twips: Some((before_pt * 20.0) as i32),
92                space_after_twips: Some((after_pt * 20.0) as i32),
93                keep_with_next: true,
94                page_break_before: false,
95            })
96            .collect()
97    }
98}
99
100/// Page geometry + base typography overrides for an ODT export. Every field is optional; the
101/// [`Default`] is "no overrides" — a plain "Standard"-styled document at ODF/LibreOffice's own
102/// built-in page defaults (A4-ish, 2cm-ish margins — whatever the reader substitutes for an
103/// unstyled `style:page-layout`).
104#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
105pub struct OdtExportOptions {
106    /// Page width in twips (1/1440"). `None` ⇒ no `style:page-layout-properties` override for
107    /// this edge (the reader's own default page size). Pair with [`page_height_twips`].
108    ///
109    /// [`page_height_twips`]: Self::page_height_twips
110    pub page_width_twips: Option<u32>,
111    /// Page height in twips. `None` ⇒ reader default.
112    pub page_height_twips: Option<u32>,
113    /// Top page margin in twips. `None` ⇒ reader default for that edge.
114    pub margin_top_twips: Option<i32>,
115    /// Bottom page margin in twips.
116    pub margin_bottom_twips: Option<i32>,
117    /// Left page margin in twips.
118    pub margin_left_twips: Option<i32>,
119    /// Right page margin in twips.
120    pub margin_right_twips: Option<i32>,
121    /// Base body font family, applied on the "Standard" paragraph style's text properties (so
122    /// every other named/automatic style that descends from it inherits it, complex-script runs
123    /// included — ODF has no separate "ascii vs. complex-script" font slot the way OOXML does;
124    /// one `style:font-name` covers both). `None` ⇒ reader default.
125    pub font_family: Option<String>,
126    /// Base body font size in half-points (24 = 12 pt). `None` ⇒ reader default.
127    pub font_half_points: Option<usize>,
128    /// Body line spacing in twips (240 = single, 360 = 1.5×, 480 = double), applied per body
129    /// paragraph as `fo:line-height` — headings keep their own style's spacing. `None` ⇒ default.
130    pub line_spacing_twips: Option<i32>,
131    /// First-line indent for body paragraphs, in twips. `None`/`0` ⇒ none.
132    pub first_line_indent_twips: Option<i32>,
133    /// Space after each body paragraph, in twips (pt × 20). `None`/`0` ⇒ none.
134    pub paragraph_spacing_after_twips: Option<i32>,
135    /// Justify body text; otherwise it is left-aligned (ragged), or right-aligned in an RTL
136    /// block.
137    pub justify: bool,
138    /// Emit a running header carrying the page number (right-aligned) — the manuscript staple.
139    /// Written as a `style:master-page`'s `style:header` holding a paragraph with a
140    /// `<text:page-number>` field.
141    pub page_numbers: bool,
142    /// Optional running-header text shown before the page number (e.g. `"Lastname / TITLE"`).
143    /// Only used when [`page_numbers`](Self::page_numbers) is set.
144    pub running_header: Option<String>,
145    /// Definitions for heading levels 1..6, index 0 being level 1. Empty ⇒ the writer falls
146    /// back to [`OdtHeadingStyle::default_ramp`] over the body size, because the one thing it
147    /// must never do is leave every heading looking like undifferentiated body text.
148    #[serde(default)]
149    pub heading_styles: Vec<OdtHeadingStyle>,
150    /// Bytes for the document's inline images, keyed by their `src`.
151    ///
152    /// Supplied by the caller for the same reason [`super::docx_options::DocxExportOptions::images`]
153    /// is: this crate resolves no paths and reads no files. An image whose `src` is absent here
154    /// is exported as its alt text instead of failing the export — a missing picture must not
155    /// cost the writer their manuscript.
156    #[serde(default)]
157    pub images: super::image_options::ExportImages,
158    /// Comment threads to anchor into the exported `.odt` as real `office:annotation` ranges —
159    /// the ODF analog of [`super::docx_options::DocxExportOptions::comments`]. A thread's
160    /// opening note and every reply become their own `office:annotation`/
161    /// `office:annotation-end` pair (paired by a generated `office:name`, all sharing the
162    /// thread's own character range — see [`super::comment_options::DocumentComment`]'s doc
163    /// comment for why a reply carries no range of its own), `loext:resolved` marks a resolved
164    /// thread, and `loext:parent-name` threads a reply back to its root — the same
165    /// LibreOffice-measured spelling `document_ingest::sources::odt`'s reader already expects
166    /// (see `M-T2b`'s own doc comment in `document_io::use_cases::export_odt_uc` for the
167    /// measurement this was checked against). Empty ⇒ no comments are written, matching plain
168    /// `to_odt`.
169    ///
170    /// Ranges are in the document's addressable character space — see
171    /// [`super::comment_options::DocumentComment`]'s doc comment for what that means and why it
172    /// is not the same space `FormatRun` byte offsets live in.
173    #[serde(default)]
174    pub comments: super::comment_options::DocumentComments,
175    /// Named positions and ranges to anchor into the exported `.odt` as `text:bookmark` /
176    /// `text:bookmark-start`+`text:bookmark-end` — the carrier a host uses to recognise its own
177    /// rows and comments when the file comes back from an editor. Empty ⇒ none are written.
178    ///
179    /// Bookmarks and not a private attribute, because a private attribute does not survive: see
180    /// [`super::mark_options`]'s module doc for the measurement. Same addressable character
181    /// space as [`comments`](Self::comments).
182    #[serde(default)]
183    pub marks: super::mark_options::DocumentMarks,
184}
185
186impl OdtExportOptions {
187    /// No overrides — what plain `to_odt` uses.
188    pub fn plain() -> Self {
189        Self::default()
190    }
191
192    /// The heading styles to write, resolved: the caller's when it gave any, otherwise the
193    /// default ramp scaled off whatever body size this export uses.
194    pub fn resolved_heading_styles(&self) -> Vec<OdtHeadingStyle> {
195        if self.heading_styles.is_empty() {
196            OdtHeadingStyle::default_ramp(self.font_half_points.unwrap_or(24))
197        } else {
198            self.heading_styles.clone()
199        }
200    }
201}