Skip to main content

rapiddoc_render/
quality.rs

1//! 有线/无线候选选优:移植 RapidDoc select_best_table_model 的规则与优先级。
2//! 输入限本 crate 输出的闭合表格 HTML;不尝试修复任意浏览器 HTML。
3use crate::escape_html;
4use rapiddoc_core::{Error, ErrorCode};
5
6#[derive(Debug, PartialEq, Eq)]
7pub struct Metrics {
8    pub cells: usize,
9    pub blank: usize,
10    pub ocr_hits: usize,
11}
12impl Metrics {
13    pub fn non_blank(&self) -> usize {
14        self.cells - self.blank
15    }
16}
17pub struct Selection {
18    pub route: &'static str,
19    pub reason: &'static str,
20    pub wired: Metrics,
21    pub wireless: Metrics,
22}
23fn invalid(message: &str) -> Error {
24    Error {
25        code: ErrorCode::InvalidInput,
26        message: message.into(),
27    }
28}
29fn limited(message: &str) -> Error {
30    Error {
31        code: ErrorCode::ResourceLimit,
32        message: message.into(),
33    }
34}
35fn whitespace(c: char) -> bool {
36    c.is_whitespace() || matches!(c, '\u{001c}'..='\u{001f}')
37}
38
39fn visible(mut text: &str) -> Result<bool, Error> {
40    let mut result = false;
41    while let Some((_, after)) = text.split_once('&') {
42        let entity = ["amp;", "lt;", "gt;", "quot;", "#39;"]
43            .iter()
44            .find(|e| after.starts_with(**e))
45            .ok_or_else(|| invalid("候选 HTML 含非规范实体;请使用 Rust 表格输出器生成候选"))?;
46        result = true;
47        text = &after[entity.len()..];
48    }
49    Ok(result || text.chars().any(|c| !whitespace(c)))
50}
51fn attributes(attrs: &str) -> Result<(), Error> {
52    let mut seen = [false; 2];
53    for attr in attrs.split_ascii_whitespace() {
54        let (key, raw) = attr
55            .split_once('=')
56            .ok_or_else(|| invalid("候选单元格属性无效"))?;
57        let slot = match key {
58            "rowspan" => 0,
59            "colspan" => 1,
60            _ => return Err(invalid("候选单元格含未知属性")),
61        };
62        if std::mem::replace(&mut seen[slot], true) {
63            return Err(invalid("候选单元格属性重复"));
64        }
65        let raw = raw
66            .strip_prefix('"')
67            .and_then(|v| v.strip_suffix('"'))
68            .unwrap_or(raw);
69        let value = raw.parse::<usize>().ok().filter(|v| (1..=1000).contains(v));
70        if value.is_none() {
71            return Err(invalid("候选单元格跨度无效"));
72        }
73    }
74    Ok(())
75}
76
77/// 只扫描规范标签、行/格跨度和转义文本,不建立 DOM,不产生外部实体或资源访问。
78pub fn metrics(mut html: &str) -> Result<Metrics, Error> {
79    if html.len() > rapiddoc_core::resources::limit(16_000_000) {
80        return Err(limited("表格候选 HTML 超过预算"));
81    }
82    let mut result = Metrics {
83        cells: 0,
84        blank: 0,
85        ocr_hits: 0,
86    };
87    let mut stack: Vec<String> = Vec::new();
88    let mut cell: Option<bool> = None;
89    let mut tables = 0;
90    while !html.is_empty() {
91        let (text, rest) = html.split_once('<').unwrap_or((html, ""));
92        let has_text = visible(text)?;
93        if let Some(v) = &mut cell {
94            *v |= has_text;
95        } else if has_text {
96            return Err(invalid("候选 HTML 的文字必须位于单元格内"));
97        }
98        if rest.is_empty() {
99            if html.ends_with('<') {
100                return Err(invalid("候选 HTML 标签未闭合"));
101            }
102            break;
103        }
104        let (raw, tail) = rest
105            .split_once('>')
106            .ok_or_else(|| invalid("候选 HTML 标签未闭合"))?;
107        if raw.len() > rapiddoc_core::resources::limit(128) {
108            return Err(invalid("候选 HTML 标签过长"));
109        }
110        let tag = raw.trim_ascii().to_ascii_lowercase();
111        html = tail;
112        if tag == "br" || tag == "br/" || tag == "br /" {
113            if cell.is_none() {
114                return Err(invalid("候选换行必须位于单元格内"));
115            }
116            continue;
117        }
118        if let Some(close) = tag.strip_prefix('/') {
119            if stack.pop().as_deref() != Some(close) {
120                return Err(invalid("候选 HTML 标签错位"));
121            }
122            if (close == "td" || close == "th")
123                && !cell.take().ok_or_else(|| invalid("候选单元格未开始"))?
124            {
125                result.blank += 1;
126            }
127            continue;
128        }
129        let (name, attrs) = tag
130            .split_once(|c: char| c.is_ascii_whitespace())
131            .unwrap_or((&tag, ""));
132        let parent = stack.last().map(String::as_str).unwrap_or("");
133        let allowed = match name {
134            "html" => parent.is_empty(),
135            "body" => parent == "html" || parent.is_empty(),
136            "table" => matches!(parent, "" | "html" | "body") && tables == 0,
137            "thead" | "tbody" | "tfoot" => parent == "table",
138            "tr" => matches!(parent, "table" | "thead" | "tbody" | "tfoot"),
139            "td" | "th" => parent == "tr" && cell.is_none(),
140            _ => false,
141        };
142        if !allowed {
143            return Err(invalid("候选 HTML 含未知或错位标签"));
144        }
145        if name == "td" || name == "th" {
146            attributes(attrs)?;
147            result.cells += 1;
148            cell = Some(false);
149        } else if !attrs.is_empty() {
150            return Err(invalid("候选容器不允许属性"));
151        }
152        // 原实现按 <td/<th 字面前缀计数,包含 thead;保留其选优语义。
153        if name == "thead" {
154            result.cells += 1;
155        }
156        if result.cells > rapiddoc_core::resources::limit(100_000) {
157            return Err(limited("候选 HTML 单元格超过预算"));
158        }
159        if name == "table" {
160            tables += 1;
161        }
162        stack.push(name.to_owned());
163    }
164    if !stack.is_empty() || cell.is_some() {
165        return Err(invalid("候选 HTML 结构未闭合"));
166    }
167    Ok(result)
168}
169
170/// 所有文本为未转义 OCR;内部采用与输出器一致的转义再执行旧规则的字面子串命中。
171pub fn select(texts: &[String], wired_html: &str, wireless_html: &str) -> Result<Selection, Error> {
172    if texts.len() > rapiddoc_core::resources::limit(1000)
173        || texts.iter().map(String::len).sum::<usize>() > rapiddoc_core::resources::limit(1_000_000)
174        || wired_html.len().saturating_add(wireless_html.len())
175            > rapiddoc_core::resources::limit(16_000_000)
176    {
177        return Err(limited("表格选优输入超过预算"));
178    }
179    let mut wired = metrics(wired_html)?;
180    let mut wireless = metrics(wireless_html)?;
181    let mut remaining = rapiddoc_core::resources::limit(50_000_000usize);
182    for text in texts {
183        let text = escape_html(text);
184        for (html, m) in [(wired_html, &mut wired), (wireless_html, &mut wireless)] {
185            if !text.is_empty() && text.len() <= html.len() {
186                remaining = remaining
187                    .checked_sub(html.len())
188                    .ok_or_else(|| limited("表格文字命中工作量超过预算"))?;
189            }
190            m.ocr_hits += usize::from(html.contains(&text));
191        }
192    }
193    Ok(select_metrics(wired, wireless))
194}
195
196/// 策略与候选HTML解析分开,供旧版HTML兼容入口复用同一决策顺序。
197pub fn select_metrics(wired: Metrics, wireless: Metrics) -> Selection {
198    let wn = wired.non_blank();
199    let ln = wireless.non_blank();
200    let gap = wireless.cells as i64 - wired.cells as i64;
201    let scale = (wn as f64).sqrt().round_ties_even() as usize;
202    let (route, reason) = if wired.ocr_hits > wireless.ocr_hits && wn >= ln {
203        ("wired", "wired_text_coverage")
204    } else if ln > wn && ln + 3 >= (wn + scale * 2).max(scale * (scale + 2)) {
205        ("wireless", "wireless_scale")
206    } else if (0..=5).contains(&gap)
207        && wired.cells <= (wireless.cells as f64 * 0.75).round_ties_even() as usize
208    {
209        ("wireless", "wireless_cell_gap")
210    } else if gap == 0 && wired.cells <= 4 && wireless.ocr_hits >= wired.ocr_hits {
211        ("wireless", "wireless_small_equal")
212    } else if wired.ocr_hits as f64 <= wireless.ocr_hits as f64 * 0.6 && wireless.ocr_hits >= 10 {
213        ("wireless", "wireless_text_coverage")
214    } else {
215        ("wired", "wired_default")
216    };
217    Selection {
218        route,
219        reason,
220        wired,
221        wireless,
222    }
223}