use std::rc::Rc;
use super::super::draw_command::{DrawCommand, OutlineMark};
use super::super::fragment::{Fragment, LinkTarget};
use super::types::{LineSpacingRule, MeasureTextFn, ParagraphStyle, TabStopDef};
use super::{LineLayoutParams, LinePlacement, LEADER_CHAR_WIDTH_FALLBACK};
use crate::render::dimension::Pt;
use crate::render::geometry::PtOffset;
pub(super) fn compute_line_placements(
fragments: &[Fragment],
style: &ParagraphStyle,
params: &LineLayoutParams,
) -> Vec<LinePlacement> {
let content_width = params.content_width;
let first_line_adjustment = params.first_line_adjustment;
let drop_cap_indent = params.drop_cap_indent;
let drop_cap_lines = params.drop_cap_lines;
let default_line_height = params.default_line_height;
let ptab_geometry = PTabGeometry {
max_width: params.max_width,
indent_left: style.indent_left,
indent_first_line: style.indent_first_line,
content_width,
float_left: Pt::ZERO,
float_right: Pt::ZERO,
};
if style.page_floats.is_empty() {
let first_line_width = (content_width - first_line_adjustment).max(Pt::ZERO);
let remaining_width = if drop_cap_indent > Pt::ZERO {
content_width - drop_cap_indent
} else {
content_width
};
return super::super::line::fit_lines_with_first(
fragments,
first_line_width,
remaining_width,
ptab_geometry,
)
.into_iter()
.map(|line| LinePlacement {
line,
float_left: Pt::ZERO,
float_right: Pt::ZERO,
})
.collect();
}
let mut placements = Vec::new();
let mut frag_idx = 0;
let mut line_y = style.space_before;
while frag_idx < fragments.len() {
let abs_y = style.page_y + line_y;
let (fl, fr) = super::super::float::float_adjustments_with_height(
&style.page_floats,
abs_y,
default_line_height,
style.page_x,
style.page_content_width,
);
let float_reduction = fl + fr;
let available = (content_width - float_reduction).max(Pt::ZERO);
let is_first = placements.is_empty();
let dc_adj = if placements.len() < drop_cap_lines {
drop_cap_indent
} else {
Pt::ZERO
};
let line_width = if is_first {
(available - first_line_adjustment).max(Pt::ZERO)
} else {
(available - dc_adj).max(Pt::ZERO)
};
let remaining = &fragments[frag_idx..];
let line_geometry = PTabGeometry {
indent_first_line: if is_first {
ptab_geometry.indent_first_line
} else {
Pt::ZERO
},
float_left: fl,
float_right: fr,
..ptab_geometry
};
let fitted = super::super::line::fit_lines_with_first(
remaining,
line_width,
line_width,
line_geometry,
);
let fitted_line = if let Some(first) = fitted.into_iter().next() {
super::super::line::FittedLine {
start: first.start + frag_idx,
end: first.end + frag_idx,
width: first.width,
height: first.height,
text_height: first.text_height,
ascent: first.ascent,
has_break: first.has_break,
}
} else {
break;
};
let natural = if fitted_line.height > Pt::ZERO {
fitted_line.height
} else {
default_line_height
};
let text_h = if fitted_line.text_height > Pt::ZERO {
fitted_line.text_height
} else {
default_line_height
};
let lh = resolve_line_height(natural, text_h, &style.line_spacing, style.auto_fit);
frag_idx = fitted_line.end;
placements.push(LinePlacement {
line: fitted_line,
float_left: fl,
float_right: fr,
});
line_y += lh;
}
placements
}
fn stretchable_gap_after(fragments: &[Fragment], frag_idx: usize, line_end: usize) -> bool {
let Fragment::Text { text, .. } = &fragments[frag_idx] else {
return false;
};
text.ends_with(' ')
&& fragments[frag_idx + 1..line_end]
.iter()
.any(|fragment| !matches!(fragment, Fragment::Bookmark { .. }))
}
fn line_has_justification_tab(fragments: &[Fragment], line_start: usize, line_end: usize) -> bool {
fragments[line_start..line_end].iter().any(|fragment| {
matches!(
fragment,
Fragment::Tab {
fitting_width: None,
..
} | Fragment::PTab { .. }
) || matches!(fragment, Fragment::Text { text, .. } if text.contains('\t'))
})
}
fn visible_line_width(fragments: &[Fragment], line: &super::super::line::FittedLine) -> Pt {
let last_visible = (line.start..line.end)
.rev()
.find(|&idx| !matches!(fragments[idx], Fragment::Bookmark { .. }));
(line.start..line.end)
.map(|idx| {
if Some(idx) == last_visible {
fragments[idx].trimmed_width()
} else {
fragments[idx].width()
}
})
.sum()
}
fn justification_extra_after(
fragments: &[Fragment],
line: &super::super::line::FittedLine,
line_idx: usize,
line_count: usize,
alignment: crate::model::Alignment,
line_has_tabs: bool,
remaining: Pt,
) -> Pt {
if alignment != crate::model::Alignment::Both
|| line.has_break
|| line_idx + 1 == line_count
|| line_has_tabs
|| remaining <= Pt::ZERO
{
return Pt::ZERO;
}
let gaps = (line.start..line.end)
.filter(|&idx| stretchable_gap_after(fragments, idx, line.end))
.count();
if gaps == 0 {
Pt::ZERO
} else {
remaining / gaps as f32
}
}
fn distribution_unit_count(fragment: &Fragment, terminal: bool) -> usize {
match fragment {
Fragment::Text { text, .. } => {
if terminal {
text.trim_end().chars().count()
} else {
text.chars().count()
}
}
Fragment::Image { .. } | Fragment::Emoji { .. } => 1,
_ => 0,
}
}
fn distribution_gap_count_after(fragments: &[Fragment], frag_idx: usize, line_end: usize) -> usize {
let has_following_unit = fragments[frag_idx + 1..line_end]
.iter()
.any(|fragment| distribution_unit_count(fragment, false) > 0);
let units = distribution_unit_count(&fragments[frag_idx], !has_following_unit);
if has_following_unit {
units
} else {
units.saturating_sub(1)
}
}
fn distribution_extra_per_gap(
fragments: &[Fragment],
line: &super::super::line::FittedLine,
alignment: crate::model::Alignment,
line_has_tabs: bool,
remaining: Pt,
) -> Pt {
if alignment != crate::model::Alignment::Distribute || line_has_tabs || remaining <= Pt::ZERO {
return Pt::ZERO;
}
let gaps = (line.start..line.end)
.map(|idx| distribution_gap_count_after(fragments, idx, line.end))
.sum::<usize>();
if gaps == 0 {
Pt::ZERO
} else {
remaining / gaps as f32
}
}
#[allow(clippy::too_many_arguments)] pub(super) fn emit_line_commands(
commands: &mut Vec<DrawCommand>,
cursor_y: &mut Pt,
line_placements: &[LinePlacement],
line_range: std::ops::Range<usize>,
fragments: &[Fragment],
style: &ParagraphStyle,
params: &LineLayoutParams,
measure_text: MeasureTextFn<'_>,
is_first_segment: bool,
) {
let content_width = params.content_width;
let first_line_adjustment = params.first_line_adjustment;
let drop_cap_indent = params.drop_cap_indent;
let drop_cap_lines = params.drop_cap_lines;
let default_line_height = params.default_line_height;
let has_bar_stop = style
.tabs
.iter()
.any(|ts| TabStopRole::of(ts.alignment) == TabStopRole::DrawsRule);
let bar_color = paragraph_bar_color(fragments);
let outline = style.outline.as_ref().filter(|_| is_first_segment);
if let Some(heading) = outline {
commands.push(DrawCommand::Outline(OutlineMark::Begin(heading.clone())));
}
for line_idx in line_range {
let lp = &line_placements[line_idx];
let line = &lp.line;
let dc_offset = if line_idx < drop_cap_lines {
drop_cap_indent
} else {
Pt::ZERO
};
let float_offset = lp.float_left;
let indent = if line_idx == 0 {
style.indent_left + style.indent_first_line + dc_offset + float_offset
} else {
style.indent_left + dc_offset + float_offset
};
let natural_height = if line.height > Pt::ZERO {
line.height
} else {
default_line_height
};
let text_height = if line.text_height > Pt::ZERO {
line.text_height
} else {
default_line_height
};
let line_height = resolve_line_height(
natural_height,
text_height,
&style.line_spacing,
style.auto_fit,
);
let float_reduction = lp.float_left + lp.float_right;
let line_available = if line_idx == 0 {
(content_width - float_reduction - first_line_adjustment).max(Pt::ZERO)
} else {
(content_width - float_reduction - dc_offset).max(Pt::ZERO)
};
let remaining = (line_available - visible_line_width(fragments, line)).max(Pt::ZERO);
let line_has_tab_placement = fragments[line.start..line.end].iter().any(is_tab_like);
let align_offset = if line_has_tab_placement {
Pt::ZERO
} else {
match style.alignment {
crate::model::Alignment::Center => remaining * 0.5,
crate::model::Alignment::End => remaining,
crate::model::Alignment::Both
if !line.has_break && line_idx < line_placements.len() - 1 =>
{
Pt::ZERO
}
_ => Pt::ZERO,
}
};
let extra_per_gap = justification_extra_after(
fragments,
line,
line_idx,
line_placements.len(),
style.alignment,
line_has_justification_tab(fragments, line.start, line.end),
remaining,
);
let distribution_extra = distribution_extra_per_gap(
fragments,
line,
style.alignment,
line_has_justification_tab(fragments, line.start, line.end),
remaining,
);
let x_start = indent + align_offset;
if has_bar_stop {
emit_bar_rules(commands, &style.tabs, bar_color, *cursor_y, line_height);
}
let mut x = x_start;
for (frag_idx, frag) in (line.start..line.end).zip(&fragments[line.start..line.end]) {
match frag {
Fragment::Text {
text,
font,
color,
shading,
border,
width,
metrics,
hyperlink_url,
baseline_offset,
text_offset,
..
} => {
let extra_after = if stretchable_gap_after(fragments, frag_idx, line.end) {
extra_per_gap
} else {
Pt::ZERO
};
let distributed_width = distribution_extra
* distribution_gap_count_after(fragments, frag_idx, line.end) as f32;
let rendered_width = *width + extra_after + distributed_width;
if let Some(bg_color) = shading {
let text_top = *cursor_y + line.ascent - metrics.ascent;
commands.push(DrawCommand::Rect {
rect: crate::render::geometry::PtRect::from_xywh(
x,
text_top,
rendered_width,
metrics.height(),
),
color: *bg_color,
});
}
if let Some(bdr) = border {
let text_top = *cursor_y + line.ascent - metrics.ascent;
let bx = x - bdr.space;
let by = text_top;
let bw = rendered_width + bdr.space * 2.0;
let bh = metrics.height();
let half = bdr.width * 0.5;
commands.push(DrawCommand::Line {
line: crate::render::geometry::PtLineSegment::new(
PtOffset::new(bx, by + half),
PtOffset::new(bx + bw, by + half),
),
color: bdr.color,
width: bdr.width,
});
commands.push(DrawCommand::Line {
line: crate::render::geometry::PtLineSegment::new(
PtOffset::new(bx, by + bh - half),
PtOffset::new(bx + bw, by + bh - half),
),
color: bdr.color,
width: bdr.width,
});
commands.push(DrawCommand::Line {
line: crate::render::geometry::PtLineSegment::new(
PtOffset::new(bx + half, by),
PtOffset::new(bx + half, by + bh),
),
color: bdr.color,
width: bdr.width,
});
commands.push(DrawCommand::Line {
line: crate::render::geometry::PtLineSegment::new(
PtOffset::new(bx + bw - half, by),
PtOffset::new(bx + bw - half, by + bh),
),
color: bdr.color,
width: bdr.width,
});
}
let y = *cursor_y + line.ascent + *baseline_offset;
commands.push(DrawCommand::Text {
position: PtOffset::new(x + *text_offset, y),
text: text.clone(),
font_family: font.family.clone(),
char_spacing: font.char_spacing + distribution_extra,
font_size: font.size,
bold: font.bold,
italic: font.italic,
color: *color,
text_scale: font.text_scale,
});
if let Some(link) = hyperlink_url {
let rect = crate::render::geometry::PtRect::from_xywh(
x,
*cursor_y,
rendered_width,
line_height,
);
match link {
LinkTarget::External(url) => {
commands.push(DrawCommand::LinkAnnotation {
rect,
url: url.clone(),
})
}
LinkTarget::Internal(dest) => {
commands.push(DrawCommand::InternalLink {
rect,
destination: dest.clone(),
})
}
}
}
if font.underline {
let underline_y = y - font.underline_position;
let stroke_width = font.underline_thickness;
commands.push(DrawCommand::Underline {
line: crate::render::geometry::PtLineSegment::new(
PtOffset::new(x, underline_y),
PtOffset::new(x + rendered_width, underline_y),
),
color: *color,
width: stroke_width,
});
}
x += rendered_width;
}
Fragment::Image {
size,
image_data,
src_rect,
..
} => {
if let Some(data) = image_data {
commands.push(DrawCommand::Image {
rect: crate::render::geometry::PtRect::from_xywh(
x,
*cursor_y,
size.width,
size.height,
),
image_data: data.clone(),
src_rect: *src_rect,
});
}
x += size.width
+ distribution_extra
* distribution_gap_count_after(fragments, frag_idx, line.end) as f32;
}
Fragment::Emoji {
text,
typeface,
size,
presentation,
structure,
advance,
metrics,
line_metrics: _,
baseline_offset,
} => {
let baseline_y = *cursor_y + line.ascent + *baseline_offset;
let top_y = baseline_y - metrics.ascent;
commands.push(DrawCommand::EmojiCluster {
rect: crate::render::geometry::PtRect::from_xywh(
x,
top_y,
*advance,
metrics.height(),
),
text: text.clone(),
typeface: typeface.clone(),
size: *size,
presentation: *presentation,
structure: *structure,
});
x += *advance
+ distribution_extra
* distribution_gap_count_after(fragments, frag_idx, line.end) as f32;
}
Fragment::Tab {
font: tab_font,
color: tab_color,
..
} => {
let (tab_pos, tab_stop) =
find_next_tab_stop(x, &style.tabs, line_available, style.default_tab_stop);
let new_x = if let Some(ts) = tab_stop {
let end = zone_end(fragments, frag_idx, line.end);
let zone = &fragments[frag_idx + 1..end];
let offset = resolve_zone_anchor(
ts.alignment,
zone,
measure_text,
style.locale.decimal_separator(),
)
.offset(|| zone_width(fragments, frag_idx + 1, end));
(tab_pos - offset).max(x)
} else {
tab_pos
};
if let Some(ts) = tab_stop {
emit_tab_leader(
commands,
LeaderStyle {
leader: ts.leader,
font: tab_font,
color: *tab_color,
},
x,
new_x,
*cursor_y + line.ascent,
measure_text,
);
}
x = new_x;
}
Fragment::PTab {
align,
relative_to,
leader,
font: tab_font,
color: tab_color,
..
} => {
let end = zone_end(fragments, frag_idx, line.end);
let geometry = PTabGeometry {
max_width: params.max_width,
indent_left: style.indent_left,
indent_first_line: style.indent_first_line,
content_width,
float_left: lp.float_left,
float_right: lp.float_right,
};
let new_x = match resolve_ptab(*align, *relative_to, geometry, x, || {
zone_width(fragments, frag_idx + 1, end)
}) {
PTabPlacement::Placed(at) => at,
PTabPlacement::AdvancesToNextLine { anchor_x } => {
if frag_idx == line.start {
anchor_x.max(Pt::ZERO)
} else {
anchor_x.max(x)
}
}
};
emit_tab_leader(
commands,
LeaderStyle {
leader: *leader,
font: tab_font,
color: *tab_color,
},
x,
new_x,
*cursor_y + line.ascent,
measure_text,
);
x = new_x;
}
Fragment::LineBreak { .. } | Fragment::ColumnBreak | Fragment::PageBreak { .. } => {
}
Fragment::Bookmark { name } => {
commands.push(DrawCommand::NamedDestination {
position: PtOffset::new(x, *cursor_y),
name: name.clone(),
});
}
}
}
*cursor_y += line_height;
}
if outline.is_some() {
commands.push(DrawCommand::Outline(OutlineMark::End));
}
}
fn is_tab_like(f: &Fragment) -> bool {
matches!(f, Fragment::Tab { .. } | Fragment::PTab { .. })
}
pub(crate) fn zone_end(fragments: &[Fragment], tab_idx: usize, limit: usize) -> usize {
fragments[tab_idx + 1..limit]
.iter()
.position(|f| is_tab_like(f) || f.is_line_break())
.map_or(limit, |offset| tab_idx + 1 + offset)
}
pub(crate) fn zone_width(fragments: &[Fragment], start: usize, end: usize) -> Pt {
fragments[start..end].iter().map(|f| f.width()).sum()
}
pub(super) fn find_next_tab_stop(
current_x: Pt,
tabs: &[TabStopDef],
line_width: Pt,
default_interval: Pt,
) -> (Pt, Option<&TabStopDef>) {
for ts in tabs {
if ts.position > current_x && TabStopRole::of(ts.alignment) == TabStopRole::PositionsContent
{
return (ts.position, Some(ts));
}
}
let interval = if default_interval.raw() > 0.0 {
default_interval.raw()
} else {
36.0
};
let next = ((current_x.raw() / interval).floor() + 1.0) * interval;
(Pt::new(next.min(line_width.raw())), None)
}
#[derive(Clone, Copy, Debug)]
pub struct PTabGeometry {
pub max_width: Pt,
pub indent_left: Pt,
pub indent_first_line: Pt,
pub content_width: Pt,
pub float_left: Pt,
pub float_right: Pt,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum PTabPlacement {
Placed(Pt),
AdvancesToNextLine { anchor_x: Pt },
}
pub fn resolve_ptab(
align: crate::model::PTabAlignment,
relative_to: crate::model::PTabRelativeTo,
geometry: PTabGeometry,
pen_x: Pt,
zone_width: impl FnOnce() -> Pt,
) -> PTabPlacement {
use crate::model::{PTabAlignment, PTabRelativeTo};
let (span_start, span_end) = match relative_to {
PTabRelativeTo::Margin => (Pt::ZERO, geometry.max_width),
PTabRelativeTo::Indent => (
geometry.indent_left + geometry.float_left,
geometry.indent_left + geometry.content_width - geometry.float_right,
),
};
let anchor = match align {
PTabAlignment::Left => span_start,
PTabAlignment::Center => (span_start + span_end) * 0.5,
PTabAlignment::Right => span_end,
};
let anchor_x = match align {
PTabAlignment::Left => anchor,
PTabAlignment::Center => anchor - zone_width() * 0.5,
PTabAlignment::Right => anchor - zone_width(),
};
if anchor_x >= pen_x {
PTabPlacement::Placed(anchor_x)
} else {
PTabPlacement::AdvancesToNextLine { anchor_x }
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum ZoneAnchor {
Start,
End,
Middle,
At(Pt),
}
impl ZoneAnchor {
fn offset(self, zone_width: impl FnOnce() -> Pt) -> Pt {
match self {
ZoneAnchor::Start => Pt::ZERO,
ZoneAnchor::End => zone_width(),
ZoneAnchor::Middle => zone_width() * 0.5,
ZoneAnchor::At(offset) => offset,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum TabStopRole {
PositionsContent,
DrawsRule,
}
impl TabStopRole {
fn of(alignment: crate::model::TabAlignment) -> Self {
use crate::model::TabAlignment;
match alignment {
TabAlignment::Left
| TabAlignment::Center
| TabAlignment::Right
| TabAlignment::Decimal
| TabAlignment::Clear => Self::PositionsContent,
TabAlignment::Bar => Self::DrawsRule,
}
}
}
const BAR_RULE_WIDTH: Pt = Pt::new(0.75);
fn paragraph_bar_color(fragments: &[Fragment]) -> crate::render::resolve::color::RgbColor {
fragments
.iter()
.find_map(|f| match f {
Fragment::Text { color, .. } => Some(*color),
_ => None,
})
.unwrap_or(crate::render::resolve::color::RgbColor::BLACK)
}
fn emit_bar_rules(
commands: &mut Vec<DrawCommand>,
tabs: &[TabStopDef],
color: crate::render::resolve::color::RgbColor,
line_top: Pt,
line_height: Pt,
) {
for ts in tabs
.iter()
.filter(|ts| TabStopRole::of(ts.alignment) == TabStopRole::DrawsRule)
{
commands.push(DrawCommand::Line {
line: crate::render::geometry::PtLineSegment::new(
PtOffset::new(ts.position, line_top),
PtOffset::new(ts.position, line_top + line_height),
),
color,
width: BAR_RULE_WIDTH,
});
}
}
fn resolve_zone_anchor(
alignment: crate::model::TabAlignment,
zone: &[Fragment],
measure_text: MeasureTextFn<'_>,
separator: char,
) -> ZoneAnchor {
use crate::model::TabAlignment;
match alignment {
TabAlignment::Left | TabAlignment::Clear => ZoneAnchor::Start,
TabAlignment::Right => ZoneAnchor::End,
TabAlignment::Center => ZoneAnchor::Middle,
TabAlignment::Decimal => decimal_anchor(zone, measure_text, separator),
TabAlignment::Bar => ZoneAnchor::Start,
}
}
fn decimal_anchor(
zone: &[Fragment],
measure_text: MeasureTextFn<'_>,
separator: char,
) -> ZoneAnchor {
let mut before = Pt::ZERO;
for fragment in zone {
if let Fragment::Text {
text, font, width, ..
} = fragment
{
if let Some(byte_idx) = text.find(separator) {
let prefix = &text[..byte_idx];
let prefix_width = match measure_text {
Some(measure) => measure(prefix, font).0,
None => {
let total = text.chars().count();
if total == 0 {
Pt::ZERO
} else {
*width * (prefix.chars().count() as f32 / total as f32)
}
}
};
return ZoneAnchor::At(before + prefix_width);
}
}
before += fragment.width();
}
ZoneAnchor::End
}
pub(super) struct LeaderStyle<'a> {
pub leader: crate::model::TabLeader,
pub font: &'a super::super::fragment::FontProps,
pub color: crate::render::resolve::color::RgbColor,
}
pub(super) fn emit_tab_leader(
commands: &mut Vec<DrawCommand>,
style: LeaderStyle<'_>,
x_start: Pt,
x_end: Pt,
baseline_y: Pt,
measure_text: MeasureTextFn<'_>,
) {
use crate::model::TabLeader;
let LeaderStyle {
leader,
font,
color,
} = style;
let leader_char = match leader {
TabLeader::Dot => ".",
TabLeader::Hyphen => "-",
TabLeader::Underscore => "_",
TabLeader::MiddleDot => "\u{00B7}",
TabLeader::Heavy => "_",
TabLeader::None => return,
};
let gap = x_end - x_start;
if gap <= Pt::ZERO {
return;
}
let leader_font = super::super::fragment::FontProps {
char_spacing: Pt::ZERO,
text_scale: 1.0,
..font.clone()
};
let char_width = if let Some(m) = measure_text {
m(leader_char, &leader_font).0
} else {
LEADER_CHAR_WIDTH_FALLBACK
};
if char_width <= Pt::ZERO {
return;
}
let count = ((gap / char_width) as usize).min(500);
if count == 0 {
return;
}
let leader_text: String = leader_char.repeat(count);
let leader_width = char_width * count as f32;
let leader_x = x_end - leader_width;
commands.push(DrawCommand::Text {
position: PtOffset::new(leader_x.max(x_start), baseline_y),
text: Rc::from(leader_text.as_str()),
font_family: leader_font.family,
char_spacing: Pt::ZERO,
font_size: leader_font.size,
bold: leader_font.bold,
italic: leader_font.italic,
color,
text_scale: 1.0,
});
}
pub(super) fn resolve_line_height(
natural: Pt,
text_height: Pt,
rule: &LineSpacingRule,
auto_fit: crate::render::layout::ShapeAutoFit,
) -> Pt {
let resolved = match rule {
LineSpacingRule::Auto(multiplier) => {
let scaled_text = text_height * *multiplier;
scaled_text.max(natural)
}
LineSpacingRule::Exact(h) => *h,
LineSpacingRule::AtLeast(min) => natural.max(*min),
};
auto_fit.scale_line_height(resolved)
}
#[cfg(test)]
mod tests {
use super::{
find_next_tab_stop, resolve_ptab, resolve_zone_anchor, Fragment, PTabGeometry,
PTabPlacement, ZoneAnchor,
};
use crate::model;
use crate::render::dimension::Pt;
use crate::render::layout::paragraph::TabStopDef;
#[test]
fn default_interval_36pt_advances_half_inch() {
let (pos, ts) = find_next_tab_stop(Pt::ZERO, &[], Pt::new(1000.0), Pt::new(36.0));
assert_eq!(pos.raw(), 36.0);
assert!(ts.is_none());
let (pos, _) = find_next_tab_stop(Pt::new(40.0), &[], Pt::new(1000.0), Pt::new(36.0));
assert_eq!(pos.raw(), 72.0);
}
#[test]
fn custom_default_interval_is_honored() {
let (pos, _) = find_next_tab_stop(Pt::ZERO, &[], Pt::new(1000.0), Pt::new(72.0));
assert_eq!(pos.raw(), 72.0);
let (pos, _) = find_next_tab_stop(Pt::new(80.0), &[], Pt::new(1000.0), Pt::new(72.0));
assert_eq!(pos.raw(), 144.0);
}
#[test]
fn zero_interval_falls_back_to_36pt() {
let (pos, _) = find_next_tab_stop(Pt::ZERO, &[], Pt::new(1000.0), Pt::ZERO);
assert_eq!(pos.raw(), 36.0);
}
#[test]
fn custom_tab_stop_preferred_over_default_grid() {
let tabs = vec![TabStopDef {
position: Pt::new(72.0),
alignment: model::TabAlignment::Left,
leader: model::TabLeader::None,
}];
let (pos, ts) = find_next_tab_stop(Pt::new(10.0), &tabs, Pt::new(1000.0), Pt::new(36.0));
assert_eq!(pos.raw(), 72.0, "custom stop wins over 36pt grid");
assert!(ts.is_some(), "custom TabStopDef returned");
}
#[test]
fn cursor_past_all_custom_stops_falls_back_to_default_grid() {
let tabs = vec![TabStopDef {
position: Pt::new(72.0),
alignment: model::TabAlignment::Left,
leader: model::TabLeader::None,
}];
let (pos, ts) = find_next_tab_stop(Pt::new(80.0), &tabs, Pt::new(1000.0), Pt::new(36.0));
assert_eq!(pos.raw(), 108.0, "default grid past the custom stop");
assert!(ts.is_none(), "no custom stop returned");
}
fn text_fragment(text: &str, width: f32) -> Fragment {
use crate::render::layout::fragment::{FontProps, TextMetrics};
use std::rc::Rc;
Fragment::Text {
text: text.into(),
font: Rc::new(FontProps {
family: Rc::from("Test"),
size: Pt::new(12.0),
bold: false,
italic: false,
underline: false,
char_spacing: Pt::ZERO,
text_scale: 1.0,
underline_position: Pt::ZERO,
underline_thickness: Pt::ZERO,
}),
color: crate::render::resolve::color::RgbColor::BLACK,
shading: None,
border: None,
width: Pt::new(width),
trimmed_width: Pt::new(width),
metrics: TextMetrics {
ascent: Pt::new(10.0),
descent: Pt::new(4.0),
leading: Pt::ZERO,
},
hyperlink_url: None,
baseline_offset: Pt::ZERO,
text_offset: Pt::ZERO,
is_footnote_ref: false,
}
}
#[test]
fn every_alignment_resolves_without_a_catch_all() {
use model::TabAlignment::*;
for (alignment, expected) in [
(Left, ZoneAnchor::Start),
(Bar, ZoneAnchor::Start),
(Clear, ZoneAnchor::Start),
(Right, ZoneAnchor::End),
(Center, ZoneAnchor::Middle),
] {
assert_eq!(
resolve_zone_anchor(alignment, &[], None, '.'),
expected,
"{alignment:?}"
);
}
}
#[test]
fn a_start_anchor_never_sums_the_zone_width() {
let offset = ZoneAnchor::Start.offset(|| panic!("zone width computed for a Start anchor"));
assert_eq!(offset, Pt::ZERO);
}
#[test]
fn zone_anchor_offsets_are_measured_from_the_zone_start() {
let width = || Pt::new(80.0);
assert_eq!(ZoneAnchor::End.offset(width).raw(), 80.0);
assert_eq!(ZoneAnchor::Middle.offset(width).raw(), 40.0);
assert_eq!(ZoneAnchor::At(Pt::new(12.5)).offset(width).raw(), 12.5);
}
#[test]
fn a_decimal_zone_with_no_separator_anchors_at_its_end() {
let zone = [text_fragment("1234", 40.0)];
assert_eq!(
resolve_zone_anchor(model::TabAlignment::Decimal, &zone, None, '.'),
ZoneAnchor::End,
"right-align rather than left-align, so a whole number stays flush"
);
}
#[test]
fn a_decimal_zone_without_a_measurer_apportions_by_character_share() {
let zone = [text_fragment("12.5", 40.0)];
assert_eq!(
resolve_zone_anchor(model::TabAlignment::Decimal, &zone, None, '.'),
ZoneAnchor::At(Pt::new(20.0))
);
}
#[test]
fn a_decimal_anchor_accumulates_fragments_before_the_separator() {
let zone = [text_fragment("12", 20.0), text_fragment(".5", 20.0)];
assert_eq!(
resolve_zone_anchor(model::TabAlignment::Decimal, &zone, None, '.'),
ZoneAnchor::At(Pt::new(20.0)),
"20pt of leading fragment + 0pt before the separator in the second"
);
}
fn ptab_geometry() -> PTabGeometry {
PTabGeometry {
max_width: Pt::new(400.0),
indent_left: Pt::ZERO,
indent_first_line: Pt::ZERO,
content_width: Pt::new(400.0),
float_left: Pt::ZERO,
float_right: Pt::ZERO,
}
}
#[test]
fn a_left_ptab_never_sums_its_zone() {
let placement = resolve_ptab(
model::PTabAlignment::Left,
model::PTabRelativeTo::Margin,
ptab_geometry(),
Pt::ZERO,
|| panic!("zone width computed for a left position tab"),
);
assert_eq!(placement, PTabPlacement::Placed(Pt::ZERO));
}
#[test]
fn right_and_centre_ptabs_measure_the_zone_once() {
for (align, expected) in [
(model::PTabAlignment::Right, 300.0),
(model::PTabAlignment::Center, 150.0),
] {
let calls = std::cell::Cell::new(0);
let placement = resolve_ptab(
align,
model::PTabRelativeTo::Margin,
ptab_geometry(),
Pt::ZERO,
|| {
calls.set(calls.get() + 1);
Pt::new(100.0)
},
);
assert_eq!(
placement,
PTabPlacement::Placed(Pt::new(expected)),
"{align:?}"
);
assert_eq!(calls.get(), 1, "{align:?} measures the zone once");
}
}
}