Skip to main content

rapiddoc_native/script_geometry/
refine.rs

1//! 移植inline/scripts.py的token精炼与公式区域分段;不调用Python。
2use 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}
872/// Full-page evidence uses the caller's existing 20,000-character page contract.
873/// Line diagnostics retain their original 1,500-character limit.
874pub 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}
1009/// 旧paired_script_roles,始终以真实基础几何分类作输入,不注入预分类角色。
1010pub 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}
1056/// Internal page loop: read full maps without cloning their unrelated entries.
1057/// The JSON entry above retains its complete caller-supplied options.
1058pub 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}