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;
39
40/// A 2D point in raw HWPUNIT coordinates for shape geometry.
41///
42/// Uses `i32` (not `HwpUnit`) because shape geometry points are raw
43/// coordinate values within a bounding box, not document-level measurements.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
45pub struct ShapePoint {
46    /// X coordinate (HWPUNIT).
47    pub x: i32,
48    /// Y coordinate (HWPUNIT).
49    pub y: i32,
50}
51
52impl ShapePoint {
53    /// Creates a new shape point with the given coordinates.
54    ///
55    /// # Examples
56    ///
57    /// ```
58    /// use hwpforge_core::control::ShapePoint;
59    ///
60    /// let pt = ShapePoint::new(100, 200);
61    /// assert_eq!(pt.x, 100);
62    /// assert_eq!(pt.y, 200);
63    /// ```
64    pub fn new(x: i32, y: i32) -> Self {
65        Self { x, y }
66    }
67}
68
69/// Line drawing style for shapes.
70///
71/// Controls how the stroke of a shape is rendered (solid, dashed, etc.).
72/// Maps to HWPX `<hc:lineShape>` `dash` attribute values.
73///
74/// # Examples
75///
76/// ```
77/// use hwpforge_core::control::LineStyle;
78///
79/// let style = LineStyle::Dash;
80/// assert_eq!(style.to_string(), "DASH");
81/// assert_eq!("DOT".parse::<LineStyle>().unwrap(), LineStyle::Dot);
82/// ```
83#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
84#[non_exhaustive]
85pub enum LineStyle {
86    /// Continuous solid line (default).
87    #[default]
88    Solid,
89    /// Dashed line.
90    Dash,
91    /// Dotted line.
92    Dot,
93    /// Alternating dash and dot.
94    DashDot,
95    /// Alternating dash, dot, dot.
96    DashDotDot,
97    /// No visible line.
98    None,
99}
100
101impl std::fmt::Display for LineStyle {
102    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
103        match self {
104            Self::Solid => f.write_str("SOLID"),
105            Self::Dash => f.write_str("DASH"),
106            Self::Dot => f.write_str("DOT"),
107            Self::DashDot => f.write_str("DASH_DOT"),
108            Self::DashDotDot => f.write_str("DASH_DOT_DOT"),
109            Self::None => f.write_str("NONE"),
110        }
111    }
112}
113
114impl std::str::FromStr for LineStyle {
115    type Err = CoreError;
116
117    fn from_str(s: &str) -> Result<Self, Self::Err> {
118        match s {
119            "SOLID" | "Solid" | "solid" => Ok(Self::Solid),
120            "DASH" | "Dash" | "dash" => Ok(Self::Dash),
121            "DOT" | "Dot" | "dot" => Ok(Self::Dot),
122            "DASH_DOT" | "DashDot" | "dash_dot" => Ok(Self::DashDot),
123            "DASH_DOT_DOT" | "DashDotDot" | "dash_dot_dot" => Ok(Self::DashDotDot),
124            "NONE" | "None" | "none" => Ok(Self::None),
125            _ => Err(CoreError::InvalidStructure {
126                context: "LineStyle".to_string(),
127                reason: format!(
128                    "unknown line style '{s}', valid: SOLID, DASH, DOT, DASH_DOT, DASH_DOT_DOT, NONE"
129                ),
130            }),
131        }
132    }
133}
134
135/// Arrowhead style for line endpoints.
136///
137/// # Examples
138///
139/// ```
140/// use hwpforge_core::control::ArrowStyle;
141/// use hwpforge_foundation::{ArrowType, ArrowSize};
142///
143/// let arrow = ArrowStyle {
144///     arrow_type: ArrowType::Normal,
145///     size: ArrowSize::Medium,
146///     filled: true,
147/// };
148/// ```
149#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
150pub struct ArrowStyle {
151    /// Shape of the arrowhead.
152    pub arrow_type: ArrowType,
153    /// Size of the arrowhead.
154    pub size: ArrowSize,
155    /// Whether the arrowhead is filled (true) or outlined (false).
156    pub filled: bool,
157}
158
159/// Fill specification for shapes.
160///
161/// Replaces simple `fill_color` for shapes that need gradient, pattern, or image fills.
162///
163/// # Examples
164///
165/// ```
166/// use hwpforge_core::control::Fill;
167/// use hwpforge_foundation::Color;
168///
169/// let solid = Fill::Solid { color: Color::from_rgb(255, 0, 0) };
170/// ```
171#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
172#[non_exhaustive]
173pub enum Fill {
174    /// Solid color fill.
175    Solid {
176        /// Fill color.
177        color: Color,
178    },
179    /// Gradient fill.
180    Gradient {
181        /// Gradient direction type.
182        gradient_type: GradientType,
183        /// Gradient angle in degrees.
184        angle: i32,
185        /// Color stops: (color, position 0-100).
186        colors: Vec<(Color, u32)>,
187    },
188    /// Hatch pattern fill.
189    Pattern {
190        /// Pattern type.
191        pattern_type: PatternType,
192        /// Foreground pattern color.
193        fg_color: Color,
194        /// Background color.
195        bg_color: Color,
196    },
197    /// Image fill.
198    Image {
199        /// Image binary data reference ID.
200        image_id: String,
201        /// Image fill mode (tile, stretch, etc.).
202        mode: ImageFillMode,
203    },
204}
205
206/// Visual style overrides for drawing shapes.
207///
208/// All fields are `Option`; `None` means "use the encoder's default"
209/// (typically black solid border, white fill, 0.12 mm stroke).
210///
211/// # Examples
212///
213/// ```
214/// use hwpforge_core::control::{ShapeStyle, LineStyle};
215/// use hwpforge_foundation::Color;
216///
217/// let style = ShapeStyle {
218///     line_color: Some(Color::from_rgb(255, 0, 0)),
219///     fill_color: Some(Color::from_rgb(0, 255, 0)),
220///     line_width: Some(100),
221///     line_style: Some(LineStyle::Dash),
222///     ..Default::default()
223/// };
224/// ```
225#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
226pub struct ShapeStyle {
227    /// Stroke/border color (e.g. `Color::from_rgb(255, 0, 0)` for red).
228    pub line_color: Option<Color>,
229    /// Fill color (e.g. `Color::from_rgb(0, 255, 0)` for green).
230    /// For advanced fills (gradient, pattern, image), use the `fill` field instead.
231    pub fill_color: Option<Color>,
232    /// Stroke width in HWPUNIT (33 ≈ 0.12mm, 100 ≈ 0.35mm).
233    pub line_width: Option<u32>,
234    /// Line drawing style (solid, dash, dot, etc.).
235    pub line_style: Option<LineStyle>,
236    /// Rotation angle in degrees (0-360). `None` means no rotation.
237    pub rotation: Option<f32>,
238    /// Flip/mirror state. `None` means no flip.
239    pub flip: Option<Flip>,
240    /// Arrowhead at the start of a line. Only meaningful for `Control::Line`.
241    pub head_arrow: Option<ArrowStyle>,
242    /// Arrowhead at the end of a line. Only meaningful for `Control::Line`.
243    pub tail_arrow: Option<ArrowStyle>,
244    /// Advanced fill (gradient, pattern, image). Overrides `fill_color` when present.
245    pub fill: Option<Fill>,
246    /// Drop cap style for the shape (HWPX `dropcapstyle` attribute).
247    /// Controls whether the shape participates in a drop-cap layout.
248    #[serde(default)]
249    pub drop_cap_style: DropCapStyle,
250}
251
252/// An inline control element.
253///
254/// Controls are non-text elements that appear within a Run.
255/// Each variant carries its own data; the enum is `#[non_exhaustive]`
256/// for forward compatibility.
257///
258/// # Examples
259///
260/// ```
261/// use hwpforge_core::control::Control;
262/// use hwpforge_core::paragraph::Paragraph;
263/// use hwpforge_foundation::{HwpUnit, ParaShapeIndex};
264///
265/// let text_box = Control::TextBox {
266///     paragraphs: vec![Paragraph::new(ParaShapeIndex::new(0))],
267///     width: HwpUnit::from_mm(80.0).unwrap(),
268///     height: HwpUnit::from_mm(40.0).unwrap(),
269///     horz_offset: 0,
270///     vert_offset: 0,
271///     caption: None,
272///     style: None,
273/// };
274/// assert!(text_box.is_text_box());
275/// assert!(!text_box.is_hyperlink());
276/// ```
277#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
278#[non_exhaustive]
279pub enum Control {
280    /// An inline text box with its own paragraph content.
281    /// Maps to HWPX `<hp:rect>` + `<hp:drawText>` (drawing object, not control).
282    TextBox {
283        /// Paragraphs inside the text box.
284        paragraphs: Vec<Paragraph>,
285        /// Box width (HWPUNIT).
286        width: HwpUnit,
287        /// Box height (HWPUNIT).
288        height: HwpUnit,
289        /// Horizontal offset from anchor point (HWPUNIT, 0 = inline/treat-as-char).
290        horz_offset: i32,
291        /// Vertical offset from anchor point (HWPUNIT, 0 = inline/treat-as-char).
292        vert_offset: i32,
293        /// Optional caption attached to this text box.
294        caption: Option<Caption>,
295        /// Optional visual style overrides (border color, fill, line width).
296        style: Option<ShapeStyle>,
297    },
298
299    /// A hyperlink with display text and URL.
300    Hyperlink {
301        /// Visible text of the link.
302        text: String,
303        /// Target URL.
304        url: String,
305    },
306
307    /// A footnote containing paragraph content.
308    /// Maps to HWPX `<hp:ctrl><hp:footNote>`.
309    Footnote {
310        /// Instance identifier (unique ID for linking, optional).
311        inst_id: Option<u32>,
312        /// Paragraphs that form the footnote body.
313        paragraphs: Vec<Paragraph>,
314    },
315
316    /// An endnote containing paragraph content.
317    /// Maps to HWPX `<hp:ctrl><hp:endNote>`.
318    Endnote {
319        /// Instance identifier (unique ID for linking, optional).
320        inst_id: Option<u32>,
321        /// Paragraphs that form the endnote body.
322        paragraphs: Vec<Paragraph>,
323    },
324
325    /// A line drawing object (2 endpoints).
326    /// Maps to HWPX `<hp:line>`.
327    Line {
328        /// Start point (x, y in HWPUNIT).
329        start: ShapePoint,
330        /// End point (x, y in HWPUNIT).
331        end: ShapePoint,
332        /// Bounding box width (HWPUNIT).
333        width: HwpUnit,
334        /// Bounding box height (HWPUNIT).
335        height: HwpUnit,
336        /// Horizontal offset from anchor point (HWPUNIT, 0 = inline/treat-as-char).
337        horz_offset: i32,
338        /// Vertical offset from anchor point (HWPUNIT, 0 = inline/treat-as-char).
339        vert_offset: i32,
340        /// Optional caption attached to this line.
341        caption: Option<Caption>,
342        /// Optional visual style overrides (border color, fill, line width).
343        style: Option<ShapeStyle>,
344    },
345
346    /// An ellipse (or circle) drawing object.
347    /// Maps to HWPX `<hp:ellipse>`.
348    Ellipse {
349        /// Center point (x, y in HWPUNIT).
350        center: ShapePoint,
351        /// Axis 1 endpoint (defines semi-major axis direction and length).
352        axis1: ShapePoint,
353        /// Axis 2 endpoint (perpendicular to axis1, defines semi-minor axis).
354        axis2: ShapePoint,
355        /// Bounding box width (HWPUNIT).
356        width: HwpUnit,
357        /// Bounding box height (HWPUNIT).
358        height: HwpUnit,
359        /// Horizontal offset from anchor point (HWPUNIT, 0 = inline/treat-as-char).
360        horz_offset: i32,
361        /// Vertical offset from anchor point (HWPUNIT, 0 = inline/treat-as-char).
362        vert_offset: i32,
363        /// Optional text content inside the ellipse.
364        paragraphs: Vec<Paragraph>,
365        /// Optional caption attached to this ellipse.
366        caption: Option<Caption>,
367        /// Optional visual style overrides (border color, fill, line width).
368        style: Option<ShapeStyle>,
369    },
370
371    /// A HWP5 chart carried as opaque OOXML + OLE blob passthrough.
372    ///
373    /// Used when chart data is extracted from a HWP5 BinData OLE container
374    /// and emitted to HWPX without round-tripping through the structured
375    /// [`Control::Chart`] data model. Renders in 한컴 via the `<hp:switch>`
376    /// block with full OOXML chart inside `<hp:case>` and an OLE fallback
377    /// inside `<hp:default>`.
378    ///
379    /// Wave 4c passthrough: the chart XML and OLE bytes are carried as-is
380    /// from the source HWP5 file. The encoder writes:
381    /// - `Chart/chartN.xml` (NOT registered in manifest — gotcha #5)
382    /// - `BinData/oleN.ole` (registered in `content.hpf` as `application/ole`)
383    /// - section `<hp:switch>` with `<hp:case>` chart + `<hp:default>` ole
384    EmbeddedChart {
385        /// Full OOXML chart XML (starts with `<?xml`, contains `<c:chartSpace>`).
386        chart_xml: String,
387        /// Raw OLE2 compound file bytes for `<hp:ole>` fallback rendering.
388        ole_bytes: Vec<u8>,
389        /// Chart width (HWPUNIT).
390        width: HwpUnit,
391        /// Chart height (HWPUNIT).
392        height: HwpUnit,
393        /// Horizontal offset from anchor point (HWPUNIT, 0 = inline/treat-as-char).
394        horz_offset: i32,
395        /// Vertical offset from anchor point (HWPUNIT, 0 = inline/treat-as-char).
396        vert_offset: i32,
397    },
398
399    /// A pure rectangle drawing object (no embedded text).
400    ///
401    /// Distinct from [`Control::TextBox`], which uses `<hp:rect>` with a
402    /// `<hp:drawText>` child for inline text. A pure `Rect` carries only the
403    /// rectangle geometry and visual style and emits `<hp:rect>` without
404    /// `<hp:drawText>`.
405    Rect {
406        /// Bounding box width (HWPUNIT).
407        width: HwpUnit,
408        /// Bounding box height (HWPUNIT).
409        height: HwpUnit,
410        /// Horizontal offset from anchor point (HWPUNIT, 0 = inline/treat-as-char).
411        horz_offset: i32,
412        /// Vertical offset from anchor point (HWPUNIT, 0 = inline/treat-as-char).
413        vert_offset: i32,
414        /// Optional caption attached to this rectangle.
415        caption: Option<Caption>,
416        /// Optional visual style overrides (border color, fill, line width).
417        style: Option<ShapeStyle>,
418    },
419
420    /// A polygon drawing object (3+ vertices).
421    /// Maps to HWPX `<hp:polygon>`.
422    Polygon {
423        /// Ordered list of vertices (minimum 3).
424        vertices: Vec<ShapePoint>,
425        /// Bounding box width (HWPUNIT).
426        width: HwpUnit,
427        /// Bounding box height (HWPUNIT).
428        height: HwpUnit,
429        /// Horizontal offset from anchor point (HWPUNIT, 0 = inline/treat-as-char).
430        horz_offset: i32,
431        /// Vertical offset from anchor point (HWPUNIT, 0 = inline/treat-as-char).
432        vert_offset: i32,
433        /// Optional text content inside the polygon.
434        paragraphs: Vec<Paragraph>,
435        /// Optional caption attached to this polygon.
436        caption: Option<Caption>,
437        /// Optional visual style overrides (border color, fill, line width).
438        style: Option<ShapeStyle>,
439    },
440
441    /// An inline equation (수식) using HancomEQN script format.
442    /// Maps to HWPX `<hp:equation>` with `<hp:script>` child.
443    ///
444    /// Equations have NO shape common block (no offset, orgSz, curSz, flip,
445    /// rotation, lineShape, fillBrush, shadow). Only sz + pos + outMargin + script.
446    Equation {
447        /// HancomEQN script text (e.g. `"{a+b} over {c+d}"`).
448        script: String,
449        /// Bounding box width (HWPUNIT).
450        width: HwpUnit,
451        /// Bounding box height (HWPUNIT).
452        height: HwpUnit,
453        /// Baseline position (51-90 typical range).
454        base_line: u32,
455        /// Text color.
456        text_color: Color,
457        /// Font name (typically `"HancomEQN"`).
458        font: String,
459    },
460
461    /// An OOXML chart embedded in the document.
462    /// Maps to HWPX `<hp:switch><hp:case><hp:chart>` with separate Chart XML file.
463    ///
464    /// Charts have NO shape common block (like Equation): only sz + pos + outMargin.
465    Chart {
466        /// Chart type (18 variants covering all OOXML chart types).
467        chart_type: ChartType,
468        /// Chart data (category-based or XY-based).
469        data: ChartData,
470        /// Chart width (HWPUNIT, default ~32250 ≈ 114mm).
471        width: HwpUnit,
472        /// Chart height (HWPUNIT, default ~18750 ≈ 66mm).
473        height: HwpUnit,
474        /// Optional chart title.
475        title: Option<String>,
476        /// Legend position.
477        legend: LegendPosition,
478        /// Series grouping mode.
479        grouping: ChartGrouping,
480        /// 3D bar/column shape (None = default Box).
481        bar_shape: Option<BarShape>,
482        /// Exploded pie/doughnut percentage (None = not exploded, Some(25) = 25% explosion).
483        explosion: Option<u32>,
484        /// Pie-of-pie or bar-of-pie sub-type (None = default pie-of-pie).
485        of_pie_type: Option<OfPieType>,
486        /// Radar chart rendering style (None = default Standard).
487        radar_style: Option<RadarStyle>,
488        /// Surface chart wireframe mode (None = default solid).
489        wireframe: Option<bool>,
490        /// 3D bubble effect (None = default flat).
491        bubble_3d: Option<bool>,
492        /// Scatter chart style (None = default Dots).
493        scatter_style: Option<ScatterStyle>,
494        /// Show data point markers on line charts (None = no markers).
495        show_markers: Option<bool>,
496        /// Stock chart sub-variant (None = default HLC, 3 series).
497        ///
498        /// VHLC and VOHLC generate a composite `<c:plotArea>` with both
499        /// `<c:barChart>` (volume) and `<c:stockChart>` (price) elements.
500        stock_variant: Option<StockVariant>,
501    },
502
503    /// Dutmal (덧말): annotation text displayed above or below main text.
504    /// Maps to HWPX `<hp:dutmal>`.
505    Dutmal {
506        /// Main text that receives the annotation.
507        main_text: String,
508        /// Annotation text displayed above/below.
509        sub_text: String,
510        /// Position of the annotation relative to main text.
511        position: DutmalPosition,
512        /// Size ratio of annotation text relative to main (0 = auto).
513        sz_ratio: u32,
514        /// Alignment of the annotation text.
515        align: DutmalAlign,
516    },
517
518    /// Compose (글자겹침): overlaid/combined characters.
519    /// Maps to HWPX `<hp:compose>`.
520    Compose {
521        /// The combined text (e.g. "12" for two overlaid digits).
522        compose_text: String,
523        /// Circle/frame type for the composition.
524        circle_type: String,
525        /// Character size adjustment (-3 = slightly smaller).
526        char_sz: i32,
527        /// Composition layout type.
528        compose_type: String,
529    },
530
531    /// An arc (partial ellipse) drawing object.
532    /// Maps to HWPX `<hp:ellipse>` with `hasArcPr="1"`.
533    Arc {
534        /// Arc type (normal open arc, pie/sector, chord).
535        arc_type: ArcType,
536        /// Center point of the parent ellipse.
537        center: ShapePoint,
538        /// Axis 1 endpoint (semi-major axis).
539        axis1: ShapePoint,
540        /// Axis 2 endpoint (semi-minor axis).
541        axis2: ShapePoint,
542        /// Arc start point 1.
543        start1: ShapePoint,
544        /// Arc end point 1.
545        end1: ShapePoint,
546        /// Arc start point 2.
547        start2: ShapePoint,
548        /// Arc end point 2.
549        end2: ShapePoint,
550        /// Bounding box width (HWPUNIT).
551        width: HwpUnit,
552        /// Bounding box height (HWPUNIT).
553        height: HwpUnit,
554        /// Horizontal offset from anchor point (HWPUNIT, 0 = inline/treat-as-char).
555        horz_offset: i32,
556        /// Vertical offset from anchor point (HWPUNIT, 0 = inline/treat-as-char).
557        vert_offset: i32,
558        /// Optional caption attached to this arc.
559        caption: Option<Caption>,
560        /// Optional visual style overrides.
561        style: Option<ShapeStyle>,
562    },
563
564    /// A curve drawing object (bezier/polyline).
565    /// Maps to HWPX `<hp:curve>`.
566    Curve {
567        /// Ordered control points for the curve path.
568        points: Vec<ShapePoint>,
569        /// Segment types (one per segment between points).
570        segment_types: Vec<CurveSegmentType>,
571        /// Bounding box width (HWPUNIT).
572        width: HwpUnit,
573        /// Bounding box height (HWPUNIT).
574        height: HwpUnit,
575        /// Horizontal offset from anchor point (HWPUNIT, 0 = inline/treat-as-char).
576        horz_offset: i32,
577        /// Vertical offset from anchor point (HWPUNIT, 0 = inline/treat-as-char).
578        vert_offset: i32,
579        /// Optional caption attached to this curve.
580        caption: Option<Caption>,
581        /// Optional visual style overrides.
582        style: Option<ShapeStyle>,
583    },
584
585    /// A connect line drawing object (line with control points for routing).
586    /// Maps to HWPX `<hp:connectLine>`.
587    ConnectLine {
588        /// Start point of the connect line.
589        start: ShapePoint,
590        /// End point of the connect line.
591        end: ShapePoint,
592        /// Intermediate control points for routing.
593        control_points: Vec<ShapePoint>,
594        /// Connect line type (e.g. "STRAIGHT", "BENT", "CURVED").
595        connect_type: String,
596        /// Bounding box width (HWPUNIT).
597        width: HwpUnit,
598        /// Bounding box height (HWPUNIT).
599        height: HwpUnit,
600        /// Horizontal offset from anchor point (HWPUNIT, 0 = inline/treat-as-char).
601        horz_offset: i32,
602        /// Vertical offset from anchor point (HWPUNIT, 0 = inline/treat-as-char).
603        vert_offset: i32,
604        /// Optional caption attached to this connect line.
605        caption: Option<Caption>,
606        /// Optional visual style overrides.
607        style: Option<ShapeStyle>,
608    },
609
610    /// A bookmark marking a named location in the document.
611    /// Maps to HWPX `<hp:ctrl><hp:bookmark>` (point) or `fieldBegin/fieldEnd type="BOOKMARK"` (span).
612    Bookmark {
613        /// Bookmark name (unique within the document).
614        name: String,
615        /// Type: point bookmark or span start/end.
616        bookmark_type: BookmarkType,
617    },
618
619    /// A cross-reference (상호참조) to a bookmark, table, figure, or equation.
620    /// Maps to HWPX `fieldBegin type="CROSSREF"` with parameters.
621    CrossRef {
622        /// Target bookmark or object name (e.g. `"bookmark1"`, `"table23"`).
623        target_name: String,
624        /// What kind of target is being referenced.
625        ref_type: RefType,
626        /// What content to display at the reference site.
627        content_type: RefContentType,
628        /// Whether to render the reference as a clickable hyperlink.
629        as_hyperlink: bool,
630    },
631
632    /// A press-field (누름틀) — an interactive form field.
633    /// Maps to HWPX `fieldBegin type="CLICK_HERE"` with parameters and `metaTag`.
634    Field {
635        /// Field type (ClickHere, Date, Time, etc.).
636        field_type: FieldType,
637        /// Hint/visible text shown in the field placeholder.
638        hint_text: Option<String>,
639        /// Help text shown when hovering or clicking the field.
640        help_text: Option<String>,
641    },
642
643    /// A memo (메모) annotation attached to text.
644    /// Maps to HWPX `fieldBegin type="MEMO"` with `<hp:subList>` body inside.
645    Memo {
646        /// Paragraphs forming the memo body content.
647        content: Vec<Paragraph>,
648        /// Author name.
649        author: String,
650        /// Date string (e.g. `"2026-03-05"`).
651        date: String,
652    },
653
654    /// An index mark for building a document index (찾아보기).
655    /// Maps to HWPX `<hp:ctrl><hp:indexmark>`.
656    IndexMark {
657        /// Primary index key (required).
658        primary: String,
659        /// Secondary (sub-entry) index key.
660        secondary: Option<String>,
661    },
662
663    /// An unrecognized control element preserved for round-trip fidelity.
664    ///
665    /// `tag` holds the element's tag name or type identifier.
666    /// `data` holds optional serialized content for lossless preservation.
667    Unknown {
668        /// Tag name or type identifier of the unrecognized element.
669        tag: String,
670        /// Optional serialized data for round-trip preservation.
671        data: Option<String>,
672    },
673}
674
675/// Position of dutmal annotation text relative to the main text.
676#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
677#[non_exhaustive]
678pub enum DutmalPosition {
679    /// Annotation above main text (default).
680    #[default]
681    Top,
682    /// Annotation below main text.
683    Bottom,
684    /// Annotation to the right.
685    Right,
686    /// Annotation to the left.
687    Left,
688}
689
690/// Alignment of dutmal annotation text.
691#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
692#[non_exhaustive]
693pub enum DutmalAlign {
694    /// Center-aligned (default).
695    #[default]
696    Center,
697    /// Left-aligned.
698    Left,
699    /// Right-aligned.
700    Right,
701}
702
703impl Control {
704    /// Returns `true` if this is a [`Control::TextBox`].
705    pub fn is_text_box(&self) -> bool {
706        matches!(self, Self::TextBox { .. })
707    }
708
709    /// Returns `true` if this is a [`Control::Hyperlink`].
710    pub fn is_hyperlink(&self) -> bool {
711        matches!(self, Self::Hyperlink { .. })
712    }
713
714    /// Returns `true` if this is a [`Control::Footnote`].
715    pub fn is_footnote(&self) -> bool {
716        matches!(self, Self::Footnote { .. })
717    }
718
719    /// Returns `true` if this is a [`Control::Endnote`].
720    pub fn is_endnote(&self) -> bool {
721        matches!(self, Self::Endnote { .. })
722    }
723
724    /// Returns `true` if this is a [`Control::Line`].
725    pub fn is_line(&self) -> bool {
726        matches!(self, Self::Line { .. })
727    }
728
729    /// Returns `true` if this is a [`Control::Ellipse`].
730    pub fn is_ellipse(&self) -> bool {
731        matches!(self, Self::Ellipse { .. })
732    }
733
734    /// Returns `true` if this is a [`Control::Rect`].
735    pub fn is_rect(&self) -> bool {
736        matches!(self, Self::Rect { .. })
737    }
738
739    /// Returns `true` if this is a [`Control::Polygon`].
740    pub fn is_polygon(&self) -> bool {
741        matches!(self, Self::Polygon { .. })
742    }
743
744    /// Returns `true` if this is a [`Control::Equation`].
745    pub fn is_equation(&self) -> bool {
746        matches!(self, Self::Equation { .. })
747    }
748
749    /// Returns `true` if this is a [`Control::Chart`].
750    pub fn is_chart(&self) -> bool {
751        matches!(self, Self::Chart { .. })
752    }
753
754    /// Returns `true` if this is a [`Control::EmbeddedChart`].
755    pub fn is_embedded_chart(&self) -> bool {
756        matches!(self, Self::EmbeddedChart { .. })
757    }
758
759    /// Returns `true` if this is a [`Control::Unknown`].
760    pub fn is_unknown(&self) -> bool {
761        matches!(self, Self::Unknown { .. })
762    }
763
764    /// Returns `true` if this is a [`Control::Dutmal`].
765    pub fn is_dutmal(&self) -> bool {
766        matches!(self, Self::Dutmal { .. })
767    }
768
769    /// Returns `true` if this is a [`Control::Compose`].
770    pub fn is_compose(&self) -> bool {
771        matches!(self, Self::Compose { .. })
772    }
773
774    /// Returns `true` if this is a [`Control::Arc`].
775    pub fn is_arc(&self) -> bool {
776        matches!(self, Self::Arc { .. })
777    }
778
779    /// Returns `true` if this is a [`Control::Curve`].
780    pub fn is_curve(&self) -> bool {
781        matches!(self, Self::Curve { .. })
782    }
783
784    /// Returns `true` if this is a [`Control::ConnectLine`].
785    pub fn is_connect_line(&self) -> bool {
786        matches!(self, Self::ConnectLine { .. })
787    }
788
789    /// Returns `true` if this is a [`Control::Bookmark`].
790    pub fn is_bookmark(&self) -> bool {
791        matches!(self, Self::Bookmark { .. })
792    }
793
794    /// Returns `true` if this is a [`Control::CrossRef`].
795    pub fn is_cross_ref(&self) -> bool {
796        matches!(self, Self::CrossRef { .. })
797    }
798
799    /// Returns `true` if this is a [`Control::Field`].
800    pub fn is_field(&self) -> bool {
801        matches!(self, Self::Field { .. })
802    }
803
804    /// Returns `true` if this is a [`Control::Memo`].
805    pub fn is_memo(&self) -> bool {
806        matches!(self, Self::Memo { .. })
807    }
808
809    /// Returns `true` if this is a [`Control::IndexMark`].
810    pub fn is_index_mark(&self) -> bool {
811        matches!(self, Self::IndexMark { .. })
812    }
813
814    /// Creates a point bookmark at a named location.
815    ///
816    /// # Examples
817    ///
818    /// ```
819    /// use hwpforge_core::control::Control;
820    ///
821    /// let bm = Control::bookmark("section1");
822    /// assert!(bm.is_bookmark());
823    /// ```
824    pub fn bookmark(name: &str) -> Self {
825        Self::Bookmark { name: name.to_string(), bookmark_type: BookmarkType::Point }
826    }
827
828    /// Creates a press-field (누름틀) with the given hint text.
829    ///
830    /// # Examples
831    ///
832    /// ```
833    /// use hwpforge_core::control::Control;
834    ///
835    /// let field = Control::field("이름을 입력하세요");
836    /// assert!(field.is_field());
837    /// ```
838    pub fn field(hint: &str) -> Self {
839        Self::Field {
840            field_type: FieldType::ClickHere,
841            hint_text: Some(hint.to_string()),
842            help_text: None,
843        }
844    }
845
846    /// Creates an index mark with a primary key.
847    ///
848    /// # Examples
849    ///
850    /// ```
851    /// use hwpforge_core::control::Control;
852    ///
853    /// let mark = Control::index_mark("한글");
854    /// assert!(mark.is_index_mark());
855    /// ```
856    pub fn index_mark(primary: &str) -> Self {
857        Self::IndexMark { primary: primary.to_string(), secondary: None }
858    }
859
860    /// Creates a memo annotation with the given text content.
861    ///
862    /// # Examples
863    ///
864    /// ```
865    /// use hwpforge_core::control::Control;
866    /// use hwpforge_core::paragraph::Paragraph;
867    /// use hwpforge_foundation::ParaShapeIndex;
868    ///
869    /// let para = Paragraph::new(ParaShapeIndex::new(0));
870    /// let memo = Control::memo(vec![para], "Author", "2026-03-05");
871    /// assert!(memo.is_memo());
872    /// ```
873    pub fn memo(content: Vec<Paragraph>, author: &str, date: &str) -> Self {
874        Self::Memo { content, author: author.to_string(), date: date.to_string() }
875    }
876
877    /// Creates a cross-reference to a bookmark target.
878    ///
879    /// # Examples
880    ///
881    /// ```
882    /// use hwpforge_core::control::Control;
883    /// use hwpforge_foundation::{RefType, RefContentType};
884    ///
885    /// let xref = Control::cross_ref("section1", RefType::Bookmark, RefContentType::Page);
886    /// assert!(xref.is_cross_ref());
887    /// ```
888    pub fn cross_ref(target: &str, ref_type: RefType, content_type: RefContentType) -> Self {
889        Self::CrossRef {
890            target_name: target.to_string(),
891            ref_type,
892            content_type,
893            as_hyperlink: false,
894        }
895    }
896
897    /// Creates a chart control with default dimensions and settings.
898    ///
899    /// Defaults: width ≈ 114mm, height ≈ 66mm, no title, right legend, clustered grouping.
900    ///
901    /// # Examples
902    ///
903    /// ```
904    /// use hwpforge_core::control::Control;
905    /// use hwpforge_core::chart::{ChartType, ChartData};
906    ///
907    /// let data = ChartData::category(&["A", "B"], &[("S1", &[10.0, 20.0])]);
908    /// let ctrl = Control::chart(ChartType::Column, data);
909    /// assert!(ctrl.is_chart());
910    /// ```
911    pub fn chart(chart_type: ChartType, data: ChartData) -> Self {
912        Self::Chart {
913            chart_type,
914            data,
915            width: HwpUnit::new(32250).expect("32250 is valid"),
916            height: HwpUnit::new(18750).expect("18750 is valid"),
917            title: None,
918            legend: LegendPosition::default(),
919            grouping: ChartGrouping::default(),
920            bar_shape: None,
921            explosion: None,
922            of_pie_type: None,
923            radar_style: None,
924            wireframe: None,
925            bubble_3d: None,
926            scatter_style: None,
927            show_markers: None,
928            stock_variant: None,
929        }
930    }
931
932    /// Creates an equation control with default dimensions for the given HancomEQN script.
933    ///
934    /// Defaults: width ≈ 31mm (8779 HWPUNIT), height ≈ 9.2mm (2600 HWPUNIT),
935    /// baseline 71%, black text, `HancomEQN` font.
936    ///
937    /// # Examples
938    ///
939    /// ```
940    /// use hwpforge_core::control::Control;
941    ///
942    /// let ctrl = Control::equation("{a+b} over {c+d}");
943    /// assert!(ctrl.is_equation());
944    /// ```
945    pub fn equation(script: &str) -> Self {
946        Self::Equation {
947            script: script.to_string(),
948            width: HwpUnit::new(8779).expect("8779 is valid"),
949            height: HwpUnit::new(2600).expect("2600 is valid"),
950            base_line: 71,
951            text_color: Color::BLACK,
952            font: "HancomEQN".to_string(),
953        }
954    }
955
956    /// Creates a text box control with the given paragraphs and dimensions.
957    ///
958    /// Defaults: inline positioning (horz_offset=0, vert_offset=0), no caption, no style override.
959    ///
960    /// # Examples
961    ///
962    /// ```
963    /// use hwpforge_core::control::Control;
964    /// use hwpforge_core::paragraph::Paragraph;
965    /// use hwpforge_foundation::{HwpUnit, ParaShapeIndex};
966    ///
967    /// let para = Paragraph::new(ParaShapeIndex::new(0));
968    /// let width = HwpUnit::from_mm(80.0).unwrap();
969    /// let height = HwpUnit::from_mm(40.0).unwrap();
970    /// let ctrl = Control::text_box(vec![para], width, height);
971    /// assert!(ctrl.is_text_box());
972    /// ```
973    pub fn text_box(paragraphs: Vec<Paragraph>, width: HwpUnit, height: HwpUnit) -> Self {
974        Self::TextBox {
975            paragraphs,
976            width,
977            height,
978            horz_offset: 0,
979            vert_offset: 0,
980            caption: None,
981            style: None,
982        }
983    }
984
985    /// Creates a footnote control with the given paragraph content.
986    ///
987    /// Defaults: no inst_id.
988    ///
989    /// # Examples
990    ///
991    /// ```
992    /// use hwpforge_core::control::Control;
993    /// use hwpforge_core::run::Run;
994    /// use hwpforge_core::paragraph::Paragraph;
995    /// use hwpforge_foundation::{CharShapeIndex, ParaShapeIndex};
996    ///
997    /// let para = Paragraph::with_runs(
998    ///     vec![Run::text("Note text", CharShapeIndex::new(0))],
999    ///     ParaShapeIndex::new(0),
1000    /// );
1001    /// let ctrl = Control::footnote(vec![para]);
1002    /// assert!(ctrl.is_footnote());
1003    /// ```
1004    pub fn footnote(paragraphs: Vec<Paragraph>) -> Self {
1005        Self::Footnote { inst_id: None, paragraphs }
1006    }
1007
1008    /// Creates an endnote control with the given paragraph content.
1009    ///
1010    /// Defaults: no inst_id.
1011    ///
1012    /// # Examples
1013    ///
1014    /// ```
1015    /// use hwpforge_core::control::Control;
1016    /// use hwpforge_core::run::Run;
1017    /// use hwpforge_core::paragraph::Paragraph;
1018    /// use hwpforge_foundation::{CharShapeIndex, ParaShapeIndex};
1019    ///
1020    /// let para = Paragraph::with_runs(
1021    ///     vec![Run::text("End note", CharShapeIndex::new(0))],
1022    ///     ParaShapeIndex::new(0),
1023    /// );
1024    /// let ctrl = Control::endnote(vec![para]);
1025    /// assert!(ctrl.is_endnote());
1026    /// ```
1027    pub fn endnote(paragraphs: Vec<Paragraph>) -> Self {
1028        Self::Endnote { inst_id: None, paragraphs }
1029    }
1030
1031    /// Creates a footnote with an explicit instance ID for cross-referencing.
1032    ///
1033    /// Use this when you need stable `inst_id` references (e.g. matching decoder output).
1034    /// For simple footnotes without cross-references, prefer [`Control::footnote`].
1035    ///
1036    /// # Examples
1037    ///
1038    /// ```
1039    /// use hwpforge_core::control::Control;
1040    /// use hwpforge_core::paragraph::Paragraph;
1041    /// use hwpforge_foundation::ParaShapeIndex;
1042    ///
1043    /// let ctrl = Control::footnote_with_id(1, vec![Paragraph::new(ParaShapeIndex::new(0))]);
1044    /// assert!(ctrl.is_footnote());
1045    /// ```
1046    pub fn footnote_with_id(inst_id: u32, paragraphs: Vec<Paragraph>) -> Self {
1047        Self::Footnote { inst_id: Some(inst_id), paragraphs }
1048    }
1049
1050    /// Creates an endnote with an explicit instance ID for cross-referencing.
1051    ///
1052    /// Use this when you need stable `inst_id` references (e.g. matching decoder output).
1053    /// For simple endnotes without cross-references, prefer [`Control::endnote`].
1054    ///
1055    /// # Examples
1056    ///
1057    /// ```
1058    /// use hwpforge_core::control::Control;
1059    /// use hwpforge_core::paragraph::Paragraph;
1060    /// use hwpforge_foundation::ParaShapeIndex;
1061    ///
1062    /// let ctrl = Control::endnote_with_id(2, vec![Paragraph::new(ParaShapeIndex::new(0))]);
1063    /// assert!(ctrl.is_endnote());
1064    /// ```
1065    pub fn endnote_with_id(inst_id: u32, paragraphs: Vec<Paragraph>) -> Self {
1066        Self::Endnote { inst_id: Some(inst_id), paragraphs }
1067    }
1068
1069    /// Creates an ellipse control with the given bounding box dimensions.
1070    ///
1071    /// Geometry is auto-derived: center=(w/2, h/2), axis1=(w, h/2), axis2=(w/2, h).
1072    /// Defaults: inline positioning (horz_offset=0, vert_offset=0), no paragraphs, no caption, no style.
1073    ///
1074    /// # Examples
1075    ///
1076    /// ```
1077    /// use hwpforge_core::control::Control;
1078    /// use hwpforge_foundation::HwpUnit;
1079    ///
1080    /// let width = HwpUnit::from_mm(40.0).unwrap();
1081    /// let height = HwpUnit::from_mm(30.0).unwrap();
1082    /// let ctrl = Control::ellipse(width, height);
1083    /// assert!(ctrl.is_ellipse());
1084    /// ```
1085    pub fn ellipse(width: HwpUnit, height: HwpUnit) -> Self {
1086        let w = width.as_i32();
1087        let h = height.as_i32();
1088        Self::Ellipse {
1089            center: ShapePoint::new(w / 2, h / 2),
1090            axis1: ShapePoint::new(w, h / 2),
1091            axis2: ShapePoint::new(w / 2, h),
1092            width,
1093            height,
1094            horz_offset: 0,
1095            vert_offset: 0,
1096            paragraphs: vec![],
1097            caption: None,
1098            style: None,
1099        }
1100    }
1101
1102    /// Creates an ellipse control with paragraph content inside.
1103    ///
1104    /// Same as [`Control::ellipse`] but accepts paragraphs for text drawn inside the ellipse.
1105    /// Geometry is auto-derived: center=(w/2, h/2), axis1=(w, h/2), axis2=(w/2, h).
1106    /// Defaults: inline positioning (horz_offset=0, vert_offset=0), no caption, no style.
1107    ///
1108    /// # Examples
1109    ///
1110    /// ```
1111    /// use hwpforge_core::control::Control;
1112    /// use hwpforge_core::paragraph::Paragraph;
1113    /// use hwpforge_foundation::{HwpUnit, ParaShapeIndex};
1114    ///
1115    /// let width = HwpUnit::from_mm(40.0).unwrap();
1116    /// let height = HwpUnit::from_mm(30.0).unwrap();
1117    /// let para = Paragraph::new(ParaShapeIndex::new(0));
1118    /// let ctrl = Control::ellipse_with_text(width, height, vec![para]);
1119    /// assert!(ctrl.is_ellipse());
1120    /// ```
1121    pub fn ellipse_with_text(width: HwpUnit, height: HwpUnit, paragraphs: Vec<Paragraph>) -> Self {
1122        let w = width.as_i32();
1123        let h = height.as_i32();
1124        Self::Ellipse {
1125            center: ShapePoint::new(w / 2, h / 2),
1126            axis1: ShapePoint::new(w, h / 2),
1127            axis2: ShapePoint::new(w / 2, h),
1128            width,
1129            height,
1130            horz_offset: 0,
1131            vert_offset: 0,
1132            paragraphs,
1133            caption: None,
1134            style: None,
1135        }
1136    }
1137
1138    /// Creates a pure rectangle control with the given bounding box dimensions.
1139    ///
1140    /// Pure rectangle means no embedded text content; for a textbox-style rect with
1141    /// inline paragraphs, use [`Control::text_box`].
1142    /// Defaults: inline positioning (horz_offset=0, vert_offset=0), no caption, no style.
1143    ///
1144    /// # Errors
1145    ///
1146    /// Returns [`CoreError::InvalidStructure`] if either dimension is zero.
1147    ///
1148    /// # Examples
1149    ///
1150    /// ```
1151    /// use hwpforge_core::control::Control;
1152    /// use hwpforge_foundation::HwpUnit;
1153    ///
1154    /// let width = HwpUnit::from_mm(40.0).unwrap();
1155    /// let height = HwpUnit::from_mm(20.0).unwrap();
1156    /// let ctrl = Control::rect(width, height).unwrap();
1157    /// assert!(ctrl.is_rect());
1158    /// ```
1159    pub fn rect(width: HwpUnit, height: HwpUnit) -> CoreResult<Self> {
1160        if width.as_i32() == 0 || height.as_i32() == 0 {
1161            return Err(CoreError::InvalidStructure {
1162                context: "Control::rect".to_string(),
1163                reason: format!(
1164                    "rectangle requires non-zero dimensions, got {}x{}",
1165                    width.as_i32(),
1166                    height.as_i32()
1167                ),
1168            });
1169        }
1170        Ok(Self::Rect { width, height, horz_offset: 0, vert_offset: 0, caption: None, style: None })
1171    }
1172
1173    /// Creates a polygon control from the given vertices.
1174    ///
1175    /// The bounding box is auto-derived from the min/max of vertex coordinates.
1176    /// Defaults: no paragraphs, no caption, no style.
1177    ///
1178    /// Returns an error if fewer than 3 vertices are provided.
1179    ///
1180    /// # Errors
1181    ///
1182    /// Returns [`CoreError::InvalidStructure`] if `vertices.len() < 3`.
1183    ///
1184    /// # Examples
1185    ///
1186    /// ```
1187    /// use hwpforge_core::control::{Control, ShapePoint};
1188    ///
1189    /// let vertices = vec![
1190    ///     ShapePoint::new(0, 1000),
1191    ///     ShapePoint::new(500, 0),
1192    ///     ShapePoint::new(1000, 1000),
1193    /// ];
1194    /// let ctrl = Control::polygon(vertices).unwrap();
1195    /// assert!(ctrl.is_polygon());
1196    /// ```
1197    pub fn polygon(vertices: Vec<ShapePoint>) -> CoreResult<Self> {
1198        if vertices.len() < 3 {
1199            return Err(CoreError::InvalidStructure {
1200                context: "Control::polygon".to_string(),
1201                reason: format!("polygon requires at least 3 vertices, got {}", vertices.len()),
1202            });
1203        }
1204        let min_x = vertices.iter().map(|p| p.x as i64).min().unwrap_or(0);
1205        let max_x = vertices.iter().map(|p| p.x as i64).max().unwrap_or(0);
1206        let min_y = vertices.iter().map(|p| p.y as i64).min().unwrap_or(0);
1207        let max_y = vertices.iter().map(|p| p.y as i64).max().unwrap_or(0);
1208        let bbox_w = i32::try_from((max_x - min_x).max(0)).unwrap_or(i32::MAX);
1209        let bbox_h = i32::try_from((max_y - min_y).max(0)).unwrap_or(i32::MAX);
1210        let width = HwpUnit::new(bbox_w).map_err(|_| CoreError::InvalidStructure {
1211            context: "Control::polygon".into(),
1212            reason: format!("bounding box width {bbox_w} exceeds HwpUnit range"),
1213        })?;
1214        let height = HwpUnit::new(bbox_h).map_err(|_| CoreError::InvalidStructure {
1215            context: "Control::polygon".into(),
1216            reason: format!("bounding box height {bbox_h} exceeds HwpUnit range"),
1217        })?;
1218        Ok(Self::Polygon {
1219            vertices,
1220            width,
1221            height,
1222            horz_offset: 0,
1223            vert_offset: 0,
1224            paragraphs: vec![],
1225            caption: None,
1226            style: None,
1227        })
1228    }
1229
1230    /// Creates a line control between two endpoints.
1231    ///
1232    /// The bounding box width and height are derived from the absolute difference
1233    /// of the endpoint coordinates: `width = |end.x - start.x|`, `height = |end.y - start.y|`.
1234    /// Each axis is clamped to a minimum of 100 HwpUnit (~1pt) because 한글 cannot
1235    /// render lines with a zero-dimension bounding box.
1236    /// Defaults: no caption, no style.
1237    ///
1238    /// Returns an error if start and end are the same point (degenerate line).
1239    ///
1240    /// # Errors
1241    ///
1242    /// Returns [`CoreError::InvalidStructure`] if start equals end.
1243    ///
1244    /// # Examples
1245    ///
1246    /// ```
1247    /// use hwpforge_core::control::{Control, ShapePoint};
1248    ///
1249    /// let ctrl = Control::line(ShapePoint::new(0, 0), ShapePoint::new(5000, 0)).unwrap();
1250    /// assert!(ctrl.is_line());
1251    /// ```
1252    pub fn line(start: ShapePoint, end: ShapePoint) -> CoreResult<Self> {
1253        if start == end {
1254            return Err(CoreError::InvalidStructure {
1255                context: "Control::line".to_string(),
1256                reason: "start and end points are identical (degenerate line)".to_string(),
1257            });
1258        }
1259        // Normalize points to bounding-box-relative coordinates.
1260        // HWPX requires startPt/endPt within the shape's bounding box (0,0)→(w,h).
1261        let min_x = start.x.min(end.x);
1262        let min_y = start.y.min(end.y);
1263        let norm_start =
1264            ShapePoint::new(start.x.saturating_sub(min_x), start.y.saturating_sub(min_y));
1265        let norm_end = ShapePoint::new(end.x.saturating_sub(min_x), end.y.saturating_sub(min_y));
1266
1267        let raw_w =
1268            i32::try_from(((end.x as i64) - (start.x as i64)).unsigned_abs()).unwrap_or(i32::MAX);
1269        let raw_h =
1270            i32::try_from(((end.y as i64) - (start.y as i64)).unsigned_abs()).unwrap_or(i32::MAX);
1271        // Minimum bounding box of 100 HwpUnit (~1pt) per axis.
1272        // 한글 cannot render lines with a zero-dimension bounding box.
1273        let raw_w = raw_w.max(100);
1274        let raw_h = raw_h.max(100);
1275        let width = HwpUnit::new(raw_w).unwrap_or_else(|_| HwpUnit::new(100).expect("valid"));
1276        let height = HwpUnit::new(raw_h).unwrap_or_else(|_| HwpUnit::new(100).expect("valid"));
1277        Ok(Self::Line {
1278            start: norm_start,
1279            end: norm_end,
1280            width,
1281            height,
1282            horz_offset: 0,
1283            vert_offset: 0,
1284            caption: None,
1285            style: None,
1286        })
1287    }
1288
1289    /// Creates a horizontal line of the given width.
1290    ///
1291    /// Shortcut for `line(ShapePoint::new(0, 0), ShapePoint::new(width.as_i32(), 0))`.
1292    /// The bounding box height is clamped to 100 HwpUnit (~1pt minimum) because
1293    /// 한글 cannot render lines with a zero-dimension bounding box.
1294    /// Defaults: no caption, no style.
1295    ///
1296    /// # Examples
1297    ///
1298    /// ```
1299    /// use hwpforge_core::control::Control;
1300    /// use hwpforge_foundation::HwpUnit;
1301    ///
1302    /// let width = HwpUnit::from_mm(100.0).unwrap();
1303    /// let ctrl = Control::horizontal_line(width);
1304    /// assert!(ctrl.is_line());
1305    /// ```
1306    pub fn horizontal_line(width: HwpUnit) -> Self {
1307        let w = width.as_i32();
1308        Self::Line {
1309            start: ShapePoint::new(0, 0),
1310            end: ShapePoint::new(w, 0),
1311            width,
1312            height: HwpUnit::new(100).expect("100 is valid"),
1313            horz_offset: 0,
1314            vert_offset: 0,
1315            caption: None,
1316            style: None,
1317        }
1318    }
1319
1320    /// Creates a dutmal (annotation text) control with default positioning.
1321    ///
1322    /// Defaults: position = Top, sz_ratio = 0 (auto), align = Center.
1323    ///
1324    /// # Examples
1325    ///
1326    /// ```
1327    /// use hwpforge_core::control::Control;
1328    ///
1329    /// let ctrl = Control::dutmal("본문", "주석");
1330    /// assert!(ctrl.is_dutmal());
1331    /// ```
1332    pub fn dutmal(main_text: impl Into<String>, sub_text: impl Into<String>) -> Self {
1333        Self::Dutmal {
1334            main_text: main_text.into(),
1335            sub_text: sub_text.into(),
1336            position: DutmalPosition::Top,
1337            sz_ratio: 0,
1338            align: DutmalAlign::Center,
1339        }
1340    }
1341
1342    /// Creates a compose (글자겹침) control with default settings.
1343    ///
1344    /// Defaults: `circle_type = "SHAPE_REVERSAL_TIRANGLE"` (spec typo preserved),
1345    /// `char_sz = -3`, `compose_type = "SPREAD"`.
1346    ///
1347    /// # Examples
1348    ///
1349    /// ```
1350    /// use hwpforge_core::control::Control;
1351    ///
1352    /// let ctrl = Control::compose("12");
1353    /// assert!(ctrl.is_compose());
1354    /// ```
1355    pub fn compose(text: impl Into<String>) -> Self {
1356        Self::Compose {
1357            compose_text: text.into(),
1358            circle_type: "SHAPE_REVERSAL_TIRANGLE".to_string(), // official spec typo preserved
1359            char_sz: -3,
1360            compose_type: "SPREAD".to_string(),
1361        }
1362    }
1363
1364    /// Creates an arc control with the given bounding box dimensions.
1365    ///
1366    /// Geometry is auto-derived from the bounding box.
1367    /// Defaults: inline positioning, no caption, no style.
1368    ///
1369    /// # Examples
1370    ///
1371    /// ```
1372    /// use hwpforge_core::control::Control;
1373    /// use hwpforge_foundation::{ArcType, HwpUnit};
1374    ///
1375    /// let width = HwpUnit::from_mm(40.0).unwrap();
1376    /// let height = HwpUnit::from_mm(30.0).unwrap();
1377    /// let ctrl = Control::arc(ArcType::Pie, width, height);
1378    /// assert!(ctrl.is_arc());
1379    /// ```
1380    pub fn arc(arc_type: ArcType, width: HwpUnit, height: HwpUnit) -> Self {
1381        let w = width.as_i32();
1382        let h = height.as_i32();
1383        Self::Arc {
1384            arc_type,
1385            center: ShapePoint::new(w / 2, h / 2),
1386            axis1: ShapePoint::new(w, h / 2),
1387            axis2: ShapePoint::new(w / 2, h),
1388            start1: ShapePoint::new(w, h / 2),
1389            end1: ShapePoint::new(w / 2, 0),
1390            start2: ShapePoint::new(w, h / 2),
1391            end2: ShapePoint::new(w / 2, 0),
1392            width,
1393            height,
1394            horz_offset: 0,
1395            vert_offset: 0,
1396            caption: None,
1397            style: None,
1398        }
1399    }
1400
1401    /// Creates a curve control from the given control points.
1402    ///
1403    /// All segments default to [`CurveSegmentType::Curve`].
1404    /// The bounding box is auto-derived from min/max of point coordinates.
1405    ///
1406    /// Returns an error if fewer than 2 points are provided.
1407    ///
1408    /// # Errors
1409    ///
1410    /// Returns [`CoreError::InvalidStructure`] if `points.len() < 2`.
1411    ///
1412    /// # Examples
1413    ///
1414    /// ```
1415    /// use hwpforge_core::control::{Control, ShapePoint};
1416    ///
1417    /// let pts = vec![
1418    ///     ShapePoint::new(0, 0),
1419    ///     ShapePoint::new(2500, 5000),
1420    ///     ShapePoint::new(5000, 0),
1421    /// ];
1422    /// let ctrl = Control::curve(pts).unwrap();
1423    /// assert!(ctrl.is_curve());
1424    /// ```
1425    pub fn curve(points: Vec<ShapePoint>) -> CoreResult<Self> {
1426        if points.len() < 2 {
1427            return Err(CoreError::InvalidStructure {
1428                context: "Control::curve".to_string(),
1429                reason: format!("curve requires at least 2 points, got {}", points.len()),
1430            });
1431        }
1432        let min_x = points.iter().map(|p| p.x as i64).min().unwrap_or(0);
1433        let max_x = points.iter().map(|p| p.x as i64).max().unwrap_or(0);
1434        let min_y = points.iter().map(|p| p.y as i64).min().unwrap_or(0);
1435        let max_y = points.iter().map(|p| p.y as i64).max().unwrap_or(0);
1436        let bbox_w = i32::try_from((max_x - min_x).max(1)).unwrap_or(i32::MAX);
1437        let bbox_h = i32::try_from((max_y - min_y).max(1)).unwrap_or(i32::MAX);
1438        let width = HwpUnit::new(bbox_w).map_err(|_| CoreError::InvalidStructure {
1439            context: "Control::curve".into(),
1440            reason: format!("bounding box width {bbox_w} exceeds HwpUnit range"),
1441        })?;
1442        let height = HwpUnit::new(bbox_h).map_err(|_| CoreError::InvalidStructure {
1443            context: "Control::curve".into(),
1444            reason: format!("bounding box height {bbox_h} exceeds HwpUnit range"),
1445        })?;
1446        let seg_count = points.len().saturating_sub(1);
1447        Ok(Self::Curve {
1448            points,
1449            segment_types: vec![CurveSegmentType::Curve; seg_count],
1450            width,
1451            height,
1452            horz_offset: 0,
1453            vert_offset: 0,
1454            caption: None,
1455            style: None,
1456        })
1457    }
1458
1459    /// Creates a connect line between two endpoints.
1460    ///
1461    /// Defaults: no control points, type "STRAIGHT", no caption, no style.
1462    ///
1463    /// Returns an error if start equals end.
1464    ///
1465    /// # Errors
1466    ///
1467    /// Returns [`CoreError::InvalidStructure`] if start equals end.
1468    ///
1469    /// # Examples
1470    ///
1471    /// ```
1472    /// use hwpforge_core::control::{Control, ShapePoint};
1473    ///
1474    /// let ctrl = Control::connect_line(
1475    ///     ShapePoint::new(0, 0),
1476    ///     ShapePoint::new(5000, 5000),
1477    /// ).unwrap();
1478    /// assert!(ctrl.is_connect_line());
1479    /// ```
1480    pub fn connect_line(start: ShapePoint, end: ShapePoint) -> CoreResult<Self> {
1481        if start == end {
1482            return Err(CoreError::InvalidStructure {
1483                context: "Control::connect_line".to_string(),
1484                reason: "start and end points are identical (degenerate line)".to_string(),
1485            });
1486        }
1487        // Normalize points to bounding-box-relative coordinates.
1488        // HWPX requires startPt/endPt within the shape's bounding box (0,0)→(w,h).
1489        let min_x = start.x.min(end.x);
1490        let min_y = start.y.min(end.y);
1491        let norm_start =
1492            ShapePoint::new(start.x.saturating_sub(min_x), start.y.saturating_sub(min_y));
1493        let norm_end = ShapePoint::new(end.x.saturating_sub(min_x), end.y.saturating_sub(min_y));
1494
1495        let raw_w =
1496            i32::try_from(((end.x as i64) - (start.x as i64)).unsigned_abs()).unwrap_or(i32::MAX);
1497        let raw_h =
1498            i32::try_from(((end.y as i64) - (start.y as i64)).unsigned_abs()).unwrap_or(i32::MAX);
1499        let raw_w = raw_w.max(100);
1500        let raw_h = raw_h.max(100);
1501        let width = HwpUnit::new(raw_w).unwrap_or_else(|_| HwpUnit::new(100).expect("valid"));
1502        let height = HwpUnit::new(raw_h).unwrap_or_else(|_| HwpUnit::new(100).expect("valid"));
1503        Ok(Self::ConnectLine {
1504            start: norm_start,
1505            end: norm_end,
1506            control_points: Vec::new(),
1507            connect_type: "STRAIGHT".to_string(),
1508            width,
1509            height,
1510            horz_offset: 0,
1511            vert_offset: 0,
1512            caption: None,
1513            style: None,
1514        })
1515    }
1516
1517    /// Creates a hyperlink control with the given display text and URL.
1518    ///
1519    /// # Examples
1520    ///
1521    /// ```
1522    /// use hwpforge_core::control::Control;
1523    ///
1524    /// let ctrl = Control::hyperlink("Visit Rust", "https://rust-lang.org");
1525    /// assert!(ctrl.is_hyperlink());
1526    /// ```
1527    pub fn hyperlink(text: &str, url: &str) -> Self {
1528        Self::Hyperlink { text: text.to_string(), url: url.to_string() }
1529    }
1530}
1531
1532impl std::fmt::Display for Control {
1533    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1534        match self {
1535            Self::TextBox { paragraphs, .. } => {
1536                let n = paragraphs.len();
1537                let word = if n == 1 { "paragraph" } else { "paragraphs" };
1538                write!(f, "TextBox({n} {word})")
1539            }
1540            Self::Hyperlink { text, url } => {
1541                let preview: String =
1542                    if text.len() > 30 { text.chars().take(30).collect() } else { text.clone() };
1543                write!(f, "Hyperlink(\"{preview}\" -> {url})")
1544            }
1545            Self::Footnote { paragraphs, .. } => {
1546                let n = paragraphs.len();
1547                let word = if n == 1 { "paragraph" } else { "paragraphs" };
1548                write!(f, "Footnote({n} {word})")
1549            }
1550            Self::Endnote { paragraphs, .. } => {
1551                let n = paragraphs.len();
1552                let word = if n == 1 { "paragraph" } else { "paragraphs" };
1553                write!(f, "Endnote({n} {word})")
1554            }
1555            Self::Line { .. } => {
1556                write!(f, "Line")
1557            }
1558            Self::Ellipse { paragraphs, .. } => {
1559                let n = paragraphs.len();
1560                let word = if n == 1 { "paragraph" } else { "paragraphs" };
1561                write!(f, "Ellipse({n} {word})")
1562            }
1563            Self::Rect { width, height, .. } => {
1564                write!(f, "Rect({}x{})", width.as_i32(), height.as_i32())
1565            }
1566            Self::Polygon { vertices, paragraphs, .. } => {
1567                let nv = vertices.len();
1568                let np = paragraphs.len();
1569                let vw = if nv == 1 { "vertex" } else { "vertices" };
1570                let pw = if np == 1 { "paragraph" } else { "paragraphs" };
1571                write!(f, "Polygon({nv} {vw}, {np} {pw})")
1572            }
1573            Self::Chart { chart_type, data, .. } => {
1574                let series_count = match data {
1575                    ChartData::Category { series, .. } => series.len(),
1576                    ChartData::Xy { series } => series.len(),
1577                };
1578                write!(f, "Chart({chart_type:?}, {series_count} series)")
1579            }
1580            Self::EmbeddedChart { chart_xml, ole_bytes, width, height, .. } => {
1581                write!(
1582                    f,
1583                    "EmbeddedChart(xml={} bytes, ole={} bytes, {}x{})",
1584                    chart_xml.len(),
1585                    ole_bytes.len(),
1586                    width.as_i32(),
1587                    height.as_i32()
1588                )
1589            }
1590            Self::Equation { script, .. } => {
1591                let preview: String = if script.len() > 30 {
1592                    script.chars().take(30).collect()
1593                } else {
1594                    script.clone()
1595                };
1596                write!(f, "Equation(\"{preview}\")")
1597            }
1598            Self::Dutmal { main_text, sub_text, .. } => {
1599                write!(f, "Dutmal(\"{main_text}\" / \"{sub_text}\")")
1600            }
1601            Self::Compose { compose_text, .. } => {
1602                write!(f, "Compose(\"{compose_text}\")")
1603            }
1604            Self::Arc { arc_type, .. } => {
1605                write!(f, "Arc({arc_type})")
1606            }
1607            Self::Curve { points, .. } => {
1608                write!(f, "Curve({} points)", points.len())
1609            }
1610            Self::ConnectLine { .. } => {
1611                write!(f, "ConnectLine")
1612            }
1613            Self::Bookmark { name, bookmark_type } => {
1614                write!(f, "Bookmark(\"{name}\", {bookmark_type})")
1615            }
1616            Self::CrossRef { target_name, ref_type, .. } => {
1617                write!(f, "CrossRef(\"{target_name}\", {ref_type})")
1618            }
1619            Self::Field { field_type, hint_text, .. } => {
1620                let hint = hint_text.as_deref().unwrap_or("");
1621                write!(f, "Field({field_type}, \"{hint}\")")
1622            }
1623            Self::Memo { content, author, .. } => {
1624                let n = content.len();
1625                let word = if n == 1 { "paragraph" } else { "paragraphs" };
1626                write!(f, "Memo({n} {word}, by {author})")
1627            }
1628            Self::IndexMark { primary, secondary } => {
1629                if let Some(sec) = secondary {
1630                    write!(f, "IndexMark(\"{primary}\" / \"{sec}\")")
1631                } else {
1632                    write!(f, "IndexMark(\"{primary}\")")
1633                }
1634            }
1635            Self::Unknown { tag, .. } => {
1636                write!(f, "Unknown({tag})")
1637            }
1638        }
1639    }
1640}
1641
1642#[cfg(test)]
1643mod tests {
1644    use super::*;
1645    use crate::run::Run;
1646    use hwpforge_foundation::{CharShapeIndex, Color, ParaShapeIndex};
1647
1648    fn simple_paragraph() -> Paragraph {
1649        Paragraph::with_runs(
1650            vec![Run::text("footnote text", CharShapeIndex::new(0))],
1651            ParaShapeIndex::new(0),
1652        )
1653    }
1654
1655    #[test]
1656    fn shape_style_default_all_none() {
1657        let s = ShapeStyle::default();
1658        assert!(s.line_color.is_none());
1659        assert!(s.fill_color.is_none());
1660        assert!(s.line_width.is_none());
1661        assert!(s.line_style.is_none());
1662    }
1663
1664    #[test]
1665    fn shape_style_with_typed_fields() {
1666        let s = ShapeStyle {
1667            line_color: Some(Color::from_rgb(255, 0, 0)),
1668            fill_color: Some(Color::from_rgb(0, 255, 0)),
1669            line_width: Some(100),
1670            line_style: Some(LineStyle::Dash),
1671            ..Default::default()
1672        };
1673        assert_eq!(s.line_color.unwrap(), Color::from_rgb(255, 0, 0));
1674        assert_eq!(s.fill_color.unwrap(), Color::from_rgb(0, 255, 0));
1675        assert_eq!(s.line_width.unwrap(), 100);
1676        assert_eq!(s.line_style.unwrap(), LineStyle::Dash);
1677    }
1678
1679    #[test]
1680    fn line_style_default() {
1681        assert_eq!(LineStyle::default(), LineStyle::Solid);
1682    }
1683
1684    #[test]
1685    fn line_style_display() {
1686        assert_eq!(LineStyle::Solid.to_string(), "SOLID");
1687        assert_eq!(LineStyle::Dash.to_string(), "DASH");
1688        assert_eq!(LineStyle::Dot.to_string(), "DOT");
1689        assert_eq!(LineStyle::DashDot.to_string(), "DASH_DOT");
1690        assert_eq!(LineStyle::DashDotDot.to_string(), "DASH_DOT_DOT");
1691        assert_eq!(LineStyle::None.to_string(), "NONE");
1692    }
1693
1694    #[test]
1695    fn line_style_from_str() {
1696        assert_eq!("SOLID".parse::<LineStyle>().unwrap(), LineStyle::Solid);
1697        assert_eq!("Dash".parse::<LineStyle>().unwrap(), LineStyle::Dash);
1698        assert_eq!("dot".parse::<LineStyle>().unwrap(), LineStyle::Dot);
1699        assert_eq!("DASH_DOT".parse::<LineStyle>().unwrap(), LineStyle::DashDot);
1700        assert_eq!("DashDotDot".parse::<LineStyle>().unwrap(), LineStyle::DashDotDot);
1701        assert_eq!("NONE".parse::<LineStyle>().unwrap(), LineStyle::None);
1702        assert!("INVALID".parse::<LineStyle>().is_err());
1703    }
1704
1705    #[test]
1706    fn line_style_serde_roundtrip() {
1707        for style in [
1708            LineStyle::Solid,
1709            LineStyle::Dash,
1710            LineStyle::Dot,
1711            LineStyle::DashDot,
1712            LineStyle::DashDotDot,
1713            LineStyle::None,
1714        ] {
1715            let json = serde_json::to_string(&style).unwrap();
1716            let back: LineStyle = serde_json::from_str(&json).unwrap();
1717            assert_eq!(style, back);
1718        }
1719    }
1720
1721    #[test]
1722    fn text_box_construction() {
1723        let ctrl = Control::TextBox {
1724            paragraphs: vec![simple_paragraph()],
1725            width: HwpUnit::from_mm(80.0).unwrap(),
1726            height: HwpUnit::from_mm(40.0).unwrap(),
1727            horz_offset: 0,
1728            vert_offset: 0,
1729            caption: None,
1730            style: None,
1731        };
1732        assert!(ctrl.is_text_box());
1733        assert!(!ctrl.is_hyperlink());
1734        assert!(!ctrl.is_footnote());
1735        assert!(!ctrl.is_endnote());
1736        assert!(!ctrl.is_unknown());
1737    }
1738
1739    #[test]
1740    fn hyperlink_construction() {
1741        let ctrl = Control::Hyperlink {
1742            text: "Click".to_string(),
1743            url: "https://example.com".to_string(),
1744        };
1745        assert!(ctrl.is_hyperlink());
1746        assert!(!ctrl.is_text_box());
1747    }
1748
1749    #[test]
1750    fn footnote_construction() {
1751        let ctrl = Control::Footnote { inst_id: None, paragraphs: vec![simple_paragraph()] };
1752        assert!(ctrl.is_footnote());
1753        assert!(!ctrl.is_text_box());
1754        assert!(!ctrl.is_endnote());
1755    }
1756
1757    #[test]
1758    fn endnote_construction() {
1759        let ctrl = Control::Endnote { inst_id: Some(123456), paragraphs: vec![simple_paragraph()] };
1760        assert!(ctrl.is_endnote());
1761        assert!(!ctrl.is_footnote());
1762        assert!(!ctrl.is_text_box());
1763    }
1764
1765    #[test]
1766    fn unknown_construction() {
1767        let ctrl = Control::Unknown {
1768            tag: "custom:widget".to_string(),
1769            data: Some("<data>value</data>".to_string()),
1770        };
1771        assert!(ctrl.is_unknown());
1772    }
1773
1774    #[test]
1775    fn unknown_without_data() {
1776        let ctrl = Control::Unknown { tag: "header".to_string(), data: None };
1777        assert!(ctrl.is_unknown());
1778    }
1779
1780    #[test]
1781    fn display_text_box() {
1782        let ctrl = Control::TextBox {
1783            paragraphs: vec![simple_paragraph(), simple_paragraph()],
1784            width: HwpUnit::from_mm(80.0).unwrap(),
1785            height: HwpUnit::from_mm(40.0).unwrap(),
1786            horz_offset: 0,
1787            vert_offset: 0,
1788            caption: None,
1789            style: None,
1790        };
1791        assert_eq!(ctrl.to_string(), "TextBox(2 paragraphs)");
1792    }
1793
1794    #[test]
1795    fn display_hyperlink() {
1796        let ctrl =
1797            Control::Hyperlink { text: "Short".to_string(), url: "https://x.com".to_string() };
1798        let s = ctrl.to_string();
1799        assert!(s.contains("Short"), "display: {s}");
1800        assert!(s.contains("https://x.com"), "display: {s}");
1801    }
1802
1803    #[test]
1804    fn display_hyperlink_long_text_truncated() {
1805        let ctrl =
1806            Control::Hyperlink { text: "A".repeat(100), url: "https://example.com".to_string() };
1807        let s = ctrl.to_string();
1808        // Should show first 30 chars
1809        assert!(s.contains(&"A".repeat(30)), "display: {s}");
1810        assert!(!s.contains(&"A".repeat(31)), "display: {s}");
1811    }
1812
1813    #[test]
1814    fn display_footnote() {
1815        let ctrl = Control::Footnote { inst_id: None, paragraphs: vec![simple_paragraph()] };
1816        assert_eq!(ctrl.to_string(), "Footnote(1 paragraph)");
1817    }
1818
1819    #[test]
1820    fn display_endnote() {
1821        let ctrl = Control::Endnote { inst_id: Some(999), paragraphs: vec![simple_paragraph()] };
1822        assert_eq!(ctrl.to_string(), "Endnote(1 paragraph)");
1823    }
1824
1825    #[test]
1826    fn display_unknown() {
1827        let ctrl = Control::Unknown { tag: "bookmark".to_string(), data: None };
1828        assert_eq!(ctrl.to_string(), "Unknown(bookmark)");
1829    }
1830
1831    #[test]
1832    fn equality() {
1833        let a = Control::Hyperlink { text: "A".to_string(), url: "B".to_string() };
1834        let b = Control::Hyperlink { text: "A".to_string(), url: "B".to_string() };
1835        let c = Control::Hyperlink { text: "A".to_string(), url: "C".to_string() };
1836        assert_eq!(a, b);
1837        assert_ne!(a, c);
1838    }
1839
1840    #[test]
1841    fn serde_roundtrip_text_box() {
1842        let ctrl = Control::TextBox {
1843            paragraphs: vec![simple_paragraph()],
1844            width: HwpUnit::from_mm(80.0).unwrap(),
1845            height: HwpUnit::from_mm(40.0).unwrap(),
1846            horz_offset: 0,
1847            vert_offset: 0,
1848            caption: None,
1849            style: None,
1850        };
1851        let json = serde_json::to_string(&ctrl).unwrap();
1852        let back: Control = serde_json::from_str(&json).unwrap();
1853        assert_eq!(ctrl, back);
1854    }
1855
1856    #[test]
1857    fn serde_roundtrip_hyperlink() {
1858        let ctrl = Control::Hyperlink {
1859            text: "link text".to_string(),
1860            url: "https://rust-lang.org".to_string(),
1861        };
1862        let json = serde_json::to_string(&ctrl).unwrap();
1863        let back: Control = serde_json::from_str(&json).unwrap();
1864        assert_eq!(ctrl, back);
1865    }
1866
1867    #[test]
1868    fn serde_roundtrip_footnote() {
1869        let ctrl = Control::Footnote { inst_id: Some(12345), paragraphs: vec![simple_paragraph()] };
1870        let json = serde_json::to_string(&ctrl).unwrap();
1871        let back: Control = serde_json::from_str(&json).unwrap();
1872        assert_eq!(ctrl, back);
1873    }
1874
1875    #[test]
1876    fn serde_roundtrip_endnote() {
1877        let ctrl = Control::Endnote { inst_id: None, paragraphs: vec![simple_paragraph()] };
1878        let json = serde_json::to_string(&ctrl).unwrap();
1879        let back: Control = serde_json::from_str(&json).unwrap();
1880        assert_eq!(ctrl, back);
1881    }
1882
1883    #[test]
1884    fn serde_roundtrip_unknown() {
1885        let ctrl = Control::Unknown { tag: "test".to_string(), data: Some("payload".to_string()) };
1886        let json = serde_json::to_string(&ctrl).unwrap();
1887        let back: Control = serde_json::from_str(&json).unwrap();
1888        assert_eq!(ctrl, back);
1889    }
1890
1891    // ── Shape variant tests ──────────────────────────────────────
1892
1893    #[test]
1894    fn line_construction() {
1895        let ctrl = Control::Line {
1896            start: ShapePoint { x: 0, y: 0 },
1897            end: ShapePoint { x: 1000, y: 500 },
1898            width: HwpUnit::from_mm(50.0).unwrap(),
1899            height: HwpUnit::from_mm(25.0).unwrap(),
1900            horz_offset: 0,
1901            vert_offset: 0,
1902            caption: None,
1903            style: None,
1904        };
1905        assert!(ctrl.is_line());
1906        assert!(!ctrl.is_text_box());
1907        assert!(!ctrl.is_ellipse());
1908        assert!(!ctrl.is_polygon());
1909    }
1910
1911    #[test]
1912    fn ellipse_construction() {
1913        let ctrl = Control::Ellipse {
1914            center: ShapePoint { x: 500, y: 500 },
1915            axis1: ShapePoint { x: 1000, y: 500 },
1916            axis2: ShapePoint { x: 500, y: 1000 },
1917            width: HwpUnit::from_mm(40.0).unwrap(),
1918            height: HwpUnit::from_mm(30.0).unwrap(),
1919            horz_offset: 0,
1920            vert_offset: 0,
1921            paragraphs: vec![],
1922            caption: None,
1923            style: None,
1924        };
1925        assert!(ctrl.is_ellipse());
1926        assert!(!ctrl.is_line());
1927        assert!(!ctrl.is_polygon());
1928    }
1929
1930    #[test]
1931    fn ellipse_with_paragraphs() {
1932        let ctrl = Control::Ellipse {
1933            center: ShapePoint { x: 500, y: 500 },
1934            axis1: ShapePoint { x: 1000, y: 500 },
1935            axis2: ShapePoint { x: 500, y: 1000 },
1936            width: HwpUnit::from_mm(40.0).unwrap(),
1937            height: HwpUnit::from_mm(30.0).unwrap(),
1938            horz_offset: 0,
1939            vert_offset: 0,
1940            paragraphs: vec![simple_paragraph()],
1941            caption: None,
1942            style: None,
1943        };
1944        assert!(ctrl.is_ellipse());
1945        assert_eq!(ctrl.to_string(), "Ellipse(1 paragraph)");
1946    }
1947
1948    #[test]
1949    fn polygon_construction() {
1950        let ctrl = Control::Polygon {
1951            vertices: vec![
1952                ShapePoint { x: 0, y: 0 },
1953                ShapePoint { x: 1000, y: 0 },
1954                ShapePoint { x: 500, y: 1000 },
1955            ],
1956            width: HwpUnit::from_mm(50.0).unwrap(),
1957            height: HwpUnit::from_mm(50.0).unwrap(),
1958            horz_offset: 0,
1959            vert_offset: 0,
1960            paragraphs: vec![],
1961            caption: None,
1962            style: None,
1963        };
1964        assert!(ctrl.is_polygon());
1965        assert!(!ctrl.is_line());
1966        assert!(!ctrl.is_ellipse());
1967        assert_eq!(ctrl.to_string(), "Polygon(3 vertices, 0 paragraphs)");
1968    }
1969
1970    #[test]
1971    fn display_line() {
1972        let ctrl = Control::Line {
1973            start: ShapePoint { x: 0, y: 0 },
1974            end: ShapePoint { x: 100, y: 200 },
1975            width: HwpUnit::from_mm(10.0).unwrap(),
1976            height: HwpUnit::from_mm(5.0).unwrap(),
1977            horz_offset: 0,
1978            vert_offset: 0,
1979            caption: None,
1980            style: None,
1981        };
1982        assert_eq!(ctrl.to_string(), "Line");
1983    }
1984
1985    #[test]
1986    fn serde_roundtrip_line() {
1987        let ctrl = Control::Line {
1988            start: ShapePoint { x: 100, y: 200 },
1989            end: ShapePoint { x: 300, y: 400 },
1990            width: HwpUnit::from_mm(20.0).unwrap(),
1991            height: HwpUnit::from_mm(10.0).unwrap(),
1992            horz_offset: 0,
1993            vert_offset: 0,
1994            caption: None,
1995            style: None,
1996        };
1997        let json = serde_json::to_string(&ctrl).unwrap();
1998        let back: Control = serde_json::from_str(&json).unwrap();
1999        assert_eq!(ctrl, back);
2000    }
2001
2002    #[test]
2003    fn serde_roundtrip_ellipse() {
2004        let ctrl = Control::Ellipse {
2005            center: ShapePoint { x: 500, y: 500 },
2006            axis1: ShapePoint { x: 1000, y: 500 },
2007            axis2: ShapePoint { x: 500, y: 1000 },
2008            width: HwpUnit::from_mm(40.0).unwrap(),
2009            height: HwpUnit::from_mm(30.0).unwrap(),
2010            horz_offset: 0,
2011            vert_offset: 0,
2012            paragraphs: vec![simple_paragraph()],
2013            caption: None,
2014            style: None,
2015        };
2016        let json = serde_json::to_string(&ctrl).unwrap();
2017        let back: Control = serde_json::from_str(&json).unwrap();
2018        assert_eq!(ctrl, back);
2019    }
2020
2021    #[test]
2022    fn serde_roundtrip_polygon() {
2023        let ctrl = Control::Polygon {
2024            vertices: vec![
2025                ShapePoint { x: 0, y: 0 },
2026                ShapePoint { x: 1000, y: 0 },
2027                ShapePoint { x: 500, y: 1000 },
2028            ],
2029            width: HwpUnit::from_mm(50.0).unwrap(),
2030            height: HwpUnit::from_mm(50.0).unwrap(),
2031            horz_offset: 0,
2032            vert_offset: 0,
2033            paragraphs: vec![],
2034            caption: None,
2035            style: None,
2036        };
2037        let json = serde_json::to_string(&ctrl).unwrap();
2038        let back: Control = serde_json::from_str(&json).unwrap();
2039        assert_eq!(ctrl, back);
2040    }
2041
2042    #[test]
2043    fn shape_point_equality() {
2044        let a = ShapePoint { x: 10, y: 20 };
2045        let b = ShapePoint { x: 10, y: 20 };
2046        let c = ShapePoint { x: 10, y: 30 };
2047        assert_eq!(a, b);
2048        assert_ne!(a, c);
2049    }
2050
2051    #[test]
2052    fn shape_point_new() {
2053        let pt = ShapePoint::new(100, 200);
2054        assert_eq!(pt.x, 100);
2055        assert_eq!(pt.y, 200);
2056    }
2057
2058    #[test]
2059    fn shape_point_serde_roundtrip() {
2060        let pt = ShapePoint::new(500, 750);
2061        let json = serde_json::to_string(&pt).unwrap();
2062        let back: ShapePoint = serde_json::from_str(&json).unwrap();
2063        assert_eq!(pt, back);
2064    }
2065
2066    // ── Convenience constructor tests ────────────────────────────────────
2067
2068    #[test]
2069    fn equation_constructor_defaults() {
2070        let ctrl = Control::equation("{a+b} over {c+d}");
2071        assert!(ctrl.is_equation());
2072        match ctrl {
2073            Control::Equation { script, width, height, base_line, text_color, ref font } => {
2074                assert_eq!(script, "{a+b} over {c+d}");
2075                assert_eq!(width, HwpUnit::new(8779).unwrap());
2076                assert_eq!(height, HwpUnit::new(2600).unwrap());
2077                assert_eq!(base_line, 71);
2078                assert_eq!(text_color, Color::BLACK);
2079                assert_eq!(font, "HancomEQN");
2080            }
2081            _ => panic!("expected Equation"),
2082        }
2083    }
2084
2085    #[test]
2086    fn equation_constructor_empty_script() {
2087        let ctrl = Control::equation("");
2088        assert!(ctrl.is_equation());
2089    }
2090
2091    #[test]
2092    fn text_box_constructor_defaults() {
2093        let width = HwpUnit::from_mm(80.0).unwrap();
2094        let height = HwpUnit::from_mm(40.0).unwrap();
2095        let ctrl = Control::text_box(vec![simple_paragraph()], width, height);
2096        assert!(ctrl.is_text_box());
2097        match ctrl {
2098            Control::TextBox { paragraphs, horz_offset, vert_offset, caption, style, .. } => {
2099                assert_eq!(paragraphs.len(), 1);
2100                assert_eq!(horz_offset, 0);
2101                assert_eq!(vert_offset, 0);
2102                assert!(caption.is_none());
2103                assert!(style.is_none());
2104            }
2105            _ => panic!("expected TextBox"),
2106        }
2107    }
2108
2109    #[test]
2110    fn footnote_constructor_defaults() {
2111        let ctrl = Control::footnote(vec![simple_paragraph()]);
2112        assert!(ctrl.is_footnote());
2113        match ctrl {
2114            Control::Footnote { inst_id, paragraphs } => {
2115                assert!(inst_id.is_none());
2116                assert_eq!(paragraphs.len(), 1);
2117            }
2118            _ => panic!("expected Footnote"),
2119        }
2120    }
2121
2122    #[test]
2123    fn endnote_constructor_defaults() {
2124        let ctrl = Control::endnote(vec![simple_paragraph()]);
2125        assert!(ctrl.is_endnote());
2126        match ctrl {
2127            Control::Endnote { inst_id, paragraphs } => {
2128                assert!(inst_id.is_none());
2129                assert_eq!(paragraphs.len(), 1);
2130            }
2131            _ => panic!("expected Endnote"),
2132        }
2133    }
2134
2135    #[test]
2136    fn ellipse_constructor_geometry() {
2137        let width = HwpUnit::from_mm(40.0).unwrap();
2138        let height = HwpUnit::from_mm(30.0).unwrap();
2139        let ctrl = Control::ellipse(width, height);
2140        assert!(ctrl.is_ellipse());
2141        match &ctrl {
2142            Control::Ellipse {
2143                center,
2144                axis1,
2145                axis2,
2146                horz_offset,
2147                vert_offset,
2148                paragraphs,
2149                caption,
2150                style,
2151                ..
2152            } => {
2153                let w = width.as_i32();
2154                let h = height.as_i32();
2155                assert_eq!(*center, ShapePoint::new(w / 2, h / 2));
2156                assert_eq!(*axis1, ShapePoint::new(w, h / 2));
2157                assert_eq!(*axis2, ShapePoint::new(w / 2, h));
2158                assert_eq!(*horz_offset, 0);
2159                assert_eq!(*vert_offset, 0);
2160                assert!(paragraphs.is_empty());
2161                assert!(caption.is_none());
2162                assert!(style.is_none());
2163            }
2164            _ => panic!("expected Ellipse"),
2165        }
2166    }
2167
2168    #[test]
2169    fn rect_constructor_basic_geometry() {
2170        let width = HwpUnit::from_mm(40.0).unwrap();
2171        let height = HwpUnit::from_mm(20.0).unwrap();
2172        let ctrl = Control::rect(width, height).unwrap();
2173        assert!(ctrl.is_rect());
2174        match ctrl {
2175            Control::Rect { width: w, height: h, horz_offset, vert_offset, caption, style } => {
2176                assert_eq!(w, width);
2177                assert_eq!(h, height);
2178                assert_eq!(horz_offset, 0);
2179                assert_eq!(vert_offset, 0);
2180                assert!(caption.is_none());
2181                assert!(style.is_none());
2182            }
2183            _ => panic!("expected Rect"),
2184        }
2185    }
2186
2187    #[test]
2188    fn rect_constructor_zero_dimension_errors() {
2189        let zero = HwpUnit::new(0).unwrap();
2190        let nonzero = HwpUnit::from_mm(10.0).unwrap();
2191        assert!(Control::rect(zero, nonzero).is_err());
2192        assert!(Control::rect(nonzero, zero).is_err());
2193    }
2194
2195    #[test]
2196    fn polygon_constructor_triangle() {
2197        let vertices =
2198            vec![ShapePoint::new(0, 1000), ShapePoint::new(500, 0), ShapePoint::new(1000, 1000)];
2199        let ctrl = Control::polygon(vertices).unwrap();
2200        assert!(ctrl.is_polygon());
2201        match &ctrl {
2202            Control::Polygon {
2203                vertices,
2204                width,
2205                height,
2206                horz_offset,
2207                vert_offset,
2208                paragraphs,
2209                caption,
2210                style,
2211            } => {
2212                assert_eq!(vertices.len(), 3);
2213                // bbox: x 0..1000, y 0..1000
2214                assert_eq!(*width, HwpUnit::new(1000).unwrap());
2215                assert_eq!(*height, HwpUnit::new(1000).unwrap());
2216                assert_eq!(*horz_offset, 0);
2217                assert_eq!(*vert_offset, 0);
2218                assert!(paragraphs.is_empty());
2219                assert!(caption.is_none());
2220                assert!(style.is_none());
2221            }
2222            _ => panic!("expected Polygon"),
2223        }
2224    }
2225
2226    #[test]
2227    fn polygon_constructor_fewer_than_3_vertices_errors() {
2228        assert!(Control::polygon(vec![]).is_err());
2229        assert!(Control::polygon(vec![ShapePoint::new(0, 0)]).is_err());
2230        assert!(Control::polygon(vec![ShapePoint::new(0, 0), ShapePoint::new(1, 1)]).is_err());
2231    }
2232
2233    #[test]
2234    fn polygon_constructor_negative_coordinates() {
2235        let vertices =
2236            vec![ShapePoint::new(-500, -500), ShapePoint::new(500, -500), ShapePoint::new(0, 500)];
2237        let ctrl = Control::polygon(vertices).unwrap();
2238        assert!(ctrl.is_polygon());
2239        match ctrl {
2240            Control::Polygon { width, height, .. } => {
2241                // bbox: x -500..500 = 1000, y -500..500 = 1000
2242                assert_eq!(width, HwpUnit::new(1000).unwrap());
2243                assert_eq!(height, HwpUnit::new(1000).unwrap());
2244            }
2245            _ => panic!("expected Polygon"),
2246        }
2247    }
2248
2249    #[test]
2250    fn polygon_constructor_degenerate_collinear() {
2251        // 3 collinear points: height = 0 (flat), should succeed
2252        let vertices =
2253            vec![ShapePoint::new(0, 0), ShapePoint::new(500, 0), ShapePoint::new(1000, 0)];
2254        let ctrl = Control::polygon(vertices).unwrap();
2255        assert!(ctrl.is_polygon());
2256        match ctrl {
2257            Control::Polygon { width, height, .. } => {
2258                assert_eq!(width, HwpUnit::new(1000).unwrap());
2259                assert_eq!(height, HwpUnit::new(0).unwrap());
2260            }
2261            _ => panic!("expected Polygon"),
2262        }
2263    }
2264
2265    #[test]
2266    fn line_constructor_horizontal() {
2267        let ctrl = Control::line(ShapePoint::new(0, 0), ShapePoint::new(5000, 0)).unwrap();
2268        assert!(ctrl.is_line());
2269        match ctrl {
2270            Control::Line {
2271                start,
2272                end,
2273                width,
2274                height,
2275                horz_offset,
2276                vert_offset,
2277                caption,
2278                style,
2279            } => {
2280                assert_eq!(start, ShapePoint::new(0, 0));
2281                assert_eq!(end, ShapePoint::new(5000, 0));
2282                assert_eq!(width, HwpUnit::new(5000).unwrap());
2283                assert_eq!(height, HwpUnit::new(100).unwrap()); // min bounding box
2284                assert_eq!(horz_offset, 0);
2285                assert_eq!(vert_offset, 0);
2286                assert!(caption.is_none());
2287                assert!(style.is_none());
2288            }
2289            _ => panic!("expected Line"),
2290        }
2291    }
2292
2293    #[test]
2294    fn line_constructor_vertical() {
2295        let ctrl = Control::line(ShapePoint::new(0, 0), ShapePoint::new(0, 3000)).unwrap();
2296        assert!(ctrl.is_line());
2297        match ctrl {
2298            Control::Line { width, height, .. } => {
2299                assert_eq!(width, HwpUnit::new(100).unwrap()); // min bounding box
2300                assert_eq!(height, HwpUnit::new(3000).unwrap());
2301            }
2302            _ => panic!("expected Line"),
2303        }
2304    }
2305
2306    #[test]
2307    fn line_constructor_diagonal_bounding_box() {
2308        let ctrl = Control::line(ShapePoint::new(100, 200), ShapePoint::new(400, 500)).unwrap();
2309        match ctrl {
2310            Control::Line { width, height, .. } => {
2311                assert_eq!(width, HwpUnit::new(300).unwrap());
2312                assert_eq!(height, HwpUnit::new(300).unwrap());
2313            }
2314            _ => panic!("expected Line"),
2315        }
2316    }
2317
2318    #[test]
2319    fn line_constructor_same_point_errors() {
2320        let pt = ShapePoint::new(100, 200);
2321        assert!(Control::line(pt, pt).is_err());
2322    }
2323
2324    #[test]
2325    fn horizontal_line_constructor() {
2326        let width = HwpUnit::from_mm(100.0).unwrap();
2327        let ctrl = Control::horizontal_line(width);
2328        assert!(ctrl.is_line());
2329        match ctrl {
2330            Control::Line {
2331                start,
2332                end,
2333                width: w,
2334                height,
2335                horz_offset,
2336                vert_offset,
2337                caption,
2338                style,
2339            } => {
2340                assert_eq!(start, ShapePoint::new(0, 0));
2341                assert_eq!(end.y, 0);
2342                assert_eq!(end.x, width.as_i32());
2343                assert_eq!(w, width);
2344                assert_eq!(height, HwpUnit::new(100).unwrap()); // min bounding box
2345                assert_eq!(horz_offset, 0);
2346                assert_eq!(vert_offset, 0);
2347                assert!(caption.is_none());
2348                assert!(style.is_none());
2349            }
2350            _ => panic!("expected Line"),
2351        }
2352    }
2353
2354    #[test]
2355    fn hyperlink_constructor() {
2356        let ctrl = Control::hyperlink("Visit Rust", "https://rust-lang.org");
2357        assert!(ctrl.is_hyperlink());
2358        match ctrl {
2359            Control::Hyperlink { text, url } => {
2360                assert_eq!(text, "Visit Rust");
2361                assert_eq!(url, "https://rust-lang.org");
2362            }
2363            _ => panic!("expected Hyperlink"),
2364        }
2365    }
2366
2367    #[test]
2368    fn footnote_with_id_sets_inst_id() {
2369        let para = Paragraph::new(ParaShapeIndex::new(0));
2370        let ctrl = Control::footnote_with_id(42, vec![para]);
2371        assert!(ctrl.is_footnote());
2372        match ctrl {
2373            Control::Footnote { inst_id, paragraphs } => {
2374                assert_eq!(inst_id, Some(42));
2375                assert_eq!(paragraphs.len(), 1);
2376            }
2377            _ => panic!("expected Footnote"),
2378        }
2379    }
2380
2381    #[test]
2382    fn endnote_with_id_sets_inst_id() {
2383        let para = Paragraph::new(ParaShapeIndex::new(0));
2384        let ctrl = Control::endnote_with_id(7, vec![para]);
2385        assert!(ctrl.is_endnote());
2386        match ctrl {
2387            Control::Endnote { inst_id, paragraphs } => {
2388                assert_eq!(inst_id, Some(7));
2389                assert_eq!(paragraphs.len(), 1);
2390            }
2391            _ => panic!("expected Endnote"),
2392        }
2393    }
2394
2395    #[test]
2396    fn footnote_with_id_differs_from_plain_footnote() {
2397        let ctrl_plain = Control::footnote(vec![]);
2398        let ctrl_id = Control::footnote_with_id(1, vec![]);
2399        match ctrl_plain {
2400            Control::Footnote { inst_id, .. } => assert_eq!(inst_id, None),
2401            _ => panic!("expected Footnote"),
2402        }
2403        match ctrl_id {
2404            Control::Footnote { inst_id, .. } => assert_eq!(inst_id, Some(1)),
2405            _ => panic!("expected Footnote"),
2406        }
2407    }
2408
2409    #[test]
2410    fn ellipse_with_text_has_correct_geometry_and_paragraphs() {
2411        use hwpforge_foundation::HwpUnit;
2412        let width = HwpUnit::from_mm(40.0).unwrap();
2413        let height = HwpUnit::from_mm(30.0).unwrap();
2414        let para = Paragraph::new(ParaShapeIndex::new(0));
2415        let ctrl = Control::ellipse_with_text(width, height, vec![para]);
2416        assert!(ctrl.is_ellipse());
2417        match ctrl {
2418            Control::Ellipse {
2419                center,
2420                axis1,
2421                axis2,
2422                width: w,
2423                height: h,
2424                horz_offset,
2425                vert_offset,
2426                paragraphs,
2427                caption,
2428                style,
2429            } => {
2430                let wv = w.as_i32();
2431                let hv = h.as_i32();
2432                assert_eq!(center, ShapePoint::new(wv / 2, hv / 2));
2433                assert_eq!(axis1, ShapePoint::new(wv, hv / 2));
2434                assert_eq!(axis2, ShapePoint::new(wv / 2, hv));
2435                assert_eq!(horz_offset, 0);
2436                assert_eq!(vert_offset, 0);
2437                assert_eq!(paragraphs.len(), 1);
2438                assert!(caption.is_none());
2439                assert!(style.is_none());
2440            }
2441            _ => panic!("expected Ellipse"),
2442        }
2443    }
2444
2445    #[test]
2446    fn serde_roundtrip_chart() {
2447        use crate::chart::{ChartData, ChartGrouping, ChartType, LegendPosition};
2448        let ctrl = Control::Chart {
2449            chart_type: ChartType::Column,
2450            data: ChartData::category(&["A", "B"], &[("S1", &[1.0, 2.0])]),
2451            title: Some("Test Chart".to_string()),
2452            legend: LegendPosition::Bottom,
2453            grouping: ChartGrouping::Stacked,
2454            width: HwpUnit::from_mm(100.0).unwrap(),
2455            height: HwpUnit::from_mm(80.0).unwrap(),
2456            stock_variant: None,
2457            bar_shape: None,
2458            scatter_style: None,
2459            radar_style: None,
2460            of_pie_type: None,
2461            explosion: None,
2462            wireframe: None,
2463            bubble_3d: None,
2464            show_markers: None,
2465        };
2466        let json = serde_json::to_string(&ctrl).unwrap();
2467        let back: Control = serde_json::from_str(&json).unwrap();
2468        assert_eq!(ctrl, back);
2469    }
2470
2471    #[test]
2472    fn serde_roundtrip_equation() {
2473        let ctrl = Control::Equation {
2474            script: "{a+b} over {c+d}".to_string(),
2475            width: HwpUnit::new(8779).unwrap(),
2476            height: HwpUnit::new(2600).unwrap(),
2477            base_line: 71,
2478            text_color: Color::BLACK,
2479            font: "HancomEQN".to_string(),
2480        };
2481        let json = serde_json::to_string(&ctrl).unwrap();
2482        let back: Control = serde_json::from_str(&json).unwrap();
2483        assert_eq!(ctrl, back);
2484    }
2485
2486    #[test]
2487    fn ellipse_with_text_empty_paragraphs_matches_ellipse() {
2488        use hwpforge_foundation::HwpUnit;
2489        let width = HwpUnit::from_mm(20.0).unwrap();
2490        let height = HwpUnit::from_mm(10.0).unwrap();
2491        let plain = Control::ellipse(width, height);
2492        let with_text = Control::ellipse_with_text(width, height, vec![]);
2493        // Both should produce identical shapes when paragraphs are empty
2494        assert_eq!(plain, with_text);
2495    }
2496
2497    // ── Dutmal (덧말) tests ──────────────────────────────────────
2498
2499    #[test]
2500    fn dutmal_constructor_defaults() {
2501        let ctrl = Control::dutmal("본문", "주석");
2502        assert!(ctrl.is_dutmal());
2503        match ctrl {
2504            Control::Dutmal { main_text, sub_text, position, sz_ratio, align } => {
2505                assert_eq!(main_text, "본문");
2506                assert_eq!(sub_text, "주석");
2507                assert_eq!(position, DutmalPosition::Top);
2508                assert_eq!(sz_ratio, 0);
2509                assert_eq!(align, DutmalAlign::Center);
2510            }
2511            _ => panic!("expected Dutmal"),
2512        }
2513    }
2514
2515    #[test]
2516    fn dutmal_is_dutmal_true() {
2517        assert!(Control::dutmal("a", "b").is_dutmal());
2518    }
2519
2520    #[test]
2521    fn dutmal_is_compose_false() {
2522        assert!(!Control::dutmal("a", "b").is_compose());
2523    }
2524
2525    #[test]
2526    fn dutmal_display() {
2527        let ctrl = Control::dutmal("hello", "world");
2528        assert_eq!(ctrl.to_string(), r#"Dutmal("hello" / "world")"#);
2529    }
2530
2531    #[test]
2532    fn dutmal_serde_roundtrip() {
2533        let ctrl = Control::Dutmal {
2534            main_text: "테스트".to_string(),
2535            sub_text: "test".to_string(),
2536            position: DutmalPosition::Bottom,
2537            sz_ratio: 50,
2538            align: DutmalAlign::Right,
2539        };
2540        let json = serde_json::to_string(&ctrl).unwrap();
2541        let decoded: Control = serde_json::from_str(&json).unwrap();
2542        assert_eq!(ctrl, decoded);
2543    }
2544
2545    #[test]
2546    fn dutmal_position_default_is_top() {
2547        assert_eq!(DutmalPosition::default(), DutmalPosition::Top);
2548    }
2549
2550    #[test]
2551    fn dutmal_align_default_is_center() {
2552        assert_eq!(DutmalAlign::default(), DutmalAlign::Center);
2553    }
2554
2555    // ── Compose (글자겹침) tests ─────────────────────────────────
2556
2557    #[test]
2558    fn compose_constructor_defaults() {
2559        let ctrl = Control::compose("가");
2560        assert!(ctrl.is_compose());
2561        match ctrl {
2562            Control::Compose { compose_text, circle_type, char_sz, compose_type } => {
2563                assert_eq!(compose_text, "가");
2564                assert_eq!(circle_type, "SHAPE_REVERSAL_TIRANGLE");
2565                assert_eq!(char_sz, -3);
2566                assert_eq!(compose_type, "SPREAD");
2567            }
2568            _ => panic!("expected Compose"),
2569        }
2570    }
2571
2572    #[test]
2573    fn compose_is_compose_true() {
2574        assert!(Control::compose("나").is_compose());
2575    }
2576
2577    #[test]
2578    fn compose_is_dutmal_false() {
2579        assert!(!Control::compose("나").is_dutmal());
2580    }
2581
2582    #[test]
2583    fn compose_display() {
2584        let ctrl = Control::compose("가나");
2585        assert_eq!(ctrl.to_string(), r#"Compose("가나")"#);
2586    }
2587
2588    #[test]
2589    fn compose_serde_roundtrip() {
2590        let ctrl = Control::Compose {
2591            compose_text: "①".to_string(),
2592            circle_type: "SHAPE_REVERSAL_TIRANGLE".to_string(),
2593            char_sz: -3,
2594            compose_type: "SPREAD".to_string(),
2595        };
2596        let json = serde_json::to_string(&ctrl).unwrap();
2597        let decoded: Control = serde_json::from_str(&json).unwrap();
2598        assert_eq!(ctrl, decoded);
2599    }
2600
2601    #[test]
2602    fn compose_spec_typo_preserved() {
2603        // "SHAPE_REVERSAL_TIRANGLE" is an official spec typo — must be preserved exactly
2604        let ctrl = Control::compose("X");
2605        match ctrl {
2606            Control::Compose { circle_type, .. } => {
2607                assert_eq!(circle_type, "SHAPE_REVERSAL_TIRANGLE");
2608                assert!(!circle_type.contains("TRIANGLE")); // confirm the typo
2609            }
2610            _ => panic!("expected Compose"),
2611        }
2612    }
2613
2614    // ===================================================================
2615    // H2: saturating i64→i32 conversion in shape constructors
2616    // ===================================================================
2617
2618    #[test]
2619    fn line_extreme_coords_no_panic() {
2620        // Coordinates near i32 extremes produce a valid line without panicking
2621        let start = ShapePoint::new(i32::MIN, i32::MIN);
2622        let end = ShapePoint::new(i32::MAX, i32::MAX);
2623        let ctrl = Control::line(start, end).unwrap();
2624        assert!(ctrl.is_line());
2625    }
2626
2627    #[test]
2628    fn connect_line_extreme_coords_no_panic() {
2629        let start = ShapePoint::new(i32::MIN, 0);
2630        let end = ShapePoint::new(i32::MAX, 0);
2631        let ctrl = Control::connect_line(start, end).unwrap();
2632        assert!(ctrl.is_connect_line());
2633    }
2634
2635    #[test]
2636    fn polygon_extreme_coords_no_panic() {
2637        // Span exceeds i32::MAX — should error (HwpUnit range exceeded), not panic
2638        let vertices = vec![
2639            ShapePoint::new(i32::MIN, 0),
2640            ShapePoint::new(i32::MAX, 0),
2641            ShapePoint::new(0, i32::MAX),
2642        ];
2643        // Either succeeds (saturated) or returns an error — must not panic
2644        let _ = Control::polygon(vertices);
2645    }
2646
2647    #[test]
2648    fn curve_extreme_coords_no_panic() {
2649        let points = vec![ShapePoint::new(i32::MIN, i32::MIN), ShapePoint::new(i32::MAX, i32::MAX)];
2650        let _ = Control::curve(points);
2651    }
2652}