Skip to main content

text_document/
convert.rs

1//! Conversion helpers between public API types and backend DTOs.
2//!
3//! The backend uses `i64` for all positions/sizes. The public API uses `usize`.
4//! All Option mapping between public format structs and backend DTOs lives here.
5
6use crate::{
7    BlockFormat, BlockInfo, DocumentStats, FindMatch, FindOptions, FrameFormat, ListFormat,
8    TextFormat,
9};
10
11// ── Position conversion ─────────────────────────────────────────
12
13pub fn to_i64(v: usize) -> i64 {
14    debug_assert!(v <= i64::MAX as usize, "position overflow: {v}");
15    v as i64
16}
17
18pub fn to_usize(v: i64) -> usize {
19    assert!(v >= 0, "negative position: {v}");
20    v as usize
21}
22
23// ── DocumentStats ───────────────────────────────────────────────
24
25impl From<&frontend::document_inspection::DocumentStatsDto> for DocumentStats {
26    fn from(dto: &frontend::document_inspection::DocumentStatsDto) -> Self {
27        Self {
28            character_count: to_usize(dto.character_count),
29            word_count: to_usize(dto.word_count),
30            block_count: to_usize(dto.block_count),
31            frame_count: to_usize(dto.frame_count),
32            image_count: to_usize(dto.image_count),
33            list_count: to_usize(dto.list_count),
34            table_count: to_usize(dto.table_count),
35        }
36    }
37}
38
39// ── BlockInfo ───────────────────────────────────────────────────
40
41impl From<&frontend::document_inspection::BlockInfoDto> for BlockInfo {
42    fn from(dto: &frontend::document_inspection::BlockInfoDto) -> Self {
43        Self {
44            block_id: to_usize(dto.block_id),
45            block_number: to_usize(dto.block_number),
46            start: to_usize(dto.block_start),
47            length: to_usize(dto.block_length),
48        }
49    }
50}
51
52// ── FindMatch / FindOptions ─────────────────────────────────────
53
54impl FindOptions {
55    pub(crate) fn to_find_text_dto(
56        &self,
57        query: &str,
58        start_position: usize,
59    ) -> frontend::document_search::FindTextDto {
60        frontend::document_search::FindTextDto {
61            query: query.into(),
62            case_sensitive: self.case_sensitive,
63            whole_word: self.whole_word,
64            diacritic_sensitive: self.diacritic_sensitive,
65            language: self.language.clone(),
66            use_regex: self.use_regex,
67            search_backward: self.search_backward,
68            start_position: to_i64(start_position),
69        }
70    }
71
72    pub(crate) fn to_find_all_dto(&self, query: &str) -> frontend::document_search::FindAllDto {
73        frontend::document_search::FindAllDto {
74            query: query.into(),
75            case_sensitive: self.case_sensitive,
76            whole_word: self.whole_word,
77            diacritic_sensitive: self.diacritic_sensitive,
78            language: self.language.clone(),
79            use_regex: self.use_regex,
80        }
81    }
82}
83
84impl crate::ReplaceOptions {
85    /// The typed ranges become the DTO's three PARALLEL lists — a list of structs is not
86    /// expressible there (`FindAllResultDto` uses the same idiom). Built in one pass so the
87    /// three can never come out of step, which would make range `i` address the wrong text.
88    pub(crate) fn to_replace_ranges_dto(
89        &self,
90        ranges: &[crate::ReplaceRange],
91    ) -> frontend::document_search::ReplaceRangesDto {
92        let mut positions = Vec::with_capacity(ranges.len());
93        let mut lengths = Vec::with_capacity(ranges.len());
94        let mut replacements = Vec::with_capacity(ranges.len());
95        for range in ranges {
96            positions.push(to_i64(range.position));
97            lengths.push(to_i64(range.length));
98            replacements.push(range.replacement.clone());
99        }
100        frontend::document_search::ReplaceRangesDto {
101            positions,
102            lengths,
103            replacements,
104            format_policy: self.format_policy,
105        }
106    }
107
108    pub(crate) fn to_replace_dto(
109        &self,
110        query: &str,
111        replacement: &str,
112        replace_all: bool,
113    ) -> frontend::document_search::ReplaceTextDto {
114        frontend::document_search::ReplaceTextDto {
115            query: query.into(),
116            replacement: replacement.into(),
117            case_sensitive: self.find.case_sensitive,
118            whole_word: self.find.whole_word,
119            diacritic_sensitive: self.find.diacritic_sensitive,
120            language: self.find.language.clone(),
121            use_regex: self.find.use_regex,
122            replace_all,
123            format_policy: self.format_policy,
124        }
125    }
126}
127
128pub fn find_result_to_match(dto: &frontend::document_search::FindResultDto) -> Option<FindMatch> {
129    if dto.found {
130        Some(FindMatch {
131            position: to_usize(dto.position),
132            length: to_usize(dto.length),
133            matched_text: dto.matched_text.clone(),
134        })
135    } else {
136        None
137    }
138}
139
140/// The three lists are parallel by construction (one `FindAllResultDto` is built in one
141/// pass), so `zip` is safe — but zip *truncates*, and a truncated match list is a search that
142/// silently loses its last hits. Assert instead.
143pub fn find_all_to_matches(dto: &frontend::document_search::FindAllResultDto) -> Vec<FindMatch> {
144    debug_assert_eq!(dto.positions.len(), dto.lengths.len());
145    debug_assert_eq!(dto.positions.len(), dto.matched_texts.len());
146    dto.positions
147        .iter()
148        .zip(dto.lengths.iter())
149        .zip(dto.matched_texts.iter())
150        .map(|((&pos, &len), text)| FindMatch {
151            position: to_usize(pos),
152            length: to_usize(len),
153            matched_text: text.clone(),
154        })
155        .collect()
156}
157
158// ── Domain ↔ DTO enum conversions ───────────────────────────────
159//
160// The DTO layer has its own enum types, separate from domain enums
161// in `common::entities`. This keeps the API boundary stable even
162// when domain internals change.
163
164// Formatting DTOs have their own enum types (separate from entity DTO enums).
165// These conversion functions bridge the two at the public API boundary.
166use frontend::document_formatting::dtos as fmt_dto;
167
168fn underline_style_to_dto(v: &crate::UnderlineStyle) -> fmt_dto::UnderlineStyle {
169    match v {
170        crate::UnderlineStyle::NoUnderline => fmt_dto::UnderlineStyle::NoUnderline,
171        crate::UnderlineStyle::SingleUnderline => fmt_dto::UnderlineStyle::SingleUnderline,
172        crate::UnderlineStyle::DashUnderline => fmt_dto::UnderlineStyle::DashUnderline,
173        crate::UnderlineStyle::DotLine => fmt_dto::UnderlineStyle::DotLine,
174        crate::UnderlineStyle::DashDotLine => fmt_dto::UnderlineStyle::DashDotLine,
175        crate::UnderlineStyle::DashDotDotLine => fmt_dto::UnderlineStyle::DashDotDotLine,
176        crate::UnderlineStyle::WaveUnderline => fmt_dto::UnderlineStyle::WaveUnderline,
177        crate::UnderlineStyle::SpellCheckUnderline => fmt_dto::UnderlineStyle::SpellCheckUnderline,
178    }
179}
180
181fn vertical_alignment_to_dto(v: &crate::CharVerticalAlignment) -> fmt_dto::CharVerticalAlignment {
182    match v {
183        crate::CharVerticalAlignment::Normal => fmt_dto::CharVerticalAlignment::Normal,
184        crate::CharVerticalAlignment::SuperScript => fmt_dto::CharVerticalAlignment::SuperScript,
185        crate::CharVerticalAlignment::SubScript => fmt_dto::CharVerticalAlignment::SubScript,
186        crate::CharVerticalAlignment::Middle => fmt_dto::CharVerticalAlignment::Middle,
187        crate::CharVerticalAlignment::Bottom => fmt_dto::CharVerticalAlignment::Bottom,
188        crate::CharVerticalAlignment::Top => fmt_dto::CharVerticalAlignment::Top,
189        crate::CharVerticalAlignment::Baseline => fmt_dto::CharVerticalAlignment::Baseline,
190    }
191}
192
193fn alignment_to_dto(v: &crate::Alignment) -> fmt_dto::Alignment {
194    match v {
195        crate::Alignment::Left => fmt_dto::Alignment::Left,
196        crate::Alignment::Right => fmt_dto::Alignment::Right,
197        crate::Alignment::Center => fmt_dto::Alignment::Center,
198        crate::Alignment::Justify => fmt_dto::Alignment::Justify,
199    }
200}
201
202fn direction_to_dto(v: &crate::TextDirection) -> fmt_dto::TextDirection {
203    match v {
204        crate::TextDirection::LeftToRight => fmt_dto::TextDirection::LeftToRight,
205        crate::TextDirection::RightToLeft => fmt_dto::TextDirection::RightToLeft,
206    }
207}
208
209fn list_style_to_dto(v: &crate::ListStyle) -> fmt_dto::ListStyle {
210    match v {
211        crate::ListStyle::Disc => fmt_dto::ListStyle::Disc,
212        crate::ListStyle::Circle => fmt_dto::ListStyle::Circle,
213        crate::ListStyle::Square => fmt_dto::ListStyle::Square,
214        crate::ListStyle::Decimal => fmt_dto::ListStyle::Decimal,
215        crate::ListStyle::LowerAlpha => fmt_dto::ListStyle::LowerAlpha,
216        crate::ListStyle::UpperAlpha => fmt_dto::ListStyle::UpperAlpha,
217        crate::ListStyle::LowerRoman => fmt_dto::ListStyle::LowerRoman,
218        crate::ListStyle::UpperRoman => fmt_dto::ListStyle::UpperRoman,
219    }
220}
221
222fn marker_to_dto(v: &crate::MarkerType) -> fmt_dto::MarkerType {
223    match v {
224        crate::MarkerType::NoMarker => fmt_dto::MarkerType::NoMarker,
225        crate::MarkerType::Unchecked => fmt_dto::MarkerType::Unchecked,
226        crate::MarkerType::Checked => fmt_dto::MarkerType::Checked,
227    }
228}
229
230// ── TextFormat → SetTextFormatDto ───────────────────────────────
231//
232// Backend DTOs now use `Option` fields: `None` means "don't change
233// this property" and `Some(value)` means "set to value".
234
235impl TextFormat {
236    pub(crate) fn to_set_dto(
237        &self,
238        position: usize,
239        anchor: usize,
240    ) -> frontend::document_formatting::SetTextFormatDto {
241        frontend::document_formatting::SetTextFormatDto {
242            position: to_i64(position),
243            anchor: to_i64(anchor),
244            font_family: self.font_family.clone(),
245            font_point_size: self.font_point_size.map(|v| v as i64),
246            font_weight: self.font_weight.map(|v| v as i64),
247            font_bold: self.font_bold,
248            font_italic: self.font_italic,
249            font_underline: self.font_underline,
250            font_overline: self.font_overline,
251            font_strikeout: self.font_strikeout,
252            letter_spacing: self.letter_spacing.map(|v| v as i64),
253            word_spacing: self.word_spacing.map(|v| v as i64),
254            underline_style: self.underline_style.as_ref().map(underline_style_to_dto),
255            vertical_alignment: self
256                .vertical_alignment
257                .as_ref()
258                .map(vertical_alignment_to_dto),
259            anchor_href: self.anchor_href.clone(),
260            clear_link: self.clear_link,
261        }
262    }
263
264    pub(crate) fn to_merge_dto(
265        &self,
266        position: usize,
267        anchor: usize,
268    ) -> frontend::document_formatting::MergeTextFormatDto {
269        frontend::document_formatting::MergeTextFormatDto {
270            position: to_i64(position),
271            anchor: to_i64(anchor),
272            font_family: self.font_family.clone(),
273            font_bold: self.font_bold,
274            font_italic: self.font_italic,
275            font_underline: self.font_underline,
276            font_strikeout: self.font_strikeout,
277            vertical_alignment: self
278                .vertical_alignment
279                .as_ref()
280                .map(vertical_alignment_to_dto),
281            anchor_href: self.anchor_href.clone(),
282            clear_link: self.clear_link,
283        }
284    }
285}
286
287// ── CharacterFormat (Phase 1 format_runs) → TextFormat ─────────
288
289impl From<&frontend::common::format_runs::CharacterFormat> for TextFormat {
290    fn from(fmt: &frontend::common::format_runs::CharacterFormat) -> Self {
291        Self {
292            font_family: fmt.font_family.clone(),
293            font_point_size: fmt.font_point_size.map(|v| v as u32),
294            font_weight: fmt.font_weight.map(|v| v as u32),
295            font_bold: fmt.font_bold,
296            font_italic: fmt.font_italic,
297            font_underline: fmt.font_underline,
298            font_overline: fmt.font_overline,
299            font_strikeout: fmt.font_strikeout,
300            letter_spacing: fmt.letter_spacing.map(|v| v as i32),
301            word_spacing: fmt.word_spacing.map(|v| v as i32),
302            underline_style: fmt.underline_style.clone(),
303            vertical_alignment: fmt.vertical_alignment.clone(),
304            anchor_href: fmt.anchor_href.clone(),
305            anchor_names: fmt.anchor_names.clone(),
306            is_anchor: fmt.is_anchor,
307            tooltip: fmt.tooltip.clone(),
308            // Describes an edit, never a state, so reading a format back never
309            // sets it.
310            clear_link: false,
311            foreground_color: None,
312            background_color: None,
313            underline_color: None,
314        }
315    }
316}
317
318// ── BlockFormat ─────────────────────────────────────────────────
319
320impl BlockFormat {
321    pub(crate) fn to_set_dto(
322        &self,
323        position: usize,
324        anchor: usize,
325    ) -> frontend::document_formatting::SetBlockFormatDto {
326        frontend::document_formatting::SetBlockFormatDto {
327            position: to_i64(position),
328            anchor: to_i64(anchor),
329            alignment: self.alignment.as_ref().map(alignment_to_dto),
330            heading_level: self.heading_level.map(|v| v as i64),
331            indent: self.indent.map(|v| v as i64),
332            marker: self.marker.as_ref().map(marker_to_dto),
333            line_height: self.line_height.map(|v| (v * 1000.0) as i64),
334            non_breakable_lines: self.non_breakable_lines,
335            page_break_before: self.page_break_before,
336            direction: self.direction.as_ref().map(direction_to_dto),
337            clear_direction: self.clear_direction,
338            background_color: self.background_color.clone(),
339            is_code_block: self.is_code_block,
340            code_language: self.code_language.clone(),
341            hyphenate: self.hyphenate,
342            language: self.language.clone(),
343            top_margin: self.top_margin.map(|v| v as i64),
344            bottom_margin: self.bottom_margin.map(|v| v as i64),
345            left_margin: self.left_margin.map(|v| v as i64),
346            right_margin: self.right_margin.map(|v| v as i64),
347            text_indent: self.text_indent.map(|v| v as i64),
348        }
349    }
350}
351
352impl From<&frontend::block::dtos::BlockDto> for BlockFormat {
353    fn from(b: &frontend::block::dtos::BlockDto) -> Self {
354        Self {
355            alignment: b.fmt_alignment.clone(),
356            top_margin: b.fmt_top_margin.map(|v| v as i32),
357            bottom_margin: b.fmt_bottom_margin.map(|v| v as i32),
358            left_margin: b.fmt_left_margin.map(|v| v as i32),
359            right_margin: b.fmt_right_margin.map(|v| v as i32),
360            heading_level: b.fmt_heading_level.map(|v| v as u8),
361            indent: b.fmt_indent.map(|v| v as u8),
362            text_indent: b.fmt_text_indent.map(|v| v as i32),
363            marker: b.fmt_marker.clone(),
364            tab_positions: b.fmt_tab_positions.iter().map(|&v| v as i32).collect(),
365            line_height: b.fmt_line_height.map(|v| v as f32 / 1000.0),
366            non_breakable_lines: b.fmt_non_breakable_lines,
367            page_break_before: b.fmt_page_break_before,
368            direction: b.fmt_direction.clone(),
369            // Reading a block never asks to clear anything.
370            clear_direction: false,
371            background_color: b.fmt_background_color.clone(),
372            is_code_block: b.fmt_is_code_block,
373            code_language: b.fmt_code_language.clone(),
374            hyphenate: b.fmt_hyphenate,
375            language: b.fmt_language.clone(),
376        }
377    }
378}
379
380// ── FrameFormat ─────────────────────────────────────────────────
381
382impl FrameFormat {
383    pub(crate) fn to_set_dto(
384        &self,
385        position: usize,
386        anchor: usize,
387        frame_id: usize,
388    ) -> frontend::document_formatting::SetFrameFormatDto {
389        frontend::document_formatting::SetFrameFormatDto {
390            position: to_i64(position),
391            anchor: to_i64(anchor),
392            frame_id: to_i64(frame_id),
393            height: self.height.map(|v| v as i64),
394            width: self.width.map(|v| v as i64),
395            top_margin: self.top_margin.map(|v| v as i64),
396            bottom_margin: self.bottom_margin.map(|v| v as i64),
397            left_margin: self.left_margin.map(|v| v as i64),
398            right_margin: self.right_margin.map(|v| v as i64),
399            padding: self.padding.map(|v| v as i64),
400            border: self.border.map(|v| v as i64),
401            is_blockquote: self.is_blockquote,
402        }
403    }
404}
405
406// ── ListFormat ─────────────────────────────────────────────────
407
408impl ListFormat {
409    pub(crate) fn to_set_dto(
410        &self,
411        list_id: usize,
412    ) -> frontend::document_formatting::SetListFormatDto {
413        frontend::document_formatting::SetListFormatDto {
414            list_id: to_i64(list_id),
415            style: self.style.as_ref().map(list_style_to_dto),
416            indent: self.indent.map(|v| v as i64),
417            prefix: self.prefix.clone(),
418            suffix: self.suffix.clone(),
419        }
420    }
421}
422
423// ── TableFormat ────────────────────────────────────────────────
424
425impl crate::flow::TableFormat {
426    pub(crate) fn to_set_dto(
427        &self,
428        table_id: usize,
429    ) -> frontend::document_formatting::SetTableFormatDto {
430        frontend::document_formatting::SetTableFormatDto {
431            table_id: to_i64(table_id),
432            border: self.border.map(|v| v as i64),
433            cell_spacing: self.cell_spacing.map(|v| v as i64),
434            cell_padding: self.cell_padding.map(|v| v as i64),
435            width: self.width.map(|v| v as i64),
436            alignment: self.alignment.as_ref().map(alignment_to_dto),
437        }
438    }
439}
440
441// ── CellFormat ─────────────────────────────────────────────────
442
443fn cell_vertical_alignment_to_dto(
444    v: &crate::flow::CellVerticalAlignment,
445) -> fmt_dto::CellVerticalAlignment {
446    match v {
447        crate::flow::CellVerticalAlignment::Top => fmt_dto::CellVerticalAlignment::Top,
448        crate::flow::CellVerticalAlignment::Middle => fmt_dto::CellVerticalAlignment::Middle,
449        crate::flow::CellVerticalAlignment::Bottom => fmt_dto::CellVerticalAlignment::Bottom,
450    }
451}
452
453impl crate::flow::CellFormat {
454    pub(crate) fn to_set_dto(
455        &self,
456        cell_id: usize,
457    ) -> frontend::document_formatting::SetTableCellFormatDto {
458        frontend::document_formatting::SetTableCellFormatDto {
459            cell_id: to_i64(cell_id),
460            padding: self.padding.map(|v| v as i64),
461            border: self.border.map(|v| v as i64),
462            vertical_alignment: self
463                .vertical_alignment
464                .as_ref()
465                .map(cell_vertical_alignment_to_dto),
466            background_color: self.background_color.clone(),
467        }
468    }
469}