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