Skip to main content

hwpforge_core/control/
mod.rs

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