rapiddoc_render/
quality.rs1use 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
77pub 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 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
170pub 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
196pub 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}