Skip to main content

hwpforge_core/
section.rs

1//! Document sections.
2//!
3//! A [`Section`] is a contiguous block of paragraphs sharing the same
4//! [`PageSettings`]. Typical HWP documents have one section, but
5//! complex reports may mix portrait and landscape sections.
6//!
7//! # Examples
8//!
9//! ```
10//! use hwpforge_core::section::Section;
11//! use hwpforge_core::PageSettings;
12//! use hwpforge_core::paragraph::Paragraph;
13//! use hwpforge_core::run::Run;
14//! use hwpforge_foundation::{CharShapeIndex, ParaShapeIndex};
15//!
16//! let mut section = Section::new(PageSettings::a4());
17//! section.add_paragraph(Paragraph::with_runs(
18//!     vec![Run::text("Hello", CharShapeIndex::new(0))],
19//!     ParaShapeIndex::new(0),
20//! ));
21//! assert_eq!(section.paragraph_count(), 1);
22//! ```
23
24use hwpforge_foundation::{
25    ApplyPageType, HwpUnit, NumberFormatType, PageNumberPosition, ShowMode, TextDirection,
26};
27use schemars::JsonSchema;
28use serde::{Deserialize, Serialize};
29
30use crate::column::ColumnSettings;
31use crate::page::PageSettings;
32use crate::paragraph::Paragraph;
33
34// ---------------------------------------------------------------------------
35// Visibility
36// ---------------------------------------------------------------------------
37
38/// Controls visibility of headers, footers, master pages, borders, and fills.
39///
40/// Maps to `<hp:visibility>` inside `<hp:secPr>`. All flags default to
41/// the standard 한글 values (show everything, no hiding).
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
43pub struct Visibility {
44    /// Hide header on the first page.
45    #[serde(default)]
46    pub hide_first_header: bool,
47    /// Hide footer on the first page.
48    #[serde(default)]
49    pub hide_first_footer: bool,
50    /// Hide master page on the first page.
51    #[serde(default)]
52    pub hide_first_master_page: bool,
53    /// Hide page number on the first page.
54    #[serde(default)]
55    pub hide_first_page_num: bool,
56    /// Hide empty line on the first page.
57    #[serde(default)]
58    pub hide_first_empty_line: bool,
59    /// Show line numbers in the section.
60    #[serde(default)]
61    pub show_line_number: bool,
62    /// Border visibility mode.
63    #[serde(default)]
64    pub border: ShowMode,
65    /// Fill visibility mode.
66    #[serde(default)]
67    pub fill: ShowMode,
68}
69
70impl Default for Visibility {
71    fn default() -> Self {
72        Self {
73            hide_first_header: false,
74            hide_first_footer: false,
75            hide_first_master_page: false,
76            hide_first_page_num: false,
77            hide_first_empty_line: false,
78            show_line_number: false,
79            border: ShowMode::ShowAll,
80            fill: ShowMode::ShowAll,
81        }
82    }
83}
84
85// ---------------------------------------------------------------------------
86// LineNumberShape
87// ---------------------------------------------------------------------------
88
89/// Line numbering settings for a section.
90///
91/// Maps to `<hp:lineNumberShape>` inside `<hp:secPr>`.
92#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
93pub struct LineNumberShape {
94    /// Restart type: 0 = continuous, 1 = per page, 2 = per section.
95    #[serde(default)]
96    pub restart_type: u8,
97    /// Count by N (show number every N lines, 0 = disabled).
98    #[serde(default)]
99    pub count_by: u16,
100    /// Distance from text to line number (HwpUnit).
101    #[serde(default)]
102    pub distance: HwpUnit,
103    /// Starting line number.
104    #[serde(default)]
105    pub start_number: u32,
106}
107
108// ---------------------------------------------------------------------------
109// PageBorderFillEntry
110// ---------------------------------------------------------------------------
111
112/// A single page border/fill entry for the section.
113///
114/// Maps to `<hp:pageBorderFill>` inside `<hp:secPr>`.
115/// Standard 한글 documents have 3 entries: BOTH, EVEN, ODD.
116#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
117pub struct PageBorderFillEntry {
118    /// Which pages this border fill applies to: `"BOTH"`, `"EVEN"`, `"ODD"`.
119    pub apply_type: String,
120    /// Reference to a borderFill definition (1-based index).
121    #[serde(default = "PageBorderFillEntry::default_border_fill_id")]
122    pub border_fill_id: u32,
123    /// Whether the border is relative to text or paper.
124    #[serde(default = "PageBorderFillEntry::default_text_border")]
125    pub text_border: String,
126    /// Whether header is inside the border.
127    #[serde(default)]
128    pub header_inside: bool,
129    /// Whether footer is inside the border.
130    #[serde(default)]
131    pub footer_inside: bool,
132    /// Fill area: `"PAPER"` or `"PAGE"`.
133    #[serde(default = "PageBorderFillEntry::default_fill_area")]
134    pub fill_area: String,
135    /// Offset from page edge (left, right, top, bottom) in HwpUnit.
136    #[serde(default = "PageBorderFillEntry::default_offset")]
137    pub offset: [HwpUnit; 4],
138}
139
140impl PageBorderFillEntry {
141    fn default_border_fill_id() -> u32 {
142        1
143    }
144    fn default_text_border() -> String {
145        "PAPER".to_string()
146    }
147    fn default_fill_area() -> String {
148        "PAPER".to_string()
149    }
150    fn default_offset() -> [HwpUnit; 4] {
151        // 1417 HwpUnit ≈ 5mm default offset
152        [
153            HwpUnit::new(1417).unwrap(),
154            HwpUnit::new(1417).unwrap(),
155            HwpUnit::new(1417).unwrap(),
156            HwpUnit::new(1417).unwrap(),
157        ]
158    }
159}
160
161impl Default for PageBorderFillEntry {
162    fn default() -> Self {
163        Self {
164            apply_type: "BOTH".to_string(),
165            border_fill_id: 1,
166            text_border: "PAPER".to_string(),
167            header_inside: false,
168            footer_inside: false,
169            fill_area: "PAPER".to_string(),
170            offset: Self::default_offset(),
171        }
172    }
173}
174
175// ---------------------------------------------------------------------------
176// BeginNum
177// ---------------------------------------------------------------------------
178
179/// Starting numbers for various auto-numbering sequences.
180///
181/// Maps to `<hh:beginNum>` in header.xml.
182#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
183pub struct BeginNum {
184    /// Starting page number (default: 1).
185    #[serde(default = "BeginNum::one")]
186    pub page: u32,
187    /// Starting footnote number (default: 1).
188    #[serde(default = "BeginNum::one")]
189    pub footnote: u32,
190    /// Starting endnote number (default: 1).
191    #[serde(default = "BeginNum::one")]
192    pub endnote: u32,
193    /// Starting picture number (default: 1).
194    #[serde(default = "BeginNum::one")]
195    pub pic: u32,
196    /// Starting table number (default: 1).
197    #[serde(default = "BeginNum::one")]
198    pub tbl: u32,
199    /// Starting equation number (default: 1).
200    #[serde(default = "BeginNum::one")]
201    pub equation: u32,
202}
203
204impl BeginNum {
205    fn one() -> u32 {
206        1
207    }
208}
209
210impl Default for BeginNum {
211    fn default() -> Self {
212        Self { page: 1, footnote: 1, endnote: 1, pic: 1, tbl: 1, equation: 1 }
213    }
214}
215
216// ---------------------------------------------------------------------------
217// MasterPage
218// ---------------------------------------------------------------------------
219
220/// A master page (background/watermark page) for a section.
221///
222/// Master pages provide background content rendered behind the main body.
223/// Maps to `<masterPage>` elements inside `<hp:secPr>`.
224///
225/// In HWPX, each master page has an `applyPageType` attribute
226/// (`BOTH`, `EVEN`, or `ODD`) and contains its own paragraphs.
227#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
228pub struct MasterPage {
229    /// Which pages this master page applies to.
230    pub apply_page_type: ApplyPageType,
231    /// Paragraphs composing the master page content.
232    pub paragraphs: Vec<Paragraph>,
233}
234
235impl MasterPage {
236    /// Creates a new master page with the given page type and paragraphs.
237    pub fn new(apply_page_type: ApplyPageType, paragraphs: Vec<Paragraph>) -> Self {
238        Self { apply_page_type, paragraphs }
239    }
240}
241
242impl std::fmt::Display for MasterPage {
243    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
244        let n = self.paragraphs.len();
245        let word = if n == 1 { "paragraph" } else { "paragraphs" };
246        write!(f, "MasterPage({n} {word}, {:?})", self.apply_page_type)
247    }
248}
249
250// ---------------------------------------------------------------------------
251// HeaderFooter
252// ---------------------------------------------------------------------------
253
254/// A header or footer region containing paragraphs.
255///
256/// In HWPX, headers and footers appear as `<hp:header>` / `<hp:footer>`
257/// elements inside `<hp:ctrl>` in the section body. Each contains its own
258/// paragraphs and an [`ApplyPageType`] controlling which pages it applies to.
259///
260/// # Examples
261///
262/// ```
263/// use hwpforge_core::section::HeaderFooter;
264/// use hwpforge_core::paragraph::Paragraph;
265/// use hwpforge_foundation::{ApplyPageType, ParaShapeIndex};
266///
267/// let hf = HeaderFooter::new(
268///     vec![Paragraph::new(ParaShapeIndex::new(0))],
269///     ApplyPageType::Both,
270/// );
271/// assert_eq!(hf.paragraphs.len(), 1);
272/// assert_eq!(hf.apply_page_type, ApplyPageType::Both);
273/// ```
274#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
275pub struct HeaderFooter {
276    /// Paragraphs composing the header/footer content.
277    pub paragraphs: Vec<Paragraph>,
278    /// Which pages this header/footer applies to.
279    pub apply_page_type: ApplyPageType,
280}
281
282impl HeaderFooter {
283    /// Creates a new header/footer with the given paragraphs and page scope.
284    pub fn new(paragraphs: Vec<Paragraph>, apply_page_type: ApplyPageType) -> Self {
285        Self { paragraphs, apply_page_type }
286    }
287
288    /// Creates a header/footer applied to **all** pages (both odd and even).
289    ///
290    /// This is the most common case for simple documents that use a single
291    /// header or footer on every page.
292    ///
293    /// # Examples
294    ///
295    /// ```
296    /// use hwpforge_core::section::HeaderFooter;
297    /// use hwpforge_core::paragraph::Paragraph;
298    /// use hwpforge_foundation::{ApplyPageType, ParaShapeIndex};
299    ///
300    /// let hf = HeaderFooter::all_pages(vec![Paragraph::new(ParaShapeIndex::new(0))]);
301    /// assert_eq!(hf.apply_page_type, ApplyPageType::Both);
302    /// assert_eq!(hf.paragraphs.len(), 1);
303    /// ```
304    pub fn all_pages(paragraphs: Vec<Paragraph>) -> Self {
305        Self { paragraphs, apply_page_type: ApplyPageType::Both }
306    }
307
308    /// Creates a header/footer applied to all pages.
309    #[deprecated(since = "0.2.0", note = "Use `all_pages()` instead")]
310    pub fn both(paragraphs: Vec<Paragraph>) -> Self {
311        Self::all_pages(paragraphs)
312    }
313}
314
315impl std::fmt::Display for HeaderFooter {
316    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
317        let n = self.paragraphs.len();
318        let word = if n == 1 { "paragraph" } else { "paragraphs" };
319        write!(f, "HeaderFooter({n} {word}, {:?})", self.apply_page_type)
320    }
321}
322
323// ---------------------------------------------------------------------------
324// PageNumber
325// ---------------------------------------------------------------------------
326
327/// Page number display settings for a section.
328///
329/// In HWPX, page numbers appear as `<hp:pageNum>` inside `<hp:ctrl>`.
330/// This struct controls position, format, and optional decoration characters.
331///
332/// # Examples
333///
334/// ```
335/// use hwpforge_core::section::PageNumber;
336/// use hwpforge_foundation::{NumberFormatType, PageNumberPosition};
337///
338/// let pn = PageNumber::new(
339///     PageNumberPosition::BottomCenter,
340///     NumberFormatType::Digit,
341/// );
342/// assert_eq!(pn.position, PageNumberPosition::BottomCenter);
343/// ```
344#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
345pub struct PageNumber {
346    /// Where to display the page number.
347    pub position: PageNumberPosition,
348    /// Numbering format (digits, roman, etc.).
349    pub number_format: NumberFormatType,
350    /// Optional decoration string placed around the number
351    /// (e.g. `"- "` for `"- 1 -"`). Empty means no decoration.
352    pub decoration: String,
353}
354
355impl PageNumber {
356    /// Creates a new page number with no decoration.
357    pub fn new(position: PageNumberPosition, number_format: NumberFormatType) -> Self {
358        Self { position, number_format, decoration: String::new() }
359    }
360
361    /// Creates a page number at the bottom-center in plain digit format.
362    ///
363    /// This is the most common page number layout for Korean documents.
364    /// Equivalent to `PageNumber::new(PageNumberPosition::BottomCenter, NumberFormatType::Digit)`
365    /// with an empty `decoration`.
366    ///
367    /// # Examples
368    ///
369    /// ```
370    /// use hwpforge_core::section::PageNumber;
371    /// use hwpforge_foundation::{NumberFormatType, PageNumberPosition};
372    ///
373    /// let pn = PageNumber::bottom_center();
374    /// assert_eq!(pn.position, PageNumberPosition::BottomCenter);
375    /// assert_eq!(pn.number_format, NumberFormatType::Digit);
376    /// assert!(pn.decoration.is_empty());
377    /// ```
378    pub fn bottom_center() -> Self {
379        Self {
380            position: PageNumberPosition::BottomCenter,
381            number_format: NumberFormatType::Digit,
382            decoration: String::new(),
383        }
384    }
385
386    /// Creates a new page number with decoration characters placed around the number.
387    ///
388    /// # Examples
389    ///
390    /// ```
391    /// use hwpforge_core::section::PageNumber;
392    /// use hwpforge_foundation::{NumberFormatType, PageNumberPosition};
393    ///
394    /// let pn = PageNumber::with_decoration(
395    ///     PageNumberPosition::BottomCenter,
396    ///     NumberFormatType::Digit,
397    ///     "- ",
398    /// );
399    /// assert_eq!(pn.decoration, "- ");
400    /// ```
401    pub fn with_decoration(
402        position: PageNumberPosition,
403        number_format: NumberFormatType,
404        decoration: impl Into<String>,
405    ) -> Self {
406        Self { position, number_format, decoration: decoration.into() }
407    }
408
409    /// Creates a new page number with side decoration characters.
410    #[deprecated(since = "0.2.0", note = "Use `with_decoration()` instead")]
411    pub fn with_side_char(
412        position: PageNumberPosition,
413        number_format: NumberFormatType,
414        side_char: impl Into<String>,
415    ) -> Self {
416        Self::with_decoration(position, number_format, side_char)
417    }
418}
419
420impl std::fmt::Display for PageNumber {
421    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
422        write!(f, "PageNumber({:?}, {:?})", self.position, self.number_format)
423    }
424}
425
426// ---------------------------------------------------------------------------
427// Section
428// ---------------------------------------------------------------------------
429
430/// A document section: paragraphs + page geometry.
431///
432/// # Examples
433///
434/// ```
435/// use hwpforge_core::section::Section;
436/// use hwpforge_core::PageSettings;
437/// use hwpforge_core::paragraph::Paragraph;
438/// use hwpforge_foundation::ParaShapeIndex;
439///
440/// let section = Section::with_paragraphs(
441///     vec![Paragraph::new(ParaShapeIndex::new(0))],
442///     PageSettings::a4(),
443/// );
444/// assert_eq!(section.paragraph_count(), 1);
445/// ```
446#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
447pub struct Section {
448    /// Ordered paragraphs in this section.
449    pub paragraphs: Vec<Paragraph>,
450    /// Page dimensions and margins for this section.
451    pub page_settings: PageSettings,
452    /// Headers for this section, ordered as in HWPX wire (`<hp:header>` × N).
453    ///
454    /// HWPX allows multiple `<hp:header>` elements in a single section,
455    /// differentiated by `applyPageType` (`BOTH` / `ODD` / `EVEN`). HWP5
456    /// stores the same information as multiple `head` ctrl records.
457    /// Empty `Vec` = "no header on this section". See
458    /// [ADR-002](../../../.docs/architecture/adr/ADR-002-section-multi-header-footer-cardinality.md)
459    /// for the cardinality decision.
460    #[serde(default, skip_serializing_if = "Vec::is_empty")]
461    pub headers: Vec<HeaderFooter>,
462    /// Footers for this section, ordered as in HWPX wire (`<hp:footer>` × N).
463    /// See `headers` for the cardinality rationale.
464    #[serde(default, skip_serializing_if = "Vec::is_empty")]
465    pub footers: Vec<HeaderFooter>,
466    /// Optional page number settings for this section.
467    #[serde(default, skip_serializing_if = "Option::is_none")]
468    pub page_number: Option<PageNumber>,
469    /// Multi-column layout. `None` = single column (default).
470    #[serde(default, skip_serializing_if = "Option::is_none")]
471    pub column_settings: Option<ColumnSettings>,
472    /// Visibility flags for headers, footers, borders, etc.
473    /// `None` = default visibility (show everything).
474    #[serde(default, skip_serializing_if = "Option::is_none")]
475    pub visibility: Option<Visibility>,
476    /// Line numbering settings. `None` = no line numbers.
477    #[serde(default, skip_serializing_if = "Option::is_none")]
478    pub line_number_shape: Option<LineNumberShape>,
479    /// Page border/fill entries. `None` = default 3 entries (BOTH/EVEN/ODD with borderFillIDRef=1).
480    #[serde(default, skip_serializing_if = "Option::is_none")]
481    pub page_border_fills: Option<Vec<PageBorderFillEntry>>,
482    /// Master pages (background content rendered behind the body).
483    /// `None` = no master pages (default).
484    #[serde(default, skip_serializing_if = "Option::is_none")]
485    pub master_pages: Option<Vec<MasterPage>>,
486    /// Starting numbers for auto-numbering sequences.
487    /// `None` = default values (all start at 1).
488    #[serde(default, skip_serializing_if = "Option::is_none")]
489    pub begin_num: Option<BeginNum>,
490    /// Text writing direction for this section.
491    /// Defaults to [`TextDirection::Horizontal`] (가로쓰기).
492    #[serde(default)]
493    pub text_direction: TextDirection,
494}
495
496impl Section {
497    /// 이 섹션의 모든 문단(본문·머리말·꼬리말·바탕쪽 + 각 문단의 중첩)을
498    /// 문서 순서로 방문한다.
499    pub fn for_each_paragraph_mut<F: FnMut(&mut Paragraph)>(&mut self, mut f: F) {
500        self.walk_paragraphs_mut(&mut f);
501    }
502
503    /// [`Self::for_each_paragraph_mut`] 의 내부 재귀 본체.
504    pub(crate) fn walk_paragraphs_mut(&mut self, f: &mut dyn FnMut(&mut Paragraph)) {
505        for p in &mut self.paragraphs {
506            p.walk_paragraphs_mut(f);
507        }
508        for hf in self.headers.iter_mut().chain(self.footers.iter_mut()) {
509            for p in &mut hf.paragraphs {
510                p.walk_paragraphs_mut(f);
511            }
512        }
513        if let Some(master_pages) = &mut self.master_pages {
514            for mp in master_pages {
515                for p in &mut mp.paragraphs {
516                    p.walk_paragraphs_mut(f);
517                }
518            }
519        }
520    }
521
522    /// Creates an empty section with the given page settings.
523    ///
524    /// # Examples
525    ///
526    /// ```
527    /// use hwpforge_core::section::Section;
528    /// use hwpforge_core::PageSettings;
529    ///
530    /// let section = Section::new(PageSettings::a4());
531    /// assert!(section.is_empty());
532    /// ```
533    pub fn new(page_settings: PageSettings) -> Self {
534        Self {
535            paragraphs: Vec::new(),
536            page_settings,
537            headers: Vec::new(),
538            footers: Vec::new(),
539            page_number: None,
540            column_settings: None,
541            visibility: None,
542            line_number_shape: None,
543            page_border_fills: None,
544            master_pages: None,
545            begin_num: None,
546            text_direction: TextDirection::Horizontal,
547        }
548    }
549
550    /// Creates a section with pre-built paragraphs.
551    ///
552    /// # Examples
553    ///
554    /// ```
555    /// use hwpforge_core::section::Section;
556    /// use hwpforge_core::PageSettings;
557    /// use hwpforge_core::paragraph::Paragraph;
558    /// use hwpforge_foundation::ParaShapeIndex;
559    ///
560    /// let section = Section::with_paragraphs(
561    ///     vec![Paragraph::new(ParaShapeIndex::new(0))],
562    ///     PageSettings::letter(),
563    /// );
564    /// assert_eq!(section.paragraph_count(), 1);
565    /// ```
566    pub fn with_paragraphs(paragraphs: Vec<Paragraph>, page_settings: PageSettings) -> Self {
567        Self {
568            paragraphs,
569            page_settings,
570            headers: Vec::new(),
571            footers: Vec::new(),
572            page_number: None,
573            column_settings: None,
574            visibility: None,
575            line_number_shape: None,
576            page_border_fills: None,
577            master_pages: None,
578            begin_num: None,
579            text_direction: TextDirection::Horizontal,
580        }
581    }
582
583    /// Sets the text writing direction for this section and returns `self`.
584    ///
585    /// # Examples
586    ///
587    /// ```
588    /// use hwpforge_core::section::Section;
589    /// use hwpforge_core::PageSettings;
590    /// use hwpforge_foundation::TextDirection;
591    ///
592    /// let section = Section::new(PageSettings::a4())
593    ///     .with_text_direction(TextDirection::Vertical);
594    /// assert_eq!(section.text_direction, TextDirection::Vertical);
595    /// ```
596    pub fn with_text_direction(mut self, dir: TextDirection) -> Self {
597        self.text_direction = dir;
598        self
599    }
600
601    /// Appends a paragraph to this section.
602    pub fn add_paragraph(&mut self, paragraph: Paragraph) {
603        self.paragraphs.push(paragraph);
604    }
605
606    /// Returns the number of paragraphs.
607    pub fn paragraph_count(&self) -> usize {
608        self.paragraphs.len()
609    }
610
611    /// Returns `true` if this section has no paragraphs.
612    pub fn is_empty(&self) -> bool {
613        self.paragraphs.is_empty()
614    }
615
616    /// Counts tables, images, and charts in this section.
617    ///
618    /// Traverses all paragraph runs once and returns aggregate counts.
619    ///
620    /// # Examples
621    ///
622    /// ```
623    /// use hwpforge_core::section::{ContentCounts, Section};
624    /// use hwpforge_core::PageSettings;
625    ///
626    /// let section = Section::new(PageSettings::a4());
627    /// let counts = section.content_counts();
628    /// assert_eq!(counts.tables, 0);
629    /// assert_eq!(counts.images, 0);
630    /// assert_eq!(counts.charts, 0);
631    /// ```
632    pub fn content_counts(&self) -> ContentCounts {
633        let mut tables: usize = 0;
634        let mut images: usize = 0;
635        let mut charts: usize = 0;
636
637        for para in &self.paragraphs {
638            for run in &para.runs {
639                match &run.content {
640                    crate::RunContent::Table(_) => tables += 1,
641                    crate::RunContent::Image(_) => images += 1,
642                    crate::RunContent::Control(c) => {
643                        if matches!(**c, crate::control::Control::Chart { .. }) {
644                            charts += 1;
645                        }
646                    }
647                    _ => {}
648                }
649            }
650        }
651
652        ContentCounts { tables, images, charts }
653    }
654}
655
656/// Aggregate content counts for a section.
657#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
658pub struct ContentCounts {
659    /// Number of tables.
660    pub tables: usize,
661    /// Number of images.
662    pub images: usize,
663    /// Number of charts.
664    pub charts: usize,
665}
666
667impl std::fmt::Display for Section {
668    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
669        let n = self.paragraphs.len();
670        let word = if n == 1 { "paragraph" } else { "paragraphs" };
671        write!(f, "Section({n} {word})")
672    }
673}
674
675#[cfg(test)]
676mod tests {
677    use super::*;
678    use crate::run::Run;
679    use hwpforge_foundation::{
680        ApplyPageType, CharShapeIndex, NumberFormatType, PageNumberPosition, ParaShapeIndex,
681    };
682
683    fn simple_paragraph() -> Paragraph {
684        Paragraph::with_runs(
685            vec![Run::text("text", CharShapeIndex::new(0))],
686            ParaShapeIndex::new(0),
687        )
688    }
689
690    #[test]
691    fn new_is_empty() {
692        let section = Section::new(PageSettings::a4());
693        assert!(section.is_empty());
694        assert_eq!(section.paragraph_count(), 0);
695    }
696
697    #[test]
698    fn with_paragraphs() {
699        let section = Section::with_paragraphs(
700            vec![simple_paragraph(), simple_paragraph()],
701            PageSettings::a4(),
702        );
703        assert_eq!(section.paragraph_count(), 2);
704        assert!(!section.is_empty());
705    }
706
707    #[test]
708    fn add_paragraph() {
709        let mut section = Section::new(PageSettings::a4());
710        section.add_paragraph(simple_paragraph());
711        section.add_paragraph(simple_paragraph());
712        assert_eq!(section.paragraph_count(), 2);
713    }
714
715    #[test]
716    fn page_settings_preserved() {
717        let section = Section::new(PageSettings::letter());
718        assert_eq!(section.page_settings, PageSettings::letter());
719    }
720
721    #[test]
722    fn display_singular() {
723        let section = Section::with_paragraphs(vec![simple_paragraph()], PageSettings::a4());
724        assert_eq!(section.to_string(), "Section(1 paragraph)");
725    }
726
727    #[test]
728    fn display_plural() {
729        let section = Section::with_paragraphs(
730            vec![simple_paragraph(), simple_paragraph()],
731            PageSettings::a4(),
732        );
733        assert_eq!(section.to_string(), "Section(2 paragraphs)");
734    }
735
736    #[test]
737    fn equality() {
738        let a = Section::with_paragraphs(vec![simple_paragraph()], PageSettings::a4());
739        let b = Section::with_paragraphs(vec![simple_paragraph()], PageSettings::a4());
740        assert_eq!(a, b);
741    }
742
743    #[test]
744    fn inequality_different_page_settings() {
745        let a = Section::new(PageSettings::a4());
746        let b = Section::new(PageSettings::letter());
747        assert_ne!(a, b);
748    }
749
750    #[test]
751    fn clone_independence() {
752        let section = Section::with_paragraphs(vec![simple_paragraph()], PageSettings::a4());
753        let mut cloned = section.clone();
754        cloned.add_paragraph(simple_paragraph());
755        assert_eq!(section.paragraph_count(), 1);
756        assert_eq!(cloned.paragraph_count(), 2);
757    }
758
759    #[test]
760    fn serde_roundtrip() {
761        let section = Section::with_paragraphs(vec![simple_paragraph()], PageSettings::a4());
762        let json = serde_json::to_string(&section).unwrap();
763        let back: Section = serde_json::from_str(&json).unwrap();
764        assert_eq!(section, back);
765    }
766
767    #[test]
768    fn serde_empty_section() {
769        let section = Section::new(PageSettings::a4());
770        let json = serde_json::to_string(&section).unwrap();
771        let back: Section = serde_json::from_str(&json).unwrap();
772        assert_eq!(section, back);
773    }
774
775    #[test]
776    fn serde_letter_page() {
777        let section = Section::new(PageSettings::letter());
778        let json = serde_json::to_string(&section).unwrap();
779        let back: Section = serde_json::from_str(&json).unwrap();
780        assert_eq!(section, back);
781    }
782
783    // -----------------------------------------------------------------------
784    // HeaderFooter tests
785    // -----------------------------------------------------------------------
786
787    #[test]
788    fn header_footer_new() {
789        let hf =
790            HeaderFooter::new(vec![Paragraph::new(ParaShapeIndex::new(0))], ApplyPageType::Both);
791        assert_eq!(hf.paragraphs.len(), 1);
792        assert_eq!(hf.apply_page_type, ApplyPageType::Both);
793    }
794
795    #[test]
796    fn header_footer_even_odd() {
797        let even = HeaderFooter::new(vec![], ApplyPageType::Even);
798        let odd = HeaderFooter::new(vec![], ApplyPageType::Odd);
799        assert_eq!(even.apply_page_type, ApplyPageType::Even);
800        assert_eq!(odd.apply_page_type, ApplyPageType::Odd);
801        assert_ne!(even, odd);
802    }
803
804    #[test]
805    fn header_footer_display() {
806        let hf =
807            HeaderFooter::new(vec![Paragraph::new(ParaShapeIndex::new(0))], ApplyPageType::Both);
808        let s = hf.to_string();
809        assert!(s.contains("1 paragraph"), "display: {s}");
810        assert!(s.contains("Both"), "display: {s}");
811    }
812
813    #[test]
814    fn header_footer_serde_roundtrip() {
815        let hf = HeaderFooter::new(
816            vec![Paragraph::with_runs(
817                vec![Run::text("Header text", CharShapeIndex::new(0))],
818                ParaShapeIndex::new(0),
819            )],
820            ApplyPageType::Both,
821        );
822        let json = serde_json::to_string(&hf).unwrap();
823        let back: HeaderFooter = serde_json::from_str(&json).unwrap();
824        assert_eq!(hf, back);
825    }
826
827    #[test]
828    fn header_footer_clone_independence() {
829        let hf =
830            HeaderFooter::new(vec![Paragraph::new(ParaShapeIndex::new(0))], ApplyPageType::Both);
831        let mut cloned = hf.clone();
832        cloned.paragraphs.push(Paragraph::new(ParaShapeIndex::new(1)));
833        assert_eq!(hf.paragraphs.len(), 1);
834        assert_eq!(cloned.paragraphs.len(), 2);
835    }
836
837    // -----------------------------------------------------------------------
838    // PageNumber tests
839    // -----------------------------------------------------------------------
840
841    #[test]
842    fn page_number_new() {
843        let pn = PageNumber::new(PageNumberPosition::BottomCenter, NumberFormatType::Digit);
844        assert_eq!(pn.position, PageNumberPosition::BottomCenter);
845        assert_eq!(pn.number_format, NumberFormatType::Digit);
846        assert!(pn.decoration.is_empty());
847    }
848
849    #[test]
850    fn page_number_with_decoration() {
851        let pn = PageNumber::with_decoration(
852            PageNumberPosition::BottomCenter,
853            NumberFormatType::RomanCapital,
854            "- ",
855        );
856        assert_eq!(pn.decoration, "- ");
857        assert_eq!(pn.number_format, NumberFormatType::RomanCapital);
858    }
859
860    #[test]
861    #[allow(deprecated)]
862    fn page_number_with_side_char_deprecated() {
863        let pn = PageNumber::with_side_char(
864            PageNumberPosition::BottomCenter,
865            NumberFormatType::Digit,
866            "- ",
867        );
868        assert_eq!(pn.decoration, "- ");
869    }
870
871    #[test]
872    fn page_number_display() {
873        let pn = PageNumber::new(PageNumberPosition::TopCenter, NumberFormatType::Digit);
874        let s = pn.to_string();
875        assert!(s.contains("TopCenter"), "display: {s}");
876        assert!(s.contains("Digit"), "display: {s}");
877    }
878
879    #[test]
880    fn page_number_serde_roundtrip() {
881        let pn = PageNumber::with_decoration(
882            PageNumberPosition::BottomCenter,
883            NumberFormatType::CircledDigit,
884            "< ",
885        );
886        let json = serde_json::to_string(&pn).unwrap();
887        let back: PageNumber = serde_json::from_str(&json).unwrap();
888        assert_eq!(pn, back);
889    }
890
891    #[test]
892    fn page_number_equality() {
893        let a = PageNumber::new(PageNumberPosition::BottomCenter, NumberFormatType::Digit);
894        let b = PageNumber::new(PageNumberPosition::BottomCenter, NumberFormatType::Digit);
895        assert_eq!(a, b);
896    }
897
898    #[test]
899    fn page_number_inequality() {
900        let a = PageNumber::new(PageNumberPosition::BottomCenter, NumberFormatType::Digit);
901        let b = PageNumber::new(PageNumberPosition::TopCenter, NumberFormatType::Digit);
902        assert_ne!(a, b);
903    }
904
905    // -----------------------------------------------------------------------
906    // Section with header/footer/page_number
907    // -----------------------------------------------------------------------
908
909    #[test]
910    fn section_new_has_empty_header_footer_vecs() {
911        let section = Section::new(PageSettings::a4());
912        assert!(section.headers.is_empty());
913        assert!(section.footers.is_empty());
914        assert!(section.page_number.is_none());
915        assert!(section.column_settings.is_none());
916    }
917
918    #[test]
919    fn section_with_header_footer() {
920        let mut section = Section::new(PageSettings::a4());
921        section.headers.push(HeaderFooter::new(
922            vec![Paragraph::with_runs(
923                vec![Run::text("Header", CharShapeIndex::new(0))],
924                ParaShapeIndex::new(0),
925            )],
926            ApplyPageType::Both,
927        ));
928        section.footers.push(HeaderFooter::new(
929            vec![Paragraph::with_runs(
930                vec![Run::text("Footer", CharShapeIndex::new(0))],
931                ParaShapeIndex::new(0),
932            )],
933            ApplyPageType::Both,
934        ));
935        assert_eq!(section.headers.len(), 1);
936        assert_eq!(section.footers.len(), 1);
937    }
938
939    #[test]
940    fn section_with_page_number() {
941        let mut section = Section::new(PageSettings::a4());
942        section.page_number =
943            Some(PageNumber::new(PageNumberPosition::BottomCenter, NumberFormatType::Digit));
944        assert!(section.page_number.is_some());
945    }
946
947    #[test]
948    fn section_serde_with_optional_fields() {
949        let mut section = Section::new(PageSettings::a4());
950        section.headers.push(HeaderFooter::new(vec![], ApplyPageType::Both));
951        section.page_number =
952            Some(PageNumber::new(PageNumberPosition::BottomCenter, NumberFormatType::Digit));
953        let json = serde_json::to_string(&section).unwrap();
954        let back: Section = serde_json::from_str(&json).unwrap();
955        assert_eq!(section, back);
956    }
957
958    #[test]
959    fn section_serde_none_fields_skipped() {
960        let section = Section::new(PageSettings::a4());
961        let json = serde_json::to_string(&section).unwrap();
962        // Section-level header/footer/page_number/column_settings should not appear
963        // (PageSettings has header_margin/footer_margin, which is different)
964        assert!(!json.contains("\"header\""));
965        assert!(!json.contains("\"footer\""));
966        assert!(!json.contains("\"page_number\""));
967        assert!(!json.contains("\"column_settings\""));
968        let back: Section = serde_json::from_str(&json).unwrap();
969        assert_eq!(section, back);
970    }
971
972    // -----------------------------------------------------------------------
973    // HeaderFooter::all_pages tests
974    // -----------------------------------------------------------------------
975
976    #[test]
977    fn header_footer_all_pages_apply_page_type() {
978        let hf = HeaderFooter::all_pages(vec![Paragraph::new(ParaShapeIndex::new(0))]);
979        assert_eq!(hf.apply_page_type, ApplyPageType::Both);
980    }
981
982    #[test]
983    fn header_footer_all_pages_preserves_paragraphs() {
984        let paras = vec![simple_paragraph(), simple_paragraph()];
985        let hf = HeaderFooter::all_pages(paras);
986        assert_eq!(hf.paragraphs.len(), 2);
987    }
988
989    #[test]
990    fn header_footer_all_pages_empty_paragraphs() {
991        let hf = HeaderFooter::all_pages(vec![]);
992        assert_eq!(hf.apply_page_type, ApplyPageType::Both);
993        assert!(hf.paragraphs.is_empty());
994    }
995
996    #[test]
997    #[allow(deprecated)]
998    fn header_footer_both_deprecated_alias() {
999        let hf = HeaderFooter::both(vec![Paragraph::new(ParaShapeIndex::new(0))]);
1000        assert_eq!(hf.apply_page_type, ApplyPageType::Both);
1001    }
1002
1003    // -----------------------------------------------------------------------
1004    // PageNumber::bottom_center tests
1005    // -----------------------------------------------------------------------
1006
1007    #[test]
1008    fn page_number_bottom_center_position() {
1009        let pn = PageNumber::bottom_center();
1010        assert_eq!(pn.position, PageNumberPosition::BottomCenter);
1011    }
1012
1013    #[test]
1014    fn page_number_bottom_center_format() {
1015        let pn = PageNumber::bottom_center();
1016        assert_eq!(pn.number_format, NumberFormatType::Digit);
1017    }
1018
1019    #[test]
1020    fn page_number_bottom_center_no_decoration() {
1021        let pn = PageNumber::bottom_center();
1022        assert!(pn.decoration.is_empty());
1023    }
1024
1025    #[test]
1026    fn page_number_bottom_center_equals_explicit() {
1027        let shortcut = PageNumber::bottom_center();
1028        let explicit = PageNumber::new(PageNumberPosition::BottomCenter, NumberFormatType::Digit);
1029        assert_eq!(shortcut, explicit);
1030    }
1031
1032    #[test]
1033    fn section_backward_compat_deserialize() {
1034        // JSON without header/footer/page_number fields (pre-4.5 format)
1035        let a4 = PageSettings::a4();
1036        let json = serde_json::to_string(&Section::with_paragraphs(vec![], a4)).unwrap();
1037        let section: Section = serde_json::from_str(&json).unwrap();
1038        assert!(section.headers.is_empty());
1039        assert!(section.footers.is_empty());
1040        assert!(section.page_number.is_none());
1041    }
1042
1043    #[test]
1044    fn all_pages_equals_new_with_both() {
1045        let paras = vec![simple_paragraph()];
1046        let from_all_pages = HeaderFooter::all_pages(paras.clone());
1047        let from_new = HeaderFooter::new(paras, ApplyPageType::Both);
1048        assert_eq!(from_all_pages, from_new);
1049    }
1050}