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::table::Table;
36
37/// A run: a segment of content with a single character shape reference.
38///
39/// Runs are the leaf nodes of the document tree. A paragraph contains
40/// one or more runs. Adjacent runs with the same `char_shape_id` could
41/// theoretically be merged, but Core preserves the original structure.
42///
43/// # Examples
44///
45/// ```
46/// use hwpforge_core::run::Run;
47/// use hwpforge_foundation::CharShapeIndex;
48///
49/// let run = Run::text("paragraph text", CharShapeIndex::new(0));
50/// assert!(run.content.is_text());
51/// ```
52#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
53pub struct Run {
54    /// The content of this run.
55    pub content: RunContent,
56    /// Index into the character shape collection (Blueprint resolves this).
57    pub char_shape_id: CharShapeIndex,
58}
59
60impl Run {
61    /// Creates a text run.
62    ///
63    /// This is the most common constructor. Most runs in a typical
64    /// document are text.
65    ///
66    /// # Examples
67    ///
68    /// ```
69    /// use hwpforge_core::run::Run;
70    /// use hwpforge_foundation::CharShapeIndex;
71    ///
72    /// let run = Run::text("Hello", CharShapeIndex::new(0));
73    /// assert_eq!(run.content.as_text(), Some("Hello"));
74    /// ```
75    pub fn text(s: impl Into<String>, char_shape_id: CharShapeIndex) -> Self {
76        Self { content: RunContent::Text(s.into()), char_shape_id }
77    }
78
79    /// Creates a table run. The table is automatically boxed.
80    ///
81    /// # Examples
82    ///
83    /// ```
84    /// use hwpforge_core::run::Run;
85    /// use hwpforge_core::table::Table;
86    /// use hwpforge_foundation::CharShapeIndex;
87    ///
88    /// let table = Table::new(vec![]);
89    /// let run = Run::table(table, CharShapeIndex::new(0));
90    /// assert!(run.content.is_table());
91    /// ```
92    pub fn table(table: Table, char_shape_id: CharShapeIndex) -> Self {
93        Self { content: RunContent::Table(Box::new(table)), char_shape_id }
94    }
95
96    /// Creates an image run.
97    ///
98    /// # Examples
99    ///
100    /// ```
101    /// use hwpforge_core::run::Run;
102    /// use hwpforge_core::image::{Image, ImageFormat};
103    /// use hwpforge_foundation::{HwpUnit, CharShapeIndex};
104    ///
105    /// let img = Image::new("test.png", HwpUnit::ZERO, HwpUnit::ZERO, ImageFormat::Png);
106    /// let run = Run::image(img, CharShapeIndex::new(0));
107    /// assert!(run.content.is_image());
108    /// ```
109    pub fn image(image: Image, char_shape_id: CharShapeIndex) -> Self {
110        Self { content: RunContent::Image(image), char_shape_id }
111    }
112
113    /// Creates a control run. The control is automatically boxed.
114    ///
115    /// # Examples
116    ///
117    /// ```
118    /// use hwpforge_core::run::Run;
119    /// use hwpforge_core::control::Control;
120    /// use hwpforge_foundation::CharShapeIndex;
121    ///
122    /// let link = Control::Hyperlink {
123    ///     text: "Click".to_string(),
124    ///     url: "https://example.com".to_string(),
125    /// };
126    /// let run = Run::control(link, CharShapeIndex::new(0));
127    /// assert!(run.content.is_control());
128    /// ```
129    pub fn control(control: Control, char_shape_id: CharShapeIndex) -> Self {
130        Self { content: RunContent::Control(Box::new(control)), char_shape_id }
131    }
132}
133
134impl std::fmt::Display for Run {
135    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
136        write!(f, "Run({})", self.content)
137    }
138}
139
140/// The content of a run.
141///
142/// Marked `#[non_exhaustive]` so future content types can be added
143/// without a breaking change.
144///
145/// # Design Decision
146///
147/// `Table` and `Control` are boxed to keep the enum size small.
148/// The common case (`Text`) is 24 bytes (a `String`). Without boxing,
149/// the enum would be ~88 bytes (dominated by the `Control` variant).
150///
151/// # Examples
152///
153/// ```
154/// use hwpforge_core::run::RunContent;
155///
156/// let text = RunContent::Text("Hello".to_string());
157/// assert!(text.is_text());
158/// assert_eq!(text.as_text(), Some("Hello"));
159/// ```
160#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
161#[non_exhaustive]
162pub enum RunContent {
163    /// Plain text.
164    Text(String),
165    /// An inline table (boxed for enum size optimization).
166    Table(Box<Table>),
167    /// An inline image.
168    Image(Image),
169    /// A control element (boxed for enum size optimization).
170    Control(Box<Control>),
171}
172
173impl RunContent {
174    /// Returns the text content if this is a `Text` variant.
175    ///
176    /// # Examples
177    ///
178    /// ```
179    /// use hwpforge_core::run::RunContent;
180    ///
181    /// let content = RunContent::Text("hello".to_string());
182    /// assert_eq!(content.as_text(), Some("hello"));
183    ///
184    /// let content = RunContent::Text(String::new());
185    /// assert_eq!(content.as_text(), Some(""));
186    /// ```
187    pub fn as_text(&self) -> Option<&str> {
188        match self {
189            Self::Text(s) => Some(s),
190            _ => None,
191        }
192    }
193
194    /// Returns the table if this is a `Table` variant.
195    pub fn as_table(&self) -> Option<&Table> {
196        match self {
197            Self::Table(t) => Some(t),
198            _ => None,
199        }
200    }
201
202    /// Returns the image if this is an `Image` variant.
203    pub fn as_image(&self) -> Option<&Image> {
204        match self {
205            Self::Image(i) => Some(i),
206            _ => None,
207        }
208    }
209
210    /// Returns the control if this is a `Control` variant.
211    pub fn as_control(&self) -> Option<&Control> {
212        match self {
213            Self::Control(c) => Some(c),
214            _ => None,
215        }
216    }
217
218    /// Returns `true` if this is a `Text` variant.
219    pub fn is_text(&self) -> bool {
220        matches!(self, Self::Text(_))
221    }
222
223    /// Returns `true` if this is a `Table` variant.
224    pub fn is_table(&self) -> bool {
225        matches!(self, Self::Table(_))
226    }
227
228    /// Returns `true` if this is an `Image` variant.
229    pub fn is_image(&self) -> bool {
230        matches!(self, Self::Image(_))
231    }
232
233    /// Returns `true` if this is a `Control` variant.
234    pub fn is_control(&self) -> bool {
235        matches!(self, Self::Control(_))
236    }
237}
238
239impl std::fmt::Display for RunContent {
240    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
241        match self {
242            Self::Text(s) => {
243                if s.len() <= 50 {
244                    write!(f, "Text(\"{s}\")")
245                } else {
246                    let truncated: String = s.chars().take(50).collect();
247                    write!(f, "Text(\"{truncated}...\")")
248                }
249            }
250            Self::Table(t) => write!(f, "{t}"),
251            Self::Image(i) => write!(f, "{i}"),
252            Self::Control(c) => write!(f, "{c}"),
253        }
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260    use crate::image::ImageFormat;
261    use hwpforge_foundation::HwpUnit;
262
263    #[test]
264    fn run_text_constructor() {
265        let run = Run::text("Hello", CharShapeIndex::new(0));
266        assert_eq!(run.content.as_text(), Some("Hello"));
267        assert_eq!(run.char_shape_id, CharShapeIndex::new(0));
268    }
269
270    #[test]
271    fn run_text_from_string() {
272        let s = String::from("owned");
273        let run = Run::text(s, CharShapeIndex::new(1));
274        assert_eq!(run.content.as_text(), Some("owned"));
275    }
276
277    #[test]
278    fn run_table_constructor() {
279        let table = Table::new(vec![]);
280        let run = Run::table(table, CharShapeIndex::new(0));
281        assert!(run.content.is_table());
282        assert!(run.content.as_table().unwrap().is_empty());
283    }
284
285    #[test]
286    fn run_image_constructor() {
287        let img = Image::new("test.png", HwpUnit::ZERO, HwpUnit::ZERO, ImageFormat::Png);
288        let run = Run::image(img, CharShapeIndex::new(0));
289        assert!(run.content.is_image());
290        assert_eq!(run.content.as_image().unwrap().path, "test.png");
291    }
292
293    #[test]
294    fn run_control_constructor() {
295        let ctrl =
296            Control::Hyperlink { text: "link".to_string(), url: "https://example.com".to_string() };
297        let run = Run::control(ctrl, CharShapeIndex::new(0));
298        assert!(run.content.is_control());
299        assert!(run.content.as_control().unwrap().is_hyperlink());
300    }
301
302    // === RunContent type checks ===
303
304    #[test]
305    fn run_content_text_checks() {
306        let c = RunContent::Text("hi".to_string());
307        assert!(c.is_text());
308        assert!(!c.is_table());
309        assert!(!c.is_image());
310        assert!(!c.is_control());
311    }
312
313    #[test]
314    fn run_content_table_checks() {
315        let c = RunContent::Table(Box::new(Table::new(vec![])));
316        assert!(!c.is_text());
317        assert!(c.is_table());
318    }
319
320    #[test]
321    fn run_content_image_checks() {
322        let c =
323            RunContent::Image(Image::new("x.png", HwpUnit::ZERO, HwpUnit::ZERO, ImageFormat::Png));
324        assert!(!c.is_text());
325        assert!(c.is_image());
326    }
327
328    #[test]
329    fn run_content_control_checks() {
330        let c =
331            RunContent::Control(Box::new(Control::Unknown { tag: "x".to_string(), data: None }));
332        assert!(!c.is_text());
333        assert!(c.is_control());
334    }
335
336    // === Accessors return None for wrong variant ===
337
338    #[test]
339    fn as_text_returns_none_for_non_text() {
340        let c = RunContent::Table(Box::new(Table::new(vec![])));
341        assert!(c.as_text().is_none());
342    }
343
344    #[test]
345    fn as_table_returns_none_for_non_table() {
346        let c = RunContent::Text("hi".to_string());
347        assert!(c.as_table().is_none());
348    }
349
350    #[test]
351    fn as_image_returns_none_for_non_image() {
352        let c = RunContent::Text("hi".to_string());
353        assert!(c.as_image().is_none());
354    }
355
356    #[test]
357    fn as_control_returns_none_for_non_control() {
358        let c = RunContent::Text("hi".to_string());
359        assert!(c.as_control().is_none());
360    }
361
362    // === Display ===
363
364    #[test]
365    fn run_content_display_text_short() {
366        let c = RunContent::Text("hello".to_string());
367        assert_eq!(c.to_string(), "Text(\"hello\")");
368    }
369
370    #[test]
371    fn run_content_display_text_long_truncated() {
372        let long = "A".repeat(100);
373        let c = RunContent::Text(long);
374        let s = c.to_string();
375        assert!(s.contains(&"A".repeat(50)), "display: {s}");
376        assert!(s.ends_with("...\")"), "display: {s}");
377    }
378
379    #[test]
380    fn run_display() {
381        let run = Run::text("test", CharShapeIndex::new(0));
382        let s = run.to_string();
383        assert!(s.contains("Run("), "display: {s}");
384        assert!(s.contains("Text"), "display: {s}");
385    }
386
387    // === Empty text ===
388
389    #[test]
390    fn empty_text_run() {
391        let run = Run::text("", CharShapeIndex::new(0));
392        assert_eq!(run.content.as_text(), Some(""));
393    }
394
395    // === Korean text ===
396
397    #[test]
398    fn korean_text_run() {
399        let run = Run::text("안녕하세요", CharShapeIndex::new(0));
400        assert_eq!(run.content.as_text(), Some("안녕하세요"));
401    }
402
403    // === Equality ===
404
405    #[test]
406    fn run_equality() {
407        let a = Run::text("hello", CharShapeIndex::new(0));
408        let b = Run::text("hello", CharShapeIndex::new(0));
409        let c = Run::text("world", CharShapeIndex::new(0));
410        let d = Run::text("hello", CharShapeIndex::new(1));
411        assert_eq!(a, b);
412        assert_ne!(a, c);
413        assert_ne!(a, d);
414    }
415
416    // === Serde ===
417
418    #[test]
419    fn serde_roundtrip_text() {
420        let run = Run::text("test", CharShapeIndex::new(5));
421        let json = serde_json::to_string(&run).unwrap();
422        let back: Run = serde_json::from_str(&json).unwrap();
423        assert_eq!(run, back);
424    }
425
426    #[test]
427    fn serde_roundtrip_table() {
428        let run = Run::table(Table::new(vec![]), CharShapeIndex::new(0));
429        let json = serde_json::to_string(&run).unwrap();
430        let back: Run = serde_json::from_str(&json).unwrap();
431        assert_eq!(run, back);
432    }
433
434    #[test]
435    fn serde_roundtrip_image() {
436        let img = Image::new("test.png", HwpUnit::ZERO, HwpUnit::ZERO, ImageFormat::Png);
437        let run = Run::image(img, CharShapeIndex::new(0));
438        let json = serde_json::to_string(&run).unwrap();
439        let back: Run = serde_json::from_str(&json).unwrap();
440        assert_eq!(run, back);
441    }
442
443    #[test]
444    fn serde_roundtrip_control() {
445        let ctrl =
446            Control::Hyperlink { text: "link".to_string(), url: "https://example.com".to_string() };
447        let run = Run::control(ctrl, CharShapeIndex::new(0));
448        let json = serde_json::to_string(&run).unwrap();
449        let back: Run = serde_json::from_str(&json).unwrap();
450        assert_eq!(run, back);
451    }
452
453    // === Clone ===
454
455    #[test]
456    fn run_clone_independence() {
457        let run = Run::text("original", CharShapeIndex::new(0));
458        let cloned = run.clone();
459        assert_eq!(run, cloned);
460    }
461}