Skip to main content

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