Skip to main content

rwml/
model.rs

1//! The rich document model — a typed intermediate representation built lazily on
2//! top of the piece table, character/paragraph properties, the style sheet, and
3//! the table structure.
4//!
5//! The flat [`crate::Document::text`] path is untouched and stays fast; the model
6//! is only assembled when a caller asks for [`crate::Document::model`],
7//! [`crate::Document::to_markdown`], or [`crate::Document::to_html`].
8//!
9//! The shape mirrors the de-facto Rust/Pandoc document IRs (a document is a list
10//! of block-level nodes; paragraphs hold inline runs) so the Markdown/HTML
11//! exporters are simple folds.
12
13use std::collections::BTreeMap;
14
15use crate::annotation::{NoteKind, RevisionKind};
16
17/// A whole `.doc` document as an ordered list of block-level nodes plus
18/// document-level metadata.
19#[derive(Debug, Clone, Default, PartialEq)]
20pub struct DocModel {
21    /// Block-level content, in reading order.
22    pub blocks: Vec<Block>,
23    /// Source-region spans for content that came from distinct Word
24    /// subdocuments. Empty for authored models and sources that do not expose a
25    /// region map yet.
26    pub regions: Vec<SourceRegion>,
27    /// Document-level metadata (codepage, language, counts).
28    pub meta: DocMeta,
29    /// Authored string custom document properties.
30    pub custom_properties: BTreeMap<String, String>,
31    /// Authored custom XML data-store items.
32    pub custom_xml_items: Vec<CustomXmlItem>,
33    /// Document-level layout for authoring/rendering (page size, header/footer,
34    /// metadata). Defaults to A4 portrait with no running header/footer.
35    pub setup: DocSetup,
36}
37
38/// A generated custom XML data-store item.
39#[derive(Debug, Clone, Default, PartialEq, Eq)]
40pub struct CustomXmlItem {
41    /// Custom XML store item ID.
42    pub store_item_id: String,
43    /// Raw XML payload for `customXml/itemN.xml`.
44    pub xml: String,
45}
46
47/// A generated Office web-extension task pane package entry.
48#[derive(Debug, Clone, Default, PartialEq, Eq)]
49pub struct WebExtensionTaskPane {
50    /// Web extension instance id.
51    pub extension_id: String,
52    /// Add-in reference id from the deployment catalog or manifest.
53    pub reference_id: String,
54    /// Add-in version string.
55    pub version: String,
56    /// Store or catalog pointer.
57    pub store: String,
58    /// Store or catalog kind, such as `EXCatalog`, `FileSystem`, or `OMEX`.
59    pub store_type: String,
60    /// Web-extension custom property bag.
61    pub properties: BTreeMap<String, String>,
62    /// Last docked task-pane location.
63    pub dock_state: String,
64    /// Whether the task pane is visible by default.
65    pub visible: bool,
66    /// Default task-pane width.
67    pub width: u32,
68    /// Task-pane row index among panes docked in the same location.
69    pub row: u32,
70    /// Whether the task pane is locked in the UI.
71    pub locked: bool,
72}
73
74/// A coarse source subdocument region from the original Word file.
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
76pub enum SourceRegionKind {
77    /// Main body text.
78    Main,
79    /// Legacy footnote subdocument.
80    Footnote,
81    /// Header/footer subdocument.
82    HeaderFooter,
83    /// Legacy annotation/comment subdocument.
84    Annotation,
85    /// Legacy endnote subdocument.
86    Endnote,
87    /// Text-box subdocument.
88    TextBox,
89}
90
91/// A span of [`DocModel::blocks`] that came from one source subdocument.
92#[derive(Debug, Clone, PartialEq, Eq)]
93pub struct SourceRegion {
94    /// Region kind.
95    pub kind: SourceRegionKind,
96    /// Source-specific story index within the subdocument, when a format table
97    /// exposes one. For legacy `.doc` header/footer regions this is the
98    /// `PlcfHdd` story index.
99    pub source_story_index: Option<usize>,
100    /// Inclusive start block index in [`DocModel::blocks`].
101    pub block_start: usize,
102    /// Exclusive end block index in [`DocModel::blocks`].
103    pub block_end: usize,
104    /// UTF-16 CP start in the original Word source stream.
105    pub source_start_cp: usize,
106    /// UTF-16 CP length reported by the source metadata.
107    pub source_len_cp: usize,
108    /// Visible character start within the flattened model text.
109    pub text_start: usize,
110    /// Visible character length contributed by this region.
111    pub text_len: usize,
112}
113
114impl DocModel {
115    /// Iterate source-region spans of the requested kind.
116    pub fn source_regions(&self, kind: SourceRegionKind) -> impl Iterator<Item = &SourceRegion> {
117        self.regions
118            .iter()
119            .filter(move |region| region.kind == kind)
120    }
121
122    /// Return the block slice covered by a source-region span.
123    ///
124    /// The range is clamped to this model, so passing a stale region from another
125    /// model returns an empty or shortened slice instead of panicking.
126    pub fn source_region_blocks(&self, region: &SourceRegion) -> &[Block] {
127        let start = region.block_start.min(self.blocks.len());
128        let end = region.block_end.min(self.blocks.len());
129        if start <= end {
130            &self.blocks[start..end]
131        } else {
132            &self.blocks[0..0]
133        }
134    }
135
136    /// Concatenate visible text from the blocks covered by a source-region span.
137    pub fn source_region_text(&self, region: &SourceRegion) -> String {
138        let mut out = String::new();
139        append_blocks_text(self.source_region_blocks(region), &mut out);
140        out
141    }
142
143    /// Concatenate visible text from all source regions of one kind, in model
144    /// order. Returns an empty string when the model has no matching regions.
145    pub fn source_region_kind_text(&self, kind: SourceRegionKind) -> String {
146        let mut out = String::new();
147        for region in self.source_regions(kind) {
148            append_blocks_text(self.source_region_blocks(region), &mut out);
149        }
150        out
151    }
152}
153
154fn append_blocks_text(blocks: &[Block], out: &mut String) {
155    for block in blocks {
156        match block {
157            Block::Paragraph(paragraph) => {
158                for run in &paragraph.runs {
159                    out.push_str(&run.text);
160                }
161            }
162            Block::Table(table) => {
163                for row in &table.rows {
164                    for cell in &row.cells {
165                        append_blocks_text(&cell.blocks, out);
166                    }
167                }
168            }
169            Block::Image(_) | Block::Chart(_) | Block::SectionBreak(_) => {}
170            Block::PageBreak => out.push('\n'),
171        }
172    }
173}
174
175/// Document-level metadata.
176#[derive(Debug, Clone, Default, PartialEq)]
177pub struct DocMeta {
178    /// ANSI codepage of 8-bit pieces (e.g. 949 for Korean).
179    pub codepage: u16,
180    /// FIB language id (`lid`).
181    pub lid: u16,
182    /// Aggregate counts (paragraphs, tables, figures, characters).
183    pub stats: Stats,
184}
185
186/// Aggregate document statistics, mirroring the project-wide `DocStats` contract.
187#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
188pub struct Stats {
189    /// Number of paragraphs (including headings, list items, and cell paragraphs).
190    pub paragraphs: u32,
191    /// Number of tables.
192    pub tables: u16,
193    /// Number of images / figures.
194    pub figures: u16,
195    /// Total visible character count.
196    pub text_chars: usize,
197}
198
199/// A block-level node.
200#[derive(Debug, Clone, PartialEq)]
201pub enum Block {
202    /// A paragraph — also carries headings and list items via [`ParaProps`].
203    Paragraph(Paragraph),
204    /// A table (rows → cells → blocks).
205    Table(Table),
206    /// A block-level image (an image-only paragraph).
207    Image(Image),
208    /// A block-level chart with literal category/value data.
209    Chart(Chart),
210    /// An explicit page break between block-level items.
211    PageBreak,
212    /// A section boundary carrying layout for the section that just ended.
213    SectionBreak(SectionSetup),
214}
215
216/// Section-break start behavior for a WordprocessingML section boundary.
217#[derive(Debug, Clone, Copy, PartialEq, Eq)]
218pub enum SectionBreakKind {
219    /// Start the following section on the next page (`nextPage`).
220    NextPage,
221    /// Start the following section on the next even page (`evenPage`).
222    EvenPage,
223    /// Start the following section on the next odd page (`oddPage`).
224    OddPage,
225}
226
227#[cfg(feature = "docx")]
228impl SectionBreakKind {
229    pub(crate) fn wml_value(self) -> &'static str {
230        match self {
231            SectionBreakKind::NextPage => "nextPage",
232            SectionBreakKind::EvenPage => "evenPage",
233            SectionBreakKind::OddPage => "oddPage",
234        }
235    }
236
237    pub(crate) fn from_wml_value(value: &str) -> Option<Self> {
238        let value = value.trim();
239        match value {
240            "nextPage" => Some(SectionBreakKind::NextPage),
241            "evenPage" => Some(SectionBreakKind::EvenPage),
242            "oddPage" => Some(SectionBreakKind::OddPage),
243            _ => None,
244        }
245    }
246}
247
248/// A paragraph: inline runs plus paragraph-level properties.
249#[derive(Debug, Clone, Default, PartialEq)]
250pub struct Paragraph {
251    /// Paragraph-level properties (style, heading, alignment, list membership).
252    pub props: ParaProps,
253    /// Inline runs, in reading order.
254    pub runs: Vec<Run>,
255}
256
257impl Paragraph {
258    /// The concatenated visible text of all runs (hidden runs included — they are
259    /// part of the indexed text; the HTML exporter may choose to omit them).
260    pub fn text(&self) -> String {
261        self.runs.iter().map(|r| r.text.as_str()).collect()
262    }
263
264    /// `true` if the paragraph has no visible text and no image in any run.
265    pub fn is_blank(&self) -> bool {
266        self.runs
267            .iter()
268            .all(|r| r.text.trim().is_empty() && r.image.is_none())
269    }
270}
271
272/// Paragraph spacing in points; `None` = unset (renderer/writer uses defaults).
273#[derive(Debug, Clone, Copy, Default, PartialEq)]
274pub struct Spacing {
275    /// Space above the paragraph.
276    pub before_pt: Option<f32>,
277    /// Space below the paragraph.
278    pub after_pt: Option<f32>,
279    /// Line height as a multiple of the font size (e.g. `1.5`).
280    pub line_pct: Option<f32>,
281}
282
283/// Paragraph indentation in points; `None` = unset.
284#[derive(Debug, Clone, Copy, Default, PartialEq)]
285pub struct Indent {
286    /// Left indent.
287    pub left_pt: Option<f32>,
288    /// Right indent.
289    pub right_pt: Option<f32>,
290    /// First-line additional indent.
291    pub first_line_pt: Option<f32>,
292    /// Hanging indent (first line outdented by this much).
293    pub hanging_pt: Option<f32>,
294}
295
296/// Paragraph-level properties.
297#[derive(Debug, Clone, Default, PartialEq)]
298pub struct ParaProps {
299    /// Raw Word paragraph style id (`w:pStyle/@w:val`), if known or authored.
300    pub style_id: Option<String>,
301    /// Resolved style name (e.g. `Heading 1`, `제목 1`), if the style sheet was read.
302    pub style_name: Option<String>,
303    /// Heading level `1..=6`, from the outline level or a recognized style name.
304    pub heading_level: Option<u8>,
305    /// Paragraph alignment.
306    pub align: Align,
307    /// Raw outline level `0..=8` (9/None = body text).
308    pub outline_level: Option<u8>,
309    /// List membership — `Some` makes this paragraph a list item.
310    pub list: Option<ListInfo>,
311    /// Spacing (before/after/line).
312    pub spacing: Spacing,
313    /// Indentation.
314    pub indent: Indent,
315    /// Paragraph background shading, if any.
316    pub shading: Option<Color>,
317    /// Force this paragraph to begin on a new page (`w:pageBreakBefore`).
318    pub page_break_before: bool,
319    /// Right-to-left paragraph layout (`w:bidi`).
320    pub bidi: bool,
321}
322
323/// A paragraph style definition for generated `.docx` output.
324#[derive(Debug, Clone, Default, PartialEq)]
325pub struct ParagraphStyle {
326    /// Stable Word style id (`w:styleId` / paragraph `w:pStyle` value).
327    pub id: String,
328    /// Human-readable style name.
329    pub name: String,
330    /// Base style id, if any.
331    pub based_on: Option<String>,
332    /// Next paragraph style id, if any.
333    pub next: Option<String>,
334    /// Whether the style should appear in Word's quick style gallery.
335    pub q_format: bool,
336    /// Optional heading level `1..=9`, emitted as style `outlineLvl`.
337    pub heading_level: Option<u8>,
338    /// Default paragraph alignment.
339    pub align: Align,
340    /// Default paragraph spacing.
341    pub spacing: Spacing,
342    /// Default paragraph indentation.
343    pub indent: Indent,
344    /// Default paragraph background shading.
345    pub shading: Option<Color>,
346    /// Default character properties for runs using this style.
347    pub run: CharProps,
348}
349
350/// A comment to author on a generated run.
351#[derive(Debug, Clone, Default, PartialEq, Eq)]
352pub struct AuthoredComment {
353    /// Comment body text.
354    pub text: String,
355    /// Comment author, if any.
356    pub author: Option<String>,
357    /// Author initials, if any.
358    pub initials: Option<String>,
359    /// Comment timestamp, if any.
360    pub date: Option<String>,
361    /// Parent comment id for authored replies, if any.
362    pub parent_comment_id: Option<String>,
363}
364
365/// Tracked revision metadata to author on a generated run.
366#[derive(Debug, Clone, PartialEq, Eq)]
367pub struct AuthoredRevision {
368    /// Revision kind. Generated `.docx` currently supports insertion/deletion.
369    pub kind: RevisionKind,
370    /// Revision author, if any.
371    pub author: Option<String>,
372    /// Revision timestamp, if any.
373    pub date: Option<String>,
374}
375
376/// Plain text content-control metadata to author on a generated run.
377#[derive(Debug, Clone, Default, PartialEq, Eq)]
378pub struct AuthoredContentControl {
379    /// Human-readable content-control title/alias, if any.
380    pub alias: Option<String>,
381    /// Machine-readable content-control tag, if any.
382    pub tag: Option<String>,
383    /// XPath for a data-bound content control, if any.
384    pub data_binding_xpath: Option<String>,
385    /// Custom XML store item ID for a data-bound content control, if any.
386    pub data_binding_store_item_id: Option<String>,
387}
388
389/// Footnote or endnote metadata to author after a generated run.
390#[derive(Debug, Clone, PartialEq, Eq)]
391pub struct AuthoredNote {
392    /// Footnote or endnote.
393    pub kind: NoteKind,
394    /// Note body text.
395    pub text: String,
396}
397
398impl Default for AuthoredRevision {
399    fn default() -> Self {
400        Self {
401            kind: RevisionKind::Insertion,
402            author: None,
403            date: None,
404        }
405    }
406}
407
408/// List membership of a paragraph.
409#[derive(Debug, Clone, Default, PartialEq, Eq)]
410pub struct ListInfo {
411    /// 0-based nesting level.
412    pub level: u8,
413    /// `true` = numbered list, `false` = bullet list.
414    pub ordered: bool,
415    /// The rendered autonumber label (e.g. `1.`, `가.`) — used by the flat text
416    /// path; the Markdown/HTML exporters use native list syntax instead.
417    pub label: String,
418}
419
420/// An inline run of text with uniform character properties.
421#[derive(Debug, Clone, Default, PartialEq)]
422pub struct Run {
423    /// The run's text.
424    pub text: String,
425    /// Character formatting.
426    pub props: CharProps,
427    /// Field role (a hyperlink result carries its URL).
428    pub field: FieldRole,
429    /// Mark generated simple fields dirty so Word can refresh them.
430    pub field_dirty: bool,
431    /// Best-effort reason an imported field result remains cached, when known.
432    pub field_unsupported_reason: Option<FieldUnsupportedReason>,
433    /// An inline picture (the run's text is empty when this is set).
434    pub image: Option<Image>,
435    /// Authored comment anchored to this run.
436    pub comment: Option<AuthoredComment>,
437    /// Authored tracked insertion/deletion metadata for this run.
438    pub revision: Option<AuthoredRevision>,
439    /// Authored plain text content-control metadata for this run.
440    pub content_control: Option<AuthoredContentControl>,
441    /// Authored bookmark name wrapping this run.
442    pub bookmark: Option<String>,
443    /// Authored footnote/endnote anchored after this run.
444    pub note: Option<AuthoredNote>,
445}
446
447/// Best-effort reason an imported field remains cached in the model.
448#[derive(Debug, Clone, Copy, PartialEq, Eq)]
449pub enum FieldUnsupportedReason {
450    /// The field points at a bookmark/scope rwml could not resolve.
451    UnresolvedBookmark,
452    /// The instruction contains a switch whose value can change the result.
453    UnsupportedSwitch,
454    /// The instruction is supported, but the document contains no computable value.
455    NoComputedResult,
456}
457
458pub(crate) fn referenceable_bookmark_name(name: &str) -> bool {
459    !name.trim().is_empty()
460        && !name.starts_with('\\')
461        && !name.contains('"')
462        && !name.chars().any(char::is_whitespace)
463}
464
465pub(crate) fn normalize_field_instruction(instruction: &str) -> String {
466    instruction.split_whitespace().collect::<Vec<_>>().join(" ")
467}
468
469/// An sRGB color.
470#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Hash)]
471pub struct Color {
472    /// Red channel.
473    pub r: u8,
474    /// Green channel.
475    pub g: u8,
476    /// Blue channel.
477    pub b: u8,
478}
479
480impl Color {
481    /// Construct from components.
482    pub fn rgb(r: u8, g: u8, b: u8) -> Self {
483        Color { r, g, b }
484    }
485}
486
487/// A physical side of a table border.
488#[derive(Debug, Clone, Copy, PartialEq, Eq)]
489pub enum TableBorderSide {
490    /// Top border.
491    Top,
492    /// Left border.
493    Left,
494    /// Bottom border.
495    Bottom,
496    /// Right border.
497    Right,
498    /// Horizontal inside borders.
499    InsideHorizontal,
500    /// Vertical inside borders.
501    InsideVertical,
502}
503
504/// Uniform table border line style.
505#[derive(Debug, Clone, Copy, PartialEq, Eq)]
506pub enum TableBorderStyle {
507    /// Single solid line (`single`).
508    Single,
509    /// Dotted line (`dotted`).
510    Dotted,
511    /// Dashed line (`dashed`).
512    Dashed,
513    /// Double solid line (`double`).
514    Double,
515}
516
517#[cfg(feature = "docx")]
518impl TableBorderStyle {
519    pub(crate) fn wml_value(self) -> &'static str {
520        match self {
521            TableBorderStyle::Single => "single",
522            TableBorderStyle::Dotted => "dotted",
523            TableBorderStyle::Dashed => "dashed",
524            TableBorderStyle::Double => "double",
525        }
526    }
527
528    pub(crate) fn from_wml_value(value: &str) -> Option<Self> {
529        let value = value.trim();
530        match value {
531            "single" => Some(TableBorderStyle::Single),
532            "dotted" => Some(TableBorderStyle::Dotted),
533            "dashed" => Some(TableBorderStyle::Dashed),
534            "double" => Some(TableBorderStyle::Double),
535            _ => None,
536        }
537    }
538}
539
540/// Optional table border colors by physical side.
541#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
542pub struct TableBorderColors {
543    /// Top border color.
544    pub top: Option<Color>,
545    /// Left border color.
546    pub left: Option<Color>,
547    /// Bottom border color.
548    pub bottom: Option<Color>,
549    /// Right border color.
550    pub right: Option<Color>,
551    /// Horizontal inside border color.
552    pub inside_h: Option<Color>,
553    /// Vertical inside border color.
554    pub inside_v: Option<Color>,
555}
556
557impl TableBorderColors {
558    /// Return the color for a side, if one was set.
559    pub fn get(&self, side: TableBorderSide) -> Option<Color> {
560        match side {
561            TableBorderSide::Top => self.top,
562            TableBorderSide::Left => self.left,
563            TableBorderSide::Bottom => self.bottom,
564            TableBorderSide::Right => self.right,
565            TableBorderSide::InsideHorizontal => self.inside_h,
566            TableBorderSide::InsideVertical => self.inside_v,
567        }
568    }
569
570    /// Set the color for a side.
571    pub fn set(&mut self, side: TableBorderSide, color: Color) {
572        match side {
573            TableBorderSide::Top => self.top = Some(color),
574            TableBorderSide::Left => self.left = Some(color),
575            TableBorderSide::Bottom => self.bottom = Some(color),
576            TableBorderSide::Right => self.right = Some(color),
577            TableBorderSide::InsideHorizontal => self.inside_h = Some(color),
578            TableBorderSide::InsideVertical => self.inside_v = Some(color),
579        }
580    }
581}
582
583/// Optional table border widths by physical side, in eighths of a point.
584#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
585pub struct TableBorderSizes {
586    /// Top border width.
587    pub top: Option<u16>,
588    /// Left border width.
589    pub left: Option<u16>,
590    /// Bottom border width.
591    pub bottom: Option<u16>,
592    /// Right border width.
593    pub right: Option<u16>,
594    /// Horizontal inside border width.
595    pub inside_h: Option<u16>,
596    /// Vertical inside border width.
597    pub inside_v: Option<u16>,
598}
599
600impl TableBorderSizes {
601    /// Return the width for a side, if one was set.
602    pub fn get(&self, side: TableBorderSide) -> Option<u16> {
603        match side {
604            TableBorderSide::Top => self.top,
605            TableBorderSide::Left => self.left,
606            TableBorderSide::Bottom => self.bottom,
607            TableBorderSide::Right => self.right,
608            TableBorderSide::InsideHorizontal => self.inside_h,
609            TableBorderSide::InsideVertical => self.inside_v,
610        }
611    }
612
613    /// Set the width for a side.
614    pub fn set(&mut self, side: TableBorderSide, size: u16) {
615        let size = size.max(1);
616        match side {
617            TableBorderSide::Top => self.top = Some(size),
618            TableBorderSide::Left => self.left = Some(size),
619            TableBorderSide::Bottom => self.bottom = Some(size),
620            TableBorderSide::Right => self.right = Some(size),
621            TableBorderSide::InsideHorizontal => self.inside_h = Some(size),
622            TableBorderSide::InsideVertical => self.inside_v = Some(size),
623        }
624    }
625}
626
627/// Optional table border line styles by physical side.
628#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
629pub struct TableBorderStyles {
630    /// Top border style.
631    pub top: Option<TableBorderStyle>,
632    /// Left border style.
633    pub left: Option<TableBorderStyle>,
634    /// Bottom border style.
635    pub bottom: Option<TableBorderStyle>,
636    /// Right border style.
637    pub right: Option<TableBorderStyle>,
638    /// Horizontal inside border style.
639    pub inside_h: Option<TableBorderStyle>,
640    /// Vertical inside border style.
641    pub inside_v: Option<TableBorderStyle>,
642}
643
644impl TableBorderStyles {
645    /// Return the style for a side, if one was set.
646    pub fn get(&self, side: TableBorderSide) -> Option<TableBorderStyle> {
647        match side {
648            TableBorderSide::Top => self.top,
649            TableBorderSide::Left => self.left,
650            TableBorderSide::Bottom => self.bottom,
651            TableBorderSide::Right => self.right,
652            TableBorderSide::InsideHorizontal => self.inside_h,
653            TableBorderSide::InsideVertical => self.inside_v,
654        }
655    }
656
657    /// Set the style for a side.
658    pub fn set(&mut self, side: TableBorderSide, style: TableBorderStyle) {
659        match side {
660            TableBorderSide::Top => self.top = Some(style),
661            TableBorderSide::Left => self.left = Some(style),
662            TableBorderSide::Bottom => self.bottom = Some(style),
663            TableBorderSide::Right => self.right = Some(style),
664            TableBorderSide::InsideHorizontal => self.inside_h = Some(style),
665            TableBorderSide::InsideVertical => self.inside_v = Some(style),
666        }
667    }
668}
669
670/// Vertical alignment of a run (super/subscript).
671#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
672pub enum VertAlign {
673    /// On the baseline (normal).
674    #[default]
675    Baseline,
676    /// Superscript.
677    Super,
678    /// Subscript.
679    Sub,
680}
681
682/// Character-level formatting that affects rendering.
683///
684/// `font`/`color`/`highlight` make this non-`Copy`; clone where a `CharProps` is
685/// needed by value.
686#[derive(Debug, Clone, Default, PartialEq, Eq)]
687pub struct CharProps {
688    /// Bold.
689    pub bold: bool,
690    /// Italic.
691    pub italic: bool,
692    /// Underlined.
693    pub underline: bool,
694    /// Struck through.
695    pub strike: bool,
696    /// Hidden text (`fVanish`) — kept in the flat/Markdown text for index recall,
697    /// but the HTML exporter omits it.
698    pub hidden: bool,
699    /// Font family name (emitted to `w:rFonts` ascii+eastAsia), if known.
700    pub font: Option<String>,
701    /// Font size in half-points (Word unit; `24` = 12pt), if known.
702    pub size_half_pt: Option<u16>,
703    /// Text color, if known.
704    pub color: Option<Color>,
705    /// Highlight color name (`w:highlight`, e.g. `"yellow"`), if any.
706    pub highlight: Option<String>,
707    /// Super/subscript.
708    pub vert_align: VertAlign,
709    /// Small caps (`w:smallCaps`): lowercase letters render as small capitals.
710    pub small_caps: bool,
711    /// All caps (`w:caps`): text renders uppercased regardless of stored case.
712    pub caps: bool,
713    /// Right-to-left run text (`w:rtl`).
714    pub rtl: bool,
715}
716
717/// Whether a run is plain text or the cached result of a field.
718#[derive(Debug, Clone, Default, PartialEq, Eq)]
719pub enum FieldRole {
720    /// Plain text (not part of a field).
721    #[default]
722    None,
723    /// A hyperlink — `url` from the `HYPERLINK` field instruction.
724    Hyperlink {
725        /// The link target.
726        url: String,
727    },
728    /// A simple non-hyperlink field whose instruction is preserved.
729    Simple {
730        /// Normalized field instruction such as `PAGE`, `FILENAME \p`, or `REF Figure1`.
731        instruction: String,
732    },
733    /// Any other field (PAGE, REF, …) whose result text is kept verbatim.
734    Other,
735}
736
737/// Paragraph alignment.
738#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
739pub enum Align {
740    /// Left-aligned (the default).
741    #[default]
742    Left,
743    /// Centered.
744    Center,
745    /// Right-aligned.
746    Right,
747    /// Justified.
748    Justify,
749}
750
751/// A table: rows of cells.
752#[derive(Debug, Clone, Default, PartialEq)]
753pub struct Table {
754    /// The table's rows, top to bottom.
755    pub rows: Vec<Row>,
756    /// Number of leading header rows (repeated `sprmTTableHeader` rows).
757    pub header_rows: usize,
758    /// Column widths as fractions of the table width; empty = even split.
759    pub col_widths_pct: Vec<f32>,
760    /// Table width as a fraction of the available width, if set (`None` = auto).
761    pub width_pct: Option<f32>,
762    /// Use Word's fixed table layout algorithm instead of autofit.
763    pub fixed_layout: bool,
764    /// Table indentation in twips, if explicitly set.
765    pub indent_twips: Option<i32>,
766    /// Table alignment, if explicitly set.
767    pub align: Option<Align>,
768    /// Visual right-to-left table ordering (`w:bidiVisual`).
769    pub bidi_visual: bool,
770    /// Uniform table border color, if explicitly set.
771    pub border_color: Option<Color>,
772    /// Side-specific table border colors, overriding `border_color` per side.
773    pub border_colors: TableBorderColors,
774    /// Uniform table border width in eighths of a point, if explicitly set.
775    pub border_size_eighths: Option<u16>,
776    /// Side-specific table border widths, overriding `border_size_eighths` per side.
777    pub border_sizes: TableBorderSizes,
778    /// Uniform table border style, if explicitly set.
779    pub border_style: Option<TableBorderStyle>,
780    /// Side-specific table border styles, overriding `border_style` per side.
781    pub border_styles: TableBorderStyles,
782}
783
784impl Table {
785    /// `true` if every cell holds exactly one single-line paragraph and no cell
786    /// spans more than one row or column — i.e. a clean GFM pipe table is lossless.
787    pub fn is_simple_grid(&self) -> bool {
788        self.rows.iter().all(|row| {
789            row.cells.iter().all(|c| {
790                c.row_span <= 1
791                    && c.col_span <= 1
792                    && c.blocks.len() <= 1
793                    && c.blocks.iter().all(|b| match b {
794                        Block::Paragraph(p) => !p.text().contains('\n'),
795                        _ => false,
796                    })
797            })
798        })
799    }
800}
801
802/// A table row.
803#[derive(Debug, Clone, Default, PartialEq)]
804pub struct Row {
805    /// The row's cells, left to right.
806    pub cells: Vec<Cell>,
807}
808
809/// Vertical alignment of cell content.
810#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
811pub enum VCell {
812    /// Top (the default).
813    #[default]
814    Top,
815    /// Vertically centered.
816    Center,
817    /// Bottom.
818    Bottom,
819}
820
821/// Table-cell margins in twips (1/20 point), ordered by physical side.
822#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
823pub struct CellMargins {
824    /// Top margin in twips.
825    pub top: u32,
826    /// Right margin in twips.
827    pub right: u32,
828    /// Bottom margin in twips.
829    pub bottom: u32,
830    /// Left margin in twips.
831    pub left: u32,
832}
833
834/// A table cell — may hold block content and span rows/columns.
835#[derive(Debug, Clone, PartialEq)]
836pub struct Cell {
837    /// Block-level content of the cell.
838    pub blocks: Vec<Block>,
839    /// Number of rows this cell spans (1 = no vertical merge).
840    pub row_span: u16,
841    /// Number of columns this cell spans (1 = no horizontal merge).
842    pub col_span: u16,
843    /// Whether this is a header cell (`<th>`).
844    pub is_header: bool,
845    /// Cell background shading, if any.
846    pub shading: Option<Color>,
847    /// Vertical alignment of the cell content.
848    pub valign: VCell,
849    /// Cell width as a fraction of the table width, if set (`None` = auto).
850    pub width_pct: Option<f32>,
851    /// Per-cell margins in twips, if explicitly set.
852    pub margins: Option<CellMargins>,
853}
854
855impl Default for Cell {
856    fn default() -> Self {
857        Cell {
858            blocks: Vec::new(),
859            row_span: 1,
860            col_span: 1,
861            is_header: false,
862            shading: None,
863            valign: VCell::Top,
864            width_pct: None,
865            margins: None,
866        }
867    }
868}
869
870impl Cell {
871    /// The cell's text, paragraphs joined by newlines.
872    pub fn text(&self) -> String {
873        self.blocks
874            .iter()
875            .filter_map(|b| match b {
876                Block::Paragraph(p) => Some(p.text()),
877                _ => None,
878            })
879            .collect::<Vec<_>>()
880            .join("\n")
881    }
882}
883
884/// An embedded image. `bytes`/`mime` are present only when the picture was
885/// extracted; otherwise the node is a placeholder.
886#[derive(Debug, Clone, Default, PartialEq)]
887pub struct Image {
888    /// Alt text, if any.
889    pub alt: Option<String>,
890    /// Raw image bytes, when extracted.
891    pub bytes: Option<Vec<u8>>,
892    /// MIME type of `bytes` (e.g. `image/png`), when known.
893    pub mime: Option<String>,
894    /// Intrinsic width in pixels, parsed from the image header, when known.
895    pub width_px: Option<u32>,
896    /// Intrinsic height in pixels, parsed from the image header, when known.
897    pub height_px: Option<u32>,
898    /// Clockwise rotation in whole degrees.
899    pub rotation_degrees: Option<i32>,
900    /// Page-relative floating anchor offset in EMUs, when authoring `wp:anchor`.
901    pub floating_offset_emu: Option<(i64, i64)>,
902}
903
904/// Supported chart layouts for authored `.docx` output.
905#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
906pub enum ChartKind {
907    /// A clustered horizontal bar chart.
908    #[default]
909    Bar,
910    /// A stacked horizontal bar chart.
911    StackedBar,
912    /// A 100% stacked horizontal bar chart.
913    PercentStackedBar,
914    /// A clustered 3-D horizontal bar chart.
915    Bar3D,
916    /// A stacked 3-D horizontal bar chart.
917    StackedBar3D,
918    /// A 100% stacked 3-D horizontal bar chart.
919    PercentStackedBar3D,
920    /// A clustered vertical column chart.
921    Column,
922    /// A stacked vertical column chart.
923    StackedColumn,
924    /// A 100% stacked vertical column chart.
925    PercentStackedColumn,
926    /// A clustered 3-D vertical column chart.
927    Column3D,
928    /// A stacked 3-D vertical column chart.
929    StackedColumn3D,
930    /// A 100% stacked 3-D vertical column chart.
931    PercentStackedColumn3D,
932    /// A line chart with category labels on the horizontal axis.
933    Line,
934    /// A line chart without point markers.
935    LineNoMarkers,
936    /// A smoothed line chart with category labels on the horizontal axis.
937    SmoothLine,
938    /// A stacked line chart with category labels on the horizontal axis.
939    StackedLine,
940    /// A 100% stacked line chart with category labels on the horizontal axis.
941    PercentStackedLine,
942    /// A 3-D line chart with category labels on the horizontal axis.
943    Line3D,
944    /// An area chart with category labels on the horizontal axis.
945    Area,
946    /// A stacked area chart with category labels on the horizontal axis.
947    StackedArea,
948    /// A 100% stacked area chart with category labels on the horizontal axis.
949    PercentStackedArea,
950    /// A 3-D area chart with category labels on the horizontal axis.
951    Area3D,
952    /// A stacked 3-D area chart with category labels on the horizontal axis.
953    StackedArea3D,
954    /// A 100% stacked 3-D area chart with category labels on the horizontal axis.
955    PercentStackedArea3D,
956    /// A radar chart with category labels around a radial axis.
957    Radar,
958    /// A radar chart with explicit point markers.
959    RadarWithMarkers,
960    /// A filled radar chart with category labels around a radial axis.
961    FilledRadar,
962    /// A scatter chart with numeric horizontal and vertical values.
963    Scatter,
964    /// A marker-only scatter chart with numeric horizontal and vertical values.
965    ScatterMarkers,
966    /// A straight-line scatter chart without point markers.
967    ScatterLines,
968    /// A smoothed scatter chart with point markers.
969    ScatterSmooth,
970    /// A smoothed scatter chart without point markers.
971    ScatterSmoothNoMarkers,
972    /// A bubble chart with numeric horizontal values, vertical values, and sizes.
973    Bubble,
974    /// A 3-D bubble chart with numeric horizontal values, vertical values, and sizes.
975    Bubble3D,
976    /// A pie chart using the first series as slice values.
977    Pie,
978    /// An exploded pie chart using the first series as slice values.
979    ExplodedPie,
980    /// A 3-D pie chart using the first series as slice values.
981    Pie3D,
982    /// An exploded 3-D pie chart using the first series as slice values.
983    ExplodedPie3D,
984    /// A pie-of-pie chart using the first series as slice values.
985    PieOfPie,
986    /// A bar-of-pie chart using the first series as slice values.
987    BarOfPie,
988    /// A doughnut chart using the first series as slice values.
989    Doughnut,
990    /// An exploded doughnut chart using the first series as slice values.
991    ExplodedDoughnut,
992    /// A surface chart using category columns and series rows as a value grid.
993    Surface,
994    /// A 3-D surface chart using category columns and series rows as a value grid.
995    Surface3D,
996    /// A high-low-close stock chart using date/category labels.
997    StockHighLowClose,
998    /// A stock chart using date/category labels and open/high/low/close-style series.
999    Stock,
1000    /// A waterfall chart using the first series as category deltas.
1001    Waterfall,
1002    /// A treemap chart using the first series as rectangle sizes.
1003    Treemap,
1004    /// A sunburst chart using the first series as radial slice sizes.
1005    Sunburst,
1006    /// A histogram chart using category bins and the first series as bin counts.
1007    Histogram,
1008    /// A box-and-whisker chart using the first series as distribution values.
1009    BoxWhisker,
1010    /// A funnel chart using the first series as staged values.
1011    Funnel,
1012}
1013
1014/// Supported shape styles for authored 3-D bar/column charts.
1015#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1016pub enum ChartShape {
1017    /// Rectangular 3-D boxes.
1018    #[default]
1019    Box,
1020    /// Cylindrical 3-D bars or columns.
1021    Cylinder,
1022    /// Cone-shaped 3-D bars or columns.
1023    Cone,
1024    /// Cone-shaped 3-D bars or columns scaled to the maximum value.
1025    ConeToMax,
1026    /// Pyramid-shaped 3-D bars or columns.
1027    Pyramid,
1028    /// Pyramid-shaped 3-D bars or columns scaled to the maximum value.
1029    PyramidToMax,
1030}
1031
1032/// One named chart series with literal numeric values.
1033#[derive(Debug, Clone, Default, PartialEq)]
1034pub struct ChartSeries {
1035    /// Series display name.
1036    pub name: String,
1037    /// Values aligned with [`Chart::categories`].
1038    pub values: Vec<f64>,
1039    /// Optional bubble sizes aligned with [`ChartSeries::values`].
1040    pub bubble_sizes: Vec<f64>,
1041}
1042
1043/// A block-level chart with literal category and numeric caches.
1044#[derive(Debug, Clone, Default, PartialEq)]
1045pub struct Chart {
1046    /// Chart layout.
1047    pub kind: ChartKind,
1048    /// Optional display title.
1049    pub title: Option<String>,
1050    /// Category labels shared by all series.
1051    pub categories: Vec<String>,
1052    /// Named numeric series.
1053    pub series: Vec<ChartSeries>,
1054    /// Drawing width in pixels, interpreted at 96 dpi.
1055    pub width_px: Option<u32>,
1056    /// Drawing height in pixels, interpreted at 96 dpi.
1057    pub height_px: Option<u32>,
1058    /// Alternate text for the chart drawing.
1059    pub alt: Option<String>,
1060    /// Render surface-family charts as wireframes instead of filled surfaces.
1061    pub wireframe: bool,
1062    /// Shape style for 3-D bar and 3-D column charts.
1063    pub shape: ChartShape,
1064}
1065
1066/// Page geometry, in points. Default is A4 portrait with 1-inch margins.
1067#[derive(Debug, Clone, Copy, PartialEq)]
1068pub struct PageSetup {
1069    /// Page width.
1070    pub width_pt: f32,
1071    /// Page height.
1072    pub height_pt: f32,
1073    /// Uniform margin fallback (used for any side without an explicit override).
1074    pub margin_pt: f32,
1075    /// Left margin override (points); falls back to `margin_pt`. Per-side overrides
1076    /// let asymmetric layouts (a wide left sidebar, a binding gutter) render with
1077    /// the correct content box instead of a forced-uniform margin.
1078    pub margin_left_pt: Option<f32>,
1079    /// Right margin override (points); falls back to `margin_pt`.
1080    pub margin_right_pt: Option<f32>,
1081    /// Top margin override (points); falls back to `margin_pt`.
1082    pub margin_top_pt: Option<f32>,
1083    /// Bottom margin override (points); falls back to `margin_pt`.
1084    pub margin_bottom_pt: Option<f32>,
1085    /// Landscape orientation (swaps width/height semantics on emit).
1086    pub landscape: bool,
1087}
1088
1089impl PageSetup {
1090    /// Left margin (override or uniform fallback).
1091    pub fn left(&self) -> f32 {
1092        self.margin_left_pt.unwrap_or(self.margin_pt)
1093    }
1094    /// Right margin (override or uniform fallback).
1095    pub fn right(&self) -> f32 {
1096        self.margin_right_pt.unwrap_or(self.margin_pt)
1097    }
1098    /// Top margin (override or uniform fallback).
1099    pub fn top(&self) -> f32 {
1100        self.margin_top_pt.unwrap_or(self.margin_pt)
1101    }
1102    /// Bottom margin (override or uniform fallback).
1103    pub fn bottom(&self) -> f32 {
1104        self.margin_bottom_pt.unwrap_or(self.margin_pt)
1105    }
1106}
1107
1108impl Default for PageSetup {
1109    fn default() -> Self {
1110        // A4 = 210×297mm = 595.3×841.9pt; 1in = 72pt margins.
1111        PageSetup {
1112            width_pt: 595.3,
1113            height_pt: 841.9,
1114            margin_pt: 72.0,
1115            margin_left_pt: None,
1116            margin_right_pt: None,
1117            margin_top_pt: None,
1118            margin_bottom_pt: None,
1119            landscape: false,
1120        }
1121    }
1122}
1123
1124/// Display format for generated section page numbers.
1125#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1126pub enum PageNumberFormat {
1127    /// Decimal numbers (`1`, `2`, `3`).
1128    Decimal,
1129    /// Zero-padded decimal numbers (`01`, `02`, `03`).
1130    DecimalZero,
1131    /// Decimal numbers surrounded by dashes (`- 1 -`, `- 2 -`, `- 3 -`).
1132    NumberInDash,
1133    /// Full-width decimal digits (`1`, `2`, `3`).
1134    DecimalFullWidth,
1135    /// Half-width decimal digits (`1`, `2`, `3`).
1136    DecimalHalfWidth,
1137    /// Alternate full-width decimal digits.
1138    DecimalFullWidth2,
1139    /// Circled decimal digits for one through twenty, then decimal fallback.
1140    DecimalEnclosedCircle,
1141    /// Decimal digits followed by full-stop glyphs for one through twenty.
1142    DecimalEnclosedFullstop,
1143    /// Parenthesized decimal digits for one through twenty.
1144    DecimalEnclosedParen,
1145    /// Korean Ganada sequence.
1146    Ganada,
1147    /// Korean Chosung sequence.
1148    Chosung,
1149    /// Korean digital numerals.
1150    KoreanDigital,
1151    /// Native Korean counting words.
1152    KoreanCounting,
1153    /// Korean legal numerals.
1154    KoreanLegal,
1155    /// Alternate Korean digital numerals.
1156    KoreanDigital2,
1157    /// Lowercase letters (`a`, `b`, `c`).
1158    LowerLetter,
1159    /// Uppercase letters (`A`, `B`, `C`).
1160    UpperLetter,
1161    /// Lowercase Roman numerals (`i`, `ii`, `iii`).
1162    LowerRoman,
1163    /// Uppercase Roman numerals (`I`, `II`, `III`).
1164    UpperRoman,
1165    /// Ordinal decimal numbers (`1st`, `2nd`, `3rd`).
1166    Ordinal,
1167    /// Cardinal text (`one`, `two`, `three`).
1168    CardinalText,
1169    /// Ordinal text (`first`, `second`, `third`).
1170    OrdinalText,
1171}
1172
1173#[cfg(feature = "docx")]
1174impl PageNumberFormat {
1175    pub(crate) fn wml_value(self) -> &'static str {
1176        match self {
1177            PageNumberFormat::Decimal => "decimal",
1178            PageNumberFormat::DecimalZero => "decimalZero",
1179            PageNumberFormat::NumberInDash => "numberInDash",
1180            PageNumberFormat::DecimalFullWidth => "decimalFullWidth",
1181            PageNumberFormat::DecimalHalfWidth => "decimalHalfWidth",
1182            PageNumberFormat::DecimalFullWidth2 => "decimalFullWidth2",
1183            PageNumberFormat::DecimalEnclosedCircle => "decimalEnclosedCircle",
1184            PageNumberFormat::DecimalEnclosedFullstop => "decimalEnclosedFullstop",
1185            PageNumberFormat::DecimalEnclosedParen => "decimalEnclosedParen",
1186            PageNumberFormat::Ganada => "ganada",
1187            PageNumberFormat::Chosung => "chosung",
1188            PageNumberFormat::KoreanDigital => "koreanDigital",
1189            PageNumberFormat::KoreanCounting => "koreanCounting",
1190            PageNumberFormat::KoreanLegal => "koreanLegal",
1191            PageNumberFormat::KoreanDigital2 => "koreanDigital2",
1192            PageNumberFormat::LowerLetter => "lowerLetter",
1193            PageNumberFormat::UpperLetter => "upperLetter",
1194            PageNumberFormat::LowerRoman => "lowerRoman",
1195            PageNumberFormat::UpperRoman => "upperRoman",
1196            PageNumberFormat::Ordinal => "ordinal",
1197            PageNumberFormat::CardinalText => "cardinalText",
1198            PageNumberFormat::OrdinalText => "ordinalText",
1199        }
1200    }
1201
1202    pub(crate) fn from_wml_value(value: &str) -> Option<Self> {
1203        let value = value.trim();
1204        match value {
1205            "decimal" => Some(PageNumberFormat::Decimal),
1206            "decimalZero" => Some(PageNumberFormat::DecimalZero),
1207            "numberInDash" => Some(PageNumberFormat::NumberInDash),
1208            "decimalFullWidth" => Some(PageNumberFormat::DecimalFullWidth),
1209            "decimalHalfWidth" => Some(PageNumberFormat::DecimalHalfWidth),
1210            "decimalFullWidth2" => Some(PageNumberFormat::DecimalFullWidth2),
1211            "decimalEnclosedCircle" => Some(PageNumberFormat::DecimalEnclosedCircle),
1212            "decimalEnclosedFullstop" => Some(PageNumberFormat::DecimalEnclosedFullstop),
1213            "decimalEnclosedParen" => Some(PageNumberFormat::DecimalEnclosedParen),
1214            "ganada" => Some(PageNumberFormat::Ganada),
1215            "chosung" => Some(PageNumberFormat::Chosung),
1216            "koreanDigital" => Some(PageNumberFormat::KoreanDigital),
1217            "koreanCounting" => Some(PageNumberFormat::KoreanCounting),
1218            "koreanLegal" => Some(PageNumberFormat::KoreanLegal),
1219            "koreanDigital2" => Some(PageNumberFormat::KoreanDigital2),
1220            "lowerLetter" => Some(PageNumberFormat::LowerLetter),
1221            "upperLetter" => Some(PageNumberFormat::UpperLetter),
1222            "lowerRoman" => Some(PageNumberFormat::LowerRoman),
1223            "upperRoman" => Some(PageNumberFormat::UpperRoman),
1224            "ordinal" => Some(PageNumberFormat::Ordinal),
1225            "cardinalText" => Some(PageNumberFormat::CardinalText),
1226            "ordinalText" => Some(PageNumberFormat::OrdinalText),
1227            _ => None,
1228        }
1229    }
1230}
1231
1232/// Text flow direction for generated `.docx` section properties.
1233#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1234pub enum TextDirection {
1235    /// Left-to-right lines flowing from top to bottom (`lrTb`).
1236    LeftToRightTopToBottom,
1237    /// Top-to-bottom columns flowing from right to left (`tbRl`).
1238    TopToBottomRightToLeft,
1239    /// Bottom-to-top columns flowing from left to right (`btLr`).
1240    BottomToTopLeftToRight,
1241    /// Vertically oriented left-to-right text flowing from top to bottom (`lrTbV`).
1242    LeftToRightTopToBottomVertical,
1243    /// Vertically oriented top-to-bottom text flowing from right to left (`tbRlV`).
1244    TopToBottomRightToLeftVertical,
1245    /// Vertically oriented top-to-bottom text flowing from left to right (`tbLrV`).
1246    TopToBottomLeftToRightVertical,
1247}
1248
1249#[cfg(feature = "docx")]
1250impl TextDirection {
1251    pub(crate) fn wml_value(self) -> &'static str {
1252        match self {
1253            TextDirection::LeftToRightTopToBottom => "lrTb",
1254            TextDirection::TopToBottomRightToLeft => "tbRl",
1255            TextDirection::BottomToTopLeftToRight => "btLr",
1256            TextDirection::LeftToRightTopToBottomVertical => "lrTbV",
1257            TextDirection::TopToBottomRightToLeftVertical => "tbRlV",
1258            TextDirection::TopToBottomLeftToRightVertical => "tbLrV",
1259        }
1260    }
1261
1262    pub(crate) fn from_wml_value(value: &str) -> Option<Self> {
1263        let value = value.trim();
1264        match value {
1265            "lrTb" => Some(TextDirection::LeftToRightTopToBottom),
1266            "tbRl" => Some(TextDirection::TopToBottomRightToLeft),
1267            "btLr" => Some(TextDirection::BottomToTopLeftToRight),
1268            "lrTbV" => Some(TextDirection::LeftToRightTopToBottomVertical),
1269            "tbRlV" => Some(TextDirection::TopToBottomRightToLeftVertical),
1270            "tbLrV" => Some(TextDirection::TopToBottomLeftToRightVertical),
1271            _ => None,
1272        }
1273    }
1274}
1275
1276/// Document grid behavior for a WordprocessingML section.
1277#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1278pub enum DocGridType {
1279    /// No document grid (`default`).
1280    Default,
1281    /// Line grid only (`lines`).
1282    Lines,
1283    /// Line and character grid (`linesAndChars`).
1284    LinesAndChars,
1285    /// Character grid only (`snapToChars`).
1286    SnapToChars,
1287}
1288
1289#[cfg(feature = "docx")]
1290impl DocGridType {
1291    pub(crate) fn wml_value(self) -> &'static str {
1292        match self {
1293            DocGridType::Default => "default",
1294            DocGridType::Lines => "lines",
1295            DocGridType::LinesAndChars => "linesAndChars",
1296            DocGridType::SnapToChars => "snapToChars",
1297        }
1298    }
1299
1300    pub(crate) fn from_wml_value(value: &str) -> Option<Self> {
1301        let value = value.trim();
1302        match value {
1303            "default" => Some(DocGridType::Default),
1304            "lines" => Some(DocGridType::Lines),
1305            "linesAndChars" => Some(DocGridType::LinesAndChars),
1306            "snapToChars" => Some(DocGridType::SnapToChars),
1307            _ => None,
1308        }
1309    }
1310}
1311
1312/// Document grid settings for a WordprocessingML section.
1313#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1314pub struct DocGrid {
1315    /// Grid behavior.
1316    pub grid_type: DocGridType,
1317    /// Grid line pitch (`w:linePitch`) in twentieths of a point, if set.
1318    pub line_pitch: Option<u32>,
1319    /// Grid character pitch adjustment (`w:charSpace`), if set.
1320    pub character_space: Option<u32>,
1321}
1322
1323/// Section-level layout recovered from or generated into `.docx` section
1324/// properties.
1325///
1326/// In WordprocessingML a section property block closes the section that came
1327/// before it. `Block::SectionBreak(setup)` follows that convention: the stored
1328/// setup describes the section ending at that block, while `DocModel::setup`
1329/// describes the final section.
1330#[derive(Debug, Clone, Default, PartialEq)]
1331pub struct SectionSetup {
1332    /// Section-break start behavior, if this setup belongs to a section boundary.
1333    pub section_break: Option<SectionBreakKind>,
1334    /// Page geometry.
1335    pub page: PageSetup,
1336    /// Running header content (empty = none).
1337    pub header: Vec<Block>,
1338    /// First-page running header content (empty = none or default applies).
1339    pub first_header: Vec<Block>,
1340    /// Even-page running header content (empty = none or default applies).
1341    pub even_header: Vec<Block>,
1342    /// Running footer content (empty = none).
1343    pub footer: Vec<Block>,
1344    /// First-page running footer content (empty = none or default applies).
1345    pub first_footer: Vec<Block>,
1346    /// Even-page running footer content (empty = none or default applies).
1347    pub even_footer: Vec<Block>,
1348    /// Use distinct first-page section behavior (`w:titlePg`).
1349    pub title_page: bool,
1350    /// Emit a centered page number (`PAGE` field) in the footer.
1351    pub page_numbers: bool,
1352    /// Display page number to start this section at, if explicitly set.
1353    pub page_number_start: Option<u32>,
1354    /// Display page-number format for this section, if explicitly set.
1355    pub page_number_format: Option<PageNumberFormat>,
1356    /// Number of text columns in this section, if explicitly set.
1357    pub columns: Option<u16>,
1358    /// Text flow direction for this section, if explicitly set.
1359    pub text_direction: Option<TextDirection>,
1360    /// Document grid settings for this section, if explicitly set.
1361    pub doc_grid: Option<DocGrid>,
1362}
1363
1364/// Document-level layout + metadata, for authoring and rendering. All fields are
1365/// optional/default so existing read paths are unaffected.
1366#[derive(Debug, Clone, Default, PartialEq)]
1367pub struct DocSetup {
1368    /// Page geometry.
1369    pub page: PageSetup,
1370    /// Paragraph style definitions for generated `.docx` output.
1371    pub styles: Vec<ParagraphStyle>,
1372    /// Running header content (empty = none).
1373    pub header: Vec<Block>,
1374    /// First-page running header content (empty = none or default applies).
1375    pub first_header: Vec<Block>,
1376    /// Even-page running header content (empty = none or default applies).
1377    pub even_header: Vec<Block>,
1378    /// Running footer content (empty = none).
1379    pub footer: Vec<Block>,
1380    /// First-page running footer content (empty = none or default applies).
1381    pub first_footer: Vec<Block>,
1382    /// Even-page running footer content (empty = none or default applies).
1383    pub even_footer: Vec<Block>,
1384    /// Use distinct first-page section behavior (`w:titlePg`).
1385    pub title_page: bool,
1386    /// Emit a centered page number (`PAGE` field) in the footer.
1387    pub page_numbers: bool,
1388    /// Display page number to start the final/current section at, if explicitly set.
1389    pub page_number_start: Option<u32>,
1390    /// Display page-number format for the final/current section, if explicitly set.
1391    pub page_number_format: Option<PageNumberFormat>,
1392    /// Number of text columns in the final/current section, if explicitly set.
1393    pub columns: Option<u16>,
1394    /// Text flow direction for the final/current section, if explicitly set.
1395    pub text_direction: Option<TextDirection>,
1396    /// Document grid settings for the final/current section, if explicitly set.
1397    pub doc_grid: Option<DocGrid>,
1398    /// Optional document identifier emitted to `word/settings.xml` as `w14:docId`.
1399    pub document_id: Option<String>,
1400    /// Authored Office web-extension task panes.
1401    pub web_extension_task_panes: Vec<WebExtensionTaskPane>,
1402    /// Document title metadata.
1403    pub title: Option<String>,
1404    /// Document subject metadata.
1405    pub subject: Option<String>,
1406    /// Document author metadata.
1407    pub creator: Option<String>,
1408    /// Document description metadata.
1409    pub description: Option<String>,
1410    /// Document keywords metadata.
1411    pub keywords: Option<String>,
1412    /// Document category metadata.
1413    pub category: Option<String>,
1414    /// Document content-status metadata.
1415    pub content_status: Option<String>,
1416    /// Document last-modified-by metadata.
1417    pub last_modified_by: Option<String>,
1418    /// Document creation timestamp metadata.
1419    pub created: Option<String>,
1420    /// Document last-modified timestamp metadata.
1421    pub modified: Option<String>,
1422    /// Document last-printed timestamp metadata.
1423    pub last_printed: Option<String>,
1424    /// Document revision-count metadata.
1425    pub revision: Option<String>,
1426    /// Document version metadata.
1427    pub version: Option<String>,
1428}
1429
1430impl From<&DocSetup> for SectionSetup {
1431    fn from(setup: &DocSetup) -> Self {
1432        SectionSetup {
1433            section_break: None,
1434            page: setup.page,
1435            header: setup.header.clone(),
1436            first_header: setup.first_header.clone(),
1437            even_header: setup.even_header.clone(),
1438            footer: setup.footer.clone(),
1439            first_footer: setup.first_footer.clone(),
1440            even_footer: setup.even_footer.clone(),
1441            title_page: setup.title_page,
1442            page_numbers: setup.page_numbers,
1443            page_number_start: setup.page_number_start,
1444            page_number_format: setup.page_number_format,
1445            columns: setup.columns,
1446            text_direction: setup.text_direction,
1447            doc_grid: setup.doc_grid,
1448        }
1449    }
1450}
1451
1452#[cfg(all(test, feature = "docx"))]
1453mod tests {
1454    use super::{DocGridType, PageNumberFormat, SectionBreakKind, TableBorderStyle, TextDirection};
1455
1456    #[test]
1457    fn wml_enum_parsers_trim_ooxml_values() {
1458        assert_eq!(
1459            SectionBreakKind::from_wml_value(" evenPage "),
1460            Some(SectionBreakKind::EvenPage)
1461        );
1462        assert_eq!(
1463            TableBorderStyle::from_wml_value(" dotted "),
1464            Some(TableBorderStyle::Dotted)
1465        );
1466        assert_eq!(
1467            PageNumberFormat::from_wml_value(" lowerRoman "),
1468            Some(PageNumberFormat::LowerRoman)
1469        );
1470        assert_eq!(
1471            TextDirection::from_wml_value(" tbRl "),
1472            Some(TextDirection::TopToBottomRightToLeft)
1473        );
1474        assert_eq!(
1475            DocGridType::from_wml_value(" linesAndChars "),
1476            Some(DocGridType::LinesAndChars)
1477        );
1478    }
1479}