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