use std::collections::BTreeMap;
use zenith_core::{
Diagnostic, FontProvider, FontStyle, Node, PropertyValue, ResolvedToken, Style, TextNode,
};
use zenith_layout::{RustybuzzEngine, TextDirection};
use crate::ir::Color;
use super::markdown_resolve::MdBlockMap;
use super::paint::resolve_property_color;
use super::style_prop;
use super::text::{
BlockStyleEnv, ChainSourceShape, HyphenationContext, LINK_COLOR, Line, LineDecoration,
LineStyle, NodeShape, ResolvedSpan, ShapeEnv, WordMetrics, WordToken, en_us_hyphenator,
flatten_lines_to_tokens, pack_lines, resolve_family_with_fallback, resolve_font_family_name,
resolve_font_weight, resolve_vertical_align, shape_source_blocks, shape_words,
};
use super::util::{resolve_geometry_px, resolve_property_dimension_px};
pub(crate) struct ChainAssignment {
pub(super) lines: Vec<Line>,
pub(super) metrics: WordMetrics,
pub(super) is_last_member: bool,
}
pub(crate) type ChainAssignments = BTreeMap<String, ChainAssignment>;
struct Member {
id: String,
w: f64,
h: f64,
}
fn member_box(
text: &TextNode,
resolved: &BTreeMap<String, ResolvedToken>,
) -> Option<(f64, f64, f64, f64)> {
Some((
resolve_geometry_px(text.x.as_ref(), resolved)?,
resolve_geometry_px(text.y.as_ref(), resolved)?,
resolve_geometry_px(text.w.as_ref(), resolved)?,
resolve_geometry_px(text.h.as_ref(), resolved)?,
))
}
fn collect_chains<'a>(
nodes: &'a [Node],
page_block_styles: &'a [zenith_core::BlockStyle],
resolved: &BTreeMap<String, ResolvedToken>,
members: &mut BTreeMap<String, Vec<Member>>,
source: &mut BTreeMap<String, &'a TextNode>,
source_page_styles: &mut BTreeMap<String, &'a [zenith_core::BlockStyle]>,
) {
for node in nodes {
match node {
Node::Text(t) => {
if let Some(chain_id) = &t.chain {
let has_spans = t.spans.iter().any(|s| !s.text.is_empty());
if has_spans && !source.contains_key(chain_id) {
source.insert(chain_id.clone(), t);
source_page_styles.insert(chain_id.clone(), page_block_styles);
}
if let Some((_x, _y, w, h)) = member_box(t, resolved) {
members.entry(chain_id.clone()).or_default().push(Member {
id: t.id.clone(),
w,
h,
});
}
}
}
Node::Frame(f) => collect_chains(
&f.children,
page_block_styles,
resolved,
members,
source,
source_page_styles,
),
Node::Group(g) => collect_chains(
&g.children,
page_block_styles,
resolved,
members,
source,
source_page_styles,
),
Node::Table(t) => {
for row in &t.rows {
for cell in &row.cells {
collect_chains(
&cell.children,
page_block_styles,
resolved,
members,
source,
source_page_styles,
);
}
}
}
Node::Rect(_)
| Node::Ellipse(_)
| Node::Line(_)
| Node::Code(_)
| Node::Image(_)
| Node::Polygon(_)
| Node::Polyline(_)
| Node::Instance(_)
| Node::Field(_)
| Node::Footnote(_)
| Node::Toc(_)
| Node::Shape(_)
| Node::Connector(_)
| Node::Pattern(_)
| Node::Chart(_)
| Node::Unknown(_) => {}
}
}
}
fn resolve_chain_base_style(
source: &TextNode,
resolved: &BTreeMap<String, ResolvedToken>,
style_map: &BTreeMap<&str, &Style>,
fonts: &dyn FontProvider,
diagnostics: &mut Vec<Diagnostic>,
) -> (Vec<String>, f32, u16) {
let font_family_prop = source
.font_family
.as_ref()
.or_else(|| style_prop(&source.style, style_map, "font-family"));
let raw_family_name = resolve_font_family_name(font_family_prop, resolved, "Noto Sans");
let (family_name, fell_back, is_local) =
resolve_family_with_fallback(fonts, &raw_family_name, "Noto Sans", 400, FontStyle::Normal);
if fell_back {
diagnostics.push(Diagnostic::advisory(
"font.unresolved",
format!(
"text node '{}': font family '{}' not available, falling back to 'Noto Sans'",
source.id, raw_family_name
),
source.source_span,
Some(source.id.clone()),
));
}
if is_local {
diagnostics.push(Diagnostic::advisory(
"font.local",
format!(
"text node '{}': font family '{}' resolved from a local/system font; rendering is \
NOT guaranteed deterministic across machines — bundle the font or guarantee the \
target OS provides it",
source.id, raw_family_name
),
source.source_span,
Some(source.id.clone()),
));
}
let families = vec![family_name];
let font_size_prop = source
.font_size
.clone()
.or_else(|| style_prop(&source.style, style_map, "font-size").cloned());
let font_size: f32 =
resolve_property_dimension_px(font_size_prop.as_ref(), resolved, 16.0) as f32;
let node_weight_prop: Option<&PropertyValue> = source
.font_weight
.as_ref()
.or_else(|| style_prop(&source.style, style_map, "font-weight"));
let base_weight = resolve_font_weight(node_weight_prop, resolved, 400);
(families, font_size, base_weight)
}
fn resolve_chain_style(
source: &TextNode,
resolved: &BTreeMap<String, ResolvedToken>,
style_map: &BTreeMap<&str, &Style>,
fonts: &dyn FontProvider,
diagnostics: &mut Vec<Diagnostic>,
) -> (Vec<String>, f32, u16, Vec<ResolvedSpan>) {
let (families, font_size, base_weight) =
resolve_chain_base_style(source, resolved, style_map, fonts, diagnostics);
let node_fill_prop: Option<&PropertyValue> = source
.fill
.as_ref()
.or_else(|| style_prop(&source.style, style_map, "fill"));
let node_weight_prop: Option<&PropertyValue> = source
.font_weight
.as_ref()
.or_else(|| style_prop(&source.style, style_map, "font-weight"));
let mut spans: Vec<ResolvedSpan> = Vec::new();
for span in &source.spans {
if span.text.is_empty() {
continue;
}
let is_link = span.link.is_some();
let color = span
.fill
.as_ref()
.and_then(|fp| resolve_property_color(fp, resolved, diagnostics, &source.id))
.or(is_link.then_some(LINK_COLOR))
.or_else(|| {
node_fill_prop
.and_then(|fp| resolve_property_color(fp, resolved, diagnostics, &source.id))
})
.unwrap_or(Color::srgb(0, 0, 0, 255));
let highlight: Option<Color> = span
.highlight
.as_ref()
.and_then(|hp| resolve_property_color(hp, resolved, diagnostics, &source.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);
spans.push(ResolvedSpan {
text: span.text.clone(),
color,
underline: span.underline == Some(true) || is_link,
strikethrough: span.strikethrough == Some(true),
highlight,
code,
link,
weight,
style,
font_size: span_font_size,
baseline_dy,
});
}
(families, font_size, base_weight, spans)
}
pub(super) fn resolve_chains_document<'a>(
doc: &'a zenith_core::Document,
resolved: &BTreeMap<String, ResolvedToken>,
style_map: &BTreeMap<&str, &Style>,
fonts: &dyn FontProvider,
engine: &RustybuzzEngine,
md_blocks: &MdBlockMap,
diagnostics: &mut Vec<Diagnostic>,
) -> ChainAssignments {
let mut members: BTreeMap<String, Vec<Member>> = BTreeMap::new();
let mut source: BTreeMap<String, &'a TextNode> = BTreeMap::new();
let mut source_page_styles: BTreeMap<String, &'a [zenith_core::BlockStyle]> = BTreeMap::new();
for page in &doc.body.pages {
collect_chains(
&page.children,
&page.block_styles,
resolved,
&mut members,
&mut source,
&mut source_page_styles,
);
}
distribute_chains(
&members,
&source,
&source_page_styles,
ChainDocStyles {
resolved,
style_map,
doc_block_styles: &doc.body.block_styles,
md_blocks,
},
fonts,
engine,
diagnostics,
)
}
#[derive(Clone, Copy)]
struct ChainDocStyles<'a> {
resolved: &'a BTreeMap<String, ResolvedToken>,
style_map: &'a BTreeMap<&'a str, &'a Style>,
doc_block_styles: &'a [zenith_core::BlockStyle],
md_blocks: &'a MdBlockMap,
}
fn distribute_chains(
members: &BTreeMap<String, Vec<Member>>,
source: &BTreeMap<String, &TextNode>,
source_page_styles: &BTreeMap<String, &[zenith_core::BlockStyle]>,
doc_styles: ChainDocStyles,
fonts: &dyn FontProvider,
engine: &RustybuzzEngine,
diagnostics: &mut Vec<Diagnostic>,
) -> ChainAssignments {
let resolved = doc_styles.resolved;
let style_map = doc_styles.style_map;
let mut assignments: ChainAssignments = BTreeMap::new();
for (chain_id, chain_members) in members {
let Some(src) = source.get(chain_id) else {
continue;
};
let direction = match src.direction.as_deref() {
Some("rtl") => TextDirection::Rtl,
_ => TextDirection::Ltr,
};
if let Some(blocks) = doc_styles.md_blocks.get(&src.id)
&& !blocks.is_empty()
{
distribute_block_chain(
BlockChainInput {
src,
blocks: blocks.as_slice(),
chain_members: chain_members.as_slice(),
page_block_styles: source_page_styles.get(chain_id).copied().unwrap_or(&[]),
doc_styles,
direction,
fonts,
engine,
},
diagnostics,
&mut assignments,
);
continue;
}
let (families, font_size, base_weight, spans) =
resolve_chain_style(src, resolved, style_map, fonts, diagnostics);
let (tokens, metrics) = shape_words(
&spans,
&families,
NodeShape {
font_size,
base_weight,
direction,
},
ShapeEnv { engine, fonts },
diagnostics,
&src.id,
src.source_span,
);
let hyph_ctx = if src.hyphenate == Some(true) {
en_us_hyphenator().map(|dict| HyphenationContext {
dict: Some(dict),
engine,
fonts,
families: &families,
hyphen: "-",
direction,
break_word: false,
})
} else {
None
};
let widow_orphan = src.widow_orphan.filter(|&n| n >= 2);
let mut remaining = tokens;
let last_member = chain_members.len().saturating_sub(1);
for (mi, member) in chain_members.iter().enumerate() {
let mut lines = pack_lines(
remaining,
member.w,
metrics.space_advance,
hyph_ctx.as_ref(),
metrics.line_height,
);
if mi == last_member {
assignments.insert(
member.id.clone(),
ChainAssignment {
lines,
metrics,
is_last_member: true,
},
);
break;
}
let max_lines = {
let mut cum = 0.0_f64;
let mut count = 0usize;
for l in &lines {
cum += l.height_px;
if cum > member.h {
break;
}
count += 1;
}
count
};
let mut take = max_lines.min(lines.len());
if let Some(n) = widow_orphan {
take = adjust_for_widow_orphan(&lines, take, n as usize);
}
let overflow_lines = lines.split_off(take);
remaining = flatten_lines_to_tokens(overflow_lines, hyph_ctx.as_ref());
assignments.insert(
member.id.clone(),
ChainAssignment {
lines,
metrics,
is_last_member: false,
},
);
}
}
assignments
}
struct BlockChainInput<'a> {
src: &'a TextNode,
blocks: &'a [zenith_core::MdBlock],
chain_members: &'a [Member],
page_block_styles: &'a [zenith_core::BlockStyle],
doc_styles: ChainDocStyles<'a>,
direction: TextDirection,
fonts: &'a dyn FontProvider,
engine: &'a RustybuzzEngine,
}
fn distribute_block_chain(
input: BlockChainInput,
diagnostics: &mut Vec<Diagnostic>,
assignments: &mut ChainAssignments,
) {
let BlockChainInput {
src,
blocks,
chain_members,
page_block_styles,
doc_styles,
direction,
fonts,
engine,
} = input;
let (families, font_size, base_weight) = resolve_chain_base_style(
src,
doc_styles.resolved,
doc_styles.style_map,
fonts,
diagnostics,
);
let descriptors = shape_source_blocks(
src,
blocks,
ChainSourceShape {
families: &families,
node_font_size: font_size,
base_weight,
direction,
},
BlockStyleEnv {
resolved: doc_styles.resolved,
page_block_styles,
doc_block_styles: doc_styles.doc_block_styles,
},
ShapeEnv { engine, fonts },
diagnostics,
);
let rep_metrics = descriptors.first().map(|d| d.metrics).unwrap_or_default();
let hyph_ctx = if src.hyphenate == Some(true) {
en_us_hyphenator().map(|dict| HyphenationContext {
dict: Some(dict),
engine,
fonts,
families: &families,
hyphen: "-",
direction,
break_word: false,
})
} else {
None
};
struct PendingBlock {
index: usize,
tokens: Vec<WordToken>,
style: LineStyle,
line_height: f64,
space_advance: f64,
space_after_px: f64,
space_before_px: f64,
is_spacer: bool,
left_indent_px: f64,
decoration: Option<LineDecoration>,
hyphenate: bool,
}
let mut queue: std::collections::VecDeque<PendingBlock> = descriptors
.into_iter()
.enumerate()
.map(|(index, d)| PendingBlock {
index,
hyphenate: !matches!(d.decoration, Some(LineDecoration::Background(_))),
tokens: d.tokens,
style: d.line_style,
line_height: d.metrics.line_height,
space_advance: d.metrics.space_advance,
space_after_px: d.space_after_px,
space_before_px: d.space_before_px,
is_spacer: d.is_spacer,
left_indent_px: d.left_indent_px,
decoration: d.decoration,
})
.collect();
let last_member = chain_members.len().saturating_sub(1);
for (mi, member) in chain_members.iter().enumerate() {
let mut member_lines: Vec<Line> = Vec::new();
let mut used_h = 0.0_f64;
let is_last = mi == last_member;
let mut prev_block_in_member: Option<usize> = None;
let mut prev_space_after: f64 = 0.0;
while let Some(block) = queue.pop_front() {
let mut block_lines: Vec<Line> = if block.is_spacer {
vec![Line {
words: Vec::new(),
content_w: 0.0,
paragraph: block.index,
height_px: block.line_height,
line_style: Some(block.style),
left_indent_px: block.left_indent_px,
decoration: block.decoration,
}]
} else {
let pack_hyph = if block.hyphenate {
hyph_ctx.as_ref()
} else {
None
};
let pack_w = (member.w - block.left_indent_px).max(0.0);
let mut ls = pack_lines(
block.tokens,
pack_w,
block.space_advance,
pack_hyph,
block.line_height,
);
for l in &mut ls {
l.paragraph = block.index;
l.line_style = Some(block.style);
l.left_indent_px = block.left_indent_px;
l.decoration = block.decoration;
}
ls
};
let gap = match prev_block_in_member {
Some(prev_idx) if prev_idx != block.index => {
prev_space_after + block.space_before_px
}
_ => 0.0,
};
if is_last {
if gap > 0.0
&& let Some(prev_line) = member_lines.last_mut()
{
prev_line.height_px += gap;
}
if block_lines.last().is_some() {
prev_block_in_member = Some(block.index);
prev_space_after = block.space_after_px;
}
member_lines.append(&mut block_lines);
continue;
}
if gap > 0.0
&& let Some(prev_line) = member_lines.last_mut()
{
prev_line.height_px += gap;
used_h += gap;
}
let mut placed = 0usize;
for l in &block_lines {
if used_h + l.height_px > member.h && !member_lines.is_empty() {
break;
}
used_h += l.height_px;
placed += 1;
}
if placed == block_lines.len() {
if block_lines.last().is_some() {
prev_block_in_member = Some(block.index);
prev_space_after = block.space_after_px;
}
member_lines.append(&mut block_lines);
continue;
}
let kept: Vec<Line> = block_lines.drain(..placed).collect();
member_lines.extend(kept);
let tail_hyph = if block.hyphenate {
hyph_ctx.as_ref()
} else {
None
};
let tail_tokens = flatten_lines_to_tokens(block_lines, tail_hyph);
queue.push_front(PendingBlock {
index: block.index,
tokens: tail_tokens,
style: block.style,
line_height: block.line_height,
space_advance: block.space_advance,
space_after_px: block.space_after_px,
space_before_px: 0.0,
is_spacer: false,
left_indent_px: block.left_indent_px,
decoration: block.decoration,
hyphenate: block.hyphenate,
});
break;
}
assignments.insert(
member.id.clone(),
ChainAssignment {
lines: member_lines,
metrics: rep_metrics,
is_last_member: is_last,
},
);
if is_last {
break;
}
}
}
fn adjust_for_widow_orphan(lines: &[Line], take: usize, n: usize) -> usize {
if take == 0 || take >= lines.len() {
return take;
}
let (Some(last_kept), Some(first_over)) = (lines.get(take - 1), lines.get(take)) else {
return take;
};
if last_kept.paragraph != first_over.paragraph {
return take;
}
let para = last_kept.paragraph;
let top_count = lines[..take]
.iter()
.rev()
.take_while(|l| l.paragraph == para)
.count();
let bottom_count = lines[take..]
.iter()
.take_while(|l| l.paragraph == para)
.count();
let mut new_take = take;
if bottom_count < n {
let need = n - bottom_count;
new_take = take.saturating_sub(need);
}
let top_after = top_count.saturating_sub(take - new_take);
if top_after < n {
new_take = take.saturating_sub(top_count);
}
if new_take >= 1 { new_take } else { take }
}
#[cfg(test)]
mod widow_orphan_tests {
use super::*;
fn lines_with_paragraphs(paras: &[usize]) -> Vec<Line> {
paras
.iter()
.map(|&p| Line {
words: Vec::new(),
content_w: 0.0,
paragraph: p,
height_px: 0.0,
line_style: None,
left_indent_px: 0.0,
decoration: None,
})
.collect()
}
#[test]
fn no_straddle_keeps_take() {
let lines = lines_with_paragraphs(&[0, 0, 0, 1, 1, 1]);
assert_eq!(adjust_for_widow_orphan(&lines, 3, 2), 3);
}
#[test]
fn orphan_single_first_line_pulled_down() {
let lines = lines_with_paragraphs(&[0, 0, 0, 1, 1, 1]);
assert_eq!(adjust_for_widow_orphan(&lines, 4, 2), 3);
}
#[test]
fn widow_single_last_line_pulled_down() {
let lines = lines_with_paragraphs(&[0, 0, 0, 0, 0, 0, 1, 1]);
assert_eq!(adjust_for_widow_orphan(&lines, 5, 2), 4);
}
#[test]
fn satisfied_both_sides_unchanged() {
let lines = lines_with_paragraphs(&[0, 0, 0, 0]);
assert_eq!(adjust_for_widow_orphan(&lines, 2, 2), 2);
}
#[test]
fn degenerate_would_empty_box_left_as_is() {
let lines = lines_with_paragraphs(&[1, 1, 1]);
assert_eq!(adjust_for_widow_orphan(&lines, 1, 2), 1);
}
}