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