Skip to main content

docvortex_core/
text_spacing.rs

1//! 与 Python 参考实现相同的相邻非 CJK 字形词界判定。
2use crate::{
3    geometry::Box4,
4    text_snapshot::{Character, TextSnapshot},
5};
6use std::collections::HashSet;
7
8/// 排除汉字扩展、假名、韩文及兼容字形,不以所在整行的语言决定词界。
9pub fn is_cjk(ch: char) -> bool {
10    matches!(ch as u32,
11        0x1100..=0x11ff | 0x2e80..=0xa4cf | 0xa960..=0xa97f | 0xac00..=0xd7ff |
12        0xf900..=0xfaff | 0xff66..=0xff9f | 0x1aff0..=0x1b16f | 0x20000..=0x323af)
13}
14
15/// 墨迹框只转换坐标轴;相邻间隙与页面原点平移无关。
16fn local(b: Box4, angle: i32) -> Box4 {
17    match angle {
18        90 => [b[1], -b[2], b[3], -b[0]],
19        180 => [-b[2], -b[3], -b[0], -b[1]],
20        270 => [-b[3], b[0], -b[1], b[2]],
21        _ => b,
22    }
23}
24
25/// 判定单个汉字;与 Python 的中文空格物化范围保持一致。
26fn han(text: &str) -> bool {
27    let mut chars = text.chars();
28    let Some(ch) = chars.next() else { return false };
29    chars.next().is_none()
30        && matches!(ch as u32, 0x3400..=0x4dbf | 0x4e00..=0x9fff | 0xf900..=0xfaff | 0x20000..=0x323af)
31}
32
33/// 只省略有生成证据且位于同向同基线中文边界的空格,不删除快照成员。
34pub fn generated_cjk_space(a: &Character, space: &Character, b: &Character) -> bool {
35    const PUNCTUATION: &str = "、。,;:?!“”‘’()【】《》〈〉「」『』〔〕[]{}—…·";
36    let punctuation = |text: &str| text.chars().count() == 1 && PUNCTUATION.contains(text);
37    if space.text != " "
38        || space.generated != Some(true)
39        || a.index.checked_add(1) != Some(space.index)
40        || space.index.checked_add(1) != Some(b.index)
41        || !((han(&a.text) && (han(&b.text) || punctuation(&b.text)))
42            || (punctuation(&a.text) && han(&b.text)))
43        || !a.writing_angle.is_finite()
44        || !b.writing_angle.is_finite()
45    {
46        return false;
47    }
48    let degrees = a.writing_angle.to_degrees().rem_euclid(360.0);
49    let angle = ((degrees / 90.0).round_ties_even() as i32 * 90) % 360;
50    if ((degrees - angle as f64 + 180.0).rem_euclid(360.0) - 180.0).abs() > 0.1
51        || ((b.writing_angle.to_degrees() - angle as f64 + 180.0).rem_euclid(360.0) - 180.0).abs()
52            > 0.1
53    {
54        return false;
55    }
56    let ab = a.tight.unwrap_or(a.bbox);
57    let bb = b.tight.unwrap_or(b.bbox);
58    if ab.iter().chain(bb.iter()).any(|v| !v.is_finite()) {
59        return false;
60    }
61    let (ab, bb) = (local(ab, angle), local(bb, angle));
62    let (ah, bh) = (ab[3] - ab[1], bb[3] - bb[1]);
63    if ah.min(bh).min(ab[2] - ab[0]).min(bb[2] - bb[0]) <= 0.0
64        || ab[3].min(bb[3]) - ab[1].max(bb[1]) < 0.5 * ah.min(bh)
65        || bb[0] - ab[2] > 0.5 * ah.max(bh)
66    {
67        return false;
68    }
69    let tolerance = 0.15 * ah.max(bh);
70    if let (Some(ao), Some(bo)) = (a.origin, b.origin) {
71        let axis = usize::from(angle != 90 && angle != 270);
72        ao.iter().chain(bo.iter()).all(|v| v.is_finite())
73            && (ao[axis] - bo[axis]).abs() <= tolerance
74    } else {
75        (ab[3] - bb[3]).abs() <= tolerance
76    }
77}
78
79/// 仅忽略同一拉丁连字字形内部的空格,源编号、字体、对象与几何证据均需一致。
80pub fn ligature_expansion_space(chars: &[&Character], index: usize) -> bool {
81    if index == 0 || index + 1 >= chars.len() || chars[index].text != " " {
82        return false;
83    }
84    let right = chars[index + 1];
85    if right.text.len() != 1 || !right.text.as_bytes()[0].is_ascii_alphabetic() {
86        return false;
87    }
88    for count in 1..=index.min(3) {
89        let glyph = &chars[index - count..index];
90        let text: String = glyph.iter().map(|ch| ch.text.as_str()).collect();
91        if !matches!(
92            text.as_str(),
93            "fi" | "fl"
94                | "ff"
95                | "ffi"
96                | "ffl"
97                | "ft"
98                | "st"
99                | "fi"
100                | "fl"
101                | "ff"
102                | "ffi"
103                | "ffl"
104                | "ſt"
105                | "st"
106        ) {
107            continue;
108        }
109        let first = glyph[0];
110        let records = &chars[index - count..=index + 1];
111        if first.object.is_none()
112            || records
113                .windows(2)
114                .any(|pair| pair[0].index.checked_add(1) != Some(pair[1].index))
115            || records
116                .iter()
117                .any(|ch| ch.object != first.object || ch.font != first.font)
118        {
119            continue;
120        }
121        let degrees = first.writing_angle.to_degrees().rem_euclid(360.0);
122        let angle = ((degrees / 90.0).round_ties_even() as i32 * 90) % 360;
123        if records.iter().any(|ch| {
124            !ch.writing_angle.is_finite()
125                || ((ch.writing_angle.to_degrees() - angle as f64 + 180.0).rem_euclid(360.0)
126                    - 180.0)
127                    .abs()
128                    > 0.1
129                || ch
130                    .tight
131                    .is_none_or(|bbox| bbox.iter().any(|v| !v.is_finite()))
132                || ch
133                    .origin
134                    .is_none_or(|point| point.iter().any(|v| !v.is_finite()))
135        }) {
136            continue;
137        }
138        let boxes: Vec<_> = records
139            .iter()
140            .map(|ch| local(ch.tight.unwrap(), angle))
141            .collect();
142        let points: Vec<_> = records
143            .iter()
144            .map(|ch| {
145                let p = ch.origin.unwrap();
146                let p = local([p[0], p[1], p[0], p[1]], angle);
147                [p[0], p[1]]
148            })
149            .collect();
150        let bbox = boxes[0];
151        let height = bbox[3] - bbox[1];
152        if height.min(bbox[2] - bbox[0]) <= 0.0
153            || (1..count).any(|i| {
154                (0..4).any(|j| (bbox[j] - boxes[i][j]).abs() > 0.01 * height)
155                    || (0..2).any(|j| (points[0][j] - points[i][j]).abs() > 0.01 * height)
156            })
157        {
158            continue;
159        }
160        let next = boxes[count + 1];
161        let gap = next[0] - bbox[2];
162        if bbox[0] <= points[count][0]
163            && points[count][0] < bbox[2]
164            && points[count..]
165                .iter()
166                .all(|p| (p[1] - points[0][1]).abs() <= 0.15 * height)
167            && gap >= -0.15 * height
168            && gap <= 0.15 * height
169            && (points[count + 1][0] - bbox[2]).abs() <= 0.15 * height
170            && next[3] > next[1]
171            && bbox[3].min(next[3]) - bbox[1].max(next[1]) >= 0.5 * height.min(next[3] - next[1])
172        {
173            return true;
174        }
175    }
176    false
177}
178
179/// 只接受解释器确认的普通单字形,避免两端标点或连字展开改变语义。
180fn ordinary(text: &str, letters: &HashSet<char>) -> bool {
181    let mut chars = text.chars();
182    let Some(ch) = chars.next() else { return false };
183    chars.next().is_none() && !is_cjk(ch) && (ch.is_ascii_alphanumeric() || letters.contains(&ch))
184}
185
186/// 使用真实源编号、方向和基线保护,只为可靠的大墨迹间隙返回一个词界。
187pub fn needs_space(
188    data: &TextSnapshot,
189    first: usize,
190    second: usize,
191    letters: &HashSet<char>,
192    decimals: &HashSet<char>,
193) -> bool {
194    let a = &data.chars[first];
195    let b = &data.chars[second];
196    if !ordinary(&a.text, letters)
197        || !ordinary(&b.text, letters)
198        || a.index.checked_add(1) != Some(b.index)
199    {
200        return false;
201    }
202    if a.text
203        .chars()
204        .all(|c| c.is_ascii_digit() || decimals.contains(&c))
205        && b.text
206            .chars()
207            .all(|c| c.is_ascii_digit() || decimals.contains(&c))
208    {
209        return false;
210    }
211    for font in [&data.fonts[a.font], &data.fonts[b.font]] {
212        let name = font.name.to_lowercase();
213        if font.flags & 1 != 0
214            || [
215                "mono",
216                "courier",
217                "consolas",
218                "menlo",
219                "typewriter",
220                "fixed",
221            ]
222            .iter()
223            .any(|hint| name.contains(hint))
224        {
225            return false;
226        }
227    }
228    let ls = data.fonts[a.font].size;
229    let rs = data.fonts[b.font].size;
230    let em = ls.max(rs);
231    if !ls.is_finite() || !rs.is_finite() || ls.min(rs) <= 0.0 || ls.min(rs) < 0.8 * em {
232        return false;
233    }
234    if !a.writing_angle.is_finite() || !b.writing_angle.is_finite() {
235        return false;
236    }
237    let degrees = a.writing_angle.to_degrees().rem_euclid(360.0);
238    let angle = ((degrees / 90.0).round_ties_even() as i32 * 90) % 360;
239    if ((degrees - angle as f64 + 180.0).rem_euclid(360.0) - 180.0).abs() > 0.1
240        || ((b.writing_angle.to_degrees() - angle as f64 + 180.0).rem_euclid(360.0) - 180.0).abs()
241            > 0.1
242    {
243        return false;
244    }
245    let (Some(ab), Some(bb)) = (a.tight, b.tight) else {
246        return false;
247    };
248    if ab.iter().chain(bb.iter()).any(|v| !v.is_finite()) {
249        return false;
250    }
251    let ab = local(ab, angle);
252    let bb = local(bb, angle);
253    if ab[2] <= ab[0]
254        || bb[2] <= bb[0]
255        || ab[3] <= ab[1]
256        || bb[3] <= bb[1]
257        || ab[3] - ab[1] > 1.5 * ls
258        || bb[3] - bb[1] > 1.5 * rs
259        || bb[0] - ab[2] <= 0.25 * em
260        || ab[3].min(bb[3]) - ab[1].max(bb[1]) < 0.5 * (ab[3] - ab[1]).min(bb[3] - bb[1])
261    {
262        return false;
263    }
264    if let (Some(ao), Some(bo)) = (a.origin, b.origin) {
265        let axis = usize::from(angle != 90 && angle != 270);
266        if ao.iter().chain(bo.iter()).any(|v| !v.is_finite())
267            || (ao[axis] - bo[axis]).abs() > 0.15 * em
268        {
269            return false;
270        }
271    } else if (ab[3] - bb[3]).abs() > 0.15 * em {
272        return false;
273    }
274    true
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280
281    /// 建立独立字符记录,以页面字形高度验证生成空格而非源字号。
282    fn character(text: &str, index: usize, angle: i32) -> Character {
283        let x = index as f64 * 6.0;
284        let bbox = local([x, 0.0, x + 10.0, 10.0], (360 - angle) % 360);
285        let point = local([x, 10.0, x, 10.0], (360 - angle) % 360);
286        Character {
287            text: text.into(),
288            bbox,
289            rotation: 0.0,
290            font: 0,
291            index,
292            sources: vec![index],
293            code: text.chars().next().unwrap() as u32,
294            generated: Some(text == " "),
295            object: None,
296            mode: None,
297            writing_angle: (angle as f64).to_radians(),
298            origin: Some([point[0], point[1]]),
299            loose: None,
300            tight: Some(bbox),
301            visible: None,
302        }
303    }
304
305    /// 四向连字使用同一墨迹框;真实词界、不同来源或字号缩放不应导致过删。
306    #[test]
307    fn ligature_internal_spaces_require_complete_evidence() {
308        for angle in [0, 90, 180, 270] {
309            let mut chars: Vec<_> = ["f", "i", " ", "n"]
310                .iter()
311                .enumerate()
312                .map(|(index, text)| character(text, index, angle))
313                .collect();
314            for (index, ch) in chars.iter_mut().enumerate() {
315                let (bbox, origin) = match index {
316                    2 => ([12.0, 19.99, 15.0, 20.0], [12.0, 20.0]),
317                    3 => ([15.3, 10.0, 20.0, 20.0], [15.2, 20.0]),
318                    _ => ([10.0, 10.0, 15.0, 20.0], [10.0, 20.0]),
319                };
320                ch.tight = Some(local(bbox, (360 - angle) % 360));
321                let p = local(
322                    [origin[0], origin[1], origin[0], origin[1]],
323                    (360 - angle) % 360,
324                );
325                ch.origin = Some([p[0], p[1]]);
326                ch.object = Some(1);
327                ch.generated = Some(false);
328            }
329            assert!(ligature_expansion_space(
330                &chars.iter().collect::<Vec<_>>(),
331                2
332            ));
333            chars[0].object = Some(2);
334            assert!(!ligature_expansion_space(
335                &chars.iter().collect::<Vec<_>>(),
336                2
337            ));
338            chars[0].object = Some(1);
339            chars[3].tight = Some(local([18.0, 10.0, 23.0, 20.0], (360 - angle) % 360));
340            assert!(!ligature_expansion_space(
341                &chars.iter().collect::<Vec<_>>(),
342                2
343            ));
344        }
345    }
346
347    #[test]
348    /// 四个方向和中文标点允许过滤,真实或未知空格、英文词界、异基线保持原样。
349    fn generated_spaces_require_chinese_and_baseline_evidence() {
350        for angle in [0, 90, 180, 270] {
351            let a = character("卫", 0, angle);
352            let mut space = character(" ", 1, angle);
353            let mut b = character("星", 2, angle);
354            assert!(generated_cjk_space(&a, &space, &b));
355            b.text = "、".into();
356            assert!(generated_cjk_space(&a, &space, &b));
357            b.text = "A".into();
358            assert!(!generated_cjk_space(&a, &space, &b));
359            b.text = "星".into();
360            for value in [Some(false), None] {
361                space.generated = value;
362                assert!(!generated_cjk_space(&a, &space, &b));
363            }
364            space.generated = Some(true);
365            b.writing_angle += 0.3;
366            assert!(!generated_cjk_space(&a, &space, &b));
367        }
368        let a = character("卫", 0, 0);
369        let space = character(" ", 1, 0);
370        let mut b = character("星", 2, 0);
371        b.origin = Some([24.0, 30.0]);
372        assert!(!generated_cjk_space(&a, &space, &b));
373        b.origin = None;
374        assert!(generated_cjk_space(&a, &space, &b));
375        b.index = 3;
376        assert!(!generated_cjk_space(&a, &space, &b));
377    }
378}