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