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