Skip to main content

hwpforge_core/
style_lookup.rs

1//! Format-agnostic style querying trait.
2//!
3//! [`StyleLookup`] provides a uniform interface for retrieving character,
4//! paragraph, and style properties by index. Each format-specific style
5//! store (e.g. `HwpxStyleStore`) implements this trait so that downstream
6//! consumers (e.g. the Markdown encoder) can query styles without knowing
7//! the underlying format.
8//!
9//! All methods have default implementations returning `None`, so
10//! implementors only need to override the methods they can support.
11
12use hwpforge_foundation::{
13    Alignment, CharShapeIndex, Color, HwpUnit, ParaShapeIndex, StyleIndex, UnderlineType,
14};
15
16/// Trait for querying resolved style properties by index.
17///
18/// This is the bridge between format-specific style stores and
19/// format-independent consumers (like the Markdown encoder). Each method
20/// takes a branded index and returns `Option<T>`, where `None` means the
21/// property is unavailable or unsupported.
22///
23/// # Default Implementations
24///
25/// Every method defaults to `None`, so an empty implementation is valid:
26///
27/// ```
28/// use hwpforge_core::StyleLookup;
29/// use hwpforge_foundation::CharShapeIndex;
30///
31/// struct NoopStore;
32/// impl StyleLookup for NoopStore {}
33///
34/// let store = NoopStore;
35/// assert!(store.char_bold(CharShapeIndex::new(0)).is_none());
36/// ```
37pub trait StyleLookup {
38    /// Returns whether the character shape at `id` is bold.
39    fn char_bold(&self, _id: CharShapeIndex) -> Option<bool> {
40        None
41    }
42
43    /// Returns whether the character shape at `id` is italic.
44    fn char_italic(&self, _id: CharShapeIndex) -> Option<bool> {
45        None
46    }
47
48    /// Returns the underline type of the character shape at `id`.
49    fn char_underline(&self, _id: CharShapeIndex) -> Option<UnderlineType> {
50        None
51    }
52
53    /// Returns whether the character shape at `id` has strikeout.
54    fn char_strikeout(&self, _id: CharShapeIndex) -> Option<bool> {
55        None
56    }
57
58    /// Returns whether the character shape at `id` is superscript.
59    fn char_superscript(&self, _id: CharShapeIndex) -> Option<bool> {
60        None
61    }
62
63    /// Returns whether the character shape at `id` is subscript.
64    fn char_subscript(&self, _id: CharShapeIndex) -> Option<bool> {
65        None
66    }
67
68    /// Returns the font name of the character shape at `id`.
69    fn char_font_name(&self, _id: CharShapeIndex) -> Option<&str> {
70        None
71    }
72
73    /// Returns the **distinct** font face names referenced across the
74    /// per-language axes (hangul/latin/hanja/…) of the character shape.
75    ///
76    /// Formats with per-language font references (HWPX `fontRef`) override
77    /// this to surface axis mismatches — a result longer than 1 means the
78    /// character shape renders with different fonts per script, which a
79    /// single-font consumer cannot reproduce faithfully. The first element
80    /// matches [`char_font_name`](Self::char_font_name) when both resolve.
81    ///
82    /// The default implementation returns the single
83    /// [`char_font_name`](Self::char_font_name) (no axis information).
84    fn char_font_axis_names(&self, id: CharShapeIndex) -> Vec<&str> {
85        self.char_font_name(id).into_iter().collect()
86    }
87
88    /// Returns the font size (in [`HwpUnit`]) of the character shape at `id`.
89    fn char_font_size(&self, _id: CharShapeIndex) -> Option<HwpUnit> {
90        None
91    }
92
93    /// Returns the char shape referenced by the named **character** style
94    /// (style table `type="CHAR"`), matching either the localized or the
95    /// English style name.
96    ///
97    /// Hancom renders page numbers (`hp:pageNum`) with the dedicated
98    /// "쪽 번호"/"Page Number" CHAR style rather than the document default —
99    /// fixture-verified (rules-pagenum, 2026-08-10). Consumers that
100    /// synthesize such text need the style's char shape to match Hancom
101    /// output. The default implementation reports no style table.
102    fn char_style_shape(&self, _name: &str) -> Option<CharShapeIndex> {
103        None
104    }
105
106    /// Returns the text color of the character shape at `id`.
107    fn char_text_color(&self, _id: CharShapeIndex) -> Option<Color> {
108        None
109    }
110
111    /// Returns the horizontal alignment of the paragraph shape at `id`.
112    fn para_alignment(&self, _id: ParaShapeIndex) -> Option<Alignment> {
113        None
114    }
115
116    /// Returns the left indent of the paragraph shape at `id`.
117    fn para_indent_left(&self, _id: ParaShapeIndex) -> Option<HwpUnit> {
118        None
119    }
120
121    /// Returns the first-line indent of the paragraph shape at `id`.
122    fn para_indent_first_line(&self, _id: ParaShapeIndex) -> Option<HwpUnit> {
123        None
124    }
125
126    /// Returns the list type for a paragraph shape: `"BULLET"`, `"NUMBER"`, or `None`.
127    ///
128    /// Returns `None` if the paragraph has no list heading or if the heading
129    /// type is `NONE` / `OUTLINE`.
130    fn para_list_type(&self, _id: ParaShapeIndex) -> Option<&str> {
131        None
132    }
133
134    /// Returns the zero-based list nesting level for a paragraph shape.
135    ///
136    /// This is only meaningful for numbered/bulleted list semantics. Outline
137    /// headings should use [`para_heading_level`](Self::para_heading_level)
138    /// instead.
139    fn para_list_level(&self, _id: ParaShapeIndex) -> Option<u8> {
140        None
141    }
142
143    /// Returns the checkbox state for a paragraph shape when it is a checkable bullet.
144    ///
145    /// `Some(true)` means a checked checkbox item, `Some(false)` means an
146    /// unchecked checkbox item, and `None` means the paragraph is not a
147    /// checkable bullet.
148    fn para_checked_state(&self, _id: ParaShapeIndex) -> Option<bool> {
149        None
150    }
151
152    /// Returns the preferred style name associated with the paragraph shape.
153    ///
154    /// This is useful for encoders that need to recover semantics carried by a
155    /// dedicated paragraph shape even when the paragraph itself has no explicit
156    /// `style_id`.
157    fn para_style_name(&self, _id: ParaShapeIndex) -> Option<&str> {
158        None
159    }
160
161    /// Returns the heading level (1–6) implied by the paragraph shape at `id`.
162    ///
163    /// This is the format-agnostic truth source for paragraph-level outline
164    /// semantics. Implementors that can inspect real paragraph-shape outline
165    /// metadata should override this method; downstream styled export paths use
166    /// it before style-name heuristics whenever both are available.
167    fn para_heading_level(&self, _id: ParaShapeIndex) -> Option<u8> {
168        None
169    }
170
171    /// Returns the Korean name of the style at `id`.
172    fn style_name(&self, _id: StyleIndex) -> Option<&str> {
173        None
174    }
175
176    /// Returns the heading level (1–6) of the style at `id`, if it is
177    /// a heading style. Returns `None` for non-heading styles.
178    fn style_heading_level(&self, _id: StyleIndex) -> Option<u8> {
179        None
180    }
181
182    /// Resolves a `binaryItemIDRef` (e.g. `"BinData/image1"`) to the actual
183    /// filename with extension (e.g. `"image1.png"`).
184    ///
185    /// Returns `None` if no matching image is found.
186    fn image_resolve_filename(&self, _key: &str) -> Option<&str> {
187        None
188    }
189
190    /// Returns the raw binary data for the image identified by `key`.
191    ///
192    /// `key` is typically a path like `"image1.jpg"`. Returns `None` if
193    /// the image is not available or if the implementor does not store
194    /// image data.
195    fn image_data(&self, _key: &str) -> Option<&[u8]> {
196        None
197    }
198
199    /// Returns the four rendered border edges of the `borderFill` at `id`
200    /// (1-based wire reference, e.g. [`crate::table::TableCell::border_fill_id`]).
201    ///
202    /// `None` means the id is not registered in this store.
203    fn border_fill_lines(&self, _id: u32) -> Option<BorderFillLines> {
204        None
205    }
206
207    /// Returns the face-fill verdict of the `borderFill` at `id` (1-based
208    /// wire reference).
209    ///
210    /// `None` means the id is not registered — distinguish this from
211    /// [`FillKind::None`] (registered, but transparent) and
212    /// [`FillKind::Unsupported`] (registered, but not renderable — warn).
213    fn border_fill_face(&self, _id: u32) -> Option<FillKind> {
214        None
215    }
216}
217
218/// Render kind of one border edge.
219#[derive(Debug, Clone, Copy, PartialEq, Eq)]
220#[non_exhaustive]
221pub enum BorderLineKind {
222    /// No line on this edge.
223    None,
224    /// Solid stroke.
225    Solid,
226    /// Any other style (dashed, double, …) or an unparsable width/color —
227    /// consumers must warn and skip instead of guessing (no fake support).
228    Other,
229}
230
231/// One rendered border edge of a `borderFill`.
232#[derive(Debug, Clone, Copy, PartialEq)]
233#[non_exhaustive]
234pub struct BorderLine {
235    /// Render kind of this edge.
236    pub kind: BorderLineKind,
237    /// Stroke width ([`HwpUnit::ZERO`] when [`BorderLineKind::None`]/`Other`).
238    pub width: HwpUnit,
239    /// Stroke color.
240    pub color: Color,
241}
242
243impl BorderLine {
244    /// Creates a border line.
245    #[must_use]
246    pub fn new(kind: BorderLineKind, width: HwpUnit, color: Color) -> Self {
247        Self { kind, width, color }
248    }
249}
250
251/// The four rendered edges of a `borderFill`.
252#[derive(Debug, Clone, Copy, PartialEq)]
253#[non_exhaustive]
254pub struct BorderFillLines {
255    /// Left edge.
256    pub left: BorderLine,
257    /// Right edge.
258    pub right: BorderLine,
259    /// Top edge.
260    pub top: BorderLine,
261    /// Bottom edge.
262    pub bottom: BorderLine,
263}
264
265impl BorderFillLines {
266    /// Creates the four edges.
267    #[must_use]
268    pub fn new(left: BorderLine, right: BorderLine, top: BorderLine, bottom: BorderLine) -> Self {
269        Self { left, right, top, bottom }
270    }
271}
272
273/// Face-fill verdict of a `borderFill` — distinguishes "no fill" from
274/// "unsupported fill": only the latter warrants a consumer warning.
275#[derive(Debug, Clone, Copy, PartialEq)]
276#[non_exhaustive]
277pub enum FillKind {
278    /// No fill (transparent) — normal, no warning.
279    None,
280    /// Solid color fill.
281    Solid(Color),
282    /// Gradient/image/hatch fill or an unparsable color — consumers must
283    /// warn and skip instead of guessing (no fake support).
284    Unsupported,
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290    use hwpforge_foundation::{ParaShapeIndex, StyleIndex};
291
292    struct NoopStore;
293    impl StyleLookup for NoopStore {}
294
295    #[test]
296    fn noop_store_returns_none_for_all_methods() {
297        let store = NoopStore;
298        let cs = CharShapeIndex::new(0);
299        let ps = ParaShapeIndex::new(0);
300        let si = StyleIndex::new(0);
301
302        assert!(store.char_bold(cs).is_none());
303        assert!(store.char_italic(cs).is_none());
304        assert!(store.char_underline(cs).is_none());
305        assert!(store.char_strikeout(cs).is_none());
306        assert!(store.char_superscript(cs).is_none());
307        assert!(store.char_subscript(cs).is_none());
308        assert!(store.char_font_name(cs).is_none());
309        assert!(store.char_font_size(cs).is_none());
310        assert!(store.char_text_color(cs).is_none());
311        assert!(store.para_alignment(ps).is_none());
312        assert!(store.para_indent_left(ps).is_none());
313        assert!(store.para_indent_first_line(ps).is_none());
314        assert!(store.para_list_type(ps).is_none());
315        assert!(store.para_list_level(ps).is_none());
316        assert!(store.para_checked_state(ps).is_none());
317        assert!(store.para_style_name(ps).is_none());
318        assert!(store.para_heading_level(ps).is_none());
319        assert!(store.style_name(si).is_none());
320        assert!(store.style_heading_level(si).is_none());
321        assert!(store.image_data("image1.jpg").is_none());
322        assert!(store.border_fill_lines(1).is_none());
323        assert!(store.border_fill_face(1).is_none());
324    }
325
326    #[test]
327    fn partial_impl_returns_some_for_overridden_methods() {
328        struct BoldOnly;
329        impl StyleLookup for BoldOnly {
330            fn char_bold(&self, _id: CharShapeIndex) -> Option<bool> {
331                Some(true)
332            }
333        }
334
335        let store = BoldOnly;
336        assert_eq!(store.char_bold(CharShapeIndex::new(0)), Some(true));
337        // Non-overridden methods still return None
338        assert!(store.char_italic(CharShapeIndex::new(0)).is_none());
339    }
340
341    #[test]
342    fn trait_object_works() {
343        let store: &dyn StyleLookup = &NoopStore;
344        assert!(store.char_bold(CharShapeIndex::new(0)).is_none());
345    }
346
347    #[test]
348    fn default_axis_names_mirror_single_font_name() {
349        // 기본 구현 = char_font_name 단일 원소 (축 정보 없는 포맷).
350        let cs = CharShapeIndex::new(0);
351        assert!(NoopStore.char_font_axis_names(cs).is_empty());
352
353        struct OneFont;
354        impl StyleLookup for OneFont {
355            fn char_font_name(&self, _id: CharShapeIndex) -> Option<&str> {
356                Some("함초롬바탕")
357            }
358        }
359        assert_eq!(OneFont.char_font_axis_names(cs), vec!["함초롬바탕"]);
360    }
361}