Skip to main content

hwpforge_core/
run.rs

1//! Run and RunContent: the leaf nodes of the document tree.
2//!
3//! A [`Run`] is a contiguous segment of content with a single character
4//! shape (font, size, etc.). The actual content is held in [`RunContent`],
5//! which may be text, a table, an image, or a control element.
6//!
7//! # Enum Size Optimization
8//!
9//! [`Table`] and [`Control`] are
10//! large types. They are boxed inside [`RunContent`] to keep the common
11//! case (`RunContent::Text`) small:
12//!
13//! - `Text(String)` -- 24 bytes
14//! - `Table(Box<Table>)` -- 8 bytes (pointer)
15//! - `Image(Image)` -- moderate
16//! - `Control(Box<Control>)` -- 8 bytes (pointer)
17//!
18//! # Examples
19//!
20//! ```
21//! use hwpforge_core::run::{Run, RunContent};
22//! use hwpforge_foundation::CharShapeIndex;
23//!
24//! let run = Run::text("Hello, world!", CharShapeIndex::new(0));
25//! assert_eq!(run.content.as_text(), Some("Hello, world!"));
26//! assert!(run.content.is_text());
27//! ```
28
29use hwpforge_foundation::CharShapeIndex;
30use schemars::JsonSchema;
31use serde::{Deserialize, Serialize};
32
33use crate::control::Control;
34use crate::image::Image;
35use crate::inline::InlineText;
36use crate::table::Table;
37
38/// A run: a segment of content with a single character shape reference.
39///
40/// Runs are the leaf nodes of the document tree. A paragraph contains
41/// one or more runs. Adjacent runs with the same `char_shape_id` could
42/// theoretically be merged, but Core preserves the original structure.
43///
44/// # Examples
45///
46/// ```
47/// use hwpforge_core::run::Run;
48/// use hwpforge_foundation::CharShapeIndex;
49///
50/// let run = Run::text("paragraph text", CharShapeIndex::new(0));
51/// assert!(run.content.is_text());
52/// ```
53#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
54pub struct Run {
55    /// The content of this run.
56    pub content: RunContent,
57    /// Index into the character shape collection (Blueprint resolves this).
58    pub char_shape_id: CharShapeIndex,
59}
60
61impl Run {
62    /// 이 run 안에 중첩된 모든 문단을 재귀 방문한다 (표 셀·캡션·컨트롤 포함).
63    pub(crate) fn walk_paragraphs_mut(
64        &mut self,
65        f: &mut dyn FnMut(&mut crate::paragraph::Paragraph),
66    ) {
67        match &mut self.content {
68            RunContent::Table(table) => table.walk_paragraphs_mut(f),
69            RunContent::Control(control) => control.walk_paragraphs_mut(f),
70            RunContent::Text(_) | RunContent::InlineText(_) | RunContent::Image(_) => {}
71        }
72    }
73
74    /// [`Self::walk_paragraphs_mut`] 의 불변 쌍둥이 — 재귀 대상 동일.
75    ///
76    /// [`RunContent::Image`] 안으로는 재귀하지 않는다 (이미지 캡션 문단은
77    /// 방문 대상이 아니다) — 가변판과 같은 선택이므로 두 순회의 문단
78    /// 번호가 일치한다.
79    pub(crate) fn walk_paragraphs(&self, f: &mut dyn FnMut(&crate::paragraph::Paragraph)) {
80        match &self.content {
81            RunContent::Table(table) => table.walk_paragraphs(f),
82            RunContent::Control(control) => control.walk_paragraphs(f),
83            RunContent::Text(_) | RunContent::InlineText(_) | RunContent::Image(_) => {}
84        }
85    }
86
87    /// Creates a text run.
88    ///
89    /// This is the most common constructor. Most runs in a typical
90    /// document are text.
91    ///
92    /// # Examples
93    ///
94    /// ```
95    /// use hwpforge_core::run::Run;
96    /// use hwpforge_foundation::CharShapeIndex;
97    ///
98    /// let run = Run::text("Hello", CharShapeIndex::new(0));
99    /// assert_eq!(run.content.as_text(), Some("Hello"));
100    /// ```
101    pub fn text(s: impl Into<String>, char_shape_id: CharShapeIndex) -> Self {
102        Self { content: RunContent::Text(s.into()), char_shape_id }
103    }
104
105    /// Creates a table run. The table is automatically boxed.
106    ///
107    /// # Examples
108    ///
109    /// ```
110    /// use hwpforge_core::run::Run;
111    /// use hwpforge_core::table::Table;
112    /// use hwpforge_foundation::CharShapeIndex;
113    ///
114    /// let table = Table::new(vec![]);
115    /// let run = Run::table(table, CharShapeIndex::new(0));
116    /// assert!(run.content.is_table());
117    /// ```
118    pub fn table(table: Table, char_shape_id: CharShapeIndex) -> Self {
119        Self { content: RunContent::Table(Box::new(table)), char_shape_id }
120    }
121
122    /// Creates an image run.
123    ///
124    /// # Examples
125    ///
126    /// ```
127    /// use hwpforge_core::run::Run;
128    /// use hwpforge_core::image::{Image, ImageFormat};
129    /// use hwpforge_foundation::{HwpUnit, CharShapeIndex};
130    ///
131    /// let img = Image::new("test.png", HwpUnit::ZERO, HwpUnit::ZERO, ImageFormat::Png);
132    /// let run = Run::image(img, CharShapeIndex::new(0));
133    /// assert!(run.content.is_image());
134    /// ```
135    pub fn image(image: Image, char_shape_id: CharShapeIndex) -> Self {
136        Self { content: RunContent::Image(image), char_shape_id }
137    }
138
139    /// Creates a control run. The control is automatically boxed.
140    ///
141    /// # Examples
142    ///
143    /// ```
144    /// use hwpforge_core::run::Run;
145    /// use hwpforge_core::control::Control;
146    /// use hwpforge_foundation::CharShapeIndex;
147    ///
148    /// let link = Control::Hyperlink {
149    ///     text: "Click".to_string(),
150    ///     url: "https://example.com".to_string(),
151    /// };
152    /// let run = Run::control(link, CharShapeIndex::new(0));
153    /// assert!(run.content.is_control());
154    /// ```
155    pub fn control(control: Control, char_shape_id: CharShapeIndex) -> Self {
156        Self { content: RunContent::Control(Box::new(control)), char_shape_id }
157    }
158}
159
160impl std::fmt::Display for Run {
161    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
162        write!(f, "Run({})", self.content)
163    }
164}
165
166/// The content of a run.
167///
168/// Marked `#[non_exhaustive]` so future content types can be added
169/// without a breaking change.
170///
171/// # Design Decision
172///
173/// `Table` and `Control` are boxed to keep the enum size small.
174/// The common case (`Text`) is 24 bytes (a `String`). Without boxing,
175/// the enum would be ~88 bytes (dominated by the `Control` variant).
176///
177/// # Examples
178///
179/// ```
180/// use hwpforge_core::run::RunContent;
181///
182/// let text = RunContent::Text("Hello".to_string());
183/// assert!(text.is_text());
184/// assert_eq!(text.as_text(), Some("Hello"));
185/// ```
186#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
187#[non_exhaustive]
188pub enum RunContent {
189    /// Plain text.
190    Text(String),
191    /// Rich inline text that needs per-segment attributes (e.g. an
192    /// inline `<hp:tab width="..." leader="..." type="..."/>`). Used
193    /// only when `Text(String)` cannot represent the payload —
194    /// projection still prefers `Text` for the common plain-string
195    /// case to keep the audit baseline and downstream encoders simple.
196    ///
197    /// See [`crate::inline::InlineText`] for the design notes.
198    InlineText(InlineText),
199    /// An inline table (boxed for enum size optimization).
200    Table(Box<Table>),
201    /// An inline image.
202    Image(Image),
203    /// A control element (boxed for enum size optimization).
204    Control(Box<Control>),
205}
206
207impl RunContent {
208    /// Returns the text content if this is a `Text` variant.
209    ///
210    /// # Examples
211    ///
212    /// ```
213    /// use hwpforge_core::run::RunContent;
214    ///
215    /// let content = RunContent::Text("hello".to_string());
216    /// assert_eq!(content.as_text(), Some("hello"));
217    ///
218    /// let content = RunContent::Text(String::new());
219    /// assert_eq!(content.as_text(), Some(""));
220    /// ```
221    pub fn as_text(&self) -> Option<&str> {
222        match self {
223            Self::Text(s) => Some(s),
224            _ => None,
225        }
226    }
227
228    /// Returns the [`InlineText`] if this is an `InlineText` variant.
229    pub fn as_inline_text(&self) -> Option<&InlineText> {
230        match self {
231            Self::InlineText(it) => Some(it),
232            _ => None,
233        }
234    }
235
236    /// Returns the plain-text equivalent of any text-bearing variant
237    /// (`Text` or `InlineText`). For [`RunContent::InlineText`], each
238    /// `Tab` segment renders as `\t`. Returns `None` for `Table`,
239    /// `Image`, and `Control` variants.
240    ///
241    /// Use this from callers (Markdown bridge, CLI search, etc.) that
242    /// need text payload without inspecting per-segment attributes.
243    pub fn plain_text(&self) -> Option<std::borrow::Cow<'_, str>> {
244        match self {
245            Self::Text(s) => Some(std::borrow::Cow::Borrowed(s)),
246            Self::InlineText(it) => Some(std::borrow::Cow::Owned(it.plain_text())),
247            _ => None,
248        }
249    }
250
251    /// Returns the table if this is a `Table` variant.
252    pub fn as_table(&self) -> Option<&Table> {
253        match self {
254            Self::Table(t) => Some(t),
255            _ => None,
256        }
257    }
258
259    /// Returns the image if this is an `Image` variant.
260    pub fn as_image(&self) -> Option<&Image> {
261        match self {
262            Self::Image(i) => Some(i),
263            _ => None,
264        }
265    }
266
267    /// Returns the control if this is a `Control` variant.
268    pub fn as_control(&self) -> Option<&Control> {
269        match self {
270            Self::Control(c) => Some(c),
271            _ => None,
272        }
273    }
274
275    /// Returns `true` if this is a `Text` variant.
276    pub fn is_text(&self) -> bool {
277        matches!(self, Self::Text(_))
278    }
279
280    /// Returns `true` if this is an `InlineText` variant.
281    pub fn is_inline_text(&self) -> bool {
282        matches!(self, Self::InlineText(_))
283    }
284
285    /// Returns `true` for any text-bearing variant (`Text` or `InlineText`).
286    pub fn carries_text(&self) -> bool {
287        matches!(self, Self::Text(_) | Self::InlineText(_))
288    }
289
290    /// Returns `true` if this is a `Table` variant.
291    pub fn is_table(&self) -> bool {
292        matches!(self, Self::Table(_))
293    }
294
295    /// Returns `true` if this is an `Image` variant.
296    pub fn is_image(&self) -> bool {
297        matches!(self, Self::Image(_))
298    }
299
300    /// Returns `true` if this is a `Control` variant.
301    pub fn is_control(&self) -> bool {
302        matches!(self, Self::Control(_))
303    }
304}
305
306impl std::fmt::Display for RunContent {
307    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
308        match self {
309            Self::Text(s) => {
310                if s.len() <= 50 {
311                    write!(f, "Text(\"{s}\")")
312                } else {
313                    let truncated: String = s.chars().take(50).collect();
314                    write!(f, "Text(\"{truncated}...\")")
315                }
316            }
317            Self::InlineText(it) => {
318                let plain = it.plain_text();
319                let tabs = it
320                    .segments
321                    .iter()
322                    .filter(|s| matches!(s, crate::inline::InlineSegment::Tab(_)))
323                    .count();
324                if plain.len() <= 50 {
325                    write!(f, "InlineText(\"{plain}\", tabs={tabs})")
326                } else {
327                    let truncated: String = plain.chars().take(50).collect();
328                    write!(f, "InlineText(\"{truncated}...\", tabs={tabs})")
329                }
330            }
331            Self::Table(t) => write!(f, "{t}"),
332            Self::Image(i) => write!(f, "{i}"),
333            Self::Control(c) => write!(f, "{c}"),
334        }
335    }
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341    use crate::image::ImageFormat;
342    use hwpforge_foundation::HwpUnit;
343
344    #[test]
345    fn run_text_constructor() {
346        let run = Run::text("Hello", CharShapeIndex::new(0));
347        assert_eq!(run.content.as_text(), Some("Hello"));
348        assert_eq!(run.char_shape_id, CharShapeIndex::new(0));
349    }
350
351    #[test]
352    fn run_text_from_string() {
353        let s = String::from("owned");
354        let run = Run::text(s, CharShapeIndex::new(1));
355        assert_eq!(run.content.as_text(), Some("owned"));
356    }
357
358    #[test]
359    fn run_table_constructor() {
360        let table = Table::new(vec![]);
361        let run = Run::table(table, CharShapeIndex::new(0));
362        assert!(run.content.is_table());
363        assert!(run.content.as_table().unwrap().is_empty());
364    }
365
366    #[test]
367    fn run_image_constructor() {
368        let img = Image::new("test.png", HwpUnit::ZERO, HwpUnit::ZERO, ImageFormat::Png);
369        let run = Run::image(img, CharShapeIndex::new(0));
370        assert!(run.content.is_image());
371        assert_eq!(run.content.as_image().unwrap().path, "test.png");
372    }
373
374    #[test]
375    fn run_control_constructor() {
376        let ctrl =
377            Control::Hyperlink { text: "link".to_string(), url: "https://example.com".to_string() };
378        let run = Run::control(ctrl, CharShapeIndex::new(0));
379        assert!(run.content.is_control());
380        assert!(run.content.as_control().unwrap().is_hyperlink());
381    }
382
383    // === RunContent type checks ===
384
385    #[test]
386    fn run_content_text_checks() {
387        let c = RunContent::Text("hi".to_string());
388        assert!(c.is_text());
389        assert!(!c.is_table());
390        assert!(!c.is_image());
391        assert!(!c.is_control());
392    }
393
394    #[test]
395    fn run_content_table_checks() {
396        let c = RunContent::Table(Box::new(Table::new(vec![])));
397        assert!(!c.is_text());
398        assert!(c.is_table());
399    }
400
401    #[test]
402    fn run_content_image_checks() {
403        let c =
404            RunContent::Image(Image::new("x.png", HwpUnit::ZERO, HwpUnit::ZERO, ImageFormat::Png));
405        assert!(!c.is_text());
406        assert!(c.is_image());
407    }
408
409    #[test]
410    fn run_content_control_checks() {
411        let c =
412            RunContent::Control(Box::new(Control::Unknown { tag: "x".to_string(), data: None }));
413        assert!(!c.is_text());
414        assert!(c.is_control());
415    }
416
417    // === Accessors return None for wrong variant ===
418
419    #[test]
420    fn as_text_returns_none_for_non_text() {
421        let c = RunContent::Table(Box::new(Table::new(vec![])));
422        assert!(c.as_text().is_none());
423    }
424
425    #[test]
426    fn as_table_returns_none_for_non_table() {
427        let c = RunContent::Text("hi".to_string());
428        assert!(c.as_table().is_none());
429    }
430
431    #[test]
432    fn as_image_returns_none_for_non_image() {
433        let c = RunContent::Text("hi".to_string());
434        assert!(c.as_image().is_none());
435    }
436
437    #[test]
438    fn as_control_returns_none_for_non_control() {
439        let c = RunContent::Text("hi".to_string());
440        assert!(c.as_control().is_none());
441    }
442
443    // === Display ===
444
445    #[test]
446    fn run_content_display_text_short() {
447        let c = RunContent::Text("hello".to_string());
448        assert_eq!(c.to_string(), "Text(\"hello\")");
449    }
450
451    #[test]
452    fn run_content_display_text_long_truncated() {
453        let long = "A".repeat(100);
454        let c = RunContent::Text(long);
455        let s = c.to_string();
456        assert!(s.contains(&"A".repeat(50)), "display: {s}");
457        assert!(s.ends_with("...\")"), "display: {s}");
458    }
459
460    #[test]
461    fn run_display() {
462        let run = Run::text("test", CharShapeIndex::new(0));
463        let s = run.to_string();
464        assert!(s.contains("Run("), "display: {s}");
465        assert!(s.contains("Text"), "display: {s}");
466    }
467
468    // === Empty text ===
469
470    #[test]
471    fn empty_text_run() {
472        let run = Run::text("", CharShapeIndex::new(0));
473        assert_eq!(run.content.as_text(), Some(""));
474    }
475
476    // === Korean text ===
477
478    #[test]
479    fn korean_text_run() {
480        let run = Run::text("안녕하세요", CharShapeIndex::new(0));
481        assert_eq!(run.content.as_text(), Some("안녕하세요"));
482    }
483
484    // === Equality ===
485
486    #[test]
487    fn run_equality() {
488        let a = Run::text("hello", CharShapeIndex::new(0));
489        let b = Run::text("hello", CharShapeIndex::new(0));
490        let c = Run::text("world", CharShapeIndex::new(0));
491        let d = Run::text("hello", CharShapeIndex::new(1));
492        assert_eq!(a, b);
493        assert_ne!(a, c);
494        assert_ne!(a, d);
495    }
496
497    // === Serde ===
498
499    #[test]
500    fn serde_roundtrip_text() {
501        let run = Run::text("test", CharShapeIndex::new(5));
502        let json = serde_json::to_string(&run).unwrap();
503        let back: Run = serde_json::from_str(&json).unwrap();
504        assert_eq!(run, back);
505    }
506
507    #[test]
508    fn serde_roundtrip_table() {
509        let run = Run::table(Table::new(vec![]), CharShapeIndex::new(0));
510        let json = serde_json::to_string(&run).unwrap();
511        let back: Run = serde_json::from_str(&json).unwrap();
512        assert_eq!(run, back);
513    }
514
515    #[test]
516    fn serde_roundtrip_image() {
517        let img = Image::new("test.png", HwpUnit::ZERO, HwpUnit::ZERO, ImageFormat::Png);
518        let run = Run::image(img, CharShapeIndex::new(0));
519        let json = serde_json::to_string(&run).unwrap();
520        let back: Run = serde_json::from_str(&json).unwrap();
521        assert_eq!(run, back);
522    }
523
524    #[test]
525    fn serde_roundtrip_control() {
526        let ctrl =
527            Control::Hyperlink { text: "link".to_string(), url: "https://example.com".to_string() };
528        let run = Run::control(ctrl, CharShapeIndex::new(0));
529        let json = serde_json::to_string(&run).unwrap();
530        let back: Run = serde_json::from_str(&json).unwrap();
531        assert_eq!(run, back);
532    }
533
534    // === Clone ===
535
536    #[test]
537    fn run_clone_independence() {
538        let run = Run::text("original", CharShapeIndex::new(0));
539        let cloned = run.clone();
540        assert_eq!(run, cloned);
541    }
542}