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