Skip to main content

hwpforge_core/
control.rs

1//! Control elements: text boxes, hyperlinks, footnotes, endnotes, etc.
2//!
3//! [`Control`] represents non-text inline elements within a document.
4//! The enum is `#[non_exhaustive]` so new control types can be added
5//! in future phases without a breaking change.
6//!
7//! TextBox, Footnote, and Endnote contain `Vec<Paragraph>` (recursive
8//! reference through the document tree). This is how HWP models inline
9//! frames and annotations.
10//!
11//! # Examples
12//!
13//! ```
14//! use hwpforge_core::control::Control;
15//! use hwpforge_core::paragraph::Paragraph;
16//! use hwpforge_foundation::{HwpUnit, ParaShapeIndex};
17//!
18//! let link = Control::Hyperlink {
19//!     text: "Click here".to_string(),
20//!     url: "https://example.com".to_string(),
21//! };
22//! assert!(link.is_hyperlink());
23//! ```
24
25use hwpforge_foundation::{
26    ArcType, ArrowSize, ArrowType, BookmarkType, Color, CurveSegmentType, DropCapStyle, FieldType,
27    Flip, GradientType, HwpUnit, ImageFillMode, PatternType, RefContentType, RefType,
28};
29use schemars::JsonSchema;
30use serde::{Deserialize, Serialize};
31
32use crate::caption::Caption;
33use crate::chart::{
34    BarShape, ChartData, ChartGrouping, ChartType, LegendPosition, OfPieType, RadarStyle,
35    ScatterStyle, StockVariant,
36};
37use crate::error::{CoreError, CoreResult};
38use crate::paragraph::Paragraph;
39use crate::run::Run;
40
41/// A 2D point in raw HWPUNIT coordinates for shape geometry.
42///
43/// Uses `i32` (not `HwpUnit`) because shape geometry points are raw
44/// coordinate values within a bounding box, not document-level measurements.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
46pub struct ShapePoint {
47    /// X coordinate (HWPUNIT).
48    pub x: i32,
49    /// Y coordinate (HWPUNIT).
50    pub y: i32,
51}
52
53impl ShapePoint {
54    /// Creates a new shape point with the given coordinates.
55    ///
56    /// # Examples
57    ///
58    /// ```
59    /// use hwpforge_core::control::ShapePoint;
60    ///
61    /// let pt = ShapePoint::new(100, 200);
62    /// assert_eq!(pt.x, 100);
63    /// assert_eq!(pt.y, 200);
64    /// ```
65    pub fn new(x: i32, y: i32) -> Self {
66        Self { x, y }
67    }
68}
69
70/// Line drawing style for shapes.
71///
72/// Controls how the stroke of a shape is rendered (solid, dashed, etc.).
73/// Maps to HWPX `<hc:lineShape>` `dash` attribute values.
74///
75/// # Examples
76///
77/// ```
78/// use hwpforge_core::control::LineStyle;
79///
80/// let style = LineStyle::Dash;
81/// assert_eq!(style.to_string(), "DASH");
82/// assert_eq!("DOT".parse::<LineStyle>().unwrap(), LineStyle::Dot);
83/// ```
84#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
85#[non_exhaustive]
86pub enum LineStyle {
87    /// Continuous solid line (default).
88    #[default]
89    Solid,
90    /// Dashed line.
91    Dash,
92    /// Dotted line.
93    Dot,
94    /// Alternating dash and dot.
95    DashDot,
96    /// Alternating dash, dot, dot.
97    DashDotDot,
98    /// No visible line.
99    None,
100}
101
102impl std::fmt::Display for LineStyle {
103    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104        match self {
105            Self::Solid => f.write_str("SOLID"),
106            Self::Dash => f.write_str("DASH"),
107            Self::Dot => f.write_str("DOT"),
108            Self::DashDot => f.write_str("DASH_DOT"),
109            Self::DashDotDot => f.write_str("DASH_DOT_DOT"),
110            Self::None => f.write_str("NONE"),
111        }
112    }
113}
114
115impl std::str::FromStr for LineStyle {
116    type Err = CoreError;
117
118    fn from_str(s: &str) -> Result<Self, Self::Err> {
119        match s {
120            "SOLID" | "Solid" | "solid" => Ok(Self::Solid),
121            "DASH" | "Dash" | "dash" => Ok(Self::Dash),
122            "DOT" | "Dot" | "dot" => Ok(Self::Dot),
123            "DASH_DOT" | "DashDot" | "dash_dot" => Ok(Self::DashDot),
124            "DASH_DOT_DOT" | "DashDotDot" | "dash_dot_dot" => Ok(Self::DashDotDot),
125            "NONE" | "None" | "none" => Ok(Self::None),
126            _ => Err(CoreError::InvalidStructure {
127                context: "LineStyle".to_string(),
128                reason: format!(
129                    "unknown line style '{s}', valid: SOLID, DASH, DOT, DASH_DOT, DASH_DOT_DOT, NONE"
130                ),
131            }),
132        }
133    }
134}
135
136/// Arrowhead style for line endpoints.
137///
138/// # Examples
139///
140/// ```
141/// use hwpforge_core::control::ArrowStyle;
142/// use hwpforge_foundation::{ArrowType, ArrowSize};
143///
144/// let arrow = ArrowStyle {
145///     arrow_type: ArrowType::Normal,
146///     size: ArrowSize::Medium,
147///     filled: true,
148/// };
149/// ```
150#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
151pub struct ArrowStyle {
152    /// Shape of the arrowhead.
153    pub arrow_type: ArrowType,
154    /// Size of the arrowhead.
155    pub size: ArrowSize,
156    /// Whether the arrowhead is filled (true) or outlined (false).
157    pub filled: bool,
158}
159
160/// Fill specification for shapes.
161///
162/// Replaces simple `fill_color` for shapes that need gradient, pattern, or image fills.
163///
164/// # Examples
165///
166/// ```
167/// use hwpforge_core::control::Fill;
168/// use hwpforge_foundation::Color;
169///
170/// let solid = Fill::Solid { color: Color::from_rgb(255, 0, 0) };
171/// ```
172#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
173#[non_exhaustive]
174pub enum Fill {
175    /// Solid color fill.
176    Solid {
177        /// Fill color.
178        color: Color,
179    },
180    /// Gradient fill.
181    Gradient {
182        /// Gradient direction type.
183        gradient_type: GradientType,
184        /// Gradient angle in degrees.
185        angle: i32,
186        /// Color stops: (color, position 0-100).
187        colors: Vec<(Color, u32)>,
188    },
189    /// Hatch pattern fill.
190    Pattern {
191        /// Pattern type.
192        pattern_type: PatternType,
193        /// Foreground pattern color.
194        fg_color: Color,
195        /// Background color.
196        bg_color: Color,
197    },
198    /// Image fill.
199    Image {
200        /// Image binary data reference ID.
201        image_id: String,
202        /// Image fill mode (tile, stretch, etc.).
203        mode: ImageFillMode,
204    },
205}
206
207/// Visual style overrides for drawing shapes.
208///
209/// All fields are `Option`; `None` means "use the encoder's default"
210/// (typically black solid border, white fill, 0.12 mm stroke).
211///
212/// # Examples
213///
214/// ```
215/// use hwpforge_core::control::{ShapeStyle, LineStyle};
216/// use hwpforge_foundation::Color;
217///
218/// let style = ShapeStyle {
219///     line_color: Some(Color::from_rgb(255, 0, 0)),
220///     fill_color: Some(Color::from_rgb(0, 255, 0)),
221///     line_width: Some(100),
222///     line_style: Some(LineStyle::Dash),
223///     ..Default::default()
224/// };
225/// ```
226#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
227pub struct ShapeStyle {
228    /// Stroke/border color (e.g. `Color::from_rgb(255, 0, 0)` for red).
229    pub line_color: Option<Color>,
230    /// Fill color (e.g. `Color::from_rgb(0, 255, 0)` for green).
231    /// For advanced fills (gradient, pattern, image), use the `fill` field instead.
232    pub fill_color: Option<Color>,
233    /// Stroke width in HWPUNIT (33 ≈ 0.12mm, 100 ≈ 0.35mm).
234    pub line_width: Option<u32>,
235    /// Line drawing style (solid, dash, dot, etc.).
236    pub line_style: Option<LineStyle>,
237    /// Rotation angle in degrees (0-360). `None` means no rotation.
238    pub rotation: Option<f32>,
239    /// Flip/mirror state. `None` means no flip.
240    pub flip: Option<Flip>,
241    /// Arrowhead at the start of a line. Only meaningful for `Control::Line`.
242    pub head_arrow: Option<ArrowStyle>,
243    /// Arrowhead at the end of a line. Only meaningful for `Control::Line`.
244    pub tail_arrow: Option<ArrowStyle>,
245    /// Advanced fill (gradient, pattern, image). Overrides `fill_color` when present.
246    pub fill: Option<Fill>,
247    /// Drop cap style for the shape (HWPX `dropcapstyle` attribute).
248    /// Controls whether the shape participates in a drop-cap layout.
249    #[serde(default)]
250    pub drop_cap_style: DropCapStyle,
251}
252
253/// An inline control element.
254///
255/// Controls are non-text elements that appear within a Run.
256/// Each variant carries its own data; the enum is `#[non_exhaustive]`
257/// for forward compatibility.
258///
259/// # Examples
260///
261/// ```
262/// use hwpforge_core::control::Control;
263/// use hwpforge_core::paragraph::Paragraph;
264/// use hwpforge_foundation::{HwpUnit, ParaShapeIndex};
265///
266/// let text_box = Control::TextBox {
267///     paragraphs: vec![Paragraph::new(ParaShapeIndex::new(0))],
268///     width: HwpUnit::from_mm(80.0).unwrap(),
269///     height: HwpUnit::from_mm(40.0).unwrap(),
270///     horz_offset: 0,
271///     vert_offset: 0,
272///     caption: None,
273///     style: None,
274/// };
275/// assert!(text_box.is_text_box());
276/// assert!(!text_box.is_hyperlink());
277/// ```
278#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
279#[non_exhaustive]
280pub enum Control {
281    /// An inline text box with its own paragraph content.
282    /// Maps to HWPX `<hp:rect>` + `<hp:drawText>` (drawing object, not control).
283    TextBox {
284        /// Paragraphs inside the text box.
285        paragraphs: Vec<Paragraph>,
286        /// Box width (HWPUNIT).
287        width: HwpUnit,
288        /// Box height (HWPUNIT).
289        height: HwpUnit,
290        /// Horizontal offset from anchor point (HWPUNIT, 0 = inline/treat-as-char).
291        horz_offset: i32,
292        /// Vertical offset from anchor point (HWPUNIT, 0 = inline/treat-as-char).
293        vert_offset: i32,
294        /// Optional caption attached to this text box.
295        caption: Option<Caption>,
296        /// Optional visual style overrides (border color, fill, line width).
297        style: Option<ShapeStyle>,
298    },
299
300    /// A hyperlink with display text and URL.
301    Hyperlink {
302        /// Visible text of the link.
303        text: String,
304        /// Target URL.
305        url: String,
306    },
307
308    /// A footnote containing paragraph content.
309    /// Maps to HWPX `<hp:ctrl><hp:footNote>`.
310    Footnote {
311        /// Instance identifier (unique ID for linking, optional).
312        inst_id: Option<u32>,
313        /// Paragraphs that form the footnote body.
314        paragraphs: Vec<Paragraph>,
315    },
316
317    /// An endnote containing paragraph content.
318    /// Maps to HWPX `<hp:ctrl><hp:endNote>`.
319    Endnote {
320        /// Instance identifier (unique ID for linking, optional).
321        inst_id: Option<u32>,
322        /// Paragraphs that form the endnote body.
323        paragraphs: Vec<Paragraph>,
324    },
325
326    /// A line drawing object (2 endpoints).
327    /// Maps to HWPX `<hp:line>`.
328    Line {
329        /// Start point (x, y in HWPUNIT).
330        start: ShapePoint,
331        /// End point (x, y in HWPUNIT).
332        end: ShapePoint,
333        /// Bounding box width (HWPUNIT).
334        width: HwpUnit,
335        /// Bounding box height (HWPUNIT).
336        height: HwpUnit,
337        /// Horizontal offset from anchor point (HWPUNIT, 0 = inline/treat-as-char).
338        horz_offset: i32,
339        /// Vertical offset from anchor point (HWPUNIT, 0 = inline/treat-as-char).
340        vert_offset: i32,
341        /// Optional caption attached to this line.
342        caption: Option<Caption>,
343        /// Optional visual style overrides (border color, fill, line width).
344        style: Option<ShapeStyle>,
345    },
346
347    /// An ellipse (or circle) drawing object.
348    /// Maps to HWPX `<hp:ellipse>`.
349    Ellipse {
350        /// Center point (x, y in HWPUNIT).
351        center: ShapePoint,
352        /// Axis 1 endpoint (defines semi-major axis direction and length).
353        axis1: ShapePoint,
354        /// Axis 2 endpoint (perpendicular to axis1, defines semi-minor axis).
355        axis2: ShapePoint,
356        /// Bounding box width (HWPUNIT).
357        width: HwpUnit,
358        /// Bounding box height (HWPUNIT).
359        height: HwpUnit,
360        /// Horizontal offset from anchor point (HWPUNIT, 0 = inline/treat-as-char).
361        horz_offset: i32,
362        /// Vertical offset from anchor point (HWPUNIT, 0 = inline/treat-as-char).
363        vert_offset: i32,
364        /// Optional text content inside the ellipse.
365        paragraphs: Vec<Paragraph>,
366        /// Optional caption attached to this ellipse.
367        caption: Option<Caption>,
368        /// Optional visual style overrides (border color, fill, line width).
369        style: Option<ShapeStyle>,
370    },
371
372    /// A HWP5 chart carried as opaque OOXML + OLE blob passthrough.
373    ///
374    /// Used when chart data is extracted from a HWP5 BinData OLE container
375    /// and emitted to HWPX without round-tripping through the structured
376    /// [`Control::Chart`] data model. Renders in 한컴 via the `<hp:switch>`
377    /// block with full OOXML chart inside `<hp:case>` and an OLE fallback
378    /// inside `<hp:default>`.
379    ///
380    /// Wave 4c passthrough: the chart XML and OLE bytes are carried as-is
381    /// from the source HWP5 file. The encoder writes:
382    /// - `Chart/chartN.xml` (NOT registered in manifest — gotcha #5)
383    /// - `BinData/oleN.ole` (registered in `content.hpf` as `application/ole`)
384    /// - section `<hp:switch>` with `<hp:case>` chart + `<hp:default>` ole
385    EmbeddedChart {
386        /// Full OOXML chart XML (starts with `<?xml`, contains `<c:chartSpace>`).
387        chart_xml: String,
388        /// Raw OLE2 compound file bytes for `<hp:ole>` fallback rendering.
389        ole_bytes: Vec<u8>,
390        /// Chart width (HWPUNIT).
391        width: HwpUnit,
392        /// Chart height (HWPUNIT).
393        height: HwpUnit,
394        /// Horizontal offset from anchor point (HWPUNIT, 0 = inline/treat-as-char).
395        horz_offset: i32,
396        /// Vertical offset from anchor point (HWPUNIT, 0 = inline/treat-as-char).
397        vert_offset: i32,
398    },
399
400    /// A pure rectangle drawing object (no embedded text).
401    ///
402    /// Distinct from [`Control::TextBox`], which uses `<hp:rect>` with a
403    /// `<hp:drawText>` child for inline text. A pure `Rect` carries only the
404    /// rectangle geometry and visual style and emits `<hp:rect>` without
405    /// `<hp:drawText>`.
406    Rect {
407        /// Bounding box width (HWPUNIT).
408        width: HwpUnit,
409        /// Bounding box height (HWPUNIT).
410        height: HwpUnit,
411        /// Horizontal offset from anchor point (HWPUNIT, 0 = inline/treat-as-char).
412        horz_offset: i32,
413        /// Vertical offset from anchor point (HWPUNIT, 0 = inline/treat-as-char).
414        vert_offset: i32,
415        /// Optional caption attached to this rectangle.
416        caption: Option<Caption>,
417        /// Optional visual style overrides (border color, fill, line width).
418        style: Option<ShapeStyle>,
419    },
420
421    /// A polygon drawing object (3+ vertices).
422    /// Maps to HWPX `<hp:polygon>`.
423    Polygon {
424        /// Ordered list of vertices (minimum 3).
425        vertices: Vec<ShapePoint>,
426        /// Bounding box width (HWPUNIT).
427        width: HwpUnit,
428        /// Bounding box height (HWPUNIT).
429        height: HwpUnit,
430        /// Horizontal offset from anchor point (HWPUNIT, 0 = inline/treat-as-char).
431        horz_offset: i32,
432        /// Vertical offset from anchor point (HWPUNIT, 0 = inline/treat-as-char).
433        vert_offset: i32,
434        /// Optional text content inside the polygon.
435        paragraphs: Vec<Paragraph>,
436        /// Optional caption attached to this polygon.
437        caption: Option<Caption>,
438        /// Optional visual style overrides (border color, fill, line width).
439        style: Option<ShapeStyle>,
440    },
441
442    /// An inline equation (수식) using HancomEQN script format.
443    /// Maps to HWPX `<hp:equation>` with `<hp:script>` child.
444    ///
445    /// Equations have NO shape common block (no offset, orgSz, curSz, flip,
446    /// rotation, lineShape, fillBrush, shadow). Only sz + pos + outMargin + script.
447    Equation {
448        /// HancomEQN script text (e.g. `"{a+b} over {c+d}"`).
449        script: String,
450        /// Bounding box width (HWPUNIT).
451        width: HwpUnit,
452        /// Bounding box height (HWPUNIT).
453        height: HwpUnit,
454        /// Baseline position (51-90 typical range).
455        base_line: u32,
456        /// Text color.
457        text_color: Color,
458        /// Font name (typically `"HancomEQN"`).
459        font: String,
460        /// Wave 12p Step 2c: instance ID for cross-ref target lookup.
461        /// HWP5 변환 시 `eqed` CtrlHeader trailer 의 instance ID 가
462        /// 채워지고, HWPX encoder 가 `<hp:equation id="...">` attribute
463        /// 로 emit. `None` 이면 encoder fallback 허용.
464        inst_id: Option<u64>,
465    },
466
467    /// An OOXML chart embedded in the document.
468    /// Maps to HWPX `<hp:switch><hp:case><hp:chart>` with separate Chart XML file.
469    ///
470    /// Charts have NO shape common block (like Equation): only sz + pos + outMargin.
471    Chart {
472        /// Chart type (18 variants covering all OOXML chart types).
473        chart_type: ChartType,
474        /// Chart data (category-based or XY-based).
475        data: ChartData,
476        /// Chart width (HWPUNIT, default ~32250 ≈ 114mm).
477        width: HwpUnit,
478        /// Chart height (HWPUNIT, default ~18750 ≈ 66mm).
479        height: HwpUnit,
480        /// Optional chart title.
481        title: Option<String>,
482        /// Legend position.
483        legend: LegendPosition,
484        /// Series grouping mode.
485        grouping: ChartGrouping,
486        /// 3D bar/column shape (None = default Box).
487        bar_shape: Option<BarShape>,
488        /// Exploded pie/doughnut percentage (None = not exploded, Some(25) = 25% explosion).
489        explosion: Option<u32>,
490        /// Pie-of-pie or bar-of-pie sub-type (None = default pie-of-pie).
491        of_pie_type: Option<OfPieType>,
492        /// Radar chart rendering style (None = default Standard).
493        radar_style: Option<RadarStyle>,
494        /// Surface chart wireframe mode (None = default solid).
495        wireframe: Option<bool>,
496        /// 3D bubble effect (None = default flat).
497        bubble_3d: Option<bool>,
498        /// Scatter chart style (None = default Dots).
499        scatter_style: Option<ScatterStyle>,
500        /// Show data point markers on line charts (None = no markers).
501        show_markers: Option<bool>,
502        /// Stock chart sub-variant (None = default HLC, 3 series).
503        ///
504        /// VHLC and VOHLC generate a composite `<c:plotArea>` with both
505        /// `<c:barChart>` (volume) and `<c:stockChart>` (price) elements.
506        stock_variant: Option<StockVariant>,
507    },
508
509    /// Dutmal (덧말): annotation text displayed above or below main text.
510    /// Maps to HWPX `<hp:dutmal>`.
511    Dutmal {
512        /// Main text that receives the annotation.
513        main_text: String,
514        /// Annotation text displayed above/below.
515        sub_text: String,
516        /// Position of the annotation relative to main text.
517        position: DutmalPosition,
518        /// Size ratio of annotation text relative to main (0 = auto).
519        sz_ratio: u32,
520        /// Alignment of the annotation text.
521        align: DutmalAlign,
522        /// Optional metadata that mirrors HWPX `<hp:dutmal>` attributes
523        /// HwpForge doesn't promote to typed fields yet — currently
524        /// carries `option` verbatim so HWP5↔HWPX round-trips preserve
525        /// it. `#[non_exhaustive]` so future fields are additive.
526        metadata: DutmalMetadata,
527    },
528
529    /// Compose (글자겹침): overlaid/combined characters.
530    /// Maps to HWPX `<hp:compose>`.
531    Compose {
532        /// The combined text (e.g. "12" for two overlaid digits).
533        compose_text: String,
534        /// Circle/frame type for the composition.
535        circle_type: String,
536        /// Character size adjustment (-3 = slightly smaller).
537        char_sz: i32,
538        /// Composition layout type.
539        compose_type: String,
540        /// 10 `<hp:charPr prIDRef="N"/>` references (HWPX `charPrCnt` is
541        /// fixed at 10). `u32::MAX` is the "no override" sentinel —
542        /// 한컴 emits it for unused slots. A `Vec` shorter or longer
543        /// than 10 is normalized by the HWPX encoder (pad / truncate).
544        char_pr_ids: Vec<u32>,
545    },
546
547    /// An arc (partial ellipse) drawing object.
548    /// Maps to HWPX `<hp:ellipse>` with `hasArcPr="1"`.
549    Arc {
550        /// Arc type (normal open arc, pie/sector, chord).
551        arc_type: ArcType,
552        /// Center point of the parent ellipse.
553        center: ShapePoint,
554        /// Axis 1 endpoint (semi-major axis).
555        axis1: ShapePoint,
556        /// Axis 2 endpoint (semi-minor axis).
557        axis2: ShapePoint,
558        /// Arc start point 1.
559        start1: ShapePoint,
560        /// Arc end point 1.
561        end1: ShapePoint,
562        /// Arc start point 2.
563        start2: ShapePoint,
564        /// Arc end point 2.
565        end2: ShapePoint,
566        /// Bounding box width (HWPUNIT).
567        width: HwpUnit,
568        /// Bounding box height (HWPUNIT).
569        height: HwpUnit,
570        /// Horizontal offset from anchor point (HWPUNIT, 0 = inline/treat-as-char).
571        horz_offset: i32,
572        /// Vertical offset from anchor point (HWPUNIT, 0 = inline/treat-as-char).
573        vert_offset: i32,
574        /// Optional caption attached to this arc.
575        caption: Option<Caption>,
576        /// Optional visual style overrides.
577        style: Option<ShapeStyle>,
578    },
579
580    /// A curve drawing object (bezier/polyline).
581    /// Maps to HWPX `<hp:curve>`.
582    Curve {
583        /// Ordered control points for the curve path.
584        points: Vec<ShapePoint>,
585        /// Segment types (one per segment between points).
586        segment_types: Vec<CurveSegmentType>,
587        /// Bounding box width (HWPUNIT).
588        width: HwpUnit,
589        /// Bounding box height (HWPUNIT).
590        height: HwpUnit,
591        /// Horizontal offset from anchor point (HWPUNIT, 0 = inline/treat-as-char).
592        horz_offset: i32,
593        /// Vertical offset from anchor point (HWPUNIT, 0 = inline/treat-as-char).
594        vert_offset: i32,
595        /// Optional caption attached to this curve.
596        caption: Option<Caption>,
597        /// Optional visual style overrides.
598        style: Option<ShapeStyle>,
599    },
600
601    /// A connect line drawing object (line with control points for routing).
602    /// Maps to HWPX `<hp:connectLine>`.
603    ConnectLine {
604        /// Start point of the connect line.
605        start: ShapePoint,
606        /// End point of the connect line.
607        end: ShapePoint,
608        /// Intermediate control points for routing.
609        control_points: Vec<ShapePoint>,
610        /// Connect line type (e.g. "STRAIGHT", "BENT", "CURVED").
611        connect_type: String,
612        /// Bounding box width (HWPUNIT).
613        width: HwpUnit,
614        /// Bounding box height (HWPUNIT).
615        height: HwpUnit,
616        /// Horizontal offset from anchor point (HWPUNIT, 0 = inline/treat-as-char).
617        horz_offset: i32,
618        /// Vertical offset from anchor point (HWPUNIT, 0 = inline/treat-as-char).
619        vert_offset: i32,
620        /// Optional caption attached to this connect line.
621        caption: Option<Caption>,
622        /// Optional visual style overrides.
623        style: Option<ShapeStyle>,
624    },
625
626    /// A group of drawing objects (묶음 객체 / 개체 묶기).
627    /// Maps to HWPX `<hp:container>` and HWP5 `gso` → `ShapeComponent` with
628    /// the `"$con"` type tag wrapping child `ShapeComponent`s.
629    ///
630    /// `children` reuses the shape `Control` variants (`Rect`/`Ellipse`/
631    /// `Line`/`Polygon`/`Curve`/`ConnectLine`/`Image`/`EmbeddedChart` and,
632    /// recursively, `Group`). Non-shape variants are rejected by
633    /// `validate` rather than the type system, matching how every other
634    /// recursive container (`TextBox`/`Footnote`/`Memo.content`) carries a
635    /// loose `Vec<Paragraph>` / `Vec<Run>`.
636    Group {
637        /// Child drawing objects, in z-order. May nest further `Group`s.
638        children: Vec<Control>,
639        /// Bounding box width (HWPUNIT).
640        width: HwpUnit,
641        /// Bounding box height (HWPUNIT).
642        height: HwpUnit,
643        /// Horizontal offset from anchor point (HWPUNIT, 0 = inline/treat-as-char).
644        horz_offset: i32,
645        /// Vertical offset from anchor point (HWPUNIT, 0 = inline/treat-as-char).
646        vert_offset: i32,
647        /// HWP5 ParaHeader / GSO trailer instance ID, mirrored to the
648        /// HWPX `<hp:container instid>` attribute. `None` = not carried.
649        inst_id: Option<u64>,
650    },
651
652    /// A TextArt (글맵시) decorative warped-text object.
653    /// Maps to HWPX `<hp:textart>` with `<hp:textartPr>`.
654    ///
655    /// TextArt warps a short string into a shape (wave, arch, circle, …) and
656    /// renders it as a drawing object. The HWP5 wire stores `shape` as an
657    /// integer enum (`0..=54`); the HWPX wire stores it as a string name
658    /// (e.g. `"WAVE2"`). This carries the HWPX string form directly.
659    TextArt {
660        /// The displayed text content.
661        text: String,
662        /// HWPX `textShape` name (e.g. `"WAVE2"`). One of 55 known shapes.
663        shape: String,
664        /// Font family name (e.g. `"함초롬바탕"`).
665        font_name: String,
666        /// Font style label (e.g. `"보통"`).
667        font_style: String,
668        /// HWPX `align` value within the textart (e.g. `"LEFT"`).
669        align: String,
670        /// Line spacing (percent, HWPX `lineSpacing`).
671        line_spacing: u32,
672        /// Character spacing (percent, HWPX `charSpacing`).
673        char_spacing: u32,
674        /// Bounding box width (HWPUNIT).
675        width: HwpUnit,
676        /// Bounding box height (HWPUNIT).
677        height: HwpUnit,
678        /// Horizontal offset from anchor point (HWPUNIT, 0 = inline/treat-as-char).
679        horz_offset: i32,
680        /// Vertical offset from anchor point (HWPUNIT, 0 = inline/treat-as-char).
681        vert_offset: i32,
682        /// Fill color (the `<hc:winBrush faceColor>` of the textart glyphs).
683        /// `None` = no explicit fill carried.
684        fill_color: Option<Color>,
685        /// HWP5 GSO trailer instance ID, mirrored to HWPX `<hp:textart instid>`.
686        /// `None` = not carried.
687        inst_id: Option<u64>,
688    },
689
690    /// A bookmark marking a named location in the document.
691    /// Maps to HWPX `<hp:ctrl><hp:bookmark>` (point) or `fieldBegin/fieldEnd type="BOOKMARK"` (span).
692    Bookmark {
693        /// Bookmark name (unique within the document).
694        name: String,
695        /// Type: point bookmark or span start/end.
696        bookmark_type: BookmarkType,
697    },
698
699    /// A cross-reference (상호참조) to a bookmark, footnote, endnote,
700    /// outline heading, table/figure/equation caption.
701    ///
702    /// Maps to HWPX `fieldBegin type="CROSSREF"` with parameters.
703    ///
704    /// Wave 12m Phase 2: `target_name: String` 가 `target: RefTarget` 로
705    /// 변경 (breaking). 책갈피 이름과 한컴 자동 ID (#<id>) 가 타입으로
706    /// 구분되어 caller 가 의미를 정확히 알 수 있음.
707    ///
708    /// Wave 12m Phase 2 Step 4 (breaking): `display_text: String` 추가.
709    /// HWPX wire 는 `<hp:fieldBegin>` 과 `<hp:fieldEnd>` 사이의 visible run
710    /// 으로 display text 를 embedding 한다. HWP5 `%xrf` wire 는 display
711    /// text 를 직접 carry 하지 않고 ParaText 본문에 풀어 두지만, projection
712    /// 이 FieldBegin..FieldEnd span 을 읽어 이 필드에 채워 넣는다.
713    /// 빈 문자열은 "display text 없음" 의미 (Hyperlink::text 와 동일).
714    CrossRef {
715        /// Reference target — Bookmark name (`Name(String)`) or system
716        /// id (`SystemId(u64)`) or unparseable raw (`Raw(String)`).
717        target: RefTarget,
718        /// What kind of target is being referenced.
719        ref_type: RefType,
720        /// What content to display at the reference site.
721        content_type: RefContentType,
722        /// Whether to render the reference as a clickable hyperlink.
723        as_hyperlink: bool,
724        /// Visible body text shown between `fieldBegin` and `fieldEnd`
725        /// in the encoded wire. HWP5 sources this from the FieldBegin
726        /// span; native builders may leave this empty.
727        display_text: String,
728    },
729
730    /// A press-field (누름틀) — an interactive form field.
731    /// Maps to HWPX `fieldBegin type="CLICK_HERE"` with parameters and `metaTag`.
732    Field {
733        /// Field type (ClickHere, Date, Time, etc.).
734        field_type: FieldType,
735        /// Hint/visible text shown in the field placeholder.
736        hint_text: Option<String>,
737        /// Help text shown when hovering or clicking the field.
738        help_text: Option<String>,
739        /// Form-mode identifier used to reference the field programmatically.
740        /// Maps to HWPX `fieldBegin name="..."` attribute. `None` represents
741        /// the empty string convention (한컴 wire stores it as a 0-length BSTR).
742        name: Option<String>,
743        /// Cached resolved value rendered between `<hp:fieldBegin>` and
744        /// `<hp:fieldEnd>` (e.g. the author name, the locale-formatted date).
745        /// HWP5 sources this from the FieldBegin..FieldEnd span; 한컴 native
746        /// HWPX carries the same cached render and recomputes it on save.
747        /// Empty string = "no cached value" (same convention as
748        /// [`Self::CrossRef::display_text`] / [`Self::Hyperlink::text`]).
749        /// For `ClickHere` this stays empty — its visible placeholder is
750        /// [`Self::Field::hint_text`], not a cached render.
751        ///
752        /// An empty body triggers 한컴's "낮은 보안 수준 복구" warning on
753        /// open for SUMMERY fields (#120/#136) — carrying the verbatim
754        /// source value avoids it.
755        display_text: String,
756    },
757
758    /// A memo (메모) annotation attached to text.
759    ///
760    /// Maps to HWPX `fieldBegin type="MEMO"` + anchor body runs +
761    /// `fieldEnd` flat inside one `<hp:run>`. The memo's body lives inside
762    /// `fieldBegin`'s `<hp:subList>`; the *anchor* runs sit between
763    /// `fieldBegin` and `fieldEnd` so 한컴 can pair the markers and render
764    /// the `[메모 시작]…[메모 끝]` UI labels instead of generic
765    /// `[메모 시작]…[필드 끝]`.
766    ///
767    /// Wave 12e: `author`/`date` fields removed (no wire path populated
768    /// them).
769    ///
770    /// Wave 12f: `anchor_runs` added. Without it, the encoder produced an
771    /// empty `<hp:t/>` between `fieldBegin` and `fieldEnd`, which 한컴 reads
772    /// as an unpaired field — visible bug.
773    Memo {
774        /// Paragraphs forming the memo body content (rendered in
775        /// `<hp:subList>`).
776        content: Vec<Paragraph>,
777        /// Runs that form the visible *anchor* text — the body span the memo
778        /// is attached to. Encoders interleave these between `fieldBegin`
779        /// and `fieldEnd` inside one `<hp:run>`. Should normally hold only
780        /// `RunContent::Text`; other variants are downgraded by the encoder
781        /// with a warning (memos cannot anchor on tables/images/nested
782        /// controls in HWPX).
783        anchor_runs: Vec<Run>,
784        /// HWPX `<hp:parameters>` for the memo. Carrying these as a
785        /// dedicated [`MemoMetadata`] (instead of half-empty hard-coded
786        /// values) keeps the metadata format-agnostic — encoders for HWPX
787        /// (and any future format with similar metadata) consume the same
788        /// struct.
789        metadata: MemoMetadata,
790    },
791
792    /// An index mark for building a document index (찾아보기).
793    /// Maps to HWPX `<hp:ctrl><hp:indexmark>`.
794    IndexMark {
795        /// Primary index key (required).
796        primary: String,
797        /// Secondary (sub-entry) index key.
798        secondary: Option<String>,
799    },
800
801    /// An unknown SUMMERY (`%smr`) `$token` carried verbatim for forward
802    /// compatibility (Wave 12n).
803    ///
804    /// Wave 12n only models the five HwpForge-observed tokens (`$author`,
805    /// `$lastsaveby`, `$createtime`, `$modifiedtime`, `$title`) as typed
806    /// [`FieldType`] variants. Any other `%smr` Command (e.g. additional
807    /// 한컴 metadata tokens not yet measured) is preserved here instead of
808    /// being silently coerced to `ClickHere`.
809    UnknownSummery {
810        /// Raw `Command` string after envelope (e.g. `"$company"`).
811        token: String,
812        /// Cached resolved value rendered between `fieldBegin`/`fieldEnd`.
813        /// Same semantics as [`Self::Field::display_text`]; empty = none.
814        display_text: String,
815    },
816
817    /// A `%dte` date/time **format-pattern** field (Wave 12n).
818    ///
819    /// HWP5 family `%dte` (ctrl_id `0x2564_7465`) used by 한컴
820    /// `입력 → 날짜/시간/파일 이름 → 날짜/시간 코드` menu. Unlike SUMMERY
821    /// (which carries semantic tokens like `$createtime`), `%dte` carries
822    /// a raw format pattern string (e.g. `"\:1년 2월 3일 (6);0;"` for date,
823    /// `"T\:;0;"` for time-only). The grammar is under-measured so the
824    /// raw command is preserved verbatim; `is_time_mode` is a derived
825    /// helper based on the `T` prefix.
826    DateCodeField {
827        /// Full Command string from the wire, including the `T` prefix
828        /// for time mode and the `\:format;options;` body.
829        raw_command: String,
830        /// Helper view: `true` when [`Self::DateCodeField::raw_command`]
831        /// starts with `T` (time-only format).
832        is_time_mode: bool,
833        /// Opaque 8-byte trailer (instance ID + flags) carried for
834        /// round-trip fidelity. The semantics are not pinned down.
835        raw_trailer: [u8; 8],
836        /// Cached resolved value rendered between `fieldBegin`/`fieldEnd`
837        /// (the locale-formatted date/time string). Same semantics as
838        /// [`Self::Field::display_text`]; empty = none.
839        display_text: String,
840    },
841
842    /// A `%pat` path / file-name field (Wave 12n).
843    ///
844    /// HWP5 family `%pat` (ctrl_id `0x2570_6174`) emitted by 한컴
845    /// `상용구 → 파일 이름 / 파일 이름과 경로`. Uses `$P` (path) and
846    /// `$F` (file name) format codes.
847    PathField {
848        /// Typed variant of the observed `Command` pattern.
849        command: PathFieldCommand,
850        /// Cached resolved value rendered between `fieldBegin`/`fieldEnd`
851        /// (the absolute path/file name 한컴 last evaluated). Same semantics
852        /// as [`Self::Field::display_text`]; empty = none. 한컴 recomputes
853        /// `$P`/`$F` against the file's on-disk path on save, but an empty
854        /// body on open triggers the recovery warning (#120).
855        display_text: String,
856    },
857
858    /// An `atno` **inline** page number control (Wave 12n).
859    ///
860    /// HWP5 family `atno` (ctrl_id `0x6174_6E6F`) used by 한컴
861    /// `상용구 → 현재 쪽 번호 / 전체 쪽수 / 현재 쪽/전체 쪽수`. Distinct
862    /// from `pgnp` (section-level page numbering control already modeled
863    /// as `Section.page_number`). Inline `atno` renders to HWPX
864    /// `<hp:autoNum>` inside a `<hp:run>`.
865    ///
866    /// The 16-byte wire envelope carries a single 4-byte flag that
867    /// distinguishes current-page from total-pages.
868    InlinePageNumber {
869        /// Typed variant of the observed `flag` byte.
870        kind: InlinePageKind,
871        /// Raw `flag` bytes (LE u32) carried for unknown values.
872        raw_flag: u32,
873    },
874
875    /// An unrecognized control element preserved for round-trip fidelity.
876    ///
877    /// `tag` holds the element's tag name or type identifier.
878    /// `data` holds optional serialized content for lossless preservation.
879    Unknown {
880        /// Tag name or type identifier of the unrecognized element.
881        tag: String,
882        /// Optional serialized data for round-trip preservation.
883        data: Option<String>,
884    },
885}
886
887/// Typed variant of the HWP5 `%pat` Command string (Wave 12n).
888///
889/// Observed forms are `$F` (file name only), `$P` (path only), and `$P$F`
890/// (full path). Anything else is preserved as [`PathFieldCommand::Unknown`].
891#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
892#[non_exhaustive]
893pub enum PathFieldCommand {
894    /// `$F` — file name only.
895    FileName,
896    /// `$P` — folder path only (no file name).
897    Path,
898    /// `$P$F` — full path including file name.
899    PathAndFileName,
900    /// Any other Command form, preserved verbatim.
901    Unknown(String),
902}
903
904impl PathFieldCommand {
905    /// Returns the canonical wire Command string for typed variants.
906    /// For [`Self::Unknown`], returns the carried raw string.
907    pub fn wire_command(&self) -> &str {
908        match self {
909            Self::FileName => "$F",
910            Self::Path => "$P",
911            Self::PathAndFileName => "$P$F",
912            Self::Unknown(s) => s.as_str(),
913        }
914    }
915
916    /// Parses a wire Command string into a typed variant. Unknown forms
917    /// are preserved as [`Self::Unknown`].
918    pub fn from_wire(cmd: &str) -> Self {
919        match cmd {
920            "$F" => Self::FileName,
921            "$P" => Self::Path,
922            "$P$F" => Self::PathAndFileName,
923            other => Self::Unknown(other.to_string()),
924        }
925    }
926}
927
928/// Cross-reference target identifier (Wave 12m Phase 2).
929///
930/// Replaces the previous `target_name: String` field on
931/// [`Control::CrossRef`] with a type-safe enum that distinguishes the
932/// three observed wire forms in HWP5 `%xrf` Command strings:
933///
934/// - **`Name(String)`** — 사용자 지정 책갈피 이름 (Bookmark 전용).
935///   한컴 fixture 의 `?target1;6;0;0;0;` 형식에서 `target1` 부분.
936/// - **`SystemId(u64)`** — 한컴 자동 생성 정수 ID (Footnote / Endnote /
937///   Caption / Outline). 한컴 fixture 의 `?#1108165575;3;0;0;0;` 형식에서
938///   `#` 접두사를 떼고 정수로 파싱한 값.
939/// - **`Raw(String)`** — 위 두 형식 어느쪽으로도 파싱이 안 된 wire 값.
940///   silent fallback 위조 금지 원칙 (Codex(architect)) — 의미를
941///   모르면 raw 보존.
942///
943/// 변환은 `smithy-hwp5/src/projection.rs::decode_hwp5_target` 의
944/// boundary 함수에서 수행 (Core 는 wire-agnostic).
945#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
946#[non_exhaustive]
947pub enum RefTarget {
948    /// 사용자 지정 책갈피 이름 (Bookmark 전용).
949    Name(String),
950    /// 한컴 자동 생성 정수 ID (Footnote / Endnote / Caption / Outline).
951    SystemId(u64),
952    /// 위 형식 어느쪽으로도 정규화되지 않은 raw 값 — fallback 위조 금지.
953    Raw(String),
954}
955
956impl RefTarget {
957    /// Returns the displayable name for this target.
958    ///
959    /// - `Name(s)` → `s`
960    /// - `SystemId(id)` → `"#{id}"` 형식 (한컴 wire 와 동일)
961    /// - `Raw(s)` → `s`
962    pub fn as_display(&self) -> String {
963        match self {
964            Self::Name(s) => s.clone(),
965            Self::SystemId(id) => format!("#{id}"),
966            Self::Raw(s) => s.clone(),
967        }
968    }
969}
970
971/// Typed variant of the HWP5 `atno` inline page-number `flag` byte
972/// (Wave 12n).
973///
974/// Observed values: `0x00` = current page, `0x06` = total page count.
975/// Other values surface as [`InlinePageKind::Unknown`].
976#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
977#[non_exhaustive]
978pub enum InlinePageKind {
979    /// Flag `0x00` — current page number.
980    CurrentPage,
981    /// Flag `0x06` — total page count.
982    TotalPages,
983    /// Any other flag value, carried verbatim via
984    /// [`Control::InlinePageNumber::raw_flag`].
985    Unknown,
986}
987
988impl InlinePageKind {
989    /// Returns the canonical raw flag value for typed variants.
990    /// For [`Self::Unknown`], callers must use the
991    /// [`Control::InlinePageNumber::raw_flag`] field directly.
992    pub fn raw_flag(self) -> Option<u32> {
993        match self {
994            Self::CurrentPage => Some(0x00),
995            Self::TotalPages => Some(0x06),
996            Self::Unknown => None,
997        }
998    }
999
1000    /// Maps an observed raw flag value to a typed variant.
1001    pub fn from_raw_flag(flag: u32) -> Self {
1002        match flag {
1003            0x00 => Self::CurrentPage,
1004            0x06 => Self::TotalPages,
1005            _ => Self::Unknown,
1006        }
1007    }
1008}
1009
1010/// Metadata associated with a memo annotation (HWPX `<hp:parameters>`).
1011///
1012/// Carries the seven HWPX memo parameters (`Prop`, `Command`, `ID`, `Number`,
1013/// `Author`, `MemoShapeIDRef`, `CreateDateTime`). Empty / default values are
1014/// rendered by encoders as sensible defaults: `Prop` is always `0` on
1015/// 한컴-authored fixtures so the field is omitted on the Core side, and
1016/// `CreateDateTime` is auto-generated at encode time if blank (a 한컴-native
1017/// memo always has a timestamp).
1018///
1019/// Other format encoders (Markdown, future ODT) can ignore the
1020/// HWPX-specific fields and just use `author` if useful.
1021#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1022#[non_exhaustive]
1023pub struct MemoMetadata {
1024    /// Reference to the `MemoShape` table entry. 한컴 default is `65535`.
1025    pub shape_id_ref: u32,
1026    /// Memo number (`<hp:integerParam name="Number">`). Equals the HWP5
1027    /// `memo_id`. Encoders normally also derive the HWPX `ID` parameter
1028    /// from this value (`"memo{number}"`).
1029    pub number: u32,
1030    /// HWPX `ID` parameter (typically `"memo{number}"`). Encoders may
1031    /// auto-derive from `number` when blank.
1032    pub id: String,
1033    /// Memo author (`Author` parameter). Empty when unknown.
1034    pub author: String,
1035    /// HWPX `CreateDateTime` parameter (ISO 8601 UTC). Empty triggers
1036    /// encoder-side auto-generation at write time.
1037    pub create_datetime: String,
1038    /// Raw wire `Command` parameter (`"MEMO/{shape}/{number}/…"` from HWP5).
1039    /// Empty for HwpForge-authored memos — encoders synthesise a minimum
1040    /// `MEMO/{shape}/{number}/` form in that case.
1041    pub command: String,
1042}
1043
1044impl Default for MemoMetadata {
1045    fn default() -> Self {
1046        Self {
1047            shape_id_ref: 65535,
1048            number: 0,
1049            id: String::new(),
1050            author: String::new(),
1051            create_datetime: String::new(),
1052            command: String::new(),
1053        }
1054    }
1055}
1056
1057impl MemoMetadata {
1058    /// Returns the HWPX `ID` parameter, deriving `"memo{number}"` when the
1059    /// explicit field is blank.
1060    pub fn hwpx_id(&self) -> String {
1061        if self.id.is_empty() {
1062            format!("memo{}", self.number)
1063        } else {
1064            self.id.clone()
1065        }
1066    }
1067}
1068
1069/// Wire-mirrored metadata attached to a `Control::Dutmal`.
1070///
1071/// Carries HWPX `<hp:dutmal>` attributes that HwpForge does not yet
1072/// model as typed fields — currently just `option`. Field is mirrored
1073/// verbatim from HWP5 wire / HWPX `option=` attribute and emitted
1074/// verbatim on encode so round-trips preserve the value even when the
1075/// semantics aren't pinned down (see
1076/// `.docs/algorithms/2026-06-01_dutmal_carry.md`).
1077///
1078/// `#[non_exhaustive]` — additions like `style_id_ref` or the two
1079/// reserved tail words are additive and don't break existing
1080/// destructure / pattern-match sites.
1081#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
1082#[non_exhaustive]
1083pub struct DutmalMetadata {
1084    /// `<hp:dutmal option=…>` value mirrored verbatim. Likely a
1085    /// bit-field or enum on the HWPX side; HwpForge treats it as an
1086    /// opaque u32 until the spec is confirmed.
1087    pub option: u32,
1088}
1089
1090/// Position of dutmal annotation text relative to the main text.
1091#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
1092#[non_exhaustive]
1093pub enum DutmalPosition {
1094    /// Annotation above main text (default).
1095    #[default]
1096    Top,
1097    /// Annotation below main text.
1098    Bottom,
1099    /// Annotation to the right.
1100    Right,
1101    /// Annotation to the left.
1102    Left,
1103}
1104
1105/// Alignment of dutmal annotation text.
1106#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
1107#[non_exhaustive]
1108pub enum DutmalAlign {
1109    /// Center-aligned (default).
1110    #[default]
1111    Center,
1112    /// Left-aligned.
1113    Left,
1114    /// Right-aligned.
1115    Right,
1116}
1117
1118impl Control {
1119    /// Returns `true` if this is a [`Control::TextBox`].
1120    pub fn is_text_box(&self) -> bool {
1121        matches!(self, Self::TextBox { .. })
1122    }
1123
1124    /// Returns `true` if this is a [`Control::Hyperlink`].
1125    pub fn is_hyperlink(&self) -> bool {
1126        matches!(self, Self::Hyperlink { .. })
1127    }
1128
1129    /// Returns `true` if this is a [`Control::Footnote`].
1130    pub fn is_footnote(&self) -> bool {
1131        matches!(self, Self::Footnote { .. })
1132    }
1133
1134    /// Returns `true` if this is a [`Control::Endnote`].
1135    pub fn is_endnote(&self) -> bool {
1136        matches!(self, Self::Endnote { .. })
1137    }
1138
1139    /// Returns `true` if this is a [`Control::Line`].
1140    pub fn is_line(&self) -> bool {
1141        matches!(self, Self::Line { .. })
1142    }
1143
1144    /// Returns `true` if this is a [`Control::Ellipse`].
1145    pub fn is_ellipse(&self) -> bool {
1146        matches!(self, Self::Ellipse { .. })
1147    }
1148
1149    /// Returns `true` if this is a [`Control::Rect`].
1150    pub fn is_rect(&self) -> bool {
1151        matches!(self, Self::Rect { .. })
1152    }
1153
1154    /// Returns `true` if this is a [`Control::Polygon`].
1155    pub fn is_polygon(&self) -> bool {
1156        matches!(self, Self::Polygon { .. })
1157    }
1158
1159    /// Returns `true` if this is a [`Control::Equation`].
1160    pub fn is_equation(&self) -> bool {
1161        matches!(self, Self::Equation { .. })
1162    }
1163
1164    /// Returns `true` if this is a [`Control::Chart`].
1165    pub fn is_chart(&self) -> bool {
1166        matches!(self, Self::Chart { .. })
1167    }
1168
1169    /// Returns `true` if this is a [`Control::EmbeddedChart`].
1170    pub fn is_embedded_chart(&self) -> bool {
1171        matches!(self, Self::EmbeddedChart { .. })
1172    }
1173
1174    /// Returns `true` if this is a [`Control::Unknown`].
1175    pub fn is_unknown(&self) -> bool {
1176        matches!(self, Self::Unknown { .. })
1177    }
1178
1179    /// Returns `true` if this is a [`Control::Dutmal`].
1180    pub fn is_dutmal(&self) -> bool {
1181        matches!(self, Self::Dutmal { .. })
1182    }
1183
1184    /// Returns `true` if this is a [`Control::Compose`].
1185    pub fn is_compose(&self) -> bool {
1186        matches!(self, Self::Compose { .. })
1187    }
1188
1189    /// Returns `true` if this is a [`Control::Arc`].
1190    pub fn is_arc(&self) -> bool {
1191        matches!(self, Self::Arc { .. })
1192    }
1193
1194    /// Returns `true` if this is a [`Control::Curve`].
1195    pub fn is_curve(&self) -> bool {
1196        matches!(self, Self::Curve { .. })
1197    }
1198
1199    /// Returns `true` if this is a [`Control::ConnectLine`].
1200    pub fn is_connect_line(&self) -> bool {
1201        matches!(self, Self::ConnectLine { .. })
1202    }
1203
1204    /// Returns `true` if this is a [`Control::Group`].
1205    pub fn is_group(&self) -> bool {
1206        matches!(self, Self::Group { .. })
1207    }
1208
1209    /// Returns `true` if this is a [`Control::Bookmark`].
1210    pub fn is_bookmark(&self) -> bool {
1211        matches!(self, Self::Bookmark { .. })
1212    }
1213
1214    /// Returns `true` if this is a [`Control::CrossRef`].
1215    pub fn is_cross_ref(&self) -> bool {
1216        matches!(self, Self::CrossRef { .. })
1217    }
1218
1219    /// Returns `true` if this is a [`Control::Field`].
1220    pub fn is_field(&self) -> bool {
1221        matches!(self, Self::Field { .. })
1222    }
1223
1224    /// Returns `true` if this is a [`Control::Memo`].
1225    pub fn is_memo(&self) -> bool {
1226        matches!(self, Self::Memo { .. })
1227    }
1228
1229    /// Returns `true` if this is a [`Control::IndexMark`].
1230    pub fn is_index_mark(&self) -> bool {
1231        matches!(self, Self::IndexMark { .. })
1232    }
1233
1234    /// Creates a point bookmark at a named location.
1235    ///
1236    /// # Examples
1237    ///
1238    /// ```
1239    /// use hwpforge_core::control::Control;
1240    ///
1241    /// let bm = Control::bookmark("section1");
1242    /// assert!(bm.is_bookmark());
1243    /// ```
1244    pub fn bookmark(name: &str) -> Self {
1245        Self::Bookmark { name: name.to_string(), bookmark_type: BookmarkType::Point }
1246    }
1247
1248    /// Creates a press-field (누름틀) with the given hint text.
1249    ///
1250    /// # Examples
1251    ///
1252    /// ```
1253    /// use hwpforge_core::control::Control;
1254    ///
1255    /// let field = Control::field("이름을 입력하세요");
1256    /// assert!(field.is_field());
1257    /// ```
1258    pub fn field(hint: &str) -> Self {
1259        Self::Field {
1260            field_type: FieldType::ClickHere,
1261            hint_text: Some(hint.to_string()),
1262            help_text: None,
1263            name: None,
1264            display_text: String::new(),
1265        }
1266    }
1267
1268    /// Creates an index mark with a primary key.
1269    ///
1270    /// # Examples
1271    ///
1272    /// ```
1273    /// use hwpforge_core::control::Control;
1274    ///
1275    /// let mark = Control::index_mark("한글");
1276    /// assert!(mark.is_index_mark());
1277    /// ```
1278    pub fn index_mark(primary: &str) -> Self {
1279        Self::IndexMark { primary: primary.to_string(), secondary: None }
1280    }
1281
1282    /// Creates a memo annotation with the given paragraph body.
1283    ///
1284    /// # Examples
1285    ///
1286    /// ```
1287    /// use hwpforge_core::control::Control;
1288    /// use hwpforge_core::paragraph::Paragraph;
1289    /// use hwpforge_foundation::ParaShapeIndex;
1290    ///
1291    /// let para = Paragraph::new(ParaShapeIndex::new(0));
1292    /// let memo = Control::memo(vec![para]);
1293    /// assert!(memo.is_memo());
1294    /// ```
1295    pub fn memo(content: Vec<Paragraph>) -> Self {
1296        Self::Memo { content, anchor_runs: Vec::new(), metadata: MemoMetadata::default() }
1297    }
1298
1299    /// Creates a memo annotation with both body content and anchor runs.
1300    ///
1301    /// `anchor_runs` are the visible body span the memo is attached to (the
1302    /// text between HWPX `<hp:fieldBegin type="MEMO">` and `<hp:fieldEnd>`);
1303    /// `content` is the memo body inside `<hp:subList>`.
1304    ///
1305    /// # Examples
1306    ///
1307    /// ```
1308    /// use hwpforge_core::control::Control;
1309    /// use hwpforge_core::paragraph::Paragraph;
1310    /// use hwpforge_core::run::Run;
1311    /// use hwpforge_foundation::{CharShapeIndex, ParaShapeIndex};
1312    ///
1313    /// let body = vec![Paragraph::new(ParaShapeIndex::new(0))];
1314    /// let anchor = vec![Run::text("hello", CharShapeIndex::new(0))];
1315    /// let memo = Control::memo_with_anchor(body, anchor);
1316    /// assert!(memo.is_memo());
1317    /// ```
1318    pub fn memo_with_anchor(content: Vec<Paragraph>, anchor_runs: Vec<Run>) -> Self {
1319        Self::Memo { content, anchor_runs, metadata: MemoMetadata::default() }
1320    }
1321
1322    /// Creates a cross-reference to a bookmark target (convenience helper).
1323    ///
1324    /// Wave 12m Phase 2: 인자 타입이 `&str` 에서 `RefTarget` 로 변경
1325    /// (breaking). 책갈피 이름이라면 `RefTarget::Name(...)`, 한컴 시스템
1326    /// ID 라면 `RefTarget::SystemId(...)` 를 명시.
1327    ///
1328    /// # Examples
1329    ///
1330    /// ```
1331    /// use hwpforge_core::control::{Control, RefTarget};
1332    /// use hwpforge_foundation::{RefType, RefContentType};
1333    ///
1334    /// let xref = Control::cross_ref(
1335    ///     RefTarget::Name("section1".to_string()),
1336    ///     RefType::Bookmark,
1337    ///     RefContentType::Page,
1338    /// );
1339    /// assert!(xref.is_cross_ref());
1340    /// ```
1341    pub fn cross_ref(target: RefTarget, ref_type: RefType, content_type: RefContentType) -> Self {
1342        Self::CrossRef {
1343            target,
1344            ref_type,
1345            content_type,
1346            as_hyperlink: false,
1347            display_text: String::new(),
1348        }
1349    }
1350
1351    /// Creates a chart control with default dimensions and settings.
1352    ///
1353    /// Defaults: width ≈ 114mm, height ≈ 66mm, no title, right legend, clustered grouping.
1354    ///
1355    /// # Examples
1356    ///
1357    /// ```
1358    /// use hwpforge_core::control::Control;
1359    /// use hwpforge_core::chart::{ChartType, ChartData};
1360    ///
1361    /// let data = ChartData::category(&["A", "B"], &[("S1", &[10.0, 20.0])]);
1362    /// let ctrl = Control::chart(ChartType::Column, data);
1363    /// assert!(ctrl.is_chart());
1364    /// ```
1365    pub fn chart(chart_type: ChartType, data: ChartData) -> Self {
1366        Self::Chart {
1367            chart_type,
1368            data,
1369            width: HwpUnit::new(32250).expect("32250 is valid"),
1370            height: HwpUnit::new(18750).expect("18750 is valid"),
1371            title: None,
1372            legend: LegendPosition::default(),
1373            grouping: ChartGrouping::default(),
1374            bar_shape: None,
1375            explosion: None,
1376            of_pie_type: None,
1377            radar_style: None,
1378            wireframe: None,
1379            bubble_3d: None,
1380            scatter_style: None,
1381            show_markers: None,
1382            stock_variant: None,
1383        }
1384    }
1385
1386    /// Creates an equation control with default dimensions for the given HancomEQN script.
1387    ///
1388    /// Defaults: width ≈ 31mm (8779 HWPUNIT), height ≈ 9.2mm (2600 HWPUNIT),
1389    /// baseline 71%, black text, `HancomEQN` font.
1390    ///
1391    /// # Examples
1392    ///
1393    /// ```
1394    /// use hwpforge_core::control::Control;
1395    ///
1396    /// let ctrl = Control::equation("{a+b} over {c+d}");
1397    /// assert!(ctrl.is_equation());
1398    /// ```
1399    pub fn equation(script: &str) -> Self {
1400        Self::Equation {
1401            script: script.to_string(),
1402            width: HwpUnit::new(8779).expect("8779 is valid"),
1403            height: HwpUnit::new(2600).expect("2600 is valid"),
1404            base_line: 71,
1405            text_color: Color::BLACK,
1406            font: "HancomEQN".to_string(),
1407            inst_id: None,
1408        }
1409    }
1410
1411    /// Creates a text box control with the given paragraphs and dimensions.
1412    ///
1413    /// Defaults: inline positioning (horz_offset=0, vert_offset=0), no caption, no style override.
1414    ///
1415    /// # Examples
1416    ///
1417    /// ```
1418    /// use hwpforge_core::control::Control;
1419    /// use hwpforge_core::paragraph::Paragraph;
1420    /// use hwpforge_foundation::{HwpUnit, ParaShapeIndex};
1421    ///
1422    /// let para = Paragraph::new(ParaShapeIndex::new(0));
1423    /// let width = HwpUnit::from_mm(80.0).unwrap();
1424    /// let height = HwpUnit::from_mm(40.0).unwrap();
1425    /// let ctrl = Control::text_box(vec![para], width, height);
1426    /// assert!(ctrl.is_text_box());
1427    /// ```
1428    pub fn text_box(paragraphs: Vec<Paragraph>, width: HwpUnit, height: HwpUnit) -> Self {
1429        Self::TextBox {
1430            paragraphs,
1431            width,
1432            height,
1433            horz_offset: 0,
1434            vert_offset: 0,
1435            caption: None,
1436            style: None,
1437        }
1438    }
1439
1440    /// Creates a footnote control with the given paragraph content.
1441    ///
1442    /// Defaults: no inst_id.
1443    ///
1444    /// # Examples
1445    ///
1446    /// ```
1447    /// use hwpforge_core::control::Control;
1448    /// use hwpforge_core::run::Run;
1449    /// use hwpforge_core::paragraph::Paragraph;
1450    /// use hwpforge_foundation::{CharShapeIndex, ParaShapeIndex};
1451    ///
1452    /// let para = Paragraph::with_runs(
1453    ///     vec![Run::text("Note text", CharShapeIndex::new(0))],
1454    ///     ParaShapeIndex::new(0),
1455    /// );
1456    /// let ctrl = Control::footnote(vec![para]);
1457    /// assert!(ctrl.is_footnote());
1458    /// ```
1459    pub fn footnote(paragraphs: Vec<Paragraph>) -> Self {
1460        Self::Footnote { inst_id: None, paragraphs }
1461    }
1462
1463    /// Creates an endnote control with the given paragraph content.
1464    ///
1465    /// Defaults: no inst_id.
1466    ///
1467    /// # Examples
1468    ///
1469    /// ```
1470    /// use hwpforge_core::control::Control;
1471    /// use hwpforge_core::run::Run;
1472    /// use hwpforge_core::paragraph::Paragraph;
1473    /// use hwpforge_foundation::{CharShapeIndex, ParaShapeIndex};
1474    ///
1475    /// let para = Paragraph::with_runs(
1476    ///     vec![Run::text("End note", CharShapeIndex::new(0))],
1477    ///     ParaShapeIndex::new(0),
1478    /// );
1479    /// let ctrl = Control::endnote(vec![para]);
1480    /// assert!(ctrl.is_endnote());
1481    /// ```
1482    pub fn endnote(paragraphs: Vec<Paragraph>) -> Self {
1483        Self::Endnote { inst_id: None, paragraphs }
1484    }
1485
1486    /// Creates a footnote with an explicit instance ID for cross-referencing.
1487    ///
1488    /// Use this when you need stable `inst_id` references (e.g. matching decoder output).
1489    /// For simple footnotes without cross-references, prefer [`Control::footnote`].
1490    ///
1491    /// # Examples
1492    ///
1493    /// ```
1494    /// use hwpforge_core::control::Control;
1495    /// use hwpforge_core::paragraph::Paragraph;
1496    /// use hwpforge_foundation::ParaShapeIndex;
1497    ///
1498    /// let ctrl = Control::footnote_with_id(1, vec![Paragraph::new(ParaShapeIndex::new(0))]);
1499    /// assert!(ctrl.is_footnote());
1500    /// ```
1501    pub fn footnote_with_id(inst_id: u32, paragraphs: Vec<Paragraph>) -> Self {
1502        Self::Footnote { inst_id: Some(inst_id), paragraphs }
1503    }
1504
1505    /// Creates an endnote with an explicit instance ID for cross-referencing.
1506    ///
1507    /// Use this when you need stable `inst_id` references (e.g. matching decoder output).
1508    /// For simple endnotes without cross-references, prefer [`Control::endnote`].
1509    ///
1510    /// # Examples
1511    ///
1512    /// ```
1513    /// use hwpforge_core::control::Control;
1514    /// use hwpforge_core::paragraph::Paragraph;
1515    /// use hwpforge_foundation::ParaShapeIndex;
1516    ///
1517    /// let ctrl = Control::endnote_with_id(2, vec![Paragraph::new(ParaShapeIndex::new(0))]);
1518    /// assert!(ctrl.is_endnote());
1519    /// ```
1520    pub fn endnote_with_id(inst_id: u32, paragraphs: Vec<Paragraph>) -> Self {
1521        Self::Endnote { inst_id: Some(inst_id), paragraphs }
1522    }
1523
1524    /// Creates an ellipse control with the given bounding box dimensions.
1525    ///
1526    /// Geometry is auto-derived: center=(w/2, h/2), axis1=(w, h/2), axis2=(w/2, h).
1527    /// Defaults: inline positioning (horz_offset=0, vert_offset=0), no paragraphs, no caption, no style.
1528    ///
1529    /// # Examples
1530    ///
1531    /// ```
1532    /// use hwpforge_core::control::Control;
1533    /// use hwpforge_foundation::HwpUnit;
1534    ///
1535    /// let width = HwpUnit::from_mm(40.0).unwrap();
1536    /// let height = HwpUnit::from_mm(30.0).unwrap();
1537    /// let ctrl = Control::ellipse(width, height);
1538    /// assert!(ctrl.is_ellipse());
1539    /// ```
1540    pub fn ellipse(width: HwpUnit, height: HwpUnit) -> Self {
1541        let w = width.as_i32();
1542        let h = height.as_i32();
1543        Self::Ellipse {
1544            center: ShapePoint::new(w / 2, h / 2),
1545            axis1: ShapePoint::new(w, h / 2),
1546            axis2: ShapePoint::new(w / 2, h),
1547            width,
1548            height,
1549            horz_offset: 0,
1550            vert_offset: 0,
1551            paragraphs: vec![],
1552            caption: None,
1553            style: None,
1554        }
1555    }
1556
1557    /// Creates an ellipse control with paragraph content inside.
1558    ///
1559    /// Same as [`Control::ellipse`] but accepts paragraphs for text drawn inside the ellipse.
1560    /// Geometry is auto-derived: center=(w/2, h/2), axis1=(w, h/2), axis2=(w/2, h).
1561    /// Defaults: inline positioning (horz_offset=0, vert_offset=0), no caption, no style.
1562    ///
1563    /// # Examples
1564    ///
1565    /// ```
1566    /// use hwpforge_core::control::Control;
1567    /// use hwpforge_core::paragraph::Paragraph;
1568    /// use hwpforge_foundation::{HwpUnit, ParaShapeIndex};
1569    ///
1570    /// let width = HwpUnit::from_mm(40.0).unwrap();
1571    /// let height = HwpUnit::from_mm(30.0).unwrap();
1572    /// let para = Paragraph::new(ParaShapeIndex::new(0));
1573    /// let ctrl = Control::ellipse_with_text(width, height, vec![para]);
1574    /// assert!(ctrl.is_ellipse());
1575    /// ```
1576    pub fn ellipse_with_text(width: HwpUnit, height: HwpUnit, paragraphs: Vec<Paragraph>) -> Self {
1577        let w = width.as_i32();
1578        let h = height.as_i32();
1579        Self::Ellipse {
1580            center: ShapePoint::new(w / 2, h / 2),
1581            axis1: ShapePoint::new(w, h / 2),
1582            axis2: ShapePoint::new(w / 2, h),
1583            width,
1584            height,
1585            horz_offset: 0,
1586            vert_offset: 0,
1587            paragraphs,
1588            caption: None,
1589            style: None,
1590        }
1591    }
1592
1593    /// Creates a pure rectangle control with the given bounding box dimensions.
1594    ///
1595    /// Pure rectangle means no embedded text content; for a textbox-style rect with
1596    /// inline paragraphs, use [`Control::text_box`].
1597    /// Defaults: inline positioning (horz_offset=0, vert_offset=0), no caption, no style.
1598    ///
1599    /// # Errors
1600    ///
1601    /// Returns [`CoreError::InvalidStructure`] if either dimension is zero.
1602    ///
1603    /// # Examples
1604    ///
1605    /// ```
1606    /// use hwpforge_core::control::Control;
1607    /// use hwpforge_foundation::HwpUnit;
1608    ///
1609    /// let width = HwpUnit::from_mm(40.0).unwrap();
1610    /// let height = HwpUnit::from_mm(20.0).unwrap();
1611    /// let ctrl = Control::rect(width, height).unwrap();
1612    /// assert!(ctrl.is_rect());
1613    /// ```
1614    pub fn rect(width: HwpUnit, height: HwpUnit) -> CoreResult<Self> {
1615        if width.as_i32() == 0 || height.as_i32() == 0 {
1616            return Err(CoreError::InvalidStructure {
1617                context: "Control::rect".to_string(),
1618                reason: format!(
1619                    "rectangle requires non-zero dimensions, got {}x{}",
1620                    width.as_i32(),
1621                    height.as_i32()
1622                ),
1623            });
1624        }
1625        Ok(Self::Rect { width, height, horz_offset: 0, vert_offset: 0, caption: None, style: None })
1626    }
1627
1628    /// Creates a polygon control from the given vertices.
1629    ///
1630    /// The bounding box is auto-derived from the min/max of vertex coordinates.
1631    /// Defaults: no paragraphs, no caption, no style.
1632    ///
1633    /// Returns an error if fewer than 3 vertices are provided.
1634    ///
1635    /// # Errors
1636    ///
1637    /// Returns [`CoreError::InvalidStructure`] if `vertices.len() < 3`.
1638    ///
1639    /// # Examples
1640    ///
1641    /// ```
1642    /// use hwpforge_core::control::{Control, ShapePoint};
1643    ///
1644    /// let vertices = vec![
1645    ///     ShapePoint::new(0, 1000),
1646    ///     ShapePoint::new(500, 0),
1647    ///     ShapePoint::new(1000, 1000),
1648    /// ];
1649    /// let ctrl = Control::polygon(vertices).unwrap();
1650    /// assert!(ctrl.is_polygon());
1651    /// ```
1652    pub fn polygon(vertices: Vec<ShapePoint>) -> CoreResult<Self> {
1653        if vertices.len() < 3 {
1654            return Err(CoreError::InvalidStructure {
1655                context: "Control::polygon".to_string(),
1656                reason: format!("polygon requires at least 3 vertices, got {}", vertices.len()),
1657            });
1658        }
1659        let min_x = vertices.iter().map(|p| p.x as i64).min().unwrap_or(0);
1660        let max_x = vertices.iter().map(|p| p.x as i64).max().unwrap_or(0);
1661        let min_y = vertices.iter().map(|p| p.y as i64).min().unwrap_or(0);
1662        let max_y = vertices.iter().map(|p| p.y as i64).max().unwrap_or(0);
1663        let bbox_w = i32::try_from((max_x - min_x).max(0)).unwrap_or(i32::MAX);
1664        let bbox_h = i32::try_from((max_y - min_y).max(0)).unwrap_or(i32::MAX);
1665        let width = HwpUnit::new(bbox_w).map_err(|_| CoreError::InvalidStructure {
1666            context: "Control::polygon".into(),
1667            reason: format!("bounding box width {bbox_w} exceeds HwpUnit range"),
1668        })?;
1669        let height = HwpUnit::new(bbox_h).map_err(|_| CoreError::InvalidStructure {
1670            context: "Control::polygon".into(),
1671            reason: format!("bounding box height {bbox_h} exceeds HwpUnit range"),
1672        })?;
1673        Ok(Self::Polygon {
1674            vertices,
1675            width,
1676            height,
1677            horz_offset: 0,
1678            vert_offset: 0,
1679            paragraphs: vec![],
1680            caption: None,
1681            style: None,
1682        })
1683    }
1684
1685    /// Creates a line control between two endpoints.
1686    ///
1687    /// The bounding box width and height are derived from the absolute difference
1688    /// of the endpoint coordinates: `width = |end.x - start.x|`, `height = |end.y - start.y|`.
1689    /// Each axis is clamped to a minimum of 100 HwpUnit (~1pt) because 한글 cannot
1690    /// render lines with a zero-dimension bounding box.
1691    /// Defaults: no caption, no style.
1692    ///
1693    /// Returns an error if start and end are the same point (degenerate line).
1694    ///
1695    /// # Errors
1696    ///
1697    /// Returns [`CoreError::InvalidStructure`] if start equals end.
1698    ///
1699    /// # Examples
1700    ///
1701    /// ```
1702    /// use hwpforge_core::control::{Control, ShapePoint};
1703    ///
1704    /// let ctrl = Control::line(ShapePoint::new(0, 0), ShapePoint::new(5000, 0)).unwrap();
1705    /// assert!(ctrl.is_line());
1706    /// ```
1707    pub fn line(start: ShapePoint, end: ShapePoint) -> CoreResult<Self> {
1708        if start == end {
1709            return Err(CoreError::InvalidStructure {
1710                context: "Control::line".to_string(),
1711                reason: "start and end points are identical (degenerate line)".to_string(),
1712            });
1713        }
1714        // Normalize points to bounding-box-relative coordinates.
1715        // HWPX requires startPt/endPt within the shape's bounding box (0,0)→(w,h).
1716        let min_x = start.x.min(end.x);
1717        let min_y = start.y.min(end.y);
1718        let norm_start =
1719            ShapePoint::new(start.x.saturating_sub(min_x), start.y.saturating_sub(min_y));
1720        let norm_end = ShapePoint::new(end.x.saturating_sub(min_x), end.y.saturating_sub(min_y));
1721
1722        let raw_w =
1723            i32::try_from(((end.x as i64) - (start.x as i64)).unsigned_abs()).unwrap_or(i32::MAX);
1724        let raw_h =
1725            i32::try_from(((end.y as i64) - (start.y as i64)).unsigned_abs()).unwrap_or(i32::MAX);
1726        // Minimum bounding box of 100 HwpUnit (~1pt) per axis.
1727        // 한글 cannot render lines with a zero-dimension bounding box.
1728        let raw_w = raw_w.max(100);
1729        let raw_h = raw_h.max(100);
1730        let width = HwpUnit::new(raw_w).unwrap_or_else(|_| HwpUnit::new(100).expect("valid"));
1731        let height = HwpUnit::new(raw_h).unwrap_or_else(|_| HwpUnit::new(100).expect("valid"));
1732        Ok(Self::Line {
1733            start: norm_start,
1734            end: norm_end,
1735            width,
1736            height,
1737            horz_offset: 0,
1738            vert_offset: 0,
1739            caption: None,
1740            style: None,
1741        })
1742    }
1743
1744    /// Creates a horizontal line of the given width.
1745    ///
1746    /// Shortcut for `line(ShapePoint::new(0, 0), ShapePoint::new(width.as_i32(), 0))`.
1747    /// The bounding box height is clamped to 100 HwpUnit (~1pt minimum) because
1748    /// 한글 cannot render lines with a zero-dimension bounding box.
1749    /// Defaults: no caption, no style.
1750    ///
1751    /// # Examples
1752    ///
1753    /// ```
1754    /// use hwpforge_core::control::Control;
1755    /// use hwpforge_foundation::HwpUnit;
1756    ///
1757    /// let width = HwpUnit::from_mm(100.0).unwrap();
1758    /// let ctrl = Control::horizontal_line(width);
1759    /// assert!(ctrl.is_line());
1760    /// ```
1761    pub fn horizontal_line(width: HwpUnit) -> Self {
1762        let w = width.as_i32();
1763        Self::Line {
1764            start: ShapePoint::new(0, 0),
1765            end: ShapePoint::new(w, 0),
1766            width,
1767            height: HwpUnit::new(100).expect("100 is valid"),
1768            horz_offset: 0,
1769            vert_offset: 0,
1770            caption: None,
1771            style: None,
1772        }
1773    }
1774
1775    /// Creates a dutmal (annotation text) control with default positioning.
1776    ///
1777    /// Defaults: position = Top, sz_ratio = 0 (auto), align = Center.
1778    ///
1779    /// # Examples
1780    ///
1781    /// ```
1782    /// use hwpforge_core::control::Control;
1783    ///
1784    /// let ctrl = Control::dutmal("본문", "주석");
1785    /// assert!(ctrl.is_dutmal());
1786    /// ```
1787    pub fn dutmal(main_text: impl Into<String>, sub_text: impl Into<String>) -> Self {
1788        Self::Dutmal {
1789            main_text: main_text.into(),
1790            sub_text: sub_text.into(),
1791            position: DutmalPosition::Top,
1792            sz_ratio: 0,
1793            align: DutmalAlign::Center,
1794            metadata: DutmalMetadata::default(),
1795        }
1796    }
1797
1798    /// Creates a compose (글자겹침) control with default settings.
1799    ///
1800    /// Defaults: `circle_type = "SHAPE_REVERSAL_TIRANGLE"` (spec typo preserved),
1801    /// `char_sz = -3`, `compose_type = "SPREAD"`.
1802    ///
1803    /// # Examples
1804    ///
1805    /// ```
1806    /// use hwpforge_core::control::Control;
1807    ///
1808    /// let ctrl = Control::compose("12");
1809    /// assert!(ctrl.is_compose());
1810    /// ```
1811    pub fn compose(text: impl Into<String>) -> Self {
1812        Self::Compose {
1813            compose_text: text.into(),
1814            circle_type: "SHAPE_REVERSAL_TIRANGLE".to_string(), // official spec typo preserved
1815            char_sz: -3,
1816            compose_type: "SPREAD".to_string(),
1817            // 10 × no-override sentinel (HWPX `charPrCnt` is fixed at 10).
1818            char_pr_ids: vec![u32::MAX; 10],
1819        }
1820    }
1821
1822    /// Creates an arc control with the given bounding box dimensions.
1823    ///
1824    /// Geometry is auto-derived from the bounding box.
1825    /// Defaults: inline positioning, no caption, no style.
1826    ///
1827    /// # Examples
1828    ///
1829    /// ```
1830    /// use hwpforge_core::control::Control;
1831    /// use hwpforge_foundation::{ArcType, HwpUnit};
1832    ///
1833    /// let width = HwpUnit::from_mm(40.0).unwrap();
1834    /// let height = HwpUnit::from_mm(30.0).unwrap();
1835    /// let ctrl = Control::arc(ArcType::Pie, width, height);
1836    /// assert!(ctrl.is_arc());
1837    /// ```
1838    pub fn arc(arc_type: ArcType, width: HwpUnit, height: HwpUnit) -> Self {
1839        let w = width.as_i32();
1840        let h = height.as_i32();
1841        Self::Arc {
1842            arc_type,
1843            center: ShapePoint::new(w / 2, h / 2),
1844            axis1: ShapePoint::new(w, h / 2),
1845            axis2: ShapePoint::new(w / 2, h),
1846            start1: ShapePoint::new(w, h / 2),
1847            end1: ShapePoint::new(w / 2, 0),
1848            start2: ShapePoint::new(w, h / 2),
1849            end2: ShapePoint::new(w / 2, 0),
1850            width,
1851            height,
1852            horz_offset: 0,
1853            vert_offset: 0,
1854            caption: None,
1855            style: None,
1856        }
1857    }
1858
1859    /// Creates a curve control from the given control points.
1860    ///
1861    /// All segments default to [`CurveSegmentType::Curve`].
1862    /// The bounding box is auto-derived from min/max of point coordinates.
1863    ///
1864    /// Returns an error if fewer than 2 points are provided.
1865    ///
1866    /// # Errors
1867    ///
1868    /// Returns [`CoreError::InvalidStructure`] if `points.len() < 2`.
1869    ///
1870    /// # Examples
1871    ///
1872    /// ```
1873    /// use hwpforge_core::control::{Control, ShapePoint};
1874    ///
1875    /// let pts = vec![
1876    ///     ShapePoint::new(0, 0),
1877    ///     ShapePoint::new(2500, 5000),
1878    ///     ShapePoint::new(5000, 0),
1879    /// ];
1880    /// let ctrl = Control::curve(pts).unwrap();
1881    /// assert!(ctrl.is_curve());
1882    /// ```
1883    pub fn curve(points: Vec<ShapePoint>) -> CoreResult<Self> {
1884        if points.len() < 2 {
1885            return Err(CoreError::InvalidStructure {
1886                context: "Control::curve".to_string(),
1887                reason: format!("curve requires at least 2 points, got {}", points.len()),
1888            });
1889        }
1890        let min_x = points.iter().map(|p| p.x as i64).min().unwrap_or(0);
1891        let max_x = points.iter().map(|p| p.x as i64).max().unwrap_or(0);
1892        let min_y = points.iter().map(|p| p.y as i64).min().unwrap_or(0);
1893        let max_y = points.iter().map(|p| p.y as i64).max().unwrap_or(0);
1894        let bbox_w = i32::try_from((max_x - min_x).max(1)).unwrap_or(i32::MAX);
1895        let bbox_h = i32::try_from((max_y - min_y).max(1)).unwrap_or(i32::MAX);
1896        let width = HwpUnit::new(bbox_w).map_err(|_| CoreError::InvalidStructure {
1897            context: "Control::curve".into(),
1898            reason: format!("bounding box width {bbox_w} exceeds HwpUnit range"),
1899        })?;
1900        let height = HwpUnit::new(bbox_h).map_err(|_| CoreError::InvalidStructure {
1901            context: "Control::curve".into(),
1902            reason: format!("bounding box height {bbox_h} exceeds HwpUnit range"),
1903        })?;
1904        let seg_count = points.len().saturating_sub(1);
1905        Ok(Self::Curve {
1906            points,
1907            segment_types: vec![CurveSegmentType::Curve; seg_count],
1908            width,
1909            height,
1910            horz_offset: 0,
1911            vert_offset: 0,
1912            caption: None,
1913            style: None,
1914        })
1915    }
1916
1917    /// Creates a connect line between two endpoints.
1918    ///
1919    /// Defaults: no control points, type "STRAIGHT", no caption, no style.
1920    ///
1921    /// Returns an error if start equals end.
1922    ///
1923    /// # Errors
1924    ///
1925    /// Returns [`CoreError::InvalidStructure`] if start equals end.
1926    ///
1927    /// # Examples
1928    ///
1929    /// ```
1930    /// use hwpforge_core::control::{Control, ShapePoint};
1931    ///
1932    /// let ctrl = Control::connect_line(
1933    ///     ShapePoint::new(0, 0),
1934    ///     ShapePoint::new(5000, 5000),
1935    /// ).unwrap();
1936    /// assert!(ctrl.is_connect_line());
1937    /// ```
1938    pub fn connect_line(start: ShapePoint, end: ShapePoint) -> CoreResult<Self> {
1939        if start == end {
1940            return Err(CoreError::InvalidStructure {
1941                context: "Control::connect_line".to_string(),
1942                reason: "start and end points are identical (degenerate line)".to_string(),
1943            });
1944        }
1945        // Normalize points to bounding-box-relative coordinates.
1946        // HWPX requires startPt/endPt within the shape's bounding box (0,0)→(w,h).
1947        let min_x = start.x.min(end.x);
1948        let min_y = start.y.min(end.y);
1949        let norm_start =
1950            ShapePoint::new(start.x.saturating_sub(min_x), start.y.saturating_sub(min_y));
1951        let norm_end = ShapePoint::new(end.x.saturating_sub(min_x), end.y.saturating_sub(min_y));
1952
1953        let raw_w =
1954            i32::try_from(((end.x as i64) - (start.x as i64)).unsigned_abs()).unwrap_or(i32::MAX);
1955        let raw_h =
1956            i32::try_from(((end.y as i64) - (start.y as i64)).unsigned_abs()).unwrap_or(i32::MAX);
1957        let raw_w = raw_w.max(100);
1958        let raw_h = raw_h.max(100);
1959        let width = HwpUnit::new(raw_w).unwrap_or_else(|_| HwpUnit::new(100).expect("valid"));
1960        let height = HwpUnit::new(raw_h).unwrap_or_else(|_| HwpUnit::new(100).expect("valid"));
1961        Ok(Self::ConnectLine {
1962            start: norm_start,
1963            end: norm_end,
1964            control_points: Vec::new(),
1965            connect_type: "STRAIGHT".to_string(),
1966            width,
1967            height,
1968            horz_offset: 0,
1969            vert_offset: 0,
1970            caption: None,
1971            style: None,
1972        })
1973    }
1974
1975    /// Creates a hyperlink control with the given display text and URL.
1976    ///
1977    /// # Examples
1978    ///
1979    /// ```
1980    /// use hwpforge_core::control::Control;
1981    ///
1982    /// let ctrl = Control::hyperlink("Visit Rust", "https://rust-lang.org");
1983    /// assert!(ctrl.is_hyperlink());
1984    /// ```
1985    pub fn hyperlink(text: &str, url: &str) -> Self {
1986        Self::Hyperlink { text: text.to_string(), url: url.to_string() }
1987    }
1988}
1989
1990impl std::fmt::Display for Control {
1991    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1992        match self {
1993            Self::TextBox { paragraphs, .. } => {
1994                let n = paragraphs.len();
1995                let word = if n == 1 { "paragraph" } else { "paragraphs" };
1996                write!(f, "TextBox({n} {word})")
1997            }
1998            Self::Hyperlink { text, url } => {
1999                let preview: String =
2000                    if text.len() > 30 { text.chars().take(30).collect() } else { text.clone() };
2001                write!(f, "Hyperlink(\"{preview}\" -> {url})")
2002            }
2003            Self::Footnote { paragraphs, .. } => {
2004                let n = paragraphs.len();
2005                let word = if n == 1 { "paragraph" } else { "paragraphs" };
2006                write!(f, "Footnote({n} {word})")
2007            }
2008            Self::Endnote { paragraphs, .. } => {
2009                let n = paragraphs.len();
2010                let word = if n == 1 { "paragraph" } else { "paragraphs" };
2011                write!(f, "Endnote({n} {word})")
2012            }
2013            Self::Line { .. } => {
2014                write!(f, "Line")
2015            }
2016            Self::Ellipse { paragraphs, .. } => {
2017                let n = paragraphs.len();
2018                let word = if n == 1 { "paragraph" } else { "paragraphs" };
2019                write!(f, "Ellipse({n} {word})")
2020            }
2021            Self::Rect { width, height, .. } => {
2022                write!(f, "Rect({}x{})", width.as_i32(), height.as_i32())
2023            }
2024            Self::Polygon { vertices, paragraphs, .. } => {
2025                let nv = vertices.len();
2026                let np = paragraphs.len();
2027                let vw = if nv == 1 { "vertex" } else { "vertices" };
2028                let pw = if np == 1 { "paragraph" } else { "paragraphs" };
2029                write!(f, "Polygon({nv} {vw}, {np} {pw})")
2030            }
2031            Self::Chart { chart_type, data, .. } => {
2032                let series_count = match data {
2033                    ChartData::Category { series, .. } => series.len(),
2034                    ChartData::Xy { series } => series.len(),
2035                };
2036                write!(f, "Chart({chart_type:?}, {series_count} series)")
2037            }
2038            Self::EmbeddedChart { chart_xml, ole_bytes, width, height, .. } => {
2039                write!(
2040                    f,
2041                    "EmbeddedChart(xml={} bytes, ole={} bytes, {}x{})",
2042                    chart_xml.len(),
2043                    ole_bytes.len(),
2044                    width.as_i32(),
2045                    height.as_i32()
2046                )
2047            }
2048            Self::Equation { script, .. } => {
2049                let preview: String = if script.len() > 30 {
2050                    script.chars().take(30).collect()
2051                } else {
2052                    script.clone()
2053                };
2054                write!(f, "Equation(\"{preview}\")")
2055            }
2056            Self::Dutmal { main_text, sub_text, .. } => {
2057                write!(f, "Dutmal(\"{main_text}\" / \"{sub_text}\")")
2058            }
2059            Self::Compose { compose_text, .. } => {
2060                write!(f, "Compose(\"{compose_text}\")")
2061            }
2062            Self::Arc { arc_type, .. } => {
2063                write!(f, "Arc({arc_type})")
2064            }
2065            Self::Curve { points, .. } => {
2066                write!(f, "Curve({} points)", points.len())
2067            }
2068            Self::ConnectLine { .. } => {
2069                write!(f, "ConnectLine")
2070            }
2071            Self::Group { children, .. } => {
2072                write!(f, "Group({} children)", children.len())
2073            }
2074            Self::TextArt { text, shape, .. } => {
2075                write!(f, "TextArt(\"{text}\", {shape})")
2076            }
2077            Self::Bookmark { name, bookmark_type } => {
2078                write!(f, "Bookmark(\"{name}\", {bookmark_type})")
2079            }
2080            Self::CrossRef { target, ref_type, .. } => {
2081                write!(f, "CrossRef({:?}, {ref_type})", target.as_display())
2082            }
2083            Self::Field { field_type, hint_text, name, .. } => {
2084                let hint = hint_text.as_deref().unwrap_or("");
2085                match name.as_deref().filter(|s| !s.is_empty()) {
2086                    Some(n) => write!(f, "Field({field_type}, name=\"{n}\", \"{hint}\")"),
2087                    None => write!(f, "Field({field_type}, \"{hint}\")"),
2088                }
2089            }
2090            Self::Memo { content, anchor_runs, .. } => {
2091                let n = content.len();
2092                let word = if n == 1 { "paragraph" } else { "paragraphs" };
2093                let anchor_len = anchor_runs.len();
2094                write!(f, "Memo({n} {word}, anchor={anchor_len} runs)")
2095            }
2096            Self::IndexMark { primary, secondary } => {
2097                if let Some(sec) = secondary {
2098                    write!(f, "IndexMark(\"{primary}\" / \"{sec}\")")
2099                } else {
2100                    write!(f, "IndexMark(\"{primary}\")")
2101                }
2102            }
2103            Self::UnknownSummery { token, .. } => {
2104                write!(f, "UnknownSummery({token})")
2105            }
2106            Self::DateCodeField { raw_command, is_time_mode, .. } => {
2107                let mode = if *is_time_mode { "time" } else { "date" };
2108                write!(f, "DateCodeField({mode}, \"{raw_command}\")")
2109            }
2110            Self::PathField { command, .. } => {
2111                write!(f, "PathField({})", command.wire_command())
2112            }
2113            Self::InlinePageNumber { kind, raw_flag } => match kind {
2114                InlinePageKind::CurrentPage => write!(f, "InlinePageNumber(current)"),
2115                InlinePageKind::TotalPages => write!(f, "InlinePageNumber(total)"),
2116                InlinePageKind::Unknown => {
2117                    write!(f, "InlinePageNumber(unknown, raw=0x{raw_flag:08X})")
2118                }
2119            },
2120            Self::Unknown { tag, .. } => {
2121                write!(f, "Unknown({tag})")
2122            }
2123        }
2124    }
2125}
2126
2127#[cfg(test)]
2128mod tests {
2129    use super::*;
2130    use crate::run::Run;
2131    use hwpforge_foundation::{CharShapeIndex, Color, ParaShapeIndex};
2132
2133    fn simple_paragraph() -> Paragraph {
2134        Paragraph::with_runs(
2135            vec![Run::text("footnote text", CharShapeIndex::new(0))],
2136            ParaShapeIndex::new(0),
2137        )
2138    }
2139
2140    #[test]
2141    fn shape_style_default_all_none() {
2142        let s = ShapeStyle::default();
2143        assert!(s.line_color.is_none());
2144        assert!(s.fill_color.is_none());
2145        assert!(s.line_width.is_none());
2146        assert!(s.line_style.is_none());
2147    }
2148
2149    #[test]
2150    fn shape_style_with_typed_fields() {
2151        let s = ShapeStyle {
2152            line_color: Some(Color::from_rgb(255, 0, 0)),
2153            fill_color: Some(Color::from_rgb(0, 255, 0)),
2154            line_width: Some(100),
2155            line_style: Some(LineStyle::Dash),
2156            ..Default::default()
2157        };
2158        assert_eq!(s.line_color.unwrap(), Color::from_rgb(255, 0, 0));
2159        assert_eq!(s.fill_color.unwrap(), Color::from_rgb(0, 255, 0));
2160        assert_eq!(s.line_width.unwrap(), 100);
2161        assert_eq!(s.line_style.unwrap(), LineStyle::Dash);
2162    }
2163
2164    #[test]
2165    fn line_style_default() {
2166        assert_eq!(LineStyle::default(), LineStyle::Solid);
2167    }
2168
2169    #[test]
2170    fn line_style_display() {
2171        assert_eq!(LineStyle::Solid.to_string(), "SOLID");
2172        assert_eq!(LineStyle::Dash.to_string(), "DASH");
2173        assert_eq!(LineStyle::Dot.to_string(), "DOT");
2174        assert_eq!(LineStyle::DashDot.to_string(), "DASH_DOT");
2175        assert_eq!(LineStyle::DashDotDot.to_string(), "DASH_DOT_DOT");
2176        assert_eq!(LineStyle::None.to_string(), "NONE");
2177    }
2178
2179    #[test]
2180    fn line_style_from_str() {
2181        assert_eq!("SOLID".parse::<LineStyle>().unwrap(), LineStyle::Solid);
2182        assert_eq!("Dash".parse::<LineStyle>().unwrap(), LineStyle::Dash);
2183        assert_eq!("dot".parse::<LineStyle>().unwrap(), LineStyle::Dot);
2184        assert_eq!("DASH_DOT".parse::<LineStyle>().unwrap(), LineStyle::DashDot);
2185        assert_eq!("DashDotDot".parse::<LineStyle>().unwrap(), LineStyle::DashDotDot);
2186        assert_eq!("NONE".parse::<LineStyle>().unwrap(), LineStyle::None);
2187        assert!("INVALID".parse::<LineStyle>().is_err());
2188    }
2189
2190    #[test]
2191    fn line_style_serde_roundtrip() {
2192        for style in [
2193            LineStyle::Solid,
2194            LineStyle::Dash,
2195            LineStyle::Dot,
2196            LineStyle::DashDot,
2197            LineStyle::DashDotDot,
2198            LineStyle::None,
2199        ] {
2200            let json = serde_json::to_string(&style).unwrap();
2201            let back: LineStyle = serde_json::from_str(&json).unwrap();
2202            assert_eq!(style, back);
2203        }
2204    }
2205
2206    #[test]
2207    fn text_box_construction() {
2208        let ctrl = Control::TextBox {
2209            paragraphs: vec![simple_paragraph()],
2210            width: HwpUnit::from_mm(80.0).unwrap(),
2211            height: HwpUnit::from_mm(40.0).unwrap(),
2212            horz_offset: 0,
2213            vert_offset: 0,
2214            caption: None,
2215            style: None,
2216        };
2217        assert!(ctrl.is_text_box());
2218        assert!(!ctrl.is_hyperlink());
2219        assert!(!ctrl.is_footnote());
2220        assert!(!ctrl.is_endnote());
2221        assert!(!ctrl.is_unknown());
2222    }
2223
2224    #[test]
2225    fn hyperlink_construction() {
2226        let ctrl = Control::Hyperlink {
2227            text: "Click".to_string(),
2228            url: "https://example.com".to_string(),
2229        };
2230        assert!(ctrl.is_hyperlink());
2231        assert!(!ctrl.is_text_box());
2232    }
2233
2234    #[test]
2235    fn footnote_construction() {
2236        let ctrl = Control::Footnote { inst_id: None, paragraphs: vec![simple_paragraph()] };
2237        assert!(ctrl.is_footnote());
2238        assert!(!ctrl.is_text_box());
2239        assert!(!ctrl.is_endnote());
2240    }
2241
2242    #[test]
2243    fn endnote_construction() {
2244        let ctrl = Control::Endnote { inst_id: Some(123456), paragraphs: vec![simple_paragraph()] };
2245        assert!(ctrl.is_endnote());
2246        assert!(!ctrl.is_footnote());
2247        assert!(!ctrl.is_text_box());
2248    }
2249
2250    #[test]
2251    fn unknown_construction() {
2252        let ctrl = Control::Unknown {
2253            tag: "custom:widget".to_string(),
2254            data: Some("<data>value</data>".to_string()),
2255        };
2256        assert!(ctrl.is_unknown());
2257    }
2258
2259    #[test]
2260    fn unknown_without_data() {
2261        let ctrl = Control::Unknown { tag: "header".to_string(), data: None };
2262        assert!(ctrl.is_unknown());
2263    }
2264
2265    #[test]
2266    fn display_text_box() {
2267        let ctrl = Control::TextBox {
2268            paragraphs: vec![simple_paragraph(), simple_paragraph()],
2269            width: HwpUnit::from_mm(80.0).unwrap(),
2270            height: HwpUnit::from_mm(40.0).unwrap(),
2271            horz_offset: 0,
2272            vert_offset: 0,
2273            caption: None,
2274            style: None,
2275        };
2276        assert_eq!(ctrl.to_string(), "TextBox(2 paragraphs)");
2277    }
2278
2279    #[test]
2280    fn display_hyperlink() {
2281        let ctrl =
2282            Control::Hyperlink { text: "Short".to_string(), url: "https://x.com".to_string() };
2283        let s = ctrl.to_string();
2284        assert!(s.contains("Short"), "display: {s}");
2285        assert!(s.contains("https://x.com"), "display: {s}");
2286    }
2287
2288    #[test]
2289    fn display_hyperlink_long_text_truncated() {
2290        let ctrl =
2291            Control::Hyperlink { text: "A".repeat(100), url: "https://example.com".to_string() };
2292        let s = ctrl.to_string();
2293        // Should show first 30 chars
2294        assert!(s.contains(&"A".repeat(30)), "display: {s}");
2295        assert!(!s.contains(&"A".repeat(31)), "display: {s}");
2296    }
2297
2298    #[test]
2299    fn display_footnote() {
2300        let ctrl = Control::Footnote { inst_id: None, paragraphs: vec![simple_paragraph()] };
2301        assert_eq!(ctrl.to_string(), "Footnote(1 paragraph)");
2302    }
2303
2304    #[test]
2305    fn display_endnote() {
2306        let ctrl = Control::Endnote { inst_id: Some(999), paragraphs: vec![simple_paragraph()] };
2307        assert_eq!(ctrl.to_string(), "Endnote(1 paragraph)");
2308    }
2309
2310    #[test]
2311    fn display_unknown() {
2312        let ctrl = Control::Unknown { tag: "bookmark".to_string(), data: None };
2313        assert_eq!(ctrl.to_string(), "Unknown(bookmark)");
2314    }
2315
2316    #[test]
2317    fn equality() {
2318        let a = Control::Hyperlink { text: "A".to_string(), url: "B".to_string() };
2319        let b = Control::Hyperlink { text: "A".to_string(), url: "B".to_string() };
2320        let c = Control::Hyperlink { text: "A".to_string(), url: "C".to_string() };
2321        assert_eq!(a, b);
2322        assert_ne!(a, c);
2323    }
2324
2325    #[test]
2326    fn serde_roundtrip_text_box() {
2327        let ctrl = Control::TextBox {
2328            paragraphs: vec![simple_paragraph()],
2329            width: HwpUnit::from_mm(80.0).unwrap(),
2330            height: HwpUnit::from_mm(40.0).unwrap(),
2331            horz_offset: 0,
2332            vert_offset: 0,
2333            caption: None,
2334            style: None,
2335        };
2336        let json = serde_json::to_string(&ctrl).unwrap();
2337        let back: Control = serde_json::from_str(&json).unwrap();
2338        assert_eq!(ctrl, back);
2339    }
2340
2341    #[test]
2342    fn serde_roundtrip_hyperlink() {
2343        let ctrl = Control::Hyperlink {
2344            text: "link text".to_string(),
2345            url: "https://rust-lang.org".to_string(),
2346        };
2347        let json = serde_json::to_string(&ctrl).unwrap();
2348        let back: Control = serde_json::from_str(&json).unwrap();
2349        assert_eq!(ctrl, back);
2350    }
2351
2352    #[test]
2353    fn serde_roundtrip_footnote() {
2354        let ctrl = Control::Footnote { inst_id: Some(12345), paragraphs: vec![simple_paragraph()] };
2355        let json = serde_json::to_string(&ctrl).unwrap();
2356        let back: Control = serde_json::from_str(&json).unwrap();
2357        assert_eq!(ctrl, back);
2358    }
2359
2360    #[test]
2361    fn serde_roundtrip_endnote() {
2362        let ctrl = Control::Endnote { inst_id: None, paragraphs: vec![simple_paragraph()] };
2363        let json = serde_json::to_string(&ctrl).unwrap();
2364        let back: Control = serde_json::from_str(&json).unwrap();
2365        assert_eq!(ctrl, back);
2366    }
2367
2368    #[test]
2369    fn serde_roundtrip_unknown() {
2370        let ctrl = Control::Unknown { tag: "test".to_string(), data: Some("payload".to_string()) };
2371        let json = serde_json::to_string(&ctrl).unwrap();
2372        let back: Control = serde_json::from_str(&json).unwrap();
2373        assert_eq!(ctrl, back);
2374    }
2375
2376    // ── Shape variant tests ──────────────────────────────────────
2377
2378    #[test]
2379    fn line_construction() {
2380        let ctrl = Control::Line {
2381            start: ShapePoint { x: 0, y: 0 },
2382            end: ShapePoint { x: 1000, y: 500 },
2383            width: HwpUnit::from_mm(50.0).unwrap(),
2384            height: HwpUnit::from_mm(25.0).unwrap(),
2385            horz_offset: 0,
2386            vert_offset: 0,
2387            caption: None,
2388            style: None,
2389        };
2390        assert!(ctrl.is_line());
2391        assert!(!ctrl.is_text_box());
2392        assert!(!ctrl.is_ellipse());
2393        assert!(!ctrl.is_polygon());
2394    }
2395
2396    #[test]
2397    fn ellipse_construction() {
2398        let ctrl = Control::Ellipse {
2399            center: ShapePoint { x: 500, y: 500 },
2400            axis1: ShapePoint { x: 1000, y: 500 },
2401            axis2: ShapePoint { x: 500, y: 1000 },
2402            width: HwpUnit::from_mm(40.0).unwrap(),
2403            height: HwpUnit::from_mm(30.0).unwrap(),
2404            horz_offset: 0,
2405            vert_offset: 0,
2406            paragraphs: vec![],
2407            caption: None,
2408            style: None,
2409        };
2410        assert!(ctrl.is_ellipse());
2411        assert!(!ctrl.is_line());
2412        assert!(!ctrl.is_polygon());
2413    }
2414
2415    #[test]
2416    fn ellipse_with_paragraphs() {
2417        let ctrl = Control::Ellipse {
2418            center: ShapePoint { x: 500, y: 500 },
2419            axis1: ShapePoint { x: 1000, y: 500 },
2420            axis2: ShapePoint { x: 500, y: 1000 },
2421            width: HwpUnit::from_mm(40.0).unwrap(),
2422            height: HwpUnit::from_mm(30.0).unwrap(),
2423            horz_offset: 0,
2424            vert_offset: 0,
2425            paragraphs: vec![simple_paragraph()],
2426            caption: None,
2427            style: None,
2428        };
2429        assert!(ctrl.is_ellipse());
2430        assert_eq!(ctrl.to_string(), "Ellipse(1 paragraph)");
2431    }
2432
2433    #[test]
2434    fn polygon_construction() {
2435        let ctrl = Control::Polygon {
2436            vertices: vec![
2437                ShapePoint { x: 0, y: 0 },
2438                ShapePoint { x: 1000, y: 0 },
2439                ShapePoint { x: 500, y: 1000 },
2440            ],
2441            width: HwpUnit::from_mm(50.0).unwrap(),
2442            height: HwpUnit::from_mm(50.0).unwrap(),
2443            horz_offset: 0,
2444            vert_offset: 0,
2445            paragraphs: vec![],
2446            caption: None,
2447            style: None,
2448        };
2449        assert!(ctrl.is_polygon());
2450        assert!(!ctrl.is_line());
2451        assert!(!ctrl.is_ellipse());
2452        assert_eq!(ctrl.to_string(), "Polygon(3 vertices, 0 paragraphs)");
2453    }
2454
2455    #[test]
2456    fn display_line() {
2457        let ctrl = Control::Line {
2458            start: ShapePoint { x: 0, y: 0 },
2459            end: ShapePoint { x: 100, y: 200 },
2460            width: HwpUnit::from_mm(10.0).unwrap(),
2461            height: HwpUnit::from_mm(5.0).unwrap(),
2462            horz_offset: 0,
2463            vert_offset: 0,
2464            caption: None,
2465            style: None,
2466        };
2467        assert_eq!(ctrl.to_string(), "Line");
2468    }
2469
2470    #[test]
2471    fn serde_roundtrip_line() {
2472        let ctrl = Control::Line {
2473            start: ShapePoint { x: 100, y: 200 },
2474            end: ShapePoint { x: 300, y: 400 },
2475            width: HwpUnit::from_mm(20.0).unwrap(),
2476            height: HwpUnit::from_mm(10.0).unwrap(),
2477            horz_offset: 0,
2478            vert_offset: 0,
2479            caption: None,
2480            style: None,
2481        };
2482        let json = serde_json::to_string(&ctrl).unwrap();
2483        let back: Control = serde_json::from_str(&json).unwrap();
2484        assert_eq!(ctrl, back);
2485    }
2486
2487    #[test]
2488    fn serde_roundtrip_ellipse() {
2489        let ctrl = Control::Ellipse {
2490            center: ShapePoint { x: 500, y: 500 },
2491            axis1: ShapePoint { x: 1000, y: 500 },
2492            axis2: ShapePoint { x: 500, y: 1000 },
2493            width: HwpUnit::from_mm(40.0).unwrap(),
2494            height: HwpUnit::from_mm(30.0).unwrap(),
2495            horz_offset: 0,
2496            vert_offset: 0,
2497            paragraphs: vec![simple_paragraph()],
2498            caption: None,
2499            style: None,
2500        };
2501        let json = serde_json::to_string(&ctrl).unwrap();
2502        let back: Control = serde_json::from_str(&json).unwrap();
2503        assert_eq!(ctrl, back);
2504    }
2505
2506    #[test]
2507    fn serde_roundtrip_polygon() {
2508        let ctrl = Control::Polygon {
2509            vertices: vec![
2510                ShapePoint { x: 0, y: 0 },
2511                ShapePoint { x: 1000, y: 0 },
2512                ShapePoint { x: 500, y: 1000 },
2513            ],
2514            width: HwpUnit::from_mm(50.0).unwrap(),
2515            height: HwpUnit::from_mm(50.0).unwrap(),
2516            horz_offset: 0,
2517            vert_offset: 0,
2518            paragraphs: vec![],
2519            caption: None,
2520            style: None,
2521        };
2522        let json = serde_json::to_string(&ctrl).unwrap();
2523        let back: Control = serde_json::from_str(&json).unwrap();
2524        assert_eq!(ctrl, back);
2525    }
2526
2527    #[test]
2528    fn shape_point_equality() {
2529        let a = ShapePoint { x: 10, y: 20 };
2530        let b = ShapePoint { x: 10, y: 20 };
2531        let c = ShapePoint { x: 10, y: 30 };
2532        assert_eq!(a, b);
2533        assert_ne!(a, c);
2534    }
2535
2536    #[test]
2537    fn shape_point_new() {
2538        let pt = ShapePoint::new(100, 200);
2539        assert_eq!(pt.x, 100);
2540        assert_eq!(pt.y, 200);
2541    }
2542
2543    #[test]
2544    fn shape_point_serde_roundtrip() {
2545        let pt = ShapePoint::new(500, 750);
2546        let json = serde_json::to_string(&pt).unwrap();
2547        let back: ShapePoint = serde_json::from_str(&json).unwrap();
2548        assert_eq!(pt, back);
2549    }
2550
2551    // ── Convenience constructor tests ────────────────────────────────────
2552
2553    #[test]
2554    fn equation_constructor_defaults() {
2555        let ctrl = Control::equation("{a+b} over {c+d}");
2556        assert!(ctrl.is_equation());
2557        match ctrl {
2558            Control::Equation {
2559                script,
2560                width,
2561                height,
2562                base_line,
2563                text_color,
2564                ref font,
2565                inst_id: _,
2566            } => {
2567                assert_eq!(script, "{a+b} over {c+d}");
2568                assert_eq!(width, HwpUnit::new(8779).unwrap());
2569                assert_eq!(height, HwpUnit::new(2600).unwrap());
2570                assert_eq!(base_line, 71);
2571                assert_eq!(text_color, Color::BLACK);
2572                assert_eq!(font, "HancomEQN");
2573            }
2574            _ => panic!("expected Equation"),
2575        }
2576    }
2577
2578    #[test]
2579    fn equation_constructor_empty_script() {
2580        let ctrl = Control::equation("");
2581        assert!(ctrl.is_equation());
2582    }
2583
2584    #[test]
2585    fn text_box_constructor_defaults() {
2586        let width = HwpUnit::from_mm(80.0).unwrap();
2587        let height = HwpUnit::from_mm(40.0).unwrap();
2588        let ctrl = Control::text_box(vec![simple_paragraph()], width, height);
2589        assert!(ctrl.is_text_box());
2590        match ctrl {
2591            Control::TextBox { paragraphs, horz_offset, vert_offset, caption, style, .. } => {
2592                assert_eq!(paragraphs.len(), 1);
2593                assert_eq!(horz_offset, 0);
2594                assert_eq!(vert_offset, 0);
2595                assert!(caption.is_none());
2596                assert!(style.is_none());
2597            }
2598            _ => panic!("expected TextBox"),
2599        }
2600    }
2601
2602    #[test]
2603    fn footnote_constructor_defaults() {
2604        let ctrl = Control::footnote(vec![simple_paragraph()]);
2605        assert!(ctrl.is_footnote());
2606        match ctrl {
2607            Control::Footnote { inst_id, paragraphs } => {
2608                assert!(inst_id.is_none());
2609                assert_eq!(paragraphs.len(), 1);
2610            }
2611            _ => panic!("expected Footnote"),
2612        }
2613    }
2614
2615    #[test]
2616    fn endnote_constructor_defaults() {
2617        let ctrl = Control::endnote(vec![simple_paragraph()]);
2618        assert!(ctrl.is_endnote());
2619        match ctrl {
2620            Control::Endnote { inst_id, paragraphs } => {
2621                assert!(inst_id.is_none());
2622                assert_eq!(paragraphs.len(), 1);
2623            }
2624            _ => panic!("expected Endnote"),
2625        }
2626    }
2627
2628    #[test]
2629    fn ellipse_constructor_geometry() {
2630        let width = HwpUnit::from_mm(40.0).unwrap();
2631        let height = HwpUnit::from_mm(30.0).unwrap();
2632        let ctrl = Control::ellipse(width, height);
2633        assert!(ctrl.is_ellipse());
2634        match &ctrl {
2635            Control::Ellipse {
2636                center,
2637                axis1,
2638                axis2,
2639                horz_offset,
2640                vert_offset,
2641                paragraphs,
2642                caption,
2643                style,
2644                ..
2645            } => {
2646                let w = width.as_i32();
2647                let h = height.as_i32();
2648                assert_eq!(*center, ShapePoint::new(w / 2, h / 2));
2649                assert_eq!(*axis1, ShapePoint::new(w, h / 2));
2650                assert_eq!(*axis2, ShapePoint::new(w / 2, h));
2651                assert_eq!(*horz_offset, 0);
2652                assert_eq!(*vert_offset, 0);
2653                assert!(paragraphs.is_empty());
2654                assert!(caption.is_none());
2655                assert!(style.is_none());
2656            }
2657            _ => panic!("expected Ellipse"),
2658        }
2659    }
2660
2661    #[test]
2662    fn rect_constructor_basic_geometry() {
2663        let width = HwpUnit::from_mm(40.0).unwrap();
2664        let height = HwpUnit::from_mm(20.0).unwrap();
2665        let ctrl = Control::rect(width, height).unwrap();
2666        assert!(ctrl.is_rect());
2667        match ctrl {
2668            Control::Rect { width: w, height: h, horz_offset, vert_offset, caption, style } => {
2669                assert_eq!(w, width);
2670                assert_eq!(h, height);
2671                assert_eq!(horz_offset, 0);
2672                assert_eq!(vert_offset, 0);
2673                assert!(caption.is_none());
2674                assert!(style.is_none());
2675            }
2676            _ => panic!("expected Rect"),
2677        }
2678    }
2679
2680    #[test]
2681    fn rect_constructor_zero_dimension_errors() {
2682        let zero = HwpUnit::new(0).unwrap();
2683        let nonzero = HwpUnit::from_mm(10.0).unwrap();
2684        assert!(Control::rect(zero, nonzero).is_err());
2685        assert!(Control::rect(nonzero, zero).is_err());
2686    }
2687
2688    #[test]
2689    fn polygon_constructor_triangle() {
2690        let vertices =
2691            vec![ShapePoint::new(0, 1000), ShapePoint::new(500, 0), ShapePoint::new(1000, 1000)];
2692        let ctrl = Control::polygon(vertices).unwrap();
2693        assert!(ctrl.is_polygon());
2694        match &ctrl {
2695            Control::Polygon {
2696                vertices,
2697                width,
2698                height,
2699                horz_offset,
2700                vert_offset,
2701                paragraphs,
2702                caption,
2703                style,
2704            } => {
2705                assert_eq!(vertices.len(), 3);
2706                // bbox: x 0..1000, y 0..1000
2707                assert_eq!(*width, HwpUnit::new(1000).unwrap());
2708                assert_eq!(*height, HwpUnit::new(1000).unwrap());
2709                assert_eq!(*horz_offset, 0);
2710                assert_eq!(*vert_offset, 0);
2711                assert!(paragraphs.is_empty());
2712                assert!(caption.is_none());
2713                assert!(style.is_none());
2714            }
2715            _ => panic!("expected Polygon"),
2716        }
2717    }
2718
2719    #[test]
2720    fn polygon_constructor_fewer_than_3_vertices_errors() {
2721        assert!(Control::polygon(vec![]).is_err());
2722        assert!(Control::polygon(vec![ShapePoint::new(0, 0)]).is_err());
2723        assert!(Control::polygon(vec![ShapePoint::new(0, 0), ShapePoint::new(1, 1)]).is_err());
2724    }
2725
2726    #[test]
2727    fn polygon_constructor_negative_coordinates() {
2728        let vertices =
2729            vec![ShapePoint::new(-500, -500), ShapePoint::new(500, -500), ShapePoint::new(0, 500)];
2730        let ctrl = Control::polygon(vertices).unwrap();
2731        assert!(ctrl.is_polygon());
2732        match ctrl {
2733            Control::Polygon { width, height, .. } => {
2734                // bbox: x -500..500 = 1000, y -500..500 = 1000
2735                assert_eq!(width, HwpUnit::new(1000).unwrap());
2736                assert_eq!(height, HwpUnit::new(1000).unwrap());
2737            }
2738            _ => panic!("expected Polygon"),
2739        }
2740    }
2741
2742    #[test]
2743    fn polygon_constructor_degenerate_collinear() {
2744        // 3 collinear points: height = 0 (flat), should succeed
2745        let vertices =
2746            vec![ShapePoint::new(0, 0), ShapePoint::new(500, 0), ShapePoint::new(1000, 0)];
2747        let ctrl = Control::polygon(vertices).unwrap();
2748        assert!(ctrl.is_polygon());
2749        match ctrl {
2750            Control::Polygon { width, height, .. } => {
2751                assert_eq!(width, HwpUnit::new(1000).unwrap());
2752                assert_eq!(height, HwpUnit::new(0).unwrap());
2753            }
2754            _ => panic!("expected Polygon"),
2755        }
2756    }
2757
2758    #[test]
2759    fn line_constructor_horizontal() {
2760        let ctrl = Control::line(ShapePoint::new(0, 0), ShapePoint::new(5000, 0)).unwrap();
2761        assert!(ctrl.is_line());
2762        match ctrl {
2763            Control::Line {
2764                start,
2765                end,
2766                width,
2767                height,
2768                horz_offset,
2769                vert_offset,
2770                caption,
2771                style,
2772            } => {
2773                assert_eq!(start, ShapePoint::new(0, 0));
2774                assert_eq!(end, ShapePoint::new(5000, 0));
2775                assert_eq!(width, HwpUnit::new(5000).unwrap());
2776                assert_eq!(height, HwpUnit::new(100).unwrap()); // min bounding box
2777                assert_eq!(horz_offset, 0);
2778                assert_eq!(vert_offset, 0);
2779                assert!(caption.is_none());
2780                assert!(style.is_none());
2781            }
2782            _ => panic!("expected Line"),
2783        }
2784    }
2785
2786    #[test]
2787    fn line_constructor_vertical() {
2788        let ctrl = Control::line(ShapePoint::new(0, 0), ShapePoint::new(0, 3000)).unwrap();
2789        assert!(ctrl.is_line());
2790        match ctrl {
2791            Control::Line { width, height, .. } => {
2792                assert_eq!(width, HwpUnit::new(100).unwrap()); // min bounding box
2793                assert_eq!(height, HwpUnit::new(3000).unwrap());
2794            }
2795            _ => panic!("expected Line"),
2796        }
2797    }
2798
2799    #[test]
2800    fn line_constructor_diagonal_bounding_box() {
2801        let ctrl = Control::line(ShapePoint::new(100, 200), ShapePoint::new(400, 500)).unwrap();
2802        match ctrl {
2803            Control::Line { width, height, .. } => {
2804                assert_eq!(width, HwpUnit::new(300).unwrap());
2805                assert_eq!(height, HwpUnit::new(300).unwrap());
2806            }
2807            _ => panic!("expected Line"),
2808        }
2809    }
2810
2811    #[test]
2812    fn line_constructor_same_point_errors() {
2813        let pt = ShapePoint::new(100, 200);
2814        assert!(Control::line(pt, pt).is_err());
2815    }
2816
2817    #[test]
2818    fn horizontal_line_constructor() {
2819        let width = HwpUnit::from_mm(100.0).unwrap();
2820        let ctrl = Control::horizontal_line(width);
2821        assert!(ctrl.is_line());
2822        match ctrl {
2823            Control::Line {
2824                start,
2825                end,
2826                width: w,
2827                height,
2828                horz_offset,
2829                vert_offset,
2830                caption,
2831                style,
2832            } => {
2833                assert_eq!(start, ShapePoint::new(0, 0));
2834                assert_eq!(end.y, 0);
2835                assert_eq!(end.x, width.as_i32());
2836                assert_eq!(w, width);
2837                assert_eq!(height, HwpUnit::new(100).unwrap()); // min bounding box
2838                assert_eq!(horz_offset, 0);
2839                assert_eq!(vert_offset, 0);
2840                assert!(caption.is_none());
2841                assert!(style.is_none());
2842            }
2843            _ => panic!("expected Line"),
2844        }
2845    }
2846
2847    #[test]
2848    fn hyperlink_constructor() {
2849        let ctrl = Control::hyperlink("Visit Rust", "https://rust-lang.org");
2850        assert!(ctrl.is_hyperlink());
2851        match ctrl {
2852            Control::Hyperlink { text, url } => {
2853                assert_eq!(text, "Visit Rust");
2854                assert_eq!(url, "https://rust-lang.org");
2855            }
2856            _ => panic!("expected Hyperlink"),
2857        }
2858    }
2859
2860    #[test]
2861    fn footnote_with_id_sets_inst_id() {
2862        let para = Paragraph::new(ParaShapeIndex::new(0));
2863        let ctrl = Control::footnote_with_id(42, vec![para]);
2864        assert!(ctrl.is_footnote());
2865        match ctrl {
2866            Control::Footnote { inst_id, paragraphs } => {
2867                assert_eq!(inst_id, Some(42));
2868                assert_eq!(paragraphs.len(), 1);
2869            }
2870            _ => panic!("expected Footnote"),
2871        }
2872    }
2873
2874    #[test]
2875    fn endnote_with_id_sets_inst_id() {
2876        let para = Paragraph::new(ParaShapeIndex::new(0));
2877        let ctrl = Control::endnote_with_id(7, vec![para]);
2878        assert!(ctrl.is_endnote());
2879        match ctrl {
2880            Control::Endnote { inst_id, paragraphs } => {
2881                assert_eq!(inst_id, Some(7));
2882                assert_eq!(paragraphs.len(), 1);
2883            }
2884            _ => panic!("expected Endnote"),
2885        }
2886    }
2887
2888    #[test]
2889    fn footnote_with_id_differs_from_plain_footnote() {
2890        let ctrl_plain = Control::footnote(vec![]);
2891        let ctrl_id = Control::footnote_with_id(1, vec![]);
2892        match ctrl_plain {
2893            Control::Footnote { inst_id, .. } => assert_eq!(inst_id, None),
2894            _ => panic!("expected Footnote"),
2895        }
2896        match ctrl_id {
2897            Control::Footnote { inst_id, .. } => assert_eq!(inst_id, Some(1)),
2898            _ => panic!("expected Footnote"),
2899        }
2900    }
2901
2902    #[test]
2903    fn ellipse_with_text_has_correct_geometry_and_paragraphs() {
2904        use hwpforge_foundation::HwpUnit;
2905        let width = HwpUnit::from_mm(40.0).unwrap();
2906        let height = HwpUnit::from_mm(30.0).unwrap();
2907        let para = Paragraph::new(ParaShapeIndex::new(0));
2908        let ctrl = Control::ellipse_with_text(width, height, vec![para]);
2909        assert!(ctrl.is_ellipse());
2910        match ctrl {
2911            Control::Ellipse {
2912                center,
2913                axis1,
2914                axis2,
2915                width: w,
2916                height: h,
2917                horz_offset,
2918                vert_offset,
2919                paragraphs,
2920                caption,
2921                style,
2922            } => {
2923                let wv = w.as_i32();
2924                let hv = h.as_i32();
2925                assert_eq!(center, ShapePoint::new(wv / 2, hv / 2));
2926                assert_eq!(axis1, ShapePoint::new(wv, hv / 2));
2927                assert_eq!(axis2, ShapePoint::new(wv / 2, hv));
2928                assert_eq!(horz_offset, 0);
2929                assert_eq!(vert_offset, 0);
2930                assert_eq!(paragraphs.len(), 1);
2931                assert!(caption.is_none());
2932                assert!(style.is_none());
2933            }
2934            _ => panic!("expected Ellipse"),
2935        }
2936    }
2937
2938    #[test]
2939    fn serde_roundtrip_chart() {
2940        use crate::chart::{ChartData, ChartGrouping, ChartType, LegendPosition};
2941        let ctrl = Control::Chart {
2942            chart_type: ChartType::Column,
2943            data: ChartData::category(&["A", "B"], &[("S1", &[1.0, 2.0])]),
2944            title: Some("Test Chart".to_string()),
2945            legend: LegendPosition::Bottom,
2946            grouping: ChartGrouping::Stacked,
2947            width: HwpUnit::from_mm(100.0).unwrap(),
2948            height: HwpUnit::from_mm(80.0).unwrap(),
2949            stock_variant: None,
2950            bar_shape: None,
2951            scatter_style: None,
2952            radar_style: None,
2953            of_pie_type: None,
2954            explosion: None,
2955            wireframe: None,
2956            bubble_3d: None,
2957            show_markers: None,
2958        };
2959        let json = serde_json::to_string(&ctrl).unwrap();
2960        let back: Control = serde_json::from_str(&json).unwrap();
2961        assert_eq!(ctrl, back);
2962    }
2963
2964    #[test]
2965    fn serde_roundtrip_equation() {
2966        let ctrl = Control::Equation {
2967            script: "{a+b} over {c+d}".to_string(),
2968            width: HwpUnit::new(8779).unwrap(),
2969            height: HwpUnit::new(2600).unwrap(),
2970            base_line: 71,
2971            text_color: Color::BLACK,
2972            font: "HancomEQN".to_string(),
2973            inst_id: None,
2974        };
2975        let json = serde_json::to_string(&ctrl).unwrap();
2976        let back: Control = serde_json::from_str(&json).unwrap();
2977        assert_eq!(ctrl, back);
2978    }
2979
2980    #[test]
2981    fn ellipse_with_text_empty_paragraphs_matches_ellipse() {
2982        use hwpforge_foundation::HwpUnit;
2983        let width = HwpUnit::from_mm(20.0).unwrap();
2984        let height = HwpUnit::from_mm(10.0).unwrap();
2985        let plain = Control::ellipse(width, height);
2986        let with_text = Control::ellipse_with_text(width, height, vec![]);
2987        // Both should produce identical shapes when paragraphs are empty
2988        assert_eq!(plain, with_text);
2989    }
2990
2991    // ── Dutmal (덧말) tests ──────────────────────────────────────
2992
2993    #[test]
2994    fn dutmal_constructor_defaults() {
2995        let ctrl = Control::dutmal("본문", "주석");
2996        assert!(ctrl.is_dutmal());
2997        match ctrl {
2998            Control::Dutmal { main_text, sub_text, position, sz_ratio, align, .. } => {
2999                assert_eq!(main_text, "본문");
3000                assert_eq!(sub_text, "주석");
3001                assert_eq!(position, DutmalPosition::Top);
3002                assert_eq!(sz_ratio, 0);
3003                assert_eq!(align, DutmalAlign::Center);
3004            }
3005            _ => panic!("expected Dutmal"),
3006        }
3007    }
3008
3009    #[test]
3010    fn dutmal_is_dutmal_true() {
3011        assert!(Control::dutmal("a", "b").is_dutmal());
3012    }
3013
3014    #[test]
3015    fn dutmal_is_compose_false() {
3016        assert!(!Control::dutmal("a", "b").is_compose());
3017    }
3018
3019    #[test]
3020    fn dutmal_display() {
3021        let ctrl = Control::dutmal("hello", "world");
3022        assert_eq!(ctrl.to_string(), r#"Dutmal("hello" / "world")"#);
3023    }
3024
3025    #[test]
3026    fn dutmal_serde_roundtrip() {
3027        let ctrl = Control::Dutmal {
3028            main_text: "테스트".to_string(),
3029            sub_text: "test".to_string(),
3030            position: DutmalPosition::Bottom,
3031            sz_ratio: 50,
3032            align: DutmalAlign::Right,
3033            metadata: DutmalMetadata::default(),
3034        };
3035        let json = serde_json::to_string(&ctrl).unwrap();
3036        let decoded: Control = serde_json::from_str(&json).unwrap();
3037        assert_eq!(ctrl, decoded);
3038    }
3039
3040    #[test]
3041    fn dutmal_position_default_is_top() {
3042        assert_eq!(DutmalPosition::default(), DutmalPosition::Top);
3043    }
3044
3045    #[test]
3046    fn dutmal_align_default_is_center() {
3047        assert_eq!(DutmalAlign::default(), DutmalAlign::Center);
3048    }
3049
3050    // ── Compose (글자겹침) tests ─────────────────────────────────
3051
3052    #[test]
3053    fn compose_constructor_defaults() {
3054        let ctrl = Control::compose("가");
3055        assert!(ctrl.is_compose());
3056        match ctrl {
3057            Control::Compose { compose_text, circle_type, char_sz, compose_type, char_pr_ids } => {
3058                assert_eq!(compose_text, "가");
3059                assert_eq!(circle_type, "SHAPE_REVERSAL_TIRANGLE");
3060                assert_eq!(char_sz, -3);
3061                assert_eq!(compose_type, "SPREAD");
3062                assert_eq!(char_pr_ids, vec![u32::MAX; 10]);
3063            }
3064            _ => panic!("expected Compose"),
3065        }
3066    }
3067
3068    #[test]
3069    fn compose_is_compose_true() {
3070        assert!(Control::compose("나").is_compose());
3071    }
3072
3073    #[test]
3074    fn compose_is_dutmal_false() {
3075        assert!(!Control::compose("나").is_dutmal());
3076    }
3077
3078    #[test]
3079    fn compose_display() {
3080        let ctrl = Control::compose("가나");
3081        assert_eq!(ctrl.to_string(), r#"Compose("가나")"#);
3082    }
3083
3084    #[test]
3085    fn compose_serde_roundtrip() {
3086        let ctrl = Control::Compose {
3087            compose_text: "①".to_string(),
3088            circle_type: "SHAPE_REVERSAL_TIRANGLE".to_string(),
3089            char_sz: -3,
3090            compose_type: "SPREAD".to_string(),
3091            char_pr_ids: vec![u32::MAX; 10],
3092        };
3093        let json = serde_json::to_string(&ctrl).unwrap();
3094        let decoded: Control = serde_json::from_str(&json).unwrap();
3095        assert_eq!(ctrl, decoded);
3096    }
3097
3098    #[test]
3099    fn compose_spec_typo_preserved() {
3100        // "SHAPE_REVERSAL_TIRANGLE" is an official spec typo — must be preserved exactly
3101        let ctrl = Control::compose("X");
3102        match ctrl {
3103            Control::Compose { circle_type, .. } => {
3104                assert_eq!(circle_type, "SHAPE_REVERSAL_TIRANGLE");
3105                assert!(!circle_type.contains("TRIANGLE")); // confirm the typo
3106            }
3107            _ => panic!("expected Compose"),
3108        }
3109    }
3110
3111    // ===================================================================
3112    // H2: saturating i64→i32 conversion in shape constructors
3113    // ===================================================================
3114
3115    #[test]
3116    fn line_extreme_coords_no_panic() {
3117        // Coordinates near i32 extremes produce a valid line without panicking
3118        let start = ShapePoint::new(i32::MIN, i32::MIN);
3119        let end = ShapePoint::new(i32::MAX, i32::MAX);
3120        let ctrl = Control::line(start, end).unwrap();
3121        assert!(ctrl.is_line());
3122    }
3123
3124    #[test]
3125    fn connect_line_extreme_coords_no_panic() {
3126        let start = ShapePoint::new(i32::MIN, 0);
3127        let end = ShapePoint::new(i32::MAX, 0);
3128        let ctrl = Control::connect_line(start, end).unwrap();
3129        assert!(ctrl.is_connect_line());
3130    }
3131
3132    #[test]
3133    fn polygon_extreme_coords_no_panic() {
3134        // Span exceeds i32::MAX — should error (HwpUnit range exceeded), not panic
3135        let vertices = vec![
3136            ShapePoint::new(i32::MIN, 0),
3137            ShapePoint::new(i32::MAX, 0),
3138            ShapePoint::new(0, i32::MAX),
3139        ];
3140        // Either succeeds (saturated) or returns an error — must not panic
3141        let _ = Control::polygon(vertices);
3142    }
3143
3144    #[test]
3145    fn curve_extreme_coords_no_panic() {
3146        let points = vec![ShapePoint::new(i32::MIN, i32::MIN), ShapePoint::new(i32::MAX, i32::MAX)];
3147        let _ = Control::curve(points);
3148    }
3149}