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}
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        // Use the unified `plain_text` accessor so `RunContent::InlineText`
262        // (Wave 4 Phase 2 carry — attribute-rich inline tabs) is folded
263        // back into a tab-containing plain string the way callers expect.
264        self.runs.iter().filter_map(|r| r.content.plain_text()).fold(
265            String::new(),
266            |mut acc, cow| {
267                acc.push_str(&cow);
268                acc
269            },
270        )
271    }
272
273    /// Returns the number of runs.
274    pub fn run_count(&self) -> usize {
275        self.runs.len()
276    }
277
278    /// Returns `true` if this paragraph has no runs.
279    pub fn is_empty(&self) -> bool {
280        self.runs.is_empty()
281    }
282}
283
284impl std::fmt::Display for Paragraph {
285    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
286        write!(f, "Paragraph({} runs)", self.runs.len())
287    }
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293    use crate::control::Control;
294    use crate::table::Table;
295    use hwpforge_foundation::CharShapeIndex;
296
297    fn text_run(s: &str) -> Run {
298        Run::text(s, CharShapeIndex::new(0))
299    }
300
301    #[test]
302    fn new_is_empty() {
303        let para = Paragraph::new(ParaShapeIndex::new(0));
304        assert!(para.is_empty());
305        assert_eq!(para.run_count(), 0);
306        assert_eq!(para.text_content(), "");
307    }
308
309    #[test]
310    fn with_runs() {
311        let para = Paragraph::with_runs(vec![text_run("a"), text_run("b")], ParaShapeIndex::new(0));
312        assert_eq!(para.run_count(), 2);
313        assert!(!para.is_empty());
314    }
315
316    #[test]
317    fn add_run() {
318        let mut para = Paragraph::new(ParaShapeIndex::new(0));
319        para.add_run(text_run("first"));
320        para.add_run(text_run("second"));
321        assert_eq!(para.run_count(), 2);
322    }
323
324    #[test]
325    fn text_content_concatenation() {
326        let para = Paragraph::with_runs(
327            vec![text_run("Hello "), text_run("world!")],
328            ParaShapeIndex::new(0),
329        );
330        assert_eq!(para.text_content(), "Hello world!");
331    }
332
333    #[test]
334    fn text_content_skips_non_text() {
335        let para = Paragraph::with_runs(
336            vec![
337                text_run("before"),
338                Run::table(Table::new(vec![]), CharShapeIndex::new(0)),
339                text_run("after"),
340            ],
341            ParaShapeIndex::new(0),
342        );
343        assert_eq!(para.text_content(), "beforeafter");
344    }
345
346    #[test]
347    fn text_content_empty_paragraph() {
348        let para = Paragraph::new(ParaShapeIndex::new(0));
349        assert_eq!(para.text_content(), "");
350    }
351
352    #[test]
353    fn text_content_no_text_runs() {
354        let para = Paragraph::with_runs(
355            vec![Run::table(Table::new(vec![]), CharShapeIndex::new(0))],
356            ParaShapeIndex::new(0),
357        );
358        assert_eq!(para.text_content(), "");
359    }
360
361    #[test]
362    fn korean_text_content() {
363        let para = Paragraph::with_runs(
364            vec![text_run("안녕"), text_run("하세요")],
365            ParaShapeIndex::new(0),
366        );
367        assert_eq!(para.text_content(), "안녕하세요");
368    }
369
370    #[test]
371    fn display() {
372        let para = Paragraph::with_runs(
373            vec![text_run("a"), text_run("b"), text_run("c")],
374            ParaShapeIndex::new(0),
375        );
376        assert_eq!(para.to_string(), "Paragraph(3 runs)");
377    }
378
379    #[test]
380    fn equality() {
381        let a = Paragraph::with_runs(vec![text_run("x")], ParaShapeIndex::new(0));
382        let b = Paragraph::with_runs(vec![text_run("x")], ParaShapeIndex::new(0));
383        let c = Paragraph::with_runs(vec![text_run("y")], ParaShapeIndex::new(0));
384        let d = Paragraph::with_runs(vec![text_run("x")], ParaShapeIndex::new(1));
385        assert_eq!(a, b);
386        assert_ne!(a, c);
387        assert_ne!(a, d);
388    }
389
390    #[test]
391    fn clone_independence() {
392        let para = Paragraph::with_runs(vec![text_run("original")], ParaShapeIndex::new(0));
393        let mut cloned = para.clone();
394        cloned.add_run(text_run("added"));
395        assert_eq!(para.run_count(), 1);
396        assert_eq!(cloned.run_count(), 2);
397    }
398
399    #[test]
400    fn many_runs() {
401        let runs: Vec<Run> = (0..100).map(|i| text_run(&format!("run{i}"))).collect();
402        let para = Paragraph::with_runs(runs, ParaShapeIndex::new(0));
403        assert_eq!(para.run_count(), 100);
404        assert!(para.text_content().starts_with("run0"));
405    }
406
407    #[test]
408    fn serde_roundtrip() {
409        let para = Paragraph::with_runs(
410            vec![text_run("hello"), text_run("world")],
411            ParaShapeIndex::new(5),
412        );
413        let json = serde_json::to_string(&para).unwrap();
414        let back: Paragraph = serde_json::from_str(&json).unwrap();
415        assert_eq!(para, back);
416    }
417
418    #[test]
419    fn serde_roundtrip_with_control() {
420        let ctrl =
421            Control::Hyperlink { text: "link".to_string(), url: "https://example.com".to_string() };
422        let para = Paragraph::with_runs(
423            vec![text_run("see "), Run::control(ctrl, CharShapeIndex::new(1))],
424            ParaShapeIndex::new(0),
425        );
426        let json = serde_json::to_string(&para).unwrap();
427        let back: Paragraph = serde_json::from_str(&json).unwrap();
428        assert_eq!(para, back);
429    }
430
431    #[test]
432    fn serde_empty_paragraph() {
433        let para = Paragraph::new(ParaShapeIndex::new(0));
434        let json = serde_json::to_string(&para).unwrap();
435        let back: Paragraph = serde_json::from_str(&json).unwrap();
436        assert_eq!(para, back);
437    }
438
439    #[test]
440    fn with_heading_level_sets_field() {
441        let para = Paragraph::new(ParaShapeIndex::new(0)).with_heading_level(1);
442        assert_eq!(para.heading_level, Some(1));
443
444        let para7 = Paragraph::new(ParaShapeIndex::new(0)).with_heading_level(7);
445        assert_eq!(para7.heading_level, Some(7));
446    }
447
448    #[test]
449    fn with_heading_level_all_valid_levels() {
450        for level in 1u8..=7 {
451            let para = Paragraph::new(ParaShapeIndex::new(0)).with_heading_level(level);
452            assert_eq!(para.heading_level, Some(level));
453        }
454    }
455
456    #[test]
457    #[should_panic(expected = "heading_level must be 1-7")]
458    fn with_heading_level_zero_panics() {
459        let _ = Paragraph::new(ParaShapeIndex::new(0)).with_heading_level(0);
460    }
461
462    #[test]
463    #[should_panic(expected = "heading_level must be 1-7")]
464    fn with_heading_level_eight_panics() {
465        let _ = Paragraph::new(ParaShapeIndex::new(0)).with_heading_level(8);
466    }
467
468    #[test]
469    fn new_has_no_heading_level() {
470        let para = Paragraph::new(ParaShapeIndex::new(0));
471        assert_eq!(para.heading_level, None);
472    }
473
474    #[test]
475    fn serde_roundtrip_with_heading_level() {
476        let para = Paragraph::with_runs(vec![text_run("heading text")], ParaShapeIndex::new(0))
477            .with_heading_level(2);
478        let json = serde_json::to_string(&para).unwrap();
479        let back: Paragraph = serde_json::from_str(&json).unwrap();
480        assert_eq!(para, back);
481        assert_eq!(back.heading_level, Some(2));
482    }
483
484    #[test]
485    fn serde_heading_level_omitted_when_none() {
486        let para = Paragraph::new(ParaShapeIndex::new(0));
487        let json = serde_json::to_string(&para).unwrap();
488        assert!(!json.contains("heading_level"), "None should be skipped in serialization");
489    }
490
491    #[test]
492    fn try_with_heading_level_valid() {
493        for level in 1u8..=7 {
494            let para =
495                Paragraph::new(ParaShapeIndex::new(0)).try_with_heading_level(level).unwrap();
496            assert_eq!(para.heading_level, Some(level));
497        }
498    }
499
500    #[test]
501    fn try_with_heading_level_zero_errors() {
502        let result = Paragraph::new(ParaShapeIndex::new(0)).try_with_heading_level(0);
503        assert!(result.is_err());
504    }
505
506    #[test]
507    fn try_with_heading_level_eight_errors() {
508        let result = Paragraph::new(ParaShapeIndex::new(0)).try_with_heading_level(8);
509        assert!(result.is_err());
510    }
511
512    #[test]
513    fn try_with_heading_level_255_errors() {
514        let result = Paragraph::new(ParaShapeIndex::new(0)).try_with_heading_level(255);
515        assert!(result.is_err());
516    }
517
518    #[test]
519    fn serde_roundtrip_all_7_heading_levels() {
520        for level in 1u8..=7 {
521            let para = Paragraph::with_runs(vec![text_run("heading")], ParaShapeIndex::new(0))
522                .with_heading_level(level);
523            let json = serde_json::to_string(&para).unwrap();
524            let back: Paragraph = serde_json::from_str(&json).unwrap();
525            assert_eq!(back.heading_level, Some(level), "level {level} roundtrip failed");
526        }
527    }
528
529    #[test]
530    fn new_has_no_style_id() {
531        let para = Paragraph::new(ParaShapeIndex::new(0));
532        assert_eq!(para.style_id, None);
533    }
534
535    #[test]
536    fn with_style_builder_works() {
537        let para = Paragraph::new(ParaShapeIndex::new(0)).with_style(StyleIndex::new(2));
538        assert_eq!(para.style_id, Some(StyleIndex::new(2)));
539    }
540
541    #[test]
542    fn with_runs_has_no_style_id() {
543        let para = Paragraph::with_runs(vec![text_run("x")], ParaShapeIndex::new(0));
544        assert_eq!(para.style_id, None);
545    }
546
547    #[test]
548    fn serde_roundtrip_with_style_id() {
549        let para = Paragraph::new(ParaShapeIndex::new(0)).with_style(StyleIndex::new(5));
550        let json = serde_json::to_string(&para).unwrap();
551        let back: Paragraph = serde_json::from_str(&json).unwrap();
552        assert_eq!(back.style_id, Some(StyleIndex::new(5)));
553    }
554
555    #[test]
556    fn serde_missing_style_id_deserializes_to_none() {
557        // JSON without style_id field → backward compat → None
558        let json = r#"{"runs":[],"para_shape_id":0,"column_break":false}"#;
559        let para: Paragraph = serde_json::from_str(json).unwrap();
560        assert_eq!(para.style_id, None);
561    }
562
563    #[test]
564    fn serde_style_id_omitted_when_none() {
565        let para = Paragraph::new(ParaShapeIndex::new(0));
566        let json = serde_json::to_string(&para).unwrap();
567        assert!(!json.contains("style_id"), "None should be skipped in serialization");
568    }
569}