///|
// Lists (asciidoctor-pdf's `convert_ulist`, `convert_olist`,
// `convert_colist`, `convert_dlist` and `convert_list_item`), styled by
// the theme's `list_*`, `ulist_marker_*`, `olist_marker_*`,
// `callout_list_*`, `conum_*` and `description_list_*` keys.
///|
/// asciidoctor-pdf's bullet type for an unordered list (`convert_ulist`):
/// its style's (`bibliography` is a square, an unknown style a disc), else
/// the one for its depth; None for `unstyled` and `no-bullet`, which have
/// no marker and a narrower indent.
fn ulist_marker_type(node : @core.Node, depth : Int) -> String? {
match node.style {
Some("circle") => Some("circle")
Some("square") | Some("bibliography") => Some("square")
Some("none") => Some("none")
Some("disc") => Some("disc")
Some("unstyled") | Some("no-bullet") => None
Some(_) => Some("disc")
None =>
match depth {
1 => Some("disc")
2 => Some("circle")
_ => Some("square")
}
}
}
///|
/// A bullet type's marker: the theme's `ulist_marker__content`, else
/// the built-in bullet (`Bullets`, `BallotBox`).
fn ulist_marker_content(marker_type : String) -> String {
match t_str("ulist_marker_\{marker_type}_content") {
Some(content) => content
None =>
match marker_type {
"disc" => "\u{2022}"
"circle" => "\u{25e6}"
"square" => "\u{25aa}"
"checked" => "\u{2611}"
"unchecked" => "\u{2610}"
_ => ""
}
}
}
///|
/// Nesting level among unordered and ordered lists (asciidoctor-pdf's
/// `list_level`).
fn list_level(node : @core.Node) -> Int {
let mut level = 1
let mut ancestor = node.parent()
while ancestor is Some(a) {
if a.context == Ulist || a.context == Olist {
level += 1
}
ancestor = a.parent()
}
level
}
///|
/// A list item's marker and the theme keys that style it (most specific
/// first: `ulist_marker_disc`, `ulist_marker`).
priv struct Marker {
text : String
keys : Array[String]
}
///|
fn Converter::convert_ulist(self : Converter, node : @core.Node) -> Unit {
self.add_dest(node)
if node.has_option("checklist") {
// a checkbox for an item that has one, nothing for the others
self.convert_list(node, fn(_index, item) {
if !item.has_attr("checkbox") {
{ text: "", keys: [], }
} else {
let kind = if item.has_attr("checked") {
"checked"
} else {
"unchecked"
}
{
text: ulist_marker_content(kind),
keys: ["ulist_marker_\{kind}", "ulist_marker"],
}
}
})
return
}
match ulist_marker_type(node, list_level(node)) {
Some(kind) => {
let marker : Marker = {
text: ulist_marker_content(kind),
keys: ["ulist_marker_\{kind}", "ulist_marker"],
}
self.convert_list(node, fn(_index, _item) { marker })
}
None =>
self.convert_list(
node,
fn(_index, _item) { { text: "", keys: [], } },
unmarked=true,
)
}
}
///|
fn roman(n : Int, upper : Bool) -> String {
let values = [1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1]
let symbols = [
"m", "cm", "d", "cd", "c", "xc", "l", "xl", "x", "ix", "v", "iv", "i",
]
let sb = StringBuilder()
let mut rest = n
for i, value in values {
while rest >= value {
sb.write_string(symbols[i])
rest -= value
}
}
if upper {
sb.to_string().to_upper()
} else {
sb.to_string()
}
}
///|
fn alpha(n : Int, upper : Bool) -> String {
let base = if upper { 'A'.to_int() } else { 'a'.to_int() }
let sb = StringBuilder()
let mut rest = n
let digits = []
while rest > 0 {
rest -= 1
digits.push(base + rest % 26)
rest = rest / 26
}
for i = digits.length() - 1; i >= 0; i = i - 1 {
sb.write_char(digits[i].unsafe_to_char())
}
sb.to_string()
}
///|
/// The block before `node` among its parent's blocks.
fn previous_sibling(node : @core.Node) -> @core.Node? {
guard node.parent() is Some(parent) else { return None }
let mut previous : @core.Node? = None
for block in parent.blocks {
if physical_equal(block, node) {
return previous
}
previous = Some(block)
}
None
}
///|
/// The alignment of a list's item text: its `text-*` role, else
/// `list_text_align` (a bibliography's is left).
fn list_text_align(node : @core.Node) -> Alignment? {
match text_align_from_roles(node.roles()) {
Some(align) => Some(align)
None =>
if node.style == Some("bibliography") {
Some(Left)
} else {
t_str("list_text_align").map(alignment_of)
}
}
}
///|
/// asciidoctor-pdf's `convert_colist`: after a code block it moves by
/// `callout_list_margin_top_after_code`; each item's number, in the conum
/// font, is centred in a column as wide as the number and an `x`, and the
/// item's text is indented by that column (`convert_colist_item`); items
/// are `callout_list_item_spacing` apart.
fn Converter::convert_colist(self : Converter, node : @core.Node) -> Unit {
let flow = self.flow
if !flow.at_page_top() &&
previous_sibling(node) is Some(prev) &&
(prev.context == Listing || prev.context == Literal) {
flow.margin(t_num_or("callout_list_margin_top_after_code", 0.0))
}
self.add_dest(node)
let (base, base_lh) = theme_font(
base_style(),
base_line_height(),
"callout_list",
)
let (conum, conum_lh) = conum_style(base, base_lh)
let conum_face = flow.font(conum)
// allocate_space_for_list_item with the conum font's line metrics (in
// `theme_font :conum`, the base line height is the conum line height)
let metrics = line_metrics(conum_lh, conum_face, conum.size)
let marker_color = t_color("callout_list_marker_font_color").unwrap_or(
conum.color,
)
let item_spacing = t_num("callout_list_item_spacing").unwrap_or(
list_item_spacing(),
)
let align = match text_align_from_roles(node.roles()) {
Some(a) => Some(a)
None => t_str("list_text_align").map(alignment_of)
}
let items = node.items()
for index, item in items {
if !flow.at_page_top() &&
flow.cursor() <
conum_lh * conum.size + metrics.leading + metrics.padding_top {
flow.advance_page()
}
self.traced(item, "list_item", () => {
let marker = conum_glyph((index + 1).to_string())
let marker_width = typeset_lines(
[{ text: marker + "x", style: conum, anchor: None, }],
flow.catalog,
1.0e6,
Left,
)[0].fit_width
let (page, y) = (flow.page, flow.y)
flow.indent(0.0, flow.width() - marker_width, () => {
self.ink_prose(
marker,
style={ ..conum, color: marker_color, },
align=Center,
margin_bottom=0.0,
normalize=false,
line_height=conum_lh,
)
})
flow.go_to_page(page)
flow.y = y
let margin_bottom = if index == items.length() - 1 {
0.0
} else {
item_spacing
}
flow.indent(marker_width, 0.0, () => {
match item.text() {
Some(text) if !text.is_empty() =>
self.ink_prose(
text,
style=base,
align?,
margin_bottom~,
line_height=base_lh,
normalize_line_height=true,
)
_ =>
if !item.has_blocks() {
self.ink_prose(
"",
style=base,
margin_bottom~,
line_height=base_lh,
normalize_line_height=true,
)
}
}
self.traverse(item)
})
})
}
if next_enclosed_block(node) is Some(_) {
flow.margin(prose_margin_bottom())
}
}
///|
/// The conum font (`theme_font :conum` over `base`), which names its font
/// (the markup of a callout does), and its line height.
fn conum_style(base : Style, line_height : Double) -> (Style, Double) {
let (style, line_height) = theme_font(base, line_height, "conum")
(
{ ..style, font_set: true, bold: style.doc_bold, italic: style.doc_italic, },
line_height,
)
}
///|
fn Converter::convert_olist(self : Converter, node : @core.Node) -> Unit {
self.add_dest(node)
let start = match node.attr("start") {
Some(s) => @string.parse_int(s) catch { _ => 1 }
None => 1
}
// a style the marker implies (`a.`) is a Symbol in Asciidoctor, which
// asciidoctor-pdf's `case node.style when 'loweralpha'` never matches:
// (the core marks such a style: it has no list marker keyword)
let style = match node.style {
Some("loweralpha" | "upperalpha" | "lowerroman" | "upperroman") if node.list_marker_keyword()
is None => "arabic"
Some(style) => style
None => "arabic"
}
// `unstyled`, `unnumbered` and `no-bullet` have no numbers and a narrower
// indent; `none` has empty ones
let unmarked = style == "unstyled" ||
style == "unnumbered" ||
style == "no-bullet"
self.convert_list(
node,
fn(index, _item) {
let n = start + index
let text = match style {
"loweralpha" => "\{alpha(n, false)}."
"upperalpha" => "\{alpha(n, true)}."
"lowerroman" => "\{roman(n, false)}."
"upperroman" => "\{roman(n, true)}."
// LowercaseGreekA, counted on by `String#next` one code point a step
// (a start below 1 does not count back from a letter)
"lowergreek" =>
"\{Int::unsafe_to_char(0x3B1 + @cmp.maximum(start - 1, 0) + index)}."
"none" | "unstyled" | "unnumbered" | "no-bullet" => ""
"decimal" => if n < 10 { "0\{n}." } else { "\{n}." }
_ => "\{n}."
}
{ text, keys: if text == "" { [] } else { ["olist_marker"] }, }
},
unmarked~,
)
}
///|
fn Converter::convert_list(
self : Converter,
node : @core.Node,
marker_of : (Int, @core.Node) -> Marker,
unmarked? : Bool = false,
) -> Unit {
let flow = self.flow
if node.has_title() {
self.ink_caption(node.title().unwrap_or(""), category="list")
}
let base = base_style()
let face = flow.font(base)
let metrics = line_metrics(base_line_height(), face, base.size)
// a list without markers is indented less: not at all when unstyled,
// else by the room a marker and an `x` would take less
let indent = if !unmarked {
list_indent()
} else if node.style == Some("unstyled") {
0.0
} else {
let sample = if node.context == Ulist { "\u{2022}x" } else { "1.x" }
@cmp.maximum(list_indent() - flow.width_of(sample, base), 0.0)
}
// the items' text (not their blocks) aligns by the list's role, a
// bibliography's to the left, else by `list_text_align`
let align = list_text_align(node)
flow.indent(indent, 0.0, fn() {
for index, item in node.items() {
// allocate_space_for_list_item
if !flow.at_page_top() &&
flow.cursor() <
base_line_height() * base.size + metrics.leading + metrics.padding_top {
flow.advance_page()
}
let marker = marker_of(index, item)
self.traced(item, "list_item", () => {
self.convert_list_item(item, marker, metrics, align?)
})
}
})
if !list_nested(node) && next_enclosed_block(node) is Some(_) {
self.flow.margin(prose_margin_bottom())
}
}
///|
/// asciidoctor-pdf's `List#nested?`: the list sits in a list item.
fn list_nested(node : @core.Node) -> Bool {
node.parent() is Some(parent) && parent.context == ListItem
}
///|
/// Ruby's `ListItem#compound?`: blocks other than a single nested list.
fn is_compound_item(item : @core.Node) -> Bool {
match item.blocks {
[] => false
[only] => !(only.context == Ulist || only.context == Olist)
_ => true
}
}
///|
/// The style of a list marker: the current font, in `list_marker_font_color`,
/// with the marker's own `font_color`, `font_family`, `font_size`,
/// `font_style` and `line_height` keys (`keys`, most specific first); and
/// its line height.
fn marker_style(base : Style, keys : Array[String]) -> (Style, Double) {
let key = (prop : String) => {
for k in keys {
match tv("\{k}_\{prop}") {
Null => ()
v => return v
}
}
Null
}
let mut style = {
..base,
color: t_color("list_marker_font_color").unwrap_or(base.color),
}
match value_color(key("font_color")) {
Some(color) => style = { ..style, color, }
None => ()
}
match value_str(key("font_family")) {
Some(family) => style = { ..style, family, }
None => ()
}
match resolve_font_size(key("font_size"), style.size) {
Some(size) => style = { ..style, size, }
None => ()
}
match value_str(key("font_style")) {
Some(fs) => {
let (doc_bold, doc_italic) = font_style_of(fs)
style = { ..style, doc_bold, doc_italic, }
}
None => ()
}
(style, value_num(key("line_height")).unwrap_or(base_line_height()))
}
///|
fn Converter::convert_list_item(
self : Converter,
item : @core.Node,
marker : Marker,
_metrics : LineMetrics,
align? : Alignment,
) -> Unit {
self.ink_list_marker(marker)
let margin_bottom = if is_compound_item(item) {
prose_margin_bottom()
} else if next_enclosed_block(item, descend=true) is Some(_) {
list_item_spacing()
} else {
0.0
}
match item.text() {
Some(text) if !text.is_empty() =>
self.ink_prose(text, align?, margin_bottom~, normalize_line_height=true)
// an item without text or blocks still takes a line (`DummyText`)
_ =>
if !item.has_blocks() {
self.ink_prose("", margin_bottom~, normalize_line_height=true)
}
}
self.traverse(item)
}
///|
/// A list item's marker (the start of `convert_list_item`), unless empty.
fn Converter::ink_list_marker(self : Converter, marker : Marker) -> Unit {
let flow = self.flow
let base = base_style()
if !marker.text.is_empty() {
// the marker hangs in the indent, right-aligned one `x` before the
// text, in a float that does not move the cursor
let (mstyle, mline_height) = marker_style(base, marker.keys)
let face = flow.font(base)
let gap = face.prawn_width('x'.to_int()) * base.size / 1000.0
let mface = flow.font(mstyle)
let mmetrics = line_metrics(mline_height, mface, mstyle.size)
let lines = typeset_lines(
[{ text: marker.text, style: mstyle, anchor: None, }],
flow.catalog,
1000.0,
Left,
)
let width = if lines.length() > 0 { lines[0].fit_width } else { 0.0 }
let y = flow.y
let page = flow.page
// the marker's text box stretches to the bottom margin: when its line
// does not fit above it, the marker goes to the top of the next page,
// where the item's text will follow it (`flow_bounding_box`)
let a = mface.ascender_pt(mstyle.size)
if !flow.at_page_top() &&
mmetrics.padding_top + a + mface.descender_pt(mstyle.size) >
flow.cursor() + 0.0001 {
flow.advance_page()
}
let baseline = flow.y + mmetrics.padding_top + a
if lines.length() > 0 {
// set with `character_spacing: -0.5`, which Prawn does not count
// after the last glyph: right-aligned in a box as wide as the marker
// without it, the text starts 0.5pt in per glyph after the first,
// plus the 0.5pt asciidoctor-pdf adds back
// (`character_spacing_correction`)
let mut glyphs = 0
for run in lines[0].runs {
for i in 0.. Unit {
let parent = node.parent().unwrap_or(node)
let list = @core.Node::new_list(
parent,
if style == "unordered" {
Ulist
} else {
Olist
},
)
let stack = node.has_role("stack")
let stop = match node.attr("subject-stop") {
Some(s) => s
None => if stack { "" } else { ":" }
}
for entry in node.dlist_items {
let subject = entry.terms.get(0).bind(t => t.text()).unwrap_or("")
let text = match entry.desc {
Some(desc) => {
let plain = sanitize(subject)
let punct = plain != "" &&
".!?;:".contains_char(plain[plain.length() - 1].unsafe_to_char())
let desc_text = match desc.text() {
Some(t) if t != "" => (if stack { "
" } else { " " }) + t
_ => ""
}
"+++\{subject}\{if punct { "" } else { stop }}\{desc_text}+++"
}
None => "+++\{subject}+++"
}
let item = @core.Node::new_list_item(list, text~)
match entry.desc {
Some(desc) =>
for block in desc.blocks.copy() {
item.append(block)
}
None => ()
}
list.append(item)
}
if style == "unordered" {
let marker : Marker = {
text: ulist_marker_content("disc"),
keys: ["ulist_marker_disc", "ulist_marker"],
}
self.convert_list(list, fn(_index, _item) { marker })
} else {
self.convert_list(list, fn(index, _item) {
{ text: "\{index + 1}.", keys: ["olist_marker"], }
})
}
}
///|
/// `convert_dlist` of a `qanda` list: a numbered list (`convert_list`)
/// whose items are the questions in italics, each followed by the term
/// spacing, then the answer (`traverse_list_item`).
fn Converter::convert_qanda(self : Converter, node : @core.Node) -> Unit {
let flow = self.flow
if node.has_title() {
self.ink_caption(node.title().unwrap_or(""), category="list")
}
let base = base_style()
let metrics = line_metrics(base_line_height(), flow.font(base), base.size)
let align = list_text_align(node)
flow.indent(list_indent(), 0.0, fn() {
for index, entry in node.dlist_items {
if !flow.at_page_top() &&
flow.cursor() <
base_line_height() * base.size + metrics.leading + metrics.padding_top {
flow.advance_page()
}
self.ink_list_marker({ text: "\{index + 1}.", keys: [], })
// convert_list_item's margin: the answer's (the junction)
let margin_bottom = match entry.desc {
Some(desc) if is_compound_item(desc) => prose_margin_bottom()
Some(desc) if next_enclosed_block(desc, descend=true) is Some(_) =>
list_item_spacing()
_ => 0.0
}
for term in entry.terms {
self.ink_prose(
"\{term.text().unwrap_or("")}",
align?,
margin_bottom=description_list_term_spacing(),
normalize_line_height=true,
)
}
match entry.desc {
Some(desc) => {
match desc.text() {
Some(text) if text != "" =>
self.ink_prose(
text,
align?,
margin_bottom~,
normalize_line_height=true,
)
_ => ()
}
self.traverse(desc)
}
None => ()
}
}
})
if !list_nested(node) && next_enclosed_block(node) is Some(_) {
flow.margin(prose_margin_bottom())
}
}
///|
/// The term font of a description list (`theme_font
/// :description_list_term`), whose font style its text inherits as markup,
/// and its line height.
fn dlist_term_style() -> (Style, Double) {
let (style, line_height) = theme_font(
base_style(),
base_line_height(),
"description_list_term",
)
(
{
..style,
bold: style.bold || style.doc_bold,
italic: style.italic || style.doc_italic,
},
line_height,
)
}
///|
fn Converter::convert_dlist(self : Converter, node : @core.Node) -> Unit {
self.add_dest(node)
match node.style {
Some("unordered" | "ordered" as style) => {
self.convert_dlist_as_list(node, style)
return
}
Some("qanda") => {
self.convert_qanda(node)
return
}
_ => ()
}
let flow = self.flow
if node.has_title() {
self.ink_caption(node.title().unwrap_or(""), category="description_list")
}
if node.style == Some("horizontal") {
self.convert_horizontal_dlist(node)
return
}
let (term_style, term_lh) = dlist_term_style()
let term_spacing = description_list_term_spacing()
// `height_of_typeset_text 'A'` in the term font and in the prose font
let one_line = fn(style : Style, lh : Double) {
flow.height_of(
[{ text: "A", style, anchor: None, }],
line_metrics(lh, flow.font(style), style.size),
style,
)
}
let term_height = one_line(term_style, term_lh)
let prose_height = one_line(base_style(), base_line_height())
for entry in node.dlist_items {
// keep the terms (and the first line of the description) together
let desc_text = match entry.desc {
Some(desc) => desc.text().map(t => !t.is_empty()).unwrap_or(false)
None => false
}
let nlines = entry.terms.length() + (if desc_text { 1 } else { 0 })
let blocks_only = match entry.desc {
Some(desc) => !desc_text && desc.has_blocks()
None => false
}
let needed = nlines.to_double() * term_height +
(nlines - 1).to_double() * term_spacing +
(if blocks_only { term_spacing + prose_height } else { 0.0 })
if !flow.at_page_top() && flow.cursor() < needed {
flow.advance_page()
}
for index, term in entry.terms {
if index > 0 {
flow.margin(term_spacing)
}
self.ink_prose(
term.text().unwrap_or(""),
style=term_style,
align=Left,
margin_bottom=0.0,
line_height=term_lh,
normalize_line_height=true,
)
}
match entry.desc {
Some(desc) =>
flow.indent(description_list_description_indent(), 0.0, fn() {
flow.margin(term_spacing)
let margin_bottom = if next_enclosed_block(desc, descend=true)
is Some(_) {
prose_margin_bottom()
} else {
0.0
}
match desc.text() {
Some(text) if !text.is_empty() =>
self.ink_prose(text, margin_bottom~, normalize_line_height=true)
_ => ()
}
self.traverse(desc)
})
None => ()
}
}
if !list_nested(node) && next_enclosed_block(node) is Some(_) {
flow.margin(prose_margin_bottom())
}
}
///|
fn Converter::convert_horizontal_dlist(
self : Converter,
node : @core.Node,
) -> Unit {
let flow = self.flow
let (term_style, term_lh) = dlist_term_style()
let term_spacing = description_list_term_spacing()
let mut term_width = 0.0
for entry in node.dlist_items {
for term in entry.terms {
let lines = typeset_lines(
parse_formatted(term.text().unwrap_or(""), term_style),
flow.catalog,
1.0e6,
Left,
)
for line in lines {
if line.fit_width > term_width {
term_width = line.fit_width
}
}
}
}
let column = @cmp.minimum(term_width + 20.0, flow.width() * 0.5 - 20.0)
// `height_of_typeset_text 'A'` in the term font and in the prose font
let one_line = fn(style : Style, lh : Double) {
flow.height_of(
[{ text: "A", style, anchor: None, }],
line_metrics(lh, flow.font(style), style.size),
style,
)
}
let term_height = one_line(term_style, term_lh)
let prose_height = one_line(base_style(), base_line_height())
for entry in node.dlist_items {
// the terms (and a line of the description) must fit, else the entry
// starts on the next page
let terms = entry.terms.length().to_double()
let needed = @cmp.maximum(
(term_spacing + term_height) * terms - term_spacing,
if entry.desc is Some(_) {
prose_height
} else {
0.0
},
)
if !flow.at_page_top() && flow.cursor() < needed {
flow.advance_page()
}
let start_page = flow.page
let start_y = flow.y
flow.indent(10.0, flow.width() - column, fn() {
for index, term in entry.terms {
if index > 0 {
flow.margin(term_spacing)
}
self.ink_prose(
term.text().unwrap_or(""),
style=term_style,
align=Left,
margin_bottom=0.0,
line_height=term_lh,
normalize_line_height=true,
)
}
})
let (term_page, term_y) = (flow.page, flow.y)
match entry.desc {
Some(desc) => {
flow.go_to_page(start_page)
flow.y = start_y
// traverse_list_item: the description's text takes the prose
// margin below when something follows it in the entry
let margin_bottom = if next_enclosed_block(desc, descend=true)
is Some(_) {
prose_margin_bottom()
} else {
0.0
}
flow.indent(column + 10.0, 10.0, fn() {
match desc.text() {
Some(text) if !text.is_empty() =>
self.ink_prose(text, margin_bottom~, normalize_line_height=true)
_ =>
if !desc.has_blocks() {
self.ink_prose("", margin_bottom~, normalize_line_height=true)
}
}
self.traverse(desc)
})
// the cursor ends below the last term
if flow.page < term_page || (flow.page == term_page && flow.y < term_y) {
flow.go_to_page(term_page)
flow.y = term_y
}
}
None => ()
}
}
if !list_nested(node) && next_enclosed_block(node) is Some(_) {
flow.margin(prose_margin_bottom())
}
}