Skip to main content

hwpforge_core/control/
mod.rs

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