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