use std::collections::BTreeSet;
use zenith_core::{
Diagnostic, Dimension, FontStyle, PropertyValue, TextNode, TextSpan, Unit, dim_to_px,
};
use zenith_layout::{ShapeRequest, TextDirection, TextLayoutEngine, ZenithGlyphRun};
use crate::ir::{Color, Paint, SceneCommand};
use super::super::RenderCtx;
use super::super::paint::{
NodeEffect, emit_node_with_effects, resolve_property_color, resolve_property_filter,
resolve_property_mask, resolve_property_shadow,
};
use super::super::style_prop;
use super::super::util::{
resolve_geometry_px, resolve_property_dimension_px, rotation_degrees, unsupported_unit_diag,
};
use super::chain_member::render_chain_member;
use super::ctx::{ChainMemberPlace, ShapeEnv, TabLeaderArgs, TextCompileEnv};
use super::measure::{
MeasureEnv, font_size_px, measure_text_wrapped_height, resolve_text_families,
};
use super::shape::{
CODE_BG, CODE_MONO_FAMILY, LINK_COLOR, ResolvedSpan, emit_glyph_missing, resolve_font_weight,
resolve_vertical_align, run_to_scene_glyphs,
};
use super::tableader::compile_tab_leader;
use super::wrap::{WrapEnv, WrapGeom, emit_wrap_path};
pub(in crate::compile) fn compile_text(
text: &TextNode,
env: TextCompileEnv,
commands: &mut Vec<SceneCommand>,
diagnostics: &mut Vec<Diagnostic>,
ctx: RenderCtx,
) -> f64 {
let start = commands.len();
let height = if text.overflow.as_deref() != Some("autofit") {
compile_text_sized(text, env, commands, diagnostics, ctx)
} else {
compile_text_autofit(text, env, commands, diagnostics, ctx)
};
if text.selectable == Some(false) {
super::shape::mark_runs_unselectable(&mut commands[start..]);
}
height
}
fn compile_text_autofit(
text: &TextNode,
env: TextCompileEnv,
commands: &mut Vec<SceneCommand>,
diagnostics: &mut Vec<Diagnostic>,
ctx: RenderCtx,
) -> f64 {
let box_w = resolve_geometry_px(text.w.as_ref(), env.resolved);
let box_h = resolve_geometry_px(text.h.as_ref(), env.resolved);
let (Some(_bw), Some(_bh)) = (box_w, box_h) else {
return compile_text_sized(text, env, commands, diagnostics, ctx);
};
let declared = f64::from(font_size_px(text, env.resolved, env.style_map));
let floor = resolve_property_dimension_px(
text.font_size_min.as_ref(),
env.resolved,
(declared * 0.5).max(8.0),
);
let ceil_px = declared.floor().max(1.0) as i64;
let floor_px = floor.floor().max(1.0).min(declared.floor().max(1.0)) as i64;
let clone_sized = |fs: f64, ov: &str| -> TextNode {
let mut t = text.clone();
t.font_size = Some(PropertyValue::Dimension(Dimension {
value: fs,
unit: Unit::Px,
}));
t.overflow = Some(ov.to_owned());
t
};
let fits = |fs: f64| -> bool {
let trial = clone_sized(fs, "fit");
let mut throwaway_cmds: Vec<SceneCommand> = Vec::new();
let mut throwaway_diags: Vec<Diagnostic> = Vec::new();
compile_text_sized(&trial, env, &mut throwaway_cmds, &mut throwaway_diags, ctx);
!throwaway_diags.iter().any(|d| {
d.code == "text.fit_failed" && d.subject_id.as_deref() == Some(text.id.as_str())
})
};
let mut fitted: Option<i64> = None;
let mut fs = ceil_px;
while fs >= floor_px {
if fits(fs as f64) {
fitted = Some(fs);
break;
}
fs -= 1;
}
let (real_fs, real_ov) = match fitted {
Some(fs) => (fs as f64, "clip"),
None => (floor_px as f64, "fit"),
};
let real = clone_sized(real_fs, real_ov);
compile_text_sized(&real, env, commands, diagnostics, ctx)
}
pub(in crate::compile) fn compile_text_sized(
text: &TextNode,
env: TextCompileEnv,
commands: &mut Vec<SceneCommand>,
diagnostics: &mut Vec<Diagnostic>,
ctx: RenderCtx,
) -> f64 {
let resolved = env.resolved;
let style_map = env.style_map;
let fonts = env.fonts;
let engine = env.engine;
let chains = env.chains;
let footnote_markers = env.footnote_markers;
let node_boxes = env.node_boxes;
let anchors = env.anchors;
if text.visible == Some(false) {
return 0.0;
}
let anchor_xy = anchors.get(&text.id).copied();
let text_x_raw = match &text.x {
Some(x_dim) => {
let Some(v) = resolve_geometry_px(Some(x_dim), resolved) else {
diagnostics.push(unsupported_unit_diag(
"text node",
&text.id,
"x",
text.source_span,
));
return 0.0;
};
v
}
None => {
if let Some((ax, _)) = anchor_xy {
ax
} else {
diagnostics.push(Diagnostic::advisory(
"scene.missing_geometry",
format!(
"text node '{}' is missing x or y geometry; skipped",
text.id
),
text.source_span,
Some(text.id.clone()),
));
return 0.0;
}
}
};
let text_y_raw = match &text.y {
Some(y_dim) => {
let Some(v) = resolve_geometry_px(Some(y_dim), resolved) else {
diagnostics.push(unsupported_unit_diag(
"text node",
&text.id,
"y",
text.source_span,
));
return 0.0;
};
v
}
None => {
if let Some((_, ay)) = anchor_xy {
ay
} else {
diagnostics.push(Diagnostic::advisory(
"scene.missing_geometry",
format!(
"text node '{}' is missing x or y geometry; skipped",
text.id
),
text.source_span,
Some(text.id.clone()),
));
return 0.0;
}
}
};
let text_x = text_x_raw + ctx.dx;
let mut text_y = text_y_raw + ctx.dy;
let early_stroke_color: Option<Color> = text
.stroke
.as_ref()
.and_then(|p| resolve_property_color(p, resolved, diagnostics, &text.id));
let early_stroke_width: Option<f64> = {
let w = resolve_property_dimension_px(text.stroke_width.as_ref(), resolved, -1.0);
if w > 0.0 { Some(w) } else { None }
};
let early_glyph_stroke: (Option<Color>, Option<f64>) = (early_stroke_color, early_stroke_width);
if text.chain.is_some()
&& let Some(assignment) = chains.get(&text.id)
{
let fs = font_size_px(text, resolved, style_map);
return render_chain_member(
text,
assignment,
ChainMemberPlace {
font_size: fs,
text_x,
text_y,
baseline_grid: ctx.baseline_grid,
glyph_stroke: early_glyph_stroke,
},
resolved,
commands,
diagnostics,
);
}
if text.chain.is_none()
&& let Some(blocks) = env.md_blocks.get(&text.id)
{
return super::markdown_block::compile_markdown_blocks(
text,
blocks,
env,
commands,
diagnostics,
ctx,
);
}
if text.spans.iter().all(|s| s.text.is_empty()) {
return 0.0;
}
let effective_spans: Vec<TextSpan> = if text.spans.iter().any(|s| s.footnote_ref.is_some()) {
let mut out: Vec<TextSpan> = Vec::with_capacity(text.spans.len());
for span in &text.spans {
out.push(span.clone());
if let Some(fref) = &span.footnote_ref {
match footnote_markers.get(fref) {
Some(marker) => out.push(TextSpan {
text: marker.clone(),
fill: span.fill.clone(),
font_weight: None,
italic: None,
underline: None,
strikethrough: None,
vertical_align: Some("super".to_owned()),
footnote_ref: None,
data_ref: None,
data_format: None,
highlight: None,
code: None,
link: None,
}),
None => diagnostics.push(Diagnostic::advisory(
"footnote.unresolved_ref",
format!(
"text node '{}': span footnote-ref '{}' matches no footnote \
on this page; no marker emitted",
text.id, fref
),
text.source_span,
Some(text.id.clone()),
)),
}
}
}
out
} else {
text.spans.clone()
};
let families = resolve_text_families(text, resolved, style_map, fonts, diagnostics);
let font_size: f32 = font_size_px(text, resolved, style_map);
if matches!(text.v_align.as_deref(), Some("middle") | Some("bottom")) {
if let Some(box_h) = resolve_geometry_px(text.h.as_ref(), resolved) {
if let Some(box_w) = resolve_geometry_px(text.w.as_ref(), resolved) {
let wrapped_h = measure_text_wrapped_height(
text,
box_w,
&families,
MeasureEnv {
resolved,
style_map,
fonts,
engine,
},
diagnostics,
)
.unwrap_or(0.0);
let v_offset = match text.v_align.as_deref() {
Some("bottom") => (box_h - wrapped_h).max(0.0),
_ => ((box_h - wrapped_h) / 2.0).max(0.0),
};
text_y += v_offset;
}
}
}
let node_opacity = text.opacity.unwrap_or(1.0).clamp(0.0, 1.0);
let blend = super::super::util::blend_mode_ir(text.blend_mode.as_deref());
let layer_op = node_opacity * ctx.opacity;
let color_opacity = if blend.is_some() {
1.0
} else {
node_opacity * ctx.opacity
};
let node_fill_prop: Option<&PropertyValue> = text
.fill
.as_ref()
.or_else(|| style_prop(&text.style, style_map, "fill"));
let node_weight_prop: Option<&PropertyValue> = text
.font_weight
.as_ref()
.or_else(|| style_prop(&text.style, style_map, "font-weight"));
let glyph_stroke = early_glyph_stroke;
if let Some(leader) = text.tab_leader.as_deref().filter(|s| !s.is_empty()) {
if let Some(blend_mode) = blend {
commands.push(SceneCommand::PushLayer {
opacity: layer_op,
blend_mode: Some(blend_mode),
});
let mut inner_ctx = ctx;
inner_ctx.opacity = 1.0;
let h = compile_tab_leader(
text,
leader,
&families,
TabLeaderArgs {
font_size,
node_fill_prop,
node_weight_prop,
node_opacity: 1.0,
resolved,
env: ShapeEnv { engine, fonts },
text_x,
text_y,
ctx: inner_ctx,
glyph_stroke,
},
commands,
diagnostics,
);
commands.push(SceneCommand::PopLayer);
return h;
}
return compile_tab_leader(
text,
leader,
&families,
TabLeaderArgs {
font_size,
node_fill_prop,
node_weight_prop,
node_opacity,
resolved,
env: ShapeEnv { engine, fonts },
text_x,
text_y,
ctx,
glyph_stroke,
},
commands,
diagnostics,
);
}
struct ShapedSpan {
run: ZenithGlyphRun,
color: Color,
underline: bool,
strikethrough: bool,
highlight: Option<Color>,
code: bool,
link: Option<String>,
text: String,
weight: u16,
style: FontStyle,
font_size: f32,
baseline_dy: f64,
vertical_align: bool,
}
let node_direction = match text.direction.as_deref() {
Some("rtl") => TextDirection::Rtl,
_ => TextDirection::Ltr,
};
let mut shaped_spans: Vec<ShapedSpan> = Vec::new();
let mut total_advance: f64 = 0.0;
let mut node_ascent: Option<f64> = None;
let mut node_missing: BTreeSet<char> = BTreeSet::new();
for span in &effective_spans {
if span.text.is_empty() {
continue;
}
let is_link = span.link.is_some();
let raw_color = span
.fill
.as_ref()
.and_then(|fp| resolve_property_color(fp, resolved, diagnostics, &text.id))
.or(is_link.then_some(LINK_COLOR))
.or_else(|| {
node_fill_prop
.and_then(|fp| resolve_property_color(fp, resolved, diagnostics, &text.id))
})
.unwrap_or(Color::srgb(0, 0, 0, 255));
let mut color = raw_color;
color.a = (color.a as f64 * color_opacity).round() as u8;
let highlight: Option<Color> = span
.highlight
.as_ref()
.and_then(|hp| resolve_property_color(hp, resolved, diagnostics, &text.id));
let code = span.code == Some(true);
let link = span.link.clone();
let weight_prop = span.font_weight.as_ref().or(node_weight_prop);
let weight = resolve_font_weight(weight_prop, resolved, 400);
let style = if span.italic == Some(true) {
FontStyle::Italic
} else {
FontStyle::Normal
};
let (span_font_size, baseline_dy) =
resolve_vertical_align(span.vertical_align.as_deref(), font_size);
let is_vertical_align = baseline_dy != 0.0;
let mono_families_buf = if code {
vec![CODE_MONO_FAMILY.to_owned()]
} else {
vec![]
};
let span_families: &[String] = if code { &mono_families_buf } else { &families };
let req = ShapeRequest {
text: &span.text,
families: span_families,
weight,
style,
font_size: span_font_size,
direction: node_direction,
};
match engine.shape_with_fallback(&req, fonts) {
Err(e) => {
diagnostics.push(Diagnostic::advisory(
"scene.text_unshaped",
format!("text node '{}' could not be shaped: {}", text.id, e.message),
text.source_span,
Some(text.id.clone()),
));
}
Ok(result) => {
node_missing.extend(result.missing_chars);
for (i, run) in result.runs.into_iter().enumerate() {
total_advance += run.advance_width as f64;
if !is_vertical_align && node_ascent.is_none() {
node_ascent = Some(run.ascent as f64);
}
let run_text = if i == 0 {
span.text.clone()
} else {
String::new()
};
shaped_spans.push(ShapedSpan {
run,
color,
underline: span.underline == Some(true) || is_link,
strikethrough: span.strikethrough == Some(true),
highlight,
code,
link: link.clone(),
text: run_text,
weight,
style,
font_size: span_font_size,
baseline_dy,
vertical_align: is_vertical_align,
});
}
}
}
}
emit_glyph_missing(diagnostics, &text.id, text.source_span, &node_missing);
let box_w_opt: Option<f64> = resolve_geometry_px(text.w.as_ref(), resolved);
let box_h_opt: Option<f64> = resolve_geometry_px(text.h.as_ref(), resolved);
let first_line_height: f64 = shaped_spans
.first()
.map(|s| s.run.line_height as f64)
.unwrap_or(0.0);
let align = text.align.as_deref().unwrap_or("start");
let deco_thickness = (font_size as f64 / 14.0).max(1.0);
let has_hanging = text.bullet.as_deref().is_some_and(|s| !s.is_empty())
|| text.padding_left.is_some()
|| text.text_indent.is_some();
let has_mandatory_break = effective_spans.iter().any(|s| s.text.contains('\n'));
let needs_wrap = match box_w_opt {
Some(box_w) => total_advance > box_w || has_hanging || has_mandatory_break,
None => has_mandatory_break,
};
let rot = rotation_degrees(text.rotate.as_ref());
let text_rot = rot
.zip(box_w_opt)
.zip(box_h_opt)
.map(|((a, bw), bh)| (a, text_x + bw / 2.0, text_y + bh / 2.0));
if let Some((angle, cx, cy)) = text_rot {
commands.push(SceneCommand::PushTransform {
angle_deg: angle,
cx,
cy,
});
}
if let Some(blend_mode) = blend {
commands.push(SceneCommand::PushLayer {
opacity: layer_op,
blend_mode: Some(blend_mode),
});
}
let blur_sigma = text
.blur
.as_ref()
.and_then(|d| dim_to_px(d.value, &d.unit))
.filter(|&s| s > 0.0);
let effect: Option<NodeEffect> = if shaped_spans.is_empty() {
None
} else if let Some(sigma) = blur_sigma {
Some(NodeEffect::Blur(sigma))
} else if let Some(shadows) = text
.shadow
.as_ref()
.and_then(|p| resolve_property_shadow(p, resolved, &text.id))
{
Some(NodeEffect::Shadow(shadows))
} else {
text.filter
.as_ref()
.and_then(|p| resolve_property_filter(p, resolved, &text.id))
.map(NodeEffect::Filter)
};
let mask = text.mask.as_ref().and_then(|p| {
let mask_w = box_w_opt.unwrap_or(total_advance);
let mask_h = box_h_opt.unwrap_or(first_line_height);
resolve_property_mask(p, resolved, (text_x, text_y, mask_w, mask_h))
});
let draw_start = commands.len();
let mut fit_line_count: usize = 1;
if !needs_wrap {
let is_rtl = node_direction == TextDirection::Rtl;
let x_offset: f64 = match box_w_opt {
None => 0.0, Some(box_w) => {
if is_rtl {
match align {
"center" => (box_w - total_advance) / 2.0,
"end" => 0.0,
_ => box_w - total_advance,
}
} else {
match align {
"center" => (box_w - total_advance) / 2.0,
"end" => box_w - total_advance,
_ => 0.0,
}
}
}
};
let mut x_cursor = text_x + x_offset;
if is_rtl {
shaped_spans.reverse();
}
for shaped in shaped_spans {
let run_advance = shaped.run.advance_width as f64;
let baseline_y = if shaped.vertical_align {
text_y + node_ascent.unwrap_or(shaped.run.ascent as f64) + shaped.baseline_dy
} else {
text_y + shaped.run.ascent as f64
};
let glyphs = run_to_scene_glyphs(&shaped.run);
if let Some(hl_color) = shaped.highlight {
let hl_y = baseline_y - shaped.run.ascent as f64;
let hl_h = (shaped.run.ascent + shaped.run.descent) as f64;
commands.push(SceneCommand::FillRect {
x: x_cursor,
y: hl_y,
w: run_advance,
h: hl_h,
paint: Paint::solid(hl_color),
});
}
if shaped.code {
let bg_y = baseline_y - shaped.run.ascent as f64;
let bg_h = (shaped.run.ascent + shaped.run.descent) as f64;
commands.push(SceneCommand::FillRect {
x: x_cursor,
y: bg_y,
w: run_advance,
h: bg_h,
paint: Paint::solid(CODE_BG),
});
}
if shaped.underline {
commands.push(SceneCommand::FillRect {
x: x_cursor,
y: baseline_y + shaped.font_size as f64 * 0.12,
w: run_advance,
h: deco_thickness,
paint: Paint::solid(shaped.color),
});
}
if shaped.strikethrough {
commands.push(SceneCommand::FillRect {
x: x_cursor,
y: baseline_y - shaped.font_size as f64 * 0.30,
w: run_advance,
h: deco_thickness,
paint: Paint::solid(shaped.color),
});
}
commands.push(SceneCommand::DrawGlyphRun {
x: x_cursor,
y: baseline_y,
font_id: shaped.run.font_id,
font_size: shaped.run.font_size,
color: shaped.color,
stroke_color: glyph_stroke.0,
stroke_width: glyph_stroke.1,
link: shaped.link.clone(),
selectable: true,
glyphs,
});
x_cursor += run_advance;
}
} else {
let box_w = box_w_opt.unwrap_or(total_advance);
let resolved_spans: Vec<ResolvedSpan> = shaped_spans
.iter()
.map(|s| ResolvedSpan {
text: s.text.clone(),
color: s.color,
underline: s.underline,
strikethrough: s.strikethrough,
highlight: s.highlight,
code: s.code,
link: s.link.clone(),
weight: s.weight,
style: s.style,
font_size: s.font_size,
baseline_dy: s.baseline_dy,
})
.collect();
fit_line_count = emit_wrap_path(
text,
resolved_spans,
&families,
WrapEnv {
env: ShapeEnv { engine, fonts },
resolved,
node_boxes,
node_fill_prop,
node_weight_prop,
color_opacity,
ctx,
},
WrapGeom {
text_x,
text_y,
box_w,
box_h_opt,
font_size,
align,
deco_thickness,
direction: node_direction,
glyph_stroke,
},
commands,
diagnostics,
);
}
if text.overflow.as_deref() == Some("fit")
&& let (Some(box_w), Some(box_h)) = (box_w_opt, box_h_opt)
{
const EPSILON: f64 = 0.5;
let content_height = fit_line_count as f64 * first_line_height;
let height_overflow = content_height > box_h + EPSILON;
let word_overflow = if needs_wrap {
fit_line_count == 1 && total_advance > box_w + EPSILON
} else {
false };
if height_overflow || word_overflow {
diagnostics.push(Diagnostic::error(
"text.fit_failed",
format!(
"text '{}': content does not fit its box (overflow=\"fit\"): \
at {:.0}px font-size it needs ~{:.0}px height in a {:.0}px-tall box \
(or a word wider than the {:.0}px box width)",
text.id, font_size as f64, content_height, box_h, box_w
),
text.source_span,
Some(text.id.clone()),
));
}
}
if matches!(text.overflow.as_deref(), None | Some("clip"))
&& let (Some(box_w), Some(box_h)) = (box_w_opt, box_h_opt)
{
const EPSILON: f64 = 0.5;
let content_height = fit_line_count as f64 * first_line_height;
let height_overflow = content_height > box_h + EPSILON;
let word_overflow = needs_wrap && fit_line_count == 1 && total_advance > box_w + EPSILON;
if height_overflow || word_overflow {
diagnostics.push(Diagnostic::warning(
"text.overflow",
format!(
"text '{}': content is clipped at the box edge \
(overflow=\"clip\"): at {:.0}px font-size it needs ~{:.0}px height in a {:.0}px-tall box",
text.id, font_size as f64, content_height, box_h
),
text.source_span,
Some(text.id.clone()),
));
}
}
let draws = commands.split_off(draw_start);
emit_node_with_effects(commands, draws, effect, mask);
if blend.is_some() {
commands.push(SceneCommand::PopLayer);
}
if text_rot.is_some() {
commands.push(SceneCommand::PopTransform);
}
fit_line_count as f64 * first_line_height
}