use std::collections::BTreeMap;
use zenith_core::{
Diagnostic, FontProvider, FootnoteNode, Node, Page, ResolvedToken, Style, TextNode, TextSpan,
};
use zenith_layout::RustybuzzEngine;
use crate::ir::{Color, Paint, SceneCommand};
use super::RenderCtx;
use super::anchor::AnchorMap;
use super::chain::ChainAssignments;
use super::field::FieldCtx;
use super::paint::resolve_property_color;
use super::style_prop;
use super::text::{TextCompileEnv, compile_text, empty_md_blocks};
use super::util::{px_prop, resolve_geometry_px, resolve_property_dimension_px};
const FOOTNOTE_GAP: f64 = 6.0;
const SEPARATOR_GAP: f64 = 10.0;
const SEPARATOR_THICKNESS: f64 = 1.0;
const SEPARATOR_WIDTH_FRACTION: f64 = 1.0 / 3.0;
const DEFAULT_FOOTNOTE_FONT_SIZE: f64 = 13.0;
const DEFAULT_RULE_COLOR: Color = Color::srgb(136, 136, 136, 255);
pub(super) fn collect_footnote_markers(page: &Page) -> BTreeMap<String, String> {
let mut markers: BTreeMap<String, String> = BTreeMap::new();
let mut number: u32 = 0;
for child in &page.children {
if let Node::Footnote(fnote) = child {
number += 1;
let marker = match &fnote.marker {
Some(explicit) => explicit.clone(),
None => number.to_string(),
};
markers.entry(fnote.id.clone()).or_insert(marker);
}
}
markers
}
fn synth_footnote_text(fnote: &FootnoteNode, marker: &str, x: f64, y: f64, w: f64) -> TextNode {
let mut spans: Vec<TextSpan> = Vec::with_capacity(fnote.spans.len() + 1);
spans.push(TextSpan {
text: format!("{marker} "),
fill: fnote.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,
});
spans.extend(fnote.spans.iter().cloned());
TextNode {
id: fnote.id.clone(),
name: fnote.name.clone(),
role: fnote.role.clone(),
x: Some(px_prop(x)),
y: Some(px_prop(y)),
w: Some(px_prop(w)),
h: None,
align: Some("start".to_owned()),
v_align: None,
direction: None,
overflow: Some("visible".to_owned()),
overflow_wrap: None,
style: fnote.style.clone(),
fill: fnote.fill.clone(),
stroke: None,
stroke_width: None,
contrast_bg: None,
font_family: fnote.font_family.clone(),
font_size: fnote.font_size.clone(),
font_size_min: None,
font_weight: None,
shadow: None,
filter: None,
mask: None,
blend_mode: None,
blur: None,
opacity: None,
visible: None,
locked: None,
selectable: None,
rotate: None,
chain: None,
drop_cap_lines: None,
hyphenate: None,
widow_orphan: None,
tab_leader: None,
text_exclusion: None,
padding_left: None,
text_indent: None,
content_format: None,
src: None,
bullet: None,
bullet_gap: None,
anchor: None,
anchor_zone: None,
anchor_sibling: None,
anchor_edge: None,
anchor_gap: None,
anchor_parent: None,
spans,
block_styles: Vec::new(),
source_span: fnote.source_span,
unknown_props: BTreeMap::new(),
}
}
#[derive(Clone, Copy)]
pub(in crate::compile) struct FootnoteZoneEnv<'a> {
pub(in crate::compile) markers: &'a BTreeMap<String, String>,
pub(in crate::compile) resolved: &'a BTreeMap<String, ResolvedToken>,
pub(in crate::compile) style_map: &'a BTreeMap<&'a str, &'a Style>,
pub(in crate::compile) fonts: &'a dyn FontProvider,
pub(in crate::compile) engine: &'a RustybuzzEngine,
pub(in crate::compile) chains: &'a ChainAssignments,
pub(in crate::compile) anchors: &'a AnchorMap,
pub(in crate::compile) field_ctx: &'a FieldCtx<'a>,
}
pub(in crate::compile) fn compile_footnote_zone(
page: &Page,
live_area: Option<(f64, f64, f64, f64)>,
env: FootnoteZoneEnv,
commands: &mut Vec<SceneCommand>,
diagnostics: &mut Vec<Diagnostic>,
ctx: RenderCtx,
) {
let FootnoteZoneEnv {
markers,
resolved,
style_map,
fonts,
engine,
chains,
anchors,
field_ctx,
} = env;
let footnotes: Vec<&FootnoteNode> = page
.children
.iter()
.filter_map(|n| match n {
Node::Footnote(f) => Some(f),
Node::Rect(_)
| Node::Ellipse(_)
| Node::Line(_)
| Node::Text(_)
| Node::Code(_)
| Node::Image(_)
| Node::Frame(_)
| Node::Group(_)
| Node::Polygon(_)
| Node::Polyline(_)
| Node::Instance(_)
| Node::Field(_)
| Node::Toc(_)
| Node::Table(_)
| Node::Shape(_)
| Node::Connector(_)
| Node::Pattern(_)
| Node::Chart(_)
| Node::Unknown(_) => None,
})
.collect();
if footnotes.is_empty() {
return;
}
let Some((live_x, live_y, live_w, live_h)) = live_area else {
diagnostics.push(Diagnostic::advisory(
"footnote.no_live_area",
format!(
"page '{}' declares footnotes but has no resolvable live area \
(all four margins required); the footnote zone is skipped",
page.id
),
page.source_span,
Some(page.id.clone()),
));
return;
};
let live_bottom = live_y + live_h;
let empty_node_boxes: BTreeMap<String, (f64, f64, f64, f64)> = BTreeMap::new();
let text_env = TextCompileEnv {
resolved,
style_map,
fonts,
engine,
chains,
footnote_markers: markers,
node_boxes: &empty_node_boxes,
anchors,
md_blocks: empty_md_blocks(),
page_block_styles: &[],
doc_block_styles: &[],
};
let mut heights: Vec<f64> = Vec::with_capacity(footnotes.len());
for fnote in &footnotes {
let marker = markers.get(&fnote.id).map(String::as_str).unwrap_or("?");
let text = synth_footnote_text(fnote, marker, live_x, 0.0, live_w);
let mut scratch: Vec<SceneCommand> = Vec::new();
let mut scratch_diags: Vec<Diagnostic> = Vec::new();
let h = compile_text(
&text,
text_env,
&mut scratch,
&mut scratch_diags,
RenderCtx::measure(),
);
let h = if h > 0.0 {
h
} else {
footnote_font_size(fnote, resolved, style_map)
};
heights.push(h);
}
let total_height: f64 =
heights.iter().sum::<f64>() + FOOTNOTE_GAP * (footnotes.len().saturating_sub(1)) as f64;
let zone_block_top = live_bottom - total_height;
let separator_y = zone_block_top - SEPARATOR_GAP;
let zone_top = separator_y - SEPARATOR_THICKNESS;
for child in &page.children {
if matches!(child, Node::Footnote(_)) {
continue;
}
if let Some((_bx, by, _bw, bh, id)) = node_bottom_box(child, resolved)
&& by + bh > zone_top
{
diagnostics.push(Diagnostic::advisory(
"footnote.body_overlap",
format!(
"body node '{}' (bottom y={:.0}) overlaps the footnote zone \
(top y={:.0}) on page '{}'; v0 does not auto-reflow an explicit \
body box — shorten the node",
id,
by + bh,
zone_top,
page.id
),
page.source_span,
Some(id),
));
}
}
let rule_w = live_w * SEPARATOR_WIDTH_FRACTION;
let rule_color = footnotes
.first()
.and_then(|f| f.fill.as_ref())
.and_then(|fp| resolve_property_color(fp, resolved, diagnostics, &page.id))
.unwrap_or(DEFAULT_RULE_COLOR);
commands.push(SceneCommand::FillRect {
x: live_x + ctx.dx,
y: separator_y + ctx.dy,
w: rule_w,
h: SEPARATOR_THICKNESS,
paint: Paint::solid(rule_color),
});
let mut cursor_y = zone_block_top;
for (fnote, h) in footnotes.iter().zip(heights.iter()) {
let marker = markers.get(&fnote.id).map(String::as_str).unwrap_or("?");
let text = synth_footnote_text(fnote, marker, live_x, cursor_y, live_w);
compile_text(&text, text_env, commands, diagnostics, ctx);
cursor_y += h + FOOTNOTE_GAP;
}
let _ = field_ctx;
}
fn footnote_font_size(
fnote: &FootnoteNode,
resolved: &BTreeMap<String, ResolvedToken>,
style_map: &BTreeMap<&str, &Style>,
) -> f64 {
let font_size_prop = fnote
.font_size
.clone()
.or_else(|| style_prop(&fnote.style, style_map, "font-size").cloned());
resolve_property_dimension_px(
font_size_prop.as_ref(),
resolved,
DEFAULT_FOOTNOTE_FONT_SIZE,
)
}
fn node_bottom_box(
node: &Node,
resolved: &BTreeMap<String, ResolvedToken>,
) -> Option<(f64, f64, f64, f64, String)> {
let (x, y, w, h, id) = match node {
Node::Rect(n) => (&n.x, &n.y, &n.w, &n.h, &n.id),
Node::Ellipse(n) => (&n.x, &n.y, &n.w, &n.h, &n.id),
Node::Text(n) => (&n.x, &n.y, &n.w, &n.h, &n.id),
Node::Code(n) => (&n.x, &n.y, &n.w, &n.h, &n.id),
Node::Image(n) => (&n.x, &n.y, &n.w, &n.h, &n.id),
Node::Frame(n) => (&n.x, &n.y, &n.w, &n.h, &n.id),
Node::Table(n) => (&n.x, &n.y, &n.w, &n.h, &n.id),
Node::Shape(n) => (&n.x, &n.y, &n.w, &n.h, &n.id),
Node::Line(_)
| Node::Polygon(_)
| Node::Polyline(_)
| Node::Group(_)
| Node::Instance(_)
| Node::Field(_)
| Node::Toc(_)
| Node::Footnote(_)
| Node::Connector(_)
| Node::Pattern(_)
| Node::Chart(_)
| Node::Unknown(_) => return None,
};
Some((
resolve_geometry_px(x.as_ref(), resolved)?,
resolve_geometry_px(y.as_ref(), resolved)?,
resolve_geometry_px(w.as_ref(), resolved)?,
resolve_geometry_px(h.as_ref(), resolved)?,
id.clone(),
))
}
#[cfg(test)]
mod tests {
use super::collect_footnote_markers;
use zenith_core::{KdlAdapter, KdlSource};
#[test]
fn two_footnotes_auto_number_one_and_two() {
let src = br##"zenith version=1 {
project id="proj.fn2" name="FN2"
tokens format="zenith-token-v1" {}
styles {}
document id="doc.fn2" title="FN2" {
page id="page.fn2" w=(px)600 h=(px)900 {
text id="body" x=(px)60 y=(px)80 w=(px)480 h=(px)200 {
span "First mark" footnote-ref="fn.1"
span " and second mark" footnote-ref="fn.2"
}
footnote id="fn.1" { span "First note." }
footnote id="fn.2" { span "Second note." }
}
}
}
"##;
let doc = KdlAdapter.parse(src).expect("test document must parse");
let page = doc.body.pages.first().expect("one page");
let markers = collect_footnote_markers(page);
assert_eq!(markers.get("fn.1").map(String::as_str), Some("1"));
assert_eq!(markers.get("fn.2").map(String::as_str), Some("2"));
assert_eq!(markers.len(), 2);
}
}