use std::collections::{BTreeMap, BTreeSet};
use zenith_core::{
Diagnostic, FontProvider, FontSource, FontStyle, PropertyValue, ResolvedToken, ResolvedValue,
};
use zenith_layout::{ShapeRequest, TextDirection, TextLayoutEngine, ZenithGlyphRun};
use crate::ir::{Color, SceneCommand, SceneGlyph};
use super::ctx::{NodeShape, ShapeEnv};
pub(in crate::compile) const CODE_MONO_FAMILY: &str = "Noto Sans Mono";
pub(in crate::compile) const CODE_BG: Color = Color::srgb(240, 240, 240, 255);
pub(in crate::compile) const LINK_COLOR: Color = Color::srgb(0, 102, 204, 255);
pub(in crate::compile) struct WordToken {
pub(in crate::compile) runs: Vec<ZenithGlyphRun>,
pub(in crate::compile) advance: f64,
pub(in crate::compile) color: Color,
pub(in crate::compile) underline: bool,
pub(in crate::compile) strikethrough: bool,
pub(in crate::compile) highlight: Option<Color>,
pub(in crate::compile) code: bool,
pub(in crate::compile) link: Option<String>,
pub(in crate::compile) baseline_dy: f64,
pub(in crate::compile) glued: bool,
pub(in crate::compile) src: WordSource,
}
#[derive(Clone)]
pub(in crate::compile) struct WordSource {
pub(in crate::compile) text: String,
pub(in crate::compile) weight: u16,
pub(in crate::compile) style: FontStyle,
pub(in crate::compile) font_size: f32,
pub(in crate::compile) paragraph: usize,
pub(in crate::compile) hyphen_part: Option<(String, bool)>,
}
#[derive(Clone, Copy, Default)]
pub(in crate::compile) struct WordMetrics {
pub(in crate::compile) ascent: f64,
pub(in crate::compile) line_height: f64,
pub(in crate::compile) space_advance: f64,
}
pub(in crate::compile) struct ResolvedSpan {
pub(in crate::compile) text: String,
pub(in crate::compile) color: Color,
pub(in crate::compile) underline: bool,
pub(in crate::compile) strikethrough: bool,
pub(in crate::compile) highlight: Option<Color>,
pub(in crate::compile) code: bool,
pub(in crate::compile) link: Option<String>,
pub(in crate::compile) weight: u16,
pub(in crate::compile) style: FontStyle,
pub(in crate::compile) font_size: f32,
pub(in crate::compile) baseline_dy: f64,
}
pub(in crate::compile) fn emit_glyph_missing(
diagnostics: &mut Vec<Diagnostic>,
node_id: &str,
span: Option<zenith_core::Span>,
missing: &BTreeSet<char>,
) {
if missing.is_empty() {
return;
}
let chars_desc = missing
.iter()
.map(|&c| format!("'{}' (U+{:04X})", c, c as u32))
.collect::<Vec<_>>()
.join(", ");
diagnostics.push(Diagnostic::warning(
"font.glyph_missing",
format!(
"text node '{node_id}' contains character(s) with no glyph in any registered font: \
{chars_desc}"
),
span,
Some(node_id.to_owned()),
));
}
pub(in crate::compile) fn shape_words(
spans: &[ResolvedSpan],
families: &[String],
shape: NodeShape,
env: ShapeEnv,
diagnostics: &mut Vec<Diagnostic>,
node_id: &str,
span: Option<zenith_core::Span>,
) -> (Vec<WordToken>, WordMetrics) {
let font_size = shape.font_size;
let node_base_weight = shape.base_weight;
let direction = shape.direction;
let engine = env.engine;
let fonts = env.fonts;
let mut tokens: Vec<WordToken> = Vec::new();
let mut metrics = WordMetrics::default();
let mut have_metrics = false;
let mut paragraph: usize = 0;
let mut node_missing: BTreeSet<char> = BTreeSet::new();
let mut prev_ends_with_ws = true;
for shaped in spans {
let span_starts_with_ws = shaped
.text
.chars()
.next()
.is_some_and(|c| c.is_whitespace());
let span_ends_with_ws = shaped
.text
.chars()
.next_back()
.is_some_and(|c| c.is_whitespace());
let mut first_word_of_span = true;
let span_first_glued = !span_starts_with_ws && !prev_ends_with_ws;
if !shaped.text.is_empty() {
prev_ends_with_ws = span_ends_with_ws;
}
let is_vertical_align = shaped.baseline_dy != 0.0;
let word_font_size = shaped.font_size;
let code_families_buf: Vec<String>;
let span_families: &[String] = if shaped.code {
code_families_buf = vec![CODE_MONO_FAMILY.to_owned()];
&code_families_buf
} else {
families
};
for (seg_idx, segment) in shaped.text.split('\n').enumerate() {
if seg_idx > 0 {
paragraph += 1;
}
for word in segment.split_whitespace() {
let req = ShapeRequest {
text: word,
families: span_families,
weight: shaped.weight,
style: shaped.style,
font_size: word_font_size,
direction,
};
match engine.shape_with_fallback(&req, fonts) {
Err(e) => {
diagnostics.push(Diagnostic::advisory(
"scene.text_unshaped",
format!("text node '{}' could not be shaped: {}", node_id, e.message),
span,
Some(node_id.to_owned()),
));
}
Ok(result) => {
node_missing.extend(result.missing_chars);
if !have_metrics
&& !is_vertical_align
&& let Some(first) = result.runs.first()
{
metrics.ascent = first.ascent as f64;
metrics.line_height = first.line_height as f64;
have_metrics = true;
}
let glued = first_word_of_span && span_first_glued;
first_word_of_span = false;
let advance: f64 = result.runs.iter().map(|r| r.advance_width as f64).sum();
tokens.push(WordToken {
advance,
color: shaped.color,
underline: shaped.underline,
strikethrough: shaped.strikethrough,
highlight: shaped.highlight,
code: shaped.code,
link: shaped.link.clone(),
baseline_dy: shaped.baseline_dy,
glued,
runs: result.runs,
src: WordSource {
text: word.to_owned(),
weight: shaped.weight,
style: shaped.style,
font_size: word_font_size,
paragraph,
hyphen_part: None,
},
});
}
}
}
}
}
emit_glyph_missing(diagnostics, node_id, span, &node_missing);
metrics.space_advance = {
let req = ShapeRequest {
text: " ",
families,
weight: node_base_weight,
style: FontStyle::Normal,
font_size,
direction: TextDirection::Ltr,
};
match engine.shape(&req, fonts) {
Ok(run) => run.advance_width as f64,
Err(_) => 0.0,
}
};
(tokens, metrics)
}
pub(in crate::compile) fn mark_runs_unselectable(commands: &mut [SceneCommand]) {
for cmd in commands {
if let SceneCommand::DrawGlyphRun { selectable, .. } = cmd {
*selectable = false;
}
}
}
pub(in crate::compile) fn run_to_scene_glyphs(run: &ZenithGlyphRun) -> Vec<SceneGlyph> {
run.glyphs
.iter()
.map(|g| SceneGlyph {
glyph_id: g.glyph_id,
dx: g.x,
dy: g.y,
text: g.text.clone(),
})
.collect()
}
pub(in crate::compile) fn resolve_font_weight(
prop: Option<&PropertyValue>,
resolved: &BTreeMap<String, ResolvedToken>,
default: u16,
) -> u16 {
match prop {
Some(PropertyValue::TokenRef(token_id)) => match resolved.get(token_id.as_str()) {
Some(rt) => match &rt.value {
ResolvedValue::FontWeight(w) => *w as u16,
ResolvedValue::Color(_)
| ResolvedValue::CmykColor { .. }
| ResolvedValue::Dimension(_)
| ResolvedValue::Number(_)
| ResolvedValue::FontFamily(_)
| ResolvedValue::Gradient(_)
| ResolvedValue::Shadow(_)
| ResolvedValue::Filter(_)
| ResolvedValue::Mask(_) => default,
},
None => default,
},
Some(PropertyValue::Literal(s)) => s.parse::<u16>().unwrap_or(default),
Some(PropertyValue::Dimension(_)) | Some(PropertyValue::DataRef(_)) => default,
None => default,
}
}
pub(in crate::compile) fn resolve_family_with_fallback(
fonts: &dyn FontProvider,
requested: &str,
default_family: &str,
weight: u16,
style: FontStyle,
) -> (String, bool, bool) {
if requested.eq_ignore_ascii_case(default_family) {
return (requested.to_owned(), false, false);
}
match fonts.resolve(&[requested.to_owned()], weight, style) {
Some(data) => {
let is_local = data.source == FontSource::Local;
(requested.to_owned(), false, is_local)
}
None => (default_family.to_owned(), true, false),
}
}
pub(in crate::compile) fn resolve_font_family_name(
prop: Option<&PropertyValue>,
resolved: &BTreeMap<String, ResolvedToken>,
default: &str,
) -> String {
match prop {
Some(PropertyValue::TokenRef(token_id)) => match resolved.get(token_id.as_str()) {
Some(rt) => match &rt.value {
ResolvedValue::FontFamily(name) => name.clone(),
ResolvedValue::Color(_)
| ResolvedValue::CmykColor { .. }
| ResolvedValue::Dimension(_)
| ResolvedValue::Number(_)
| ResolvedValue::FontWeight(_)
| ResolvedValue::Gradient(_)
| ResolvedValue::Shadow(_)
| ResolvedValue::Filter(_)
| ResolvedValue::Mask(_) => default.to_owned(),
},
None => default.to_owned(),
},
Some(PropertyValue::Literal(name)) => name.clone(),
Some(PropertyValue::Dimension(_)) | Some(PropertyValue::DataRef(_)) | None => {
default.to_owned()
}
}
}
const VALIGN_SCALE: f64 = 0.65;
const VALIGN_SUPER_SHIFT: f64 = 0.34;
const VALIGN_SUB_SHIFT: f64 = 0.16;
pub(in crate::compile) fn resolve_vertical_align(
vertical_align: Option<&str>,
node_font_size: f32,
) -> (f32, f64) {
let full = node_font_size as f64;
match vertical_align {
Some("super") => ((full * VALIGN_SCALE) as f32, -(full * VALIGN_SUPER_SHIFT)),
Some("sub") => ((full * VALIGN_SCALE) as f32, full * VALIGN_SUB_SHIFT),
_ => (node_font_size, 0.0),
}
}