mod borders;
mod line_emit;
mod types;
pub use types::*;
use super::draw_command::DrawCommand;
use super::fragment::Fragment;
use super::BoxConstraints;
use crate::render::dimension::Pt;
use crate::render::geometry::{PtOffset, PtRect, PtSize};
use borders::emit_paragraph_borders_and_shading;
use line_emit::{
compute_line_placements, emit_line_commands, resolve_line_height, split_oversized_fragments,
};
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,
}
struct LineLayoutParams {
content_width: Pt,
max_width: Pt,
first_line_adjustment: Pt,
drop_cap_indent: Pt,
drop_cap_lines: usize,
default_line_height: Pt,
}
fn clip_oversized_images(fragments: &[Fragment], max_width: Pt) -> Option<Vec<Fragment>> {
let oversized =
|f: &Fragment| matches!(f, Fragment::Image { size, .. } if size.width > max_width);
if max_width <= Pt::ZERO || !fragments.iter().any(oversized) {
return None;
}
Some(
fragments
.iter()
.map(|f| match f {
Fragment::Image {
size,
rel_id,
image_data,
src_rect,
} if size.width > max_width => {
let visible = max_width.raw() / size.width.raw();
let base = src_rect.unwrap_or_else(|| {
PtRect::from_xywh(Pt::ZERO, Pt::ZERO, Pt::new(1.0), Pt::new(1.0))
});
let cropped = PtRect::from_xywh(
base.origin.x,
base.origin.y,
Pt::new(base.size.width.raw() * visible),
base.size.height,
);
Fragment::Image {
size: PtSize::new(max_width, size.height),
rel_id: rel_id.clone(),
image_data: image_data.clone(),
src_rect: Some(cropped),
}
}
other => other.clone(),
})
.collect(),
)
}
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 clipped_frags;
let fragments: &[Fragment] = match clip_oversized_images(fragments, content_width) {
Some(v) => {
clipped_frags = v;
&clipped_frags
}
None => fragments,
};
let min_avail = (content_width - first_line_adjustment).max(Pt::ZERO);
let split_frags;
let fragments: &[Fragment] = if min_avail > Pt::ZERO {
split_frags = split_oversized_fragments(fragments, min_avail, measure_text);
&split_frags
} else {
fragments
};
let params = LineLayoutParams {
content_width,
max_width: constraints.max_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,
text_scale: font.text_scale,
});
}
}
}
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),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{Alignment, PTabAlignment, PTabRelativeTo};
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: 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: 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))
}
fn image_frag(w: f32, h: f32) -> Fragment {
Fragment::Image {
size: PtSize::new(Pt::new(w), Pt::new(h)),
rel_id: "rId1".to_string(),
image_data: None,
src_rect: None,
}
}
fn clipped_src(frag: &Fragment) -> (PtSize, PtRect) {
match frag {
Fragment::Image { size, src_rect, .. } => (*size, src_rect.expect("crop applied")),
other => panic!("expected image, got {other:?}"),
}
}
#[test]
fn clip_oversized_image_crops_left_keeps_height_and_scale() {
let frags = vec![image_frag(200.0, 100.0)];
let out = clip_oversized_images(&frags, Pt::new(100.0)).expect("clip");
let (size, crop) = clipped_src(&out[0]);
assert_eq!(size.width.raw(), 100.0);
assert_eq!(size.height.raw(), 100.0, "height must NOT change");
assert!(
(crop.origin.x.raw() - 0.0).abs() < 1e-4,
"left-anchored, not centred"
);
assert!((crop.size.width.raw() - 0.5).abs() < 1e-4);
assert!((crop.size.height.raw() - 1.0).abs() < 1e-4);
}
#[test]
fn clip_oversized_image_composes_with_existing_src_rect() {
let mut frag = image_frag(200.0, 100.0);
if let Fragment::Image { src_rect, .. } = &mut frag {
*src_rect = Some(PtRect::from_xywh(
Pt::new(0.1),
Pt::ZERO,
Pt::new(0.8),
Pt::new(1.0),
));
}
let out = clip_oversized_images(&[frag], Pt::new(100.0)).expect("clip");
let crop = clipped_src(&out[0]).1;
assert!((crop.origin.x.raw() - 0.1).abs() < 1e-4);
assert!((crop.size.width.raw() - 0.4).abs() < 1e-4);
}
#[test]
fn clip_oversized_images_leaves_a_fitting_image_untouched() {
let frags = vec![image_frag(163.0, 290.0)];
assert!(clip_oversized_images(&frags, Pt::new(499.0)).is_none());
}
#[test]
fn clip_oversized_images_ignores_wide_non_image_fragments() {
let frags = vec![text_frag("very wide text", 999.0)];
assert!(clip_oversized_images(&frags, Pt::new(100.0)).is_none());
}
#[test]
fn clip_oversized_images_guards_nonpositive_width() {
let frags = vec![image_frag(516.0, 290.0)];
assert!(clip_oversized_images(&frags, Pt::ZERO).is_none());
}
#[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"
);
}
fn ptab_frag(align: PTabAlignment, relative_to: PTabRelativeTo) -> Fragment {
Fragment::PTab {
align,
relative_to,
leader: crate::model::TabLeader::None,
line_height: Pt::new(12.0),
}
}
fn text_xs(result: &ParagraphLayout) -> Vec<f32> {
result
.commands
.iter()
.filter_map(|c| match c {
DrawCommand::Text { position, .. } => Some(position.x.raw()),
_ => None,
})
.collect()
}
#[test]
fn ptab_three_region_header() {
let frags = vec![
text_frag("L", 10.0),
ptab_frag(PTabAlignment::Center, PTabRelativeTo::Margin),
text_frag("C", 20.0),
ptab_frag(PTabAlignment::Right, PTabRelativeTo::Margin),
text_frag("R", 30.0),
];
let result = layout_paragraph(
&frags,
&body_constraints(100.0),
&ParagraphStyle::default(),
Pt::new(14.0),
None,
);
let xs = text_xs(&result);
assert_eq!(xs.len(), 3, "three text runs, no leaders");
assert_eq!(xs[0], 0.0, "left run at the left margin");
assert_eq!(xs[1], 40.0, "center run centered on 50 → 50 - 20/2");
assert_eq!(xs[2], 70.0, "right run ends at the right margin → 100 - 30");
}
#[test]
fn ptab_right_relative_to_indent() {
let frags = vec![
text_frag("x", 10.0),
ptab_frag(PTabAlignment::Right, PTabRelativeTo::Indent),
text_frag("yy", 20.0),
];
let style = ParagraphStyle {
indent_right: Pt::new(30.0),
..Default::default()
};
let result = layout_paragraph(
&frags,
&body_constraints(100.0),
&style,
Pt::new(14.0),
None,
);
let xs = text_xs(&result);
assert_eq!(xs[1], 50.0, "right run ends at the right indent");
}
#[test]
fn ptab_suppresses_paragraph_alignment() {
let frags = vec![
text_frag("L", 10.0),
ptab_frag(PTabAlignment::Right, PTabRelativeTo::Margin),
text_frag("R", 30.0),
];
let style = ParagraphStyle {
alignment: Alignment::End,
..Default::default()
};
let result = layout_paragraph(
&frags,
&body_constraints(100.0),
&style,
Pt::new(14.0),
None,
);
let xs = text_xs(&result);
assert_eq!(xs[0], 0.0, "left run not shifted by End alignment");
assert_eq!(xs[1], 70.0, "right run ends at the right margin");
}
}