Skip to main content

common/parser_tools/
docx_options.rs

1//! Page geometry + base typography for DOCX export.
2//!
3//! `TextDocument::to_docx` writes with docx-rs's built-in defaults (US-Letter, the default
4//! font, single-spaced, 1" margins). [`DocxExportOptions`] lets a caller override that with a
5//! *manuscript* style: page size, margins, body font, line spacing, first-line indent,
6//! paragraph spacing, alignment, and an optional page-number header. **Everything is in DOCX
7//! units** — twips (1/1440 inch) for lengths, half-points for the font size — so this crate
8//! stays free of any point/inch or preset semantics; the caller (e.g. skribisto's compiler)
9//! does the conversion.
10//!
11//! Per-block **RTL is not an option here**: it is read from each block's own `fmt_direction`
12//! (set on the model) and emitted as a paragraph-level `<w:bidi/>`. A document that mixes LTR
13//! and RTL scenes is therefore handled per paragraph, independently of these options.
14
15use crate::entities::Alignment;
16use serde::{Deserialize, Serialize};
17
18/// How one `HeadingN` paragraph style is **defined** in the output.
19///
20/// Paragraphs carrying a heading level reference a style id (`Heading1`…`Heading6`), and a
21/// referenced-but-undefined id is not an error in OOXML — the reader silently substitutes its
22/// own built-in. That is why an export could ask for a 24 pt centred chapter title and open
23/// as whatever Word felt like: nothing in the file ever said what `Heading1` *is*.
24#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
25pub struct DocxHeadingStyle {
26    /// Size in half-points (24 = 12 pt). `None` ⇒ the document's body size.
27    pub size_half_points: Option<usize>,
28    pub bold: bool,
29    pub italic: bool,
30    /// Paragraph alignment. `None` ⇒ inherit (left, or right in an RTL paragraph).
31    pub alignment: Option<Alignment>,
32    /// Space above, in twips (pt × 20).
33    pub space_before_twips: Option<i32>,
34    /// Space below, in twips.
35    pub space_after_twips: Option<i32>,
36    /// Keep the heading on the same page as what follows it, so a chapter title can never
37    /// be left stranded alone at the foot of a page.
38    pub keep_with_next: bool,
39    /// Start the heading on a new page. This is the *style-level* rule ("every heading at
40    /// this level opens a page"); a single block can also ask for it through
41    /// `Block::fmt_page_break_before`, and either one is enough.
42    pub page_break_before: bool,
43}
44
45impl Default for DocxHeadingStyle {
46    fn default() -> Self {
47        Self {
48            size_half_points: None,
49            bold: true,
50            italic: false,
51            alignment: None,
52            space_before_twips: None,
53            space_after_twips: None,
54            keep_with_next: true,
55            page_break_before: false,
56        }
57    }
58}
59
60impl DocxHeadingStyle {
61    /// The conventional six-level ramp, scaled off `body_half_points`: each level a little
62    /// smaller than the one above, bold, opening with space and never orphaned from its text.
63    /// Deliberately close to what a reader's own built-in headings look like, because this
64    /// exists to make the file *say* what it was already silently relying on.
65    pub fn default_ramp(body_half_points: usize) -> Vec<Self> {
66        // (size multiple, space above in points, space below in points)
67        const RAMP: [(f32, f32, f32); 6] = [
68            (1.80, 24.0, 12.0),
69            (1.50, 18.0, 9.0),
70            (1.25, 14.0, 7.0),
71            (1.10, 12.0, 6.0),
72            (1.00, 12.0, 6.0),
73            (1.00, 12.0, 6.0),
74        ];
75        RAMP.iter()
76            .enumerate()
77            .map(|(i, &(scale, before_pt, after_pt))| Self {
78                size_half_points: Some(((body_half_points as f32 * scale).round() as usize).max(2)),
79                bold: true,
80                // Level 6 is the one conventionally set apart by slope rather than size,
81                // since it is already at body size and cannot get smaller.
82                italic: i == 5,
83                alignment: None,
84                space_before_twips: Some((before_pt * 20.0) as i32),
85                space_after_twips: Some((after_pt * 20.0) as i32),
86                keep_with_next: true,
87                page_break_before: false,
88            })
89            .collect()
90    }
91}
92
93/// Page geometry + base typography overrides for a DOCX export. Every field is optional; the
94/// [`Default`] is "no overrides" — exactly what plain `to_docx` produces.
95#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
96pub struct DocxExportOptions {
97    /// Page width in twips (1/1440"). `None` ⇒ docx default. Pair with [`page_height_twips`].
98    ///
99    /// [`page_height_twips`]: Self::page_height_twips
100    pub page_width_twips: Option<u32>,
101    /// Page height in twips. `None` ⇒ docx default.
102    pub page_height_twips: Option<u32>,
103    /// Top page margin in twips. `None` ⇒ docx default for that edge.
104    pub margin_top_twips: Option<i32>,
105    /// Bottom page margin in twips.
106    pub margin_bottom_twips: Option<i32>,
107    /// Left page margin in twips.
108    pub margin_left_twips: Option<i32>,
109    /// Right page margin in twips.
110    pub margin_right_twips: Option<i32>,
111    /// Base body font family, applied as the document default (ascii + complex-script slots, so
112    /// it also covers RTL runs). `None` ⇒ docx default.
113    pub font_family: Option<String>,
114    /// Base body font size in half-points (24 = 12 pt). `None` ⇒ docx default.
115    pub font_half_points: Option<usize>,
116    /// Body line spacing in twips (240 = single, 360 = 1.5×, 480 = double), applied per body
117    /// paragraph — headings keep their own style's spacing. `None` ⇒ default.
118    pub line_spacing_twips: Option<i32>,
119    /// First-line indent for body paragraphs, in twips. `None`/`0` ⇒ none.
120    pub first_line_indent_twips: Option<i32>,
121    /// Space after each body paragraph, in twips (pt × 20). `None`/`0` ⇒ none.
122    pub paragraph_spacing_after_twips: Option<i32>,
123    /// Justify body text; otherwise it is left-aligned (ragged), or right-aligned in an RTL
124    /// block.
125    pub justify: bool,
126    /// Emit a running header carrying the page number (right-aligned) — the manuscript staple.
127    pub page_numbers: bool,
128    /// Optional running-header text shown before the page number (e.g. `"Lastname / TITLE"`).
129    /// Only used when [`page_numbers`](Self::page_numbers) is set.
130    pub running_header: Option<String>,
131    /// Definitions for `Heading1`…`Heading6`, index 0 being level 1. Empty ⇒ the writer
132    /// falls back to [`DocxHeadingStyle::default_ramp`] over the body size, because the one
133    /// thing it must never do is leave the ids undefined for the reader to guess at.
134    #[serde(default)]
135    pub heading_styles: Vec<DocxHeadingStyle>,
136    /// Bytes for the document's inline images, keyed by their `src`.
137    ///
138    /// Supplied by the caller for the same reason [`PdfExportOptions::font_bytes`]
139    /// is: this crate resolves no paths and reads no files. An image whose `src`
140    /// is absent here is exported as its alt text instead of failing the export —
141    /// a missing picture must not cost the writer their manuscript.
142    ///
143    /// [`PdfExportOptions::font_bytes`]: super::pdf_options::PdfExportOptions::font_bytes
144    #[serde(default)]
145    pub images: super::image_options::ExportImages,
146}
147
148impl DocxExportOptions {
149    /// docx-rs's built-in defaults — no manuscript styling (what plain `to_docx` uses).
150    pub fn plain() -> Self {
151        Self::default()
152    }
153
154    /// The heading styles to write, resolved: the caller's when it gave any, otherwise the
155    /// default ramp scaled off whatever body size this export uses.
156    pub fn resolved_heading_styles(&self) -> Vec<DocxHeadingStyle> {
157        if self.heading_styles.is_empty() {
158            DocxHeadingStyle::default_ramp(self.font_half_points.unwrap_or(24))
159        } else {
160            self.heading_styles.clone()
161        }
162    }
163}