use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
#[cfg(feature = "use-opencv")]
use opencv::{
core::{Mat, Point2f},
prelude::MatTraitConst,
};
#[cfg(not(feature = "use-opencv"))]
use crate::image_impl::{Mat, Point2f};
use crate::cal_rec_boxes::CalRecBoxes;
use crate::config::InitializeConfig;
use crate::det::TextDetector;
use crate::engine::EngineError;
use crate::geometry::{
apply_vertical_padding, get_rotate_crop_image, map_boxes_to_original,
resize_image_within_bounds, OpRecord,
};
use crate::orient::{OrientClassifier, Orientation};
use crate::rec::{TextRecOutput, TextRecognizer};
use crate::types::GlobalConfig;
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum OutputGranularity {
#[default]
Lines,
Words,
Spatial,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct OcrRunOptions {
#[serde(default)]
pub output: OutputGranularity,
pub line_y_threshold: Option<f32>,
pub word_x_threshold: Option<f32>,
pub text_score: Option<f32>,
pub classification: Option<bool>,
pub orientation: Option<bool>,
}
#[derive(Clone, Debug)]
pub enum ImageSource {
Path(PathBuf),
Bytes(Vec<u8>),
}
#[derive(Clone, Debug, PartialEq)]
pub enum DetectTextResult {
Structured(Vec<crate::TextResult>),
Spatial(String),
}
#[allow(dead_code)]
pub(crate) struct RustOOutput {
pub boxes: Vec<[Point2f; 4]>,
pub txts: Vec<String>,
pub scores: Vec<f32>,
pub word_results: Vec<Vec<(String, f32, [Point2f; 4])>>,
pub orientation: Option<Orientation>,
pub elapse_det: f64,
pub elapse_rec: f64,
pub elapse_orient: f64,
pub debug_oriented_image: Option<Mat>,
pub y_threshold_multiplier: Option<f32>,
pub x_threshold_multiplier: Option<f32>,
}
pub struct RustO {
pub det: TextDetector,
pub rec: TextRecognizer,
pub global: GlobalConfig,
pub cal_rec_boxes: CalRecBoxes,
pub orient: Option<OrientClassifier>,
pub cls: Option<OrientClassifier>,
}
impl RustO {
pub fn initialize(config: InitializeConfig) -> Result<Self, EngineError> {
let det = TextDetector::new(config.det.clone())?;
let rec = TextRecognizer::new(config.rec.clone())?;
let cal_rec_boxes = CalRecBoxes::new();
let orient = if let Some(orient_cfg) = config.orient {
Some(OrientClassifier::new(orient_cfg)?)
} else {
None
};
let cls = if let Some(cls_cfg) = config.cls {
let orient_cfg = crate::types::OrientConfig {
engine_type: cls_cfg.engine_type,
model_type: cls_cfg.model_type,
task_type: cls_cfg.task_type,
model_path: cls_cfg.model_path,
orient_image_shape: cls_cfg.cls_image_shape,
mean: [0.5, 0.5, 0.5],
std: [0.5, 0.5, 0.5],
confidence_threshold: cls_cfg.cls_thresh,
orient_batch_num: cls_cfg.cls_batch_num,
orient_thresh: cls_cfg.cls_thresh,
engine_cfg: cls_cfg.engine_cfg,
};
Some(OrientClassifier::new(orient_cfg)?)
} else {
None
};
Ok(Self {
det,
rec,
global: config.global,
cal_rec_boxes,
orient,
cls,
})
}
pub fn detect_text(
&mut self,
source: &ImageSource,
options: &OcrRunOptions,
) -> Result<DetectTextResult, EngineError> {
let output = match source {
ImageSource::Path(path) => self.run_with_options(path, options)?,
ImageSource::Bytes(bytes) => {
#[cfg(not(feature = "use-opencv"))]
{
let image = image::load_from_memory(bytes)
.map_err(|error| EngineError::ImageError(error.to_string()))?;
self.run_on_mat_with_options(&Mat::new(image), options)?
}
#[cfg(feature = "use-opencv")]
{
return Err(EngineError::ImageError(
"in-memory image sources require the pure Rust image backend".into(),
));
}
}
};
match options.output {
OutputGranularity::Spatial => Ok(DetectTextResult::Spatial(
output.to_spatial_text(options.line_y_threshold, options.word_x_threshold),
)),
OutputGranularity::Lines | OutputGranularity::Words => Ok(
DetectTextResult::Structured(output.to_text_results_with_options(options)),
),
}
}
pub(crate) fn run_with_options<P: AsRef<Path>>(
&mut self,
image_path: P,
options: &OcrRunOptions,
) -> Result<RustOOutput, EngineError> {
use crate::image_impl::imread;
let img = imread(image_path)?;
self.run_on_mat_with_options(&img, options)
}
pub(crate) fn run_on_mat_with_options(
&mut self,
img: &Mat,
options: &OcrRunOptions,
) -> Result<RustOOutput, EngineError> {
let mut effective = self.global.clone();
effective.return_word_box = options.output == OutputGranularity::Words;
effective.return_single_char_box = false;
effective.use_cls = options.classification.unwrap_or(false) && self.cls.is_some();
effective.use_orient = options.orientation.unwrap_or(false) && self.orient.is_some();
effective.use_unwarp = false;
if let Some(value) = options.text_score {
effective.text_score = value;
}
self.run_on_mat_with_global(img, &effective)
}
fn run_on_mat_with_global(
&mut self,
img: &Mat,
global: &GlobalConfig,
) -> Result<RustOOutput, EngineError> {
let size = img.size()?;
let ori_h = size.height;
let ori_w = size.width;
let mut elapse_orient = 0.0;
let mut orientation = None;
let mut debug_oriented_image = None;
let mut working_img = img.clone();
if global.use_orient && self.orient.is_some() {
if let Some(orient_classifier) = &mut self.orient {
let orient_result = orient_classifier.classify(img)?;
elapse_orient = orient_result.elapse;
if orient_result.orientation.degrees() != 0 {
let rotated = orient_result.orientation.rotate_image(&working_img)?;
if global.debug_images {
debug_oriented_image = Some(rotated.clone());
}
working_img = rotated;
if orient_result.confidence >= orient_classifier.config.confidence_threshold {
orientation = Some(orient_result.orientation);
}
} else {
orientation = Some(orient_result.orientation);
}
}
}
let mut op_record: OpRecord = OpRecord::new();
let (resized, ratio_h, ratio_w) =
resize_image_within_bounds(&working_img, global.min_side_len, global.max_side_len)?;
let mut m = std::collections::BTreeMap::new();
m.insert("ratio_h".to_string(), ratio_h);
m.insert("ratio_w".to_string(), ratio_w);
op_record.insert("preprocess".to_string(), m);
let (padded, op_record) = apply_vertical_padding(
&resized,
op_record,
global.width_height_ratio,
global.min_height,
)?;
let det_res = self.det.run(&padded)?;
let padded_boxes = match det_res.boxes {
Some(b) if !b.is_empty() => b,
_ => {
return Ok(RustOOutput {
boxes: Vec::new(),
txts: Vec::new(),
scores: Vec::new(),
word_results: Vec::new(),
orientation,
elapse_det: det_res.elapse,
elapse_rec: 0.0,
elapse_orient,
debug_oriented_image,
y_threshold_multiplier: global.y_threshold_multiplier,
x_threshold_multiplier: global.x_threshold_multiplier,
});
}
};
let mut crop_imgs: Vec<Mat> = Vec::with_capacity(padded_boxes.len());
for b in &padded_boxes {
let crop = get_rotate_crop_image(&padded, b)?;
crop_imgs.push(crop);
}
if global.use_cls && self.cls.is_some() {
if let Some(cls_classifier) = &mut self.cls {
for crop in &mut crop_imgs {
if let Ok(cls_result) = cls_classifier.classify(crop) {
if cls_result.orientation == Orientation::Rotate180
&& cls_result.confidence >= cls_classifier.config.confidence_threshold
{
if let Ok(rotated) = cls_result.orientation.rotate_image(crop) {
*crop = rotated;
}
}
}
}
}
}
let mut boxes = padded_boxes.clone();
map_boxes_to_original(&mut boxes, &op_record, ori_h, ori_w);
let rec_res: TextRecOutput = self.rec.run(&crop_imgs, global.return_word_box)?;
let word_results_all: Vec<Vec<(String, f32, [Point2f; 4])>> = if global.return_word_box {
self.cal_rec_boxes
.calc_word_boxes(&boxes, &rec_res, global.return_single_char_box)
} else {
vec![Vec::new(); boxes.len()]
};
let mut txts = rec_res.txts;
let mut scores = rec_res.scores;
let mut f_boxes = Vec::new();
let mut f_txts = Vec::new();
let mut f_scores = Vec::new();
let mut f_word_results: Vec<Vec<(String, f32, [Point2f; 4])>> = Vec::new();
for (idx, (b, (t, s))) in boxes
.into_iter()
.zip(txts.drain(..).zip(scores.drain(..)))
.enumerate()
{
if s < global.text_score {
continue;
}
f_boxes.push(b);
f_txts.push(t);
f_scores.push(s);
if idx < word_results_all.len() {
f_word_results.push(word_results_all[idx].clone());
} else {
f_word_results.push(Vec::new());
}
}
Ok(RustOOutput {
boxes: f_boxes,
txts: f_txts,
scores: f_scores,
word_results: f_word_results,
orientation,
elapse_det: det_res.elapse,
elapse_rec: rec_res.elapse,
elapse_orient,
debug_oriented_image,
y_threshold_multiplier: global.y_threshold_multiplier,
x_threshold_multiplier: global.x_threshold_multiplier,
})
}
}
struct AlignedWord {
result: crate::TextResult,
whitespace_before: bool,
}
fn group_words(
word_lines: Vec<Vec<crate::TextResult>>,
line_y_threshold: f32,
word_x_threshold: f32,
) -> Vec<crate::TextResult> {
let mut words: Vec<AlignedWord> = word_lines
.into_iter()
.flat_map(|line| {
line.into_iter()
.enumerate()
.map(|(index, result)| AlignedWord {
result,
whitespace_before: index > 0,
})
})
.collect();
if words.is_empty() {
return Vec::new();
}
let mut heights: Vec<f32> = words.iter().map(|word| word.result.frame.height).collect();
heights.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let line_tolerance = heights[heights.len() / 2] * line_y_threshold;
words.sort_by(|a, b| {
a.result
.frame
.top
.partial_cmp(&b.result.frame.top)
.unwrap_or(std::cmp::Ordering::Equal)
.then(
a.result
.frame
.left
.partial_cmp(&b.result.frame.left)
.unwrap_or(std::cmp::Ordering::Equal),
)
});
let mut lines: Vec<Vec<AlignedWord>> = Vec::new();
for word in words {
if let Some(line) = lines.last_mut() {
let center = line
.iter()
.map(|item| item.result.frame.top + item.result.frame.height / 2.0)
.sum::<f32>()
/ line.len() as f32;
if ((word.result.frame.top + word.result.frame.height / 2.0) - center).abs()
<= line_tolerance
{
line.push(word);
continue;
}
}
lines.push(vec![word]);
}
lines
.into_iter()
.flat_map(|mut line| {
line.sort_by(|a, b| {
a.result
.frame
.left
.partial_cmp(&b.result.frame.left)
.unwrap_or(std::cmp::Ordering::Equal)
});
let mut grouped: Vec<Vec<crate::TextResult>> = Vec::new();
for word in line {
let char_width = (word.result.frame.width
/ word.result.text.chars().count().max(1) as f32)
.max(1e-6);
if !word.whitespace_before {
if let Some(current) = grouped.last_mut() {
let previous = current.last().expect("non-empty word group");
let gap =
word.result.frame.left - (previous.frame.left + previous.frame.width);
if gap <= char_width * word_x_threshold {
current.push(word.result);
continue;
}
}
}
grouped.push(vec![word.result]);
}
grouped
.into_iter()
.map(|group| merge_text_results(group, ""))
})
.collect()
}
fn merge_text_results(mut entries: Vec<crate::TextResult>, separator: &str) -> crate::TextResult {
entries.sort_by(|a, b| {
a.frame
.left
.partial_cmp(&b.frame.left)
.unwrap_or(std::cmp::Ordering::Equal)
});
let text = entries
.iter()
.map(|item| item.text.as_str())
.collect::<Vec<_>>()
.join(separator);
let score = entries.iter().map(|item| item.score).fold(1.0, f32::min);
let left = entries
.iter()
.map(|item| item.frame.left)
.fold(f32::INFINITY, f32::min);
let top = entries
.iter()
.map(|item| item.frame.top)
.fold(f32::INFINITY, f32::min);
let right = entries
.iter()
.map(|item| item.frame.left + item.frame.width)
.fold(f32::NEG_INFINITY, f32::max);
let bottom = entries
.iter()
.map(|item| item.frame.top + item.frame.height)
.fold(f32::NEG_INFINITY, f32::max);
let box_points = [(left, top), (right, top), (right, bottom), (left, bottom)];
crate::TextResult {
text,
score,
box_points,
frame: crate::Frame::from_points(&box_points),
}
}
#[allow(dead_code)]
impl RustOOutput {
pub(crate) fn to_text_results(&self) -> Vec<crate::TextResult> {
let mut results = Vec::with_capacity(self.boxes.len());
for (i, bbox) in self.boxes.iter().enumerate() {
if i >= self.txts.len() || i >= self.scores.len() {
break;
}
let box_points = [
(bbox[0].x, bbox[0].y),
(bbox[1].x, bbox[1].y),
(bbox[2].x, bbox[2].y),
(bbox[3].x, bbox[3].y),
];
let frame = crate::Frame::from_points(&box_points);
results.push(crate::TextResult {
text: self.txts[i].clone(),
score: self.scores[i],
box_points,
frame,
});
}
results
}
pub(crate) fn to_text_results_with_options(&self, options: &OcrRunOptions) -> Vec<crate::TextResult> {
if options.output == OutputGranularity::Words {
let word_lines: Vec<Vec<crate::TextResult>> = self
.word_results
.iter()
.map(|words| {
words
.iter()
.map(|(text, score, quad)| {
let box_points = [
(quad[0].x, quad[0].y),
(quad[1].x, quad[1].y),
(quad[2].x, quad[2].y),
(quad[3].x, quad[3].y),
];
crate::TextResult {
text: text.clone(),
score: *score,
box_points,
frame: crate::Frame::from_points(&box_points),
}
})
.collect()
})
.collect();
return group_words(
word_lines,
options.line_y_threshold.unwrap_or(0.5),
options.word_x_threshold.unwrap_or(0.4),
);
}
let mut entries = self.to_text_results();
if entries.is_empty() {
return entries;
}
let mut heights: Vec<f32> = entries.iter().map(|entry| entry.frame.height).collect();
heights.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let tolerance = heights[heights.len() / 2] * options.line_y_threshold.unwrap_or(0.5);
entries.sort_by(|a, b| {
a.frame
.top
.partial_cmp(&b.frame.top)
.unwrap_or(std::cmp::Ordering::Equal)
.then(
a.frame
.left
.partial_cmp(&b.frame.left)
.unwrap_or(std::cmp::Ordering::Equal),
)
});
let mut lines: Vec<Vec<crate::TextResult>> = Vec::new();
for entry in entries {
if let Some(line) = lines.last_mut() {
let y = line
.iter()
.map(|item| item.frame.top + item.frame.height / 2.0)
.sum::<f32>()
/ line.len() as f32;
if ((entry.frame.top + entry.frame.height / 2.0) - y).abs() <= tolerance {
line.push(entry);
continue;
}
}
lines.push(vec![entry]);
}
lines
.into_iter()
.map(|mut line| {
line.sort_by(|a, b| {
a.frame
.left
.partial_cmp(&b.frame.left)
.unwrap_or(std::cmp::Ordering::Equal)
});
let text = line
.iter()
.map(|item| item.text.as_str())
.collect::<Vec<_>>()
.join(" ");
let score = line.iter().map(|item| item.score).fold(1.0, f32::min);
let left = line
.iter()
.map(|item| item.frame.left)
.fold(f32::INFINITY, f32::min);
let top = line
.iter()
.map(|item| item.frame.top)
.fold(f32::INFINITY, f32::min);
let right = line
.iter()
.map(|item| item.frame.left + item.frame.width)
.fold(f32::NEG_INFINITY, f32::max);
let bottom = line
.iter()
.map(|item| item.frame.top + item.frame.height)
.fold(f32::NEG_INFINITY, f32::max);
let box_points = [(left, top), (right, top), (right, bottom), (left, bottom)];
crate::TextResult {
text,
score,
box_points,
frame: crate::Frame::from_points(&box_points),
}
})
.collect()
}
pub(crate) fn to_raw(&self) -> String {
if self.boxes.is_empty() {
return String::new();
}
let mut entries: Vec<(f32, f32, String, f32)> = self
.boxes
.iter()
.zip(self.txts.iter())
.zip(self.scores.iter())
.map(|((bbox, text), &score)| {
let x = bbox[0].x;
let y = bbox[0].y;
(y, x, text.clone(), score)
})
.collect();
entries.sort_by(|a, b| {
a.0.partial_cmp(&b.0)
.unwrap()
.then(a.1.partial_cmp(&b.1).unwrap())
});
let mut result = String::new();
for (y, x, text, score) in entries {
result.push_str(&format!(
"[{:.0},{:.0}] {:.2}% {}\n",
x,
y,
score * 100.0,
text
));
}
result
}
pub(crate) fn to_csv(&self) -> String {
if self.boxes.is_empty() {
return "line_id,column_id,text\n".to_string();
}
#[derive(Debug, Clone)]
struct Token {
x: f32,
y: f32,
text: String,
}
let mut tokens: Vec<Token> = self
.boxes
.iter()
.zip(self.txts.iter())
.map(|(bbox, text)| Token {
x: bbox[0].x,
y: bbox[0].y,
text: text.clone(),
})
.collect();
tokens.sort_by(|a, b| a.y.partial_cmp(&b.y).unwrap());
let y_diffs: Vec<f32> = tokens
.windows(2)
.map(|w| w[1].y - w[0].y)
.filter(|&d| d > 0.0)
.collect();
let typical_y_diff = if !y_diffs.is_empty() {
let mut sorted = y_diffs.clone();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
sorted[sorted.len() / 2]
} else {
10.0
};
let y_tolerance = typical_y_diff * 0.6;
let mut lines: Vec<Vec<Token>> = Vec::new();
let mut current_line: Vec<Token> = Vec::new();
let mut current_y: Option<f32> = None;
for token in tokens {
if let Some(cy) = current_y {
if (token.y - cy).abs() <= y_tolerance {
current_line.push(token.clone());
current_y = Some(
(cy * (current_line.len() - 1) as f32 + token.y)
/ current_line.len() as f32,
);
} else {
lines.push(current_line.clone());
current_line = vec![token.clone()];
current_y = Some(token.y);
}
} else {
current_line = vec![token.clone()];
current_y = Some(token.y);
}
}
if !current_line.is_empty() {
lines.push(current_line);
}
let mut csv_rows = Vec::new();
for (line_id, mut line) in lines.into_iter().enumerate() {
line.sort_by(|a, b| a.x.partial_cmp(&b.x).unwrap());
if line.len() == 1 {
csv_rows.push((line_id, 0, line[0].text.clone()));
continue;
}
let x_gaps: Vec<f32> = line.windows(2).map(|w| w[1].x - w[0].x).collect();
let median_gap = if !x_gaps.is_empty() {
let mut sorted = x_gaps.clone();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
sorted[sorted.len() / 2].max(1.0)
} else {
1.0
};
let gap_threshold = median_gap * 1.3;
let mut columns: Vec<Vec<Token>> = Vec::new();
let mut current_col = vec![line[0].clone()];
for i in 1..line.len() {
let gap = line[i].x - line[i - 1].x;
if gap > gap_threshold {
columns.push(current_col.clone());
current_col = vec![line[i].clone()];
} else {
current_col.push(line[i].clone());
}
}
columns.push(current_col);
for (col_id, col) in columns.into_iter().enumerate() {
let text = col
.iter()
.map(|t| t.text.as_str())
.collect::<Vec<_>>()
.join(" ");
csv_rows.push((line_id, col_id, text));
}
}
let mut result = String::from("line_id,column_id,text\n");
for (line_id, col_id, text) in csv_rows {
let safe_text = text.replace('"', "\"\"");
result.push_str(&format!("{},{},\"{}\"\n", line_id, col_id, safe_text));
}
result
}
pub(crate) fn to_text_with_position(&self) -> String {
let mut result = String::new();
let mut indexed: Vec<(&[Point2f; 4], &String, f32)> = self
.boxes
.iter()
.zip(self.txts.iter())
.zip(self.scores.iter())
.map(|((b, t), &s)| (b, t, s))
.collect();
indexed.sort_by(|a, b| {
let ay = (a.0[0].y + a.0[2].y) / 2.0;
let by = (b.0[0].y + b.0[2].y) / 2.0;
let ax = (a.0[0].x + a.0[2].x) / 2.0;
let bx = (b.0[0].x + b.0[2].x) / 2.0;
if (ay - by).abs() < 20.0 {
ax.partial_cmp(&bx).unwrap()
} else {
ay.partial_cmp(&by).unwrap()
}
});
for (bbox, text, score) in &indexed {
let x = (bbox[0].x + bbox[2].x) / 2.0;
let y = (bbox[0].y + bbox[2].y) / 2.0;
result.push_str(&format!(
"[{:.0},{:.0}] {:.2}% {}\n",
x,
y,
score * 100.0,
text
));
}
result
}
pub(crate) fn to_spatial_text(
&self,
y_threshold_multiplier: Option<f32>,
x_threshold_multiplier: Option<f32>,
) -> String {
if self.boxes.is_empty() {
return String::new();
}
let y_mult = y_threshold_multiplier
.or(self.y_threshold_multiplier)
.unwrap_or(0.5);
let x_mult = x_threshold_multiplier
.or(self.x_threshold_multiplier)
.unwrap_or(0.4);
#[derive(Debug, Clone)]
struct Token {
x: f32,
y: f32,
width: f32,
height: f32,
text: String,
}
let mut tokens: Vec<Token> = self
.boxes
.iter()
.zip(self.txts.iter())
.map(|(bbox, text)| {
let x = bbox[0].x;
let y = bbox[0].y;
let width = (bbox[1].x - bbox[0].x)
.abs()
.max((bbox[2].x - bbox[3].x).abs());
let height = (bbox[3].y - bbox[0].y)
.abs()
.max((bbox[2].y - bbox[1].y).abs());
Token {
x,
y,
width,
height,
text: text.clone(),
}
})
.collect();
let median_height = if !tokens.is_empty() {
let mut heights: Vec<f32> = tokens.iter().map(|t| t.height).collect();
heights.sort_by(|a, b| a.partial_cmp(b).unwrap());
heights[heights.len() / 2]
} else {
10.0
};
let avg_char_width = if !tokens.is_empty() {
let total_chars: usize = tokens.iter().map(|t| t.text.len()).sum();
let total_width: f32 = tokens.iter().map(|t| t.width).sum();
if total_chars > 0 {
total_width / total_chars as f32
} else {
median_height * 0.5
}
} else {
10.0
};
let x_gap_threshold = avg_char_width * x_mult;
let y_tolerance = median_height * y_mult;
tokens.sort_by(|a, b| {
a.y.partial_cmp(&b.y)
.unwrap()
.then(a.x.partial_cmp(&b.x).unwrap())
});
let mut lines: Vec<Vec<Token>> = Vec::new();
let mut current_line: Vec<Token> = Vec::new();
let mut current_line_y_sum: f32 = 0.0;
for token in tokens {
if !current_line.is_empty() {
let current_line_avg_y = current_line_y_sum / current_line.len() as f32;
if (token.y - current_line_avg_y).abs() <= y_tolerance {
current_line.push(token.clone());
current_line_y_sum += token.y;
} else {
lines.push(current_line);
current_line = vec![token.clone()];
current_line_y_sum = token.y;
}
} else {
current_line = vec![token.clone()];
current_line_y_sum = token.y;
}
}
if !current_line.is_empty() {
lines.push(current_line);
}
let mut result = String::new();
let mut prev_line_avg_y: Option<f32> = None;
let min_x = if !lines.is_empty() {
lines
.iter()
.flatten()
.map(|t| t.x)
.fold(f32::INFINITY, f32::min)
} else {
0.0
};
for line in lines {
if line.is_empty() {
continue;
}
let current_line_avg_y = line.iter().map(|t| t.y).sum::<f32>() / line.len() as f32;
if let Some(prev_y) = prev_line_avg_y {
let vertical_gap = current_line_avg_y - prev_y;
if vertical_gap > median_height * 1.5 {
let num_blank_lines =
((vertical_gap - median_height) / median_height).round() as usize;
for _ in 0..num_blank_lines.max(1) - 1 {
result.push('\n');
}
}
}
prev_line_avg_y = Some(current_line_avg_y);
let mut line_sorted = line.clone();
line_sorted.sort_by(|a, b| a.x.partial_cmp(&b.x).unwrap());
let mut current_char_pos: f32 = 0.0;
let mut prev_token_end_x: f32 = min_x;
for (i, token) in line_sorted.iter().enumerate() {
let target_char_pos = (token.x - min_x) / avg_char_width;
let spaces_needed = if i == 0 {
target_char_pos.max(0.0)
} else {
let physical_gap = token.x - prev_token_end_x;
if physical_gap < x_gap_threshold {
1.0
} else {
target_char_pos - current_char_pos
}
};
let spaces_to_insert = if i > 0 {
spaces_needed.round().max(1.0) as usize
} else {
spaces_needed.round() as usize
};
for _ in 0..spaces_to_insert {
result.push(' ');
}
result.push_str(&token.text);
current_char_pos += spaces_to_insert as f32 + token.text.len() as f32;
prev_token_end_x = token.x + token.width;
}
result.push('\n');
}
result
}
}