Skip to main content

rapiddoc_native/
grid.rs

1//! 已确认物理网格的单元格拓扑;候选仲裁和轨道发现由整页规则层负责。
2
3use crate::{cell_regions, error};
4use rapiddoc_core::{Error, ErrorCode, Rect};
5use serde::Serialize;
6use std::collections::HashSet;
7
8/// 页面视觉坐标中的水平或竖直分隔线。
9#[derive(Debug, Clone, Copy)]
10pub struct Separator {
11    pub vertical: bool,
12    pub coordinate: f64,
13    pub start: f64,
14    pub end: f64,
15}
16
17/// 原字符来源与视觉框;传入顺序可以乱序,输出仍按 index 保持来源顺序。
18#[derive(Debug, Clone)]
19pub struct Glyph {
20    pub index: usize,
21    pub bounds: Rect,
22    pub text: String,
23    pub generated: bool,
24}
25
26/// 物理表格单元格,保留来源索引以便输出层防止正文重复。
27#[derive(Debug, Clone, Serialize)]
28pub struct Cell {
29    pub row: usize,
30    pub column: usize,
31    pub row_span: usize,
32    pub column_span: usize,
33    pub text: String,
34    pub character_indices: Vec<usize>,
35}
36
37/// 已确认轨道的表格,坐标为左上原点 PDF point。
38#[derive(Debug, Clone, Serialize)]
39pub struct Table {
40    pub bounds: Rect,
41    pub rows: usize,
42    pub columns: usize,
43    pub cells: Vec<Cell>,
44}
45
46/// 与表格同时保留实测物理轨道,供不均匀行列的页面几何输出使用。
47#[derive(Debug, Serialize)]
48pub struct RecoveredGrid {
49    pub table: Table,
50    pub x_tracks: Vec<f64>,
51    pub y_tracks: Vec<f64>,
52    pub coverage_work: usize,
53}
54pub struct TrackPlan {
55    pub x_tracks: Vec<f64>,
56    pub y_tracks: Vec<f64>,
57    pub lines: Vec<Separator>,
58    pub coverage_work: usize,
59}
60
61/// 网格组装限制与覆盖阈值;阈值必须在迁移差分后冻结,不能由失败动态放宽。
62#[derive(Debug, Clone)]
63pub struct GridConfig {
64    pub max_cells: usize,
65    pub max_separators: usize,
66    pub tolerance: f64,
67    pub minimum_coverage: f64,
68    pub max_coverage_work: usize,
69}
70
71impl Default for GridConfig {
72    fn default() -> Self {
73        Self {
74            max_cells: 100_000,
75            max_separators: 100_000,
76            tolerance: 1.,
77            minimum_coverage: 0.9,
78            max_coverage_work: 50_000_000,
79        }
80    }
81}
82
83/// 在调用方已指定的表格区域内发现轴线轨道,再交给物理网格组装器。
84/// 这不是整页表格检测器,不对没有外边界的区域猜测单元格。
85pub fn recover(
86    region: Rect,
87    lines: &[Separator],
88    glyphs: Vec<Glyph>,
89    config: &GridConfig,
90) -> Result<Table, Error> {
91    Ok(recover_with_tracks(region, lines, glyphs, config)?.table)
92}
93
94pub fn recover_with_tracks(
95    region: Rect,
96    lines: &[Separator],
97    glyphs: Vec<Glyph>,
98    config: &GridConfig,
99) -> Result<RecoveredGrid, Error> {
100    let plan = plan_tracks(region, lines, config)?;
101    let table = assemble(&plan.x_tracks, &plan.y_tracks, &plan.lines, glyphs, config)?;
102    Ok(RecoveredGrid {
103        table,
104        x_tracks: plan.x_tracks,
105        y_tracks: plan.y_tracks,
106        coverage_work: plan.coverage_work,
107    })
108}
109
110/// 先预算轨道/覆盖查询,再做拓扑,失败的候选同样需要计入调用方累计预算。
111pub fn plan_tracks(
112    region: Rect,
113    lines: &[Separator],
114    config: &GridConfig,
115) -> Result<TrackPlan, Error> {
116    if lines.len() > rapiddoc_core::resources::limit(config.max_separators) {
117        return Err(error(ErrorCode::ResourceLimit, "分隔线超过限制"));
118    }
119    if !config.tolerance.is_finite() || config.tolerance < 0. {
120        return Err(error(ErrorCode::InvalidInput, "无效轨道容差"));
121    }
122    let [left, top, right, bottom] = region.into();
123    if right <= left || bottom <= top {
124        return Err(error(ErrorCode::InvalidInput, "表格区域必须有正面积"));
125    }
126    let mut selected = Vec::new();
127    let mut xs = Vec::new();
128    let mut ys = Vec::new();
129    for line in lines {
130        if ![line.coordinate, line.start, line.end]
131            .iter()
132            .all(|v| v.is_finite())
133        {
134            return Err(error(ErrorCode::InvalidInput, "分隔线几何无效"));
135        }
136        let (low, high, first, last) = if line.vertical {
137            (left, right, top, bottom)
138        } else {
139            (top, bottom, left, right)
140        };
141        if line.coordinate < low - config.tolerance || line.coordinate > high + config.tolerance {
142            continue;
143        }
144        let start = line.start.min(line.end).max(first);
145        let end = line.start.max(line.end).min(last);
146        if end <= start {
147            continue;
148        }
149        selected.push(Separator {
150            vertical: line.vertical,
151            coordinate: line.coordinate,
152            start,
153            end,
154        });
155        if line.vertical {
156            xs.push(line.coordinate);
157        } else {
158            ys.push(line.coordinate);
159        }
160    }
161    let xs = tracks(xs, config.tolerance);
162    let ys = tracks(ys, config.tolerance);
163    let cells = xs
164        .len()
165        .saturating_sub(1)
166        .checked_mul(ys.len().saturating_sub(1))
167        .ok_or_else(|| error(ErrorCode::ResourceLimit, "网格尺寸溢出"))?;
168    let coverage_work = cells
169        .checked_mul(2)
170        .and_then(|n| n.checked_mul(selected.len()))
171        .ok_or_else(|| error(ErrorCode::ResourceLimit, "覆盖查询预算溢出"))?;
172    if cells > rapiddoc_core::resources::limit(config.max_cells)
173        || coverage_work > rapiddoc_core::resources::limit(config.max_coverage_work)
174    {
175        return Err(error(ErrorCode::ResourceLimit, "网格或覆盖查询超过预算"));
176    }
177    Ok(TrackPlan {
178        x_tracks: xs,
179        y_tracks: ys,
180        lines: selected,
181        coverage_work,
182    })
183}
184
185/// 按簇首值限制总跨度,避免链式相邻把多个物理轨道合为一列。
186fn tracks(mut values: Vec<f64>, tolerance: f64) -> Vec<f64> {
187    values.sort_by(f64::total_cmp);
188    let mut output = Vec::new();
189    let mut index = 0;
190    while index < values.len() {
191        let first = values[index];
192        let mut end = index + 1;
193        while end < values.len() && values[end] - first <= tolerance {
194            end += 1;
195        }
196        output.push(values[index..end].iter().sum::<f64>() / (end - index) as f64);
197        index = end;
198    }
199    output
200}
201
202/// 组装已知轨道网格。外边界不完整、L 形合并、来源重复均明确拒绝。
203pub fn assemble(
204    xs: &[f64],
205    ys: &[f64],
206    lines: &[Separator],
207    mut glyphs: Vec<Glyph>,
208    config: &GridConfig,
209) -> Result<Table, Error> {
210    for tracks in [xs, ys] {
211        if tracks.len() < 2
212            || tracks.iter().any(|v| !v.is_finite())
213            || tracks.windows(2).any(|w| w[0] >= w[1])
214        {
215            return Err(error(ErrorCode::InvalidInput, "轨道必须有限且严格递增"));
216        }
217    }
218    if !config.tolerance.is_finite()
219        || config.tolerance < 0.
220        || !config.minimum_coverage.is_finite()
221        || config.minimum_coverage <= 0.
222        || config.minimum_coverage > 1.
223    {
224        return Err(error(ErrorCode::InvalidInput, "无效覆盖阈值"));
225    }
226    let rows = ys.len() - 1;
227    let columns = xs.len() - 1;
228    let count = rows
229        .checked_mul(columns)
230        .ok_or_else(|| error(ErrorCode::ResourceLimit, "网格尺寸溢出"))?;
231    if count > rapiddoc_core::resources::limit(config.max_cells)
232        || lines.len() > rapiddoc_core::resources::limit(config.max_separators)
233    {
234        return Err(error(ErrorCode::ResourceLimit, "网格超过预算"));
235    }
236    let work = count
237        .checked_mul(2)
238        .and_then(|value| value.checked_mul(lines.len()))
239        .ok_or_else(|| error(ErrorCode::ResourceLimit, "覆盖查询预算溢出"))?;
240    if work > rapiddoc_core::resources::limit(config.max_coverage_work) {
241        return Err(error(ErrorCode::ResourceLimit, "覆盖查询超过计算预算"));
242    }
243    if lines.iter().any(|line| {
244        ![line.coordinate, line.start, line.end]
245            .iter()
246            .all(|v| v.is_finite())
247    }) {
248        return Err(error(ErrorCode::InvalidInput, "分隔线包含非有限值"));
249    }
250    // 整批查询共享内核的轨道缓存,避免逐单元格复制和重新过滤所有线段。
251    let mut queries = Vec::new();
252    let mut connections = Vec::new();
253    for (vertical, coordinate, start, end) in [
254        (true, xs[0], ys[0], ys[rows]),
255        (true, xs[columns], ys[0], ys[rows]),
256        (false, ys[0], xs[0], xs[columns]),
257        (false, ys[rows], xs[0], xs[columns]),
258    ] {
259        queries.push((
260            u8::from(vertical),
261            vec![coordinate],
262            start,
263            end,
264            config.tolerance,
265        ));
266    }
267    for row in 0..rows {
268        for column in 0..columns - 1 {
269            queries.push((
270                1,
271                vec![xs[column + 1]],
272                ys[row],
273                ys[row + 1],
274                config.tolerance,
275            ));
276            connections.push((row * columns + column, row * columns + column + 1));
277        }
278    }
279    for row in 0..rows - 1 {
280        for column in 0..columns {
281            queries.push((
282                0,
283                vec![ys[row + 1]],
284                xs[column],
285                xs[column + 1],
286                config.tolerance,
287            ));
288            connections.push((row * columns + column, (row + 1) * columns + column));
289        }
290    }
291    let rules = lines
292        .iter()
293        .map(|line| {
294            (
295                u8::from(line.vertical),
296                line.coordinate,
297                line.start,
298                line.end,
299            )
300        })
301        .collect();
302    let coverages = docvortex_core::tables::coverage_batch(rules, queries)
303        .ok_or_else(|| error(ErrorCode::InvalidInput, "分隔线查询无效"))?;
304    if coverages[..4]
305        .iter()
306        .any(|value| *value < config.minimum_coverage)
307    {
308        return Err(error(
309            ErrorCode::InvalidInput,
310            "外边界不完整,不能认领物理表格",
311        ));
312    }
313    let missing = connections
314        .into_iter()
315        .zip(&coverages[4..])
316        .filter_map(|(edge, coverage)| (*coverage < config.minimum_coverage).then_some(edge))
317        .collect();
318    let regions = cell_regions(
319        rows,
320        columns,
321        missing,
322        rapiddoc_core::resources::limit(config.max_cells),
323    )?;
324    let mut cells: Vec<Cell> = regions
325        .into_iter()
326        .map(|region| Cell {
327            row: region.row,
328            column: region.column,
329            row_span: region.row_span,
330            column_span: region.column_span,
331            text: String::new(),
332            character_indices: Vec::new(),
333        })
334        .collect();
335    let mut owner = vec![0usize; count];
336    for (index, cell) in cells.iter().enumerate() {
337        for row in cell.row..cell.row + cell.row_span {
338            for column in cell.column..cell.column + cell.column_span {
339                owner[row * columns + column] = index;
340            }
341        }
342    }
343    let mut seen = HashSet::new();
344    glyphs.sort_by_key(|glyph| glyph.index);
345    let mut assignments = Vec::with_capacity(glyphs.len());
346    for glyph in &glyphs {
347        if !seen.insert(glyph.index) {
348            return Err(error(ErrorCode::InvalidInput, "字符来源索引重复"));
349        }
350        let [left, top, right, bottom] = glyph.bounds.into();
351        let x = left / 2. + right / 2.;
352        let y = top / 2. + bottom / 2.;
353        if x < xs[0] || x > xs[columns] || y < ys[0] || y > ys[rows] {
354            assignments.push(None);
355            continue;
356        }
357        let column = xs
358            .partition_point(|value| *value <= x)
359            .saturating_sub(1)
360            .min(columns - 1);
361        let row = ys
362            .partition_point(|value| *value <= y)
363            .saturating_sub(1)
364            .min(rows - 1);
365        assignments.push(Some(owner[row * columns + column]));
366    }
367    // 一次正向、一次反向准备邻居,长空白串也不退化为逐字符回扫。
368    let mut before = Vec::with_capacity(glyphs.len());
369    let mut previous = None;
370    for (glyph, assignment) in glyphs.iter().zip(&assignments) {
371        before.push(previous);
372        if !glyph.text.chars().all(char::is_whitespace) {
373            previous = *assignment;
374        }
375    }
376    let mut after = vec![None; glyphs.len()];
377    let mut next = None;
378    for index in (0..glyphs.len()).rev() {
379        after[index] = next;
380        if !glyphs[index].text.chars().all(char::is_whitespace) {
381            next = assignments[index];
382        }
383    }
384    for (index, glyph) in glyphs.into_iter().enumerate() {
385        let assignment = if glyph.generated && glyph.text.chars().all(char::is_whitespace) {
386            if before[index] == after[index] {
387                before[index]
388            } else {
389                None
390            }
391        } else {
392            assignments[index]
393        };
394        let Some(assignment) = assignment else {
395            continue;
396        };
397        let cell = &mut cells[assignment];
398        cell.text.push_str(&glyph.text);
399        cell.character_indices.push(glyph.index);
400    }
401    Ok(Table {
402        bounds: Rect::try_from([xs[0], ys[0], xs[columns], ys[rows]])?,
403        rows,
404        columns,
405        cells,
406    })
407}