use crate::charinfo::{
CharBox, CharType, LooseBoundsInput, ObjectIndex, inverse_or_zero, loose_bounds, matrix_angle,
transform_distance, transform_rect,
};
use crate::line::{Line, Output};
use crate::object::{GlyphWidth, Item, TextRun, ladder_char_width};
use crate::orientation::{Orientation, object_flow};
use crate::unicode::{is_alnum, is_alpha, is_print};
use kurbo::{Affine, Point, Rect};
use pdfrum_common::{DiagKind, Diagnostics, Severity};
use pdfrum_font::CharCode;
use pdfrum_object::{Name, Resolve};
pub(crate) use crate::object::SIZE_EPSILON;
pub(crate) const SOFT_HYPHEN: u32 = 0x00AD;
pub(crate) const UNMAPPABLE: u32 = 0xFFFD;
const DEFAULT_FONT_SIZE: f32 = 1.0;
fn actual_text_key() -> Name {
Name::from("ActualText")
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Generate {
None,
Space,
LineBreak,
Hyphen,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum MarkState {
Pass,
Done,
Delay,
}
#[derive(Debug, Clone)]
struct Previous {
index: ObjectIndex,
}
pub(crate) struct Builder<'a, R: Resolve> {
pub out: Output,
line: Line,
runs: &'a [TextRun],
batch: Vec<usize>,
previous: Option<Previous>,
page_flow: Orientation,
line_rect: Rect,
display: Affine,
rtl: bool,
keep_spaces_only: bool,
resolver: &'a R,
}
fn draws_only_spaces(run: &TextRun) -> bool {
run.count() > 0
&& (0..run.count())
.filter_map(|index| run.item(index))
.all(|item| {
run.font
.unicode_from_charcode(item.code)
.first()
.is_some_and(|ch| *ch == ' ')
})
}
impl<'a, R: Resolve> Builder<'a, R> {
pub(crate) fn new(
runs: &'a [TextRun],
page_flow: Orientation,
display: Affine,
rtl: bool,
resolver: &'a R,
) -> Self {
Self {
out: Output::default(),
line: Line::default(),
runs,
batch: Vec::new(),
previous: None,
page_flow,
line_rect: Rect::ZERO,
display,
rtl,
keep_spaces_only: matches!(runs, [run] if draws_only_spaces(run)),
resolver,
}
}
fn run(&self, index: usize) -> Option<&'a TextRun> {
self.runs.get(index)
}
pub(crate) fn offer(&mut self, index: usize, diags: &mut Diagnostics) {
let Some(run) = self.run(index) else { return };
if !crate::object::gate(run).keeps(self.keep_spaces_only) {
diags.record(Severity::Recovered, DiagKind::TextObjectDegenerate, None);
return;
}
let Some(&last) = self.batch.last() else {
self.batch.push(index);
return;
};
let Some(previous) = self.run(last) else {
return;
};
if previous.count() == 0 {
diags.record(Severity::Recovered, DiagKind::TextObjectDropped, None);
return;
}
let previous_width = previous
.item(previous.count() - 1)
.map_or(GlyphWidth::ZERO, |item| {
ladder_char_width(previous, Some(item.code))
});
let previous_width =
(previous_width.as_f64() * f64::from(previous.font_size) / 1000.0).abs();
let previous_width = transform_distance(previous.text_matrix, previous_width);
let this_width = run.item(0).map_or(GlyphWidth::ZERO, |item| {
ladder_char_width(run, Some(item.code))
});
let this_width = (this_width.as_f64() * f64::from(run.font_size) / 1000.0).abs();
let this_width = transform_distance(run.text_matrix, this_width);
let threshold = previous_width.max(this_width) / 4.0;
let previous_pos = self.display * previous.position;
let this_pos = self.display * run.position;
if (this_pos.y - previous_pos.y).abs() > threshold * 2.0 {
self.flush(diags);
self.batch.clear();
self.batch.push(index);
return;
}
for slot in (1..=self.batch.len()).rev() {
let Some(&earlier) = self.batch.get(slot - 1) else {
continue;
};
let Some(earlier) = self.run(earlier) else {
continue;
};
if this_pos.x >= (self.display * earlier.position).x {
self.batch.insert(slot, index);
return;
}
}
self.batch.insert(0, index);
}
pub(crate) fn flush(&mut self, diags: &mut Diagnostics) {
let batch = std::mem::take(&mut self.batch);
for index in batch {
let Some(run) = self.run(index) else { continue };
if !crate::object::gate(run).keeps(self.keep_spaces_only) {
continue;
}
let state = self.pre_marked_content(run, diags);
if state == MarkState::Done {
self.previous = Some(Previous { index: run.index });
continue;
}
if self.previous.is_some() {
let generate = self.decide(run);
if generate == Generate::LineBreak {
self.line_rect = run.rect;
} else {
self.line_rect = union(self.line_rect, run.rect);
}
if !self.apply(generate, run, diags) {
continue;
}
} else {
self.line_rect = run.rect;
}
if state == MarkState::Delay {
self.process_marked_content(run, diags);
self.previous = Some(Previous { index: run.index });
continue;
}
self.previous = Some(Previous { index: run.index });
let start = self.line.len();
if self.emit_items(run, diags) {
self.line.reverse_from(start);
}
}
}
pub(crate) fn close_line(&mut self) {
crate::line::close(&mut self.line, &mut self.out, self.rtl);
}
fn pre_marked_content(&self, run: &TextRun, diags: &mut Diagnostics) -> MarkState {
let marks = run.marks.marks();
if marks.is_empty() {
return MarkState::Pass;
}
let mut actual_text = None;
let mut last_dict = None;
for mark in marks {
let Some(dict) = mark.properties.as_ref() else {
continue;
};
last_dict = Some(dict);
if let Some(string) = dict.raw(&actual_text_key()).and_then(|o| o.as_string()) {
actual_text = Some(string.as_text().into_owned());
}
}
let Some(actual_text) = actual_text else {
return MarkState::Pass;
};
if let Some(previous) = self
.previous
.as_ref()
.and_then(|previous| self.find_run(previous.index))
{
let previous_marks = previous.marks.marks();
if previous_marks.len() == marks.len()
&& let (Some(a), Some(b)) = (previous_marks.last(), last_dict)
{
let same = a
.properties
.as_ref()
.is_some_and(|previous| std::sync::Arc::ptr_eq(previous, b));
if same {
return MarkState::Done;
}
}
}
if actual_text.is_empty() {
return MarkState::Pass;
}
let printable = actual_text
.chars()
.map(u32::from)
.any(|code| (0x80 < code && code < 0xFFFD) || (code <= 0x80 && is_print(code)));
if printable {
MarkState::Delay
} else {
diags.record(
Severity::Recovered,
DiagKind::TextActualTextUnprintable,
None,
);
MarkState::Done
}
}
fn process_marked_content(&mut self, run: &TextRun, diags: &mut Diagnostics) {
let mut actual_text = String::new();
for mark in run.marks.marks() {
let Some(dict) = mark.properties.as_ref() else {
continue;
};
actual_text = dict
.text(&actual_text_key(), self.resolver)
.unwrap_or_default();
}
let chars: Vec<char> = actual_text.chars().collect();
if chars.is_empty() {
return;
}
let is_rtl = Self::object_is_rtl(run);
let matrix = run.text_matrix;
let mut rect = run.rect;
#[expect(
clippy::cast_precision_loss,
reason = "a string long enough to lose precision here cannot be laid out anyway"
)]
let count = chars.len() as f64;
let step = if is_rtl {
rect.x0 = rect.x1 - rect.width() / count;
-rect.width()
} else {
rect.x1 = rect.x0 + rect.width() / count;
rect.width()
};
for (offset, ch) in chars.iter().enumerate() {
let mut code = u32::from(*ch);
if code <= 0x80 && !is_print(code) {
code = 0x20;
}
if code >= 0xFFFD {
diags.record(
Severity::Recovered,
DiagKind::TextActualTextCharDropped,
None,
);
continue;
}
#[expect(
clippy::cast_precision_loss,
reason = "an index this large cannot be laid out"
)]
let shift = offset as f64 * step;
let char_box = Rect::new(rect.x0 + shift, rect.y0, rect.x1 + shift, rect.y1);
let info = CharBox {
char_type: CharType::ActualText,
unicode: code,
code: None,
origin: run.position,
char_box,
loose_char_box: char_box,
matrix,
object: Some(run.index),
font_size: run.font_size,
angle: matrix_angle(matrix),
};
self.line.push(code, info);
}
}
fn emit_items(&mut self, run: &TextRun, diags: &mut Diagnostics) -> bool {
let matrix = run.text_matrix;
let base_space = base_space(run, matrix) + base_space_adjustment(run, matrix);
let mut spacing = 0.0f64;
let mut unmapped = 0u32;
for index in 0..run.count() {
let Some(item) = run.item(index) else {
continue;
};
if index > 0 && run.kerning(index - 1) != 0.0 {
let last = self
.line
.last_unit()
.or_else(|| self.out.text.last().copied());
if last.is_some_and(|unit| unit != u32::from(b' ')) {
spacing = f64::from(-run.font_size_h * run.kerning(index - 1) / 1000.0);
}
}
spacing -= base_space;
if spacing != 0.0 && index > 0 {
let threshold = space_threshold(run, item.code);
if threshold != 0.0 && spacing >= threshold {
let origin = matrix * item.origin;
self.line.push(
u32::from(b' '),
CharBox {
char_type: CharType::Generated,
unicode: u32::from(b' '),
code: None,
origin,
char_box: Rect::new(origin.x, origin.y, origin.x, origin.y),
loose_char_box: Rect::new(origin.x, origin.y, origin.x, origin.y),
matrix: Affine::IDENTITY,
object: Some(run.index),
font_size: run.font_size,
angle: 0.0,
},
);
}
}
spacing = 0.0;
let mut unicode: Vec<u32> = run
.font
.unicode_from_charcode(item.code)
.into_iter()
.map(u32::from)
.collect();
let mut char_type = CharType::Normal;
if unicode.is_empty() && item.code.0 != 0 {
unicode.push(item.code.0);
char_type = CharType::NotUnicode;
unmapped = unmapped.saturating_add(1);
}
let info = Self::build_char(run, item, char_type, matrix);
if unicode.is_empty() {
diags.record(Severity::Recovered, DiagKind::TextCharcodeZero, None);
self.line.push(0xFFFE, info);
continue;
}
for code in unicode {
let mut piece = info;
piece.unicode = code;
self.line
.push(if code == 0 { UNMAPPABLE } else { code }, piece);
}
}
if unmapped > 0 {
diags.record(
Severity::Recovered,
DiagKind::TextCharcodesUnmapped(unmapped),
None,
);
}
let is_rtl = Self::object_is_rtl(run);
let [a, b, c, d, ..] = matrix.as_coeffs();
is_rtl && (a * d - b * c) < 0.0
}
fn build_char(run: &TextRun, item: Item, char_type: CharType, matrix: Affine) -> CharBox {
let bbox = run.glyph_bbox(item.code);
let scale = f64::from(run.font_size) / 1000.0;
let mut char_box = Rect::new(
bbox.x0 * scale + item.origin.x,
bbox.y0 * scale + item.origin.y,
bbox.x1 * scale + item.origin.x,
bbox.y1 * scale + item.origin.y,
);
if (char_box.y1 - char_box.y0).abs() < SIZE_EPSILON {
char_box.y1 = char_box.y0 + scale;
}
if (char_box.x1 - char_box.x0).abs() < SIZE_EPSILON {
char_box.x1 = char_box.x0 + f64::from(run.scaled_char_width(item.code));
}
let char_box = transform_rect(matrix, char_box);
let origin = matrix * item.origin;
let loose = loose_bounds(&LooseBoundsInput {
char_box,
origin,
matrix,
code: Some(item.code),
font: Some(&run.font),
font_size: run.font_size,
scaled_width: run.scaled_char_width(item.code),
});
CharBox {
char_type,
unicode: 0,
code: Some(item.code),
origin,
char_box,
loose_char_box: loose,
matrix,
object: Some(run.index),
font_size: run.font_size,
angle: matrix_angle(matrix),
}
}
fn find_run(&self, index: ObjectIndex) -> Option<&'a TextRun> {
self.runs.iter().find(|run| run.index == index)
}
fn object_is_rtl(run: &TextRun) -> bool {
let codes: Vec<u32> = (0..run.count())
.filter_map(|index| run.item(index))
.filter_map(|item| {
let unicode = run.font.unicode_from_charcode(item.code);
let code = unicode.first().map_or(item.code.0, |ch| u32::from(*ch));
(code != 0).then_some(code)
})
.collect();
crate::bidi::is_right_to_left(&codes)
}
fn previous_run(&self) -> Option<&'a TextRun> {
let last = self.line.last_char().or_else(|| self.out.chars.last());
let from_char = last
.and_then(|info| info.object)
.and_then(|index| self.find_run(index));
from_char.or_else(|| {
self.previous
.as_ref()
.and_then(|previous| self.find_run(previous.index))
})
}
fn decide(&self, run: &TextRun) -> Generate {
let Some(previous) = self.previous_run() else {
return Generate::None;
};
let mut mode = object_flow(run, self.page_flow);
if mode == Orientation::Unknown {
mode = object_flow(previous, self.page_flow);
}
let count = previous.count();
if count == 0 {
return Generate::None;
}
let (Some(previous_item), Some(item)) = (previous.item(count - 1), run.item(0)) else {
return Generate::None;
};
let this_rect = run.rect;
let previous_rect = previous.rect;
let current_char = run
.font
.unicode_from_charcode(item.code)
.first()
.map_or(item.code.0, |ch| u32::from(*ch));
let ends_line = match mode {
Orientation::Horizontal => ends_horizontal_line(this_rect, previous_rect),
Orientation::Vertical => {
ends_vertical_line(this_rect, self.line_rect, run.font_size, previous.font_size)
}
Orientation::Unknown => false,
};
if ends_line {
return self.hyphen_or_break(current_char);
}
let last_pos = previous_item.origin.x;
let last_glyph_width = ladder_char_width(previous, Some(previous_item.code));
let last_width = (last_glyph_width.as_f64() * f64::from(previous.font_size) / 1000.0).abs();
let this_glyph_width = ladder_char_width(run, Some(item.code));
let this_width = (this_glyph_width.as_f64() * f64::from(run.font_size) / 1000.0).abs();
let mut threshold = last_width.max(this_width) / 4.0;
let previous_inverse = inverse_or_zero(previous.text_matrix);
let pos = previous_inverse * run.position;
if last_width < this_width {
threshold = transform_distance(previous_inverse, threshold);
}
if mode == Orientation::Horizontal && self.is_newline(previous, run, pos, threshold) {
return self.hyphen_or_break(current_char);
}
if run.count() == 1 && is_hyphen_code(current_char) && self.is_hyphen(current_char) {
return Generate::Hyphen;
}
if current_char == u32::from(b' ') {
return Generate::None;
}
let previous_char = previous
.font
.unicode_from_charcode(previous_item.code)
.last()
.map_or(0, |ch| u32::from(*ch));
if previous_char == u32::from(b' ') {
return Generate::None;
}
let mut threshold2 = last_glyph_width.max(this_glyph_width).as_f64();
threshold2 = normalize_threshold(threshold2, 400.0, 700.0, 800.0);
if last_glyph_width >= this_glyph_width {
threshold2 *= f64::from(previous.font_size.abs());
} else {
threshold2 *= f64::from(run.font_size.abs());
threshold2 = transform_distance(run.text_matrix, threshold2);
threshold2 = transform_distance(previous_inverse, threshold2);
}
threshold2 /= 1000.0;
if (threshold2 < 1.4881 && threshold2 > 1.4879)
|| (threshold2 < 1.39001 && threshold2 > 1.38999)
{
threshold2 *= 1.5;
}
if generates_space(pos, last_pos, this_width, last_width, threshold2) {
Generate::Space
} else {
Generate::None
}
}
fn is_newline(&self, previous: &TextRun, run: &TextRun, pos: Point, threshold: f64) -> bool {
let rect = previous.rect;
let height = rect.y1 - rect.y0;
let normalized = normalize_rect(rect);
let empty = normalized.x1 <= normalized.x0 || normalized.y1 <= normalized.y0;
let jumped = (pos.y > threshold * 2.0 || pos.y < threshold * -3.0)
&& (pos.y.abs() >= 1.0 || pos.y.abs() > pos.x.abs());
if !((empty && height > 5.0) || jumped) {
return false;
}
if previous.count() <= 1 {
return true;
}
let (Some(first), Some(last)) = (previous.item(0), previous.item(previous.count() - 1))
else {
return true;
};
let [da, db, dc, dd, ..] = self.display.as_coeffs();
let [_, mb, mc, ..] = previous.text_matrix.as_coeffs();
if last.origin.x > first.origin.x
&& da > 0.9
&& db < 0.1
&& dc < 0.1
&& dd < -0.9
&& mb < 0.1
&& mc < 0.1
{
let band = Rect::new(0.0, previous.rect.y0, 1000.0, previous.rect.y1);
if contains(band, run.position) {
return false;
}
let other = Rect::new(0.0, run.rect.y0, 1000.0, run.rect.y1);
if contains(other, previous.position) {
return false;
}
}
true
}
fn hyphen_or_break(&self, current_char: u32) -> Generate {
if self.is_hyphen(current_char) {
Generate::Hyphen
} else {
Generate::LineBreak
}
}
fn is_hyphen(&self, current_char: u32) -> bool {
let staged = self.line.text();
let text: &[u32] = if staged.is_empty() {
&self.out.text
} else {
staged
};
if text.is_empty() {
return false;
}
let mut at = text.len() - 1;
while at > 0 && text.get(at) == Some(&0x20) {
at -= 1;
}
let Some(&candidate) = text.get(at) else {
return false;
};
if !is_hyphen_code(candidate) {
return false;
}
if at > 0
&& let Some(&before) = text.get(at - 1)
&& is_alpha(before)
&& is_alnum(current_char)
{
return true;
}
let previous = self.line.last_char().or_else(|| self.out.chars.last());
previous.is_some_and(|info| {
matches!(info.char_type, CharType::Piece | CharType::ActualText)
&& is_hyphen_code(info.unicode)
})
}
fn apply(&mut self, generate: Generate, run: &TextRun, diags: &mut Diagnostics) -> bool {
match generate {
Generate::None => true,
Generate::Space => {
self.append_generated(u32::from(b' '), true);
true
}
Generate::LineBreak => {
self.close_line();
if !self.out.text.is_empty() {
self.append_generated(u32::from('\r'), false);
self.append_generated(u32::from('\n'), false);
}
true
}
Generate::Hyphen => self.apply_hyphen(run, diags),
}
}
fn apply_hyphen(&mut self, run: &TextRun, diags: &mut Diagnostics) -> bool {
if run.count() == 1
&& let Some(item) = run.item(0)
{
let code = run
.font
.unicode_from_charcode(item.code)
.first()
.map_or(item.code.0, |ch| u32::from(*ch));
if is_hyphen_code(code) {
return false;
}
}
while self.line.last_unit() == Some(0x20) {
self.line.pop();
}
let Some(last) = self.line.last_char_mut() else {
diags.record(Severity::Suspicious, DiagKind::TextHyphenNoPrevChar, None);
return true;
};
last.char_type = CharType::Hyphen;
last.unicode = 0x2;
self.line.set_last_unit(SOFT_HYPHEN);
true
}
fn append_generated(&mut self, unicode: u32, staged: bool) {
let Some(previous) = self.line.last_char().or_else(|| self.out.chars.last()) else {
return;
};
let previous = *previous;
let run = previous.object.and_then(|index| self.find_run(index));
let width = match (run, previous.code) {
(Some(run), Some(code)) => ladder_char_width(run, Some(code)),
_ => GlyphWidth::ZERO,
};
let mut font_size = run.map_or_else(
|| {
#[expect(
clippy::cast_possible_truncation,
reason = "a page-space height narrowed to the f32 the C++ uses"
)]
let height = previous.char_box.height() as f32;
height
},
|run| run.font_size,
);
if font_size == 0.0 {
font_size = DEFAULT_FONT_SIZE;
}
let origin = Point::new(
previous.origin.x + width.as_f64() * f64::from(font_size) / 1000.0,
previous.origin.y,
);
let info = CharBox {
char_type: CharType::Generated,
unicode,
code: None,
origin,
char_box: Rect::new(origin.x, origin.y, origin.x, origin.y),
loose_char_box: Rect::new(origin.x, origin.y, origin.x, origin.y),
matrix: Affine::IDENTITY,
object: None,
font_size: DEFAULT_FONT_SIZE,
angle: 0.0,
};
if staged {
self.line.push(unicode, info);
} else {
self.out.text.push(unicode);
self.out.chars.push(info);
}
}
}
#[must_use]
pub(crate) fn is_hyphen_code(code: u32) -> bool {
code == 0x2D || code == 0xAD
}
#[must_use]
pub(crate) fn normalize_threshold(threshold: f64, t1: f64, t2: f64, t3: f64) -> f64 {
if threshold < t1 {
threshold / 2.0
} else if threshold < t2 {
threshold / 4.0
} else if threshold < t3 {
threshold / 5.0
} else {
threshold / 6.0
}
}
#[must_use]
pub(crate) fn base_space(run: &TextRun, matrix: Affine) -> f64 {
let count = run.count();
if run.char_space == 0.0 || count < 2 {
return 0.0;
}
let spacing = transform_distance(matrix, f64::from(run.char_space));
let mut base = spacing;
let mut has_kerning = false;
for kerning in &run.kernings {
if *kerning != 0.0 {
let adjusted = f64::from(-run.font_size_h * kerning / 1000.0);
base = base.min(adjusted + spacing);
has_kerning = true;
}
}
if base < 0.0 || (count == 2 && has_kerning) {
return 0.0;
}
base
}
#[must_use]
pub(crate) fn base_space_adjustment(run: &TextRun, matrix: Affine) -> f64 {
let char_space = run.char_space;
if char_space > 0.001 {
return -transform_distance(matrix, f64::from(char_space));
}
if char_space < -0.001 {
return transform_distance(matrix, f64::from(char_space.abs()));
}
0.0
}
#[must_use]
pub(crate) fn space_threshold(run: &TextRun, code: CharCode) -> f64 {
let font_size_h = f64::from(run.font_size_h);
let mut threshold = 0.0;
if let Some(space) = run.font.char_code_from_unicode(' ') {
threshold = font_size_h * f64::from(run.font.char_width(space)) / 1000.0;
}
if threshold > font_size_h / 3.0 {
threshold = 0.0;
} else {
threshold /= 2.0;
}
if threshold == 0.0 {
threshold = ladder_char_width(run, Some(code)).as_f64();
threshold = normalize_threshold(threshold, 300.0, 500.0, 700.0);
threshold = font_size_h * threshold / 1000.0;
}
threshold
}
#[must_use]
pub(crate) fn generates_space(
pos: Point,
last_pos: f64,
this_width: f64,
last_width: f64,
threshold: f64,
) -> bool {
if (last_pos + last_width - pos.x).abs() <= threshold {
return false;
}
let threshold_pos = threshold + last_width;
let difference = pos.x - last_pos;
if difference.abs() > threshold_pos {
return true;
}
if pos.x < 0.0 && -threshold_pos > difference {
return true;
}
difference > this_width + last_width
}
#[must_use]
pub(crate) fn ends_horizontal_line(this_rect: Rect, previous_rect: Rect) -> bool {
if this_rect.height() <= 4.5 || previous_rect.height() <= 4.5 {
return false;
}
let top = this_rect.y1.min(previous_rect.y1);
let bottom = this_rect.y0.max(previous_rect.y0);
bottom >= top
}
#[must_use]
pub(crate) fn ends_vertical_line(
this_rect: Rect,
line_rect: Rect,
font_size: f32,
previous_font_size: f32,
) -> bool {
if this_rect.width() <= f64::from(font_size) * 0.1
|| line_rect.width() <= f64::from(previous_font_size) * 0.1
{
return false;
}
let left = this_rect.x0.max(line_rect.x0);
let right = this_rect.x1.min(line_rect.x1);
right <= left
}
fn union(a: Rect, b: Rect) -> Rect {
Rect::new(
a.x0.min(b.x0),
a.y0.min(b.y0),
a.x1.max(b.x1),
a.y1.max(b.y1),
)
}
fn normalize_rect(rect: Rect) -> Rect {
Rect::new(
rect.x0.min(rect.x1),
rect.y0.min(rect.y1),
rect.x0.max(rect.x1),
rect.y0.max(rect.y1),
)
}
fn contains(rect: Rect, point: Point) -> bool {
point.x >= rect.x0 && point.x <= rect.x1 && point.y >= rect.y0 && point.y <= rect.y1
}
#[cfg(test)]
mod tests {
#![allow(
clippy::float_cmp,
clippy::indexing_slicing,
clippy::unreadable_literal,
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
reason = "test fixtures quote oracle vectors verbatim and compare exactly"
)]
use super::*;
#[test]
fn the_threshold_bucketer_divides_by_two_four_five_and_six() {
assert_eq!(normalize_threshold(299.0, 300.0, 500.0, 700.0), 149.5);
assert_eq!(normalize_threshold(300.0, 300.0, 500.0, 700.0), 75.0);
assert_eq!(normalize_threshold(499.0, 300.0, 500.0, 700.0), 124.75);
assert_eq!(normalize_threshold(500.0, 300.0, 500.0, 700.0), 100.0);
assert_eq!(normalize_threshold(699.0, 300.0, 500.0, 700.0), 139.8);
assert_eq!(normalize_threshold(700.0, 300.0, 500.0, 700.0), 700.0 / 6.0);
assert_eq!(normalize_threshold(399.0, 400.0, 700.0, 800.0), 199.5);
assert_eq!(normalize_threshold(400.0, 400.0, 700.0, 800.0), 100.0);
assert_eq!(normalize_threshold(700.0, 400.0, 700.0, 800.0), 140.0);
assert_eq!(normalize_threshold(800.0, 400.0, 700.0, 800.0), 800.0 / 6.0);
assert_eq!(normalize_threshold(0.0, 300.0, 500.0, 700.0), 0.0);
assert_eq!(normalize_threshold(-10.0, 300.0, 500.0, 700.0), -5.0);
}
#[test]
fn hyphen_codes_are_the_two_the_cpp_lists() {
assert!(is_hyphen_code(0x2D));
assert!(is_hyphen_code(0xAD));
assert!(!is_hyphen_code(0x2010));
assert!(!is_hyphen_code(0x2D + 1));
}
#[test]
fn a_short_object_never_ends_a_horizontal_line() {
let short = Rect::new(0.0, 0.0, 10.0, 4.5);
let tall = Rect::new(0.0, 20.0, 10.0, 30.0);
assert!(!ends_horizontal_line(short, tall));
assert!(!ends_horizontal_line(tall, short));
let just_tall = Rect::new(0.0, 0.0, 10.0, 4.6);
assert!(ends_horizontal_line(just_tall, tall));
let overlapping = Rect::new(0.0, 25.0, 10.0, 35.0);
assert!(!ends_horizontal_line(overlapping, tall));
}
#[test]
fn a_narrow_object_never_ends_a_vertical_line() {
let narrow = Rect::new(0.0, 0.0, 1.0, 100.0);
let line = Rect::new(50.0, 0.0, 60.0, 100.0);
assert!(!ends_vertical_line(narrow, line, 10.0, 10.0));
let wide = Rect::new(0.0, 0.0, 20.0, 100.0);
assert!(ends_vertical_line(wide, line, 10.0, 10.0));
let overlapping = Rect::new(55.0, 0.0, 80.0, 100.0);
assert!(!ends_vertical_line(overlapping, line, 10.0, 10.0));
}
#[test]
fn space_generation_has_three_independent_clauses() {
let at = |x: f64| Point::new(x, 0.0);
assert!(!generates_space(at(10.5), 0.0, 5.0, 10.0, 1.0));
assert!(generates_space(at(20.0), 0.0, 5.0, 5.0, 1.0));
assert!(!generates_space(at(11.0), 0.0, 2.0, 3.0, 20.0));
assert!(generates_space(at(11.0), 0.0, 2.0, 3.0, 1.0));
assert!(generates_space(at(-30.0), 0.0, 5.0, 5.0, 1.0));
}
#[test]
fn the_two_magic_float_bands_are_exclusive_ranges() {
let in_band = |t: f64| (t < 1.4881 && t > 1.4879) || (t < 1.39001 && t > 1.38999);
assert!(in_band(1.4880));
assert!(in_band(1.3900));
assert!(!in_band(1.4879));
assert!(!in_band(1.4881));
assert!(!in_band(1.38999));
assert!(!in_band(1.39001));
assert!(!in_band(1.44));
}
}