#[derive(Debug, Clone)]
pub struct TextSpan {
pub text: String,
pub x: f32,
pub y: f32,
pub width: f32,
pub height: f32,
pub rotation_degrees: f32,
}
const UNROTATED_DEGREES: f32 = 0.0;
const ROTATION_TOLERANCE_DEGREES: f32 = 0.001;
pub(crate) const ATOMIC_FRAGMENT_GAP_RATIO: f32 = 0.15;
const ROTATED_LINE_CROSS_TOLERANCE_RATIO: f32 = 0.5;
pub(crate) fn upright_reading_origin(span: &TextSpan) -> (f32, f32) {
if is_unrotated(span.rotation_degrees) {
return (span.x, span.y);
}
let (sin, cos) = (-span.rotation_degrees).to_radians().sin_cos();
(span.x * cos - span.y * sin, span.x * sin + span.y * cos)
}
pub(crate) fn assemble_reading_order_text(spans: &[TextSpan], order: &[usize]) -> String {
let mut text = String::new();
let mut run_start = 0;
while run_start < order.len() {
let rotation = span_rotation(spans, order[run_start]);
let mut run_end = run_start + 1;
while run_end < order.len() && same_rotation(span_rotation(spans, order[run_end]), rotation) {
run_end += 1;
}
if run_start > 0 && !text.is_empty() && !text.ends_with(char::is_whitespace) {
text.push(' ');
}
append_run(&mut text, spans, &order[run_start..run_end], rotation);
run_start = run_end;
}
text
}
fn span_rotation(spans: &[TextSpan], index: usize) -> f32 {
spans.get(index).map_or(UNROTATED_DEGREES, |span| span.rotation_degrees)
}
fn is_unrotated(rotation: f32) -> bool {
rotation.is_finite() && (rotation - UNROTATED_DEGREES).abs() <= ROTATION_TOLERANCE_DEGREES
}
fn same_rotation(left: f32, right: f32) -> bool {
left.is_finite() && right.is_finite() && (left - right).abs() <= ROTATION_TOLERANCE_DEGREES
}
fn append_run(text: &mut String, spans: &[TextSpan], indices: &[usize], rotation: f32) {
if is_unrotated(rotation) {
for &index in indices {
if let Some(span) = spans.get(index) {
text.push_str(&span.text);
}
}
return;
}
let mut line_start = 0;
let mut first_line = true;
while line_start < indices.len() {
let Some(anchor) = spans.get(indices[line_start]) else {
line_start += 1;
continue;
};
let (_, anchor_cross) = upright_reading_origin(anchor);
let mut line_end = line_start + 1;
while line_end < indices.len() {
let Some(candidate) = spans.get(indices[line_end]) else {
break;
};
let (_, candidate_cross) = upright_reading_origin(candidate);
let tolerance = anchor.height.max(candidate.height).max(f32::EPSILON) * ROTATED_LINE_CROSS_TOLERANCE_RATIO;
if (candidate_cross - anchor_cross).abs() > tolerance {
break;
}
line_end += 1;
}
if !first_line && !text.is_empty() && !text.ends_with(char::is_whitespace) {
text.push(' ');
}
first_line = false;
append_rotated_line(text, spans, &indices[line_start..line_end]);
line_start = line_end;
}
}
fn append_rotated_line(text: &mut String, spans: &[TextSpan], indices: &[usize]) {
let mut ordered: Vec<usize> = indices.to_vec();
ordered.sort_by(|&a, &b| {
let advance_a = spans.get(a).map_or(0.0, |span| upright_reading_origin(span).0);
let advance_b = spans.get(b).map_or(0.0, |span| upright_reading_origin(span).0);
advance_a.total_cmp(&advance_b)
});
let mut previous_advance_end: Option<f32> = None;
for index in ordered {
let Some(span) = spans.get(index) else { continue };
let (advance_start, _) = upright_reading_origin(span);
if let Some(previous_end) = previous_advance_end {
let gap = advance_start - previous_end;
let kerning_limit = span.height.max(f32::EPSILON) * ATOMIC_FRAGMENT_GAP_RATIO;
if gap > kerning_limit && !text.is_empty() && !text.ends_with(char::is_whitespace) {
text.push(' ');
}
}
text.push_str(&span.text);
previous_advance_end = Some(advance_start + span.width);
}
}
pub(crate) fn page_has_rotated_spans(spans: &[TextSpan]) -> bool {
spans.iter().any(|span| !is_unrotated(span.rotation_degrees))
}
const MIN_ROTATED_TEXT_SHARE: f32 = 0.2;
fn rotation_is_dominant(spans: &[TextSpan]) -> bool {
let mut rotated_chars = 0usize;
let mut total_chars = 0usize;
for span in spans {
let chars = span.text.chars().count();
total_chars += chars;
if !is_unrotated(span.rotation_degrees) {
rotated_chars += chars;
}
}
total_chars > 0 && (rotated_chars as f32 / total_chars as f32) >= MIN_ROTATED_TEXT_SHARE
}
pub(crate) fn repair_rotated_page_text(spans: &[TextSpan]) -> Option<String> {
if !page_has_rotated_spans(spans) || !rotation_is_dominant(spans) {
return None;
}
let identity_order: Vec<usize> = (0..spans.len()).collect();
Some(assemble_reading_order_text(spans, &identity_order))
}
#[cfg(test)]
mod tests {
use super::*;
mod issue_292_span_assembly {
use super::*;
fn rotated_word(text: &str, y: f32, width: f32) -> TextSpan {
TextSpan {
text: text.to_string(),
x: 100.0,
y,
width,
height: 10.0,
rotation_degrees: 90.0,
}
}
fn scrambled_rotated_sentence() -> Vec<TextSpan> {
vec![
rotated_word("Engine", 0.0, 36.0),
rotated_word("oil", 39.0, 18.0),
rotated_word("need", 60.0, 24.0),
rotated_word("only", 87.0, 24.0),
rotated_word("meet", 114.0, 24.0),
rotated_word("the", 141.0, 18.0),
]
}
#[test]
fn should_reassemble_rotated_run_in_advance_order_with_word_gaps() {
let spans = scrambled_rotated_sentence();
let order = vec![5, 4, 3, 2, 1, 0];
let text = assemble_reading_order_text(&spans, &order);
assert_eq!(
text, "Engine oil need only meet the",
"rotated-run assembly must read along the advance axis and space real word gaps"
);
}
#[test]
fn should_not_insert_space_for_kerning_tight_rotated_fragments() {
let spans = vec![rotated_word("Eng", 0.0, 18.0), rotated_word("ine", 18.5, 18.0)];
let order = vec![0, 1];
let text = assemble_reading_order_text(&spans, &order);
assert_eq!(
text, "Engine",
"kerning-tight rotated fragments must glue, not space, together"
);
}
#[test]
fn should_not_force_one_frame_across_a_rotation_boundary() {
let spans = vec![
rotated_word("oil", 39.0, 18.0), rotated_word("Engine", 0.0, 36.0), TextSpan {
text: "Page 264".to_string(),
x: 500.0,
y: 10.0,
width: 40.0,
height: 8.0,
rotation_degrees: 0.0,
},
];
let order = vec![0, 1, 2];
let text = assemble_reading_order_text(&spans, &order);
assert_eq!(
text, "Engine oil Page 264",
"the rotated run resolves to advance order internally; a separator is inserted \
at the rotation boundary so the upright run after it is never glued to the \
rotated run, even though the upright run's own text is untouched pass-through"
);
}
#[test]
fn should_leave_unrotated_spans_byte_identical_to_plain_concatenation() {
let spans = vec![
TextSpan {
text: "second".to_string(),
x: 300.0,
y: 0.0,
width: 40.0,
height: 10.0,
rotation_degrees: 0.0,
},
TextSpan {
text: "first".to_string(),
x: 0.0,
y: 0.0,
width: 40.0,
height: 10.0,
rotation_degrees: 0.0,
},
];
let order = vec![0, 1];
let text = assemble_reading_order_text(&spans, &order);
assert_eq!(
text, "secondfirst",
"unrotated spans must pass through in the given order with no separators, unchanged"
);
}
}
mod rotation_repair_without_layout_hints {
use super::*;
fn rotated_word(text: &str, y: f32, width: f32) -> TextSpan {
TextSpan {
text: text.to_string(),
x: 100.0,
y,
width,
height: 10.0,
rotation_degrees: 90.0,
}
}
fn upright_word(text: &str, x: f32, width: f32) -> TextSpan {
TextSpan {
text: text.to_string(),
x,
y: 0.0,
width,
height: 10.0,
rotation_degrees: 0.0,
}
}
fn scrambled_rotated_page() -> Vec<TextSpan> {
vec![
rotated_word("the", 141.0, 18.0),
rotated_word("meet", 114.0, 24.0),
rotated_word("only", 87.0, 24.0),
rotated_word("need", 60.0, 24.0),
rotated_word("oil", 39.0, 18.0),
rotated_word("Engine", 0.0, 36.0),
]
}
#[test]
fn should_report_no_rotated_spans_when_every_span_is_upright() {
let spans = vec![upright_word("first", 0.0, 40.0), upright_word("second", 50.0, 40.0)];
assert!(
!page_has_rotated_spans(&spans),
"an all-upright page must not be flagged as rotated"
);
}
#[test]
fn should_report_rotated_spans_when_any_single_span_is_rotated() {
let spans = vec![
upright_word("first", 0.0, 40.0),
rotated_word("Engine", 0.0, 36.0),
upright_word("second", 50.0, 40.0),
];
assert!(
page_has_rotated_spans(&spans),
"one rotated span anywhere on the page is enough to need the repair"
);
}
#[test]
fn should_return_none_for_an_unrotated_page() {
let spans = vec![upright_word("second", 300.0, 40.0), upright_word("first", 0.0, 40.0)];
assert!(
repair_rotated_page_text(&spans).is_none(),
"a page with no rotated span must be left untouched, not re-assembled \
into positional order"
);
}
#[test]
fn should_repair_a_ninety_degree_page_from_identity_span_order() {
let spans = scrambled_rotated_page();
let naive: String = spans.iter().map(|span| span.text.as_str()).collect();
assert_eq!(
naive, "themeetonlyneedoilEngine",
"guard: the unrepaired page really is word-reversed and glued"
);
let repaired = repair_rotated_page_text(&spans);
assert_eq!(
repaired.as_deref(),
Some("Engine oil need only meet the"),
"the rotated run must be re-read along its own advance axis with word gaps restored"
);
}
#[test]
fn should_keep_the_unrotated_run_in_legacy_order_on_a_mixed_page() {
let spans = vec![
rotated_word("oil", 39.0, 18.0),
rotated_word("Engine", 0.0, 36.0),
upright_word("second", 300.0, 40.0),
upright_word("first", 0.0, 40.0),
];
let repaired = repair_rotated_page_text(&spans);
assert_eq!(
repaired.as_deref(),
Some("Engine oil secondfirst"),
"the rotated run resolves to advance order; the upright run keeps its given \
order and remains separated from the rotated frame"
);
}
mod rotation_dominance_gate {
use super::*;
#[test]
fn should_not_repair_when_rotated_text_is_a_small_minority_of_the_page() {
let spans = vec![
upright_word(
"This upright paragraph carries the overwhelming majority of the page text",
0.0,
400.0,
),
upright_word(
"and must keep its normal paragraph and line-break assembly untouched",
50.0,
400.0,
),
rotated_word("2.1", 500.0, 12.0),
];
assert!(
page_has_rotated_spans(&spans),
"guard: the page does carry a rotated span"
);
assert_eq!(
repair_rotated_page_text(&spans),
None,
"a minority rotated label must not trigger a full-page rewrite that costs \
the upright majority its own assembly"
);
}
#[test]
fn should_repair_when_rotated_text_dominates_the_page() {
let spans = scrambled_rotated_page();
assert!(
repair_rotated_page_text(&spans).is_some(),
"an entirely-rotated page must still be repaired"
);
}
}
}
}