use libmandoc_rs::{
AuthorMode, DisplayKind, Node, NodeKind, NormalizedFont, NormalizedListKind,
TableAlignment as MandocTableAlignment,
};
use mant_ir::{
Block, DefinitionItem, Inline, LayoutHint, ListItem, ListKind, Section,
TableAlignment as AstTableAlignment, TableCell as AstTableCell, TableRow,
};
use super::{
LoweringContext, TableTextBlock, first_part_children,
inline::{
FilledBoundary, InlineBuilder, append_inline_node, is_enclosure_macro, lower_inline_nodes,
lower_inline_nodes_with_spacing, lower_man_link, lower_source_alternating_fonts,
lower_source_mdoc_request, parse_roff_text, plain_text, spacing_after_node,
spacing_after_nodes, terms_fit_inline, updated_spacing,
},
layout::{
add_leading_spacing, block_indent, display_indent, horizontal_distance_columns, layout,
layout_with_spacing, normalize_explicit_vertical_spacing, section_spacing,
set_block_spacing, update_paragraph_distance, vertical_distance_lines,
},
part_child_groups,
roff_escape::visible_text,
source_span,
};
pub(super) fn lower_sections(root: &Node, context: &mut LoweringContext<'_>) -> Vec<Section> {
let mut paragraph_distance = 1;
let mut sections = Vec::new();
for node in &root.children {
update_paragraph_distance(node, &mut paragraph_distance);
if !is_section(node, true) && !is_section(node, false) {
continue;
}
let has_preceding_content = sections.last().is_some_and(section_has_body);
let spacing_before_lines = section_spacing(
node,
sections.is_empty(),
has_preceding_content,
paragraph_distance,
);
sections.push(lower_section(
node,
context,
spacing_before_lines,
&mut paragraph_distance,
));
}
sections
}
pub(super) fn lower_root_blocks(root: &Node, context: &LoweringContext<'_>) -> Vec<Block> {
let mut output = Vec::new();
let mut paragraph_distance = 1;
let mut start = 0;
for (index, node) in root.children.iter().enumerate() {
if !is_section(node, true) && !is_section(node, false) {
continue;
}
output.extend(lower_blocks(
&root.children[start..index],
context,
0,
&mut paragraph_distance,
));
start = index + 1;
}
output.extend(lower_blocks(
&root.children[start..],
context,
0,
&mut paragraph_distance,
));
output
}
fn is_bullet_glyph(text: &str) -> bool {
let mut chars = text.chars();
match (chars.next(), chars.next()) {
(Some(glyph), None) => glyph == 'o' || !glyph.is_alphanumeric(),
_ => false,
}
}
fn lower_section(
node: &Node,
context: &mut LoweringContext<'_>,
spacing_before_lines: u16,
paragraph_distance: &mut u16,
) -> Section {
let heading = lower_inline_nodes(
first_part_children(node, NodeKind::Head),
context.default_name,
);
let title = plain_text(&heading).trim().to_owned();
let id = context.section_id(&title);
let body = first_part_children(node, NodeKind::Body);
let first_subsection = body
.iter()
.position(|child| is_section(child, false))
.unwrap_or(body.len());
let blocks = lower_blocks(&body[..first_subsection], context, 0, paragraph_distance);
let mut children = Vec::new();
let mut has_preceding_content = !blocks.is_empty();
for child in &body[first_subsection..] {
update_paragraph_distance(child, paragraph_distance);
if !is_section(child, false) {
continue;
}
let child_spacing = section_spacing(
child,
children.is_empty(),
has_preceding_content,
*paragraph_distance,
);
let child = lower_section(child, context, child_spacing, paragraph_distance);
has_preceding_content = section_has_body(&child);
children.push(child);
}
Section {
id: id.into(),
title,
spacing_before_lines,
blocks,
children,
source: source_span(node),
}
}
fn section_has_body(section: &Section) -> bool {
!section.blocks.is_empty() || section.children.iter().any(section_has_body)
}
fn is_section(node: &Node, top_level: bool) -> bool {
matches!(
(node.macro_name.as_deref(), top_level),
(Some("Sh" | "SH"), true) | (Some("Ss" | "SS"), false)
)
}
fn lower_blocks(
nodes: &[Node],
context: &LoweringContext<'_>,
indent_columns: u16,
paragraph_distance: &mut u16,
) -> Vec<Block> {
lower_blocks_with_spacing(nodes, context, indent_columns, paragraph_distance, true)
}
fn lower_blocks_with_spacing(
nodes: &[Node],
context: &LoweringContext<'_>,
indent_columns: u16,
paragraph_distance: &mut u16,
spacing_enabled: bool,
) -> Vec<Block> {
let (table_embeddings, embedded_nodes) = table_embeddings(nodes, context);
let mut lowerer =
BlockLowerer::new(context, indent_columns, paragraph_distance, spacing_enabled);
for (index, node) in nodes.iter().enumerate() {
if !embedded_nodes[index] && !is_inline_equation_quote_artifact(nodes, index) {
if follows_inline_equation_punctuation(nodes, index) {
lowerer.state.tighten_next_boundary();
}
lowerer.push(node, table_embeddings[index].as_ref());
}
}
lowerer.finish()
}
struct BlockLowerer<'a, 'source> {
context: &'a LoweringContext<'source>,
indent_columns: u16,
paragraph_distance: &'a mut u16,
state: BlockState,
definition_hanging_width: usize,
split_authors: bool,
synopsis_return_type_open: bool,
}
impl<'a, 'source> BlockLowerer<'a, 'source> {
fn new(
context: &'a LoweringContext<'source>,
indent_columns: u16,
paragraph_distance: &'a mut u16,
spacing_enabled: bool,
) -> Self {
Self {
context,
indent_columns,
paragraph_distance,
state: BlockState::new(indent_columns, spacing_enabled),
definition_hanging_width: 7,
split_authors: false,
synopsis_return_type_open: false,
}
}
fn push(&mut self, node: &Node, table_embedding: Option<&TableEmbedding<'_>>) {
if self.consume_control_or_empty_block(node) {
return;
}
if self.push_no_fill_lines(node) {
return;
}
self.state.flush_preformatted();
if self.push_mdoc_synopsis_declaration(node) {
self.state.inherit_spacing(spacing_after_node(
node,
self.state.spacing_enabled(),
self.context.default_name,
));
return;
}
if node.flags.delimiter_close
&& participates_in_inline_flow(node)
&& self.state.paragraph.is_empty()
{
let tail = lower_inline_nodes(std::slice::from_ref(node), self.context.default_name);
if append_to_last_inline_block(&mut self.state.output, &tail) {
return;
}
}
if node.macro_name.as_deref() == Some("Pp") {
self.state.flush_paragraph();
if !self.state.output.is_empty() {
self.state.output.push(Block::VerticalSpace {
lines: 1,
source: source_span(node),
});
}
} else if node.macro_name.as_deref() == Some("sp") {
self.state.flush_paragraph();
if let Some(lines) = vertical_distance_lines(node).filter(|lines| *lines > 0) {
self.state.output.push(Block::VerticalSpace {
lines,
source: source_span(node),
});
}
} else if node.macro_name.as_deref() == Some("br") {
self.state.hard_break();
} else if matches!(node.macro_name.as_deref(), Some("UR" | "MT")) {
let spacing_enabled = self.state.spacing_enabled();
push_man_link(
&mut self.state,
node,
self.context.default_name,
spacing_enabled,
);
} else if participates_in_inline_flow(node) {
self.push_inline_node(node);
} else {
self.state.flush_paragraph();
let spacing_enabled = self.state.spacing_enabled();
StructuralLowerer {
context: self.context,
indent_columns: self.indent_columns,
paragraph_distance: self.paragraph_distance,
output: &mut self.state.output,
definition_hanging_width: &mut self.definition_hanging_width,
spacing_enabled,
}
.push(node, table_embedding);
}
self.state.inherit_spacing(spacing_after_node(
node,
self.state.spacing_enabled(),
self.context.default_name,
));
}
fn consume_control_or_empty_block(&mut self, node: &Node) -> bool {
if consume_block_control(
node,
self.context,
&mut self.state,
self.paragraph_distance,
&mut self.split_authors,
) || node.flags.no_print
|| node.kind == NodeKind::Comment
|| is_section(node, false)
|| is_nonprinting_request(node)
{
return true;
}
if node.kind == NodeKind::Equation
&& node
.equation
.as_deref()
.is_none_or(|value| value.trim().is_empty())
{
return true;
}
if node.kind == NodeKind::Text
&& node.text.as_deref().is_some_and(str::is_empty)
&& !node.flags.no_fill
{
self.state.flush_paragraph();
self.state.output.push(Block::VerticalSpace {
lines: 1,
source: source_span(node),
});
return true;
}
false
}
fn finish(self) -> Vec<Block> {
self.state.finish()
}
fn push_no_fill_lines(&mut self, node: &Node) -> bool {
let Some(lines) = lower_no_fill_lines(node, self.context.default_name) else {
return false;
};
for line in lines {
self.state.push_preformatted(
line.nodes,
line.source,
line.continues_line,
self.context,
);
}
true
}
fn push_inline_node(&mut self, node: &Node) {
let source = source_span(node);
if node.flags.delimiter_close || node.macro_name.as_deref() == Some("Ns") {
self.state.tighten_next_boundary();
}
self.state.push_inline(
lower_inline_nodes_with_spacing(
std::slice::from_ref(node),
self.context.default_name,
self.state.spacing_enabled(),
),
source,
starts_indented_filled_line(node),
ends_with_line_continuation(node),
);
if node.macro_name.as_deref() == Some("Pf") {
self.state.tighten_next_boundary();
}
}
fn push_mdoc_synopsis_declaration(&mut self, node: &Node) -> bool {
let Some(role) = mdoc_synopsis_declaration_role(node) else {
if self.synopsis_return_type_open {
self.state.flush_paragraph();
self.synopsis_return_type_open = false;
}
return false;
};
match role {
SynopsisDeclarationRole::ReturnType => {
self.state.flush_paragraph();
self.push_inline_node(node);
self.synopsis_return_type_open = true;
}
SynopsisDeclarationRole::Function => {
if !self.synopsis_return_type_open {
self.state.flush_paragraph();
}
self.push_inline_node(node);
self.state.flush_paragraph();
self.synopsis_return_type_open = false;
}
SynopsisDeclarationRole::Standalone => {
self.state.flush_paragraph();
self.push_inline_node(node);
self.state.flush_paragraph();
self.synopsis_return_type_open = false;
}
}
true
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum SynopsisDeclarationRole {
Standalone,
ReturnType,
Function,
}
fn mdoc_synopsis_declaration_role(node: &Node) -> Option<SynopsisDeclarationRole> {
let synopsis_pretty = node.flags.synopsis_pretty
|| node
.children
.iter()
.any(|child| child.flags.synopsis_pretty);
if !synopsis_pretty {
return None;
}
match node.macro_name.as_deref()? {
"Fd" | "In" | "Vt" => Some(SynopsisDeclarationRole::Standalone),
"Ft" => Some(SynopsisDeclarationRole::ReturnType),
"Fn" | "Fo" => Some(SynopsisDeclarationRole::Function),
_ => None,
}
}
struct TableEmbedding<'a> {
blocks: Vec<TableTextBlock>,
nodes: Vec<&'a Node>,
}
fn table_embeddings<'a>(
nodes: &'a [Node],
context: &LoweringContext<'_>,
) -> (Vec<Option<TableEmbedding<'a>>>, Vec<bool>) {
let mut embeddings = (0..nodes.len()).map(|_| None).collect::<Vec<_>>();
let mut consumed = vec![false; nodes.len()];
for (index, node) in nodes.iter().enumerate() {
if node.kind != NodeKind::Table {
continue;
}
let blocks = context.table_text_blocks(
node.line,
node.table_cells
.iter()
.filter(|cell| cell.text_block)
.count(),
);
let Some(last_line) = blocks.iter().map(|block| block.end_line).max() else {
continue;
};
let mut semantic_nodes = Vec::new();
for (candidate_index, candidate) in nodes.iter().enumerate().skip(index + 1) {
if candidate.line > last_line {
break;
}
if blocks
.iter()
.any(|block| block.contains_line(candidate.line))
{
consumed[candidate_index] = true;
semantic_nodes.push(candidate);
}
}
embeddings[index] = Some(TableEmbedding {
blocks,
nodes: semantic_nodes,
});
}
(embeddings, consumed)
}
fn consume_block_control(
node: &Node,
context: &LoweringContext<'_>,
state: &mut BlockState,
paragraph_distance: &mut u16,
split_authors: &mut bool,
) -> bool {
match node.macro_name.as_deref() {
Some("PD") => update_paragraph_distance(node, paragraph_distance),
Some("An") => match node.author_mode {
Some(AuthorMode::Split) => *split_authors = true,
Some(AuthorMode::NoSplit) => *split_authors = false,
None if *split_authors => {
state.hard_break();
return false;
}
None => return false,
},
Some("Sm") => {
let setting = plain_text(&lower_inline_nodes(&node.children, context.default_name));
state.set_spacing(setting.trim());
}
_ => return false,
}
true
}
fn push_man_link(
state: &mut BlockState,
node: &Node,
default_name: Option<&str>,
spacing_enabled: bool,
) {
state.push_inline(
lower_man_link(node, default_name, spacing_enabled),
source_span(node),
starts_indented_filled_line(node),
ends_with_line_continuation(node),
);
}
struct LoweredNoFillLine {
nodes: Vec<Inline>,
source: Option<mant_ir::SourceSpan>,
continues_line: bool,
}
fn lower_no_fill_lines(node: &Node, default_name: Option<&str>) -> Option<Vec<LoweredNoFillLine>> {
if node.flags.no_fill && participates_in_inline_flow(node) {
return Some(vec![LoweredNoFillLine {
nodes: lower_inline_nodes(std::slice::from_ref(node), default_name),
source: source_span(node),
continues_line: ends_with_line_continuation(node),
}]);
}
let body = first_part_children(node, NodeKind::Body);
if node.macro_name.as_deref() != Some("SY") || !body.iter().any(|child| child.flags.no_fill) {
return None;
}
let mut lines = Vec::new();
let head = lower_inline_nodes(first_part_children(node, NodeKind::Head), default_name);
if !head.is_empty() {
lines.push(LoweredNoFillLine {
nodes: vec![Inline::Strong { children: head }],
source: source_span(node),
continues_line: first_part_children(node, NodeKind::Head)
.last()
.is_some_and(ends_with_line_continuation),
});
}
for child in body {
let line = lower_inline_nodes(std::slice::from_ref(child), default_name);
if !line.is_empty() {
lines.push(LoweredNoFillLine {
nodes: line,
source: source_span(child),
continues_line: ends_with_line_continuation(child),
});
}
}
Some(lines)
}
struct StructuralLowerer<'a, 'source, 'state> {
context: &'a LoweringContext<'source>,
indent_columns: u16,
paragraph_distance: &'state mut u16,
output: &'state mut Vec<Block>,
definition_hanging_width: &'state mut usize,
spacing_enabled: bool,
}
impl StructuralLowerer<'_, '_, '_> {
fn push(&mut self, node: &Node, table_embedding: Option<&TableEmbedding<'_>>) {
if self.lower_transparent_container(node) {
return;
}
match node.macro_name.as_deref() {
Some("TP" | "IP" | "TQ") => {
lower_man_definition(
node,
self.context,
self.indent_columns,
self.paragraph_distance,
self.output,
self.definition_hanging_width,
self.spacing_enabled,
);
}
Some("Bl") => {
let mut block = lower_mdoc_list(
node,
self.context,
self.indent_columns,
self.paragraph_distance,
self.spacing_enabled,
);
if !self.output.is_empty() && !node.compact {
set_block_spacing(&mut block, 1);
}
self.output.push(block);
}
Some("Bd" | "D1" | "Dl") => {
let mut nested = preformatted_blocks(
node,
self.context,
self.indent_columns + display_indent(node),
);
if node.macro_name.as_deref() == Some("Bd")
&& !self.output.is_empty()
&& !node.compact
{
add_leading_spacing(&mut nested, 1);
}
self.output.extend(nested);
}
Some("Rs") => {
let mut children = lower_inline_nodes_with_spacing(
first_part_children(node, NodeKind::Body),
self.context.default_name,
self.spacing_enabled,
);
if !children.is_empty() {
append_bibliography_period(&mut children);
self.output.push(Block::Paragraph {
children,
layout: layout_with_spacing(
self.indent_columns,
u16::from(!self.output.is_empty()),
),
source: source_span(node),
});
}
}
Some("SY" | "Nm") => lower_synopsis_head(
self.output,
node,
self.context,
self.indent_columns,
self.paragraph_distance,
self.spacing_enabled,
),
Some("Fo") => lower_mdoc_function(
self.output,
node,
self.context,
self.indent_columns,
self.spacing_enabled,
),
_ if node.kind == NodeKind::Table => append_table_row(
self.output,
node,
self.context,
self.indent_columns,
table_embedding,
),
_ if node.kind == NodeKind::Equation => {
self.output.push(equation_block(node, self.indent_columns));
}
_ => lower_structural_fallback(
self.output,
node,
self.context,
self.indent_columns,
self.paragraph_distance,
self.spacing_enabled,
),
}
}
fn lower_transparent_container(&mut self, node: &Node) -> bool {
match node.macro_name.as_deref() {
Some("PP" | "P" | "LP" | "HP") => {
let spacing_before = if self.output.is_empty() {
0
} else {
*self.paragraph_distance
};
let nested = lower_blocks_with_spacing(
first_part_children(node, NodeKind::Body),
self.context,
self.indent_columns,
self.paragraph_distance,
self.spacing_enabled,
);
extend_blocks_with_spacing(self.output, nested, spacing_before);
}
Some("Bf") => {
let mut nested = lower_blocks_with_spacing(
first_part_children(node, NodeKind::Body),
self.context,
self.indent_columns,
self.paragraph_distance,
self.spacing_enabled,
);
if let Some(font) = node.font {
apply_normalized_font(&mut nested, font);
}
extend_transparent_blocks(self.output, nested, *self.paragraph_distance);
}
Some("Bd") if node.display_kind == Some(DisplayKind::Filled) => {
let spacing_before = u16::from(!self.output.is_empty() && !node.compact);
let nested = lower_blocks_with_spacing(
first_part_children(node, NodeKind::Body),
self.context,
self.indent_columns + display_indent(node),
self.paragraph_distance,
self.spacing_enabled,
);
extend_blocks_with_spacing(self.output, nested, spacing_before);
}
Some("RS") => {
let nested = lower_blocks_with_spacing(
first_part_children(node, NodeKind::Body),
self.context,
self.indent_columns + 4,
self.paragraph_distance,
self.spacing_enabled,
);
extend_transparent_blocks(self.output, nested, *self.paragraph_distance);
}
_ => return false,
}
true
}
}
fn append_bibliography_period(children: &mut Vec<Inline>) {
let text = plain_text(children);
if !text.trim_end().ends_with(['.', '!', '?']) {
children.push(Inline::Text { value: ".".into() });
}
}
fn lower_structural_fallback(
output: &mut Vec<Block>,
node: &Node,
context: &LoweringContext<'_>,
indent_columns: u16,
paragraph_distance: &mut u16,
spacing_enabled: bool,
) {
let heads = part_child_groups(node, NodeKind::Head).collect::<Vec<_>>();
let bodies = part_child_groups(node, NodeKind::Body).collect::<Vec<_>>();
let tails = part_child_groups(node, NodeKind::Tail).collect::<Vec<_>>();
if heads
.iter()
.chain(&tails)
.any(|part| parts_have_visible_text(part, context.default_name))
|| bodies.len() > 1
{
context.warn_unhandled_structural_parts(node);
}
if bodies.is_empty() {
output.extend(lower_blocks_with_spacing(
node.children.as_slice(),
context,
indent_columns,
paragraph_distance,
spacing_enabled,
));
} else {
for body in bodies {
output.extend(lower_blocks_with_spacing(
body,
context,
indent_columns,
paragraph_distance,
spacing_enabled,
));
}
}
}
fn parts_have_visible_text(nodes: &[Node], default_name: Option<&str>) -> bool {
!plain_text(&lower_inline_nodes(nodes, default_name))
.trim()
.is_empty()
}
fn lower_mdoc_function(
output: &mut Vec<Block>,
node: &Node,
context: &LoweringContext<'_>,
indent_columns: u16,
spacing_enabled: bool,
) {
let children = lower_inline_nodes_with_spacing(
std::slice::from_ref(node),
context.default_name,
spacing_enabled,
);
if children.is_empty() {
return;
}
output.push(Block::Paragraph {
children,
layout: layout(indent_columns),
source: source_span(node),
});
}
fn equation_block(node: &Node, indent_columns: u16) -> Block {
Block::Equation {
value: visible_text(node.equation.as_deref().unwrap_or_default()),
display: true,
layout: layout(indent_columns),
source: source_span(node),
}
}
fn is_inline_equation(node: &Node) -> bool {
node.kind == NodeKind::Equation
&& !node.flags.line_start
&& node
.equation
.as_deref()
.is_some_and(|value| !value.trim().is_empty())
}
fn is_inline_equation_quote_artifact(nodes: &[Node], index: usize) -> bool {
let Some(node) = nodes.get(index) else {
return false;
};
let Some(previous) = index.checked_sub(1).and_then(|index| nodes.get(index)) else {
return false;
};
is_inline_equation(previous)
&& previous.line == node.line
&& node.kind == NodeKind::Text
&& node
.text
.as_deref()
.is_some_and(|text| visible_text(text).trim() == "\"")
}
fn follows_inline_equation_punctuation(nodes: &[Node], index: usize) -> bool {
let Some(node) = nodes.get(index) else {
return false;
};
let Some(previous) = index.checked_sub(1).and_then(|index| nodes.get(index)) else {
return false;
};
is_inline_equation(previous)
&& previous.line == node.line
&& node.kind == NodeKind::Text
&& node
.text
.as_deref()
.map(visible_text)
.and_then(|text| text.chars().next())
.is_some_and(|character| matches!(character, '.' | ',' | ':' | ';' | '!' | '?'))
}
fn lower_synopsis_head(
output: &mut Vec<Block>,
node: &Node,
context: &LoweringContext<'_>,
indent_columns: u16,
paragraph_distance: &mut u16,
spacing_enabled: bool,
) {
let head = lower_inline_nodes_with_spacing(
first_part_children(node, NodeKind::Head),
context.default_name,
spacing_enabled,
);
let mut nested = lower_blocks_with_spacing(
first_part_children(node, NodeKind::Body),
context,
indent_columns,
paragraph_distance,
spacing_enabled,
);
if head.is_empty() {
output.extend(nested);
return;
}
let head = vec![Inline::Strong { children: head }];
if let Some(Block::Paragraph {
children, source, ..
}) = nested.first_mut()
{
let body = std::mem::take(children);
let mut synopsis = InlineBuilder::with_spacing(spacing_enabled);
synopsis.append(head);
synopsis.append(body);
*children = synopsis.finish();
*source = source_span(node);
output.extend(nested);
return;
}
output.push(Block::Paragraph {
children: head,
layout: layout(indent_columns),
source: source_span(node),
});
output.extend(nested);
}
fn apply_normalized_font(blocks: &mut [Block], font: NormalizedFont) {
for block in blocks {
match block {
Block::Paragraph { children, .. } | Block::Preformatted { children, .. } => {
let content = std::mem::take(children);
if content.is_empty() {
continue;
}
*children = style_preformatted_inlines(content, font);
}
Block::List { items, .. } => {
for item in items {
apply_normalized_font(&mut item.blocks, font);
}
}
Block::DefinitionList { items, .. } => {
for item in items {
for term in &mut item.terms {
let content = std::mem::take(term);
if !content.is_empty() {
*term = style_preformatted_inlines(content, font);
}
}
apply_normalized_font(&mut item.description, font);
}
}
Block::Table { rows, .. } => {
for cell in rows.iter_mut().flat_map(|row| &mut row.cells) {
apply_normalized_font(&mut cell.blocks, font);
}
}
Block::Equation { .. }
| Block::VerticalSpace { .. }
| Block::ThematicBreak { .. }
| Block::Unsupported { .. } => {}
}
}
}
fn append_to_last_inline_block(blocks: &mut [Block], tail: &[Inline]) -> bool {
for block in blocks.iter_mut().rev() {
match block {
Block::Paragraph { children, .. } | Block::Preformatted { children, .. } => {
children.extend_from_slice(tail);
return true;
}
Block::List { items, .. } => {
if items
.last_mut()
.is_some_and(|item| append_to_last_inline_block(&mut item.blocks, tail))
{
return true;
}
}
Block::DefinitionList { items, .. } => {
if items.last_mut().is_some_and(|item| {
append_to_last_inline_block(&mut item.description, tail)
|| item.terms.last_mut().is_some_and(|term| {
term.extend_from_slice(tail);
true
})
}) {
return true;
}
}
Block::Table { rows, .. } => {
if rows
.last_mut()
.and_then(|row| row.cells.last_mut())
.is_some_and(|cell| append_to_last_inline_block(&mut cell.blocks, tail))
{
return true;
}
}
Block::Equation { .. }
| Block::VerticalSpace { .. }
| Block::ThematicBreak { .. }
| Block::Unsupported { .. } => {}
}
}
false
}
fn lower_man_definition(
node: &Node,
context: &LoweringContext<'_>,
indent_columns: u16,
paragraph_distance: &mut u16,
output: &mut Vec<Block>,
definition_hanging_width: &mut usize,
spacing_enabled: bool,
) {
let spacing_before = if node.macro_name.as_deref() == Some("TQ") {
0
} else {
*paragraph_distance
};
update_man_definition_width(node, definition_hanging_width);
let max_width = definition_hanging_width.saturating_sub(1);
let item = definition_item(
node,
context,
indent_columns,
paragraph_distance,
max_width,
spacing_enabled,
);
if node.macro_name.as_deref() == Some("IP") && is_ip_bullet_item(&item) {
append_ip_bullet(
output,
item,
indent_columns,
spacing_before,
source_span(node),
);
} else {
append_definition(
output,
item,
indent_columns,
spacing_before,
source_span(node),
max_width,
);
}
}
fn append_table_row(
output: &mut Vec<Block>,
node: &Node,
context: &LoweringContext<'_>,
indent_columns: u16,
embedding: Option<&TableEmbedding<'_>>,
) {
if node.table_cells.is_empty() {
return;
}
let mut text_block_index = 0;
let source_cells = context.tab_separated_table_cells(node.line);
let cell_count = node
.table_cells
.len()
.max(source_cells.as_ref().map_or(0, Vec::len));
let row = TableRow {
cells: (0..cell_count)
.map(|index| {
let cell = node.table_cells.get(index);
let vertical_continuation = cell.is_some_and(|cell| cell.vertical_continuation);
let text_block = if cell.is_some_and(|cell| cell.text_block) {
let block =
embedding.and_then(|embedding| embedding.blocks.get(text_block_index));
text_block_index += 1;
block
} else {
None
};
let raw_source = source_cells
.as_ref()
.and_then(|cells| cells.get(index))
.copied();
let blocks = if vertical_continuation {
Vec::new()
} else {
let children = cell.map_or_else(
|| lower_missing_table_cell(raw_source, node, context),
|cell| {
let lowered = lower_table_cell(
cell,
node,
context,
text_block,
embedding.map_or(&[], |embedding| embedding.nodes.as_slice()),
);
if lowered.is_empty()
&& raw_source.is_some_and(|source| !source.is_empty())
{
lower_missing_table_cell(raw_source, node, context)
} else {
lowered
}
},
);
vec![Block::Paragraph {
children,
layout: LayoutHint::default(),
source: source_span(node),
}]
};
AstTableCell {
blocks,
column_span: cell.map_or(1, |cell| cell.column_span),
row_span: cell.map_or(1, |cell| cell.row_span),
alignment: Some(match cell.map(|cell| cell.alignment) {
None | Some(MandocTableAlignment::Left) => AstTableAlignment::Left,
Some(MandocTableAlignment::Center) => AstTableAlignment::Center,
Some(MandocTableAlignment::Right) => AstTableAlignment::Right,
}),
}
})
.collect(),
};
if let Some(Block::Table { rows, .. }) = output.last_mut() {
rows.push(row);
} else {
output.push(Block::Table {
rows: vec![row],
layout: layout(indent_columns),
source: source_span(node),
});
}
}
fn lower_missing_table_cell(
source: Option<&str>,
node: &Node,
context: &LoweringContext<'_>,
) -> Vec<Inline> {
let source = source.unwrap_or_default().trim();
if source.is_empty() {
return Vec::new();
}
let lowered = lower_table_cell_text(source, node.line, context);
if !lowered.is_empty() {
return lowered;
}
context.warn_unexpanded_table_cell(node.line);
vec![Inline::Code {
value: source.to_owned(),
}]
}
fn lower_table_cell(
cell: &libmandoc_rs::TableCell,
node: &Node,
context: &LoweringContext<'_>,
text_block: Option<&TableTextBlock>,
semantic_nodes: &[&Node],
) -> Vec<Inline> {
if let Some(text_block) = text_block {
let semantic_nodes = semantic_nodes
.iter()
.copied()
.filter(|candidate| text_block.contains_line(candidate.line))
.collect::<Vec<_>>();
let reconstructed = lower_table_text_block(text_block, &semantic_nodes, context);
if !reconstructed.is_empty() {
return reconstructed;
}
}
if cell.text.as_deref().is_some_and(|text| !text.is_empty()) {
return lower_table_cell_text(cell.text.as_deref().unwrap_or_default(), node.line, context);
}
if !cell.text_block {
return Vec::new();
}
let request = text_block.and_then(|block| {
block
.source
.lines()
.map(str::trim)
.find(|line| !line.is_empty())
});
let name = request
.and_then(|line| {
line.strip_prefix(".Nm")
.or_else(|| line.strip_prefix("'Nm"))
})
.filter(|rest| rest.is_empty() || rest.starts_with(char::is_whitespace))
.map(str::trim)
.and_then(|argument| {
if argument.is_empty() {
context.default_name.map(|name| {
vec![Inline::Text {
value: name.to_owned(),
}]
})
} else {
Some(parse_roff_text(argument))
}
});
if let Some(children) = name.filter(|children| !children.is_empty()) {
return vec![Inline::Strong { children }];
}
context.warn_unhandled_table_text_block(node);
Vec::new()
}
fn lower_table_cell_text(source: &str, line: u32, context: &LoweringContext<'_>) -> Vec<Inline> {
let Some((opening, closing)) = context.equation_delimiters_at(line) else {
return parse_roff_text(source);
};
let mut output = Vec::new();
let mut remainder = source;
while let Some(opening_index) = remainder.find(opening) {
let after_opening = &remainder[opening_index + opening.len_utf8()..];
let Some(closing_index) = after_opening.find(closing) else {
break;
};
output.extend(parse_roff_text(&remainder[..opening_index]));
let expression = &after_opening[..closing_index];
if !expression.trim().is_empty() {
output.push(Inline::Code {
value: context.normalize_equation(expression, line),
});
}
remainder = &after_opening[closing_index + closing.len_utf8()..];
}
output.extend(parse_roff_text(remainder));
output
}
fn lower_table_text_block(
block: &TableTextBlock,
semantic_nodes: &[&Node],
context: &LoweringContext<'_>,
) -> Vec<Inline> {
let mut builder = InlineBuilder::new();
for (offset, source_line) in block.source.lines().enumerate() {
let line = block
.start_line
.saturating_add(u32::try_from(offset).unwrap_or(u32::MAX));
let nodes = semantic_nodes
.iter()
.copied()
.filter(|node| node.line == line)
.collect::<Vec<_>>();
if !nodes.is_empty() {
if let Some(inline) = source_table_inline(source_line.trim(), context.default_name) {
builder.append_filled(inline, FilledBoundary::Word);
continue;
}
for node in nodes {
let spacing_enabled = builder.spacing_enabled();
let lowered = if matches!(node.macro_name.as_deref(), Some("UR" | "MT")) {
lower_man_link(node, context.default_name, spacing_enabled)
} else {
lower_inline_nodes_with_spacing(
std::slice::from_ref(node),
context.default_name,
spacing_enabled,
)
};
builder.append_filled(lowered, FilledBoundary::Word);
}
continue;
}
let source_line = source_line.trim();
if source_line.is_empty() {
continue;
}
if let Some(inline) = source_table_inline(source_line, context.default_name) {
builder.append_filled(inline, FilledBoundary::Word);
} else if source_line.starts_with('.') || source_line.starts_with('\'') {
context.warn_unhandled_table_text_block_line(line);
} else {
builder.append_filled(parse_roff_text(source_line), FilledBoundary::Word);
}
}
builder.finish()
}
fn source_table_inline(source_line: &str, default_name: Option<&str>) -> Option<Vec<Inline>> {
let request = source_line.strip_prefix(['.', '\''])?;
let (name, rest) = request
.split_once(char::is_whitespace)
.unwrap_or((request, ""));
let argument = rest.trim();
if matches!(name, "BI" | "BR" | "IB" | "IR" | "RB" | "RI") {
return lower_source_alternating_fonts(name, argument);
}
lower_source_mdoc_request(name, argument, default_name)
}
struct BlockState {
output: Vec<Block>,
paragraph: InlineBuilder,
paragraph_source: Option<mant_ir::SourceSpan>,
paragraph_last_line: Option<u32>,
preformatted: Vec<Inline>,
pre_source: Option<mant_ir::SourceSpan>,
preformatted_last_line: Option<u32>,
preformatted_tight_boundary: bool,
indent_columns: u16,
spacing_enabled: bool,
}
impl BlockState {
const fn new(indent_columns: u16, spacing_enabled: bool) -> Self {
Self {
output: Vec::new(),
paragraph: InlineBuilder::with_spacing(spacing_enabled),
paragraph_source: None,
paragraph_last_line: None,
preformatted: Vec::new(),
pre_source: None,
preformatted_last_line: None,
preformatted_tight_boundary: false,
indent_columns,
spacing_enabled,
}
}
const fn spacing_enabled(&self) -> bool {
self.spacing_enabled
}
fn set_spacing(&mut self, setting: &str) {
self.paragraph.set_spacing(setting);
self.spacing_enabled = updated_spacing(self.spacing_enabled, setting);
}
fn inherit_spacing(&mut self, spacing_enabled: bool) {
if spacing_enabled == self.spacing_enabled {
return;
}
self.paragraph.inherit_spacing(spacing_enabled);
self.spacing_enabled = spacing_enabled;
}
fn push_inline(
&mut self,
nodes: Vec<Inline>,
source: Option<mant_ir::SourceSpan>,
starts_indented_line: bool,
continues_line: bool,
) {
if nodes.is_empty() {
if continues_line {
self.paragraph.tighten_next_boundary();
}
return;
}
if self.paragraph_source.is_none() {
self.paragraph_source = source;
}
let source_line = source.map(|span| span.line);
let crossed_source_line = self
.paragraph_last_line
.zip(source_line)
.is_some_and(|(previous, current)| current > previous);
let boundary = if self.paragraph.has_tight_boundary() || !crossed_source_line {
FilledBoundary::SameLine
} else if starts_indented_line {
FilledBoundary::LineBreak
} else {
FilledBoundary::Word
};
self.paragraph.append_filled(nodes, boundary);
if continues_line {
self.paragraph.tighten_next_boundary();
}
if source_line.is_some() {
self.paragraph_last_line = source_line;
}
}
fn hard_break(&mut self) {
self.paragraph.hard_break();
}
fn tighten_next_boundary(&mut self) {
self.paragraph.tighten_next_boundary();
}
fn push_preformatted(
&mut self,
nodes: Vec<Inline>,
source: Option<mant_ir::SourceSpan>,
continues_line: bool,
context: &LoweringContext<'_>,
) {
self.flush_paragraph();
if nodes.is_empty() {
if continues_line {
self.preformatted_tight_boundary = true;
}
return;
}
if !self.preformatted.is_empty() && !self.preformatted_tight_boundary {
self.preformatted.push(Inline::LineBreak);
let blank_rows = context.no_fill_blank_rows_between(
self.preformatted_last_line,
source.map(|span| span.line),
);
self.preformatted.extend(std::iter::repeat_n(
Inline::LineBreak,
usize::from(blank_rows),
));
}
self.preformatted.extend(nodes);
self.preformatted_tight_boundary = continues_line;
if let Some(source_line) = source.map(|span| span.line) {
self.preformatted_last_line = Some(source_line);
}
if self.pre_source.is_none() {
self.pre_source = source;
}
}
fn flush_paragraph(&mut self) {
flush_paragraph(
&mut self.output,
&mut self.paragraph,
&mut self.paragraph_source,
self.indent_columns,
self.spacing_enabled,
);
self.paragraph_last_line = None;
}
fn flush_preformatted(&mut self) {
flush_preformatted(
&mut self.output,
&mut self.preformatted,
&mut self.pre_source,
self.indent_columns,
);
self.preformatted_last_line = None;
self.preformatted_tight_boundary = false;
}
fn finish(mut self) -> Vec<Block> {
self.flush_preformatted();
self.flush_paragraph();
normalize_explicit_vertical_spacing(&mut self.output);
self.output
}
}
fn starts_indented_filled_line(node: &Node) -> bool {
if node.flags.no_print || node.kind == NodeKind::Comment {
return false;
}
if node.kind == NodeKind::Text {
return node.flags.line_start
&& node
.text
.as_deref()
.is_some_and(|text| text.starts_with(char::is_whitespace));
}
node.children
.iter()
.find(|child| !child.flags.no_print && child.kind != NodeKind::Comment)
.is_some_and(starts_indented_filled_line)
}
fn ends_with_line_continuation(node: &Node) -> bool {
if node.flags.no_print || node.kind == NodeKind::Comment {
return false;
}
if node.kind == NodeKind::Text {
return node.flags.line_continuation;
}
node.children
.iter()
.rev()
.find(|child| !child.flags.no_print && child.kind != NodeKind::Comment)
.is_some_and(ends_with_line_continuation)
}
fn lower_mdoc_list(
node: &Node,
context: &LoweringContext<'_>,
indent_columns: u16,
paragraph_distance: &mut u16,
initial_spacing: bool,
) -> Block {
let items = mdoc_list_items(node, initial_spacing, context.default_name);
let is_definition = matches!(
node.list_kind,
Some(NormalizedListKind::Definition | NormalizedListKind::Column)
) || (node.list_kind.is_none()
&& items
.iter()
.any(|item| !first_part_children(item.node, NodeKind::Head).is_empty()));
let list_indent = indent_columns + display_indent(node);
if node.list_kind == Some(NormalizedListKind::Column) {
return lower_mdoc_column_list(
node,
items,
context,
indent_columns,
list_indent,
paragraph_distance,
);
}
if is_definition {
let max_term_width = node
.width
.as_deref()
.and_then(horizontal_distance_columns)
.unwrap_or(6);
Block::DefinitionList {
items: items
.into_iter()
.map(|item| {
definition_item(
item.node,
context,
list_indent,
paragraph_distance,
max_term_width,
item.spacing_enabled,
)
})
.collect(),
compact: node.compact,
layout: layout(indent_columns),
source: source_span(node),
}
} else {
Block::List {
kind: match node.list_kind {
Some(NormalizedListKind::Ordered) => ListKind::Ordered,
Some(NormalizedListKind::Plain) => ListKind::Plain,
_ => ListKind::Bullet,
},
start: (node.list_kind == Some(NormalizedListKind::Ordered)).then_some(1),
compact: node.compact,
items: items
.into_iter()
.map(|item| ListItem {
blocks: lower_blocks_with_spacing(
first_part_children(item.node, NodeKind::Body),
context,
list_indent,
paragraph_distance,
spacing_after_nodes(
first_part_children(item.node, NodeKind::Head),
item.spacing_enabled,
context.default_name,
),
),
})
.collect(),
layout: layout(indent_columns),
source: source_span(node),
}
}
}
#[derive(Clone, Copy)]
struct MdocListItem<'a> {
node: &'a Node,
spacing_enabled: bool,
}
fn mdoc_list_items<'a>(
node: &'a Node,
initial_spacing: bool,
default_name: Option<&str>,
) -> Vec<MdocListItem<'a>> {
let mut spacing_enabled = initial_spacing;
let mut items = Vec::new();
for child in first_part_children(node, NodeKind::Body) {
if child.macro_name.as_deref() == Some("It") {
items.push(MdocListItem {
node: child,
spacing_enabled,
});
}
spacing_enabled = spacing_after_node(child, spacing_enabled, default_name);
}
items
}
fn lower_mdoc_column_list(
node: &Node,
items: Vec<MdocListItem<'_>>,
context: &LoweringContext<'_>,
indent_columns: u16,
cell_indent: u16,
paragraph_distance: &mut u16,
) -> Block {
let rows = items
.into_iter()
.map(|item| {
let body_spacing = spacing_after_nodes(
first_part_children(item.node, NodeKind::Head),
item.spacing_enabled,
context.default_name,
);
let mut cells = part_child_groups(item.node, NodeKind::Body)
.map(|body| AstTableCell {
blocks: lower_blocks_with_spacing(
body,
context,
cell_indent,
paragraph_distance,
body_spacing,
),
column_span: 1,
row_span: 1,
alignment: Some(AstTableAlignment::Left),
})
.collect::<Vec<_>>();
if item.node.flags.deep_link_target
&& let Some(id) = item.node.tag.as_deref()
&& let Some(Block::Paragraph { children, .. }) =
cells.first_mut().and_then(|cell| cell.blocks.first_mut())
{
children.insert(0, Inline::Anchor { id: id.into() });
}
TableRow { cells }
})
.filter(|row| !row.cells.is_empty())
.collect();
Block::Table {
rows,
layout: layout(indent_columns),
source: source_span(node),
}
}
fn definition_item(
node: &Node,
context: &LoweringContext<'_>,
indent_columns: u16,
paragraph_distance: &mut u16,
max_term_width: usize,
spacing_enabled: bool,
) -> DefinitionItem {
let head = visible_definition_head(node);
let body = first_part_children(node, NodeKind::Body);
let (displaced_equations, body) = displaced_definition_equations(head, body);
let mut term_builder = InlineBuilder::with_spacing(spacing_enabled);
term_builder.append(lower_inline_nodes_with_spacing(
head,
context.default_name,
spacing_enabled,
));
for equation in displaced_equations {
term_builder.append(lower_inline_nodes_with_spacing(
std::slice::from_ref(equation),
context.default_name,
spacing_enabled,
));
}
let mut term = term_builder.finish();
if let Some(id) = definition_head_anchor(node, &term) {
term.insert(0, Inline::Anchor { id: id.into() });
}
let terms = split_definition_terms(term);
DefinitionItem {
identity: None,
inline_term: terms_fit_inline(&terms, max_term_width),
terms,
description: lower_blocks_with_spacing(
body,
context,
indent_columns + 4,
paragraph_distance,
spacing_after_nodes(head, spacing_enabled, context.default_name),
),
spacing_before_lines: None,
}
}
fn displaced_definition_equations<'a>(
head: &[Node],
body: &'a [Node],
) -> (Vec<&'a Node>, &'a [Node]) {
let Some(head_line) = head.iter().map(maximum_node_line).max() else {
return (Vec::new(), body);
};
let mut equations = Vec::new();
let mut consumed = 0;
while let Some(candidate) = body
.get(consumed)
.filter(|candidate| candidate.line == head_line)
{
if is_inline_equation(candidate) {
equations.push(candidate);
consumed += 1;
continue;
}
if consumed > 0 && is_inline_equation_quote_artifact(body, consumed) {
consumed += 1;
continue;
}
break;
}
if equations.is_empty() {
(equations, body)
} else {
(equations, &body[consumed..])
}
}
fn maximum_node_line(node: &Node) -> u32 {
node.children
.iter()
.map(maximum_node_line)
.fold(node.line, u32::max)
}
fn split_definition_terms(term: Vec<Inline>) -> Vec<Vec<Inline>> {
let mut terms = Vec::new();
let mut current = Vec::new();
for node in term {
if node == Inline::LineBreak {
if !current.is_empty() {
terms.push(std::mem::take(&mut current));
}
} else {
current.push(node);
}
}
if !current.is_empty() {
terms.push(current);
}
terms
}
fn definition_head_anchor(node: &Node, term: &[Inline]) -> Option<String> {
let head = node
.children
.iter()
.find(|child| child.kind == NodeKind::Head)?;
if !head.flags.deep_link_target {
return None;
}
head.tag.as_deref().map(visible_text).or_else(|| {
plain_text(term)
.trim_start_matches('-')
.split_whitespace()
.next()
.map(ToOwned::to_owned)
})
}
fn visible_definition_head(node: &Node) -> &[Node] {
let head = first_part_children(node, NodeKind::Head);
match node.macro_name.as_deref() {
Some("IP") => head.first().map_or(&[], std::slice::from_ref),
Some("TP" | "TQ") => head
.iter()
.position(|child| child.flags.line_start)
.map_or(&[], |visible_start| &head[visible_start..]),
_ => head,
}
}
fn append_definition(
output: &mut Vec<Block>,
mut item: DefinitionItem,
indent_columns: u16,
paragraph_distance: u16,
source: Option<mant_ir::SourceSpan>,
max_term_width: usize,
) {
if let Some(Block::DefinitionList { items, compact, .. }) = output
.last_mut()
.filter(|block| block_indent(block) == Some(indent_columns))
{
if !item.description.is_empty() {
let first_pending = items
.iter()
.rposition(|previous| !previous.description.is_empty())
.map_or(0, |index| index + 1);
for pending in items.drain(first_pending..) {
item.terms.splice(0..0, pending.terms);
}
item.inline_term = terms_fit_inline(&item.terms, max_term_width);
}
item.spacing_before_lines = Some(if items.is_empty() {
0
} else {
paragraph_distance
});
*compact = *compact && paragraph_distance == 0;
items.push(item);
} else {
item.spacing_before_lines = Some(0);
let spacing_before_lines = if output.is_empty() {
0
} else {
paragraph_distance
};
output.push(Block::DefinitionList {
items: vec![item],
compact: paragraph_distance == 0,
layout: layout_with_spacing(indent_columns, spacing_before_lines),
source,
});
}
}
fn update_man_definition_width(node: &Node, current_width: &mut usize) {
let head = first_part_children(node, NodeKind::Head);
let argument = match node.macro_name.as_deref() {
Some("TP" | "TQ") => head
.iter()
.find(|child| !child.flags.line_start)
.and_then(first_node_text),
Some("IP") => head.get(1).and_then(first_node_text),
_ => None,
};
if let Some(width) = argument.and_then(horizontal_distance_columns) {
*current_width = width;
}
}
fn first_node_text(node: &Node) -> Option<&str> {
node.text
.as_deref()
.or_else(|| node.children.iter().find_map(first_node_text))
}
fn append_ip_bullet(
output: &mut Vec<Block>,
item: DefinitionItem,
indent_columns: u16,
paragraph_distance: u16,
source: Option<mant_ir::SourceSpan>,
) {
let list_item = ListItem {
blocks: item.description,
};
if let Some(Block::List {
kind: ListKind::Bullet,
compact,
items,
..
}) = output
.last_mut()
.filter(|block| block_indent(block) == Some(indent_columns))
{
*compact = *compact && paragraph_distance == 0;
items.push(list_item);
return;
}
let spacing_before_lines = if output.is_empty() {
0
} else {
paragraph_distance
};
output.push(Block::List {
kind: ListKind::Bullet,
start: None,
compact: paragraph_distance == 0,
items: vec![list_item],
layout: layout_with_spacing(indent_columns, spacing_before_lines),
source,
});
}
fn is_ip_bullet_item(item: &DefinitionItem) -> bool {
let [term] = item.terms.as_slice() else {
return false;
};
is_bullet_glyph(plain_text(term).trim())
}
fn extend_transparent_blocks(
output: &mut Vec<Block>,
mut nested: Vec<Block>,
paragraph_distance: u16,
) {
let boundary_spacing = if output.is_empty()
|| output
.last()
.is_some_and(|block| matches!(block, Block::VerticalSpace { .. }))
{
0
} else {
paragraph_distance
};
add_leading_spacing(&mut nested, boundary_spacing);
let merged_first = match (output.last_mut(), nested.first_mut()) {
(
Some(Block::DefinitionList {
items: previous_items,
compact: previous_compact,
layout: previous_layout,
..
}),
Some(Block::DefinitionList {
items: nested_items,
compact: nested_compact,
layout: nested_layout,
..
}),
) if previous_layout.indent_columns == nested_layout.indent_columns => {
if let Some(first) = nested_items.first_mut() {
first.spacing_before_lines = Some(if previous_items.is_empty() {
0
} else {
nested_layout.spacing_before_lines
});
}
previous_items.append(nested_items);
*previous_compact = *previous_compact && *nested_compact && paragraph_distance == 0;
true
}
_ => false,
};
if merged_first {
nested.remove(0);
}
output.extend(nested);
}
fn extend_blocks_with_spacing(output: &mut Vec<Block>, mut nested: Vec<Block>, lines: u16) {
add_leading_spacing(&mut nested, lines);
output.extend(nested);
}
fn preformatted_blocks(
node: &Node,
context: &LoweringContext<'_>,
indent_columns: u16,
) -> Vec<Block> {
let body_index = node
.children
.iter()
.position(|child| child.kind == NodeKind::Body);
let children = body_index.map_or_else(
|| node.children.as_slice(),
|index| node.children[index].children.as_slice(),
);
let (table_embeddings, embedded_nodes) = table_embeddings(children, context);
let mut output = Vec::new();
let mut inline_run = Vec::new();
for (index, child) in children.iter().enumerate() {
if embedded_nodes[index] {
continue;
}
if child.kind == NodeKind::Table {
push_preformatted_inline_run(&mut output, &mut inline_run, context, indent_columns);
append_table_row(
&mut output,
child,
context,
indent_columns,
table_embeddings[index].as_ref(),
);
} else {
inline_run.push(child);
}
}
let mut inlines = preformatted_inlines_refs(&inline_run, context);
if let Some(body_index) = body_index {
let tail = &node.children[body_index + 1..];
let tail_len = tail
.iter()
.take_while(|child| child.line == node.line && participates_in_inline_flow(child))
.count();
if tail
.first()
.is_some_and(|child| child.flags.delimiter_close)
{
inlines.extend(lower_inline_nodes(&tail[..tail_len], context.default_name));
}
}
if !inlines.is_empty() {
output.push(Block::Preformatted {
children: inlines,
language: None,
layout: layout(indent_columns),
source: source_span(node),
});
}
output
}
fn push_preformatted_inline_run(
output: &mut Vec<Block>,
nodes: &mut Vec<&Node>,
context: &LoweringContext<'_>,
indent_columns: u16,
) {
if nodes.is_empty() {
return;
}
let children = preformatted_inlines_refs(nodes, context);
let source = nodes.first().and_then(|node| source_span(node));
nodes.clear();
if !children.is_empty() {
output.push(Block::Preformatted {
children,
language: None,
layout: layout(indent_columns),
source,
});
}
}
fn preformatted_inlines(nodes: &[Node], context: &LoweringContext<'_>) -> Vec<Inline> {
let nodes = nodes.iter().collect::<Vec<_>>();
preformatted_inlines_refs(&nodes, context)
}
fn preformatted_inlines_refs(nodes: &[&Node], context: &LoweringContext<'_>) -> Vec<Inline> {
let mut output = Vec::new();
let mut line = InlineBuilder::new();
let mut previous_visible_line = None;
for node in nodes {
if node.kind == NodeKind::Comment || node.flags.no_print {
continue;
}
if node.kind == NodeKind::Text && node.text.as_deref().is_some_and(str::is_empty) {
continue;
}
if previous_visible_line.is_some_and(|previous| node.line > previous) {
output.extend(std::mem::replace(&mut line, InlineBuilder::new()).finish());
}
if let Some(previous) = previous_visible_line.filter(|previous| node.line > *previous)
&& !output.is_empty()
{
output.push(Inline::LineBreak);
let extra_rows = context.no_fill_blank_rows_between(Some(previous), Some(node.line));
output.extend(std::iter::repeat_n(
Inline::LineBreak,
usize::from(extra_rows),
));
}
if node.macro_name.as_deref() == Some("Bf") {
let body = first_part_children(node, NodeKind::Body)
.iter()
.collect::<Vec<_>>();
let nested = preformatted_inlines_refs(&body, context);
line.append(if let Some(font) = node.font {
style_preformatted_inlines(nested, font)
} else {
nested
});
} else if node.kind == NodeKind::Block
&& matches!(node.macro_name.as_deref(), Some("Bd" | "D1" | "Dl"))
{
let body = first_part_children(node, NodeKind::Body)
.iter()
.collect::<Vec<_>>();
line.append(preformatted_inlines_refs(&body, context));
} else if node.kind == NodeKind::Text || node.macro_name.is_some() {
append_inline_node(&mut line, node, context.default_name);
} else {
line.append(preformatted_inlines(&node.children, context));
}
previous_visible_line = Some(node.line);
}
output.extend(line.finish());
output
}
fn style_preformatted_inlines(nodes: Vec<Inline>, font: NormalizedFont) -> Vec<Inline> {
let mut output = Vec::new();
let mut line = Vec::new();
for node in nodes {
if node == Inline::LineBreak {
append_styled_preformatted_line(&mut output, &mut line, font);
output.push(Inline::LineBreak);
} else {
line.push(node);
}
}
append_styled_preformatted_line(&mut output, &mut line, font);
output
}
fn append_styled_preformatted_line(
output: &mut Vec<Inline>,
line: &mut Vec<Inline>,
font: NormalizedFont,
) {
let content = std::mem::take(line);
if content.is_empty() {
return;
}
output.push(match font {
NormalizedFont::Emphasis => Inline::Emphasis { children: content },
NormalizedFont::Literal => Inline::Code {
value: plain_text(&content),
},
NormalizedFont::Symbolic => Inline::Strong { children: content },
});
}
fn flush_paragraph(
output: &mut Vec<Block>,
paragraph: &mut InlineBuilder,
source: &mut Option<mant_ir::SourceSpan>,
indent_columns: u16,
spacing_enabled: bool,
) {
let current =
std::mem::replace(paragraph, InlineBuilder::with_spacing(spacing_enabled)).finish();
if current.is_empty() {
*source = None;
} else {
output.push(Block::Paragraph {
children: current,
layout: layout(indent_columns),
source: source.take(),
});
}
}
fn flush_preformatted(
output: &mut Vec<Block>,
preformatted: &mut Vec<Inline>,
source: &mut Option<mant_ir::SourceSpan>,
indent_columns: u16,
) {
if preformatted.is_empty() {
*source = None;
return;
}
output.push(Block::Preformatted {
children: std::mem::take(preformatted),
language: None,
layout: layout(indent_columns),
source: source.take(),
});
}
fn participates_in_inline_flow(node: &Node) -> bool {
matches!(node.kind, NodeKind::Text | NodeKind::Element)
|| is_inline_equation(node)
|| is_enclosure_macro(node.macro_name.as_deref())
|| node.macro_name.as_deref() == Some("Nd")
}
fn is_nonprinting_request(node: &Node) -> bool {
matches!(
node.macro_name.as_deref(),
Some("ad" | "fi" | "ft" | "hy" | "in" | "na" | "ne" | "nf" | "nh" | "nr" | "ta" | "ti")
)
}
#[cfg(test)]
mod tests {
use mant_ir::{Block, DefinitionItem, Inline, LayoutHint};
fn text(value: &str) -> Vec<Inline> {
vec![Inline::Text {
value: value.to_owned(),
}]
}
fn definition(term: &str, description: &str) -> DefinitionItem {
DefinitionItem {
identity: None,
inline_term: false,
terms: vec![text(term)],
description: vec![Block::Paragraph {
children: text(description),
layout: LayoutHint::default(),
source: None,
}],
spacing_before_lines: None,
}
}
#[test]
fn only_single_glyph_definition_terms_are_ip_bullets() {
assert!(super::is_ip_bullet_item(&definition("*", "multiply")));
assert!(super::is_ip_bullet_item(&definition("o", "item")));
assert!(!super::is_ip_bullet_item(&definition("&&", "logical and")));
assert!(!super::is_ip_bullet_item(&definition(
"-a, --all",
"show all"
)));
}
#[test]
fn short_terms_hang_inline_but_long_ones_do_not() {
assert!(super::terms_fit_inline(&[text("space")], 6));
assert!(super::terms_fit_inline(&[text("* / %")], 6));
assert!(!super::terms_fit_inline(&[text("--listed-incremental")], 6));
assert!(!super::terms_fit_inline(&[], 6));
}
}