1use super::{invalid, limit, number, scalar};
3use crate::{
4 median, python_printable,
5 text_boundary::{python_alnum, python_alpha, python_digit, python_space},
6};
7use rapiddoc_core::Error;
8use serde_json::{json, Value};
9use std::{
10 cell::Cell,
11 collections::{BTreeSet, HashMap, HashSet},
12};
13type Box4 = [f64; 4];
14const BODY: u8 = 0;
15const SUP: u8 = 1;
16const SUB: u8 = 2;
17fn connector(s: &str) -> bool {
18 matches!(s, "^" | "_" | "ˆ" | "~")
19}
20fn sign(s: &str) -> bool {
21 matches!(s, "+" | "-" | "−" | "–" | "⁻" | "⁺")
22}
23fn operator(s: &str) -> bool {
24 matches!(s, "+" | "-" | "−" | "–")
25}
26fn joiner(s: &str) -> bool {
27 matches!(s, "-" | "−" | "–")
28}
29fn author(s: &str) -> bool {
30 matches!(s, "*" | "†" | "‡" | "∗")
31}
32fn integer(v: &Value) -> Option<i64> {
33 v.as_i64().or_else(|| v.as_bool().map(i64::from))
34}
35fn raw_box(v: &Value) -> Option<Box4> {
36 let a = v.as_array()?;
37 if a.len() != 4 {
38 return None;
39 }
40 Some([
41 number(&a[0])?,
42 number(&a[1])?,
43 number(&a[2])?,
44 number(&a[3])?,
45 ])
46}
47fn coerce_box(v: &Value) -> Option<Box4> {
48 let b = raw_box(v)?;
49 let out = [
50 b[0].min(b[2]),
51 b[1].min(b[3]),
52 b[0].max(b[2]),
53 b[1].max(b[3]),
54 ];
55 (out[2] > out[0] && out[3] > out[1]).then_some(out)
56}
57fn point(v: &Value) -> Option<[f64; 2]> {
58 let a = v.as_array()?;
59 Some([number(a.first()?)?, number(a.get(1)?)?])
60}
61pub fn math_identifier(s: &str) -> bool {
62 let mut chars = s.chars();
63 let Some(c) = chars.next() else {
64 return false;
65 };
66 if chars.next().is_some() {
67 return false;
68 }
69 let n = c as u32;
70 let r = super::math_identifiers::RANGES;
71 let i = r.partition_point(|r| r.0 <= n);
72 i > 0 && n <= r[i - 1].1
73}
74fn all(s: &str, f: fn(char) -> bool) -> bool {
75 !s.is_empty() && s.chars().all(f)
76}
77#[derive(Clone)]
78struct Char {
79 text: String,
80 index: Option<i64>,
81 tight: Option<Box4>,
82 origin: Option<[f64; 2]>,
83 id: bool,
84 space: bool,
85 alpha: bool,
86 digit: bool,
87 decimal: bool,
88 alnum: bool,
89 printable: bool,
90}
91impl Char {
92 fn height(&self) -> f64 {
93 self.tight.map_or(0., |b| (b[3] - b[1]).max(0.))
94 }
95 fn y(&self) -> Option<f64> {
96 self.origin.map(|p| p[1])
97 }
98}
99struct Context {
100 data: Vec<Char>,
101 work: Cell<usize>,
102}
103impl Context {
104 fn new(v: &Value) -> Result<Self, Error> {
105 Self::bounded(v, 1500)
106 }
107 fn bounded(v: &Value, max_chars: usize) -> Result<Self, Error> {
108 let rows = v["chars"].as_array().ok_or_else(invalid)?;
109 Self::rows(rows, v.get("tight_bboxes"), v.get("origins"), max_chars)
110 }
111 fn rows(
112 rows: &[Value],
113 tight: Option<&Value>,
114 origins: Option<&Value>,
115 max_chars: usize,
116 ) -> Result<Self, Error> {
117 if rows.len() > rapiddoc_core::resources::limit(max_chars) {
118 return Err(limit());
119 }
120 for map in [tight, origins] {
121 if let Some(map) = map {
122 if !map.is_object() {
123 return Err(invalid());
124 }
125 if map.as_object().unwrap().len() > rapiddoc_core::resources::limit(20000) {
126 return Err(limit());
127 }
128 }
129 }
130 let mut data = Vec::with_capacity(rows.len());
131 let mut bytes = 0;
132 for c in rows {
133 if !c.is_object() {
134 return Err(invalid());
135 }
136 let text = scalar(c.get("char"))?;
137 bytes += text.len();
138 if text.len() > rapiddoc_core::resources::limit(256)
139 || bytes > rapiddoc_core::resources::limit(1_000_000)
140 {
141 return Err(limit());
142 }
143 let index = integer(&c["char_idx"]);
144 let key = index.map(|i| i.to_string());
145 let tight = key
146 .as_ref()
147 .and_then(|k| tight.and_then(|map| raw_box(&map[k])));
148 let origin = key
149 .as_ref()
150 .and_then(|k| origins.and_then(|map| point(&map[k])));
151 if tight
152 .iter()
153 .flatten()
154 .chain(origin.iter().flatten())
155 .any(|n| n.abs() > 1e9)
156 {
157 return Err(limit());
158 }
159 data.push(Char {
160 id: math_identifier(&text),
161 space: all(&text, python_space),
162 alpha: all(&text, python_alpha),
163 digit: all(&text, python_digit),
164 decimal: !text.is_empty()
165 && text.chars().all(|c| crate::python_decimal(c).is_some()),
166 alnum: all(&text, python_alnum),
167 printable: text.chars().all(python_printable),
168 text,
169 index,
170 tight,
171 origin,
172 });
173 }
174 Ok(Self {
175 data,
176 work: Cell::new(0),
177 })
178 }
179 fn spend(&self, n: usize) -> bool {
180 let total = self.work.get().saturating_add(n);
181 self.work.set(total);
182 total <= rapiddoc_core::resources::limit(10_000_000)
183 }
184 fn finish(&self) -> Result<(), Error> {
185 if self.work.get() > rapiddoc_core::resources::limit(10_000_000) {
186 Err(limit())
187 } else {
188 Ok(())
189 }
190 }
191 fn aligned(&self, a: usize, b: usize) -> bool {
192 if !self.spend(1) {
193 return false;
194 }
195 let (a, b) = (&self.data[a], &self.data[b]);
196 let (Some(ay), Some(by), Some(ab), Some(bb)) = (a.y(), b.y(), a.tight, b.tight) else {
197 return false;
198 };
199 let scale = a.height().max(b.height()).max(1.);
200 (ay - by).abs() <= 0.35f64.max(0.06 * scale)
201 && ((ab[1] + ab[3]) / 2. - (bb[1] + bb[3]) / 2.).abs() <= 0.75f64.max(0.3 * scale)
202 }
203 fn next(&self, i: usize, d: i32) -> Option<usize> {
204 let mut j = i as i32 + d;
205 while j >= 0 && (j as usize) < self.data.len() {
206 if !self.data[j as usize].space {
207 return Some(j as usize);
208 }
209 j += d;
210 }
211 None
212 }
213 fn adjacent_base(&self, i: usize, roles: &[u8]) -> bool {
214 if !self.spend(1) || i == 0 || roles[i - 1] != BODY || !self.data[i - 1].id {
215 return false;
216 }
217 let (a, b) = (&self.data[i - 1], &self.data[i]);
218 let (Some(ay), Some(by), Some(ab), Some(bb)) = (a.y(), b.y(), a.tight, b.tight) else {
219 return false;
220 };
221 (overlap(ab, bb, 1) > 0. || gap(ab, bb) <= 2f64.max(0.5 * a.height()))
222 && (by - ay).abs() >= 0.35f64.max(0.08 * a.height())
223 }
224 fn tokens(&self) -> Vec<Vec<usize>> {
225 let mut out = Vec::new();
226 let mut token = Vec::new();
227 for (i, c) in self.data.iter().enumerate() {
228 if c.id || connector(&c.text) {
229 token.push(i);
230 } else if !token.is_empty() {
231 out.push(std::mem::take(&mut token));
232 }
233 }
234 if !token.is_empty() {
235 out.push(token);
236 }
237 out
238 }
239 fn citations(&self, roles: &[u8]) -> HashSet<usize> {
240 let mut out = HashSet::new();
241 for (start, c) in self.data.iter().enumerate() {
242 let endchar = match c.text.as_str() {
243 "[" => "]",
244 "[" => "]",
245 _ => continue,
246 };
247 for end in start + 1..self.data.len().min(start + 16) {
248 if self.data[end].text != endchar {
249 continue;
250 }
251 if (start + 1..end).any(|i| roles[i] != BODY && self.data[i].alnum) {
252 out.extend(start..=end);
253 }
254 break;
255 }
256 }
257 out
258 }
259 fn split(&self, token: &[usize], roles: &[u8]) -> Option<usize> {
260 let ids: Vec<_> = token.iter().copied().filter(|i| self.data[*i].id).collect();
261 if ids.len() < 2 {
262 return None;
263 }
264 let first = ids[0];
265 let tolerance = 0.35f64.max(0.06 * self.data[first].height());
266 if token
267 .iter()
268 .any(|&i| i < first && connector(&self.data[i].text))
269 {
270 return Some(ids[1]);
271 }
272 let scripted: Vec<_> = ids[1..]
273 .iter()
274 .copied()
275 .filter(|&i| roles[i] != BODY)
276 .collect();
277 if roles[first] == BODY && !scripted.is_empty() {
278 let p = scripted[0];
279 let prefix: Vec<_> = token.iter().copied().filter(|&i| i < p).collect();
280 if prefix.len() >= 2
281 && prefix
282 .iter()
283 .all(|&i| roles[i] == BODY && self.data[i].alpha)
284 && token
285 .iter()
286 .filter(|&&i| i >= p)
287 .all(|&i| roles[i] == SUP && self.data[i].digit)
288 {
289 return Some(p);
290 }
291 }
292 for &p in &ids[1..] {
293 self.spend(p - first);
294 if (first + 1..p).any(|i| connector(&self.data[i].text)) {
295 return Some(p);
296 }
297 if let (Some(a), Some(b)) = (self.data[first].y(), self.data[p].y()) {
298 if (a - b).abs() > tolerance {
299 return Some(p);
300 }
301 }
302 }
303 if roles[first] == BODY {
304 scripted.first().copied()
305 } else {
306 None
307 }
308 }
309 fn close_operators(&self, roles: &mut [u8]) {
310 let snapshot = roles.to_vec();
311 for (seed, &role) in snapshot.iter().enumerate() {
312 if role == BODY || !self.data[seed].id {
313 continue;
314 }
315 let Some(op) = self.next(seed, 1) else {
316 continue;
317 };
318 if op - seed > 3 || !operator(&self.data[op].text) {
319 continue;
320 }
321 let Some(target) = self.next(op, 1) else {
322 continue;
323 };
324 if target - op > 3
325 || !self.data[target].id
326 || !self.aligned(seed, op)
327 || !self.aligned(seed, target)
328 {
329 continue;
330 }
331 roles[op] = role;
332 roles[target] = role;
333 }
334 }
335 fn close_suffixes(&self, raw: &[u8], roles: &mut [u8]) {
336 if roles.len() < 3 {
337 return;
338 }
339 for join in 1..roles.len() - 1 {
340 if !joiner(&self.data[join].text) {
341 continue;
342 }
343 let left = join - 1;
344 let role = roles[left];
345 if role == BODY || !self.data[left].id {
346 continue;
347 }
348 let mut start = left;
349 while start > 0 && roles[start - 1] == role && self.data[start - 1].id {
350 start -= 1;
351 }
352 if left - start + 1 < 2
353 || start == 0
354 || roles[start - 1] != BODY
355 || !self.data[start - 1].id
356 {
357 continue;
358 }
359 let mut end = join + 1;
360 while end < roles.len() && self.data[end].id {
361 end += 1;
362 }
363 if end - (join + 1) < 2
364 || (join..end).any(|i| raw[i] != role)
365 || !self.aligned(left, join)
366 || (join + 1..end).any(|i| !self.aligned(left, i))
367 {
368 continue;
369 }
370 roles[join..end].fill(role);
371 }
372 }
373 fn protected_subs(&self, roles: &[u8], tokens: &[Vec<usize>]) -> BTreeSet<usize> {
374 let mut out = BTreeSet::new();
375 for token in tokens {
376 for (p, &i) in token.iter().enumerate() {
377 self.spend(p);
378 if roles[i] == SUB
379 && self.data[i].id
380 && (token[..p]
381 .iter()
382 .any(|&j| j < i && roles[j] == BODY && self.data[j].id)
383 || self.adjacent_base(i, roles))
384 {
385 out.insert(i);
386 }
387 }
388 }
389 let mut changed = true;
390 while changed {
391 changed = false;
392 for (i, &role) in roles.iter().enumerate() {
393 if role != SUB || out.contains(&i) || !self.data[i].id {
394 continue;
395 }
396 let seeds: Vec<_> = out
397 .range(i.saturating_sub(5)..=i.saturating_add(5))
398 .copied()
399 .collect();
400 for seed in seeds {
401 if !self.aligned(seed, i) {
402 continue;
403 }
404 let (a, b) = (seed.min(i), seed.max(i));
405 if (a + 1..b).all(|j| {
406 let c = &self.data[j];
407 c.space || connector(&c.text) || sign(&c.text) || c.text == "."
408 }) {
409 out.insert(i);
410 changed = true;
411 break;
412 }
413 }
414 }
415 if self.work.get() > rapiddoc_core::resources::limit(10_000_000) {
416 break;
417 }
418 }
419 out
420 }
421}
422fn gap(a: Box4, b: Box4) -> f64 {
423 0f64.max(a[0] - b[2]).max(b[0] - a[2])
424}
425fn overlap(a: Box4, b: Box4, axis: usize) -> f64 {
426 0f64.max(a[axis + 2].min(b[axis + 2]) - a[axis].max(b[axis]))
427}
428fn parse_roles(v: &Value, n: usize) -> Result<Vec<u8>, Error> {
429 let a = v.as_array().filter(|a| a.len() == n).ok_or_else(invalid)?;
430 a.iter()
431 .map(|r| match r.as_str() {
432 Some("body") => Ok(BODY),
433 Some("sup") => Ok(SUP),
434 Some("sub") => Ok(SUB),
435 _ => Err(invalid()),
436 })
437 .collect()
438}
439fn names(roles: &[u8]) -> Value {
440 json!(roles
441 .iter()
442 .map(|&r| ["body", "sup", "sub"][r as usize])
443 .collect::<Vec<_>>())
444}
445fn boolean(v: &Value, key: &str, default: bool) -> Result<bool, Error> {
446 match v.get(key) {
447 None => Ok(default),
448 Some(b) => b.as_bool().ok_or_else(invalid),
449 }
450}
451fn refine_roles(
452 ctx: &Context,
453 v: &Value,
454 raw: &[u8],
455 formula: bool,
456 ordinary: bool,
457) -> Result<Vec<u8>, Error> {
458 let d = &ctx.data;
459 let mut refined = raw.to_vec();
460 if ordinary && refined.iter().all(|&r| r == BODY) {
461 return Ok(refined);
462 }
463 let citations = ctx.citations(&refined);
464 let mut numeric = HashSet::new();
465 if raw.iter().zip(d).any(|(&r, c)| r == SUP && c.decimal) {
466 let mut request = v.clone();
467 request["roles"] = names(raw);
468 request["protected_body_indices"] = json!([]);
469 let indices = super::numeric_indices(&request)?;
470 for i in indices.as_array().unwrap() {
471 let i = i.as_u64().unwrap() as usize;
472 if raw[i] == SUP {
473 numeric.insert(i);
474 }
475 }
476 }
477 let tokens = ctx.tokens();
478 let splits: Vec<_> = tokens.iter().map(|t| ctx.split(t, &refined)).collect();
479 let ids: Vec<Vec<usize>> = tokens
480 .iter()
481 .map(|t| t.iter().copied().filter(|&i| d[i].id).collect())
482 .collect();
483 let mut families: HashMap<&str, Vec<usize>> = HashMap::new();
484 for (t, alnum) in ids.iter().enumerate() {
485 if alnum.len() >= 2 {
486 families.entry(&d[alnum[0]].text).or_default().push(t);
487 }
488 }
489 let trusted: HashSet<_> = families
490 .into_iter()
491 .filter_map(|(base, group)| {
492 (group.len() >= 3
493 || group.iter().any(|&i| splits[i].is_some())
494 || group
495 .iter()
496 .any(|&i| tokens[i].iter().any(|&j| connector(&d[j].text))))
497 .then_some(base)
498 })
499 .collect();
500 let mut complex = false;
501 for (t, token) in tokens.iter().enumerate() {
502 if token.iter().any(|i| citations.contains(i)) || token.iter().all(|i| numeric.contains(i))
503 {
504 continue;
505 }
506 let alnum = &ids[t];
507 if alnum.is_empty() || token.iter().all(|&i| refined[i] == BODY) {
508 continue;
509 }
510 if token.iter().any(|&i| refined[i] == SUP) && token.iter().any(|&i| refined[i] == SUB) {
511 complex = true;
512 }
513 if alnum.len() == 1 {
514 continue;
515 }
516 let first = alnum[0];
517 if refined[first] == SUP
518 && alnum[1..].len() >= 2
519 && alnum[1..].iter().all(|&i| refined[i] == BODY && d[i].alpha)
520 {
521 continue;
522 }
523 let split =
524 splits[t].or_else(|| trusted.contains(d[first].text.as_str()).then_some(alnum[1]));
525 let Some(split) = split else {
526 if alnum.iter().all(|&i| refined[i] != BODY) {
527 for &i in token {
528 refined[i] = BODY;
529 }
530 }
531 continue;
532 };
533 let origins: Vec<_> = alnum
534 .iter()
535 .filter(|&&i| i < split)
536 .filter_map(|&i| d[i].y())
537 .collect();
538 let heights: Vec<_> = alnum
539 .iter()
540 .filter(|&&i| i < split)
541 .map(|&i| d[i].height())
542 .filter(|&h| h > 0.)
543 .collect();
544 let origin = (!origins.is_empty()).then(|| median(origins));
545 let height = if heights.is_empty() {
546 0.
547 } else {
548 median(heights)
549 };
550 for &i in token {
551 if i < split || connector(&d[i].text) {
552 refined[i] = BODY;
553 continue;
554 }
555 if !d[i].id || refined[i] != BODY {
556 continue;
557 }
558 if let (Some(base), Some(y)) = (origin, d[i].y()) {
559 let shift = y - base;
560 if shift.abs() >= 0.35f64.max(0.08 * height) {
561 refined[i] = if shift > 0. { SUB } else { SUP };
562 }
563 }
564 }
565 }
566 ctx.close_operators(&mut refined);
567 let clear = |roles: &mut [u8]| {
568 for (i, r) in roles.iter_mut().enumerate() {
569 if !citations.contains(&i) {
570 *r = BODY;
571 }
572 }
573 };
574 if complex && !formula {
575 clear(&mut refined);
576 }
577 let scripted: Vec<_> = refined
578 .iter()
579 .enumerate()
580 .filter_map(|(i, &r)| (r != BODY && d[i].alnum).then_some(i))
581 .collect();
582 if !formula && scripted.len() >= 2 && d.iter().any(|c| matches!(c.text.as_str(), "∑" | "∫"))
583 {
584 clear(&mut refined);
585 }
586 let multiply = d.iter().enumerate().any(|(i, c)| {
587 c.text == "×" && i > 0 && i + 1 < d.len() && !d[i - 1].space && !d[i + 1].space
588 });
589 if !formula && scripted.len() >= 2 && multiply {
590 clear(&mut refined);
591 }
592 if !formula {
593 for (i, c) in d.iter().enumerate() {
594 if !matches!(c.text.as_str(), "/" | "⁄") {
595 continue;
596 }
597 let nearby: Vec<_> = scripted
598 .iter()
599 .copied()
600 .filter(|&j| i.abs_diff(j) <= 5)
601 .collect();
602 if nearby.iter().any(|&j| j < i) && nearby.iter().any(|&j| j > i) {
603 for j in nearby {
604 if !citations.contains(&j) {
605 refined[j] = BODY;
606 }
607 }
608 }
609 }
610 }
611 let protected = ctx.protected_subs(&refined, &tokens);
612 for (i, &role) in refined.clone().iter().enumerate() {
613 if role == SUB && !citations.contains(&i) && !protected.contains(&i) && d[i].id {
614 refined[i] = BODY;
615 }
616 }
617 for (i, &role) in refined.clone().iter().enumerate() {
618 if role == BODY || citations.contains(&i) {
619 continue;
620 }
621 let s = d[i].text.as_str();
622 if d[i].alnum || author(s) {
623 continue;
624 }
625 if matches!(s, "," | ",")
626 && i > 0
627 && i + 1 < d.len()
628 && [i - 1, i + 1]
629 .iter()
630 .all(|&j| refined[j] == role && (d[j].alnum || author(&d[j].text)))
631 {
632 continue;
633 }
634 if s == "."
635 && i > 0
636 && i + 1 < d.len()
637 && [i - 1, i + 1]
638 .iter()
639 .all(|&j| refined[j] == role && d[j].digit)
640 {
641 continue;
642 }
643 if sign(s)
644 && [-1, 1]
645 .iter()
646 .filter_map(|&step| ctx.next(i, step))
647 .any(|j| j.abs_diff(i) <= 3 && refined[j] == role && d[j].digit)
648 {
649 continue;
650 }
651 if matches!(s, ")" | ")") && role == SUP {
652 if let Some(p) = ctx.next(i, -1) {
653 if i - p == 1
654 && p > 0
655 && refined[p - 1] == BODY
656 && d[p - 1].alpha
657 && refined[p] == role
658 && raw[i] == role
659 && d[p].alpha
660 && ctx.aligned(p, i)
661 {
662 continue;
663 }
664 }
665 }
666 refined[i] = BODY;
667 }
668 if !formula {
669 ctx.close_suffixes(raw, &mut refined);
670 }
671 ctx.finish()?;
672 Ok(refined)
673}
674pub fn refine(v: &Value) -> Result<Value, Error> {
675 let ctx = Context::new(v)?;
676 let raw = parse_roles(&v["roles"], ctx.data.len())?;
677 Ok(names(&refine_roles(
678 &ctx,
679 v,
680 &raw,
681 boolean(v, "formula_region", false)?,
682 boolean(v, "ordinary_native_roles", false)?,
683 )?))
684}
685
686fn drop_cap(ctx: &Context) -> Vec<usize> {
687 let d = &ctx.data;
688 let word: Vec<_> = d
689 .iter()
690 .enumerate()
691 .take_while(|(_, c)| c.text.is_ascii() && c.alpha)
692 .map(|(i, _)| i)
693 .collect();
694 if !(3..=12).contains(&word.len())
695 || !d[0].text.bytes().all(|b| b.is_ascii_uppercase())
696 || word[1..]
697 .iter()
698 .any(|&i| !d[i].text.bytes().all(|b| b.is_ascii_lowercase()))
699 {
700 return Vec::new();
701 }
702 if word
703 .iter()
704 .any(|&i| d[i].tight.is_none() || d[i].origin.is_none())
705 {
706 return Vec::new();
707 }
708 let cap = d[0].tight.unwrap();
709 let h = median(
710 word[1..]
711 .iter()
712 .map(|&i| {
713 let b = d[i].tight.unwrap();
714 b[3] - b[1]
715 })
716 .collect(),
717 );
718 let ch = cap[3] - cap[1];
719 let base = median(word[1..].iter().map(|&i| d[i].y().unwrap()).collect());
720 if h <= 0.
721 || ch < 3. * h
722 || base < cap[1] + 0.25 * ch
723 || base > cap[1] + 0.7 * ch
724 || d[0].y().unwrap() - base < 1.2 * h
725 || !(-0.1 * h..=1.5 * h).contains(&(d[1].tight.unwrap()[0] - cap[2]))
726 || word[1..].iter().any(|&i| {
727 let b = d[i].tight.unwrap();
728 (d[i].y().unwrap() - base).abs() > 0.15 * h
729 || b[3] - b[1] < 0.5 * h
730 || b[3] - b[1] > 1.5 * h
731 || b[0] < cap[2] - 0.1 * h
732 })
733 {
734 Vec::new()
735 } else {
736 word
737 }
738}
739pub fn classify_runs(v: &Value) -> Result<Value, Error> {
740 let ctx = Context::new(v)?;
741 let n = ctx.data.len();
742 let rows = v["chars"].as_array().unwrap();
743 let memberships: Vec<Option<i64>> = match v.get("memberships") {
744 None => vec![None; n],
745 Some(a) => a
746 .as_array()
747 .filter(|a| a.len() == n)
748 .ok_or_else(invalid)?
749 .iter()
750 .map(|m| {
751 if m.is_null() {
752 Ok(None)
753 } else {
754 integer(m).map(Some).ok_or_else(invalid)
755 }
756 })
757 .collect::<Result<_, _>>()?,
758 };
759 let (mut roles, mut counts, mut flags) = (vec![BODY; n], vec![0usize; n], vec![false; n]);
760 let mut start = 0;
761 while start < n {
762 let mut end = start + 1;
763 while end < n && memberships[end] == memberships[start] {
764 end += 1;
765 }
766 let mut request = v.clone();
767 request["chars"] = json!(&rows[start..end]);
768 request["protected_body_indices"] = json!([]);
769 let raw = super::classify(&request)?;
770 let raw = parse_roles(&raw, end - start)?;
771 let local = Context::new(&request)?;
772 let mut refined =
773 refine_roles(&local, &request, &raw, memberships[start].is_some(), false)?;
774 if memberships[start].is_none() && refined.iter().any(|&r| r != BODY) {
775 for i in drop_cap(&local) {
776 refined[i] = BODY;
777 }
778 }
779 let visible: Vec<_> = local
780 .data
781 .iter()
782 .enumerate()
783 .filter_map(|(i, c)| (c.printable && !c.space).then_some(i))
784 .collect();
785 let count = visible
786 .iter()
787 .filter(|&&i| refined[i] == BODY && local.data[i].alnum)
788 .count();
789 let marked = visible.iter().filter(|&&i| refined[i] != BODY).count();
790 let body_heights: Vec<_> = visible
791 .iter()
792 .filter(|&&i| refined[i] == BODY)
793 .filter_map(|&i| local.data[i].tight.map(|b| b[3] - b[1]))
794 .collect();
795 let script_heights: Vec<_> = visible
796 .iter()
797 .filter(|&&i| refined[i] != BODY)
798 .filter_map(|&i| local.data[i].tight.map(|b| b[3] - b[1]))
799 .collect();
800 let stable = count > 0
801 && !body_heights.is_empty()
802 && (script_heights.is_empty()
803 || body_heights
804 .iter()
805 .copied()
806 .fold(f64::NEG_INFINITY, f64::max)
807 >= 1.1
808 * script_heights
809 .iter()
810 .copied()
811 .fold(f64::NEG_INFINITY, f64::max));
812 if memberships[start].is_some() && (!stable || marked >= visible.len()) {
813 refined.fill(BODY);
814 }
815 roles[start..end].copy_from_slice(&refined);
816 counts[start..end].fill(count);
817 flags[start..end].fill(memberships[start].is_some());
818 ctx.spend(local.work.get());
819 ctx.finish()?;
820 start = end;
821 }
822 Ok(json!({"roles":names(&roles),"body_counts":counts,"formula_flags":flags}))
823}
824
825fn page(v: &Value) -> Result<[f64; 2], Error> {
826 page_value(&v["page_size"])
827}
828fn page_value(v: &Value) -> Result<[f64; 2], Error> {
829 let a = v.as_array().filter(|a| a.len() == 2).ok_or_else(invalid)?;
830 let p = [
831 number(&a[0]).ok_or_else(invalid)?,
832 number(&a[1]).ok_or_else(invalid)?,
833 ];
834 if p.iter().any(|&n| n <= 0. || n > 1e9) {
835 return Err(limit());
836 }
837 Ok(p)
838}
839fn angle(v: &Value) -> Result<i64, Error> {
840 if v.is_null() {
841 return Ok(0);
842 }
843 let n = integer(v)
844 .or_else(|| {
845 v.as_f64()
846 .filter(|n| n.is_finite() && n.abs() <= 1e12)
847 .map(|n| n.trunc() as i64)
848 })
849 .or_else(|| v.as_str().and_then(|s| s.trim().parse().ok()))
850 .ok_or_else(invalid)?;
851 Ok(n.rem_euclid(360))
852}
853fn rotate(b: Box4, p: [f64; 2], angle: i64) -> Box4 {
854 match angle {
855 90 => [b[1], p[0] - b[2], b[3], p[0] - b[0]],
856 180 => [p[0] - b[2], p[1] - b[3], p[0] - b[0], p[1] - b[1]],
857 270 => [p[1] - b[3], b[0], p[1] - b[1], b[2]],
858 _ => b,
859 }
860}
861fn rotate_point(b: [f64; 2], p: [f64; 2], angle: i64) -> [f64; 2] {
862 match angle {
863 90 => [b[1], p[0] - b[0]],
864 180 => [p[0] - b[0], p[1] - b[1]],
865 270 => [p[1] - b[1], b[0]],
866 _ => b,
867 }
868}
869pub fn fraction_members(v: &Value) -> Result<Value, Error> {
870 fraction_members_bounded(v, 1500)
871}
872pub fn fraction_members_page(v: &Value) -> Result<Value, Error> {
875 fraction_members_bounded(v, 20000)
876}
877fn fraction_members_bounded(v: &Value, max_chars: usize) -> Result<Value, Error> {
878 let ctx = Context::bounded(v, max_chars)?;
879 let rules = v["drawing_lines"]
880 .as_array()
881 .filter(|r| r.len() <= rapiddoc_core::resources::limit(5000))
882 .ok_or_else(limit)?;
883 if rules.is_empty() || ctx.data.is_empty() {
884 return Ok(json!([]));
885 }
886 let p = page(v)?;
887 let a = angle(&v["angle"])?;
888 let mut ids = Vec::new();
889 let mut rows = Vec::new();
890 let mut heights = Vec::new();
891 for c in &ctx.data {
892 if let (Some(index), Some(b)) = (c.index, c.tight) {
893 if c.printable && !c.space {
894 let b = rotate(b, p, a);
895 ids.push(index);
896 rows.push((b, c.alnum));
897 if b[3] - b[1] > 0. {
898 heights.push(b[3] - b[1]);
899 }
900 }
901 }
902 }
903 let scale = if rows.is_empty() {
904 8.
905 } else if heights.is_empty() {
906 return Err(invalid());
907 } else {
908 median(heights)
909 };
910 if rows.len().saturating_mul(rules.len()) > rapiddoc_core::resources::limit(10_000_000) {
911 return Err(limit());
912 }
913 let prepared = match v.get("prepared_rules") {
914 None | Some(Value::Null) => None,
915 Some(a) => Some(
916 a.as_array()
917 .filter(|a| a.len() == rules.len())
918 .ok_or_else(invalid)?,
919 ),
920 };
921 let mut boxes = Vec::new();
922 for (i, r) in rules.iter().enumerate() {
923 let b = if let Some(prepared) = prepared {
924 if prepared[i].is_null() {
925 None
926 } else {
927 Some(raw_box(&prepared[i]).ok_or_else(invalid)?)
928 }
929 } else {
930 coerce_box(r.get("bbox").unwrap_or(r)).map(|b| rotate(b, p, a))
931 };
932 if let Some(b) = b {
933 if b.iter().any(|n| n.abs() > 1e9) {
934 return Err(limit());
935 }
936 boxes.push(b);
937 }
938 }
939 let positions = docvortex_core::rule_text::fraction_members(&rows, &boxes, scale);
940 let members: BTreeSet<_> = positions.into_iter().map(|i| ids[i]).collect();
941 Ok(json!(members))
942}
943fn paired_geometry(ctx: &Context, v: &Value, roles: &[u8]) -> BTreeSet<(usize, u8)> {
944 let d = &ctx.data;
945 let rows = v["chars"].as_array().unwrap();
946 let valid = |i: usize| {
947 super::source(&rows[i]).is_some()
948 && !d[i].space
949 && !matches!(d[i].text.as_str(), "\r" | "\n")
950 && d[i].tight.is_some_and(|b| b[2] > b[0] && b[3] > b[1])
951 && d[i].origin.is_some()
952 };
953 let visible: Vec<_> = d
954 .iter()
955 .enumerate()
956 .filter_map(|(i, c)| (c.printable && !c.space).then_some(i))
957 .collect();
958 let mut out = BTreeSet::new();
959 for (pos, t) in visible.windows(3).enumerate() {
960 let (base, first, second) = (t[0], t[1], t[2]);
961 if ![base, first, second]
962 .iter()
963 .all(|&i| valid(i) && d[i].alnum)
964 || roles[base] != BODY
965 || !((roles[first] == SUP && roles[second] == SUB)
966 || (roles[first] == SUB && roles[second] == SUP))
967 {
968 continue;
969 }
970 let b = d[base].tight.unwrap();
971 let a = d[first].tight.unwrap();
972 let c = d[second].tight.unwrap();
973 let h = d[base].height();
974 if h <= 0.
975 || [first, second].iter().any(|&i| d[i].height() > 0.8 * h)
976 || a[2].min(c[2]) - a[0].max(c[0]) < 0.5 * (a[2] - a[0]).min(c[2] - c[0])
977 {
978 continue;
979 }
980 if [first, second].iter().any(|&i| {
981 let bb = d[i].tight.unwrap();
982 !(-0.15 * h..=0.5 * h).contains(&(bb[0] - b[2]))
983 || (d[i].y().unwrap() - d[base].y().unwrap()).abs() < 0.15 * h
984 || (d[i].y().unwrap() - d[base].y().unwrap()).abs() > h
985 }) {
986 continue;
987 }
988 if (d[first].y().unwrap() - d[base].y().unwrap())
989 * (d[second].y().unwrap() - d[base].y().unwrap())
990 >= 0.
991 {
992 continue;
993 }
994 if let Some(&following) = visible.get(pos + 3) {
995 if valid(following)
996 && d[following].alnum
997 && d[following].height() <= 0.8 * h
998 && (d[following].y().unwrap() - d[second].y().unwrap()).abs() <= 0.15 * h
999 && (-0.15 * h..=0.5 * h).contains(&(d[following].tight.unwrap()[0] - c[2]))
1000 {
1001 continue;
1002 }
1003 }
1004 out.insert((first, roles[first]));
1005 out.insert((second, roles[second]));
1006 }
1007 out
1008}
1009pub fn paired_indices(v: &Value) -> Result<Vec<usize>, Error> {
1011 let ctx = Context::new(v)?;
1012 let raw = super::classify(v)?;
1013 let roles = parse_roles(&raw, ctx.data.len())?;
1014 let result = paired_geometry(&ctx, v, &roles);
1015 ctx.finish()?;
1016 Ok(result.into_iter().map(|(i, _)| i).collect())
1017}
1018fn strong(ctx: &Context, v: &Value, roles: &[u8]) -> BTreeSet<(usize, u8)> {
1019 let d = &ctx.data;
1020 let mut out = paired_geometry(ctx, v, roles);
1021 for i in ctx.citations(roles) {
1022 if roles[i] != BODY {
1023 out.insert((i, roles[i]));
1024 }
1025 }
1026 for (i, &role) in roles.iter().enumerate() {
1027 if role != SUP || !d[i].id {
1028 continue;
1029 }
1030 let h = if i > 0 { d[i - 1].height() } else { 0. };
1031 if h <= 0. || d[i].height() > 0.8 * h {
1032 continue;
1033 }
1034 if ctx
1035 .next(i, 1)
1036 .is_some_and(|j| d[j].id || connector(&d[j].text))
1037 {
1038 continue;
1039 }
1040 if ctx.adjacent_base(i, roles) {
1041 out.insert((i, role));
1042 }
1043 }
1044 out
1045}
1046pub fn classify_line(v: &Value) -> Result<Value, Error> {
1047 classify_line_inner(
1048 &v["line"],
1049 &v["page_size"],
1050 v.get("tight_bboxes"),
1051 v.get("origins"),
1052 v.get("fraction_members"),
1053 Some(v),
1054 )
1055}
1056pub fn classify_line_parts(
1059 line: &Value,
1060 page: &Value,
1061 tight: &Value,
1062 origins: &Value,
1063 fractions: &Value,
1064) -> Result<Value, Error> {
1065 classify_line_inner(
1066 line,
1067 page,
1068 Some(tight),
1069 Some(origins),
1070 Some(fractions),
1071 None,
1072 )
1073}
1074fn classify_line_inner(
1075 line: &Value,
1076 page: &Value,
1077 tight_map: Option<&Value>,
1078 origin_map: Option<&Value>,
1079 fractions: Option<&Value>,
1080 base: Option<&Value>,
1081) -> Result<Value, Error> {
1082 let rows = line["chars"].as_array().ok_or_else(invalid)?;
1083 if rows.len() > rapiddoc_core::resources::limit(1500) {
1084 return Err(limit());
1085 }
1086 let mut chars: Vec<_> = rows.iter().filter(|c| c.is_object()).cloned().collect();
1087 if chars.iter().all(|c| integer(&c["char_idx"]).is_some())
1088 && chars
1089 .windows(2)
1090 .any(|c| integer(&c[0]["char_idx"]) > integer(&c[1]["char_idx"]))
1091 {
1092 chars.sort_by_key(|c| integer(&c["char_idx"]));
1093 }
1094 if chars.is_empty() {
1095 return Ok(json!({"chars":[],"roles":[],"body_counts":[],"formula_flags":[]}));
1096 }
1097 let p = page_value(page)?;
1098 let a = angle(&line["angle"])?;
1099 let original = Context::rows(&chars, tight_map, origin_map, 1500)?;
1100 let mut request = base.cloned().unwrap_or_else(|| json!({}));
1101 let mut local = chars.clone();
1102 let mut tight = serde_json::Map::new();
1103 let mut origins = serde_json::Map::new();
1104 for (i, c) in chars.iter().enumerate() {
1105 if let Some(b) = coerce_box(&c["bbox"]) {
1106 local[i]["bbox"] = json!(rotate(b, p, a));
1107 }
1108 if let Some(index) = integer(&c["char_idx"]) {
1109 if let Some(b) = original.data[i].tight {
1110 tight.insert(index.to_string(), json!(rotate(b, p, a)));
1111 }
1112 if let Some(o) = original.data[i].origin {
1113 origins.insert(index.to_string(), json!(rotate_point(o, p, a)));
1114 }
1115 }
1116 }
1117 let regions = match line.get("inline_math_regions") {
1118 None => Vec::new(),
1119 Some(a) => a
1120 .as_array()
1121 .filter(|a| a.len() <= rapiddoc_core::resources::limit(2000))
1122 .ok_or_else(limit)?
1123 .iter()
1124 .filter_map(coerce_box)
1125 .collect::<Vec<_>>(),
1126 };
1127 let memberships: Vec<_> = original
1128 .data
1129 .iter()
1130 .map(|c| {
1131 c.tight.and_then(|b| {
1132 regions.iter().position(|r| {
1133 r[0] <= (b[0] + b[2]) / 2.
1134 && (b[0] + b[2]) / 2. <= r[2]
1135 && r[1] <= (b[1] + b[3]) / 2.
1136 && (b[1] + b[3]) / 2. <= r[3]
1137 })
1138 })
1139 })
1140 .collect();
1141 request["chars"] = Value::Array(local);
1142 request["tight_bboxes"] = Value::Object(tight);
1143 request["origins"] = Value::Object(origins);
1144 request["memberships"] = json!(memberships);
1145 let mut result = classify_runs(&request)?;
1146 let mut roles = parse_roles(&result["roles"], chars.len())?;
1147 if boolean(line, "compact_formula_cluster", false)?
1148 || (boolean(line, "restored_inline_cluster", false)? && !regions.is_empty())
1149 {
1150 let ctx = Context::new(&request)?;
1151 let raw = super::classify(&request)?;
1152 let raw = parse_roles(&raw, chars.len())?;
1153 roles.fill(BODY);
1154 for (i, r) in strong(&ctx, &request, &raw) {
1155 roles[i] = r;
1156 }
1157 ctx.finish()?;
1158 }
1159 let fraction: BTreeSet<_> = match fractions {
1160 None => BTreeSet::new(),
1161 Some(a) => a
1162 .as_array()
1163 .filter(|a| a.len() <= rapiddoc_core::resources::limit(20000))
1164 .ok_or_else(limit)?
1165 .iter()
1166 .map(|n| integer(n).ok_or_else(invalid))
1167 .collect::<Result<_, _>>()?,
1168 };
1169 for (i, c) in chars.iter().enumerate() {
1170 if integer(&c["char_idx"]).is_some_and(|id| fraction.contains(&id)) {
1171 roles[i] = BODY;
1172 }
1173 }
1174 result["roles"] = names(&roles);
1175 result["chars"] = Value::Array(chars);
1176 Ok(result)
1177}
1178
1179#[cfg(test)]
1180mod tests {
1181 use super::*;
1182 #[test]
1183 fn borrowed_line_maps_keep_roles_and_validation() {
1184 let line = json!({"angle":0,"chars":[
1185 {"char":"x","char_idx":1,"bbox":[0,0,10,10]},
1186 {"char":"2","char_idx":2,"bbox":[10,0,15,5]}
1187 ]});
1188 for angle in [0, 90, 180, 270] {
1189 for compact in [false, true] {
1190 let mut line = line.clone();
1191 line["angle"] = json!(angle);
1192 line["compact_formula_cluster"] = json!(compact);
1193 for tight in [
1194 json!({"1":[0,0,10,10],"2":[10,0,15,5]}),
1195 json!(null),
1196 json!([]),
1197 json!({"1":[0,0,1e10,10]}),
1198 ] {
1199 let q = json!({"line":line,"page_size":[200,300],"tight_bboxes":tight,
1200 "origins":{"1":[0,10],"2":[10,5]},"fraction_members":[]});
1201 let public = classify_line(&q);
1202 let borrowed = classify_line_parts(
1203 &q["line"],
1204 &q["page_size"],
1205 &q["tight_bboxes"],
1206 &q["origins"],
1207 &q["fraction_members"],
1208 );
1209 match (public, borrowed) {
1210 (Ok(a), Ok(b)) => assert_eq!(a, b),
1211 (Err(a), Err(b)) => {
1212 assert_eq!(a.code, b.code);
1213 assert_eq!(a.message, b.message);
1214 }
1215 other => panic!("borrowed/public mismatch: {other:?}"),
1216 }
1217 }
1218 }
1219 }
1220 }
1221 #[test]
1222 fn original_page_fraction_capacity() {
1223 let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
1224 let f: Value = serde_json::from_slice(
1225 &std::fs::read(root.join("build/migration/native-page-fractions.json")).unwrap(),
1226 )
1227 .unwrap();
1228 for c in f["cases"].as_array().unwrap() {
1229 assert!(fraction_members(&c["request"]).is_err());
1230 assert_eq!(
1231 fraction_members_page(&c["request"]).unwrap(),
1232 c["expected"],
1233 "{}",
1234 c["label"]
1235 );
1236 }
1237 }
1238 #[test]
1239 fn old_fraction_members() {
1240 let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1241 .join("../../build/migration/script-fractions.json");
1242 let f: Value = serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap();
1243 for c in f["cases"].as_array().unwrap() {
1244 assert_eq!(
1245 fraction_members(&c["request"]).unwrap(),
1246 c["expected"],
1247 "{}",
1248 c["label"]
1249 );
1250 }
1251 }
1252 #[test]
1253 fn old_fraction_line_pipeline() {
1254 let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1255 .join("../../build/migration/script-fraction-pipeline.json");
1256 let f: Value = serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap();
1257 let mut changed = 0;
1258 for c in f["cases"].as_array().unwrap() {
1259 let members = fraction_members(&c["fraction"]).unwrap();
1260 assert_eq!(members, c["expected_members"], "{}", c["label"]);
1261 assert_eq!(
1262 classify_line(&c["line"]).unwrap(),
1263 c["before"],
1264 "{}",
1265 c["label"]
1266 );
1267 let mut request = c["line"].clone();
1268 request["fraction_members"] = members;
1269 assert_eq!(
1270 classify_line(&request).unwrap(),
1271 c["expected"],
1272 "{}",
1273 c["label"]
1274 );
1275 changed += usize::from(c["before"]["roles"] != c["expected"]["roles"]);
1276 }
1277 assert_eq!(changed, 20);
1278 }
1279 #[test]
1280 fn old_refine_tokens_and_complete_runs() {
1281 let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1282 .join("../../build/migration/script-refinement.json");
1283 let f: Value = serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap();
1284 let ranges: Vec<(u32, u32)> =
1285 serde_json::from_value(f["identifier_ranges"].clone()).unwrap();
1286 assert_eq!(ranges, super::super::math_identifiers::RANGES);
1287 for c in f["refine"].as_array().unwrap() {
1288 assert_eq!(
1289 refine(&c["request"]).unwrap(),
1290 c["expected"],
1291 "{}",
1292 c["label"]
1293 );
1294 }
1295 for c in f["runs"].as_array().unwrap() {
1296 assert_eq!(
1297 classify_runs(&c["request"]).unwrap(),
1298 c["expected"],
1299 "{}",
1300 c["label"]
1301 );
1302 }
1303 for c in f["lines"].as_array().unwrap() {
1304 assert_eq!(
1305 classify_line(&c["request"]).unwrap(),
1306 c["expected"],
1307 "{}",
1308 c["label"]
1309 );
1310 }
1311 }
1312}
1313
1314#[cfg(test)]
1315mod production_capacity_regression {
1316 use super::*;
1317 #[test]
1318 fn dense_page_context_keeps_all_records_in_production() {
1319 let text = "a".repeat(257);
1320 let rows = vec![json!({"char":text,"bbox":[0,0,1,1]}); 20001];
1321 assert!(Context::rows(&rows, None, None, 20000).is_err());
1322 {
1323 let _scope = rapiddoc_core::resources::python_compatible();
1324 let context = Context::rows(&rows, None, None, 20000).unwrap();
1325 assert_eq!(context.data.len(), rows.len());
1326 assert_eq!(context.data[0].text.len(), 257);
1327 }
1328 assert!(Context::rows(&rows, None, None, 20000).is_err());
1329 }
1330}