use crate::error::Result;
use crate::font::FontManager;
use crate::output::{Color, FieldKind, FontId, MediaId};
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TabStop {
pub pos_pt: f64,
pub align: TabAlign,
pub leader: Option<TabLeader>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Align {
Start,
Center,
End,
Justify,
Distribute,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TabAlign {
Left,
Center,
Right,
Decimal,
Bar,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TabLeader {
None,
Dot,
Hyphen,
Underscore,
Heavy,
MiddleDot,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Underline {
Single,
Words,
Double,
Thick,
Dotted,
Dash,
DotDash,
DotDotDash,
Wave,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum LineSpacing {
Single,
Multiple(f64),
Exact(f64),
AtLeast(f64),
}
#[derive(Debug, Clone)]
pub enum InlineItem {
Text(TextSegment),
Tab,
LineBreak,
PageBreak,
ColumnBreak,
Image {
width: f64,
height: f64,
media_id: MediaId,
},
Marker(TextSegment),
}
#[derive(Debug, Clone)]
pub struct TextSegment {
pub text: String,
pub font_id: FontId,
pub font_size: f64,
pub glyph_ids: Vec<u16>,
pub advances: Vec<f64>,
pub width: f64,
pub ascent: f64,
pub descent: f64,
pub line_gap: f64,
pub color: Color,
pub bold: bool,
pub italic: bool,
pub underline: Option<Underline>,
pub strike: bool,
pub dstrike: bool,
pub highlight: Option<Color>,
pub baseline_offset: f64,
pub hyperlink_url: Option<String>,
pub field_kind: Option<FieldKind>,
pub footnote_id: Option<i32>,
}
#[derive(Debug, Clone)]
pub enum LineItem {
Text(TextSegment),
Tab {
width: f64,
leader: Option<TextSegment>,
},
Image {
width: f64,
height: f64,
media_id: MediaId,
},
Marker(TextSegment),
}
impl LineItem {
pub fn width(&self) -> f64 {
match self {
LineItem::Text(seg) => seg.width,
LineItem::Tab { width, .. } => *width,
LineItem::Image { width, .. } => *width,
LineItem::Marker(seg) => seg.width,
}
}
}
#[derive(Debug, Clone)]
pub struct LayoutLine {
pub items: Vec<LineItem>,
pub width: f64,
pub ascent: f64,
pub descent: f64,
pub line_gap: f64,
pub height: f64,
pub indent_left: f64,
pub available_width: f64,
pub is_last: bool,
}
impl LayoutLine {
pub fn baseline_offset(&self) -> f64 {
let leading = self.height - self.ascent - self.descent;
self.ascent + if leading >= 0.0 { leading / 2.0 } else { 0.0 }
}
}
pub struct LineBreakParams {
pub available_width: f64,
pub ind_left: f64,
pub ind_right: f64,
pub ind_first_line: f64,
pub ind_hanging: f64,
pub tab_stops: Vec<TabStop>,
pub line_spacing: LineSpacing,
pub jc: Option<Align>,
pub wrap: bool,
}
impl Default for LineBreakParams {
fn default() -> Self {
LineBreakParams {
available_width: 468.0, ind_left: 0.0,
ind_right: 0.0,
ind_first_line: 0.0,
ind_hanging: 0.0,
tab_stops: Vec::new(),
line_spacing: LineSpacing::Single,
jc: None,
wrap: true,
}
}
}
pub fn break_into_lines(
items: &[InlineItem],
params: &LineBreakParams,
fm: &FontManager,
) -> Result<Vec<LayoutLine>> {
if items.is_empty() {
return Ok(vec![LayoutLine {
items: Vec::new(),
width: 0.0,
ascent: 0.0,
descent: 0.0,
line_gap: 0.0,
height: compute_line_height(0.0, 0.0, 0.0, 0.0, params),
indent_left: params.ind_left + params.ind_first_line,
available_width: params.available_width,
is_last: true,
}]);
}
let mut lines: Vec<LayoutLine> = Vec::new();
let mut current_items: Vec<LineItem> = Vec::new();
let mut current_width: f64 = 0.0;
let mut current_ascent: f64 = 0.0;
let mut current_descent: f64 = 0.0;
let mut current_natural_height: f64 = 0.0;
let mut current_font_size: f64 = 0.0;
let mut is_first_line = true;
let first_line_width = compute_first_line_width(params);
let subsequent_line_width = compute_subsequent_line_width(params);
let mut line_avail = first_line_width;
let mut font_ctx: Option<(FontId, f64)> = None;
for item in items {
if let InlineItem::Text(seg) | InlineItem::Marker(seg) = item {
font_ctx = Some((seg.font_id, seg.font_size));
break;
}
}
let segments = build_breakable_segments(items, fm)?;
for seg in &segments {
match seg {
BreakableSegment::Items(seg_items) => {
let seg_width: f64 = seg_items.iter().map(inline_item_width).sum();
if params.wrap
&& !current_items.is_empty()
&& current_width + seg_width > line_avail + 0.01
{
let indent = if is_first_line {
first_line_indent(params)
} else {
subsequent_line_indent(params)
};
let line_gap =
effective_line_gap(current_ascent, current_descent, current_natural_height);
lines.push(LayoutLine {
items: std::mem::take(&mut current_items),
width: current_width,
ascent: current_ascent,
descent: current_descent,
line_gap,
height: compute_line_height(
current_ascent,
current_descent,
line_gap,
current_font_size,
params,
),
indent_left: indent,
available_width: line_avail,
is_last: false,
});
current_width = 0.0;
current_ascent = 0.0;
current_descent = 0.0;
current_natural_height = 0.0;
current_font_size = 0.0;
is_first_line = false;
line_avail = subsequent_line_width;
}
for item in seg_items {
let (w, a, d, natural_height, font_size) = item_metrics(item);
current_width += w;
if a > current_ascent {
current_ascent = a;
}
if d > current_descent {
current_descent = d;
}
current_natural_height = current_natural_height.max(natural_height);
current_font_size = current_font_size.max(font_size);
if let InlineItem::Text(seg) | InlineItem::Marker(seg) = item {
font_ctx = Some((seg.font_id, seg.font_size));
}
current_items.push(inline_to_line_item(
item,
current_width,
¶ms.tab_stops,
fm,
font_ctx,
));
}
}
BreakableSegment::ForcedBreak(break_type) => {
let indent = if is_first_line {
first_line_indent(params)
} else {
subsequent_line_indent(params)
};
let line_gap =
effective_line_gap(current_ascent, current_descent, current_natural_height);
lines.push(LayoutLine {
items: std::mem::take(&mut current_items),
width: current_width,
ascent: current_ascent,
descent: current_descent,
line_gap,
height: compute_line_height(
current_ascent,
current_descent,
line_gap,
current_font_size,
params,
),
indent_left: indent,
available_width: line_avail,
is_last: matches!(break_type, ForcedBreakType::Page | ForcedBreakType::Column),
});
current_width = 0.0;
current_ascent = 0.0;
current_descent = 0.0;
current_natural_height = 0.0;
current_font_size = 0.0;
is_first_line = false;
line_avail = subsequent_line_width;
}
}
}
let indent = if is_first_line {
first_line_indent(params)
} else {
subsequent_line_indent(params)
};
let line_gap = effective_line_gap(current_ascent, current_descent, current_natural_height);
lines.push(LayoutLine {
items: current_items,
width: current_width,
ascent: current_ascent,
descent: current_descent,
line_gap,
height: compute_line_height(
current_ascent,
current_descent,
line_gap,
current_font_size,
params,
),
indent_left: indent,
available_width: line_avail,
is_last: true,
});
Ok(lines)
}
#[derive(Debug)]
enum BreakableSegment {
Items(Vec<InlineItem>),
ForcedBreak(ForcedBreakType),
}
#[derive(Debug)]
enum ForcedBreakType {
Line,
Page,
Column,
}
fn build_breakable_segments(
items: &[InlineItem],
fm: &FontManager,
) -> Result<Vec<BreakableSegment>> {
let mut segments = Vec::new();
let mut current_group: Vec<InlineItem> = Vec::new();
for item in items {
match item {
InlineItem::LineBreak => {
if !current_group.is_empty() {
segments.push(BreakableSegment::Items(std::mem::take(&mut current_group)));
}
segments.push(BreakableSegment::ForcedBreak(ForcedBreakType::Line));
}
InlineItem::PageBreak => {
if !current_group.is_empty() {
segments.push(BreakableSegment::Items(std::mem::take(&mut current_group)));
}
segments.push(BreakableSegment::ForcedBreak(ForcedBreakType::Page));
}
InlineItem::ColumnBreak => {
if !current_group.is_empty() {
segments.push(BreakableSegment::Items(std::mem::take(&mut current_group)));
}
segments.push(BreakableSegment::ForcedBreak(ForcedBreakType::Column));
}
InlineItem::Tab => {
if !current_group.is_empty() {
segments.push(BreakableSegment::Items(std::mem::take(&mut current_group)));
}
segments.push(BreakableSegment::Items(vec![item.clone()]));
}
InlineItem::Text(seg) => {
if seg.text.is_empty() {
if !current_group.is_empty() {
segments.push(BreakableSegment::Items(std::mem::take(&mut current_group)));
}
segments.push(BreakableSegment::Items(vec![item.clone()]));
continue;
}
let breaks = split_text_at_break_opportunities(seg);
for tb in &breaks {
let chunk = &seg.text[tb.start..tb.end];
if chunk.is_empty() {
continue;
}
if !current_group.is_empty() && chunk.starts_with(|c: char| c.is_whitespace()) {
segments.push(BreakableSegment::Items(std::mem::take(&mut current_group)));
}
let sub_item = split_text_subsegment(seg, tb.start, tb.end, fm)?;
current_group.push(sub_item);
if tb.is_break {
segments.push(BreakableSegment::Items(std::mem::take(&mut current_group)));
}
}
if !current_group.is_empty() {
segments.push(BreakableSegment::Items(std::mem::take(&mut current_group)));
}
}
InlineItem::Marker(_) | InlineItem::Image { .. } => {
current_group.push(item.clone());
}
}
}
if !current_group.is_empty() {
segments.push(BreakableSegment::Items(current_group));
}
Ok(segments)
}
fn split_text_subsegment(
seg: &TextSegment,
byte_start: usize,
byte_end: usize,
fm: &FontManager,
) -> Result<InlineItem> {
if byte_start == 0 && byte_end == seg.text.len() {
return Ok(InlineItem::Text(seg.clone()));
}
let sub_text = seg.text[byte_start..byte_end].to_string();
let mut shaped = fm.shape_text(seg.font_id, &sub_text, seg.font_size)?;
let original = fm.shape_text(seg.font_id, &seg.text, seg.font_size)?;
let spacing = if original.advances.len() == seg.advances.len() && !original.advances.is_empty()
{
(seg.width - original.width) / original.advances.len() as f64
} else {
0.0
};
for advance in &mut shaped.advances {
*advance += spacing;
}
shaped.width += spacing * shaped.advances.len() as f64;
Ok(InlineItem::Text(TextSegment {
text: sub_text,
font_id: seg.font_id,
font_size: seg.font_size,
glyph_ids: shaped.glyph_ids,
advances: shaped.advances,
width: shaped.width,
ascent: seg.ascent,
descent: seg.descent,
line_gap: seg.line_gap,
color: seg.color,
bold: seg.bold,
italic: seg.italic,
underline: seg.underline,
strike: seg.strike,
dstrike: seg.dstrike,
highlight: seg.highlight,
baseline_offset: seg.baseline_offset,
hyperlink_url: seg.hyperlink_url.clone(),
field_kind: seg.field_kind,
footnote_id: seg.footnote_id,
}))
}
struct TextBreakInfo {
start: usize,
end: usize,
is_break: bool,
}
fn split_text_at_break_opportunities(seg: &TextSegment) -> Vec<TextBreakInfo> {
use unicode_linebreak::{BreakOpportunity, linebreaks};
let text = &seg.text;
if text.is_empty() {
return vec![];
}
let mut breaks = Vec::new();
let mut last_start = 0;
for (byte_pos, opportunity) in linebreaks(text) {
if byte_pos == 0 {
continue;
}
let is_break = matches!(
opportunity,
BreakOpportunity::Allowed | BreakOpportunity::Mandatory
);
breaks.push(TextBreakInfo {
start: last_start,
end: byte_pos,
is_break,
});
last_start = byte_pos;
}
if breaks.is_empty() {
breaks.push(TextBreakInfo {
start: 0,
end: text.len(),
is_break: true,
});
}
breaks
}
fn inline_item_width(item: &InlineItem) -> f64 {
match item {
InlineItem::Text(seg) => seg.width,
InlineItem::Tab => 36.0, InlineItem::Image { width, .. } => *width,
InlineItem::Marker(seg) => seg.width,
InlineItem::LineBreak | InlineItem::PageBreak | InlineItem::ColumnBreak => 0.0,
}
}
fn item_metrics(item: &InlineItem) -> (f64, f64, f64, f64, f64) {
match item {
InlineItem::Text(seg) => (
seg.width,
seg.ascent,
seg.descent,
seg.ascent + seg.descent + seg.line_gap,
seg.font_size,
),
InlineItem::Marker(seg) => (
seg.width,
seg.ascent,
seg.descent,
seg.ascent + seg.descent + seg.line_gap,
0.0,
),
InlineItem::Tab => (36.0, 0.0, 0.0, 0.0, 0.0),
InlineItem::Image { width, height, .. } => (*width, *height, 0.0, *height, 0.0),
InlineItem::LineBreak | InlineItem::PageBreak | InlineItem::ColumnBreak => {
(0.0, 0.0, 0.0, 0.0, 0.0)
}
}
}
fn inline_to_line_item(
item: &InlineItem,
current_x: f64,
tab_stops: &[TabStop],
fm: &FontManager,
font_ctx: Option<(FontId, f64)>,
) -> LineItem {
match item {
InlineItem::Text(seg) => LineItem::Text(seg.clone()),
InlineItem::Marker(seg) => LineItem::Marker(seg.clone()),
InlineItem::Tab => {
let (tab_width, leader_char) = resolve_tab_width(current_x, tab_stops);
let leader = leader_char.and_then(|ch| shape_leader(fm, font_ctx, ch, tab_width));
LineItem::Tab {
width: tab_width,
leader,
}
}
InlineItem::Image {
width,
height,
media_id,
} => LineItem::Image {
width: *width,
height: *height,
media_id: *media_id,
},
InlineItem::LineBreak | InlineItem::PageBreak | InlineItem::ColumnBreak => LineItem::Tab {
width: 0.0,
leader: None,
},
}
}
fn shape_leader(
fm: &FontManager,
font_ctx: Option<(FontId, f64)>,
leader_char: char,
tab_width: f64,
) -> Option<TextSegment> {
let (font_id, font_size) = font_ctx?;
if tab_width < 1.0 {
return None;
}
let single = String::from(leader_char);
let shaped = fm.shape_text(font_id, &single, font_size).ok()?;
if shaped.glyph_ids.is_empty() {
return None;
}
let char_advance = shaped.advances[0];
if char_advance < 0.5 {
return None;
}
let spacing = match leader_char {
'.' | '\u{00B7}' => char_advance * 0.5,
_ => char_advance * 0.15,
};
let step = char_advance + spacing;
let count = ((tab_width - spacing) / step).floor() as usize;
if count == 0 {
return None;
}
let leader_text: String = std::iter::repeat_n(leader_char, count).collect();
let mut glyph_ids = Vec::with_capacity(count);
let mut advances = Vec::with_capacity(count);
for i in 0..count {
glyph_ids.push(shaped.glyph_ids[0]);
if i + 1 < count {
advances.push(char_advance + spacing);
} else {
advances.push(char_advance);
}
}
let metrics = fm.metrics(font_id, font_size).ok()?;
Some(TextSegment {
text: leader_text,
font_id,
font_size,
glyph_ids,
advances,
width: tab_width, ascent: metrics.ascent,
descent: metrics.descent,
line_gap: metrics.line_gap,
color: Color::BLACK,
bold: false,
italic: false,
underline: None,
strike: false,
dstrike: false,
highlight: None,
baseline_offset: 0.0,
hyperlink_url: None,
field_kind: None,
footnote_id: None,
})
}
fn resolve_tab_width(current_x: f64, tab_stops: &[TabStop]) -> (f64, Option<char>) {
for stop in tab_stops {
let stop_pos = stop.pos_pt;
if stop_pos > current_x {
let width = match stop.align {
TabAlign::Left => stop_pos - current_x,
TabAlign::Center => (stop_pos - current_x).max(0.0),
TabAlign::Right => (stop_pos - current_x).max(0.0),
_ => stop_pos - current_x,
};
let leader = stop.leader.and_then(|l| match l {
TabLeader::Dot => Some('.'),
TabLeader::Hyphen => Some('-'),
TabLeader::Underscore => Some('_'),
TabLeader::MiddleDot => Some('\u{00B7}'),
TabLeader::Heavy => Some('_'),
TabLeader::None => None,
});
return (width, leader);
}
}
let default_interval = 36.0;
let next_stop = ((current_x / default_interval).floor() + 1.0) * default_interval;
(next_stop - current_x, None)
}
fn compute_first_line_width(params: &LineBreakParams) -> f64 {
if params.ind_hanging > 0.0 {
params.available_width - params.ind_left - params.ind_right + params.ind_hanging
} else {
params.available_width - params.ind_left - params.ind_right - params.ind_first_line
}
}
fn compute_subsequent_line_width(params: &LineBreakParams) -> f64 {
params.available_width - params.ind_left - params.ind_right
}
fn first_line_indent(params: &LineBreakParams) -> f64 {
if params.ind_hanging > 0.0 {
params.ind_left - params.ind_hanging
} else {
params.ind_left + params.ind_first_line
}
}
fn subsequent_line_indent(params: &LineBreakParams) -> f64 {
params.ind_left
}
fn effective_line_gap(ascent: f64, descent: f64, natural_height: f64) -> f64 {
(natural_height - ascent - descent).max(0.0)
}
fn compute_line_height(
ascent: f64,
descent: f64,
line_gap: f64,
font_size: f64,
params: &LineBreakParams,
) -> f64 {
let natural = ascent + descent + line_gap;
let natural = if natural < 1.0 { 12.0 } else { natural }; let font_size = if font_size < 1.0 { 12.0 } else { font_size };
match params.line_spacing {
LineSpacing::Single => natural,
LineSpacing::Multiple(factor) => font_size * factor,
LineSpacing::Exact(points) => points,
LineSpacing::AtLeast(points) => natural.max(points),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_text_segment(text: &str, width: f64) -> TextSegment {
TextSegment {
text: text.to_string(),
font_id: FontId(0),
font_size: 12.0,
glyph_ids: vec![],
advances: vec![],
width,
ascent: 10.0,
descent: 3.0,
line_gap: 0.0,
color: Color::BLACK,
bold: false,
italic: false,
underline: None,
strike: false,
dstrike: false,
highlight: None,
baseline_offset: 0.0,
hyperlink_url: None,
field_kind: None,
footnote_id: None,
}
}
fn deterministic_font_manager() -> FontManager {
FontManager::new_deterministic().expect("bundled fonts should load")
}
fn shaped_text_segment(fm: &mut FontManager, text: &str, spacing: f64) -> TextSegment {
let font_id = fm
.resolve_font(Some("Carlito"), false, false)
.expect("bundled Carlito should resolve");
let metrics = fm.metrics(font_id, 30.0).expect("Carlito metrics");
let mut shaped = fm.shape_text(font_id, text, 30.0).expect("shape text");
for advance in &mut shaped.advances {
*advance += spacing;
}
shaped.width += spacing * shaped.advances.len() as f64;
TextSegment {
text: text.to_owned(),
font_id,
font_size: 30.0,
glyph_ids: shaped.glyph_ids,
advances: shaped.advances,
width: shaped.width,
ascent: metrics.ascent,
descent: metrics.descent,
line_gap: metrics.line_gap,
color: Color::BLACK,
bold: false,
italic: false,
underline: None,
strike: false,
dstrike: false,
highlight: None,
baseline_offset: 0.0,
hyperlink_url: None,
field_kind: None,
footnote_id: None,
}
}
#[test]
fn empty_paragraph_gets_one_line() {
let fm = deterministic_font_manager();
let lines = break_into_lines(&[], &LineBreakParams::default(), &fm).unwrap();
assert_eq!(lines.len(), 1);
assert!(lines[0].is_last);
assert!(lines[0].items.is_empty());
}
#[test]
fn single_word_fits_one_line() {
let fm = deterministic_font_manager();
let items = vec![InlineItem::Text(make_text_segment("Hello", 50.0))];
let lines = break_into_lines(&items, &LineBreakParams::default(), &fm).unwrap();
assert_eq!(lines.len(), 1);
assert!(lines[0].is_last);
}
#[test]
fn words_wrap_to_multiple_lines() {
let fm = deterministic_font_manager();
let mut items = vec![
InlineItem::Text(make_text_segment("Word1", 200.0)),
InlineItem::Text(make_text_segment("Word2", 200.0)),
];
items.push(InlineItem::Text(make_text_segment("Word3", 200.0)));
let lines = break_into_lines(&items, &LineBreakParams::default(), &fm).unwrap();
assert!(lines.len() >= 2);
}
#[test]
fn ligature_runs_reshape_each_break_chunk_without_duplicate_glyphs() {
let mut fm = deterministic_font_manager();
let text = "by providing opportunities to crawl in cluttered spaces and handle 3-dimensional objects";
let spacing = 0.4;
let segment = shaped_text_segment(&mut fm, text, spacing);
assert_ne!(segment.glyph_ids.len(), text.chars().count());
let lines = break_into_lines(
&[InlineItem::Text(segment)],
&LineBreakParams {
available_width: 260.0,
..LineBreakParams::default()
},
&fm,
)
.expect("wrap ligature-bearing text");
assert!(lines.len() > 1);
let mut rendered_text = String::new();
for text_segment in lines.iter().flat_map(|line| {
line.items.iter().filter_map(|item| match item {
LineItem::Text(segment) => Some(segment),
_ => None,
})
}) {
rendered_text.push_str(&text_segment.text);
let exact = fm
.shape_text(
text_segment.font_id,
&text_segment.text,
text_segment.font_size,
)
.expect("reshape emitted chunk");
assert_eq!(text_segment.glyph_ids, exact.glyph_ids);
assert_eq!(text_segment.advances.len(), exact.advances.len());
for (actual, unspaced) in text_segment.advances.iter().zip(exact.advances) {
assert!((actual - (unspaced + spacing)).abs() < 1.0e-10);
}
}
assert_eq!(rendered_text, text);
}
#[test]
fn forced_line_break() {
let fm = deterministic_font_manager();
let items = vec![
InlineItem::Text(make_text_segment("Before", 50.0)),
InlineItem::LineBreak,
InlineItem::Text(make_text_segment("After", 50.0)),
];
let lines = break_into_lines(&items, &LineBreakParams::default(), &fm).unwrap();
assert!(lines.len() >= 2);
}
#[test]
fn line_height_exact() {
let params = LineBreakParams {
line_spacing: LineSpacing::Exact(24.0),
..Default::default()
};
let h = compute_line_height(10.0, 3.0, 5.0, 12.0, ¶ms);
assert!((h - 24.0).abs() < 0.01);
}
#[test]
fn line_height_auto() {
let params = LineBreakParams {
line_spacing: LineSpacing::Multiple(2.0),
..Default::default()
};
let h = compute_line_height(10.0, 3.0, 2.0, 15.0, ¶ms);
assert!((h - 30.0).abs() < 0.01); }
#[test]
fn first_line_indent() {
let params = LineBreakParams {
ind_first_line: 36.0,
..Default::default()
};
let first_w = compute_first_line_width(¶ms);
let subseq_w = compute_subsequent_line_width(¶ms);
assert!(first_w < subseq_w);
}
#[test]
fn hanging_indent() {
let params = LineBreakParams {
ind_left: 36.0,
ind_hanging: 36.0,
..Default::default()
};
let first_indent = super::first_line_indent(¶ms);
let subseq_indent = super::subsequent_line_indent(¶ms);
assert!(first_indent < subseq_indent);
}
#[test]
fn tab_stop_resolution() {
let stops = vec![TabStop {
pos_pt: 72.0,
align: TabAlign::Left,
leader: None,
}];
let (w, leader) = resolve_tab_width(36.0, &stops);
assert!((w - 36.0).abs() < 0.01);
assert!(leader.is_none());
}
#[test]
fn default_tab_stops() {
let (w, _) = resolve_tab_width(10.0, &[]);
assert!((w - 26.0).abs() < 0.01); }
#[test]
fn tab_stop_with_dot_leader() {
let stops = vec![TabStop {
pos_pt: 400.0,
align: TabAlign::Right,
leader: Some(TabLeader::Dot),
}];
let (w, leader) = resolve_tab_width(100.0, &stops);
assert!((w - 300.0).abs() < 0.01);
assert_eq!(leader, Some('.'));
}
#[test]
fn the_eleven_line_tests_pass_with_owned_types() {
assert!(LineBreakParams::default().wrap);
empty_paragraph_gets_one_line();
single_word_fits_one_line();
words_wrap_to_multiple_lines();
forced_line_break();
line_height_exact();
line_height_auto();
first_line_indent();
hanging_indent();
tab_stop_resolution();
default_tab_stops();
tab_stop_with_dot_leader();
}
#[test]
fn line_spacing_variants_preserve_existing_height_rules() {
let height = |line_spacing| {
compute_line_height(
10.0,
3.0,
2.0,
11.0,
&LineBreakParams {
line_spacing,
..Default::default()
},
)
};
assert!((height(LineSpacing::Single) - 15.0).abs() < 0.01);
assert!((height(LineSpacing::Multiple(1.5)) - 16.5).abs() < 0.01);
assert!((height(LineSpacing::Exact(8.25)) - 8.25).abs() < 0.01);
assert!((height(LineSpacing::AtLeast(8.25)) - 15.0).abs() < 0.01);
assert!((height(LineSpacing::AtLeast(18.5)) - 18.5).abs() < 0.01);
}
#[test]
fn mixed_font_line_uses_tallest_full_natural_advance() {
let fm = deterministic_font_manager();
let mut first = make_text_segment("first", 20.0);
first.ascent = 10.0;
first.descent = 2.0;
first.line_gap = 4.0;
let mut second = make_text_segment("second", 20.0);
second.ascent = 8.0;
second.descent = 5.0;
second.line_gap = 1.0;
let lines = break_into_lines(
&[InlineItem::Text(first), InlineItem::Text(second)],
&LineBreakParams::default(),
&fm,
)
.expect("lay out mixed-font line");
assert_eq!(lines.len(), 1);
assert!((lines[0].ascent - 10.0).abs() < 0.01);
assert!((lines[0].descent - 5.0).abs() < 0.01);
assert!((lines[0].line_gap - 1.0).abs() < 0.01);
assert!((lines[0].height - 16.0).abs() < 0.01);
assert!((lines[0].baseline_offset() - 10.5).abs() < 0.01);
}
#[test]
fn multiple_spacing_uses_largest_text_point_size_on_each_line() {
let fm = deterministic_font_manager();
let mut first = make_text_segment("first", 20.0);
first.font_size = 12.0;
first.line_gap = 4.0;
let mut second = make_text_segment("second", 20.0);
second.font_size = 20.0;
second.line_gap = 1.0;
let lines = break_into_lines(
&[InlineItem::Text(first), InlineItem::Text(second)],
&LineBreakParams {
line_spacing: LineSpacing::Multiple(1.25),
..LineBreakParams::default()
},
&fm,
)
.expect("lay out percentage-spaced mixed-size line");
assert_eq!(lines.len(), 1);
assert!((lines[0].height - 25.0).abs() < 0.01);
}
#[test]
fn positive_leading_is_split_and_below_natural_exact_spacing_is_not_clamped() {
let positive = LayoutLine {
items: Vec::new(),
width: 0.0,
ascent: 10.0,
descent: 3.0,
line_gap: 5.0,
height: 18.0,
indent_left: 0.0,
available_width: 100.0,
is_last: true,
};
let below_natural = LayoutLine {
height: 8.0,
..positive.clone()
};
assert!((positive.baseline_offset() - 12.5).abs() < 0.01);
assert!((below_natural.height - 8.0).abs() < 0.01);
assert!((below_natural.baseline_offset() - 10.0).abs() < 0.01);
}
#[test]
fn zero_gap_and_empty_segment_preserve_natural_height_rules() {
let fm = deterministic_font_manager();
let zero_gap = make_text_segment("zero", 20.0);
let mut empty = make_text_segment("", 0.0);
empty.line_gap = 4.0;
let zero_gap_line = break_into_lines(
&[InlineItem::Text(zero_gap)],
&LineBreakParams::default(),
&fm,
)
.expect("lay out zero-gap line");
let empty_line =
break_into_lines(&[InlineItem::Text(empty)], &LineBreakParams::default(), &fm)
.expect("lay out styled empty line");
assert!((zero_gap_line[0].height - 13.0).abs() < 0.01);
assert!((empty_line[0].height - 17.0).abs() < 0.01);
}
#[test]
fn wrap_false_only_breaks_on_an_explicit_break() {
let fm = deterministic_font_manager();
let params = LineBreakParams {
available_width: 100.0,
wrap: false,
..Default::default()
};
for forced_break in [
InlineItem::LineBreak,
InlineItem::PageBreak,
InlineItem::ColumnBreak,
] {
let items = vec![
InlineItem::Text(make_text_segment("one", 80.0)),
InlineItem::Text(make_text_segment("two", 80.0)),
forced_break,
InlineItem::Text(make_text_segment("three", 80.0)),
InlineItem::Text(make_text_segment("four", 80.0)),
];
let lines = break_into_lines(&items, ¶ms, &fm).unwrap();
assert_eq!(lines.len(), 2);
assert!((lines[0].width - 160.0).abs() < 0.01);
assert!((lines[1].width - 160.0).abs() < 0.01);
}
}
#[test]
fn tab_stops_use_point_positions_and_owned_leaders() {
let mut fm = deterministic_font_manager();
let font_id = fm
.resolve_font(Some("Carlito"), false, false)
.expect("bundled Carlito should resolve");
let stop = TabStop {
pos_pt: 72.25,
align: TabAlign::Decimal,
leader: Some(TabLeader::Dot),
};
let item = inline_to_line_item(&InlineItem::Tab, 12.0, &[stop], &fm, Some((font_id, 12.0)));
let LineItem::Tab {
width,
leader: Some(leader),
} = item
else {
panic!("owned dot leader should shape into a tab line item");
};
assert!((width - 60.25).abs() < 0.01);
assert!((leader.width - 60.25).abs() < 0.01);
assert!(!leader.glyph_ids.is_empty());
assert!(leader.text.chars().all(|ch| ch == '.'));
}
#[test]
fn staged_image_types_use_media_id_instead_of_embed_id() {
let media_id = crate::MediaId::from_bytes(b"image");
let item = inline_to_line_item(
&InlineItem::Image {
width: 10.0,
height: 20.0,
media_id,
},
0.0,
&[],
&deterministic_font_manager(),
None,
);
let LineItem::Image {
media_id: actual, ..
} = item
else {
panic!("image should remain an image");
};
assert_eq!(actual, media_id);
}
}