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/// Which page parity a section starts on.
180///
181/// Maps to HWPX `<hp:startNum pageStartsOn="BOTH|EVEN|ODD">`. 홀짝 강제
182/// 시작은 다중 섹션 쪽번호 연속성 계산의 입력이다 (W5-α C2 — 스키마에
183/// 있던 값을 디코더가 폐기하던 것을 승격).
184#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
185pub enum PageStartsOn {
186    /// 아무 쪽성에서나 시작한다 (기본).
187    #[default]
188    Both,
189    /// 짝수 쪽에서 시작한다.
190    Even,
191    /// 홀수 쪽에서 시작한다.
192    Odd,
193}
194
195/// Starting numbers for various auto-numbering sequences.
196///
197/// Maps to `<hh:beginNum>` in header.xml and per-section
198/// `<hp:startNum>` in section XML.
199#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
200pub struct BeginNum {
201    /// Starting page number.
202    ///
203    /// 섹션 `<hp:startNum>` 의미론 (OWPML §10.6.2): **`0` = 이전 구역에서
204    /// 계속**, `n > 0` = 이 구역을 `n` 번부터 재시작. ("기본값 1" 이
205    /// 아니다 — serde 기본 1 은 header.xml `<hh:beginNum>` 쪽 관례.)
206    #[serde(default = "BeginNum::one")]
207    pub page: u32,
208    /// Starting footnote number (default: 1).
209    #[serde(default = "BeginNum::one")]
210    pub footnote: u32,
211    /// Starting endnote number (default: 1).
212    #[serde(default = "BeginNum::one")]
213    pub endnote: u32,
214    /// Starting picture number (default: 1).
215    #[serde(default = "BeginNum::one")]
216    pub pic: u32,
217    /// Starting table number (default: 1).
218    #[serde(default = "BeginNum::one")]
219    pub tbl: u32,
220    /// Starting equation number (default: 1).
221    #[serde(default = "BeginNum::one")]
222    pub equation: u32,
223    /// Which page parity this section starts on (`pageStartsOn`).
224    #[serde(default, skip_serializing_if = "PageStartsOn::is_default")]
225    pub page_starts_on: PageStartsOn,
226}
227
228impl PageStartsOn {
229    /// serde `skip_serializing_if` 용 기본값 판정.
230    fn is_default(&self) -> bool {
231        *self == Self::Both
232    }
233}
234
235impl BeginNum {
236    fn one() -> u32 {
237        1
238    }
239}
240
241impl Default for BeginNum {
242    fn default() -> Self {
243        Self {
244            page: 1,
245            footnote: 1,
246            endnote: 1,
247            pic: 1,
248            tbl: 1,
249            equation: 1,
250            page_starts_on: PageStartsOn::Both,
251        }
252    }
253}
254
255// ---------------------------------------------------------------------------
256// MasterPage
257// ---------------------------------------------------------------------------
258
259/// A master page (background/watermark page) for a section.
260///
261/// Master pages provide background content rendered behind the main body.
262/// Maps to `<masterPage>` elements inside `<hp:secPr>`.
263///
264/// In HWPX, each master page has an `applyPageType` attribute
265/// (`BOTH`, `EVEN`, or `ODD`) and contains its own paragraphs.
266#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
267pub struct MasterPage {
268    /// Which pages this master page applies to.
269    pub apply_page_type: ApplyPageType,
270    /// Paragraphs composing the master page content.
271    pub paragraphs: Vec<Paragraph>,
272}
273
274impl MasterPage {
275    /// Creates a new master page with the given page type and paragraphs.
276    pub fn new(apply_page_type: ApplyPageType, paragraphs: Vec<Paragraph>) -> Self {
277        Self { apply_page_type, paragraphs }
278    }
279}
280
281impl std::fmt::Display for MasterPage {
282    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
283        let n = self.paragraphs.len();
284        let word = if n == 1 { "paragraph" } else { "paragraphs" };
285        write!(f, "MasterPage({n} {word}, {:?})", self.apply_page_type)
286    }
287}
288
289// ---------------------------------------------------------------------------
290// HeaderFooter
291// ---------------------------------------------------------------------------
292
293/// A header or footer region containing paragraphs.
294///
295/// In HWPX, headers and footers appear as `<hp:header>` / `<hp:footer>`
296/// elements inside `<hp:ctrl>` in the section body. Each contains its own
297/// paragraphs and an [`ApplyPageType`] controlling which pages it applies to.
298///
299/// # Examples
300///
301/// ```
302/// use hwpforge_core::section::HeaderFooter;
303/// use hwpforge_core::paragraph::Paragraph;
304/// use hwpforge_foundation::{ApplyPageType, ParaShapeIndex};
305///
306/// let hf = HeaderFooter::new(
307///     vec![Paragraph::new(ParaShapeIndex::new(0))],
308///     ApplyPageType::Both,
309/// );
310/// assert_eq!(hf.paragraphs.len(), 1);
311/// assert_eq!(hf.apply_page_type, ApplyPageType::Both);
312/// ```
313#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
314pub struct HeaderFooter {
315    /// Paragraphs composing the header/footer content.
316    pub paragraphs: Vec<Paragraph>,
317    /// Which pages this header/footer applies to.
318    pub apply_page_type: ApplyPageType,
319    /// 컨테이너(subList) 세로 정렬 (W5-α H1 — 일반 꼬리말 실측 = BOTTOM).
320    ///
321    /// 밴드 안에서 문단 블록을 어디에 앉히는지 결정한다 — 렌더 재생의
322    /// 필수 입력. HWPX `<hp:subList vertAlign>`.
323    #[serde(default)]
324    pub vert_align: hwpforge_foundation::VerticalAlign,
325    /// 컨테이너 텍스트 폭 (HWPX `<hp:subList textWidth>`, 0 = 위임).
326    #[serde(default, skip_serializing_if = "HwpUnit::is_zero")]
327    pub text_width: HwpUnit,
328    /// 컨테이너 텍스트 높이 (HWPX `<hp:subList textHeight>`, 0 = 위임).
329    ///
330    /// 밴드(margin.header/footer)와 다를 수 있다 — 초과 시 동작은 실측
331    /// 전이므로 렌더는 fail-closed 로 다룬다 (W5-a).
332    #[serde(default, skip_serializing_if = "HwpUnit::is_zero")]
333    pub text_height: HwpUnit,
334}
335
336impl HeaderFooter {
337    /// Creates a new header/footer with the given paragraphs and page scope.
338    ///
339    /// 컨테이너 기하는 기본값(Top/0/0 = 위임)으로 시작한다 — wire 승격은
340    /// 디코더가 채운다.
341    pub fn new(paragraphs: Vec<Paragraph>, apply_page_type: ApplyPageType) -> Self {
342        Self {
343            paragraphs,
344            apply_page_type,
345            vert_align: hwpforge_foundation::VerticalAlign::default(),
346            text_width: HwpUnit::ZERO,
347            text_height: HwpUnit::ZERO,
348        }
349    }
350
351    /// Creates a header/footer applied to **all** pages (both odd and even).
352    ///
353    /// This is the most common case for simple documents that use a single
354    /// header or footer on every page.
355    ///
356    /// # Examples
357    ///
358    /// ```
359    /// use hwpforge_core::section::HeaderFooter;
360    /// use hwpforge_core::paragraph::Paragraph;
361    /// use hwpforge_foundation::{ApplyPageType, ParaShapeIndex};
362    ///
363    /// let hf = HeaderFooter::all_pages(vec![Paragraph::new(ParaShapeIndex::new(0))]);
364    /// assert_eq!(hf.apply_page_type, ApplyPageType::Both);
365    /// assert_eq!(hf.paragraphs.len(), 1);
366    /// ```
367    pub fn all_pages(paragraphs: Vec<Paragraph>) -> Self {
368        Self::new(paragraphs, ApplyPageType::Both)
369    }
370
371    /// Creates a header/footer applied to all pages.
372    #[deprecated(since = "0.2.0", note = "Use `all_pages()` instead")]
373    pub fn both(paragraphs: Vec<Paragraph>) -> Self {
374        Self::all_pages(paragraphs)
375    }
376}
377
378impl std::fmt::Display for HeaderFooter {
379    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
380        let n = self.paragraphs.len();
381        let word = if n == 1 { "paragraph" } else { "paragraphs" };
382        write!(f, "HeaderFooter({n} {word}, {:?})", self.apply_page_type)
383    }
384}
385
386// ---------------------------------------------------------------------------
387// PageNumber
388// ---------------------------------------------------------------------------
389
390/// Page number display settings for a section.
391///
392/// In HWPX, page numbers appear as `<hp:pageNum>` inside `<hp:ctrl>`.
393/// This struct controls position, format, and optional decoration characters.
394///
395/// # Examples
396///
397/// ```
398/// use hwpforge_core::section::PageNumber;
399/// use hwpforge_foundation::{NumberFormatType, PageNumberPosition};
400///
401/// let pn = PageNumber::new(
402///     PageNumberPosition::BottomCenter,
403///     NumberFormatType::Digit,
404/// );
405/// assert_eq!(pn.position, PageNumberPosition::BottomCenter);
406/// ```
407#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
408pub struct PageNumber {
409    /// Where to display the page number.
410    pub position: PageNumberPosition,
411    /// Numbering format (digits, roman, etc.).
412    pub number_format: NumberFormatType,
413    /// Optional decoration string placed around the number
414    /// (e.g. `"- "` for `"- 1 -"`). Empty means no decoration.
415    pub decoration: String,
416}
417
418impl PageNumber {
419    /// Creates a new page number with no decoration.
420    pub fn new(position: PageNumberPosition, number_format: NumberFormatType) -> Self {
421        Self { position, number_format, decoration: String::new() }
422    }
423
424    /// Creates a page number at the bottom-center in plain digit format.
425    ///
426    /// This is the most common page number layout for Korean documents.
427    /// Equivalent to `PageNumber::new(PageNumberPosition::BottomCenter, NumberFormatType::Digit)`
428    /// with an empty `decoration`.
429    ///
430    /// # Examples
431    ///
432    /// ```
433    /// use hwpforge_core::section::PageNumber;
434    /// use hwpforge_foundation::{NumberFormatType, PageNumberPosition};
435    ///
436    /// let pn = PageNumber::bottom_center();
437    /// assert_eq!(pn.position, PageNumberPosition::BottomCenter);
438    /// assert_eq!(pn.number_format, NumberFormatType::Digit);
439    /// assert!(pn.decoration.is_empty());
440    /// ```
441    pub fn bottom_center() -> Self {
442        Self {
443            position: PageNumberPosition::BottomCenter,
444            number_format: NumberFormatType::Digit,
445            decoration: String::new(),
446        }
447    }
448
449    /// Creates a new page number with decoration characters placed around the number.
450    ///
451    /// # Examples
452    ///
453    /// ```
454    /// use hwpforge_core::section::PageNumber;
455    /// use hwpforge_foundation::{NumberFormatType, PageNumberPosition};
456    ///
457    /// let pn = PageNumber::with_decoration(
458    ///     PageNumberPosition::BottomCenter,
459    ///     NumberFormatType::Digit,
460    ///     "- ",
461    /// );
462    /// assert_eq!(pn.decoration, "- ");
463    /// ```
464    pub fn with_decoration(
465        position: PageNumberPosition,
466        number_format: NumberFormatType,
467        decoration: impl Into<String>,
468    ) -> Self {
469        Self { position, number_format, decoration: decoration.into() }
470    }
471
472    /// Creates a new page number with side decoration characters.
473    #[deprecated(since = "0.2.0", note = "Use `with_decoration()` instead")]
474    pub fn with_side_char(
475        position: PageNumberPosition,
476        number_format: NumberFormatType,
477        side_char: impl Into<String>,
478    ) -> Self {
479        Self::with_decoration(position, number_format, side_char)
480    }
481}
482
483impl std::fmt::Display for PageNumber {
484    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
485        write!(f, "PageNumber({:?}, {:?})", self.position, self.number_format)
486    }
487}
488
489// ---------------------------------------------------------------------------
490// Section
491// ---------------------------------------------------------------------------
492
493/// A document section: paragraphs + page geometry.
494///
495/// # Examples
496///
497/// ```
498/// use hwpforge_core::section::Section;
499/// use hwpforge_core::PageSettings;
500/// use hwpforge_core::paragraph::Paragraph;
501/// use hwpforge_foundation::ParaShapeIndex;
502///
503/// let section = Section::with_paragraphs(
504///     vec![Paragraph::new(ParaShapeIndex::new(0))],
505///     PageSettings::a4(),
506/// );
507/// assert_eq!(section.paragraph_count(), 1);
508/// ```
509#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
510pub struct Section {
511    /// Ordered paragraphs in this section.
512    pub paragraphs: Vec<Paragraph>,
513    /// Page dimensions and margins for this section.
514    pub page_settings: PageSettings,
515    /// Headers for this section, ordered as in HWPX wire (`<hp:header>` × N).
516    ///
517    /// HWPX allows multiple `<hp:header>` elements in a single section,
518    /// differentiated by `applyPageType` (`BOTH` / `ODD` / `EVEN`). HWP5
519    /// stores the same information as multiple `head` ctrl records.
520    /// Empty `Vec` = "no header on this section". See
521    /// [ADR-002](../../../.docs/architecture/adr/ADR-002-section-multi-header-footer-cardinality.md)
522    /// for the cardinality decision.
523    #[serde(default, skip_serializing_if = "Vec::is_empty")]
524    pub headers: Vec<HeaderFooter>,
525    /// Footers for this section, ordered as in HWPX wire (`<hp:footer>` × N).
526    /// See `headers` for the cardinality rationale.
527    #[serde(default, skip_serializing_if = "Vec::is_empty")]
528    pub footers: Vec<HeaderFooter>,
529    /// Optional page number settings for this section.
530    #[serde(default, skip_serializing_if = "Option::is_none")]
531    pub page_number: Option<PageNumber>,
532    /// Multi-column layout. `None` = single column (default).
533    #[serde(default, skip_serializing_if = "Option::is_none")]
534    pub column_settings: Option<ColumnSettings>,
535    /// Visibility flags for headers, footers, borders, etc.
536    /// `None` = default visibility (show everything).
537    #[serde(default, skip_serializing_if = "Option::is_none")]
538    pub visibility: Option<Visibility>,
539    /// Line numbering settings. `None` = no line numbers.
540    #[serde(default, skip_serializing_if = "Option::is_none")]
541    pub line_number_shape: Option<LineNumberShape>,
542    /// Page border/fill entries. `None` = default 3 entries (BOTH/EVEN/ODD with borderFillIDRef=1).
543    #[serde(default, skip_serializing_if = "Option::is_none")]
544    pub page_border_fills: Option<Vec<PageBorderFillEntry>>,
545    /// Master pages (background content rendered behind the body).
546    /// `None` = no master pages (default).
547    #[serde(default, skip_serializing_if = "Option::is_none")]
548    pub master_pages: Option<Vec<MasterPage>>,
549    /// Starting numbers for auto-numbering sequences.
550    /// `None` = default values (all start at 1).
551    #[serde(default, skip_serializing_if = "Option::is_none")]
552    pub begin_num: Option<BeginNum>,
553    /// Text writing direction for this section.
554    /// Defaults to [`TextDirection::Horizontal`] (가로쓰기).
555    #[serde(default)]
556    pub text_direction: TextDirection,
557}
558
559impl Section {
560    /// 이 섹션의 모든 문단(본문·머리말·꼬리말·바탕쪽 + 각 문단의 중첩)을
561    /// 문서 순서로 방문한다.
562    pub fn for_each_paragraph_mut<F: FnMut(&mut Paragraph)>(&mut self, mut f: F) {
563        self.walk_paragraphs_mut(&mut f);
564    }
565
566    /// [`Self::for_each_paragraph_mut`] 의 내부 재귀 본체.
567    pub(crate) fn walk_paragraphs_mut(&mut self, f: &mut dyn FnMut(&mut Paragraph)) {
568        for p in &mut self.paragraphs {
569            p.walk_paragraphs_mut(f);
570        }
571        for hf in self.headers.iter_mut().chain(self.footers.iter_mut()) {
572            for p in &mut hf.paragraphs {
573                p.walk_paragraphs_mut(f);
574            }
575        }
576        if let Some(master_pages) = &mut self.master_pages {
577            for mp in master_pages {
578                for p in &mut mp.paragraphs {
579                    p.walk_paragraphs_mut(f);
580                }
581            }
582        }
583    }
584
585    /// Creates an empty section with the given page settings.
586    ///
587    /// # Examples
588    ///
589    /// ```
590    /// use hwpforge_core::section::Section;
591    /// use hwpforge_core::PageSettings;
592    ///
593    /// let section = Section::new(PageSettings::a4());
594    /// assert!(section.is_empty());
595    /// ```
596    pub fn new(page_settings: PageSettings) -> Self {
597        Self {
598            paragraphs: Vec::new(),
599            page_settings,
600            headers: Vec::new(),
601            footers: Vec::new(),
602            page_number: None,
603            column_settings: None,
604            visibility: None,
605            line_number_shape: None,
606            page_border_fills: None,
607            master_pages: None,
608            begin_num: None,
609            text_direction: TextDirection::Horizontal,
610        }
611    }
612
613    /// Creates a section with pre-built paragraphs.
614    ///
615    /// # Examples
616    ///
617    /// ```
618    /// use hwpforge_core::section::Section;
619    /// use hwpforge_core::PageSettings;
620    /// use hwpforge_core::paragraph::Paragraph;
621    /// use hwpforge_foundation::ParaShapeIndex;
622    ///
623    /// let section = Section::with_paragraphs(
624    ///     vec![Paragraph::new(ParaShapeIndex::new(0))],
625    ///     PageSettings::letter(),
626    /// );
627    /// assert_eq!(section.paragraph_count(), 1);
628    /// ```
629    pub fn with_paragraphs(paragraphs: Vec<Paragraph>, page_settings: PageSettings) -> Self {
630        Self {
631            paragraphs,
632            page_settings,
633            headers: Vec::new(),
634            footers: Vec::new(),
635            page_number: None,
636            column_settings: None,
637            visibility: None,
638            line_number_shape: None,
639            page_border_fills: None,
640            master_pages: None,
641            begin_num: None,
642            text_direction: TextDirection::Horizontal,
643        }
644    }
645
646    /// Sets the text writing direction for this section and returns `self`.
647    ///
648    /// # Examples
649    ///
650    /// ```
651    /// use hwpforge_core::section::Section;
652    /// use hwpforge_core::PageSettings;
653    /// use hwpforge_foundation::TextDirection;
654    ///
655    /// let section = Section::new(PageSettings::a4())
656    ///     .with_text_direction(TextDirection::Vertical);
657    /// assert_eq!(section.text_direction, TextDirection::Vertical);
658    /// ```
659    pub fn with_text_direction(mut self, dir: TextDirection) -> Self {
660        self.text_direction = dir;
661        self
662    }
663
664    /// Appends a paragraph to this section.
665    pub fn add_paragraph(&mut self, paragraph: Paragraph) {
666        self.paragraphs.push(paragraph);
667    }
668
669    /// Returns the number of paragraphs.
670    pub fn paragraph_count(&self) -> usize {
671        self.paragraphs.len()
672    }
673
674    /// Returns `true` if this section has no paragraphs.
675    pub fn is_empty(&self) -> bool {
676        self.paragraphs.is_empty()
677    }
678
679    /// Counts tables, images, and charts in this section.
680    ///
681    /// Traverses all paragraph runs once and returns aggregate counts.
682    ///
683    /// # Examples
684    ///
685    /// ```
686    /// use hwpforge_core::section::{ContentCounts, Section};
687    /// use hwpforge_core::PageSettings;
688    ///
689    /// let section = Section::new(PageSettings::a4());
690    /// let counts = section.content_counts();
691    /// assert_eq!(counts.tables, 0);
692    /// assert_eq!(counts.images, 0);
693    /// assert_eq!(counts.charts, 0);
694    /// ```
695    pub fn content_counts(&self) -> ContentCounts {
696        let mut tables: usize = 0;
697        let mut images: usize = 0;
698        let mut charts: usize = 0;
699
700        for para in &self.paragraphs {
701            for run in &para.runs {
702                match &run.content {
703                    crate::RunContent::Table(_) => tables += 1,
704                    crate::RunContent::Image(_) => images += 1,
705                    crate::RunContent::Control(c) => {
706                        if matches!(**c, crate::control::Control::Chart { .. }) {
707                            charts += 1;
708                        }
709                    }
710                    _ => {}
711                }
712            }
713        }
714
715        ContentCounts { tables, images, charts }
716    }
717}
718
719/// Aggregate content counts for a section.
720#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
721pub struct ContentCounts {
722    /// Number of tables.
723    pub tables: usize,
724    /// Number of images.
725    pub images: usize,
726    /// Number of charts.
727    pub charts: usize,
728}
729
730impl std::fmt::Display for Section {
731    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
732        let n = self.paragraphs.len();
733        let word = if n == 1 { "paragraph" } else { "paragraphs" };
734        write!(f, "Section({n} {word})")
735    }
736}
737
738#[cfg(test)]
739mod tests {
740    use super::*;
741    use crate::run::Run;
742    use hwpforge_foundation::{
743        ApplyPageType, CharShapeIndex, NumberFormatType, PageNumberPosition, ParaShapeIndex,
744    };
745
746    fn simple_paragraph() -> Paragraph {
747        Paragraph::with_runs(
748            vec![Run::text("text", CharShapeIndex::new(0))],
749            ParaShapeIndex::new(0),
750        )
751    }
752
753    #[test]
754    fn new_is_empty() {
755        let section = Section::new(PageSettings::a4());
756        assert!(section.is_empty());
757        assert_eq!(section.paragraph_count(), 0);
758    }
759
760    #[test]
761    fn with_paragraphs() {
762        let section = Section::with_paragraphs(
763            vec![simple_paragraph(), simple_paragraph()],
764            PageSettings::a4(),
765        );
766        assert_eq!(section.paragraph_count(), 2);
767        assert!(!section.is_empty());
768    }
769
770    #[test]
771    fn add_paragraph() {
772        let mut section = Section::new(PageSettings::a4());
773        section.add_paragraph(simple_paragraph());
774        section.add_paragraph(simple_paragraph());
775        assert_eq!(section.paragraph_count(), 2);
776    }
777
778    #[test]
779    fn page_settings_preserved() {
780        let section = Section::new(PageSettings::letter());
781        assert_eq!(section.page_settings, PageSettings::letter());
782    }
783
784    #[test]
785    fn display_singular() {
786        let section = Section::with_paragraphs(vec![simple_paragraph()], PageSettings::a4());
787        assert_eq!(section.to_string(), "Section(1 paragraph)");
788    }
789
790    #[test]
791    fn display_plural() {
792        let section = Section::with_paragraphs(
793            vec![simple_paragraph(), simple_paragraph()],
794            PageSettings::a4(),
795        );
796        assert_eq!(section.to_string(), "Section(2 paragraphs)");
797    }
798
799    #[test]
800    fn equality() {
801        let a = Section::with_paragraphs(vec![simple_paragraph()], PageSettings::a4());
802        let b = Section::with_paragraphs(vec![simple_paragraph()], PageSettings::a4());
803        assert_eq!(a, b);
804    }
805
806    #[test]
807    fn inequality_different_page_settings() {
808        let a = Section::new(PageSettings::a4());
809        let b = Section::new(PageSettings::letter());
810        assert_ne!(a, b);
811    }
812
813    #[test]
814    fn clone_independence() {
815        let section = Section::with_paragraphs(vec![simple_paragraph()], PageSettings::a4());
816        let mut cloned = section.clone();
817        cloned.add_paragraph(simple_paragraph());
818        assert_eq!(section.paragraph_count(), 1);
819        assert_eq!(cloned.paragraph_count(), 2);
820    }
821
822    #[test]
823    fn serde_roundtrip() {
824        let section = Section::with_paragraphs(vec![simple_paragraph()], PageSettings::a4());
825        let json = serde_json::to_string(&section).unwrap();
826        let back: Section = serde_json::from_str(&json).unwrap();
827        assert_eq!(section, back);
828    }
829
830    #[test]
831    fn serde_empty_section() {
832        let section = Section::new(PageSettings::a4());
833        let json = serde_json::to_string(&section).unwrap();
834        let back: Section = serde_json::from_str(&json).unwrap();
835        assert_eq!(section, back);
836    }
837
838    #[test]
839    fn serde_letter_page() {
840        let section = Section::new(PageSettings::letter());
841        let json = serde_json::to_string(&section).unwrap();
842        let back: Section = serde_json::from_str(&json).unwrap();
843        assert_eq!(section, back);
844    }
845
846    // -----------------------------------------------------------------------
847    // HeaderFooter tests
848    // -----------------------------------------------------------------------
849
850    #[test]
851    fn header_footer_new() {
852        let hf =
853            HeaderFooter::new(vec![Paragraph::new(ParaShapeIndex::new(0))], ApplyPageType::Both);
854        assert_eq!(hf.paragraphs.len(), 1);
855        assert_eq!(hf.apply_page_type, ApplyPageType::Both);
856    }
857
858    #[test]
859    fn header_footer_even_odd() {
860        let even = HeaderFooter::new(vec![], ApplyPageType::Even);
861        let odd = HeaderFooter::new(vec![], ApplyPageType::Odd);
862        assert_eq!(even.apply_page_type, ApplyPageType::Even);
863        assert_eq!(odd.apply_page_type, ApplyPageType::Odd);
864        assert_ne!(even, odd);
865    }
866
867    #[test]
868    fn header_footer_display() {
869        let hf =
870            HeaderFooter::new(vec![Paragraph::new(ParaShapeIndex::new(0))], ApplyPageType::Both);
871        let s = hf.to_string();
872        assert!(s.contains("1 paragraph"), "display: {s}");
873        assert!(s.contains("Both"), "display: {s}");
874    }
875
876    #[test]
877    fn header_footer_serde_roundtrip() {
878        let hf = HeaderFooter::new(
879            vec![Paragraph::with_runs(
880                vec![Run::text("Header text", CharShapeIndex::new(0))],
881                ParaShapeIndex::new(0),
882            )],
883            ApplyPageType::Both,
884        );
885        let json = serde_json::to_string(&hf).unwrap();
886        let back: HeaderFooter = serde_json::from_str(&json).unwrap();
887        assert_eq!(hf, back);
888    }
889
890    #[test]
891    fn header_footer_clone_independence() {
892        let hf =
893            HeaderFooter::new(vec![Paragraph::new(ParaShapeIndex::new(0))], ApplyPageType::Both);
894        let mut cloned = hf.clone();
895        cloned.paragraphs.push(Paragraph::new(ParaShapeIndex::new(1)));
896        assert_eq!(hf.paragraphs.len(), 1);
897        assert_eq!(cloned.paragraphs.len(), 2);
898    }
899
900    // -----------------------------------------------------------------------
901    // PageNumber tests
902    // -----------------------------------------------------------------------
903
904    #[test]
905    fn page_number_new() {
906        let pn = PageNumber::new(PageNumberPosition::BottomCenter, NumberFormatType::Digit);
907        assert_eq!(pn.position, PageNumberPosition::BottomCenter);
908        assert_eq!(pn.number_format, NumberFormatType::Digit);
909        assert!(pn.decoration.is_empty());
910    }
911
912    #[test]
913    fn page_number_with_decoration() {
914        let pn = PageNumber::with_decoration(
915            PageNumberPosition::BottomCenter,
916            NumberFormatType::RomanCapital,
917            "- ",
918        );
919        assert_eq!(pn.decoration, "- ");
920        assert_eq!(pn.number_format, NumberFormatType::RomanCapital);
921    }
922
923    #[test]
924    #[allow(deprecated)]
925    fn page_number_with_side_char_deprecated() {
926        let pn = PageNumber::with_side_char(
927            PageNumberPosition::BottomCenter,
928            NumberFormatType::Digit,
929            "- ",
930        );
931        assert_eq!(pn.decoration, "- ");
932    }
933
934    #[test]
935    fn page_number_display() {
936        let pn = PageNumber::new(PageNumberPosition::TopCenter, NumberFormatType::Digit);
937        let s = pn.to_string();
938        assert!(s.contains("TopCenter"), "display: {s}");
939        assert!(s.contains("Digit"), "display: {s}");
940    }
941
942    #[test]
943    fn page_number_serde_roundtrip() {
944        let pn = PageNumber::with_decoration(
945            PageNumberPosition::BottomCenter,
946            NumberFormatType::CircledDigit,
947            "< ",
948        );
949        let json = serde_json::to_string(&pn).unwrap();
950        let back: PageNumber = serde_json::from_str(&json).unwrap();
951        assert_eq!(pn, back);
952    }
953
954    #[test]
955    fn page_number_equality() {
956        let a = PageNumber::new(PageNumberPosition::BottomCenter, NumberFormatType::Digit);
957        let b = PageNumber::new(PageNumberPosition::BottomCenter, NumberFormatType::Digit);
958        assert_eq!(a, b);
959    }
960
961    #[test]
962    fn page_number_inequality() {
963        let a = PageNumber::new(PageNumberPosition::BottomCenter, NumberFormatType::Digit);
964        let b = PageNumber::new(PageNumberPosition::TopCenter, NumberFormatType::Digit);
965        assert_ne!(a, b);
966    }
967
968    // -----------------------------------------------------------------------
969    // Section with header/footer/page_number
970    // -----------------------------------------------------------------------
971
972    #[test]
973    fn section_new_has_empty_header_footer_vecs() {
974        let section = Section::new(PageSettings::a4());
975        assert!(section.headers.is_empty());
976        assert!(section.footers.is_empty());
977        assert!(section.page_number.is_none());
978        assert!(section.column_settings.is_none());
979    }
980
981    #[test]
982    fn section_with_header_footer() {
983        let mut section = Section::new(PageSettings::a4());
984        section.headers.push(HeaderFooter::new(
985            vec![Paragraph::with_runs(
986                vec![Run::text("Header", CharShapeIndex::new(0))],
987                ParaShapeIndex::new(0),
988            )],
989            ApplyPageType::Both,
990        ));
991        section.footers.push(HeaderFooter::new(
992            vec![Paragraph::with_runs(
993                vec![Run::text("Footer", CharShapeIndex::new(0))],
994                ParaShapeIndex::new(0),
995            )],
996            ApplyPageType::Both,
997        ));
998        assert_eq!(section.headers.len(), 1);
999        assert_eq!(section.footers.len(), 1);
1000    }
1001
1002    #[test]
1003    fn section_with_page_number() {
1004        let mut section = Section::new(PageSettings::a4());
1005        section.page_number =
1006            Some(PageNumber::new(PageNumberPosition::BottomCenter, NumberFormatType::Digit));
1007        assert!(section.page_number.is_some());
1008    }
1009
1010    #[test]
1011    fn section_serde_with_optional_fields() {
1012        let mut section = Section::new(PageSettings::a4());
1013        section.headers.push(HeaderFooter::new(vec![], ApplyPageType::Both));
1014        section.page_number =
1015            Some(PageNumber::new(PageNumberPosition::BottomCenter, NumberFormatType::Digit));
1016        let json = serde_json::to_string(&section).unwrap();
1017        let back: Section = serde_json::from_str(&json).unwrap();
1018        assert_eq!(section, back);
1019    }
1020
1021    #[test]
1022    fn section_serde_none_fields_skipped() {
1023        let section = Section::new(PageSettings::a4());
1024        let json = serde_json::to_string(&section).unwrap();
1025        // Section-level header/footer/page_number/column_settings should not appear
1026        // (PageSettings has header_margin/footer_margin, which is different)
1027        assert!(!json.contains("\"header\""));
1028        assert!(!json.contains("\"footer\""));
1029        assert!(!json.contains("\"page_number\""));
1030        assert!(!json.contains("\"column_settings\""));
1031        let back: Section = serde_json::from_str(&json).unwrap();
1032        assert_eq!(section, back);
1033    }
1034
1035    // -----------------------------------------------------------------------
1036    // HeaderFooter::all_pages tests
1037    // -----------------------------------------------------------------------
1038
1039    #[test]
1040    fn header_footer_all_pages_apply_page_type() {
1041        let hf = HeaderFooter::all_pages(vec![Paragraph::new(ParaShapeIndex::new(0))]);
1042        assert_eq!(hf.apply_page_type, ApplyPageType::Both);
1043    }
1044
1045    #[test]
1046    fn header_footer_all_pages_preserves_paragraphs() {
1047        let paras = vec![simple_paragraph(), simple_paragraph()];
1048        let hf = HeaderFooter::all_pages(paras);
1049        assert_eq!(hf.paragraphs.len(), 2);
1050    }
1051
1052    #[test]
1053    fn header_footer_all_pages_empty_paragraphs() {
1054        let hf = HeaderFooter::all_pages(vec![]);
1055        assert_eq!(hf.apply_page_type, ApplyPageType::Both);
1056        assert!(hf.paragraphs.is_empty());
1057    }
1058
1059    #[test]
1060    #[allow(deprecated)]
1061    fn header_footer_both_deprecated_alias() {
1062        let hf = HeaderFooter::both(vec![Paragraph::new(ParaShapeIndex::new(0))]);
1063        assert_eq!(hf.apply_page_type, ApplyPageType::Both);
1064    }
1065
1066    // -----------------------------------------------------------------------
1067    // PageNumber::bottom_center tests
1068    // -----------------------------------------------------------------------
1069
1070    #[test]
1071    fn page_number_bottom_center_position() {
1072        let pn = PageNumber::bottom_center();
1073        assert_eq!(pn.position, PageNumberPosition::BottomCenter);
1074    }
1075
1076    #[test]
1077    fn page_number_bottom_center_format() {
1078        let pn = PageNumber::bottom_center();
1079        assert_eq!(pn.number_format, NumberFormatType::Digit);
1080    }
1081
1082    #[test]
1083    fn page_number_bottom_center_no_decoration() {
1084        let pn = PageNumber::bottom_center();
1085        assert!(pn.decoration.is_empty());
1086    }
1087
1088    #[test]
1089    fn page_number_bottom_center_equals_explicit() {
1090        let shortcut = PageNumber::bottom_center();
1091        let explicit = PageNumber::new(PageNumberPosition::BottomCenter, NumberFormatType::Digit);
1092        assert_eq!(shortcut, explicit);
1093    }
1094
1095    #[test]
1096    fn section_backward_compat_deserialize() {
1097        // JSON without header/footer/page_number fields (pre-4.5 format)
1098        let a4 = PageSettings::a4();
1099        let json = serde_json::to_string(&Section::with_paragraphs(vec![], a4)).unwrap();
1100        let section: Section = serde_json::from_str(&json).unwrap();
1101        assert!(section.headers.is_empty());
1102        assert!(section.footers.is_empty());
1103        assert!(section.page_number.is_none());
1104    }
1105
1106    #[test]
1107    fn all_pages_equals_new_with_both() {
1108        let paras = vec![simple_paragraph()];
1109        let from_all_pages = HeaderFooter::all_pages(paras.clone());
1110        let from_new = HeaderFooter::new(paras, ApplyPageType::Both);
1111        assert_eq!(from_all_pages, from_new);
1112    }
1113}