use lopdf::{Object, StringFormat};
use super::fonts::get_operand_bytes;
use super::{get_number, is_spaceless_cjk, multiply_matrices};
use crate::text_utils::{expand_ligatures, is_rtl_char};
use crate::types::{FontWidthInfo, TextItem};
const MAX_BOUNDARY_GLYPHS: usize = 3;
const KERN_ALLOWANCE_EM: f32 = 0.12;
const TAKE_BACK_MIN: f32 = 0.7;
pub(crate) fn word_gap_threshold(font_info: Option<&FontWidthInfo>) -> f32 {
match font_info {
Some(font_info) => {
let space_em = font_info.space_width as f32 * font_info.units_scale;
(space_em * 1000.0 * 0.4).max(80.0)
}
None => 120.0,
}
}
pub(crate) fn spacing_is_word_gap(spacing_ts: f32, font_size: f32, threshold: f32) -> bool {
font_size > 0.0 && spacing_ts / font_size * 1000.0 > threshold
}
pub(crate) fn spacing_taken_back(taken_back_ts: f32, spacing_ts: f32, font_size: f32) -> bool {
spacing_ts > 0.0
&& taken_back_ts >= spacing_ts * TAKE_BACK_MIN
&& taken_back_ts <= spacing_ts + font_size.abs() * KERN_ALLOWANCE_EM
}
pub(crate) fn offset_takes_spacing_back(element: &Object, spacing_ts: f32, font_size: f32) -> bool {
get_number(element).is_some_and(|offset| {
offset > 0.0 && spacing_taken_back(offset / 1000.0 * font_size, spacing_ts, font_size)
})
}
fn is_space_code(code: &[u8]) -> bool {
matches!(code, [0x20] | [0x00, 0x20])
}
fn spacing_after(code: &[u8], char_spacing: f32, word_spacing: f32) -> f32 {
char_spacing
+ if is_space_code(code) {
word_spacing
} else {
0.0
}
}
fn junction_takes_space(prev_last: Option<char>, next_first: Option<char>) -> bool {
let (Some(prev), Some(next)) = (prev_last, next_first) else {
return false;
};
if prev.is_whitespace() || next.is_whitespace() {
return false;
}
if is_spaceless_cjk(prev) || is_spaceless_cjk(next) || is_rtl_char(prev) || is_rtl_char(next) {
return false;
}
!matches!(
next,
'.' | ',' | ';' | ':' | '!' | '?' | ')' | ']' | '}' | '%' | '\u{2019}' | '\u{201D}' | '»'
)
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct WordGapCandidate {
pub(crate) spaced_text: String,
pub(crate) trailing_spacing_ts: f32,
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn word_gap_candidate(
raw: &[u8],
text: &str,
font_info: Option<&FontWidthInfo>,
font_size: f32,
char_spacing: f32,
word_spacing: f32,
threshold: f32,
mut decode: impl FnMut(&Object) -> Option<(String, bool)>,
) -> Option<WordGapCandidate> {
let code_len = if font_info.is_some_and(|font_info| font_info.is_cid) {
2
} else {
1
};
if font_size <= 0.0 || !raw.len().is_multiple_of(code_len) {
return None;
}
let codes: Vec<&[u8]> = raw.chunks_exact(code_len).collect();
if !(2..=MAX_BOUNDARY_GLYPHS).contains(&codes.len()) {
return None;
}
let is_gap = |code: &[u8], paints: bool| {
let spacing = if is_space_code(code) && !paints {
char_spacing
} else {
spacing_after(code, char_spacing, word_spacing)
};
spacing_is_word_gap(spacing, font_size, threshold)
};
if !codes[..codes.len() - 1]
.iter()
.any(|code| is_gap(code, true))
{
return None;
}
let labels: Vec<String> = codes
.iter()
.map(|code| {
decode(&Object::String(code.to_vec(), StringFormat::Literal)).map(|(label, _)| label)
})
.collect::<Option<_>>()?;
if labels.concat() != text {
return None;
}
let mut spaced_text = String::new();
let mut spaces = 0usize;
for (index, label) in labels.iter().enumerate() {
spaced_text.push_str(label);
let Some(next) = labels.get(index + 1) else {
continue;
};
let paints = !label.chars().all(char::is_whitespace);
if is_gap(codes[index], paints)
&& junction_takes_space(label.chars().last(), next.chars().next())
{
spaced_text.push(' ');
spaces += 1;
}
}
(spaces > 0).then(|| WordGapCandidate {
spaced_text,
trailing_spacing_ts: spacing_after(codes[codes.len() - 1], char_spacing, word_spacing),
})
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct PendingWordGaps {
item: usize,
spaced_text: String,
pen: (f32, f32),
unit: (f32, f32),
spacing_ts: f32,
font_size: f32,
}
impl PendingWordGaps {
#[allow(clippy::too_many_arguments)]
pub(crate) fn for_shown_string(
item: usize,
raw: &[u8],
text: &str,
font_info: Option<&FontWidthInfo>,
font_size: f32,
char_spacing: f32,
word_spacing: f32,
pen_tm: &[f32; 6],
ctm: &[f32; 6],
horizontal_scale: f32,
decode: impl FnMut(&Object) -> Option<(String, bool)>,
) -> Option<Self> {
let candidate = word_gap_candidate(
raw,
text,
font_info,
font_size,
char_spacing,
word_spacing,
word_gap_threshold(font_info),
decode,
)?;
Some(Self::new(
item,
expand_ligatures(&candidate.spaced_text),
pen_tm,
ctm,
horizontal_scale,
candidate.trailing_spacing_ts,
font_size,
))
}
pub(crate) fn new(
item: usize,
spaced_text: String,
pen_tm: &[f32; 6],
ctm: &[f32; 6],
horizontal_scale: f32,
spacing_ts: f32,
font_size: f32,
) -> Self {
let pen = multiply_matrices(pen_tm, ctm);
Self {
item,
spaced_text,
pen: (pen[4], pen[5]),
unit: (pen[0] * horizontal_scale, pen[1] * horizontal_scale),
spacing_ts,
font_size,
}
}
pub(crate) fn resolve(self, items: &mut [TextItem], next_tm: &[f32; 6], ctm: &[f32; 6]) {
if self.item + 1 != items.len() {
return;
}
let next = multiply_matrices(next_tm, ctm);
let (dx, dy) = (next[4] - self.pen.0, next[5] - self.pen.1);
let (ux, uy) = self.unit;
let scale = ux * ux + uy * uy;
if scale <= 0.0 {
return;
}
let along = (dx * ux + dy * uy) / scale;
let across = (dx * uy - dy * ux) / scale;
if across.abs() > self.font_size.abs() * 0.2 {
return;
}
if spacing_taken_back(-along, self.spacing_ts, self.font_size) {
items[self.item].text = self.spaced_text;
}
}
}
const TRACKING_MIN: f32 = 0.5;
const TRACKING_MAX: f32 = 3.0;
const TRACKING_NEEDS_CAPITALS: f32 = 1.25;
const TRACKING_NEEDS_CAPITALS_MAX: f32 = 140.0;
const TRACKING_MAX_ABSOLUTE: f32 = 450.0;
const COLUMN_GAP_THRESHOLDS: f32 = 4.0;
fn is_title_punctuation(c: char) -> bool {
matches!(
c,
'.' | ','
| ':'
| ';'
| '!'
| '?'
| '\''
| '\u{2019}'
| '"'
| '\u{201C}'
| '\u{201D}'
| '-'
| '\u{2010}'
| '\u{2013}'
| '\u{2014}'
| '&'
| '/'
| '\u{00B7}'
| '\u{2022}'
| '\u{2026}'
)
}
fn is_tracked_display_glyph(c: char) -> bool {
c.is_uppercase()
|| c.is_numeric()
|| is_spaceless_cjk(c)
|| c.is_whitespace()
|| is_title_punctuation(c)
}
fn is_single_glyph(raw: &[u8], font_info: Option<&FontWidthInfo>) -> bool {
let code_len = if font_info.is_some_and(|font_info| font_info.is_cid) {
2
} else {
1
};
raw.len() == code_len
}
fn lower_median(sorted: &[f32]) -> f32 {
sorted[(sorted.len() - 1) / 2]
}
pub(crate) fn tj_tracking(
array: &[Object],
font_info: Option<&FontWidthInfo>,
space_threshold: f32,
mut decode: impl FnMut(&Object) -> Option<(String, bool)>,
) -> Option<f32> {
let mut gaps: Vec<f32> = Vec::new();
let mut strings: Vec<&Object> = Vec::new();
let mut pending = 0.0f32;
let mut numbers_since_string = 0usize;
for element in array {
if let Some(offset) = get_number(element) {
pending -= offset;
numbers_since_string += 1;
continue;
}
let Some(raw) = get_operand_bytes(element) else {
continue;
};
if raw.is_empty() {
continue;
}
if !is_single_glyph(raw, font_info) {
return None;
}
if !strings.is_empty() {
if numbers_since_string > 1 {
return None;
}
gaps.push(pending);
}
pending = 0.0;
numbers_since_string = 0;
strings.push(element);
}
if gaps.len() < 2 || gaps.iter().any(|gap| *gap < 0.0) {
return None;
}
let mut sorted = gaps;
sorted.sort_by(|a, b| a.total_cmp(b));
let seed = lower_median(&sorted);
let letter_gaps: Vec<f32> = sorted
.iter()
.copied()
.filter(|gap| *gap <= seed + space_threshold)
.collect();
let tracking = lower_median(&letter_gaps);
let tracking_max = (space_threshold * TRACKING_MAX).min(TRACKING_MAX_ABSOLUTE);
if tracking < space_threshold * TRACKING_MIN || tracking > tracking_max {
return None;
}
let mut texts: Vec<String> = Vec::with_capacity(strings.len());
let (mut letters, mut punctuation) = (0usize, 0usize);
for element in strings {
let (text, _) = decode(element)?;
for c in text.chars() {
if c.is_alphanumeric() {
letters += 1;
} else if !c.is_whitespace() {
punctuation += 1;
}
}
texts.push(text);
}
if letters < punctuation {
return None;
}
let needs_capitals =
(space_threshold * TRACKING_NEEDS_CAPITALS).min(TRACKING_NEEDS_CAPITALS_MAX);
if tracking >= needs_capitals
&& !texts
.iter()
.all(|text| text.chars().all(is_tracked_display_glyph))
{
return None;
}
Some(tracking)
}
pub(crate) fn tj_gap_thresholds(
space_threshold: f32,
tracking: Option<f32>,
horizontal: bool,
) -> (f32, f32) {
match tracking {
Some(tracking) if horizontal => (space_threshold + tracking, space_threshold + tracking),
Some(tracking) => (
space_threshold + tracking,
space_threshold * COLUMN_GAP_THRESHOLDS + tracking,
),
None => (space_threshold, space_threshold * COLUMN_GAP_THRESHOLDS),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::ItemType;
use std::collections::HashMap;
fn font() -> FontWidthInfo {
let mut widths = HashMap::new();
for code in 0u16..=255 {
widths.insert(code, if code == 32 { 300 } else { 600 });
}
FontWidthInfo {
widths,
default_width: 600,
space_width: 300,
is_cid: false,
units_scale: 0.001,
wmode: 0,
}
}
fn latin(code: &Object) -> Option<(String, bool)> {
match code {
Object::String(bytes, _) => {
Some((bytes.iter().map(|&b| b as char).collect::<String>(), false))
}
_ => None,
}
}
fn candidate(raw: &[u8], char_spacing: f32, word_spacing: f32) -> Option<WordGapCandidate> {
let font = font();
let text: String = raw.iter().map(|&b| b as char).collect();
word_gap_candidate(
raw,
&text,
Some(&font),
10.0,
char_spacing,
word_spacing,
word_gap_threshold(Some(&font)),
latin,
)
}
fn spaced(text: &str, trailing_spacing_ts: f32) -> Option<WordGapCandidate> {
Some(WordGapCandidate {
spaced_text: text.to_string(),
trailing_spacing_ts,
})
}
fn item(text: &str) -> TextItem {
TextItem {
text: text.to_string(),
x: 72.0,
y: 700.0,
width: 18.0,
height: 10.0,
font: "F1".to_string(),
font_tag: String::new(),
legacy_symbol_rewrite: false,
font_size: 10.0,
page: 1,
is_bold: false,
is_italic: false,
font_weight: None,
bold_source: None,
fixed_pitch: None,
is_underline: false,
is_strikeout: false,
rotation: 0.0,
advance_known: true,
item_type: ItemType::Text,
mcid: None,
baseline_shift: 0.0,
}
}
#[test]
fn threshold_follows_the_space_width_with_a_floor() {
assert_eq!(word_gap_threshold(Some(&font())), 120.0);
let mut narrow = font();
narrow.space_width = 100;
assert_eq!(word_gap_threshold(Some(&narrow)), 80.0);
assert_eq!(word_gap_threshold(None), 120.0);
}
#[test]
fn a_short_string_with_word_gap_spacing_is_a_candidate() {
assert_eq!(candidate(b"dt", 2.0, 0.0), spaced("d t", 2.0));
assert_eq!(candidate(b"sad", 2.0, 0.0), spaced("s a d", 2.0));
assert_eq!(candidate(b"dt", 1.0, 0.0), None);
assert_eq!(candidate(b"dt", -1.0, 0.0), None);
assert_eq!(candidate(b"d", 3.0, 0.0), None);
assert_eq!(candidate(b"dtoM", 3.0, 0.0), None);
}
#[test]
fn junctions_that_take_no_space_leave_no_candidate() {
assert_eq!(candidate(b"t,", 2.0, 0.0), None);
assert_eq!(candidate(b",b", 2.0, 0.0), spaced(", b", 2.0));
assert_eq!(candidate(b"a b", 2.0, 3.0), None);
let han = |code: &Object| match code {
Object::String(bytes, _) if bytes == b"A" => Some(("\u{4E2D}".to_string(), false)),
Object::String(_, _) => Some(("\u{6587}".to_string(), false)),
_ => None,
};
let font = font();
assert_eq!(
word_gap_candidate(
b"AB",
"\u{4E2D}\u{6587}",
Some(&font),
10.0,
2.0,
0.0,
word_gap_threshold(Some(&font)),
han
),
None
);
}
#[test]
fn word_spacing_counts_only_after_a_space_code_that_paints() {
assert_eq!(candidate(b"dt", 0.0, 5.0), None);
let mixed = |code: &Object| match code {
Object::String(bytes, _) if bytes == b" " => Some(("a".to_string(), false)),
Object::String(bytes, _) if bytes == b"\t" => Some((" ".to_string(), false)),
other => latin(other),
};
let mixed_font = font();
assert_eq!(
word_gap_candidate(
b"\t b",
" ab",
Some(&mixed_font),
10.0,
0.0,
3.0,
word_gap_threshold(Some(&mixed_font)),
mixed
),
spaced(" a b", 0.0)
);
let differences = |code: &Object| match code {
Object::String(bytes, _) if bytes == b" " => Some(("a".to_string(), false)),
other => latin(other),
};
let font = font();
assert_eq!(
word_gap_candidate(
b"t b",
"tab",
Some(&font),
10.0,
0.5,
3.0,
word_gap_threshold(Some(&font)),
differences
),
spaced("ta b", 0.5)
);
}
#[test]
fn a_decoder_that_reads_the_whole_string_keeps_its_item() {
let dropping = |code: &Object| match code {
Object::String(bytes, _) if bytes == b"\x01" => None,
other => latin(other),
};
let font = font();
assert_eq!(
word_gap_candidate(
b"\x01dt",
"dt",
Some(&font),
10.0,
2.0,
0.0,
word_gap_threshold(Some(&font)),
dropping
),
None
);
assert_eq!(
word_gap_candidate(
b"fi",
"\u{FB01}",
Some(&font),
10.0,
2.0,
0.0,
word_gap_threshold(Some(&font)),
latin
),
None
);
}
#[test]
fn taking_the_spacing_back_means_most_of_it_up_to_a_kern_more() {
assert!(spacing_taken_back(3.0, 3.0, 10.0));
assert!(spacing_taken_back(2.1, 3.0, 10.0));
assert!(!spacing_taken_back(2.0, 3.0, 10.0));
assert!(spacing_taken_back(4.2, 3.0, 10.0));
assert!(!spacing_taken_back(4.3, 3.0, 10.0));
assert!(!spacing_taken_back(0.0, 3.0, 10.0));
assert!(!spacing_taken_back(1.0, 0.0, 10.0));
}
fn pending(items: &[TextItem]) -> PendingWordGaps {
PendingWordGaps::new(
items.len() - 1,
"d t".to_string(),
&[1.0, 0.0, 0.0, 1.0, 90.0, 700.0],
&[1.0, 0.0, 0.0, 1.0, 0.0, 0.0],
1.0,
3.0,
10.0,
)
}
#[test]
fn a_next_run_that_takes_the_spacing_back_spaces_the_candidate() {
let identity = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0];
let mut items = vec![item("dt")];
pending(&items).resolve(&mut items, &[1.0, 0.0, 0.0, 1.0, 87.0, 700.0], &identity);
assert_eq!(items[0].text, "d t");
let mut items = vec![item("dt")];
pending(&items).resolve(&mut items, &[1.0, 0.0, 0.0, 1.0, 90.0, 700.0], &identity);
assert_eq!(items[0].text, "dt");
let mut items = vec![item("dt")];
pending(&items).resolve(&mut items, &[1.0, 0.0, 0.0, 1.0, 87.0, 688.0], &identity);
assert_eq!(items[0].text, "dt");
let mut items = vec![item("dt")];
let pending = pending(&items);
items.push(item("x"));
pending.resolve(&mut items, &[1.0, 0.0, 0.0, 1.0, 87.0, 700.0], &identity);
assert_eq!(items[0].text, "dt");
let mut items = vec![item("dt")];
PendingWordGaps::new(
0,
"d t".to_string(),
&[1.0, 0.0, 0.0, 1.0, 81.0, 700.0],
&identity,
0.5,
3.0,
10.0,
)
.resolve(&mut items, &[1.0, 0.0, 0.0, 1.0, 79.5, 700.0], &identity);
assert_eq!(items[0].text, "d t");
}
fn tj(spec: &str) -> Vec<Object> {
spec.split_whitespace()
.map(
|token| match token.strip_prefix('(').and_then(|t| t.strip_suffix(')')) {
Some(text) => Object::String(text.as_bytes().to_vec(), StringFormat::Literal),
None => Object::Real(token.parse().unwrap()),
},
)
.collect()
}
fn tracking(spec: &str) -> Option<f32> {
let font = font();
tj_tracking(
&tj(spec),
Some(&font),
word_gap_threshold(Some(&font)),
latin,
)
}
#[test]
fn punctuation_led_arrays_are_not_tracking() {
assert_eq!(
tracking("(,) -167 (.) -167 (.) -167 (.) -167 (,) -167 (D)"),
None
);
assert_eq!(
tracking("(,) -100 (.) -100 (.) -100 (.) -100 (,) -100 (D)"),
None
);
assert_eq!(
tracking("(R) -300 (.) -300 (E) -300 (.) -300 (V)"),
Some(300.0)
);
}
#[test]
fn tracked_display_runs_read_their_letter_spacing() {
assert_eq!(
tracking("(V) -250 (A) -250 (L) -250 (L) -250 (E) -250 (Y)"),
Some(250.0)
);
assert_eq!(
tracking("(V) -216 (A) -333 (L) -166 (L) -250 (E) -290 (Y)"),
Some(250.0)
);
assert_eq!(
tracking(
"(V) -216 (A) -333 (L) -166 (L) -250 (E) -290 (Y) -560 (R) -240 (O) -260 (A) -250 (D)"
),
Some(250.0)
);
assert_eq!(
tracking("(V) (A) -250 (L) -250 (L) -250 (E) -250 (Y)"),
Some(250.0)
);
assert_eq!(
tracking("(V) -120 (a) -140 (l) -100 (l) -120 (e) -130 (y)"),
Some(120.0)
);
}
#[test]
fn half_a_space_width_between_single_glyphs_is_tracking_only_in_capitals() {
assert_eq!(tracking("(a) -300 (b) -300 (c) -300 (d)"), None);
assert_eq!(tracking("(a) -200 (b) -200 (c) -200 (d)"), None);
assert_eq!(tracking("(A) -300 (b) -300 (c) -300 (d)"), None);
assert_eq!(tracking("(U) -300 (S) -300 (A)"), Some(300.0));
assert_eq!(tracking("(2) -300 (0) -300 (2) -300 (4)"), Some(300.0));
let han = |code: &Object| match code {
Object::String(bytes, _) if bytes == b"A" => Some(("\u{4E2D}".to_string(), false)),
Object::String(_, _) => Some(("\u{6587}".to_string(), false)),
_ => None,
};
let font = font();
assert_eq!(
tj_tracking(
&tj("(A) -300 (B) -300 (A)"),
Some(&font),
word_gap_threshold(Some(&font)),
han
),
Some(300.0)
);
let partial = |code: &Object| match code {
Object::String(bytes, _) if bytes == b"S" => None,
other => latin(other),
};
assert_eq!(
tj_tracking(
&tj("(U) -300 (S) -300 (A)"),
Some(&font),
word_gap_threshold(Some(&font)),
partial
),
None
);
assert_eq!(tracking("(a) -120 (b) -120 (c) -120 (d)"), Some(120.0));
assert_eq!(tracking("(A) -300 (-) -300 (B)"), Some(300.0));
assert_eq!(tracking("(A) -300 (&) -300 (B)"), Some(300.0));
assert_eq!(tracking("(E) -278 ([) -278 (N)"), None);
assert_eq!(tracking("(A) -278 (=) -278 (B)"), None);
assert_eq!(tracking("(A) -278 (+) -278 (1)"), None);
}
#[test]
fn a_wide_reported_space_does_not_open_the_bands() {
let mut wide = font();
wide.space_width = 600;
let threshold = word_gap_threshold(Some(&wide));
assert_eq!(threshold, 240.0);
let read = |spec: &str| tj_tracking(&tj(spec), Some(&wide), threshold, latin);
assert_eq!(read("(a) -278 (<) -278 (b)"), None);
assert_eq!(read("(a) -200 (b) -200 (c) -200 (d)"), None);
assert_eq!(read("(A) -600 (B) -600 (C) -600 (D)"), None);
assert_eq!(
read("(V) -250 (A) -250 (L) -250 (L) -250 (E) -250 (Y)"),
Some(250.0)
);
assert_eq!(
read("(V) -125 (a) -135 (l) -120 (l) -125 (e) -130 (y)"),
Some(125.0)
);
}
#[test]
fn offsets_that_are_not_tracking_are_left_alone() {
assert_eq!(tracking("(The) -258 (quick) -300 (brown)"), None);
assert_eq!(tracking("(A) 83 (VALLEY)"), None);
assert_eq!(
tracking("(T) 20 (h) -5 (e) -278 (q) 10 (u) -3 (i) -8 (c) (k)"),
None
);
assert_eq!(tracking("(A) 20 (V) -333 (I) 10 (S) -333 (A)"), None);
assert_eq!(
tracking("(D) 27 (1) -166 (,) -166 (D) 27 (2) -166 (,)"),
None
);
assert_eq!(tracking("(A) -30 (B) -20 (C) -40 (D)"), None);
assert_eq!(tracking("(V) -125 -125 (A) -250 (L) -250 (L)"), None);
assert_eq!(tracking("(A) -1500 (B) -1500 (C)"), None);
assert_eq!(tracking("(A) -250 (B)"), None);
assert_eq!(tracking("(A)"), None);
assert_eq!(tracking(""), None);
}
#[test]
fn cid_fonts_count_two_bytes_per_glyph() {
let mut cid = font();
cid.is_cid = true;
let threshold = word_gap_threshold(Some(&cid));
let latin = |code: &Object| match code {
Object::String(bytes, _) => Some((
bytes
.iter()
.filter(|&&b| b != 0)
.map(|&b| b as char)
.collect(),
false,
)),
_ => None,
};
let glyph = |code: u16| Object::String(code.to_be_bytes().to_vec(), StringFormat::Literal);
let array = vec![
glyph(0x41),
Object::Integer(-250),
glyph(0x42),
Object::Integer(-250),
glyph(0x43),
];
assert_eq!(
tj_tracking(&array, Some(&cid), threshold, latin),
Some(250.0)
);
let pair = Object::String(vec![0, 0x41, 0, 0x42], StringFormat::Literal);
let array = vec![
pair,
Object::Integer(-250),
glyph(0x43),
Object::Integer(-250),
glyph(0x44),
];
assert_eq!(tj_tracking(&array, Some(&cid), threshold, latin), None);
}
#[test]
fn gap_thresholds_follow_the_tracking() {
assert_eq!(tj_gap_thresholds(120.0, None, true), (120.0, 480.0));
assert_eq!(tj_gap_thresholds(120.0, None, false), (120.0, 480.0));
assert_eq!(tj_gap_thresholds(120.0, Some(250.0), true), (370.0, 370.0));
assert_eq!(tj_gap_thresholds(120.0, Some(250.0), false), (370.0, 730.0));
}
}