Skip to main content

hwpforge_foundation/enums/
reference.rs

1//! Reference enums: bookmarks, fields, cross-references, and drop-cap styles.
2
3use crate::error::FoundationError;
4use serde::{Deserialize, Serialize};
5use std::fmt;
6
7// ---------------------------------------------------------------------------
8// BookmarkType
9// ---------------------------------------------------------------------------
10
11/// Type of bookmark in an HWPX document.
12///
13/// Bookmarks can mark a single point or span a range of content
14/// (start/end pair using `fieldBegin`/`fieldEnd`).
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
16#[non_exhaustive]
17#[repr(u8)]
18pub enum BookmarkType {
19    /// A point bookmark at a single location (direct serde in `<hp:ctrl>`).
20    #[default]
21    Point = 0,
22    /// Start of a span bookmark (`fieldBegin type="BOOKMARK"`).
23    SpanStart = 1,
24    /// End of a span bookmark (`fieldEnd beginIDRef`).
25    SpanEnd = 2,
26}
27
28impl fmt::Display for BookmarkType {
29    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
30        match self {
31            Self::Point => f.write_str("Point"),
32            Self::SpanStart => f.write_str("SpanStart"),
33            Self::SpanEnd => f.write_str("SpanEnd"),
34        }
35    }
36}
37
38impl std::str::FromStr for BookmarkType {
39    type Err = FoundationError;
40
41    fn from_str(s: &str) -> Result<Self, Self::Err> {
42        match s {
43            "Point" | "point" => Ok(Self::Point),
44            "SpanStart" | "span_start" => Ok(Self::SpanStart),
45            "SpanEnd" | "span_end" => Ok(Self::SpanEnd),
46            _ => Err(FoundationError::ParseError {
47                type_name: "BookmarkType".to_string(),
48                value: s.to_string(),
49                valid_values: "Point, SpanStart, SpanEnd".to_string(),
50            }),
51        }
52    }
53}
54
55impl TryFrom<u8> for BookmarkType {
56    type Error = FoundationError;
57
58    fn try_from(value: u8) -> Result<Self, Self::Error> {
59        match value {
60            0 => Ok(Self::Point),
61            1 => Ok(Self::SpanStart),
62            2 => Ok(Self::SpanEnd),
63            _ => Err(FoundationError::ParseError {
64                type_name: "BookmarkType".to_string(),
65                value: value.to_string(),
66                valid_values: "0 (Point), 1 (SpanStart), 2 (SpanEnd)".to_string(),
67            }),
68        }
69    }
70}
71
72impl schemars::JsonSchema for BookmarkType {
73    fn schema_name() -> std::borrow::Cow<'static, str> {
74        std::borrow::Cow::Borrowed("BookmarkType")
75    }
76
77    fn json_schema(gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
78        gen.subschema_for::<String>()
79    }
80}
81
82// ---------------------------------------------------------------------------
83// FieldType
84// ---------------------------------------------------------------------------
85
86/// Type of a press-field (누름틀) or SUMMERY auto-field carried in
87/// [`Control::Field`].
88///
89/// `ClickHere` is the press-field (user-fillable form). The rest are
90/// SUMMERY-family auto-fields (HWPX `<hp:fieldBegin type="SUMMERY">`,
91/// HWP5 `%smr` ctrl_id) that resolve to document metadata at render time.
92///
93/// Wave 12n (2026-06-02) breaking: legacy `Date`/`Time`/`DocSummary`/`UserInfo`
94/// variants were renamed to match the actual HWPX `Command` token and 한컴
95/// menu item, and `PageNum` was moved out to [`Control::InlinePageNumber`]
96/// because the wire family is `atno`, not SUMMERY/fieldBegin.
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
98#[non_exhaustive]
99#[repr(u8)]
100pub enum FieldType {
101    /// Click-here placeholder press-field (default). 한컴 누름틀. HWP5 `%clk`.
102    #[default]
103    ClickHere = 0,
104    /// SUMMERY `$author` — 만든 사람 (was [`FieldType::DocSummary`]).
105    Author = 1,
106    /// SUMMERY `$lastsaveby` — 마지막 저장한 사람 (was [`FieldType::UserInfo`]).
107    LastSavedBy = 2,
108    /// SUMMERY `$createtime` — 만든 날짜 (was [`FieldType::Time`]).
109    CreatedTime = 3,
110    /// SUMMERY `$modifiedtime` — 마지막 저장한 날짜 (was [`FieldType::Date`]).
111    ModifiedTime = 4,
112    /// SUMMERY `$title` — 문서 제목 (NEW in Wave 12n).
113    Title = 5,
114}
115
116impl FieldType {
117    /// Returns the HWPX `Command` `$token` for SUMMERY variants, or `None`
118    /// for non-SUMMERY (`ClickHere`).
119    #[must_use]
120    pub fn summary_token(self) -> Option<&'static str> {
121        match self {
122            Self::ClickHere => None,
123            Self::Author => Some("$author"),
124            Self::LastSavedBy => Some("$lastsaveby"),
125            Self::CreatedTime => Some("$createtime"),
126            Self::ModifiedTime => Some("$modifiedtime"),
127            Self::Title => Some("$title"),
128        }
129    }
130
131    /// Parses a SUMMERY `$token` (`$author`, `$createtime`, …) into a
132    /// matching [`FieldType`]. Returns `None` for unknown tokens; callers
133    /// should carry the raw token via [`Control::UnknownSummary`] instead.
134    #[must_use]
135    pub fn from_summary_token(token: &str) -> Option<Self> {
136        match token {
137            "$author" => Some(Self::Author),
138            "$lastsaveby" => Some(Self::LastSavedBy),
139            "$createtime" => Some(Self::CreatedTime),
140            "$modifiedtime" => Some(Self::ModifiedTime),
141            "$title" => Some(Self::Title),
142            _ => None,
143        }
144    }
145
146    /// Returns the HWPX `<hp:fieldBegin editable="…">` value for this
147    /// field type (Wave 12p task #124).
148    ///
149    /// Empirically derived from the Hancom-native
150    /// `sample-field-docsummary.hwpx`:
151    /// - `Author`, `Title` → `false` (stable user metadata; Hancom keeps
152    ///   the authored value)
153    /// - `LastSavedBy`, `CreatedTime`, `ModifiedTime` → `true` (Hancom
154    ///   recomputes from the document metadata on save)
155    /// - `ClickHere` → `true` (placeholder press-fields are user-editable)
156    ///
157    /// Encoders use this method to populate the `editable` attribute
158    /// instead of the prior hardcoded `"1"` (which previously caused
159    /// `$author`/`$title` to appear edited when re-saved by Hancom).
160    #[must_use]
161    pub fn hwpx_editable(self) -> bool {
162        match self {
163            Self::ClickHere => true,
164            Self::Author | Self::Title => false,
165            Self::LastSavedBy | Self::CreatedTime | Self::ModifiedTime => true,
166        }
167    }
168}
169
170impl fmt::Display for FieldType {
171    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
172        match self {
173            Self::ClickHere => f.write_str("CLICK_HERE"),
174            Self::Author => f.write_str("AUTHOR"),
175            Self::LastSavedBy => f.write_str("LAST_SAVED_BY"),
176            Self::CreatedTime => f.write_str("CREATED_TIME"),
177            Self::ModifiedTime => f.write_str("MODIFIED_TIME"),
178            Self::Title => f.write_str("TITLE"),
179        }
180    }
181}
182
183impl std::str::FromStr for FieldType {
184    type Err = FoundationError;
185
186    fn from_str(s: &str) -> Result<Self, Self::Err> {
187        match s {
188            "CLICK_HERE" | "ClickHere" | "click_here" => Ok(Self::ClickHere),
189            "AUTHOR" | "Author" | "author"
190            // legacy Wave 12l alias: DocSummary used to emit $author
191            | "DOC_SUMMARY" | "DocSummary" | "doc_summary" => Ok(Self::Author),
192            "LAST_SAVED_BY" | "LastSavedBy" | "last_saved_by"
193            // legacy alias: UserInfo used to emit $lastsaveby
194            | "USER_INFO" | "UserInfo" | "user_info" => Ok(Self::LastSavedBy),
195            "CREATED_TIME" | "CreatedTime" | "created_time"
196            // legacy alias: Time used to emit $createtime
197            | "TIME" | "Time" | "time" => Ok(Self::CreatedTime),
198            "MODIFIED_TIME" | "ModifiedTime" | "modified_time"
199            // legacy alias: Date used to emit $modifiedtime
200            | "DATE" | "Date" | "date" => Ok(Self::ModifiedTime),
201            "TITLE" | "Title" | "title" => Ok(Self::Title),
202            _ => Err(FoundationError::ParseError {
203                type_name: "FieldType".to_string(),
204                value: s.to_string(),
205                valid_values:
206                    "CLICK_HERE, AUTHOR, LAST_SAVED_BY, CREATED_TIME, MODIFIED_TIME, TITLE"
207                        .to_string(),
208            }),
209        }
210    }
211}
212
213impl TryFrom<u8> for FieldType {
214    type Error = FoundationError;
215
216    fn try_from(value: u8) -> Result<Self, Self::Error> {
217        match value {
218            0 => Ok(Self::ClickHere),
219            1 => Ok(Self::Author),
220            2 => Ok(Self::LastSavedBy),
221            3 => Ok(Self::CreatedTime),
222            4 => Ok(Self::ModifiedTime),
223            5 => Ok(Self::Title),
224            _ => Err(FoundationError::ParseError {
225                type_name: "FieldType".to_string(),
226                value: value.to_string(),
227                valid_values: "0..5 (ClickHere..Title)".to_string(),
228            }),
229        }
230    }
231}
232
233impl schemars::JsonSchema for FieldType {
234    fn schema_name() -> std::borrow::Cow<'static, str> {
235        std::borrow::Cow::Borrowed("FieldType")
236    }
237
238    fn json_schema(gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
239        gen.subschema_for::<String>()
240    }
241}
242
243// ---------------------------------------------------------------------------
244// RefType
245// ---------------------------------------------------------------------------
246
247/// Target type of a cross-reference (상호참조).
248///
249/// Wave 12m Phase 2 (Codex(architect+critic) review 반영):
250/// - `#[repr(u8)]` 제거 — HWP5 wire 코드는 boundary 에서 매핑 (Core/foundation
251///   은 wire-agnostic 의미 모델만 유지).
252/// - `Footnote` / `Endnote` / `Outline` 추가 (한컴 native dropdown 7 종 완비).
253/// - `Unknown(u8)` tuple variant 추가 — silent fallback 위조 방지 (Codex HIGH).
254///
255/// HWP5 `%xrf` Command N1 코드 → `RefType` 변환은
256/// `smithy-hwp5/src/projection.rs::decode_hwp5_ref_type` 에서 수행.
257#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
258#[non_exhaustive]
259pub enum RefType {
260    /// Reference to a bookmark target. 한컴: "책갈피"
261    #[default]
262    Bookmark,
263    /// Reference to a table caption number. 한컴: "표"
264    Table,
265    /// Reference to a figure/image caption number. 한컴: "그림"
266    Figure,
267    /// Reference to an equation caption number. 한컴: "수식"
268    Equation,
269    /// Reference to a footnote (각주).
270    Footnote,
271    /// Reference to an endnote (미주).
272    Endnote,
273    /// Reference to an outline heading (개요).
274    Outline,
275    /// Unrecognized RefType code preserved from wire for forward
276    /// compatibility. Carriers MUST NOT silently fallback to a known
277    /// variant — the unknown code is preserved so future Hancom
278    /// extensions don't get silently lost.
279    Unknown(u8),
280}
281
282impl fmt::Display for RefType {
283    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
284        match self {
285            Self::Bookmark => f.write_str("TARGET_BOOKMARK"),
286            Self::Table => f.write_str("TARGET_TABLE"),
287            Self::Figure => f.write_str("TARGET_FIGURE"),
288            Self::Equation => f.write_str("TARGET_EQUATION"),
289            Self::Footnote => f.write_str("TARGET_FOOTNOTE"),
290            Self::Endnote => f.write_str("TARGET_ENDNOTE"),
291            Self::Outline => f.write_str("TARGET_OUTLINE"),
292            Self::Unknown(code) => write!(f, "TARGET_UNKNOWN({code})"),
293        }
294    }
295}
296
297impl std::str::FromStr for RefType {
298    type Err = FoundationError;
299
300    fn from_str(s: &str) -> Result<Self, Self::Err> {
301        match s {
302            "TARGET_BOOKMARK" | "Bookmark" | "bookmark" => Ok(Self::Bookmark),
303            "TARGET_TABLE" | "Table" | "table" => Ok(Self::Table),
304            "TARGET_FIGURE" | "Figure" | "figure" => Ok(Self::Figure),
305            "TARGET_EQUATION" | "Equation" | "equation" => Ok(Self::Equation),
306            "TARGET_FOOTNOTE" | "Footnote" | "footnote" => Ok(Self::Footnote),
307            "TARGET_ENDNOTE" | "Endnote" | "endnote" => Ok(Self::Endnote),
308            "TARGET_OUTLINE" | "Outline" | "outline" => Ok(Self::Outline),
309            _ => Err(FoundationError::ParseError {
310                type_name: "RefType".to_string(),
311                value: s.to_string(),
312                valid_values: "TARGET_BOOKMARK, TARGET_TABLE, TARGET_FIGURE, TARGET_EQUATION, \
313                    TARGET_FOOTNOTE, TARGET_ENDNOTE, TARGET_OUTLINE"
314                    .to_string(),
315            }),
316        }
317    }
318}
319
320impl schemars::JsonSchema for RefType {
321    fn schema_name() -> std::borrow::Cow<'static, str> {
322        std::borrow::Cow::Borrowed("RefType")
323    }
324
325    fn json_schema(gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
326        gen.subschema_for::<String>()
327    }
328}
329
330// ---------------------------------------------------------------------------
331// RefContentType
332// ---------------------------------------------------------------------------
333
334/// Content display type for a cross-reference (what to show at the reference site).
335///
336/// Wave 12m Phase 2 (Codex(architect+critic) review 반영):
337/// - `#[repr(u8)]` 제거 — N2 wire 코드는 boundary 에서 매핑.
338/// - `Unknown(u8)` 추가 — silent fallback 위조 방지.
339///
340/// Wave 12p pre-fix (Wave 12m fixup regression revert + 한컴 native
341/// wire 일치 보정, 2026-06-08):
342///
343/// Wave 12m fixup 에서 `BookmarkName` 폐기 + Bookmark+Contents 를 N2=2
344/// 로 통일했으나, native wire 분석 결과 정반대였음:
345///
346/// | HWP5 N2 (Bookmark) | 한컴 의미     | HWPX RefContentType 문자열 |
347/// |--------------------|---------------|---------------------------|
348/// | 0                  | 책갈피 위치 페이지 | `OBJECT_TYPE_PAGE`        |
349/// | 1                  | 책갈피 본문 / 번호 | `OBJECT_TYPE_NUMBER` (!!) |
350/// | 2                  | 책갈피 이름       | `OBJECT_TYPE_CONTENTS`    |
351/// | 3                  | 위/아래          | `OBJECT_TYPE_UPDOWNPOS`   |
352///
353/// (책갈피의 wire 는 OWPML spec 표 156 의 직관과 어긋남 — `CONTENTS`
354/// 가 "책갈피 이름", `NUMBER` 가 "책갈피 내용" 의미.)
355///
356/// E6 슬라이스 B (2026-06-28): 한때 분리됐던 `BookmarkName` variant 를
357/// `Contents` 로 흡수 — wire(N2=2)·Display(`OBJECT_TYPE_CONTENTS`) 가
358/// 동일하고, Bookmark vs caption 구분은 동반 [`RefType`] 가 carry
359/// (gotcha #27 — wire 모호성을 content variant 에 prebake 하지 않음).
360#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
361#[non_exhaustive]
362pub enum RefContentType {
363    /// Show page number where the target appears. 한컴: "쪽 번호" (모든 RefType 공통)
364    #[default]
365    Page,
366    /// Show the target's numbering. 의미는 RefType-상대적:
367    /// - Footnote/Endnote/Caption/Outline: "주 번호" / "표 번호" / "그림 번호" / "개요 번호" (N2=1)
368    /// - Bookmark: **책갈피 본문 / 번호 텍스트** (N2=1, 한컴 wire 특이성)
369    Number,
370    /// Show the target's content. 의미는 RefType-상대적 (gotcha #27 —
371    /// disambiguation 은 동반 [`RefType`] 가 carry, content variant 가 아님):
372    /// - Figure/Table/Equation: "캡션 내용" (N2=2)
373    /// - Outline: "개요 내용" (N2=2)
374    /// - Bookmark: **책갈피 이름** "bookmark name" (N2=2). E6 슬라이스 B 에서
375    ///   이전 `BookmarkName` variant 를 흡수 — wire(N2=2)·Display
376    ///   (`OBJECT_TYPE_CONTENTS`) 가 동일하므로 별도 variant 불필요.
377    ///   (책갈피의 "본문/번호" 의미는 [`RefContentType::Number`] 가 carry, N2=1.)
378    Contents,
379    /// Show relative position ("위" / "아래"). 한컴: "위/아래" (N2=3).
380    UpDownPos,
381    /// Unrecognized ContentType code preserved from wire for forward
382    /// compatibility.
383    Unknown(u8),
384}
385
386impl fmt::Display for RefContentType {
387    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
388        match self {
389            Self::Page => f.write_str("OBJECT_TYPE_PAGE"),
390            Self::Number => f.write_str("OBJECT_TYPE_NUMBER"),
391            // `Contents` → `OBJECT_TYPE_CONTENTS`. 의미 구분(Bookmark N2=2
392            // = 책갈피 이름 vs Figure/Table/Eq/Outline N2=2 = 캡션 내용)은
393            // 동반 RefType 가 carry (gotcha #27). E6 슬라이스 B 에서
394            // `BookmarkName` variant 를 `Contents` 로 흡수 (wire 동일).
395            Self::Contents => f.write_str("OBJECT_TYPE_CONTENTS"),
396            Self::UpDownPos => f.write_str("OBJECT_TYPE_UPDOWNPOS"),
397            Self::Unknown(code) => write!(f, "OBJECT_TYPE_UNKNOWN({code})"),
398        }
399    }
400}
401
402impl std::str::FromStr for RefContentType {
403    type Err = FoundationError;
404
405    fn from_str(s: &str) -> Result<Self, Self::Err> {
406        match s {
407            "OBJECT_TYPE_PAGE" | "Page" | "page" => Ok(Self::Page),
408            "OBJECT_TYPE_NUMBER" | "Number" | "number" => Ok(Self::Number),
409            // `OBJECT_TYPE_CONTENTS` → `Contents`. Bookmark N2=2(책갈피
410            // 이름) vs caption-content 구분은 동반 RefType 가 carry
411            // (gotcha #27) — content variant 는 단일 `Contents`.
412            "OBJECT_TYPE_CONTENTS" | "Contents" | "contents" => Ok(Self::Contents),
413            "OBJECT_TYPE_UPDOWNPOS" | "UpDownPos" | "updownpos" => Ok(Self::UpDownPos),
414            _ => Err(FoundationError::ParseError {
415                type_name: "RefContentType".to_string(),
416                value: s.to_string(),
417                valid_values: "OBJECT_TYPE_PAGE, OBJECT_TYPE_NUMBER, OBJECT_TYPE_CONTENTS, \
418                    OBJECT_TYPE_UPDOWNPOS"
419                    .to_string(),
420            }),
421        }
422    }
423}
424
425impl schemars::JsonSchema for RefContentType {
426    fn schema_name() -> std::borrow::Cow<'static, str> {
427        std::borrow::Cow::Borrowed("RefContentType")
428    }
429
430    fn json_schema(gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
431        gen.subschema_for::<String>()
432    }
433}
434
435/// Drop cap style for floating shape objects (HWPX `dropcapstyle` attribute).
436///
437/// Controls whether a shape (text box, image, table, etc.) is formatted as a
438/// drop capital that occupies multiple lines at the start of a paragraph.
439///
440/// # HWPX Values
441///
442/// | Variant      | HWPX string     |
443/// |--------------|-----------------|
444/// | `None`       | `"None"`        |
445/// | `DoubleLine` | `"DoubleLine"`  |
446/// | `TripleLine` | `"TripleLine"`  |
447/// | `Margin`     | `"Margin"`      |
448#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
449#[non_exhaustive]
450pub enum DropCapStyle {
451    /// No drop cap (default).
452    #[default]
453    None = 0,
454    /// Drop cap spanning 2 lines.
455    DoubleLine = 1,
456    /// Drop cap spanning 3 lines.
457    TripleLine = 2,
458    /// Drop cap positioned in the margin.
459    Margin = 3,
460}
461
462impl fmt::Display for DropCapStyle {
463    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
464        match self {
465            Self::None => f.write_str("None"),
466            Self::DoubleLine => f.write_str("DoubleLine"),
467            Self::TripleLine => f.write_str("TripleLine"),
468            Self::Margin => f.write_str("Margin"),
469        }
470    }
471}
472
473impl DropCapStyle {
474    /// Parses an HWPX `dropcapstyle` attribute value (PascalCase).
475    ///
476    /// Unknown values fall back to `None` (default) for forward compatibility.
477    pub fn from_hwpx_str(s: &str) -> Self {
478        match s {
479            "DoubleLine" => Self::DoubleLine,
480            "TripleLine" => Self::TripleLine,
481            "Margin" => Self::Margin,
482            _ => Self::None,
483        }
484    }
485}
486
487impl std::str::FromStr for DropCapStyle {
488    type Err = FoundationError;
489
490    fn from_str(s: &str) -> Result<Self, Self::Err> {
491        match s {
492            "None" | "NONE" | "none" => Ok(Self::None),
493            "DoubleLine" | "DOUBLE_LINE" => Ok(Self::DoubleLine),
494            "TripleLine" | "TRIPLE_LINE" => Ok(Self::TripleLine),
495            "Margin" | "MARGIN" => Ok(Self::Margin),
496            _ => Err(FoundationError::ParseError {
497                type_name: "DropCapStyle".to_string(),
498                value: s.to_string(),
499                valid_values: "None, DoubleLine, TripleLine, Margin".to_string(),
500            }),
501        }
502    }
503}
504
505impl schemars::JsonSchema for DropCapStyle {
506    fn schema_name() -> std::borrow::Cow<'static, str> {
507        std::borrow::Cow::Borrowed("DropCapStyle")
508    }
509
510    fn json_schema(gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
511        gen.subschema_for::<String>()
512    }
513}
514
515impl serde::Serialize for DropCapStyle {
516    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
517        serializer.serialize_str(&self.to_string())
518    }
519}
520
521impl<'de> serde::Deserialize<'de> for DropCapStyle {
522    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
523        let s = String::deserialize(deserializer)?;
524        s.parse().map_err(serde::de::Error::custom)
525    }
526}