use crate::error::FocrResult;
pub const EOS_MARKER: &str = "<|end▁of▁sentence|>";
pub const PAGE_MARKER: &str = "<PAGE>";
#[derive(Debug, Clone, PartialEq)]
pub struct RefMatch {
pub full: String,
pub label: String,
pub boxes: Vec<[i64; 4]>,
}
impl RefMatch {
#[must_use]
pub fn is_image(&self) -> bool {
self.label.trim() == "image" || self.full.contains("<|ref|>image<|/ref|>")
}
#[must_use]
pub fn rescaled_boxes(&self, width: u32, height: u32) -> Vec<[i64; 4]> {
self.boxes
.iter()
.map(|&[x1, y1, x2, y2]| {
[
rescale(x1, width),
rescale(y1, height),
rescale(x2, width),
rescale(y2, height),
]
})
.collect()
}
}
#[must_use]
fn rescale(coord: i64, extent: u32) -> i64 {
(coord as f64 / 999.0 * f64::from(extent)) as i64
}
#[must_use]
pub fn strip_eos(text: &str) -> String {
let body = text.strip_suffix(EOS_MARKER).unwrap_or(text);
body.trim().to_string()
}
#[must_use]
pub fn re_match(text: &str) -> Vec<RefMatch> {
let mut out = Vec::new();
out.extend(scan_ref_spans(text));
out.extend(scan_det_spans(text));
out
}
fn scan_ref_spans(text: &str) -> Vec<RefMatch> {
const REF_OPEN: &str = "<|ref|>";
const REF_CLOSE: &str = "<|/ref|>";
const DET_OPEN: &str = "<|det|>";
const DET_CLOSE: &str = "<|/det|>";
let mut out = Vec::new();
let bytes = text.as_bytes();
let mut i = 0usize;
while let Some(rel) = find_from(bytes, i, REF_OPEN) {
let body_start = rel + REF_OPEN.len();
let Some(ref_close) = find_from(bytes, body_start, REF_CLOSE) else {
break;
};
let label = &text[body_start..ref_close];
let after_ref = ref_close + REF_CLOSE.len();
if !slice_starts_with(bytes, after_ref, DET_OPEN) {
i = body_start;
continue;
}
let det_body_start = after_ref + DET_OPEN.len();
let Some(det_close) = find_from(bytes, det_body_start, DET_CLOSE) else {
break;
};
let box_text = &text[det_body_start..det_close];
let span_end = det_close + DET_CLOSE.len();
let full = &text[rel..span_end];
out.push(RefMatch {
full: full.to_string(),
label: label.to_string(),
boxes: parse_boxes(box_text),
});
i = span_end;
}
out
}
fn scan_det_spans(text: &str) -> Vec<RefMatch> {
const DET_OPEN: &str = "<|det|>";
const DET_CLOSE: &str = "<|/det|>";
let mut out = Vec::new();
let bytes = text.as_bytes();
let mut i = 0usize;
while let Some(open) = find_from(bytes, i, DET_OPEN) {
let mut p = open + DET_OPEN.len();
i = p; p = skip_ws(text, p);
let label_start = p;
let Some(first) = text[p..].chars().next() else {
continue;
};
if !is_ident_start(first) {
continue;
}
p += first.len_utf8();
while let Some(ch) = text[p..].chars().next() {
if !is_ident_continue(ch) {
break;
}
p += ch.len_utf8();
}
let label_end = p;
p = skip_ws(text, p);
if p >= bytes.len() || bytes[p] != b'[' {
continue;
}
let box_start = p;
p += 1;
let inner_start = p;
while p < bytes.len() && bytes[p] != b']' {
p += 1;
}
if p >= bytes.len() || p == inner_start {
continue;
}
let box_end = p + 1; p = box_end;
p = skip_ws(text, p);
if !slice_starts_with(bytes, p, DET_CLOSE) {
continue;
}
let span_end = p + DET_CLOSE.len();
let full = &text[open..span_end];
let label = &text[label_start..label_end];
let box_text = &text[box_start..box_end];
out.push(RefMatch {
full: full.to_string(),
label: label.to_string(),
boxes: parse_boxes(box_text),
});
i = span_end;
}
out
}
fn parse_boxes(box_text: &str) -> Vec<[i64; 4]> {
let nums = extract_numbers(box_text);
let mut out = Vec::new();
for chunk in nums.chunks(4) {
if chunk.len() == 4 {
out.push([
chunk[0] as i64,
chunk[1] as i64,
chunk[2] as i64,
chunk[3] as i64,
]);
}
}
out
}
fn extract_numbers(s: &str) -> Vec<f64> {
let bytes = s.as_bytes();
let mut nums = Vec::new();
let mut i = 0usize;
while i < bytes.len() {
let c = bytes[i];
let is_num_start = c.is_ascii_digit()
|| ((c == b'-' || c == b'+' || c == b'.')
&& i + 1 < bytes.len()
&& (bytes[i + 1].is_ascii_digit() || bytes[i + 1] == b'.'));
if is_num_start {
let start = i;
if c == b'-' || c == b'+' {
i += 1;
}
let mut seen_dot = false;
while i < bytes.len() {
let d = bytes[i];
if d.is_ascii_digit() {
i += 1;
} else if d == b'.' && !seen_dot {
seen_dot = true;
i += 1;
} else {
break;
}
}
if let Ok(v) = s[start..i].parse::<f64>() {
nums.push(v);
}
} else {
i += 1;
}
}
nums
}
fn find_from(hay: &[u8], from: usize, needle: &str) -> Option<usize> {
let n = needle.as_bytes();
if n.is_empty() || from > hay.len() {
return None;
}
let last = hay.len().checked_sub(n.len())?;
(from..=last).find(|&i| &hay[i..i + n.len()] == n)
}
fn slice_starts_with(hay: &[u8], at: usize, needle: &str) -> bool {
let n = needle.as_bytes();
at + n.len() <= hay.len() && &hay[at..at + n.len()] == n
}
fn skip_ws(text: &str, mut at: usize) -> usize {
while let Some(ch) = text[at..].chars().next() {
if !ch.is_whitespace() {
break;
}
at += ch.len_utf8();
}
at
}
fn is_ident_start(ch: char) -> bool {
ch.is_ascii_alphabetic() || ch == '_'
}
fn is_ident_continue(ch: char) -> bool {
ch == '_' || ch == '-' || ch.is_alphanumeric()
}
fn assemble_page(text: &str, img_base: &str) -> String {
let matches = re_match(text);
let mut images = Vec::new();
let mut others = Vec::new();
for m in &matches {
if m.is_image() {
images.push(m);
} else {
others.push(m);
}
}
let mut out = text.to_string();
for (idx, m) in images.iter().enumerate() {
let replacement = format!("{}\n", image_md_token(img_base, idx));
out = out.replace(&m.full, &replacement);
}
for m in &others {
out = out.replace(&m.full, "");
}
normalize_colon_equals(&out)
}
fn image_md_token(img_base: &str, idx: usize) -> String {
format!("")
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FigureRef {
pub index: usize,
pub label: String,
pub boxes: Vec<[i64; 4]>,
pub markdown_ref: String,
}
#[must_use]
pub fn figure_refs(decoded: &str, image_w: u32, image_h: u32, img_base: &str) -> Vec<FigureRef> {
let stripped = strip_eos(decoded);
re_match(&stripped)
.into_iter()
.filter(RefMatch::is_image)
.enumerate()
.map(|(index, m)| FigureRef {
index,
boxes: m.rescaled_boxes(image_w, image_h),
label: m.label,
markdown_ref: image_md_token(img_base, index),
})
.collect()
}
fn normalize_colon_equals(text: &str) -> String {
text.replace("\\coloneqq", ":=").replace("\\eqqcolon", "=:")
}
pub fn finalize(decoded: &str, _image_w: u32, _image_h: u32) -> FocrResult<String> {
let stripped = strip_eos(decoded);
Ok(assemble_page(&stripped, ""))
}
pub fn finalize_multi(decoded: &str, num_pages: usize) -> FocrResult<String> {
let stripped = strip_eos(decoded);
let mut chunks = stripped.split(PAGE_MARKER);
let _ = chunks.next(); let mut processed = Vec::new();
for (page_idx, page) in chunks.enumerate() {
let page = page.trim();
if page_idx >= num_pages {
processed.push(page.to_string());
continue;
}
let prefix = format!("page_{page_idx}_");
processed.push(assemble_page(page, &prefix));
}
let body = processed.join("\n<PAGE>\n");
Ok(format!("<PAGE>\n{body}"))
}
#[derive(Debug, Default)]
pub struct PageStream {
body_start: Option<usize>,
current_page: usize,
}
impl PageStream {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn feed(&mut self, full_text: &str, mut on_page: impl FnMut(usize, &str)) {
loop {
match self.body_start {
None => {
let Some(pos) = full_text.find(PAGE_MARKER) else {
return;
};
self.body_start = Some(pos + PAGE_MARKER.len());
self.current_page = 1;
}
Some(start) => {
let Some(rel) = full_text[start..].find(PAGE_MARKER) else {
return;
};
let end = start + rel;
on_page(self.current_page, full_text[start..end].trim());
self.body_start = Some(end + PAGE_MARKER.len());
self.current_page += 1;
}
}
}
}
pub fn finish(self, full_text: &str, mut on_page: impl FnMut(usize, &str)) {
if let Some(start) = self.body_start {
on_page(self.current_page, full_text[start..].trim());
}
}
}
#[must_use]
pub fn parse_layout(decoded: &str, image_w: u32, image_h: u32) -> Vec<(String, Vec<[i64; 4]>)> {
let stripped = strip_eos(decoded);
re_match(&stripped)
.into_iter()
.map(|m| {
let boxes = m.rescaled_boxes(image_w, image_h);
(m.label, boxes)
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strip_eos_removes_trailing_marker_and_trims() {
let raw = format!("hello world{EOS_MARKER}");
assert_eq!(strip_eos(&raw), "hello world");
let raw2 = format!(" body text {EOS_MARKER}");
assert_eq!(strip_eos(&raw2), "body text");
assert_eq!(strip_eos(" bare "), "bare");
let raw3 = format!("{EOS_MARKER}keep");
assert_eq!(strip_eos(&raw3), format!("{EOS_MARKER}keep"));
}
#[test]
fn rescale_matches_python_int_truncation() {
assert_eq!(rescale(999, 1000), 1000);
assert_eq!(rescale(0, 1000), 0);
assert_eq!(rescale(500, 1000), 500);
assert_eq!(rescale(1, 100), 0);
}
#[test]
fn extract_numbers_handles_ints_floats_signs() {
assert_eq!(extract_numbers("[1, 2, 3, 4]"), vec![1.0, 2.0, 3.0, 4.0]);
assert_eq!(
extract_numbers("[10, -5, 3.5, 0]"),
vec![10.0, -5.0, 3.5, 0.0]
);
assert!(extract_numbers("[]").is_empty());
}
#[test]
fn parse_boxes_single_quad() {
assert_eq!(
parse_boxes("[100, 200, 300, 400]"),
vec![[100, 200, 300, 400]]
);
}
#[test]
fn parse_boxes_list_of_quads() {
assert_eq!(
parse_boxes("[[1, 2, 3, 4], [5, 6, 7, 8]]"),
vec![[1, 2, 3, 4], [5, 6, 7, 8]]
);
}
#[test]
fn parse_boxes_drops_incomplete_trailing() {
assert_eq!(parse_boxes("[1, 2, 3, 4, 5]"), vec![[1, 2, 3, 4]]);
}
#[test]
fn re_match_full_ref_det_span() {
let text = "<|ref|>title<|/ref|><|det|>[10, 20, 30, 40]<|/det|>";
let ms = re_match(text);
assert!(ms.iter().any(|m| m.full == text && m.label == "title"));
let full = ms.iter().find(|m| m.label == "title").unwrap();
assert_eq!(full.boxes, vec![[10, 20, 30, 40]]);
}
#[test]
fn re_match_non_greedy_two_spans() {
let text =
"<|ref|>a<|/ref|><|det|>[1,2,3,4]<|/det|>X<|ref|>b<|/ref|><|det|>[5,6,7,8]<|/det|>";
let ms = scan_ref_spans(text);
assert_eq!(ms.len(), 2);
assert_eq!(ms[0].label, "a");
assert_eq!(ms[0].boxes, vec![[1, 2, 3, 4]]);
assert_eq!(ms[1].label, "b");
assert_eq!(ms[1].boxes, vec![[5, 6, 7, 8]]);
}
#[test]
fn re_match_bare_det_span() {
let text = "noise <|det|> figure [0, 0, 100, 100] <|/det|> tail";
let ms = scan_det_spans(text);
assert_eq!(ms.len(), 1);
assert_eq!(ms[0].label, "figure");
assert_eq!(ms[0].boxes, vec![[0, 0, 100, 100]]);
assert!(ms[0].full.starts_with("<|det|>"));
assert!(ms[0].full.ends_with("<|/det|>"));
}
#[test]
fn re_match_det_uses_python_unicode_whitespace() {
let text = "noise <|det|>\u{00a0}figure\u{00a0}[0, 0, 100, 100]\u{00a0}<|/det|> tail";
let ms = scan_det_spans(text);
assert_eq!(ms.len(), 1);
assert_eq!(ms[0].label, "figure");
assert_eq!(ms[0].boxes, vec![[0, 0, 100, 100]]);
}
#[test]
fn re_match_det_allows_unicode_word_continuation() {
let text = "noise <|det|> a\u{00e9}-label [1, 2, 3, 4] <|/det|> tail";
let ms = scan_det_spans(text);
assert_eq!(ms.len(), 1);
assert_eq!(ms[0].label, "a\u{00e9}-label");
assert_eq!(ms[0].boxes, vec![[1, 2, 3, 4]]);
}
#[test]
fn re_match_det_rejects_bad_label() {
let text = "<|det|>9bad [1,2,3,4]<|/det|>";
assert!(scan_det_spans(text).is_empty());
}
#[test]
fn is_image_predicate() {
let m = RefMatch {
full: "<|ref|>image<|/ref|><|det|>[1,2,3,4]<|/det|>".into(),
label: "image".into(),
boxes: vec![[1, 2, 3, 4]],
};
assert!(m.is_image());
let m2 = RefMatch {
full: "<|ref|>title<|/ref|><|det|>[1,2,3,4]<|/det|>".into(),
label: "title".into(),
boxes: vec![[1, 2, 3, 4]],
};
assert!(!m2.is_image());
}
#[test]
fn finalize_strips_other_spans_and_keeps_text() {
let raw =
format!("Heading\n<|ref|>title<|/ref|><|det|>[1,2,3,4]<|/det|>\nBody text{EOS_MARKER}");
let md = finalize(&raw, 1000, 1000).unwrap();
assert!(md.contains("Heading"));
assert!(md.contains("Body text"));
assert!(!md.contains("<|ref|>"));
assert!(!md.contains("<|det|>"));
assert!(!md.contains(EOS_MARKER));
}
#[test]
fn finalize_rewrites_image_spans_to_markdown() {
let raw = format!(
"Top\n<|ref|>image<|/ref|><|det|>[0,0,500,500]<|/det|>\n<|ref|>image<|/ref|><|det|>[1,1,2,2]<|/det|>End{EOS_MARKER}"
);
let md = finalize(&raw, 800, 600).unwrap();
assert!(md.contains(""));
assert!(md.contains(""));
assert!(md.contains("Top"));
assert!(md.contains("End"));
assert!(!md.contains("<|ref|>"));
}
#[test]
fn finalize_normalizes_latex_coloneqq() {
let raw = format!(
"x \\coloneqq y and a \\eqqcolon b <|ref|>note<|/ref|><|det|>[1,2,3,4]<|/det|>{EOS_MARKER}"
);
let md = finalize(&raw, 100, 100).unwrap();
assert!(md.contains("x := y"));
assert!(md.contains("a =: b"));
assert!(!md.contains("\\coloneqq"));
assert!(!md.contains("\\eqqcolon"));
}
#[test]
fn finalize_normalizes_latex_coloneqq_without_tags() {
let raw = format!("x \\coloneqq y and a \\eqqcolon b{EOS_MARKER}");
let md = finalize(&raw, 100, 100).unwrap();
assert_eq!(md, "x := y and a =: b");
}
#[test]
fn finalize_normalizes_latex_coloneqq_with_only_image_spans() {
let raw =
format!("x \\coloneqq y <|ref|>image<|/ref|><|det|>[0,0,10,10]<|/det|>{EOS_MARKER}");
let md = finalize(&raw, 100, 100).unwrap();
assert!(md.contains("x := y"));
assert!(md.contains(""));
assert!(!md.contains("\\coloneqq"));
}
#[test]
fn page_stream_streams_bodies_as_markers_arrive() {
let mut ps = PageStream::new();
let mut got: Vec<(usize, String)> = Vec::new();
ps.feed("preamble ", |i, s| got.push((i, s.to_string())));
assert!(got.is_empty());
ps.feed("preamble <PAGE>\nalpha", |i, s| {
got.push((i, s.to_string()))
});
assert!(got.is_empty(), "page 1 is still in flight");
let text = "preamble <PAGE>\nalpha\n<PAGE>\nbeta";
ps.feed(text, |i, s| got.push((i, s.to_string())));
assert_eq!(got, vec![(1, "alpha".to_string())]);
ps.feed(text, |i, s| got.push((i, s.to_string())));
assert_eq!(got.len(), 1, "re-feeding the same text must not re-emit");
ps.finish(text, |i, s| got.push((i, s.to_string())));
assert_eq!(
got,
vec![(1, "alpha".to_string()), (2, "beta".to_string())],
"finish flushes the final in-flight page"
);
println!(r#"{{"check":"page_stream_boundaries","pages":2,"result":"pass"}}"#);
}
#[test]
fn page_stream_without_markers_streams_nothing() {
let ps = PageStream::new();
let mut got = 0usize;
ps.finish("no markers at all", |_, _| got += 1);
assert_eq!(got, 0);
}
#[test]
fn page_stream_marker_split_across_feeds_is_caught() {
let mut ps = PageStream::new();
let mut got: Vec<usize> = Vec::new();
ps.feed("<PAGE>one <PA", |i, _| got.push(i));
assert!(got.is_empty());
ps.feed("<PAGE>one <PAGE>two", |i, _| got.push(i));
assert_eq!(got, vec![1]);
}
#[test]
fn finalize_multi_splits_and_rejoins_pages() {
let raw = format!("preamble<PAGE>page one text<PAGE>page two text{EOS_MARKER}");
let md = finalize_multi(&raw, 2).unwrap();
assert!(md.starts_with("<PAGE>\n"));
assert!(md.contains("page one text"));
assert!(md.contains("page two text"));
assert!(!md.contains("preamble"));
assert_eq!(md.matches(PAGE_MARKER).count(), 2);
}
#[test]
fn finalize_multi_per_page_image_prefix() {
let raw = format!(
"<PAGE>p0 <|ref|>image<|/ref|><|det|>[0,0,10,10]<|/det|><PAGE>p1 <|ref|>image<|/ref|><|det|>[0,0,10,10]<|/det|>{EOS_MARKER}"
);
let md = finalize_multi(&raw, 2).unwrap();
assert!(md.contains(""));
assert!(md.contains(""));
}
#[test]
fn finalize_multi_passthrough_overflow_pages() {
let raw = format!(
"<PAGE>real <|ref|>title<|/ref|><|det|>[1,2,3,4]<|/det|><PAGE> extra <|ref|>x<|/ref|><|det|>[1,2,3,4]<|/det|> {EOS_MARKER}"
);
let md = finalize_multi(&raw, 1).unwrap();
assert!(!md.contains("<|ref|>title"));
assert!(md.contains("<|ref|>x<|/ref|>"));
}
#[test]
fn parse_layout_rescales_boxes() {
let raw = format!("<|ref|>title<|/ref|><|det|>[0, 0, 999, 999]<|/det|>{EOS_MARKER}");
let layout = parse_layout(&raw, 1920, 1080);
let (label, boxes) = layout
.iter()
.find(|(l, _)| l == "title")
.expect("title span");
assert_eq!(label, "title");
assert_eq!(boxes, &vec![[0, 0, 1920, 1080]]);
}
#[test]
fn re_match_empty_when_no_tags() {
assert!(re_match("just plain markdown text\n# Heading").is_empty());
}
#[test]
fn finalize_plain_text_passthrough() {
let raw = format!("# Title\n\nSome **markdown** body.\n{EOS_MARKER}");
let md = finalize(&raw, 640, 480).unwrap();
assert_eq!(md, "# Title\n\nSome **markdown** body.");
}
#[test]
fn figure_refs_enumerate_image_spans_with_tokens_matching_assemble_page() {
let decoded = concat!(
"<|ref|>title<|/ref|><|det|>[[0,0,500,500]]<|/det|>",
"<|ref|>image<|/ref|><|det|>[[0,0,999,999]]<|/det|>",
"<|ref|>image<|/ref|><|det|>[[0,0,499,499]]<|/det|>",
);
let refs = figure_refs(decoded, 200, 100, "");
assert_eq!(refs.len(), 2, "only the two image spans are figures");
assert_eq!(refs[0].index, 0);
assert_eq!(refs[0].label, "image");
assert_eq!(refs[0].markdown_ref, "");
assert_eq!(refs[0].boxes, vec![[0, 0, 200, 100]]);
assert_eq!(refs[1].index, 1);
assert_eq!(refs[1].markdown_ref, "");
let md = finalize(decoded, 200, 100).unwrap();
assert!(md.contains(&refs[0].markdown_ref), "md: {md}");
assert!(md.contains(&refs[1].markdown_ref), "md: {md}");
}
#[test]
fn figure_refs_multipage_prefix_matches_assemble_page() {
let decoded = "<|ref|>image<|/ref|><|det|>[[1,2,3,4]]<|/det|>";
let refs = figure_refs(decoded, 999, 999, "page_0_");
assert_eq!(refs.len(), 1);
assert_eq!(refs[0].markdown_ref, "");
let md = finalize_multi(&format!("<PAGE>\n{decoded}"), 1).unwrap();
assert!(md.contains(""), "md: {md}");
}
#[test]
fn figure_refs_empty_without_image_spans() {
let decoded = "<|ref|>title<|/ref|><|det|>[[0,0,9,9]]<|/det|>plain text";
assert!(figure_refs(decoded, 100, 100, "").is_empty());
}
}