use zenith_layout::TextDirection;
use crate::ir::{Color, Paint, SceneCommand};
use super::ctx::{EmitStyle, UniformGeom};
use super::pack::{Line, LineDecoration, LineStyle};
use super::shape::{CODE_BG, WordToken, run_to_scene_glyphs};
pub(in crate::compile) fn emit_lines(
lines: &[Line],
text_x: f64,
text_y: f64,
box_w: f64,
style: EmitStyle,
commands: &mut Vec<SceneCommand>,
) {
let geom = UniformGeom { text_x, box_w };
emit_lines_profiled(lines, |i| geom.at(i), text_y, style, commands);
}
pub(in crate::compile) fn emit_lines_profiled<F>(
lines: &[Line],
geom: F,
text_y: f64,
style: EmitStyle,
commands: &mut Vec<SceneCommand>,
) where
F: Fn(usize) -> (f64, f64),
{
let align = style.align;
let metrics = style.metrics;
let justify_final_line = style.justify_final_line;
let direction = style.direction;
let glyph_stroke = style.glyph_stroke;
let global_ascent = metrics.ascent;
let global_space_advance = metrics.space_advance;
let global_font_size = style.font_size;
let global_deco_thickness = style.deco_thickness;
let last_idx = lines.len().saturating_sub(1);
let is_rtl = direction == TextDirection::Rtl;
let mut y_offset: f64 = 0.0;
for (i, line) in lines.iter().enumerate() {
let (geom_x, geom_w) = geom(i);
match line.decoration {
Some(LineDecoration::Background(color)) => {
commands.push(SceneCommand::FillRect {
x: geom_x,
y: text_y + y_offset,
w: geom_w,
h: line.height_px,
paint: Paint::solid(color),
});
}
Some(LineDecoration::Rule { color, thickness }) => {
let band_mid = text_y + y_offset + line.height_px / 2.0;
commands.push(SceneCommand::FillRect {
x: geom_x,
y: band_mid - thickness / 2.0,
w: geom_w,
h: thickness,
paint: Paint::solid(color),
});
}
None => {}
}
let indent = line.left_indent_px;
let box_w = (geom_w - indent).max(0.0);
let text_x = if is_rtl { geom_x } else { geom_x + indent };
let (ascent, space_advance, font_size, deco_thickness) = match line.line_style {
Some(LineStyle {
ascent,
space_advance,
font_size,
deco_thickness,
}) => (ascent, space_advance, font_size, deco_thickness),
None => (
global_ascent,
global_space_advance,
global_font_size,
global_deco_thickness,
),
};
let baseline_y = text_y + ascent + y_offset;
let word_count = line.words.len();
let visual: Vec<&WordToken> = if is_rtl {
line.words.iter().rev().collect()
} else {
line.words.iter().collect()
};
let gap_suppressed = |vi: usize| -> bool {
let later = if is_rtl { vi.checked_sub(1) } else { Some(vi) };
later.and_then(|j| visual.get(j)).is_some_and(|w| w.glued)
};
let real_gap_count = (1..word_count).filter(|&vi| !gap_suppressed(vi)).count();
let (base_x, extra) = if is_rtl {
match align {
"center" => (text_x + (box_w - line.content_w) / 2.0, 0.0),
"end" => (text_x, 0.0),
"justify" => {
let is_final_line = i == last_idx && !justify_final_line;
if !is_final_line && real_gap_count > 0 {
let extra = (box_w - line.content_w).max(0.0) / (real_gap_count as f64);
(text_x, extra)
} else {
(text_x + (box_w - line.content_w), 0.0)
}
}
_ => (text_x + (box_w - line.content_w), 0.0),
}
} else {
match align {
"center" => (text_x + (box_w - line.content_w) / 2.0, 0.0),
"end" => (text_x + (box_w - line.content_w), 0.0),
"justify" => {
let is_final_line = i == last_idx && !justify_final_line;
if !is_final_line && real_gap_count > 0 {
let extra = (box_w - line.content_w).max(0.0) / (real_gap_count as f64);
(text_x, extra)
} else {
(text_x, 0.0)
}
}
_ => (text_x, 0.0),
}
};
let mut word_x: Vec<f64> = Vec::with_capacity(word_count);
{
let mut x = base_x;
for (wi, word) in visual.iter().enumerate() {
word_x.push(x);
x += word.advance;
let next = wi + 1;
if next < word_count && !gap_suppressed(next) {
x += space_advance + extra;
}
}
}
emit_background_run(&visual, &word_x, base_x, baseline_y, commands, |w| {
w.highlight
});
emit_background_run(&visual, &word_x, base_x, baseline_y, commands, |w| {
if w.code { Some(CODE_BG) } else { None }
});
let underline_y = baseline_y + font_size as f64 * 0.12;
let strike_y = baseline_y - font_size as f64 * 0.30;
for (is_underline, deco_y) in [(true, underline_y), (false, strike_y)] {
let mut run_start: Option<(f64, Color)> = None;
let mut run_right: f64 = base_x;
for (wi, word) in visual.iter().enumerate() {
let on = if is_underline {
word.underline
} else {
word.strikethrough
};
let wx = word_x.get(wi).copied().unwrap_or(base_x);
if on {
if run_start.is_none() {
run_start = Some((wx, word.color));
}
run_right = wx + word.advance;
} else if let Some((sx, color)) = run_start.take() {
commands.push(SceneCommand::FillRect {
x: sx,
y: deco_y,
w: run_right - sx,
h: deco_thickness,
paint: Paint::solid(color),
});
}
}
if let Some((sx, color)) = run_start.take() {
commands.push(SceneCommand::FillRect {
x: sx,
y: deco_y,
w: run_right - sx,
h: deco_thickness,
paint: Paint::solid(color),
});
}
}
for (wi, word) in visual.iter().enumerate() {
let mut run_x = word_x.get(wi).copied().unwrap_or(base_x);
let word_baseline_y = baseline_y + word.baseline_dy;
for run in &word.runs {
commands.push(SceneCommand::DrawGlyphRun {
x: run_x,
y: word_baseline_y,
font_id: run.font_id.clone(),
font_size: run.font_size,
color: word.color,
stroke_color: glyph_stroke.0,
stroke_width: glyph_stroke.1,
link: word.link.clone(),
selectable: true,
glyphs: run_to_scene_glyphs(run),
});
run_x += run.advance_width as f64;
}
}
y_offset += line.height_px;
}
}
fn emit_background_run<F>(
visual: &[&WordToken],
word_x: &[f64],
base_x: f64,
baseline_y: f64,
commands: &mut Vec<SceneCommand>,
key: F,
) where
F: Fn(&WordToken) -> Option<Color>,
{
let mut run: Option<BgRun> = None;
let flush = |run: Option<BgRun>, commands: &mut Vec<SceneCommand>| {
if let Some(BgRun {
color,
left,
right,
band: Some((y, h)),
}) = run
{
commands.push(SceneCommand::FillRect {
x: left,
y,
w: right - left,
h,
paint: Paint::solid(color),
});
}
};
for (wi, word) in visual.iter().enumerate() {
let wx = word_x.get(wi).copied().unwrap_or(base_x);
let band = word.runs.first().map(|r| {
let y = baseline_y - r.ascent as f64;
let h = (r.ascent + r.descent) as f64;
(y, h)
});
match key(word) {
Some(color) => match run.take() {
Some(cur) if cur.color == color => {
run = Some(BgRun {
color,
left: cur.left,
right: wx + word.advance,
band: cur.band.or(band),
});
}
other => {
flush(other, commands);
run = Some(BgRun {
color,
left: wx,
right: wx + word.advance,
band,
});
}
},
None => flush(run.take(), commands),
}
}
flush(run.take(), commands);
}
struct BgRun {
color: Color,
left: f64,
right: f64,
band: Option<(f64, f64)>,
}
#[cfg(test)]
mod rtl_tests {
use super::{EmitStyle, Line, WordToken, emit_lines};
use zenith_core::FontStyle;
use zenith_layout::{TextDirection, ZenithGlyphRun};
use crate::ir::{Color, SceneCommand};
use super::super::shape::{WordMetrics, WordSource};
fn word(advance: f64) -> WordToken {
WordToken {
runs: vec![ZenithGlyphRun {
font_id: "test-font".to_owned(),
font_size: 16.0,
ascent: 12.0,
descent: 4.0,
line_height: 18.0,
advance_width: advance as f32,
glyphs: Vec::new(),
}],
advance,
color: Color::srgb(0, 0, 0, 255),
underline: false,
strikethrough: false,
highlight: None,
code: false,
link: None,
baseline_dy: 0.0,
glued: false,
src: WordSource {
text: String::new(),
weight: 400,
style: FontStyle::Normal,
font_size: 16.0,
paragraph: 0,
hyphen_part: None,
},
}
}
fn metrics() -> WordMetrics {
WordMetrics {
ascent: 12.0,
line_height: 18.0,
space_advance: 5.0,
}
}
fn run_xs(commands: &[SceneCommand]) -> Vec<f64> {
commands
.iter()
.filter_map(|c| match c {
SceneCommand::DrawGlyphRun { x, .. } => Some(*x),
_ => None,
})
.collect()
}
fn emit_line(direction: TextDirection, align: &str) -> Vec<f64> {
let line = Line {
words: vec![word(10.0), word(20.0), word(30.0)],
content_w: 70.0,
paragraph: 0,
height_px: 18.0,
line_style: None,
left_indent_px: 0.0,
decoration: None,
};
let mut commands = Vec::new();
emit_lines(
std::slice::from_ref(&line),
100.0,
0.0,
200.0,
EmitStyle {
align,
metrics: metrics(),
font_size: 16.0,
deco_thickness: 1.0,
justify_final_line: false,
direction,
glyph_stroke: (None, None),
},
&mut commands,
);
run_xs(&commands)
}
#[test]
fn ltr_start_is_byte_identical_left_anchored() {
let xs = emit_line(TextDirection::Ltr, "start");
assert_eq!(xs, vec![100.0, 115.0, 140.0]);
}
#[test]
fn rtl_start_first_word_at_right_descending_leftward() {
let xs = emit_line(TextDirection::Rtl, "start");
assert_eq!(xs, vec![230.0, 265.0, 290.0]);
let first_logical_x = *xs.last().expect("three runs");
assert!(
first_logical_x > xs[0] && first_logical_x > xs[1],
"first logical word must be rightmost, got {xs:?}"
);
}
#[test]
fn rtl_end_left_anchors() {
let xs = emit_line(TextDirection::Rtl, "end");
assert_eq!(xs, vec![100.0, 135.0, 160.0]);
}
#[test]
fn rtl_center_is_symmetric() {
let xs = emit_line(TextDirection::Rtl, "center");
assert_eq!(xs, vec![165.0, 200.0, 225.0]);
}
}
#[cfg(test)]
mod line_style_tests {
use super::{EmitStyle, Line, WordToken, emit_lines};
use zenith_core::FontStyle;
use zenith_layout::{TextDirection, ZenithGlyphRun};
use crate::ir::{Color, SceneCommand};
use super::super::pack::LineStyle;
use super::super::shape::{WordMetrics, WordSource};
fn word(advance: f64) -> WordToken {
WordToken {
runs: vec![ZenithGlyphRun {
font_id: "test-font".to_owned(),
font_size: 16.0,
ascent: 12.0,
descent: 4.0,
line_height: 18.0,
advance_width: advance as f32,
glyphs: Vec::new(),
}],
advance,
color: Color::srgb(0, 0, 0, 255),
underline: false,
strikethrough: false,
highlight: None,
code: false,
link: None,
baseline_dy: 0.0,
glued: false,
src: WordSource {
text: String::new(),
weight: 400,
style: FontStyle::Normal,
font_size: 16.0,
paragraph: 0,
hyphen_part: None,
},
}
}
fn global_metrics() -> WordMetrics {
WordMetrics {
ascent: 12.0,
line_height: 18.0,
space_advance: 5.0,
}
}
fn baseline_ys(commands: &[SceneCommand]) -> Vec<f64> {
commands
.iter()
.filter_map(|c| match c {
SceneCommand::DrawGlyphRun { y, .. } => Some(*y),
_ => None,
})
.collect()
}
#[test]
fn none_line_uses_global_ascent() {
let line = Line {
words: vec![word(20.0)],
content_w: 20.0,
paragraph: 0,
height_px: 18.0,
line_style: None,
left_indent_px: 0.0,
decoration: None,
};
let mut commands = Vec::new();
emit_lines(
std::slice::from_ref(&line),
0.0,
0.0,
100.0,
EmitStyle {
align: "start",
metrics: global_metrics(),
font_size: 16.0,
deco_thickness: 1.0,
justify_final_line: false,
direction: TextDirection::Ltr,
glyph_stroke: (None, None),
},
&mut commands,
);
let ys = baseline_ys(&commands);
assert_eq!(ys, vec![12.0], "None line must use global ascent (12)");
}
#[test]
fn some_line_uses_override_ascent() {
let line = Line {
words: vec![word(20.0)],
content_w: 20.0,
paragraph: 0,
height_px: 36.0,
line_style: Some(LineStyle {
ascent: 24.0,
space_advance: 8.0,
font_size: 32.0,
deco_thickness: 2.0,
}),
left_indent_px: 0.0,
decoration: None,
};
let mut commands = Vec::new();
emit_lines(
std::slice::from_ref(&line),
0.0,
0.0,
100.0,
EmitStyle {
align: "start",
metrics: global_metrics(), font_size: 16.0,
deco_thickness: 1.0,
justify_final_line: false,
direction: TextDirection::Ltr,
glyph_stroke: (None, None),
},
&mut commands,
);
let ys = baseline_ys(&commands);
assert_eq!(
ys,
vec![24.0],
"Some line must use its own ascent (24), not the global (12)"
);
}
#[test]
fn two_lines_mixed_style_correct_baselines() {
let line0 = Line {
words: vec![word(20.0)],
content_w: 20.0,
paragraph: 0,
height_px: 18.0,
line_style: None,
left_indent_px: 0.0,
decoration: None,
};
let line1 = Line {
words: vec![word(20.0)],
content_w: 20.0,
paragraph: 0,
height_px: 36.0,
line_style: Some(LineStyle {
ascent: 24.0,
space_advance: 8.0,
font_size: 32.0,
deco_thickness: 2.0,
}),
left_indent_px: 0.0,
decoration: None,
};
let mut commands = Vec::new();
emit_lines(
&[line0, line1],
0.0,
0.0,
100.0,
EmitStyle {
align: "start",
metrics: global_metrics(),
font_size: 16.0,
deco_thickness: 1.0,
justify_final_line: false,
direction: TextDirection::Ltr,
glyph_stroke: (None, None),
},
&mut commands,
);
let ys = baseline_ys(&commands);
assert_eq!(
ys,
vec![12.0, 42.0],
"mixed None/Some lines must produce independent baselines"
);
}
}