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