use std::collections::BTreeMap;
use zenith_core::{
BlockStyle, Diagnostic, Dimension, ListKind, MdBlock, PropertyValue, ResolvedToken, TextNode,
TextSpan, Unit, dim_to_px,
};
use crate::ir::{Color, Paint, SceneCommand};
use super::super::RenderCtx;
use super::super::util::{resolve_geometry_px, resolve_property_dimension_px};
use super::ctx::{TextCompileEnv, empty_md_blocks};
use super::measure::font_size_px;
use super::shape::CODE_MONO_FAMILY;
use super::text_node::compile_text_sized;
pub(in crate::compile) const BLOCKQUOTE_INDENT_PX: f64 = 24.0;
pub(in crate::compile) const LIST_INDENT_PX: f64 = 24.0;
pub(in crate::compile) const HR_COLOR: Color = Color::srgb(204, 204, 204, 255);
pub(in crate::compile) const CODE_BLOCK_BG: Color = Color::srgb(245, 245, 245, 255);
pub(in crate::compile) const HR_THICKNESS_PX: f64 = 2.0;
const PARAGRAPH_SPACE_AFTER_FACTOR: f64 = 0.5;
const HEADING_SPACE_BEFORE_FACTOR: f64 = 0.6;
const HEADING_SPACE_AFTER_FACTOR: f64 = 0.25;
const BLOCK_SPACE_AFTER_FACTOR: f64 = 0.4;
const HR_SPACE_FACTOR: f64 = 0.5;
pub(in crate::compile) struct ResolvedBlockStyle {
pub(in crate::compile) font_family: Option<PropertyValue>,
pub(in crate::compile) font_size_px: f64,
pub(in crate::compile) font_weight: Option<PropertyValue>,
pub(in crate::compile) fill: Option<PropertyValue>,
pub(in crate::compile) align: Option<String>,
pub(in crate::compile) italic: Option<bool>,
pub(in crate::compile) space_before_px: f64,
pub(in crate::compile) space_after_px: f64,
}
pub(in crate::compile) fn block_role(block: &MdBlock) -> &'static str {
match block {
MdBlock::Heading { level, .. } => match level {
1 => "h1",
2 => "h2",
3 => "h3",
4 => "h4",
5 => "h5",
_ => "h6",
},
MdBlock::Paragraph { .. } => "p",
MdBlock::Blockquote { .. } => "blockquote",
MdBlock::ListItem { .. } => "li",
MdBlock::CodeBlock { .. } => "code-block",
MdBlock::HorizontalRule => "hr",
}
}
fn cascade_prop<'a, F>(
role: &str,
node_styles: &'a [BlockStyle],
page_styles: &'a [BlockStyle],
doc_styles: &'a [BlockStyle],
pick: F,
) -> Option<&'a PropertyValue>
where
F: Fn(&BlockStyle) -> Option<&PropertyValue>,
{
for scope in [node_styles, page_styles, doc_styles] {
if let Some(found) = scope.iter().find(|b| b.role == role).and_then(&pick) {
return Some(found);
}
}
None
}
fn cascade_field<'a, T, F>(
role: &str,
node_styles: &'a [BlockStyle],
page_styles: &'a [BlockStyle],
doc_styles: &'a [BlockStyle],
pick: F,
) -> Option<T>
where
F: Fn(&'a BlockStyle) -> Option<T>,
{
for scope in [node_styles, page_styles, doc_styles] {
if let Some(found) = scope.iter().find(|b| b.role == role).and_then(&pick) {
return Some(found);
}
}
None
}
fn default_spacing_factors(role: &str) -> (f64, f64) {
match role {
"h1" | "h2" | "h3" | "h4" | "h5" | "h6" => {
(HEADING_SPACE_BEFORE_FACTOR, HEADING_SPACE_AFTER_FACTOR)
}
"p" => (0.0, PARAGRAPH_SPACE_AFTER_FACTOR),
"blockquote" | "li" | "code-block" => (0.0, BLOCK_SPACE_AFTER_FACTOR),
"hr" => (HR_SPACE_FACTOR, HR_SPACE_FACTOR),
_ => (0.0, PARAGRAPH_SPACE_AFTER_FACTOR),
}
}
fn resolve_block_style_for_role(
role: &str,
text: &TextNode,
env: TextCompileEnv,
) -> ResolvedBlockStyle {
let node_font_size = f64::from(font_size_px(text, env.resolved, env.style_map));
resolve_block_style_core(BlockStyleCascade {
role,
node_styles: text.block_styles.as_slice(),
page_styles: env.page_block_styles,
doc_styles: env.doc_block_styles,
resolved: env.resolved,
node_font_size,
node_font_family: text.font_family.as_ref(),
node_font_weight: text.font_weight.as_ref(),
node_fill: text.fill.as_ref(),
node_align: text.align.as_ref(),
})
}
pub(in crate::compile) struct BlockStyleCascade<'a> {
pub(in crate::compile) role: &'a str,
pub(in crate::compile) node_styles: &'a [BlockStyle],
pub(in crate::compile) page_styles: &'a [BlockStyle],
pub(in crate::compile) doc_styles: &'a [BlockStyle],
pub(in crate::compile) resolved: &'a BTreeMap<String, ResolvedToken>,
pub(in crate::compile) node_font_size: f64,
pub(in crate::compile) node_font_family: Option<&'a PropertyValue>,
pub(in crate::compile) node_font_weight: Option<&'a PropertyValue>,
pub(in crate::compile) node_fill: Option<&'a PropertyValue>,
pub(in crate::compile) node_align: Option<&'a String>,
}
pub(in crate::compile) fn resolve_block_style_core(c: BlockStyleCascade) -> ResolvedBlockStyle {
let BlockStyleCascade {
role,
node_styles,
page_styles,
doc_styles,
resolved,
node_font_size,
node_font_family,
node_font_weight,
node_fill,
node_align,
} = c;
let font_family = cascade_prop(role, node_styles, page_styles, doc_styles, |b| {
b.font_family.as_ref()
})
.cloned()
.or_else(|| node_font_family.cloned());
let font_size_px = match cascade_prop(role, node_styles, page_styles, doc_styles, |b| {
b.font_size.as_ref()
}) {
Some(prop) => resolve_property_dimension_px(Some(prop), resolved, node_font_size),
None => node_font_size,
};
let font_weight = cascade_prop(role, node_styles, page_styles, doc_styles, |b| {
b.font_weight.as_ref()
})
.cloned()
.or_else(|| node_font_weight.cloned());
let fill = cascade_prop(role, node_styles, page_styles, doc_styles, |b| {
b.fill.as_ref()
})
.cloned()
.or_else(|| node_fill.cloned());
let align = cascade_field(role, node_styles, page_styles, doc_styles, |b| {
b.align.clone()
})
.or_else(|| node_align.cloned());
let italic = cascade_field(role, node_styles, page_styles, doc_styles, |b| b.italic);
let (sb_factor, sa_factor) = default_spacing_factors(role);
let space_before_px = cascade_field(role, node_styles, page_styles, doc_styles, |b| {
b.space_before.as_ref()
})
.and_then(|d| dim_to_px(d.value, &d.unit))
.unwrap_or(font_size_px * sb_factor);
let space_after_px = cascade_field(role, node_styles, page_styles, doc_styles, |b| {
b.space_after.as_ref()
})
.and_then(|d| dim_to_px(d.value, &d.unit))
.unwrap_or(font_size_px * sa_factor);
ResolvedBlockStyle {
font_family,
font_size_px,
font_weight,
fill,
align,
italic,
space_before_px,
space_after_px,
}
}
fn synth_base(text: &TextNode, style: &ResolvedBlockStyle, x: f64, y: f64) -> TextNode {
let mut n = text.clone();
n.chain = None;
n.content_format = None;
n.src = None;
n.anchor = None;
n.anchor_zone = None;
n.anchor_sibling = None;
n.anchor_edge = None;
n.anchor_gap = None;
n.anchor_parent = None;
n.v_align = Some("top".to_owned());
n.h = None;
n.x = Some(PropertyValue::Dimension(px_dim(x)));
n.y = Some(PropertyValue::Dimension(px_dim(y)));
n.w = None;
n.bullet = None;
n.bullet_gap = None;
n.padding_left = None;
n.text_indent = None;
n.font_family = style.font_family.clone();
n.font_size = Some(PropertyValue::Dimension(px_dim(style.font_size_px)));
n.font_weight = style.font_weight.clone();
n.fill = style.fill.clone();
n.align = style.align.clone();
n
}
fn px_dim(value: f64) -> Dimension {
Dimension {
value,
unit: Unit::Px,
}
}
fn apply_italic(spans: &mut [TextSpan], italic: Option<bool>) {
if italic == Some(true) {
for s in spans {
s.italic = Some(true);
}
}
}
pub(in crate::compile) fn compile_markdown_blocks(
text: &TextNode,
blocks: &[MdBlock],
env: TextCompileEnv,
commands: &mut Vec<SceneCommand>,
diagnostics: &mut Vec<Diagnostic>,
ctx: RenderCtx,
) -> f64 {
if text.visible == Some(false) {
return 0.0;
}
let anchor_xy = env.anchors.get(&text.id).copied();
let Some(box_x) =
resolve_geometry_px(text.x.as_ref(), env.resolved).or(anchor_xy.map(|(ax, _)| 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 Some(box_y) =
resolve_geometry_px(text.y.as_ref(), env.resolved).or(anchor_xy.map(|(_, ay)| 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 box_w = resolve_geometry_px(text.w.as_ref(), env.resolved);
let mut synth_env = env;
synth_env.md_blocks = empty_md_blocks();
let mut y_cursor = box_y;
for (i, block) in blocks.iter().enumerate() {
let role = block_role(block);
let style = resolve_block_style_for_role(role, text, env);
let space_before = if i == 0 { 0.0 } else { style.space_before_px };
let block_top = y_cursor + space_before;
let block_height = match block {
MdBlock::Heading { spans, .. }
| MdBlock::Paragraph { spans }
| MdBlock::Blockquote { spans } => {
let indent = if matches!(block, MdBlock::Blockquote { .. }) {
BLOCKQUOTE_INDENT_PX
} else {
0.0
};
let slot_w = box_w.map(|w| (w - indent).max(0.0));
let mut synth = synth_base(text, &style, box_x + indent, block_top);
synth.w = slot_w.map(|v| PropertyValue::Dimension(px_dim(v)));
synth.spans = spans.clone();
apply_italic(&mut synth.spans, style.italic);
compile_text_sized(&synth, synth_env, commands, diagnostics, ctx)
}
MdBlock::ListItem {
kind,
depth,
ordinal,
spans,
} => {
let indent = (*depth as f64) * LIST_INDENT_PX;
let slot_w = box_w.map(|w| (w - indent).max(0.0));
let mut synth = synth_base(text, &style, box_x + indent, block_top);
synth.w = slot_w.map(|v| PropertyValue::Dimension(px_dim(v)));
let marker = match kind {
ListKind::Unordered => "•".to_owned(),
ListKind::Ordered => format!("{}.", ordinal.unwrap_or(1)),
};
synth.bullet = Some(marker);
synth.spans = spans.clone();
apply_italic(&mut synth.spans, style.italic);
compile_text_sized(&synth, synth_env, commands, diagnostics, ctx)
}
MdBlock::CodeBlock { content, .. } => {
let mut synth = synth_base(text, &style, box_x, block_top);
synth.font_family = Some(PropertyValue::Literal(CODE_MONO_FAMILY.to_owned()));
synth.w = box_w.map(|v| PropertyValue::Dimension(px_dim(v)));
synth.spans = vec![literal_span(content.clone())];
let draw_start = commands.len();
let code_h = compile_text_sized(&synth, synth_env, commands, diagnostics, ctx);
let glyph_cmds = commands.split_off(draw_start);
if let Some(w) = box_w {
commands.push(SceneCommand::FillRect {
x: box_x + ctx.dx,
y: block_top + ctx.dy,
w,
h: code_h,
paint: Paint::solid(CODE_BLOCK_BG),
});
}
commands.extend(glyph_cmds);
code_h
}
MdBlock::HorizontalRule => {
if let Some(w) = box_w {
commands.push(SceneCommand::FillRect {
x: box_x + ctx.dx,
y: block_top + ctx.dy,
w,
h: HR_THICKNESS_PX,
paint: Paint::solid(HR_COLOR),
});
}
HR_THICKNESS_PX
}
};
y_cursor = block_top + block_height + style.space_after_px;
}
let total_height = (y_cursor - box_y).max(0.0);
if let Some(box_h) = resolve_geometry_px(text.h.as_ref(), env.resolved) {
const EPSILON: f64 = 0.5;
if total_height > box_h + EPSILON {
let delta = total_height - box_h;
diagnostics.push(Diagnostic::warning(
"text.overflow",
format!(
"text '{}': markdown content ({:.0}px) exceeds the box height ({:.0}px) \
by {:.0}px; enlarge the box height, reduce font-size/spacing, \
or add a chained continuation box (chain=\"{}\") on another page",
text.id, total_height, box_h, delta, text.id
),
text.source_span,
Some(text.id.clone()),
));
}
}
total_height
}
fn literal_span(text: String) -> TextSpan {
TextSpan {
text,
fill: None,
font_weight: None,
italic: None,
underline: None,
strikethrough: None,
vertical_align: None,
footnote_ref: None,
data_ref: None,
data_format: None,
highlight: None,
code: None,
link: None,
}
}