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