Skip to main content

docvortex_core/
text_content.rs

1//! 在自有字符上完成正文片段组装;Unicode 分类和 NFC 组合由绑定层按解释器语义准备。
2use crate::{geometry::Box4, text_snapshot::TextSnapshot};
3use std::collections::{HashMap, HashSet};
4
5pub struct Content {
6    pub text: Option<String>,
7    pub private_count: usize,
8    pub text_count: usize,
9    pub private_run: usize,
10}
11
12/// 按原字符顺序收集各 span 的成员,保持 PUA 连续段判定所需的原始顺序。
13pub fn groups(assignments: &[Option<usize>], span_count: usize) -> Vec<Vec<usize>> {
14    let mut output = vec![Vec::new(); span_count];
15    for (index, owner) in assignments.iter().enumerate() {
16        if let Some(owner) = owner {
17            output[*owner].push(index);
18        }
19    }
20    output
21}
22
23/// 与 Python 一样,仅在来源编号乱序时稳定排序,等号不改变成员顺序。
24pub fn ordered(data: &TextSnapshot, group: &[usize]) -> Vec<usize> {
25    let mut values = group.to_vec();
26    if values
27        .windows(2)
28        .any(|pair| data.chars[pair[0]].index > data.chars[pair[1]].index)
29    {
30        values.sort_by_key(|&index| data.chars[index].index);
31    }
32    values
33}
34
35/// 计算相对较短边的交集比例,零宽高不组成重叠附加符。
36fn overlap(a: Box4, b: Box4, axis: usize) -> f64 {
37    let denominator = (a[axis + 2] - a[axis]).min(b[axis + 2] - b[axis]);
38    if denominator <= 0.0 {
39        return 0.0;
40    }
41    (a[axis + 2].min(b[axis + 2]) - a[axis].max(b[axis])).max(0.0) / denominator
42}
43
44/// 保留当前字符为附加符时的优先分支;不在失败后尝试参考实现未覆盖的反向组合。
45pub fn composition_pair(
46    data: &TextSnapshot,
47    first: usize,
48    second: usize,
49    modifiers: &HashMap<String, String>,
50    threshold: f64,
51) -> Option<(usize, usize)> {
52    let (base, modifier) = if modifiers.contains_key(&data.chars[first].text) {
53        (second, first)
54    } else if modifiers.contains_key(&data.chars[second].text) {
55        (first, second)
56    } else {
57        return None;
58    };
59    let a = &data.chars[base];
60    let b = &data.chars[modifier];
61    (a.text.chars().count() == 1
62        && overlap(a.bbox, b.bbox, 0) >= threshold
63        && overlap(a.bbox, b.bbox, 1) >= threshold)
64        .then_some((base, modifier))
65}
66
67/// 仅替换现有控制字符和七种拉丁连字,不把其他 Unicode 字符擅自做 NFKC 转换。
68fn clean(text: &str) -> String {
69    let controls = text.replace("\r\n", "").replace('\u{2}', "-");
70    let mut output = String::with_capacity(controls.len());
71    for ch in controls.chars() {
72        match ch {
73            '\u{fb01}' => output.push_str("fi"),
74            '\u{fb02}' => output.push_str("fl"),
75            '\u{fb00}' => output.push_str("ff"),
76            '\u{fb03}' => output.push_str("ffi"),
77            '\u{fb04}' => output.push_str("ffl"),
78            '\u{fb05}' => output.push_str("ft"),
79            '\u{fb06}' => output.push_str("st"),
80            _ => output.push(ch),
81        }
82    }
83    output
84}
85
86/// 连续执行 PUA 信号、重叠附加符合成、词间空格及正文组装;空组保留调用方原文本。
87pub struct Rules<'a> {
88    pub ordinary: &'a HashSet<char>,
89    pub decimals: &'a HashSet<char>,
90    pub tight_spacing: &'a [bool],
91    pub modifiers: &'a HashMap<String, String>,
92    pub threshold: f64,
93    pub compositions: &'a HashMap<(String, String), String>,
94    pub spaces: &'a HashSet<char>,
95    pub breaks: &'a HashSet<String>,
96    pub private_ranges: &'a [(u32, u32)],
97}
98
99/// 使用本次调用固定的字符规则构造所有片段,避免重新读取可变 Python 配置。
100pub fn build(data: &TextSnapshot, groups: &[Vec<usize>], rules: &Rules<'_>) -> Vec<Content> {
101    let Rules {
102        ordinary,
103        decimals,
104        tight_spacing,
105        modifiers,
106        threshold,
107        compositions,
108        spaces,
109        breaks,
110        private_ranges,
111    } = rules;
112    groups
113        .iter()
114        .enumerate()
115        .map(|(group_index, group)| {
116            let mut result = Content {
117                text: None,
118                private_count: 0,
119                text_count: 0,
120                private_run: 0,
121            };
122            let mut run = 0;
123            for &index in group {
124                for ch in data.chars[index].text.chars() {
125                    if spaces.contains(&ch) {
126                        run = 0;
127                        continue;
128                    }
129                    result.text_count += 1;
130                    if private_ranges
131                        .iter()
132                        .any(|&(start, end)| start <= ch as u32 && ch as u32 <= end)
133                    {
134                        result.private_count += 1;
135                        run += 1;
136                        result.private_run = result.private_run.max(run);
137                    } else {
138                        run = 0;
139                    }
140                }
141            }
142            if group.is_empty() {
143                return result;
144            }
145            let members = ordered(data, group);
146            let mut chars: Vec<(&str, Box4, usize)> = Vec::with_capacity(members.len());
147            let mut cursor = 0;
148            while cursor < members.len() {
149                if cursor + 1 < members.len() {
150                    if let Some((base, modifier)) = composition_pair(
151                        data,
152                        members[cursor],
153                        members[cursor + 1],
154                        modifiers,
155                        *threshold,
156                    ) {
157                        let a = &data.chars[base];
158                        let b = &data.chars[modifier];
159                        if let Some(composed) = compositions.get(&(a.text.clone(), b.text.clone()))
160                        {
161                            chars.push((composed, a.bbox, base));
162                            cursor += 2;
163                            continue;
164                        }
165                    }
166                }
167                let ch = &data.chars[members[cursor]];
168                chars.push((&ch.text, ch.bbox, members[cursor]));
169                cursor += 1;
170            }
171            let mut widths: Vec<_> = chars.iter().map(|(_, b, _)| b[2] - b[0]).collect();
172            widths.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
173            let median = if widths.len() % 2 == 1 {
174                widths[widths.len() / 2]
175            } else {
176                (widths[widths.len() / 2 - 1] + widths[widths.len() / 2]) / 2.0
177            };
178            let mut text = String::new();
179            let spacing_chars: Vec<_> = chars
180                .iter()
181                .map(|(_, _, source)| &data.chars[*source])
182                .collect();
183            for (index, &(value, bbox, source)) in chars.iter().enumerate() {
184                if breaks.contains(value) {
185                    continue;
186                }
187                if tight_spacing.get(group_index).copied().unwrap_or(false)
188                    && index > 0
189                    && index + 1 < chars.len()
190                    && (crate::text_spacing::generated_cjk_space(
191                        &data.chars[chars[index - 1].2],
192                        &data.chars[source],
193                        &data.chars[chars[index + 1].2],
194                    ) || crate::text_spacing::ligature_expansion_space(&spacing_chars, index))
195                {
196                    continue;
197                }
198                text.push_str(value);
199                if let Some(&(next, next_box, next_source)) = chars.get(index + 1) {
200                    if (next_box[0] - bbox[2] > median * 0.25
201                        || (tight_spacing.get(group_index).copied().unwrap_or(false)
202                            && crate::text_spacing::needs_space(
203                                data,
204                                source,
205                                next_source,
206                                ordinary,
207                                decimals,
208                            )))
209                        && value != " "
210                        && next != " "
211                    {
212                        text.push(' ');
213                    }
214                }
215            }
216            result.text = Some(clean(&text));
217            result
218        })
219        .collect()
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225    use crate::text_snapshot::{Character, Font};
226
227    /// 三个私用区参与同一连续段统计,空白中断连续段,原始文本及边界外字符保持不变。
228    #[test]
229    fn supplementary_private_ranges_preserve_text() {
230        let text = "A\u{e000}\u{f0000} \u{1001b0}B\u{f900}\u{ffffe}\u{20000}";
231        let data = TextSnapshot {
232            chars: text
233                .chars()
234                .enumerate()
235                .map(|(index, ch)| Character {
236                    text: ch.to_string(),
237                    bbox: [index as f64 * 5.0, 0.0, index as f64 * 5.0 + 5.0, 10.0],
238                    rotation: 0.0,
239                    font: 0,
240                    index,
241                    sources: vec![index],
242                    code: ch as u32,
243                    generated: Some(false),
244                    object: Some(0),
245                    mode: Some(0),
246                    writing_angle: 0.0,
247                    origin: None,
248                    loose: None,
249                    tight: None,
250                    visible: Some(true),
251                })
252                .collect(),
253            fonts: vec![Font {
254                name: "TestFont".into(),
255                name_id: 0,
256                flags: 0,
257                size: 10.0,
258                weight: 400,
259            }],
260            extended: true,
261            visible_only: false,
262        };
263        let rules = Rules {
264            ordinary: &HashSet::new(),
265            decimals: &HashSet::new(),
266            tight_spacing: &[false],
267            modifiers: &HashMap::new(),
268            threshold: 0.5,
269            compositions: &HashMap::new(),
270            spaces: &HashSet::from([' ']),
271            breaks: &HashSet::new(),
272            private_ranges: &[(0xe000, 0xf8ff), (0xf0000, 0xffffd), (0x100000, 0x10fffd)],
273        };
274        let result = build(&data, &[(0..data.chars.len()).collect()], &rules);
275        assert_eq!(result[0].text.as_deref(), Some(text));
276        assert_eq!(result[0].text_count, 8);
277        assert_eq!(result[0].private_count, 3);
278        assert_eq!(result[0].private_run, 2);
279    }
280}