Skip to main content

docvortex_core/
text_snapshot.rs

1//! 自有 PDF 文本快照;所有字符、字体和来源均为 Rust 数据,不持有 Python 或 PDFium 对象。
2use crate::{
3    dedup,
4    geometry::{Box4, Size},
5};
6use std::collections::{HashMap, HashSet};
7
8#[derive(Clone, Debug)]
9pub struct Font {
10    pub name: String,
11    pub name_id: usize,
12    pub flags: i32,
13    pub size: f64,
14    pub weight: i32,
15}
16#[derive(Clone, Debug)]
17pub struct Character {
18    pub text: String,
19    pub bbox: Box4,
20    pub rotation: f64,
21    pub font: usize,
22    pub index: usize,
23    pub sources: Vec<usize>,
24    pub code: u32,
25    pub generated: Option<bool>,
26    pub object: Option<usize>,
27    pub mode: Option<i32>,
28    pub writing_angle: f64,
29    pub origin: Option<Size>,
30    pub loose: Option<Box4>,
31    pub tight: Option<Box4>,
32    pub visible: Option<bool>,
33}
34pub struct InputCharacter {
35    pub character: Character,
36    pub admitted: bool,
37    pub clip: Option<Box4>,
38}
39#[derive(Clone, Default)]
40pub struct Properties {
41    pub space: bool,
42    pub canonical: String,
43    pub han: bool,
44}
45#[derive(Clone, Copy)]
46pub struct Angle {
47    pub cos: f64,
48    pub sin: f64,
49    pub rounded: u64,
50    pub bucket: i64,
51}
52#[derive(Clone)]
53pub struct Glyph {
54    pub chars: Vec<Character>,
55    pub bbox: Option<Box4>,
56    pub text: String,
57    pub angle: f64,
58}
59#[derive(Debug)]
60pub enum SnapshotError {
61    MissingSource(usize),
62    Invalid(&'static str),
63}
64pub struct TextSnapshot {
65    pub chars: Vec<Character>,
66    pub fonts: Vec<Font>,
67    pub extended: bool,
68    pub visible_only: bool,
69}
70
71/// 判断解释器定义的空白,不以 Rust Unicode 版本替换宿主语义。
72pub fn is_space(text: &str, properties: &HashMap<String, Properties>) -> bool {
73    !text.is_empty()
74        && text
75            .chars()
76            .all(|ch| properties.get(&ch.to_string()).is_some_and(|p| p.space))
77}
78/// 严格比较保持 Python min/max 对有符号零的首值语义。
79fn min(a: f64, b: f64) -> f64 {
80    if b < a {
81        b
82    } else {
83        a
84    }
85}
86/// 严格比较保持 Python max 的首值语义。
87fn max(a: f64, b: f64) -> f64 {
88    if b > a {
89        b
90    } else {
91        a
92    }
93}
94/// 计算可见交集,不重新计算字符原点或来源编号。
95fn intersection(a: Box4, b: Box4) -> Box4 {
96    [
97        max(a[0], b[0]),
98        max(a[1], b[1]),
99        min(a[2], b[2]),
100        min(a[3], b[3]),
101    ]
102}
103/// 判断矩形是否具有正面积。
104fn nonempty(b: Box4) -> bool {
105    b[2] > b[0] && b[3] > b[1]
106}
107/// 仅裁剪实际被截断的字形,损坏 loose 框继续使用有效墨迹范围。
108fn clip_character(
109    ch: &mut Character,
110    clip: Box4,
111    properties: &HashMap<String, Properties>,
112) -> bool {
113    let ink = ch.tight.unwrap_or(ch.bbox);
114    if is_space(&ch.text, properties) {
115        let x = (ink[0] + ink[2]) / 2.0;
116        let y = (ink[1] + ink[3]) / 2.0;
117        return clip[0] <= x && x <= clip[2] && clip[1] <= y && y <= clip[3];
118    }
119    let visible = intersection(ink, clip);
120    if !nonempty(visible) {
121        return false;
122    }
123    if ink != visible {
124        let clipped = intersection(ch.bbox, clip);
125        ch.bbox = if nonempty(clipped) { clipped } else { visible };
126        for value in [&mut ch.loose, &mut ch.tight] {
127            if let Some(bbox) = *value {
128                let clipped = intersection(bbox, clip);
129                *value = Some(if nonempty(clipped) { clipped } else { visible });
130            }
131        }
132    }
133    true
134}
135/// 在代理对恢复之前按原顺序过滤和赋予书写方向;页内对象 ID 已由绑定层在过滤前分配。
136pub fn initialize(
137    input: Vec<InputCharacter>,
138    properties: &HashMap<String, Properties>,
139) -> Vec<Character> {
140    let chars: Vec<Character> = input
141        .into_iter()
142        .filter_map(|item| {
143            if !item.admitted {
144                return None;
145            }
146            let mut ch = item.character;
147            if item
148                .clip
149                .is_some_and(|clip| !clip_character(&mut ch, clip, properties))
150            {
151                return None;
152            }
153            Some(ch)
154        })
155        .collect();
156    chars
157}
158
159pub struct WritingRun {
160    start: usize,
161    end: usize,
162    next: usize,
163}
164/// 只记录有段首原点的连续同对象范围,不把缺失段首替换成后继原点。
165pub fn writing_runs(chars: &[Character]) -> Vec<WritingRun> {
166    let mut start = 0;
167    let mut runs = Vec::new();
168    while start < chars.len() {
169        let mut end = start + 1;
170        if let Some(object) = chars[start].object {
171            while end < chars.len() && chars[end].object == Some(object) {
172                end += 1;
173            }
174            if chars[start].origin.is_some() {
175                runs.push(WritingRun {
176                    start,
177                    end,
178                    next: start + 1,
179                });
180            }
181        }
182        start = end;
183    }
184    runs
185}
186/// 对每个未完成段请求下一个缺失方向元数据;游标避免退化成重复扫描。
187pub fn advance_writing_angles(
188    chars: &mut [Character],
189    runs: &mut [WritingRun],
190    directions: &HashMap<(u64, u64), (bool, f64)>,
191) -> Vec<(u64, u64)> {
192    let mut pending = HashSet::new();
193    let mut requested = Vec::new();
194    for run in runs {
195        let origin = chars[run.start].origin.unwrap();
196        while run.next < run.end {
197            let Some(next) = chars[run.next].origin else {
198                run.next += 1;
199                continue;
200            };
201            let key = (
202                (next[0] - origin[0]).to_bits(),
203                (next[1] - origin[1]).to_bits(),
204            );
205            let Some(&(valid, angle)) = directions.get(&key) else {
206                if pending.insert(key) {
207                    requested.push(key);
208                }
209                break;
210            };
211            run.next += 1;
212            if valid {
213                for ch in &mut chars[run.start..run.end] {
214                    ch.writing_angle = angle;
215                }
216                run.next = run.end;
217            }
218        }
219    }
220    requested
221}
222
223/// 恢复 PDFium UTF-16 代理对,保持原过滤顺序、代表字符和排序后的来源索引。
224pub fn restore_surrogates(
225    chars: Vec<Character>,
226    raw_count: usize,
227) -> Result<Vec<Character>, SnapshotError> {
228    let codes: HashMap<usize, u32> = chars.iter().map(|ch| (ch.index, ch.code)).collect();
229    let mut consumed = HashSet::new();
230    let mut output = Vec::with_capacity(chars.len());
231    for mut ch in chars {
232        if consumed.contains(&ch.index) {
233            continue;
234        }
235        if ch.text == "\u{fffd}" && ch.index < raw_count {
236            let mut pair = None;
237            if (0xd800..=0xdbff).contains(&ch.code) && ch.index + 1 < raw_count {
238                let low = *codes
239                    .get(&(ch.index + 1))
240                    .ok_or(SnapshotError::MissingSource(ch.index + 1))?;
241                if (0xdc00..=0xdfff).contains(&low) {
242                    consumed.insert(ch.index + 1);
243                    pair = Some((ch.code, low, ch.index + 1));
244                }
245            } else if (0xdc00..=0xdfff).contains(&ch.code) && ch.index > 0 {
246                let high = *codes
247                    .get(&(ch.index - 1))
248                    .ok_or(SnapshotError::MissingSource(ch.index - 1))?;
249                if (0xd800..=0xdbff).contains(&high) {
250                    pair = Some((high, ch.code, ch.index - 1));
251                }
252            }
253            if let Some((high, low, source)) = pair {
254                ch.text = char::from_u32(0x10000 + ((high - 0xd800) << 10) + (low - 0xdc00))
255                    .unwrap()
256                    .to_string();
257                ch.sources.push(source);
258                ch.sources.sort_unstable();
259                ch.sources.dedup();
260            }
261        }
262        output.push(ch);
263    }
264    Ok(output)
265}
266/// 判断同对象的连续来源映射,保护连字的一对多输出。
267fn same_mapping(a: &Character, b: &Character, properties: &HashMap<String, Properties>) -> bool {
268    a.object.is_some()
269        && a.object == b.object
270        && a.sources.iter().max().and_then(|i| i.checked_add(1)) == Some(b.index)
271        && a.font == b.font
272        && a.rotation == b.rotation
273        && close(a.origin, b.origin)
274        && close(Some(a.bbox), Some(b.bbox))
275        && !a.text.is_empty()
276        && !b.text.is_empty()
277        && !is_space(&a.text, properties)
278        && !is_space(&b.text, properties)
279}
280/// 缺失几何不能作为来源等价证据。
281fn close<const N: usize>(a: Option<[f64; N]>, b: Option<[f64; N]>) -> bool {
282    matches!((a, b), (Some(a), Some(b)) if a.iter().zip(b).all(|(x,y)| x.is_finite() && y.is_finite() && (*x-y).abs() <= 0.001))
283}
284/// 合并来源索引但不改变代表字形的几何、字体及原始代码。
285fn sources<'a>(chars: impl IntoIterator<Item = &'a Character>) -> Vec<usize> {
286    let mut values: Vec<usize> = chars
287        .into_iter()
288        .flat_map(|ch| ch.sources.iter().copied())
289        .collect();
290    values.sort_unstable();
291    values.dedup();
292    values
293}
294/// 仅为实际一对多且文本不同的映射请求汉字规范化,普通字符不触发固定部首资源读取。
295pub fn mapping_metadata(
296    chars: &[Character],
297    properties: &HashMap<String, Properties>,
298) -> Vec<String> {
299    let mut start = 0;
300    let mut output = Vec::new();
301    let mut seen = HashSet::new();
302    while start < chars.len() {
303        let mut end = start + 1;
304        while end < chars.len() && same_mapping(&chars[end - 1], &chars[end], properties) {
305            end += 1;
306        }
307        if end > start + 1
308            && chars[start + 1..end]
309                .iter()
310                .any(|ch| ch.text != chars[start].text)
311        {
312            for ch in &chars[start..end] {
313                if seen.insert(ch.text.clone()) {
314                    output.push(ch.text.clone());
315                }
316            }
317        }
318        start = end;
319    }
320    output
321}
322/// 建立完整映射保护组,只折叠已由宿主 Unicode 数据证明等价的单个汉字。
323pub fn mapping_groups(
324    chars: Vec<Character>,
325    properties: &HashMap<String, Properties>,
326) -> Vec<Glyph> {
327    let mut groups: Vec<Vec<Character>> = Vec::new();
328    for ch in chars {
329        if groups
330            .last()
331            .is_some_and(|group| same_mapping(group.last().unwrap(), &ch, properties))
332        {
333            groups.last_mut().unwrap().push(ch);
334        } else {
335            groups.push(vec![ch]);
336        }
337    }
338    groups
339        .into_iter()
340        .map(|mut group| {
341            if group.len() > 1 && group.iter().any(|ch| ch.text != group[0].text) {
342                let canonical = properties
343                    .get(&group[0].text)
344                    .map(|p| p.canonical.as_str())
345                    .unwrap_or(&group[0].text);
346                if properties.get(canonical).is_some_and(|p| p.han)
347                    && group.iter().all(|ch| {
348                        properties
349                            .get(&ch.text)
350                            .map(|p| p.canonical.as_str())
351                            .unwrap_or(&ch.text)
352                            == canonical
353                    })
354                {
355                    let mut head = group[0].clone();
356                    head.text = canonical.to_owned();
357                    head.sources = sources(group.iter());
358                    group = vec![head];
359                }
360            }
361            let head = &group[0];
362            let angle = head.writing_angle;
363            let bbox = (angle.is_finite()
364                && head.bbox.iter().all(|v| v.is_finite())
365                && nonempty(head.bbox))
366            .then_some(head.bbox);
367            let text = group.iter().map(|ch| ch.text.as_str()).collect();
368            Glyph {
369                chars: group,
370                bbox,
371                text,
372                angle,
373            }
374        })
375        .collect()
376}
377/// 合并重复字形来源,长度不同时全部来源归于代表字符。
378fn merge_sources(retained: &mut Glyph, duplicate: &Glyph) {
379    if retained.chars.len() == duplicate.chars.len() {
380        for (a, b) in retained.chars.iter_mut().zip(&duplicate.chars) {
381            a.sources = sources([&*a, b]);
382        }
383    } else {
384        retained.chars[0].sources = sources(retained.chars.iter().chain(&duplicate.chars));
385    }
386}
387/// 删除两侧内容均被移除的孤立空白,保留正文之间的空格和换行。
388fn retained(
389    glyphs: Vec<Glyph>,
390    removed: &HashSet<usize>,
391    properties: &HashMap<String, Properties>,
392) -> Vec<Glyph> {
393    if removed.is_empty() {
394        return glyphs;
395    }
396    let mut trailing = vec![true; glyphs.len()];
397    let mut following = true;
398    for i in (0..glyphs.len()).rev() {
399        trailing[i] = following;
400        if !is_space(&glyphs[i].text, properties) {
401            following = removed.contains(&i);
402        }
403    }
404    let mut previous = true;
405    glyphs
406        .into_iter()
407        .enumerate()
408        .filter_map(|(i, g)| {
409            let space = is_space(&g.text, properties);
410            let orphaned = space && previous && trailing[i];
411            if !space {
412                previous = removed.contains(&i);
413            }
414            (!removed.contains(&i) && !orphaned).then_some(g)
415        })
416        .collect()
417}
418/// 复用原 Rust 数值内核,连续完成重复绘制候选、平移证据、连通分量和来源归并。
419pub fn collapse_paints(
420    mut glyphs: Vec<Glyph>,
421    angles: &HashMap<u64, Angle>,
422    properties: &HashMap<String, Properties>,
423) -> Result<Vec<Glyph>, SnapshotError> {
424    let mut signatures = HashMap::new();
425    let records = glyphs
426        .iter()
427        .map(|g| {
428            let h = &g.chars[0];
429            let active = g.bbox.is_some()
430                && !g.text.is_empty()
431                && !is_space(&g.text, properties)
432                && h.object.is_some()
433                && h.origin.is_some()
434                && h.mode.is_some_and(|v| (0..=6).contains(&v));
435            if !active {
436                return (None, None, 0, 0, false);
437            }
438            let key = (
439                g.text.clone(),
440                h.font,
441                angles[&g.angle.to_bits()].rounded,
442                h.mode,
443                h.visible,
444            );
445            let next = signatures.len();
446            let signature = *signatures.entry(key).or_insert(next);
447            (g.bbox, h.origin, signature, h.object.unwrap(), true)
448        })
449        .collect();
450    let (exact, offsets) =
451        dedup::paint_pairs(records).ok_or(SnapshotError::Invalid("unsupported paint geometry"))?;
452    let active: HashSet<usize> = offsets.iter().map(|p| p.0).collect();
453    let mut texts = HashMap::new();
454    let mut angle_ids = HashMap::new();
455    let evidence = glyphs
456        .iter()
457        .enumerate()
458        .map(|(index, g)| {
459            let next = texts.len();
460            let text = *texts.entry(g.text.as_str()).or_insert(next);
461            let (projected, normal, angle_id) = if active.contains(&index) {
462                let angle = angles[&g.angle.to_bits()];
463                let origin = g.chars[0].origin.unwrap();
464                let next = angle_ids.len();
465                let id = *angle_ids.entry(angle.bucket).or_insert(next);
466                (
467                    Some(dedup::project(g.bbox.unwrap(), angle.cos, angle.sin)),
468                    -angle.sin * origin[0] + angle.cos * origin[1],
469                    id,
470                )
471            } else {
472                (None, 0.0, 0)
473            };
474            (
475                projected,
476                normal,
477                angle_id,
478                text,
479                is_space(&g.text, properties),
480            )
481        })
482        .collect();
483    let confirmed = dedup::confirmed_offsets(evidence, offsets, exact.clone());
484    let roots = dedup::components(
485        glyphs.len(),
486        &exact.into_iter().chain(confirmed).collect::<Vec<_>>(),
487    );
488    for index in (0..glyphs.len()).rev() {
489        if roots[index] != index {
490            let duplicate = glyphs[index].clone();
491            merge_sources(&mut glyphs[roots[index]], &duplicate);
492        }
493    }
494    let removed = roots
495        .iter()
496        .enumerate()
497        .filter_map(|(i, &root)| (root != i).then_some(i))
498        .collect();
499    Ok(retained(glyphs, &removed, properties))
500}
501/// 隐藏文本与可见文本的有序字符索引配对。
502pub type HiddenPairs = Vec<(Vec<usize>, Vec<usize>)>;
503
504/// 生成隐藏副本配对,宿主随后只需为不同整段字符串准备 NFKC 比较元数据。
505pub fn hidden_pairs(
506    glyphs: &[Glyph],
507    angles: &HashMap<u64, Angle>,
508    properties: &HashMap<String, Properties>,
509) -> Result<HiddenPairs, SnapshotError> {
510    if !glyphs.iter().any(|g| g.chars[0].mode == Some(3)) {
511        return Ok(Vec::new());
512    }
513    let mut objects = HashMap::new();
514    let records = glyphs
515        .iter()
516        .map(|g| {
517            let head = &g.chars[0];
518            let next = objects.len();
519            let object = *objects.entry(head.object).or_insert(next);
520            let angle = angles[&g.angle.to_bits()];
521            let mode = head.mode.filter(|v| (0..=6).contains(v)).unwrap_or(-1) as i8;
522            (
523                g.bbox,
524                head.origin,
525                object,
526                mode,
527                head.visible.unwrap_or(true),
528                !g.text.is_empty() && !is_space(&g.text, properties),
529                g.angle,
530                angle.cos,
531                angle.sin,
532                head.index,
533            )
534        })
535        .collect();
536    dedup::hidden_candidates(records)
537        .ok_or(SnapshotError::Invalid("unsupported hidden-text geometry"))
538}
539/// 按预先准备的宿主 Unicode 结果判定整段配对,保留现有少量汉字 OCR 容错门槛。
540pub fn suppress_hidden(
541    mut glyphs: Vec<Glyph>,
542    pairs: HiddenPairs,
543    normalized: &HashMap<String, String>,
544    properties: &HashMap<String, Properties>,
545) -> Vec<Character> {
546    let mut removed = HashSet::new();
547    for (run, matches) in pairs {
548        let a: String = run.iter().map(|&i| glyphs[i].text.as_str()).collect();
549        let b: String = matches.iter().map(|&i| glyphs[i].text.as_str()).collect();
550        let a = &normalized[&a];
551        let b = &normalized[&b];
552        let aa: Vec<char> = a.chars().collect();
553        let bb: Vec<char> = b.chars().collect();
554        if aa.is_empty() || aa.len() != bb.len() {
555            continue;
556        }
557        if aa != bb {
558            let count = aa.iter().zip(&bb).filter(|(a, b)| a == b).count();
559            if count < 6
560                || (count as f64) / (aa.len() as f64) < 0.85
561                || !aa.iter().zip(&bb).all(|(a, b)| {
562                    a == b
563                        || (properties.get(&a.to_string()).is_some_and(|p| p.han)
564                            && properties.get(&b.to_string()).is_some_and(|p| p.han))
565                })
566            {
567                continue;
568            }
569        }
570        removed.extend(run.iter().copied());
571        if run.len() == matches.len()
572            && run
573                .iter()
574                .zip(&matches)
575                .all(|(&a, &b)| glyphs[a].chars.len() == glyphs[b].chars.len())
576        {
577            for (&a, &b) in run.iter().zip(&matches) {
578                let duplicate = glyphs[a].clone();
579                merge_sources(&mut glyphs[b], &duplicate);
580            }
581        } else {
582            let values = sources(
583                glyphs[matches[0]]
584                    .chars
585                    .iter()
586                    .chain(run.iter().flat_map(|&i| glyphs[i].chars.iter())),
587            );
588            glyphs[matches[0]].chars[0].sources = values;
589        }
590    }
591    retained(glyphs, &removed, properties)
592        .into_iter()
593        .flat_map(|g| g.chars)
594        .collect()
595}