Skip to main content

hwpforge_core/
paragraph.rs

1//! Paragraph: a sequence of runs with a paragraph shape reference.
2//!
3//! [`Paragraph`] aggregates [`Run`] objects and holds
4//! a [`ParaShapeIndex`] reference to the paragraph shape (alignment,
5//! spacing, indentation) defined in Blueprint.
6//!
7//! # Design Decisions
8//!
9//! - **`Vec<Run>`** not `SmallVec<[Run; 5]>` -- YAGNI. SmallVec would
10//!   bloat each Paragraph from ~40 bytes to ~220 bytes with no profiling
11//!   evidence that allocation is a bottleneck. Migration to SmallVec is
12//!   a non-breaking internal change if needed later.
13//!
14//! - **No `raw_xml` / `raw_binary`** -- raw preservation belongs in the
15//!   Smithy layer, not the format-agnostic domain model.
16//!
17//! # Examples
18//!
19//! ```
20//! use hwpforge_core::paragraph::Paragraph;
21//! use hwpforge_core::run::Run;
22//! use hwpforge_foundation::{CharShapeIndex, ParaShapeIndex};
23//!
24//! let mut para = Paragraph::new(ParaShapeIndex::new(0));
25//! para.add_run(Run::text("Hello ", CharShapeIndex::new(0)));
26//! para.add_run(Run::text("world!", CharShapeIndex::new(1)));
27//! assert_eq!(para.text_content(), "Hello world!");
28//! assert_eq!(para.run_count(), 2);
29//! ```
30
31use hwpforge_foundation::{ParaShapeIndex, StyleIndex};
32use schemars::JsonSchema;
33use serde::{Deserialize, Serialize};
34
35use crate::error::{CoreError, CoreResult};
36use crate::run::Run;
37
38/// A paragraph: an ordered sequence of runs sharing a paragraph shape.
39///
40/// # Examples
41///
42/// ```
43/// use hwpforge_core::paragraph::Paragraph;
44/// use hwpforge_core::run::Run;
45/// use hwpforge_foundation::{CharShapeIndex, ParaShapeIndex};
46///
47/// let para = Paragraph::with_runs(
48///     vec![Run::text("Hello", CharShapeIndex::new(0))],
49///     ParaShapeIndex::new(0),
50/// );
51/// assert_eq!(para.run_count(), 1);
52/// assert!(!para.is_empty());
53/// ```
54#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
55pub struct Paragraph {
56    /// Ordered sequence of runs.
57    pub runs: Vec<Run>,
58    /// Index into the paragraph shape collection (Blueprint resolves this).
59    pub para_shape_id: ParaShapeIndex,
60    /// Whether this paragraph starts a new column (HWPX `columnBreak="1"`).
61    #[serde(default)]
62    pub column_break: bool,
63    /// Whether this paragraph starts a new page (HWPX `pageBreak="1"`).
64    #[serde(default)]
65    pub page_break: bool,
66    /// Optional heading level (1-7) for TOC participation.
67    /// Maps to 개요 1-7 styles. Paragraphs with a heading level
68    /// will emit `<hp:titleMark>` in HWPX for auto-TOC support.
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub heading_level: Option<u8>,
71    /// Optional reference to a named style (e.g. 개요 1, 본문).
72    /// `None` means 바탕글 (style 0, the default).
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub style_id: Option<StyleIndex>,
75    /// Hancom 이 저장한 줄 조판 캐시 (decode-only 승격).
76    ///
77    /// HWPX `<hp:linesegarray>` / HWP5 `PARA_LINE_SEG` 에서 디코더가
78    /// 채운다. `None` = 캐시 없음 (우리 순수 생성물의 정상 상태).
79    /// 인코더는 기본적으로 이 필드를 방출하지 않는다 (opt-in 전용).
80    /// 문서 동등성 비교(admission/golden)는 이 필드를 정규화한 사본으로
81    /// 수행한다 — [`crate::document::Document::strip_layout_caches`] 참조.
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub layout_cache: Option<crate::layout::LayoutCache>,
84}
85
86impl Paragraph {
87    /// Creates an empty paragraph with the given shape reference.
88    ///
89    /// # Examples
90    ///
91    /// ```
92    /// use hwpforge_core::paragraph::Paragraph;
93    /// use hwpforge_foundation::ParaShapeIndex;
94    ///
95    /// let para = Paragraph::new(ParaShapeIndex::new(0));
96    /// assert!(para.is_empty());
97    /// ```
98    pub fn new(para_shape_id: ParaShapeIndex) -> Self {
99        Self {
100            runs: Vec::new(),
101            para_shape_id,
102            column_break: false,
103            page_break: false,
104            heading_level: None,
105            style_id: None,
106            layout_cache: None,
107        }
108    }
109
110    /// Creates a paragraph with pre-built runs.
111    ///
112    /// # Examples
113    ///
114    /// ```
115    /// use hwpforge_core::paragraph::Paragraph;
116    /// use hwpforge_core::run::Run;
117    /// use hwpforge_foundation::{CharShapeIndex, ParaShapeIndex};
118    ///
119    /// let para = Paragraph::with_runs(
120    ///     vec![Run::text("text", CharShapeIndex::new(0))],
121    ///     ParaShapeIndex::new(0),
122    /// );
123    /// assert_eq!(para.run_count(), 1);
124    /// ```
125    pub fn with_runs(runs: Vec<Run>, para_shape_id: ParaShapeIndex) -> Self {
126        Self {
127            runs,
128            para_shape_id,
129            column_break: false,
130            page_break: false,
131            heading_level: None,
132            style_id: None,
133            layout_cache: None,
134        }
135    }
136
137    /// 이 문단 자신과 안에 중첩된 문단을 문서 순서로 방문한다 — 자신을
138    /// 먼저 방문한 뒤 run 내용물로 재귀한다 (pre-order).
139    ///
140    /// 재귀 대상은 표 셀 문단(중첩 표 포함)과 표 캡션, 글상자/타원/다각형
141    /// 본문과 캡션, 선·사각형·호·곡선·연결선의 캡션, 각주/미주 본문, 메모
142    /// 본문과 앵커 run, 묶음 객체 자식이다.
143    ///
144    /// **[`crate::image::Image::caption`] 안의 문단은 방문하지 않는다** —
145    /// 알려진 갭이며 `.docs/followups.md` 에 기록돼 있다. 배경과 왜 별도
146    /// 슬라이스인지는
147    /// [`crate::document::Document::for_each_paragraph_mut`] 참조.
148    pub fn for_each_paragraph_mut<F: FnMut(&mut Paragraph)>(&mut self, mut f: F) {
149        self.walk_paragraphs_mut(&mut f);
150    }
151
152    /// [`Self::for_each_paragraph_mut`] 의 내부 재귀 본체 (dyn 으로 단형화 제한).
153    pub(crate) fn walk_paragraphs_mut(&mut self, f: &mut dyn FnMut(&mut Paragraph)) {
154        f(self);
155        for run in &mut self.runs {
156            run.walk_paragraphs_mut(f);
157        }
158    }
159
160    /// [`Self::for_each_paragraph_mut`] 의 불변 쌍둥이 — 방문 순서와 재귀
161    /// 대상이 동일하며, 이미지 캡션 갭도 동일하게 적용된다.
162    pub fn for_each_paragraph<F: FnMut(&Paragraph)>(&self, mut f: F) {
163        self.walk_paragraphs(&mut f);
164    }
165
166    /// [`Self::for_each_paragraph`] 의 내부 재귀 본체 (dyn 으로 단형화 제한).
167    ///
168    /// [`Self::walk_paragraphs_mut`] 와 같이 **자신을 먼저** 방문한 뒤 run
169    /// 내용물로 재귀한다 (pre-order).
170    pub(crate) fn walk_paragraphs(&self, f: &mut dyn FnMut(&Paragraph)) {
171        f(self);
172        for run in &self.runs {
173            run.walk_paragraphs(f);
174        }
175    }
176
177    /// Appends a run to this paragraph.
178    ///
179    /// # Examples
180    ///
181    /// ```
182    /// use hwpforge_core::paragraph::Paragraph;
183    /// use hwpforge_core::run::Run;
184    /// use hwpforge_foundation::{CharShapeIndex, ParaShapeIndex};
185    ///
186    /// let mut para = Paragraph::new(ParaShapeIndex::new(0));
187    /// para.add_run(Run::text("hello", CharShapeIndex::new(0)));
188    /// assert_eq!(para.run_count(), 1);
189    /// ```
190    pub fn add_run(&mut self, run: Run) {
191        self.runs.push(run);
192    }
193
194    /// Sets the heading level for TOC participation (1-7).
195    ///
196    /// Paragraphs with a heading level emit `<hp:titleMark>` in HWPX,
197    /// enabling 한글 to auto-build a Table of Contents from document headings.
198    ///
199    /// # Panics
200    ///
201    /// Panics if `level` is 0 or greater than 7.
202    ///
203    /// # Examples
204    ///
205    /// ```
206    /// use hwpforge_core::paragraph::Paragraph;
207    /// use hwpforge_foundation::ParaShapeIndex;
208    ///
209    /// let para = Paragraph::new(ParaShapeIndex::new(0))
210    ///     .with_heading_level(1);
211    /// assert_eq!(para.heading_level, Some(1));
212    /// ```
213    pub fn with_heading_level(mut self, level: u8) -> Self {
214        assert!((1..=7).contains(&level), "heading_level must be 1-7, got {level}");
215        self.heading_level = Some(level);
216        self
217    }
218
219    /// Sets the style ID for this paragraph.
220    ///
221    /// # Examples
222    ///
223    /// ```
224    /// use hwpforge_core::paragraph::Paragraph;
225    /// use hwpforge_foundation::{ParaShapeIndex, StyleIndex};
226    ///
227    /// let para = Paragraph::new(ParaShapeIndex::new(0))
228    ///     .with_style(StyleIndex::new(2));
229    /// assert_eq!(para.style_id, Some(StyleIndex::new(2)));
230    /// ```
231    pub fn with_style(mut self, style_id: StyleIndex) -> Self {
232        self.style_id = Some(style_id);
233        self
234    }
235
236    /// Marks this paragraph as starting a new page (HWPX `pageBreak="1"`).
237    ///
238    /// # Examples
239    ///
240    /// ```
241    /// use hwpforge_core::paragraph::Paragraph;
242    /// use hwpforge_foundation::ParaShapeIndex;
243    ///
244    /// let para = Paragraph::new(ParaShapeIndex::new(0)).with_page_break();
245    /// assert!(para.page_break);
246    /// ```
247    pub fn with_page_break(mut self) -> Self {
248        self.page_break = true;
249        self
250    }
251
252    /// Sets the heading level for TOC participation (1-7), returning an error
253    /// if the level is out of range.
254    ///
255    /// This is the fallible alternative to [`with_heading_level`](Self::with_heading_level),
256    /// suitable for user-supplied input where panicking is undesirable.
257    ///
258    /// # Errors
259    ///
260    /// Returns [`CoreError::InvalidStructure`] if `level` is 0 or greater than 7.
261    ///
262    /// # Examples
263    ///
264    /// ```
265    /// use hwpforge_core::paragraph::Paragraph;
266    /// use hwpforge_foundation::ParaShapeIndex;
267    ///
268    /// let para = Paragraph::new(ParaShapeIndex::new(0))
269    ///     .try_with_heading_level(3)
270    ///     .unwrap();
271    /// assert_eq!(para.heading_level, Some(3));
272    ///
273    /// let err = Paragraph::new(ParaShapeIndex::new(0))
274    ///     .try_with_heading_level(0);
275    /// assert!(err.is_err());
276    /// ```
277    pub fn try_with_heading_level(mut self, level: u8) -> CoreResult<Self> {
278        if !(1..=7).contains(&level) {
279            return Err(CoreError::InvalidStructure {
280                context: "Paragraph::try_with_heading_level".into(),
281                reason: format!("heading_level must be 1-7, got {level}"),
282            });
283        }
284        self.heading_level = Some(level);
285        Ok(self)
286    }
287
288    /// Concatenates all text runs into a single string.
289    ///
290    /// Non-text runs (Table, Image, Control) are silently skipped.
291    /// This is useful for full-text search and preview generation.
292    ///
293    /// # Examples
294    ///
295    /// ```
296    /// use hwpforge_core::paragraph::Paragraph;
297    /// use hwpforge_core::run::Run;
298    /// use hwpforge_core::table::Table;
299    /// use hwpforge_foundation::{CharShapeIndex, ParaShapeIndex};
300    ///
301    /// let para = Paragraph::with_runs(
302    ///     vec![
303    ///         Run::text("Hello ", CharShapeIndex::new(0)),
304    ///         Run::table(Table::new(vec![]), CharShapeIndex::new(0)),
305    ///         Run::text("world", CharShapeIndex::new(0)),
306    ///     ],
307    ///     ParaShapeIndex::new(0),
308    /// );
309    /// assert_eq!(para.text_content(), "Hello world");
310    /// ```
311    pub fn text_content(&self) -> String {
312        // Use the unified `plain_text` accessor so `RunContent::InlineText`
313        // (Wave 4 Phase 2 carry — attribute-rich inline tabs) is folded
314        // back into a tab-containing plain string the way callers expect.
315        self.runs.iter().filter_map(|r| r.content.plain_text()).fold(
316            String::new(),
317            |mut acc, cow| {
318                acc.push_str(&cow);
319                acc
320            },
321        )
322    }
323
324    /// Returns the number of runs.
325    pub fn run_count(&self) -> usize {
326        self.runs.len()
327    }
328
329    /// Returns `true` if this paragraph has no runs.
330    pub fn is_empty(&self) -> bool {
331        self.runs.is_empty()
332    }
333}
334
335impl std::fmt::Display for Paragraph {
336    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
337        write!(f, "Paragraph({} runs)", self.runs.len())
338    }
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344    use crate::control::Control;
345    use crate::table::Table;
346    use hwpforge_foundation::CharShapeIndex;
347
348    fn text_run(s: &str) -> Run {
349        Run::text(s, CharShapeIndex::new(0))
350    }
351
352    #[test]
353    fn new_is_empty() {
354        let para = Paragraph::new(ParaShapeIndex::new(0));
355        assert!(para.is_empty());
356        assert_eq!(para.run_count(), 0);
357        assert_eq!(para.text_content(), "");
358    }
359
360    #[test]
361    fn with_runs() {
362        let para = Paragraph::with_runs(vec![text_run("a"), text_run("b")], ParaShapeIndex::new(0));
363        assert_eq!(para.run_count(), 2);
364        assert!(!para.is_empty());
365    }
366
367    #[test]
368    fn add_run() {
369        let mut para = Paragraph::new(ParaShapeIndex::new(0));
370        para.add_run(text_run("first"));
371        para.add_run(text_run("second"));
372        assert_eq!(para.run_count(), 2);
373    }
374
375    #[test]
376    fn text_content_concatenation() {
377        let para = Paragraph::with_runs(
378            vec![text_run("Hello "), text_run("world!")],
379            ParaShapeIndex::new(0),
380        );
381        assert_eq!(para.text_content(), "Hello world!");
382    }
383
384    #[test]
385    fn text_content_skips_non_text() {
386        let para = Paragraph::with_runs(
387            vec![
388                text_run("before"),
389                Run::table(Table::new(vec![]), CharShapeIndex::new(0)),
390                text_run("after"),
391            ],
392            ParaShapeIndex::new(0),
393        );
394        assert_eq!(para.text_content(), "beforeafter");
395    }
396
397    #[test]
398    fn text_content_empty_paragraph() {
399        let para = Paragraph::new(ParaShapeIndex::new(0));
400        assert_eq!(para.text_content(), "");
401    }
402
403    #[test]
404    fn text_content_no_text_runs() {
405        let para = Paragraph::with_runs(
406            vec![Run::table(Table::new(vec![]), CharShapeIndex::new(0))],
407            ParaShapeIndex::new(0),
408        );
409        assert_eq!(para.text_content(), "");
410    }
411
412    #[test]
413    fn korean_text_content() {
414        let para = Paragraph::with_runs(
415            vec![text_run("안녕"), text_run("하세요")],
416            ParaShapeIndex::new(0),
417        );
418        assert_eq!(para.text_content(), "안녕하세요");
419    }
420
421    #[test]
422    fn display() {
423        let para = Paragraph::with_runs(
424            vec![text_run("a"), text_run("b"), text_run("c")],
425            ParaShapeIndex::new(0),
426        );
427        assert_eq!(para.to_string(), "Paragraph(3 runs)");
428    }
429
430    #[test]
431    fn equality() {
432        let a = Paragraph::with_runs(vec![text_run("x")], ParaShapeIndex::new(0));
433        let b = Paragraph::with_runs(vec![text_run("x")], ParaShapeIndex::new(0));
434        let c = Paragraph::with_runs(vec![text_run("y")], ParaShapeIndex::new(0));
435        let d = Paragraph::with_runs(vec![text_run("x")], ParaShapeIndex::new(1));
436        assert_eq!(a, b);
437        assert_ne!(a, c);
438        assert_ne!(a, d);
439    }
440
441    #[test]
442    fn clone_independence() {
443        let para = Paragraph::with_runs(vec![text_run("original")], ParaShapeIndex::new(0));
444        let mut cloned = para.clone();
445        cloned.add_run(text_run("added"));
446        assert_eq!(para.run_count(), 1);
447        assert_eq!(cloned.run_count(), 2);
448    }
449
450    #[test]
451    fn many_runs() {
452        let runs: Vec<Run> = (0..100).map(|i| text_run(&format!("run{i}"))).collect();
453        let para = Paragraph::with_runs(runs, ParaShapeIndex::new(0));
454        assert_eq!(para.run_count(), 100);
455        assert!(para.text_content().starts_with("run0"));
456    }
457
458    #[test]
459    fn serde_roundtrip() {
460        let para = Paragraph::with_runs(
461            vec![text_run("hello"), text_run("world")],
462            ParaShapeIndex::new(5),
463        );
464        let json = serde_json::to_string(&para).unwrap();
465        let back: Paragraph = serde_json::from_str(&json).unwrap();
466        assert_eq!(para, back);
467    }
468
469    #[test]
470    fn serde_roundtrip_with_control() {
471        let ctrl =
472            Control::Hyperlink { text: "link".to_string(), url: "https://example.com".to_string() };
473        let para = Paragraph::with_runs(
474            vec![text_run("see "), Run::control(ctrl, CharShapeIndex::new(1))],
475            ParaShapeIndex::new(0),
476        );
477        let json = serde_json::to_string(&para).unwrap();
478        let back: Paragraph = serde_json::from_str(&json).unwrap();
479        assert_eq!(para, back);
480    }
481
482    #[test]
483    fn serde_empty_paragraph() {
484        let para = Paragraph::new(ParaShapeIndex::new(0));
485        let json = serde_json::to_string(&para).unwrap();
486        let back: Paragraph = serde_json::from_str(&json).unwrap();
487        assert_eq!(para, back);
488    }
489
490    #[test]
491    fn with_heading_level_sets_field() {
492        let para = Paragraph::new(ParaShapeIndex::new(0)).with_heading_level(1);
493        assert_eq!(para.heading_level, Some(1));
494
495        let para7 = Paragraph::new(ParaShapeIndex::new(0)).with_heading_level(7);
496        assert_eq!(para7.heading_level, Some(7));
497    }
498
499    #[test]
500    fn with_heading_level_all_valid_levels() {
501        for level in 1u8..=7 {
502            let para = Paragraph::new(ParaShapeIndex::new(0)).with_heading_level(level);
503            assert_eq!(para.heading_level, Some(level));
504        }
505    }
506
507    #[test]
508    #[should_panic(expected = "heading_level must be 1-7")]
509    fn with_heading_level_zero_panics() {
510        let _ = Paragraph::new(ParaShapeIndex::new(0)).with_heading_level(0);
511    }
512
513    #[test]
514    #[should_panic(expected = "heading_level must be 1-7")]
515    fn with_heading_level_eight_panics() {
516        let _ = Paragraph::new(ParaShapeIndex::new(0)).with_heading_level(8);
517    }
518
519    #[test]
520    fn new_has_no_heading_level() {
521        let para = Paragraph::new(ParaShapeIndex::new(0));
522        assert_eq!(para.heading_level, None);
523    }
524
525    #[test]
526    fn serde_roundtrip_with_heading_level() {
527        let para = Paragraph::with_runs(vec![text_run("heading text")], ParaShapeIndex::new(0))
528            .with_heading_level(2);
529        let json = serde_json::to_string(&para).unwrap();
530        let back: Paragraph = serde_json::from_str(&json).unwrap();
531        assert_eq!(para, back);
532        assert_eq!(back.heading_level, Some(2));
533    }
534
535    #[test]
536    fn serde_heading_level_omitted_when_none() {
537        let para = Paragraph::new(ParaShapeIndex::new(0));
538        let json = serde_json::to_string(&para).unwrap();
539        assert!(!json.contains("heading_level"), "None should be skipped in serialization");
540    }
541
542    #[test]
543    fn try_with_heading_level_valid() {
544        for level in 1u8..=7 {
545            let para =
546                Paragraph::new(ParaShapeIndex::new(0)).try_with_heading_level(level).unwrap();
547            assert_eq!(para.heading_level, Some(level));
548        }
549    }
550
551    #[test]
552    fn try_with_heading_level_zero_errors() {
553        let result = Paragraph::new(ParaShapeIndex::new(0)).try_with_heading_level(0);
554        assert!(result.is_err());
555    }
556
557    #[test]
558    fn try_with_heading_level_eight_errors() {
559        let result = Paragraph::new(ParaShapeIndex::new(0)).try_with_heading_level(8);
560        assert!(result.is_err());
561    }
562
563    #[test]
564    fn try_with_heading_level_255_errors() {
565        let result = Paragraph::new(ParaShapeIndex::new(0)).try_with_heading_level(255);
566        assert!(result.is_err());
567    }
568
569    #[test]
570    fn serde_roundtrip_all_7_heading_levels() {
571        for level in 1u8..=7 {
572            let para = Paragraph::with_runs(vec![text_run("heading")], ParaShapeIndex::new(0))
573                .with_heading_level(level);
574            let json = serde_json::to_string(&para).unwrap();
575            let back: Paragraph = serde_json::from_str(&json).unwrap();
576            assert_eq!(back.heading_level, Some(level), "level {level} roundtrip failed");
577        }
578    }
579
580    #[test]
581    fn new_has_no_style_id() {
582        let para = Paragraph::new(ParaShapeIndex::new(0));
583        assert_eq!(para.style_id, None);
584    }
585
586    #[test]
587    fn with_style_builder_works() {
588        let para = Paragraph::new(ParaShapeIndex::new(0)).with_style(StyleIndex::new(2));
589        assert_eq!(para.style_id, Some(StyleIndex::new(2)));
590    }
591
592    #[test]
593    fn with_runs_has_no_style_id() {
594        let para = Paragraph::with_runs(vec![text_run("x")], ParaShapeIndex::new(0));
595        assert_eq!(para.style_id, None);
596    }
597
598    #[test]
599    fn serde_roundtrip_with_style_id() {
600        let para = Paragraph::new(ParaShapeIndex::new(0)).with_style(StyleIndex::new(5));
601        let json = serde_json::to_string(&para).unwrap();
602        let back: Paragraph = serde_json::from_str(&json).unwrap();
603        assert_eq!(back.style_id, Some(StyleIndex::new(5)));
604    }
605
606    #[test]
607    fn serde_missing_style_id_deserializes_to_none() {
608        // JSON without style_id field → backward compat → None
609        let json = r#"{"runs":[],"para_shape_id":0,"column_break":false}"#;
610        let para: Paragraph = serde_json::from_str(json).unwrap();
611        assert_eq!(para.style_id, None);
612    }
613
614    #[test]
615    fn serde_style_id_omitted_when_none() {
616        let para = Paragraph::new(ParaShapeIndex::new(0));
617        let json = serde_json::to_string(&para).unwrap();
618        assert!(!json.contains("style_id"), "None should be skipped in serialization");
619    }
620}