use std::collections::BTreeMap;
use zenith_core::{FieldNode, Page, TextNode, TextSpan};
use crate::compile::util::px_prop;
use super::folio::format_folio;
#[derive(Clone, Copy)]
pub(crate) struct FieldCtx<'a> {
pub(in crate::compile) page_index_1based: usize,
pub(in crate::compile) is_recto: bool,
pub(in crate::compile) live_area: Option<(f64, f64, f64, f64)>,
pub(in crate::compile) page_index_by_node_id: &'a BTreeMap<String, usize>,
pub(in crate::compile) footnote_markers: &'a BTreeMap<String, String>,
pub(in crate::compile) node_boxes: &'a BTreeMap<String, (f64, f64, f64, f64)>,
pub(in crate::compile) total_pages: usize,
pub(in crate::compile) pages: &'a [Page],
pub(in crate::compile) section_page_index: Option<usize>,
pub(in crate::compile) section_page_count: Option<usize>,
pub(in crate::compile) section_folio_start: Option<usize>,
pub(in crate::compile) section_folio_style: Option<&'a str>,
pub(in crate::compile) section_name: Option<&'a str>,
}
pub(in crate::compile) fn resolve_field_to_text(
field: &FieldNode,
ctx: &FieldCtx,
) -> Option<TextNode> {
if field.visible == Some(false) {
return None;
}
let is_numeric_type = matches!(
field.field_type.as_str(),
"page-number" | "page-count" | "page-ref" | "section-page-number" | "section-page-count"
);
if field.suppress_first.is_some_and(|v| v) && ctx.page_index_1based == 1 && is_numeric_type {
return None;
}
let style = field.folio_style.as_deref();
let (text, default_align) = match field.field_type.as_str() {
"running-head" => {
let side = if ctx.is_recto {
field.recto.as_deref()
} else {
field.verso.as_deref()
};
let s = side?;
if s.is_empty() {
return None;
}
(s.to_owned(), "center")
}
"page-number" => (format_folio(ctx.page_index_1based, style), "center"),
"page-count" => (format_folio(ctx.total_pages, style), "center"),
"page-ref" => {
let target = field.target.as_deref()?;
let idx = ctx.page_index_by_node_id.get(target)?;
(format_folio(*idx, style), "start")
}
"section-page-number" => {
let rel = ctx.section_page_index?;
let folio_n = ctx.section_folio_start.unwrap_or(1) + rel;
let effective_style = style.or(ctx.section_folio_style);
(format_folio(folio_n, effective_style), "center")
}
"section-page-count" => {
let n = ctx.section_page_count?;
let effective_style = style.or(ctx.section_folio_style);
(format_folio(n, effective_style), "center")
}
"section-name" => {
let name = ctx.section_name?;
if name.is_empty() {
return None;
}
(name.to_owned(), "center")
}
_ => return None,
};
let live = ctx.live_area;
let x = field
.x
.clone()
.or_else(|| live.map(|(lx, _, _, _)| px_prop(lx)));
let y = field
.y
.clone()
.or_else(|| live.map(|(_, ly, _, _)| px_prop(ly)));
let w = field
.w
.clone()
.or_else(|| live.map(|(_, _, lw, _)| px_prop(lw)));
let h = field
.h
.clone()
.or_else(|| live.map(|(_, _, _, lh)| px_prop(lh)));
Some(TextNode {
id: field.id.clone(),
name: field.name.clone(),
role: field.role.clone(),
x,
y,
w,
h,
align: Some(default_align.to_owned()),
v_align: None,
direction: None,
overflow: Some("clip".to_owned()),
overflow_wrap: None,
style: field.style.clone(),
fill: field.fill.clone(),
stroke: None,
stroke_width: None,
contrast_bg: None,
font_family: field.font_family.clone(),
font_size: field.font_size.clone(),
font_size_min: None,
font_weight: None,
shadow: None,
filter: None,
mask: None,
blend_mode: None,
blur: None,
opacity: field.opacity,
visible: field.visible,
locked: field.locked,
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: vec![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,
}],
block_styles: Vec::new(),
source_span: field.source_span,
unknown_props: BTreeMap::new(),
})
}
#[cfg(test)]
mod tests {
use super::*;
fn page_count_field() -> FieldNode {
FieldNode {
id: "total".to_owned(),
anchor: None,
anchor_zone: None,
anchor_sibling: None,
anchor_edge: None,
anchor_gap: None,
anchor_parent: None,
name: None,
role: None,
field_type: "page-count".to_owned(),
recto: None,
verso: None,
target: None,
folio_style: None,
suppress_first: None,
x: None,
y: None,
w: None,
h: None,
style: None,
fill: None,
font_family: None,
font_size: None,
opacity: None,
visible: None,
locked: None,
source_span: None,
unknown_props: BTreeMap::new(),
}
}
#[test]
fn page_count_field_resolves_to_total_page_count() {
let by_id: BTreeMap<String, usize> = BTreeMap::new();
let markers: BTreeMap<String, String> = BTreeMap::new();
let boxes: BTreeMap<String, (f64, f64, f64, f64)> = BTreeMap::new();
let ctx = FieldCtx {
page_index_1based: 2,
is_recto: false,
live_area: None,
page_index_by_node_id: &by_id,
pages: &[],
footnote_markers: &markers,
node_boxes: &boxes,
total_pages: 5,
section_page_index: None,
section_page_count: None,
section_folio_start: None,
section_folio_style: None,
section_name: None,
};
let text = resolve_field_to_text(&page_count_field(), &ctx)
.expect("a page-count field must resolve to a text node");
assert_eq!(text.spans.len(), 1, "a field is a single span");
assert_eq!(
text.spans.first().map(|s| s.text.as_str()),
Some("5"),
"page-count resolves to the total page count as a decimal string"
);
}
type CtxStores = (
BTreeMap<String, usize>,
BTreeMap<String, String>,
BTreeMap<String, (f64, f64, f64, f64)>,
);
fn make_ctx() -> CtxStores {
(BTreeMap::new(), BTreeMap::new(), BTreeMap::new())
}
#[derive(Default)]
struct SectionArgs<'a> {
page_index: Option<usize>,
page_count: Option<usize>,
folio_start: Option<usize>,
folio_style: Option<&'a str>,
name: Option<&'a str>,
}
fn field_ctx<'a>(
page: usize,
total: usize,
by_id: &'a BTreeMap<String, usize>,
markers: &'a BTreeMap<String, String>,
boxes: &'a BTreeMap<String, (f64, f64, f64, f64)>,
) -> FieldCtx<'a> {
FieldCtx {
page_index_1based: page,
is_recto: page % 2 == 1,
live_area: None,
page_index_by_node_id: by_id,
pages: &[],
footnote_markers: markers,
node_boxes: boxes,
total_pages: total,
section_page_index: None,
section_page_count: None,
section_folio_start: None,
section_folio_style: None,
section_name: None,
}
}
fn page_number_field(folio_style: Option<&str>, suppress_first: Option<bool>) -> FieldNode {
FieldNode {
id: "pn".to_owned(),
anchor: None,
anchor_zone: None,
anchor_sibling: None,
anchor_edge: None,
anchor_gap: None,
anchor_parent: None,
name: None,
role: None,
field_type: "page-number".to_owned(),
recto: None,
verso: None,
target: None,
folio_style: folio_style.map(str::to_owned),
suppress_first,
x: None,
y: None,
w: None,
h: None,
style: None,
fill: None,
font_family: None,
font_size: None,
opacity: None,
visible: None,
locked: None,
source_span: None,
unknown_props: BTreeMap::new(),
}
}
fn running_head_field(suppress_first: Option<bool>) -> FieldNode {
FieldNode {
id: "rh".to_owned(),
anchor: None,
anchor_zone: None,
anchor_sibling: None,
anchor_edge: None,
anchor_gap: None,
anchor_parent: None,
name: None,
role: None,
field_type: "running-head".to_owned(),
recto: Some("Chapter One".to_owned()),
verso: Some("My Book".to_owned()),
target: None,
folio_style: None,
suppress_first,
x: None,
y: None,
w: None,
h: None,
style: None,
fill: None,
font_family: None,
font_size: None,
opacity: None,
visible: None,
locked: None,
source_span: None,
unknown_props: BTreeMap::new(),
}
}
fn span_text(node: &TextNode) -> &str {
node.spans.first().map(|s| s.text.as_str()).unwrap_or("")
}
#[test]
fn page_number_lower_roman_on_page_3() {
let (by_id, markers, boxes) = make_ctx();
let ctx = field_ctx(3, 10, &by_id, &markers, &boxes);
let field = page_number_field(Some("lower-roman"), None);
let text =
resolve_field_to_text(&field, &ctx).expect("page-number with lower-roman must resolve");
assert_eq!(span_text(&text), "iii");
}
#[test]
fn page_number_upper_roman_on_page_4() {
let (by_id, markers, boxes) = make_ctx();
let ctx = field_ctx(4, 10, &by_id, &markers, &boxes);
let field = page_number_field(Some("upper-roman"), None);
let text =
resolve_field_to_text(&field, &ctx).expect("page-number with upper-roman must resolve");
assert_eq!(span_text(&text), "IV");
}
#[test]
fn page_number_no_folio_style_is_decimal() {
let (by_id, markers, boxes) = make_ctx();
let ctx = field_ctx(5, 10, &by_id, &markers, &boxes);
let field = page_number_field(None, None);
let text = resolve_field_to_text(&field, &ctx)
.expect("page-number without folio-style must resolve");
assert_eq!(span_text(&text), "5");
}
#[test]
fn page_number_unknown_folio_style_falls_back_to_decimal() {
let (by_id, markers, boxes) = make_ctx();
let ctx = field_ctx(7, 10, &by_id, &markers, &boxes);
let field = page_number_field(Some("klingon"), None);
let text = resolve_field_to_text(&field, &ctx)
.expect("page-number with unknown folio-style must resolve (decimal fallback)");
assert_eq!(span_text(&text), "7");
}
#[test]
fn suppress_first_hides_numeric_field_on_page_1() {
let (by_id, markers, boxes) = make_ctx();
let ctx = field_ctx(1, 10, &by_id, &markers, &boxes);
let field = page_number_field(None, Some(true));
assert!(
resolve_field_to_text(&field, &ctx).is_none(),
"suppress-first=true on page 1 must return None"
);
}
#[test]
fn suppress_first_allows_numeric_field_on_page_2() {
let (by_id, markers, boxes) = make_ctx();
let ctx = field_ctx(2, 10, &by_id, &markers, &boxes);
let field = page_number_field(None, Some(true));
let text = resolve_field_to_text(&field, &ctx)
.expect("suppress-first=true on page 2 must resolve normally");
assert_eq!(span_text(&text), "2");
}
#[test]
fn suppress_first_does_not_suppress_running_head_on_page_1() {
let (by_id, markers, boxes) = make_ctx();
let ctx = field_ctx(1, 10, &by_id, &markers, &boxes);
let field = running_head_field(Some(true));
let text = resolve_field_to_text(&field, &ctx)
.expect("suppress-first must NOT suppress running-head on page 1");
assert_eq!(span_text(&text), "Chapter One");
}
fn section_field(field_type: &str, folio_style: Option<&str>) -> FieldNode {
FieldNode {
id: "sf".to_owned(),
anchor: None,
anchor_zone: None,
anchor_sibling: None,
anchor_edge: None,
anchor_gap: None,
anchor_parent: None,
name: None,
role: None,
field_type: field_type.to_owned(),
recto: None,
verso: None,
target: None,
folio_style: folio_style.map(str::to_owned),
suppress_first: None,
x: None,
y: None,
w: None,
h: None,
style: None,
fill: None,
font_family: None,
font_size: None,
opacity: None,
visible: None,
locked: None,
source_span: None,
unknown_props: BTreeMap::new(),
}
}
fn ctx_with_section<'a>(
page_1based: usize,
total: usize,
by_id: &'a BTreeMap<String, usize>,
markers: &'a BTreeMap<String, String>,
boxes: &'a BTreeMap<String, (f64, f64, f64, f64)>,
sec: SectionArgs<'a>,
) -> FieldCtx<'a> {
FieldCtx {
page_index_1based: page_1based,
is_recto: page_1based % 2 == 1,
live_area: None,
page_index_by_node_id: by_id,
pages: &[],
footnote_markers: markers,
node_boxes: boxes,
total_pages: total,
section_page_index: sec.page_index,
section_page_count: sec.page_count,
section_folio_start: sec.folio_start,
section_folio_style: sec.folio_style,
section_name: sec.name,
}
}
#[test]
fn section_page_number_first_body_page_is_1() {
let (by_id, markers, boxes) = make_ctx();
let ctx = ctx_with_section(
3,
5,
&by_id,
&markers,
&boxes,
SectionArgs {
page_index: Some(0),
page_count: Some(3),
folio_start: Some(1),
name: Some("Body"),
..SectionArgs::default()
},
);
let field = section_field("section-page-number", None);
let text = resolve_field_to_text(&field, &ctx)
.expect("section-page-number on first body page must resolve");
assert_eq!(span_text(&text), "1");
}
#[test]
fn section_page_number_front_matter_second_page_lower_roman() {
let (by_id, markers, boxes) = make_ctx();
let ctx = ctx_with_section(
2,
5,
&by_id,
&markers,
&boxes,
SectionArgs {
page_index: Some(1),
page_count: Some(2),
folio_start: Some(1),
folio_style: Some("lower-roman"),
name: Some("Front"),
},
);
let field = section_field("section-page-number", None);
let text = resolve_field_to_text(&field, &ctx)
.expect("section-page-number with lower-roman must resolve");
assert_eq!(span_text(&text), "ii");
}
#[test]
fn section_page_number_field_folio_style_overrides_section() {
let (by_id, markers, boxes) = make_ctx();
let ctx = ctx_with_section(
2,
5,
&by_id,
&markers,
&boxes,
SectionArgs {
page_index: Some(1),
page_count: Some(2),
folio_start: Some(1),
folio_style: Some("lower-roman"),
name: Some("Front"),
},
);
let field = section_field("section-page-number", Some("upper-roman"));
let text =
resolve_field_to_text(&field, &ctx).expect("field folio_style override must resolve");
assert_eq!(span_text(&text), "II");
}
#[test]
fn section_page_number_no_section_returns_none() {
let (by_id, markers, boxes) = make_ctx();
let ctx = ctx_with_section(1, 5, &by_id, &markers, &boxes, SectionArgs::default());
let field = section_field("section-page-number", None);
assert!(
resolve_field_to_text(&field, &ctx).is_none(),
"section-page-number with no section must return None"
);
}
#[test]
fn section_page_count_returns_section_count() {
let (by_id, markers, boxes) = make_ctx();
let ctx = ctx_with_section(
3,
5,
&by_id,
&markers,
&boxes,
SectionArgs {
page_index: Some(0),
page_count: Some(3),
folio_start: Some(1),
name: Some("Body"),
..SectionArgs::default()
},
);
let field = section_field("section-page-count", None);
let text = resolve_field_to_text(&field, &ctx).expect("section-page-count must resolve");
assert_eq!(span_text(&text), "3");
}
#[test]
fn section_page_count_no_section_returns_none() {
let (by_id, markers, boxes) = make_ctx();
let ctx = ctx_with_section(1, 5, &by_id, &markers, &boxes, SectionArgs::default());
let field = section_field("section-page-count", None);
assert!(
resolve_field_to_text(&field, &ctx).is_none(),
"section-page-count with no section must return None"
);
}
#[test]
fn section_name_returns_name() {
let (by_id, markers, boxes) = make_ctx();
let ctx = ctx_with_section(
3,
5,
&by_id,
&markers,
&boxes,
SectionArgs {
page_index: Some(0),
page_count: Some(3),
folio_start: Some(1),
name: Some("Chapter One"),
..SectionArgs::default()
},
);
let field = section_field("section-name", None);
let text = resolve_field_to_text(&field, &ctx).expect("section-name must resolve");
assert_eq!(span_text(&text), "Chapter One");
}
#[test]
fn section_name_empty_returns_none() {
let (by_id, markers, boxes) = make_ctx();
let ctx = ctx_with_section(
1,
5,
&by_id,
&markers,
&boxes,
SectionArgs {
page_index: Some(0),
page_count: Some(1),
folio_start: Some(1),
name: Some(""),
..SectionArgs::default()
},
);
let field = section_field("section-name", None);
assert!(
resolve_field_to_text(&field, &ctx).is_none(),
"section-name with empty name must return None"
);
}
#[test]
fn section_name_no_section_returns_none() {
let (by_id, markers, boxes) = make_ctx();
let ctx = ctx_with_section(1, 5, &by_id, &markers, &boxes, SectionArgs::default());
let field = section_field("section-name", None);
assert!(
resolve_field_to_text(&field, &ctx).is_none(),
"section-name with no section must return None"
);
}
#[test]
fn suppress_first_hides_section_page_number_on_page_1() {
let (by_id, markers, boxes) = make_ctx();
let mut ctx = ctx_with_section(
1,
5,
&by_id,
&markers,
&boxes,
SectionArgs {
page_index: Some(0),
page_count: Some(2),
folio_start: Some(1),
name: Some("Front"),
..SectionArgs::default()
},
);
ctx.page_index_1based = 1;
let mut field = section_field("section-page-number", None);
field.suppress_first = Some(true);
assert!(
resolve_field_to_text(&field, &ctx).is_none(),
"suppress-first=true on page 1 must suppress section-page-number"
);
}
}