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    /// 바탕쪽, 각 문단은 자신을 먼저 방문한 뒤 run 안으로 내려간다.
562    ///
563    /// 정확한 재귀 대상(표 셀·표/도형 캡션·각주/미주·메모·묶음 자식)과
564    /// **이미지 캡션 문단을 방문하지 않는 알려진 갭**은
565    /// [`crate::document::Document::for_each_paragraph_mut`] 문서에 있다.
566    pub fn for_each_paragraph_mut<F: FnMut(&mut Paragraph)>(&mut self, mut f: F) {
567        self.walk_paragraphs_mut(&mut f);
568    }
569
570    /// [`Self::for_each_paragraph_mut`] 의 내부 재귀 본체.
571    pub(crate) fn walk_paragraphs_mut(&mut self, f: &mut dyn FnMut(&mut Paragraph)) {
572        for p in &mut self.paragraphs {
573            p.walk_paragraphs_mut(f);
574        }
575        for hf in self.headers.iter_mut().chain(self.footers.iter_mut()) {
576            for p in &mut hf.paragraphs {
577                p.walk_paragraphs_mut(f);
578            }
579        }
580        if let Some(master_pages) = &mut self.master_pages {
581            for mp in master_pages {
582                for p in &mut mp.paragraphs {
583                    p.walk_paragraphs_mut(f);
584                }
585            }
586        }
587    }
588
589    /// [`Self::for_each_paragraph_mut`] 의 불변 쌍둥이 — 방문 순서와 재귀
590    /// 대상이 동일하며, 이미지 캡션 갭도 동일하게 적용된다.
591    pub fn for_each_paragraph<F: FnMut(&Paragraph)>(&self, mut f: F) {
592        self.walk_paragraphs(&mut f);
593    }
594
595    /// [`Self::for_each_paragraph`] 의 내부 재귀 본체.
596    ///
597    /// [`Self::walk_paragraphs_mut`] 와 순서·재귀 대상이 같아야 한다
598    /// (본문 → 머리말 → 꼬리말 → 바탕쪽).
599    pub(crate) fn walk_paragraphs(&self, f: &mut dyn FnMut(&Paragraph)) {
600        for p in &self.paragraphs {
601            p.walk_paragraphs(f);
602        }
603        for hf in self.headers.iter().chain(self.footers.iter()) {
604            for p in &hf.paragraphs {
605                p.walk_paragraphs(f);
606            }
607        }
608        if let Some(master_pages) = &self.master_pages {
609            for mp in master_pages {
610                for p in &mp.paragraphs {
611                    p.walk_paragraphs(f);
612                }
613            }
614        }
615    }
616
617    /// Creates an empty section with the given page settings.
618    ///
619    /// # Examples
620    ///
621    /// ```
622    /// use hwpforge_core::section::Section;
623    /// use hwpforge_core::PageSettings;
624    ///
625    /// let section = Section::new(PageSettings::a4());
626    /// assert!(section.is_empty());
627    /// ```
628    pub fn new(page_settings: PageSettings) -> Self {
629        Self {
630            paragraphs: Vec::new(),
631            page_settings,
632            headers: Vec::new(),
633            footers: Vec::new(),
634            page_number: None,
635            column_settings: None,
636            visibility: None,
637            line_number_shape: None,
638            page_border_fills: None,
639            master_pages: None,
640            begin_num: None,
641            text_direction: TextDirection::Horizontal,
642        }
643    }
644
645    /// Creates a section with pre-built paragraphs.
646    ///
647    /// # Examples
648    ///
649    /// ```
650    /// use hwpforge_core::section::Section;
651    /// use hwpforge_core::PageSettings;
652    /// use hwpforge_core::paragraph::Paragraph;
653    /// use hwpforge_foundation::ParaShapeIndex;
654    ///
655    /// let section = Section::with_paragraphs(
656    ///     vec![Paragraph::new(ParaShapeIndex::new(0))],
657    ///     PageSettings::letter(),
658    /// );
659    /// assert_eq!(section.paragraph_count(), 1);
660    /// ```
661    pub fn with_paragraphs(paragraphs: Vec<Paragraph>, page_settings: PageSettings) -> Self {
662        Self {
663            paragraphs,
664            page_settings,
665            headers: Vec::new(),
666            footers: Vec::new(),
667            page_number: None,
668            column_settings: None,
669            visibility: None,
670            line_number_shape: None,
671            page_border_fills: None,
672            master_pages: None,
673            begin_num: None,
674            text_direction: TextDirection::Horizontal,
675        }
676    }
677
678    /// Sets the text writing direction for this section and returns `self`.
679    ///
680    /// # Examples
681    ///
682    /// ```
683    /// use hwpforge_core::section::Section;
684    /// use hwpforge_core::PageSettings;
685    /// use hwpforge_foundation::TextDirection;
686    ///
687    /// let section = Section::new(PageSettings::a4())
688    ///     .with_text_direction(TextDirection::Vertical);
689    /// assert_eq!(section.text_direction, TextDirection::Vertical);
690    /// ```
691    pub fn with_text_direction(mut self, dir: TextDirection) -> Self {
692        self.text_direction = dir;
693        self
694    }
695
696    /// Appends a paragraph to this section.
697    pub fn add_paragraph(&mut self, paragraph: Paragraph) {
698        self.paragraphs.push(paragraph);
699    }
700
701    /// Returns the number of paragraphs.
702    pub fn paragraph_count(&self) -> usize {
703        self.paragraphs.len()
704    }
705
706    /// Returns `true` if this section has no paragraphs.
707    pub fn is_empty(&self) -> bool {
708        self.paragraphs.is_empty()
709    }
710
711    /// Counts tables, images, and charts in this section.
712    ///
713    /// Traverses all paragraph runs once and returns aggregate counts.
714    ///
715    /// # Examples
716    ///
717    /// ```
718    /// use hwpforge_core::section::{ContentCounts, Section};
719    /// use hwpforge_core::PageSettings;
720    ///
721    /// let section = Section::new(PageSettings::a4());
722    /// let counts = section.content_counts();
723    /// assert_eq!(counts.tables, 0);
724    /// assert_eq!(counts.images, 0);
725    /// assert_eq!(counts.charts, 0);
726    /// ```
727    pub fn content_counts(&self) -> ContentCounts {
728        let mut tables: usize = 0;
729        let mut images: usize = 0;
730        let mut charts: usize = 0;
731
732        for para in &self.paragraphs {
733            for run in &para.runs {
734                match &run.content {
735                    crate::RunContent::Table(_) => tables += 1,
736                    crate::RunContent::Image(_) => images += 1,
737                    crate::RunContent::Control(c) => {
738                        if matches!(**c, crate::control::Control::Chart { .. }) {
739                            charts += 1;
740                        }
741                    }
742                    _ => {}
743                }
744            }
745        }
746
747        ContentCounts { tables, images, charts }
748    }
749}
750
751/// Aggregate content counts for a section.
752#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
753pub struct ContentCounts {
754    /// Number of tables.
755    pub tables: usize,
756    /// Number of images.
757    pub images: usize,
758    /// Number of charts.
759    pub charts: usize,
760}
761
762impl std::fmt::Display for Section {
763    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
764        let n = self.paragraphs.len();
765        let word = if n == 1 { "paragraph" } else { "paragraphs" };
766        write!(f, "Section({n} {word})")
767    }
768}
769
770#[cfg(test)]
771mod tests {
772    use super::*;
773    use crate::run::Run;
774    use hwpforge_foundation::{
775        ApplyPageType, CharShapeIndex, NumberFormatType, PageNumberPosition, ParaShapeIndex,
776    };
777
778    fn simple_paragraph() -> Paragraph {
779        Paragraph::with_runs(
780            vec![Run::text("text", CharShapeIndex::new(0))],
781            ParaShapeIndex::new(0),
782        )
783    }
784
785    #[test]
786    fn new_is_empty() {
787        let section = Section::new(PageSettings::a4());
788        assert!(section.is_empty());
789        assert_eq!(section.paragraph_count(), 0);
790    }
791
792    #[test]
793    fn with_paragraphs() {
794        let section = Section::with_paragraphs(
795            vec![simple_paragraph(), simple_paragraph()],
796            PageSettings::a4(),
797        );
798        assert_eq!(section.paragraph_count(), 2);
799        assert!(!section.is_empty());
800    }
801
802    #[test]
803    fn add_paragraph() {
804        let mut section = Section::new(PageSettings::a4());
805        section.add_paragraph(simple_paragraph());
806        section.add_paragraph(simple_paragraph());
807        assert_eq!(section.paragraph_count(), 2);
808    }
809
810    #[test]
811    fn page_settings_preserved() {
812        let section = Section::new(PageSettings::letter());
813        assert_eq!(section.page_settings, PageSettings::letter());
814    }
815
816    #[test]
817    fn display_singular() {
818        let section = Section::with_paragraphs(vec![simple_paragraph()], PageSettings::a4());
819        assert_eq!(section.to_string(), "Section(1 paragraph)");
820    }
821
822    #[test]
823    fn display_plural() {
824        let section = Section::with_paragraphs(
825            vec![simple_paragraph(), simple_paragraph()],
826            PageSettings::a4(),
827        );
828        assert_eq!(section.to_string(), "Section(2 paragraphs)");
829    }
830
831    #[test]
832    fn equality() {
833        let a = Section::with_paragraphs(vec![simple_paragraph()], PageSettings::a4());
834        let b = Section::with_paragraphs(vec![simple_paragraph()], PageSettings::a4());
835        assert_eq!(a, b);
836    }
837
838    #[test]
839    fn inequality_different_page_settings() {
840        let a = Section::new(PageSettings::a4());
841        let b = Section::new(PageSettings::letter());
842        assert_ne!(a, b);
843    }
844
845    #[test]
846    fn clone_independence() {
847        let section = Section::with_paragraphs(vec![simple_paragraph()], PageSettings::a4());
848        let mut cloned = section.clone();
849        cloned.add_paragraph(simple_paragraph());
850        assert_eq!(section.paragraph_count(), 1);
851        assert_eq!(cloned.paragraph_count(), 2);
852    }
853
854    #[test]
855    fn serde_roundtrip() {
856        let section = Section::with_paragraphs(vec![simple_paragraph()], PageSettings::a4());
857        let json = serde_json::to_string(&section).unwrap();
858        let back: Section = serde_json::from_str(&json).unwrap();
859        assert_eq!(section, back);
860    }
861
862    #[test]
863    fn serde_empty_section() {
864        let section = Section::new(PageSettings::a4());
865        let json = serde_json::to_string(&section).unwrap();
866        let back: Section = serde_json::from_str(&json).unwrap();
867        assert_eq!(section, back);
868    }
869
870    #[test]
871    fn serde_letter_page() {
872        let section = Section::new(PageSettings::letter());
873        let json = serde_json::to_string(&section).unwrap();
874        let back: Section = serde_json::from_str(&json).unwrap();
875        assert_eq!(section, back);
876    }
877
878    // -----------------------------------------------------------------------
879    // HeaderFooter tests
880    // -----------------------------------------------------------------------
881
882    #[test]
883    fn header_footer_new() {
884        let hf =
885            HeaderFooter::new(vec![Paragraph::new(ParaShapeIndex::new(0))], ApplyPageType::Both);
886        assert_eq!(hf.paragraphs.len(), 1);
887        assert_eq!(hf.apply_page_type, ApplyPageType::Both);
888    }
889
890    #[test]
891    fn header_footer_even_odd() {
892        let even = HeaderFooter::new(vec![], ApplyPageType::Even);
893        let odd = HeaderFooter::new(vec![], ApplyPageType::Odd);
894        assert_eq!(even.apply_page_type, ApplyPageType::Even);
895        assert_eq!(odd.apply_page_type, ApplyPageType::Odd);
896        assert_ne!(even, odd);
897    }
898
899    #[test]
900    fn header_footer_display() {
901        let hf =
902            HeaderFooter::new(vec![Paragraph::new(ParaShapeIndex::new(0))], ApplyPageType::Both);
903        let s = hf.to_string();
904        assert!(s.contains("1 paragraph"), "display: {s}");
905        assert!(s.contains("Both"), "display: {s}");
906    }
907
908    #[test]
909    fn header_footer_serde_roundtrip() {
910        let hf = HeaderFooter::new(
911            vec![Paragraph::with_runs(
912                vec![Run::text("Header text", CharShapeIndex::new(0))],
913                ParaShapeIndex::new(0),
914            )],
915            ApplyPageType::Both,
916        );
917        let json = serde_json::to_string(&hf).unwrap();
918        let back: HeaderFooter = serde_json::from_str(&json).unwrap();
919        assert_eq!(hf, back);
920    }
921
922    #[test]
923    fn header_footer_clone_independence() {
924        let hf =
925            HeaderFooter::new(vec![Paragraph::new(ParaShapeIndex::new(0))], ApplyPageType::Both);
926        let mut cloned = hf.clone();
927        cloned.paragraphs.push(Paragraph::new(ParaShapeIndex::new(1)));
928        assert_eq!(hf.paragraphs.len(), 1);
929        assert_eq!(cloned.paragraphs.len(), 2);
930    }
931
932    // -----------------------------------------------------------------------
933    // PageNumber tests
934    // -----------------------------------------------------------------------
935
936    #[test]
937    fn page_number_new() {
938        let pn = PageNumber::new(PageNumberPosition::BottomCenter, NumberFormatType::Digit);
939        assert_eq!(pn.position, PageNumberPosition::BottomCenter);
940        assert_eq!(pn.number_format, NumberFormatType::Digit);
941        assert!(pn.decoration.is_empty());
942    }
943
944    #[test]
945    fn page_number_with_decoration() {
946        let pn = PageNumber::with_decoration(
947            PageNumberPosition::BottomCenter,
948            NumberFormatType::RomanCapital,
949            "- ",
950        );
951        assert_eq!(pn.decoration, "- ");
952        assert_eq!(pn.number_format, NumberFormatType::RomanCapital);
953    }
954
955    #[test]
956    #[allow(deprecated)]
957    fn page_number_with_side_char_deprecated() {
958        let pn = PageNumber::with_side_char(
959            PageNumberPosition::BottomCenter,
960            NumberFormatType::Digit,
961            "- ",
962        );
963        assert_eq!(pn.decoration, "- ");
964    }
965
966    #[test]
967    fn page_number_display() {
968        let pn = PageNumber::new(PageNumberPosition::TopCenter, NumberFormatType::Digit);
969        let s = pn.to_string();
970        assert!(s.contains("TopCenter"), "display: {s}");
971        assert!(s.contains("Digit"), "display: {s}");
972    }
973
974    #[test]
975    fn page_number_serde_roundtrip() {
976        let pn = PageNumber::with_decoration(
977            PageNumberPosition::BottomCenter,
978            NumberFormatType::CircledDigit,
979            "< ",
980        );
981        let json = serde_json::to_string(&pn).unwrap();
982        let back: PageNumber = serde_json::from_str(&json).unwrap();
983        assert_eq!(pn, back);
984    }
985
986    #[test]
987    fn page_number_equality() {
988        let a = PageNumber::new(PageNumberPosition::BottomCenter, NumberFormatType::Digit);
989        let b = PageNumber::new(PageNumberPosition::BottomCenter, NumberFormatType::Digit);
990        assert_eq!(a, b);
991    }
992
993    #[test]
994    fn page_number_inequality() {
995        let a = PageNumber::new(PageNumberPosition::BottomCenter, NumberFormatType::Digit);
996        let b = PageNumber::new(PageNumberPosition::TopCenter, NumberFormatType::Digit);
997        assert_ne!(a, b);
998    }
999
1000    // -----------------------------------------------------------------------
1001    // Section with header/footer/page_number
1002    // -----------------------------------------------------------------------
1003
1004    #[test]
1005    fn section_new_has_empty_header_footer_vecs() {
1006        let section = Section::new(PageSettings::a4());
1007        assert!(section.headers.is_empty());
1008        assert!(section.footers.is_empty());
1009        assert!(section.page_number.is_none());
1010        assert!(section.column_settings.is_none());
1011    }
1012
1013    #[test]
1014    fn section_with_header_footer() {
1015        let mut section = Section::new(PageSettings::a4());
1016        section.headers.push(HeaderFooter::new(
1017            vec![Paragraph::with_runs(
1018                vec![Run::text("Header", CharShapeIndex::new(0))],
1019                ParaShapeIndex::new(0),
1020            )],
1021            ApplyPageType::Both,
1022        ));
1023        section.footers.push(HeaderFooter::new(
1024            vec![Paragraph::with_runs(
1025                vec![Run::text("Footer", CharShapeIndex::new(0))],
1026                ParaShapeIndex::new(0),
1027            )],
1028            ApplyPageType::Both,
1029        ));
1030        assert_eq!(section.headers.len(), 1);
1031        assert_eq!(section.footers.len(), 1);
1032    }
1033
1034    #[test]
1035    fn section_with_page_number() {
1036        let mut section = Section::new(PageSettings::a4());
1037        section.page_number =
1038            Some(PageNumber::new(PageNumberPosition::BottomCenter, NumberFormatType::Digit));
1039        assert!(section.page_number.is_some());
1040    }
1041
1042    #[test]
1043    fn section_serde_with_optional_fields() {
1044        let mut section = Section::new(PageSettings::a4());
1045        section.headers.push(HeaderFooter::new(vec![], ApplyPageType::Both));
1046        section.page_number =
1047            Some(PageNumber::new(PageNumberPosition::BottomCenter, NumberFormatType::Digit));
1048        let json = serde_json::to_string(&section).unwrap();
1049        let back: Section = serde_json::from_str(&json).unwrap();
1050        assert_eq!(section, back);
1051    }
1052
1053    #[test]
1054    fn section_serde_none_fields_skipped() {
1055        let section = Section::new(PageSettings::a4());
1056        let json = serde_json::to_string(&section).unwrap();
1057        // Section-level header/footer/page_number/column_settings should not appear
1058        // (PageSettings has header_margin/footer_margin, which is different)
1059        assert!(!json.contains("\"header\""));
1060        assert!(!json.contains("\"footer\""));
1061        assert!(!json.contains("\"page_number\""));
1062        assert!(!json.contains("\"column_settings\""));
1063        let back: Section = serde_json::from_str(&json).unwrap();
1064        assert_eq!(section, back);
1065    }
1066
1067    // -----------------------------------------------------------------------
1068    // HeaderFooter::all_pages tests
1069    // -----------------------------------------------------------------------
1070
1071    #[test]
1072    fn header_footer_all_pages_apply_page_type() {
1073        let hf = HeaderFooter::all_pages(vec![Paragraph::new(ParaShapeIndex::new(0))]);
1074        assert_eq!(hf.apply_page_type, ApplyPageType::Both);
1075    }
1076
1077    #[test]
1078    fn header_footer_all_pages_preserves_paragraphs() {
1079        let paras = vec![simple_paragraph(), simple_paragraph()];
1080        let hf = HeaderFooter::all_pages(paras);
1081        assert_eq!(hf.paragraphs.len(), 2);
1082    }
1083
1084    #[test]
1085    fn header_footer_all_pages_empty_paragraphs() {
1086        let hf = HeaderFooter::all_pages(vec![]);
1087        assert_eq!(hf.apply_page_type, ApplyPageType::Both);
1088        assert!(hf.paragraphs.is_empty());
1089    }
1090
1091    #[test]
1092    #[allow(deprecated)]
1093    fn header_footer_both_deprecated_alias() {
1094        let hf = HeaderFooter::both(vec![Paragraph::new(ParaShapeIndex::new(0))]);
1095        assert_eq!(hf.apply_page_type, ApplyPageType::Both);
1096    }
1097
1098    // -----------------------------------------------------------------------
1099    // PageNumber::bottom_center tests
1100    // -----------------------------------------------------------------------
1101
1102    #[test]
1103    fn page_number_bottom_center_position() {
1104        let pn = PageNumber::bottom_center();
1105        assert_eq!(pn.position, PageNumberPosition::BottomCenter);
1106    }
1107
1108    #[test]
1109    fn page_number_bottom_center_format() {
1110        let pn = PageNumber::bottom_center();
1111        assert_eq!(pn.number_format, NumberFormatType::Digit);
1112    }
1113
1114    #[test]
1115    fn page_number_bottom_center_no_decoration() {
1116        let pn = PageNumber::bottom_center();
1117        assert!(pn.decoration.is_empty());
1118    }
1119
1120    #[test]
1121    fn page_number_bottom_center_equals_explicit() {
1122        let shortcut = PageNumber::bottom_center();
1123        let explicit = PageNumber::new(PageNumberPosition::BottomCenter, NumberFormatType::Digit);
1124        assert_eq!(shortcut, explicit);
1125    }
1126
1127    #[test]
1128    fn section_backward_compat_deserialize() {
1129        // JSON without header/footer/page_number fields (pre-4.5 format)
1130        let a4 = PageSettings::a4();
1131        let json = serde_json::to_string(&Section::with_paragraphs(vec![], a4)).unwrap();
1132        let section: Section = serde_json::from_str(&json).unwrap();
1133        assert!(section.headers.is_empty());
1134        assert!(section.footers.is_empty());
1135        assert!(section.page_number.is_none());
1136    }
1137
1138    #[test]
1139    fn all_pages_equals_new_with_both() {
1140        let paras = vec![simple_paragraph()];
1141        let from_all_pages = HeaderFooter::all_pages(paras.clone());
1142        let from_new = HeaderFooter::new(paras, ApplyPageType::Both);
1143        assert_eq!(from_all_pages, from_new);
1144    }
1145}