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