use std::rc::Rc;
use crate::model::Alignment;
use super::draw_command::DrawCommand;
use super::fragment::Fragment;
use super::BoxConstraints;
use crate::render::dimension::Pt;
use crate::render::geometry::{PtOffset, PtSize};
use crate::render::resolve::color::RgbColor;
const LEADER_FONT_SIZE_CAP: Pt = Pt::new(12.0);
const LEADER_CHAR_WIDTH_FALLBACK: Pt = Pt::new(4.0);
struct LinePlacement {
line: super::line::FittedLine,
float_left: Pt,
float_right: Pt,
}
#[derive(Clone, Debug)]
pub struct TabStopDef {
pub position: Pt,
pub alignment: crate::model::TabAlignment,
pub leader: crate::model::TabLeader,
}
#[derive(Clone, Debug)]
pub struct ParagraphStyle {
pub alignment: Alignment,
pub space_before: Pt,
pub space_after: Pt,
pub indent_left: Pt,
pub indent_right: Pt,
pub indent_first_line: Pt,
pub line_spacing: LineSpacingRule,
pub tabs: Vec<TabStopDef>,
pub drop_cap: Option<DropCapInfo>,
pub borders: Option<ParagraphBorderStyle>,
pub shading: Option<RgbColor>,
pub keep_next: bool,
pub contextual_spacing: bool,
pub style_id: Option<crate::model::StyleId>,
pub page_floats: Vec<super::float::ActiveFloat>,
pub page_y: Pt,
pub page_x: Pt,
pub page_content_width: Pt,
}
#[derive(Clone, Debug, PartialEq)]
pub struct ParagraphBorderStyle {
pub top: Option<BorderLine>,
pub bottom: Option<BorderLine>,
pub left: Option<BorderLine>,
pub right: Option<BorderLine>,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct BorderLine {
pub width: Pt,
pub color: RgbColor,
pub space: Pt,
}
#[derive(Clone, Debug)]
pub struct DropCapInfo {
pub fragments: Vec<Fragment>,
pub lines: u32,
pub width: Pt,
pub height: Pt,
pub ascent: Pt,
pub h_space: Pt,
pub margin_mode: bool,
pub indent: Pt,
pub frame_height: Option<Pt>,
pub position_offset: Pt,
}
impl Default for ParagraphStyle {
fn default() -> Self {
Self {
alignment: Alignment::Start,
space_before: Pt::ZERO,
space_after: Pt::ZERO,
indent_left: Pt::ZERO,
indent_right: Pt::ZERO,
indent_first_line: Pt::ZERO,
line_spacing: LineSpacingRule::Auto(1.0),
tabs: Vec::new(),
drop_cap: None,
borders: None,
shading: None,
keep_next: false,
contextual_spacing: false,
style_id: None,
page_floats: Vec::new(),
page_y: Pt::ZERO,
page_x: Pt::ZERO,
page_content_width: Pt::ZERO,
}
}
}
#[derive(Clone, Copy, Debug)]
pub enum LineSpacingRule {
Auto(f32),
Exact(Pt),
AtLeast(Pt),
}
#[derive(Debug)]
pub struct ParagraphLayout {
pub commands: Vec<DrawCommand>,
pub size: PtSize,
}
pub type MeasureTextFn<'a> =
Option<&'a dyn Fn(&str, &super::fragment::FontProps) -> (Pt, super::fragment::TextMetrics)>;
pub fn layout_paragraph(
fragments: &[Fragment],
constraints: &BoxConstraints,
style: &ParagraphStyle,
default_line_height: Pt,
measure_text: MeasureTextFn<'_>,
) -> ParagraphLayout {
let drop_cap_indent = style
.drop_cap
.as_ref()
.filter(|dc| !dc.margin_mode)
.map(|dc| dc.indent + dc.width + dc.h_space)
.unwrap_or(Pt::ZERO);
let drop_cap_lines = style
.drop_cap
.as_ref()
.map(|dc| dc.lines as usize)
.unwrap_or(0);
let border_space_left = style
.borders
.as_ref()
.and_then(|b| b.left.as_ref())
.map(|b| b.space)
.unwrap_or(Pt::ZERO);
let border_space_right = style
.borders
.as_ref()
.and_then(|b| b.right.as_ref())
.map(|b| b.space)
.unwrap_or(Pt::ZERO);
let content_width = constraints.max_width
- style.indent_left
- style.indent_right
- border_space_left
- border_space_right;
let first_line_adjustment = style.indent_first_line + drop_cap_indent;
let min_avail = (content_width - first_line_adjustment).max(Pt::ZERO);
let split_frags;
let fragments = if min_avail > Pt::ZERO {
split_frags = split_oversized_fragments(fragments, min_avail, measure_text);
&split_frags
} else {
fragments
};
let params = LineLayoutParams {
content_width,
first_line_adjustment,
drop_cap_indent,
drop_cap_lines,
default_line_height,
};
let line_placements = compute_line_placements(fragments, style, ¶ms);
let mut commands = Vec::new();
let mut cursor_y = style.space_before;
let drop_cap_baseline_y = if let Some(ref dc) = style.drop_cap {
if let Some(fh) = dc.frame_height {
let baseline = cursor_y + fh + dc.position_offset;
Some(baseline)
} else {
let n = dc.lines.max(1) as usize;
let mut y = cursor_y;
for (i, lp) in line_placements.iter().enumerate().take(n) {
let natural = if lp.line.height > Pt::ZERO {
lp.line.height
} else {
default_line_height
};
let text_h = if lp.line.text_height > Pt::ZERO {
lp.line.text_height
} else {
default_line_height
};
let lh = resolve_line_height(natural, text_h, &style.line_spacing);
if i == n - 1 {
y += lp.line.ascent;
break;
}
y += lh;
}
Some(y)
}
} else {
None
};
if let (Some(ref dc), Some(baseline_y)) = (&style.drop_cap, drop_cap_baseline_y) {
let dc_x = if dc.margin_mode {
dc.indent - dc.width - dc.h_space
} else {
dc.indent
};
for frag in &dc.fragments {
if let Fragment::Text {
text, font, color, ..
} = frag
{
commands.push(DrawCommand::Text {
position: PtOffset::new(dc_x, baseline_y),
text: text.clone(),
font_family: font.family.clone(),
char_spacing: font.char_spacing,
font_size: font.size,
bold: font.bold,
italic: font.italic,
color: *color,
});
}
}
}
emit_line_commands(
&mut commands,
&mut cursor_y,
&line_placements,
fragments,
style,
¶ms,
measure_text,
);
cursor_y = emit_paragraph_borders_and_shading(
&mut commands,
style,
constraints,
cursor_y,
default_line_height,
line_placements.is_empty(),
);
let total_height = constraints
.constrain(PtSize::new(constraints.max_width, cursor_y))
.height;
ParagraphLayout {
commands,
size: PtSize::new(constraints.max_width, total_height),
}
}
struct LineLayoutParams {
content_width: Pt,
first_line_adjustment: Pt,
drop_cap_indent: Pt,
drop_cap_lines: usize,
default_line_height: Pt,
}
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;
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::line::fit_lines_with_first(fragments, first_line_width, remaining_width)
.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::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 fitted = super::line::fit_lines_with_first(remaining, line_width, line_width);
let fitted_line = if let Some(first) = fitted.into_iter().next() {
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);
frag_idx = fitted_line.end;
placements.push(LinePlacement {
line: fitted_line,
float_left: fl,
float_right: fr,
});
line_y += lh;
}
placements
}
fn emit_line_commands(
commands: &mut Vec<DrawCommand>,
cursor_y: &mut Pt,
line_placements: &[LinePlacement],
fragments: &[Fragment],
style: &ParagraphStyle,
params: &LineLayoutParams,
measure_text: MeasureTextFn<'_>,
) {
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;
for (line_idx, lp) in line_placements.iter().enumerate() {
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);
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 - line.width).max(Pt::ZERO);
let line_has_tabs = fragments[line.start..line.end]
.iter()
.any(|f| matches!(f, Fragment::Tab { .. }));
let align_offset = if line_has_tabs {
Pt::ZERO
} else {
match style.alignment {
Alignment::Center => remaining * 0.5,
Alignment::End => remaining,
Alignment::Both if !line.has_break && line_idx < line_placements.len() - 1 => {
Pt::ZERO
}
_ => Pt::ZERO,
}
};
let x_start = indent + align_offset;
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,
..
} => {
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,
*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 = *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,
font_size: font.size,
bold: font.bold,
italic: font.italic,
color: *color,
});
if let Some(url) = hyperlink_url {
let rect = crate::render::geometry::PtRect::from_xywh(
x,
*cursor_y,
*width,
line_height,
);
if url.starts_with("http://")
|| url.starts_with("https://")
|| url.starts_with("mailto:")
|| url.starts_with("ftp://")
{
commands.push(DrawCommand::LinkAnnotation {
rect,
url: url.clone(),
});
} else {
commands.push(DrawCommand::InternalLink {
rect,
destination: url.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 + *width, underline_y),
),
color: *color,
width: stroke_width,
});
}
x += *width;
}
Fragment::Image {
size, image_data, ..
} => {
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(),
});
}
x += size.width;
}
Fragment::Tab { .. } => {
let (tab_pos, tab_stop) = find_next_tab_stop(x, &style.tabs, line_available);
let new_x = if let Some(ts) = tab_stop {
use crate::model::TabAlignment;
let zone_end = fragments[frag_idx + 1..line.end]
.iter()
.position(|f| matches!(f, Fragment::Tab { .. }))
.map_or(line.end, |i| frag_idx + 1 + i);
match ts.alignment {
TabAlignment::Right => {
let zone_width: Pt = fragments[frag_idx + 1..zone_end]
.iter()
.map(|f| f.width())
.sum();
(tab_pos - zone_width).max(x)
}
TabAlignment::Center => {
let zone_width: Pt = fragments[frag_idx + 1..zone_end]
.iter()
.map(|f| f.width())
.sum();
(tab_pos - zone_width * 0.5).max(x)
}
_ => tab_pos,
}
} else {
tab_pos
};
if let Some(ts) = tab_stop {
emit_tab_leader(
commands,
ts.leader,
x,
new_x,
*cursor_y + line.ascent,
measure_text,
default_line_height,
);
}
x = new_x;
}
Fragment::LineBreak { .. } | Fragment::ColumnBreak => {}
Fragment::Bookmark { name } => {
commands.push(DrawCommand::NamedDestination {
position: PtOffset::new(x, *cursor_y),
name: name.clone(),
});
}
}
}
*cursor_y += line_height;
}
}
fn emit_paragraph_borders_and_shading(
commands: &mut Vec<DrawCommand>,
style: &ParagraphStyle,
constraints: &BoxConstraints,
cursor_y: Pt,
default_line_height: Pt,
no_lines: bool,
) -> Pt {
let border_space_top = style
.borders
.as_ref()
.and_then(|b| b.top.as_ref())
.map(|b| b.space)
.unwrap_or(Pt::ZERO);
let border_space_bottom = style
.borders
.as_ref()
.and_then(|b| b.bottom.as_ref())
.map(|b| b.space)
.unwrap_or(Pt::ZERO);
let para_left = style.indent_left;
let para_right = constraints.max_width - style.indent_right;
let para_top = style.space_before - border_space_top;
let para_bottom = cursor_y + border_space_bottom;
if let Some(bg_color) = style.shading {
commands.insert(
0,
DrawCommand::Rect {
rect: crate::render::geometry::PtRect::from_xywh(
para_left,
para_top,
para_right - para_left,
para_bottom - para_top,
),
color: bg_color,
},
);
}
if let Some(ref borders) = style.borders {
if let Some(ref top) = borders.top {
commands.push(DrawCommand::Line {
line: crate::render::geometry::PtLineSegment::new(
PtOffset::new(para_left, para_top),
PtOffset::new(para_right, para_top),
),
color: top.color,
width: top.width,
});
}
if let Some(ref bottom) = borders.bottom {
commands.push(DrawCommand::Line {
line: crate::render::geometry::PtLineSegment::new(
PtOffset::new(para_left, para_bottom),
PtOffset::new(para_right, para_bottom),
),
color: bottom.color,
width: bottom.width,
});
}
if let Some(ref left) = borders.left {
commands.push(DrawCommand::Line {
line: crate::render::geometry::PtLineSegment::new(
PtOffset::new(para_left, para_top),
PtOffset::new(para_left, para_bottom),
),
color: left.color,
width: left.width,
});
}
if let Some(ref right) = borders.right {
commands.push(DrawCommand::Line {
line: crate::render::geometry::PtLineSegment::new(
PtOffset::new(para_right, para_top),
PtOffset::new(para_right, para_bottom),
),
color: right.color,
width: right.width,
});
}
}
let mut cursor_y = cursor_y + border_space_bottom + style.space_after;
if no_lines {
let line_h = resolve_line_height(
default_line_height,
default_line_height,
&style.line_spacing,
);
cursor_y = style.space_before + line_h + style.space_after;
}
cursor_y
}
fn split_oversized_fragments(
fragments: &[Fragment],
max_width: Pt,
measure: MeasureTextFn<'_>,
) -> Vec<Fragment> {
let mut result = Vec::with_capacity(fragments.len());
let mut any_split = false;
for frag in fragments {
match frag {
Fragment::Text {
text,
width,
font,
color,
shading,
border,
metrics,
hyperlink_url,
baseline_offset,
..
} if *width > max_width && text.chars().count() > 1 => {
any_split = true;
for ch in text.chars() {
let ch_str = ch.to_string();
let (w, char_metrics) = if let Some(m) = measure {
m(&ch_str, font)
} else {
let per_char = *width / text.chars().count() as f32;
(per_char, *metrics)
};
result.push(Fragment::Text {
text: Rc::from(ch_str.as_str()),
font: font.clone(),
color: *color,
shading: *shading,
border: *border,
width: w,
trimmed_width: w,
metrics: char_metrics,
hyperlink_url: hyperlink_url.clone(),
baseline_offset: *baseline_offset,
text_offset: Pt::ZERO,
});
}
}
_ => result.push(frag.clone()),
}
}
if !any_split {
return fragments.to_vec();
}
result
}
fn find_next_tab_stop(
current_x: Pt,
tabs: &[TabStopDef],
line_width: Pt,
) -> (Pt, Option<&TabStopDef>) {
const DEFAULT_TAB_INTERVAL: f32 = 36.0;
for ts in tabs {
if ts.position > current_x {
return (ts.position, Some(ts));
}
}
let next = ((current_x.raw() / DEFAULT_TAB_INTERVAL).floor() + 1.0) * DEFAULT_TAB_INTERVAL;
(Pt::new(next.min(line_width.raw())), None)
}
fn emit_tab_leader(
commands: &mut Vec<DrawCommand>,
leader: crate::model::TabLeader,
x_start: Pt,
x_end: Pt,
baseline_y: Pt,
measure_text: MeasureTextFn<'_>,
default_line_height: Pt,
) {
use crate::model::TabLeader;
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::fragment::FontProps {
family: std::rc::Rc::from("Times New Roman"),
size: default_line_height.min(LEADER_FONT_SIZE_CAP),
bold: false,
italic: false,
underline: false,
char_spacing: Pt::ZERO,
underline_position: Pt::ZERO,
underline_thickness: Pt::ZERO,
};
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: false,
italic: false,
color: crate::render::resolve::color::RgbColor::BLACK,
});
}
fn resolve_line_height(natural: Pt, text_height: Pt, rule: &LineSpacingRule) -> Pt {
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),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::render::layout::fragment::{FontProps, TextMetrics};
use crate::render::resolve::color::RgbColor;
use std::rc::Rc;
fn text_frag(text: &str, width: f32) -> Fragment {
Fragment::Text {
text: text.into(),
font: FontProps {
family: Rc::from("Test"),
size: Pt::new(12.0),
bold: false,
italic: false,
underline: false,
char_spacing: Pt::ZERO,
underline_position: Pt::ZERO,
underline_thickness: Pt::ZERO,
},
color: RgbColor::BLACK,
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,
shading: None,
border: None,
baseline_offset: Pt::ZERO,
text_offset: Pt::ZERO,
}
}
fn body_constraints(width: f32) -> BoxConstraints {
BoxConstraints::new(Pt::ZERO, Pt::new(width), Pt::ZERO, Pt::new(1000.0))
}
#[test]
fn empty_paragraph_has_default_height() {
let result = layout_paragraph(
&[],
&body_constraints(400.0),
&ParagraphStyle::default(),
Pt::new(14.0),
None,
);
assert_eq!(result.size.height.raw(), 14.0, "default line height");
assert!(result.commands.is_empty());
}
#[test]
fn single_line_produces_text_command() {
let frags = vec![text_frag("hello", 30.0)];
let result = layout_paragraph(
&frags,
&body_constraints(400.0),
&ParagraphStyle::default(),
Pt::new(14.0),
None,
);
assert_eq!(result.commands.len(), 1);
if let DrawCommand::Text { text, position, .. } = &result.commands[0] {
assert_eq!(&**text, "hello");
assert_eq!(position.x.raw(), 0.0); }
}
#[test]
fn center_alignment_shifts_text() {
let frags = vec![text_frag("hi", 20.0)];
let style = ParagraphStyle {
alignment: Alignment::Center,
..Default::default()
};
let result = layout_paragraph(
&frags,
&body_constraints(100.0),
&style,
Pt::new(14.0),
None,
);
if let DrawCommand::Text { position, .. } = &result.commands[0] {
assert_eq!(position.x.raw(), 40.0); }
}
#[test]
fn end_alignment_right_aligns() {
let frags = vec![text_frag("hi", 20.0)];
let style = ParagraphStyle {
alignment: Alignment::End,
..Default::default()
};
let result = layout_paragraph(
&frags,
&body_constraints(100.0),
&style,
Pt::new(14.0),
None,
);
if let DrawCommand::Text { position, .. } = &result.commands[0] {
assert_eq!(position.x.raw(), 80.0); }
}
#[test]
fn indentation_shifts_text() {
let frags = vec![text_frag("text", 40.0)];
let style = ParagraphStyle {
indent_left: Pt::new(36.0),
..Default::default()
};
let result = layout_paragraph(
&frags,
&body_constraints(400.0),
&style,
Pt::new(14.0),
None,
);
if let DrawCommand::Text { position, .. } = &result.commands[0] {
assert_eq!(position.x.raw(), 36.0);
}
}
#[test]
fn first_line_indent() {
let frags = vec![text_frag("first ", 40.0), text_frag("second", 40.0)];
let style = ParagraphStyle {
indent_first_line: Pt::new(24.0),
..Default::default()
};
let result = layout_paragraph(
&frags,
&body_constraints(400.0),
&style,
Pt::new(14.0),
None,
);
if let DrawCommand::Text { position, .. } = &result.commands[0] {
assert_eq!(position.x.raw(), 24.0, "first line indented");
}
}
#[test]
fn space_before_and_after() {
let frags = vec![text_frag("text", 30.0)];
let style = ParagraphStyle {
space_before: Pt::new(10.0),
space_after: Pt::new(8.0),
..Default::default()
};
let result = layout_paragraph(
&frags,
&body_constraints(400.0),
&style,
Pt::new(14.0),
None,
);
assert_eq!(result.size.height.raw(), 32.0);
if let DrawCommand::Text { position, .. } = &result.commands[0] {
assert!(
position.y.raw() >= 10.0,
"y should account for space_before"
);
}
}
#[test]
fn line_spacing_exact() {
let frags = vec![text_frag("line1 ", 60.0), text_frag("line2", 60.0)];
let style = ParagraphStyle {
line_spacing: LineSpacingRule::Exact(Pt::new(20.0)),
..Default::default()
};
let result = layout_paragraph(&frags, &body_constraints(80.0), &style, Pt::new(14.0), None);
assert_eq!(result.size.height.raw(), 40.0, "2 lines * 20pt each");
}
#[test]
fn line_spacing_at_least_with_larger_natural() {
let frags = vec![text_frag("text", 30.0)];
let style = ParagraphStyle {
line_spacing: LineSpacingRule::AtLeast(Pt::new(10.0)),
..Default::default()
};
let result = layout_paragraph(
&frags,
&body_constraints(400.0),
&style,
Pt::new(14.0),
None,
);
assert_eq!(result.size.height.raw(), 14.0);
}
#[test]
fn wrapping_produces_multiple_lines() {
let frags = vec![
text_frag("word1 ", 45.0),
text_frag("word2 ", 45.0),
text_frag("word3", 45.0),
];
let result = layout_paragraph(
&frags,
&body_constraints(80.0),
&ParagraphStyle::default(),
Pt::new(14.0),
None,
);
let text_count = result
.commands
.iter()
.filter(|c| matches!(c, DrawCommand::Text { .. }))
.count();
assert_eq!(text_count, 3);
assert_eq!(result.size.height.raw(), 42.0);
}
#[test]
fn resolve_line_height_auto_text_only() {
assert_eq!(
resolve_line_height(Pt::new(14.0), Pt::new(14.0), &LineSpacingRule::Auto(1.0)).raw(),
14.0
);
assert_eq!(
resolve_line_height(Pt::new(14.0), Pt::new(14.0), &LineSpacingRule::Auto(1.5)).raw(),
21.0
);
}
#[test]
fn resolve_line_height_auto_image_line() {
let h = resolve_line_height(Pt::new(325.0), Pt::ZERO, &LineSpacingRule::Auto(1.08));
assert_eq!(h.raw(), 325.0, "image height should not be multiplied");
}
#[test]
fn resolve_line_height_auto_mixed_line() {
let h = resolve_line_height(Pt::new(100.0), Pt::new(14.0), &LineSpacingRule::Auto(1.5));
assert_eq!(h.raw(), 100.0, "image dominates");
}
#[test]
fn resolve_line_height_exact_overrides() {
assert_eq!(
resolve_line_height(
Pt::new(14.0),
Pt::new(14.0),
&LineSpacingRule::Exact(Pt::new(20.0))
)
.raw(),
20.0
);
}
#[test]
fn resolve_line_height_at_least() {
assert_eq!(
resolve_line_height(
Pt::new(14.0),
Pt::new(14.0),
&LineSpacingRule::AtLeast(Pt::new(10.0))
)
.raw(),
14.0,
"natural > minimum"
);
assert_eq!(
resolve_line_height(
Pt::new(8.0),
Pt::new(8.0),
&LineSpacingRule::AtLeast(Pt::new(10.0))
)
.raw(),
10.0,
"minimum > natural"
);
}
}