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    /// 메모 등)을 문서 순서로 방문한다.
139    ///
140    /// 자신을 먼저 방문한 뒤 run 내용물로 재귀한다. 캐시 정규화 등
141    /// 전 문단 일괄 변환의 기반 유틸 —
142    /// [`crate::document::Document::for_each_paragraph_mut`] 참조.
143    pub fn for_each_paragraph_mut<F: FnMut(&mut Paragraph)>(&mut self, mut f: F) {
144        self.walk_paragraphs_mut(&mut f);
145    }
146
147    /// [`Self::for_each_paragraph_mut`] 의 내부 재귀 본체 (dyn 으로 단형화 제한).
148    pub(crate) fn walk_paragraphs_mut(&mut self, f: &mut dyn FnMut(&mut Paragraph)) {
149        f(self);
150        for run in &mut self.runs {
151            run.walk_paragraphs_mut(f);
152        }
153    }
154
155    /// Appends a run to this paragraph.
156    ///
157    /// # Examples
158    ///
159    /// ```
160    /// use hwpforge_core::paragraph::Paragraph;
161    /// use hwpforge_core::run::Run;
162    /// use hwpforge_foundation::{CharShapeIndex, ParaShapeIndex};
163    ///
164    /// let mut para = Paragraph::new(ParaShapeIndex::new(0));
165    /// para.add_run(Run::text("hello", CharShapeIndex::new(0)));
166    /// assert_eq!(para.run_count(), 1);
167    /// ```
168    pub fn add_run(&mut self, run: Run) {
169        self.runs.push(run);
170    }
171
172    /// Sets the heading level for TOC participation (1-7).
173    ///
174    /// Paragraphs with a heading level emit `<hp:titleMark>` in HWPX,
175    /// enabling 한글 to auto-build a Table of Contents from document headings.
176    ///
177    /// # Panics
178    ///
179    /// Panics if `level` is 0 or greater than 7.
180    ///
181    /// # Examples
182    ///
183    /// ```
184    /// use hwpforge_core::paragraph::Paragraph;
185    /// use hwpforge_foundation::ParaShapeIndex;
186    ///
187    /// let para = Paragraph::new(ParaShapeIndex::new(0))
188    ///     .with_heading_level(1);
189    /// assert_eq!(para.heading_level, Some(1));
190    /// ```
191    pub fn with_heading_level(mut self, level: u8) -> Self {
192        assert!((1..=7).contains(&level), "heading_level must be 1-7, got {level}");
193        self.heading_level = Some(level);
194        self
195    }
196
197    /// Sets the style ID for this paragraph.
198    ///
199    /// # Examples
200    ///
201    /// ```
202    /// use hwpforge_core::paragraph::Paragraph;
203    /// use hwpforge_foundation::{ParaShapeIndex, StyleIndex};
204    ///
205    /// let para = Paragraph::new(ParaShapeIndex::new(0))
206    ///     .with_style(StyleIndex::new(2));
207    /// assert_eq!(para.style_id, Some(StyleIndex::new(2)));
208    /// ```
209    pub fn with_style(mut self, style_id: StyleIndex) -> Self {
210        self.style_id = Some(style_id);
211        self
212    }
213
214    /// Marks this paragraph as starting a new page (HWPX `pageBreak="1"`).
215    ///
216    /// # Examples
217    ///
218    /// ```
219    /// use hwpforge_core::paragraph::Paragraph;
220    /// use hwpforge_foundation::ParaShapeIndex;
221    ///
222    /// let para = Paragraph::new(ParaShapeIndex::new(0)).with_page_break();
223    /// assert!(para.page_break);
224    /// ```
225    pub fn with_page_break(mut self) -> Self {
226        self.page_break = true;
227        self
228    }
229
230    /// Sets the heading level for TOC participation (1-7), returning an error
231    /// if the level is out of range.
232    ///
233    /// This is the fallible alternative to [`with_heading_level`](Self::with_heading_level),
234    /// suitable for user-supplied input where panicking is undesirable.
235    ///
236    /// # Errors
237    ///
238    /// Returns [`CoreError::InvalidStructure`] if `level` is 0 or greater than 7.
239    ///
240    /// # Examples
241    ///
242    /// ```
243    /// use hwpforge_core::paragraph::Paragraph;
244    /// use hwpforge_foundation::ParaShapeIndex;
245    ///
246    /// let para = Paragraph::new(ParaShapeIndex::new(0))
247    ///     .try_with_heading_level(3)
248    ///     .unwrap();
249    /// assert_eq!(para.heading_level, Some(3));
250    ///
251    /// let err = Paragraph::new(ParaShapeIndex::new(0))
252    ///     .try_with_heading_level(0);
253    /// assert!(err.is_err());
254    /// ```
255    pub fn try_with_heading_level(mut self, level: u8) -> CoreResult<Self> {
256        if !(1..=7).contains(&level) {
257            return Err(CoreError::InvalidStructure {
258                context: "Paragraph::try_with_heading_level".into(),
259                reason: format!("heading_level must be 1-7, got {level}"),
260            });
261        }
262        self.heading_level = Some(level);
263        Ok(self)
264    }
265
266    /// Concatenates all text runs into a single string.
267    ///
268    /// Non-text runs (Table, Image, Control) are silently skipped.
269    /// This is useful for full-text search and preview generation.
270    ///
271    /// # Examples
272    ///
273    /// ```
274    /// use hwpforge_core::paragraph::Paragraph;
275    /// use hwpforge_core::run::Run;
276    /// use hwpforge_core::table::Table;
277    /// use hwpforge_foundation::{CharShapeIndex, ParaShapeIndex};
278    ///
279    /// let para = Paragraph::with_runs(
280    ///     vec![
281    ///         Run::text("Hello ", CharShapeIndex::new(0)),
282    ///         Run::table(Table::new(vec![]), CharShapeIndex::new(0)),
283    ///         Run::text("world", CharShapeIndex::new(0)),
284    ///     ],
285    ///     ParaShapeIndex::new(0),
286    /// );
287    /// assert_eq!(para.text_content(), "Hello world");
288    /// ```
289    pub fn text_content(&self) -> String {
290        // Use the unified `plain_text` accessor so `RunContent::InlineText`
291        // (Wave 4 Phase 2 carry — attribute-rich inline tabs) is folded
292        // back into a tab-containing plain string the way callers expect.
293        self.runs.iter().filter_map(|r| r.content.plain_text()).fold(
294            String::new(),
295            |mut acc, cow| {
296                acc.push_str(&cow);
297                acc
298            },
299        )
300    }
301
302    /// Returns the number of runs.
303    pub fn run_count(&self) -> usize {
304        self.runs.len()
305    }
306
307    /// Returns `true` if this paragraph has no runs.
308    pub fn is_empty(&self) -> bool {
309        self.runs.is_empty()
310    }
311}
312
313impl std::fmt::Display for Paragraph {
314    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
315        write!(f, "Paragraph({} runs)", self.runs.len())
316    }
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322    use crate::control::Control;
323    use crate::table::Table;
324    use hwpforge_foundation::CharShapeIndex;
325
326    fn text_run(s: &str) -> Run {
327        Run::text(s, CharShapeIndex::new(0))
328    }
329
330    #[test]
331    fn new_is_empty() {
332        let para = Paragraph::new(ParaShapeIndex::new(0));
333        assert!(para.is_empty());
334        assert_eq!(para.run_count(), 0);
335        assert_eq!(para.text_content(), "");
336    }
337
338    #[test]
339    fn with_runs() {
340        let para = Paragraph::with_runs(vec![text_run("a"), text_run("b")], ParaShapeIndex::new(0));
341        assert_eq!(para.run_count(), 2);
342        assert!(!para.is_empty());
343    }
344
345    #[test]
346    fn add_run() {
347        let mut para = Paragraph::new(ParaShapeIndex::new(0));
348        para.add_run(text_run("first"));
349        para.add_run(text_run("second"));
350        assert_eq!(para.run_count(), 2);
351    }
352
353    #[test]
354    fn text_content_concatenation() {
355        let para = Paragraph::with_runs(
356            vec![text_run("Hello "), text_run("world!")],
357            ParaShapeIndex::new(0),
358        );
359        assert_eq!(para.text_content(), "Hello world!");
360    }
361
362    #[test]
363    fn text_content_skips_non_text() {
364        let para = Paragraph::with_runs(
365            vec![
366                text_run("before"),
367                Run::table(Table::new(vec![]), CharShapeIndex::new(0)),
368                text_run("after"),
369            ],
370            ParaShapeIndex::new(0),
371        );
372        assert_eq!(para.text_content(), "beforeafter");
373    }
374
375    #[test]
376    fn text_content_empty_paragraph() {
377        let para = Paragraph::new(ParaShapeIndex::new(0));
378        assert_eq!(para.text_content(), "");
379    }
380
381    #[test]
382    fn text_content_no_text_runs() {
383        let para = Paragraph::with_runs(
384            vec![Run::table(Table::new(vec![]), CharShapeIndex::new(0))],
385            ParaShapeIndex::new(0),
386        );
387        assert_eq!(para.text_content(), "");
388    }
389
390    #[test]
391    fn korean_text_content() {
392        let para = Paragraph::with_runs(
393            vec![text_run("안녕"), text_run("하세요")],
394            ParaShapeIndex::new(0),
395        );
396        assert_eq!(para.text_content(), "안녕하세요");
397    }
398
399    #[test]
400    fn display() {
401        let para = Paragraph::with_runs(
402            vec![text_run("a"), text_run("b"), text_run("c")],
403            ParaShapeIndex::new(0),
404        );
405        assert_eq!(para.to_string(), "Paragraph(3 runs)");
406    }
407
408    #[test]
409    fn equality() {
410        let a = Paragraph::with_runs(vec![text_run("x")], ParaShapeIndex::new(0));
411        let b = Paragraph::with_runs(vec![text_run("x")], ParaShapeIndex::new(0));
412        let c = Paragraph::with_runs(vec![text_run("y")], ParaShapeIndex::new(0));
413        let d = Paragraph::with_runs(vec![text_run("x")], ParaShapeIndex::new(1));
414        assert_eq!(a, b);
415        assert_ne!(a, c);
416        assert_ne!(a, d);
417    }
418
419    #[test]
420    fn clone_independence() {
421        let para = Paragraph::with_runs(vec![text_run("original")], ParaShapeIndex::new(0));
422        let mut cloned = para.clone();
423        cloned.add_run(text_run("added"));
424        assert_eq!(para.run_count(), 1);
425        assert_eq!(cloned.run_count(), 2);
426    }
427
428    #[test]
429    fn many_runs() {
430        let runs: Vec<Run> = (0..100).map(|i| text_run(&format!("run{i}"))).collect();
431        let para = Paragraph::with_runs(runs, ParaShapeIndex::new(0));
432        assert_eq!(para.run_count(), 100);
433        assert!(para.text_content().starts_with("run0"));
434    }
435
436    #[test]
437    fn serde_roundtrip() {
438        let para = Paragraph::with_runs(
439            vec![text_run("hello"), text_run("world")],
440            ParaShapeIndex::new(5),
441        );
442        let json = serde_json::to_string(&para).unwrap();
443        let back: Paragraph = serde_json::from_str(&json).unwrap();
444        assert_eq!(para, back);
445    }
446
447    #[test]
448    fn serde_roundtrip_with_control() {
449        let ctrl =
450            Control::Hyperlink { text: "link".to_string(), url: "https://example.com".to_string() };
451        let para = Paragraph::with_runs(
452            vec![text_run("see "), Run::control(ctrl, CharShapeIndex::new(1))],
453            ParaShapeIndex::new(0),
454        );
455        let json = serde_json::to_string(&para).unwrap();
456        let back: Paragraph = serde_json::from_str(&json).unwrap();
457        assert_eq!(para, back);
458    }
459
460    #[test]
461    fn serde_empty_paragraph() {
462        let para = Paragraph::new(ParaShapeIndex::new(0));
463        let json = serde_json::to_string(&para).unwrap();
464        let back: Paragraph = serde_json::from_str(&json).unwrap();
465        assert_eq!(para, back);
466    }
467
468    #[test]
469    fn with_heading_level_sets_field() {
470        let para = Paragraph::new(ParaShapeIndex::new(0)).with_heading_level(1);
471        assert_eq!(para.heading_level, Some(1));
472
473        let para7 = Paragraph::new(ParaShapeIndex::new(0)).with_heading_level(7);
474        assert_eq!(para7.heading_level, Some(7));
475    }
476
477    #[test]
478    fn with_heading_level_all_valid_levels() {
479        for level in 1u8..=7 {
480            let para = Paragraph::new(ParaShapeIndex::new(0)).with_heading_level(level);
481            assert_eq!(para.heading_level, Some(level));
482        }
483    }
484
485    #[test]
486    #[should_panic(expected = "heading_level must be 1-7")]
487    fn with_heading_level_zero_panics() {
488        let _ = Paragraph::new(ParaShapeIndex::new(0)).with_heading_level(0);
489    }
490
491    #[test]
492    #[should_panic(expected = "heading_level must be 1-7")]
493    fn with_heading_level_eight_panics() {
494        let _ = Paragraph::new(ParaShapeIndex::new(0)).with_heading_level(8);
495    }
496
497    #[test]
498    fn new_has_no_heading_level() {
499        let para = Paragraph::new(ParaShapeIndex::new(0));
500        assert_eq!(para.heading_level, None);
501    }
502
503    #[test]
504    fn serde_roundtrip_with_heading_level() {
505        let para = Paragraph::with_runs(vec![text_run("heading text")], ParaShapeIndex::new(0))
506            .with_heading_level(2);
507        let json = serde_json::to_string(&para).unwrap();
508        let back: Paragraph = serde_json::from_str(&json).unwrap();
509        assert_eq!(para, back);
510        assert_eq!(back.heading_level, Some(2));
511    }
512
513    #[test]
514    fn serde_heading_level_omitted_when_none() {
515        let para = Paragraph::new(ParaShapeIndex::new(0));
516        let json = serde_json::to_string(&para).unwrap();
517        assert!(!json.contains("heading_level"), "None should be skipped in serialization");
518    }
519
520    #[test]
521    fn try_with_heading_level_valid() {
522        for level in 1u8..=7 {
523            let para =
524                Paragraph::new(ParaShapeIndex::new(0)).try_with_heading_level(level).unwrap();
525            assert_eq!(para.heading_level, Some(level));
526        }
527    }
528
529    #[test]
530    fn try_with_heading_level_zero_errors() {
531        let result = Paragraph::new(ParaShapeIndex::new(0)).try_with_heading_level(0);
532        assert!(result.is_err());
533    }
534
535    #[test]
536    fn try_with_heading_level_eight_errors() {
537        let result = Paragraph::new(ParaShapeIndex::new(0)).try_with_heading_level(8);
538        assert!(result.is_err());
539    }
540
541    #[test]
542    fn try_with_heading_level_255_errors() {
543        let result = Paragraph::new(ParaShapeIndex::new(0)).try_with_heading_level(255);
544        assert!(result.is_err());
545    }
546
547    #[test]
548    fn serde_roundtrip_all_7_heading_levels() {
549        for level in 1u8..=7 {
550            let para = Paragraph::with_runs(vec![text_run("heading")], ParaShapeIndex::new(0))
551                .with_heading_level(level);
552            let json = serde_json::to_string(&para).unwrap();
553            let back: Paragraph = serde_json::from_str(&json).unwrap();
554            assert_eq!(back.heading_level, Some(level), "level {level} roundtrip failed");
555        }
556    }
557
558    #[test]
559    fn new_has_no_style_id() {
560        let para = Paragraph::new(ParaShapeIndex::new(0));
561        assert_eq!(para.style_id, None);
562    }
563
564    #[test]
565    fn with_style_builder_works() {
566        let para = Paragraph::new(ParaShapeIndex::new(0)).with_style(StyleIndex::new(2));
567        assert_eq!(para.style_id, Some(StyleIndex::new(2)));
568    }
569
570    #[test]
571    fn with_runs_has_no_style_id() {
572        let para = Paragraph::with_runs(vec![text_run("x")], ParaShapeIndex::new(0));
573        assert_eq!(para.style_id, None);
574    }
575
576    #[test]
577    fn serde_roundtrip_with_style_id() {
578        let para = Paragraph::new(ParaShapeIndex::new(0)).with_style(StyleIndex::new(5));
579        let json = serde_json::to_string(&para).unwrap();
580        let back: Paragraph = serde_json::from_str(&json).unwrap();
581        assert_eq!(back.style_id, Some(StyleIndex::new(5)));
582    }
583
584    #[test]
585    fn serde_missing_style_id_deserializes_to_none() {
586        // JSON without style_id field → backward compat → None
587        let json = r#"{"runs":[],"para_shape_id":0,"column_break":false}"#;
588        let para: Paragraph = serde_json::from_str(json).unwrap();
589        assert_eq!(para.style_id, None);
590    }
591
592    #[test]
593    fn serde_style_id_omitted_when_none() {
594        let para = Paragraph::new(ParaShapeIndex::new(0));
595        let json = serde_json::to_string(&para).unwrap();
596        assert!(!json.contains("style_id"), "None should be skipped in serialization");
597    }
598}