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}