#[cfg(feature = "visitor")]
use crate::converter::utility::content::collect_tag_attributes;
use crate::options::{ConversionOptions, HeadingStyle};
use std::borrow::Cow;
#[allow(unused_imports)]
use std::collections::BTreeMap;
use tl::{NodeHandle, Parser};
type Context = crate::converter::Context;
type DomContext = crate::converter::DomContext;
pub fn handle(
tag_name: &str,
node_handle: &NodeHandle,
parser: &Parser,
output: &mut String,
options: &ConversionOptions,
ctx: &Context,
depth: usize,
dom_ctx: &DomContext,
) {
use crate::converter::walk_node;
let level = tag_name.chars().last().and_then(|c| c.to_digit(10)).unwrap_or(1) as usize;
let needs_leading_sep = !ctx.in_table_cell
&& !ctx.in_list_item
&& !ctx.convert_as_inline
&& ctx.blockquote_depth == 0
&& !output.is_empty()
&& !output.ends_with("\n\n");
if needs_leading_sep {
crate::converter::trim_trailing_whitespace(output);
output.push_str("\n\n");
}
let mut text = String::new();
let heading_ctx = Context {
in_heading: true,
convert_as_inline: true,
heading_allow_inline_images: heading_allows_inline_images(tag_name, &ctx.keep_inline_images_in),
..ctx.clone()
};
if let Some(node) = node_handle.get(parser) {
if let tl::Node::Tag(tag) = node {
let children = tag.children();
for child_handle in children.top().iter() {
walk_node(
child_handle,
parser,
&mut text,
options,
&heading_ctx,
depth + 1,
dom_ctx,
);
}
}
}
let trimmed = text.trim();
if !trimmed.is_empty() {
let normalized = normalize_heading_text(trimmed);
#[cfg(feature = "visitor")]
let heading_output = visitor_heading_output(
node_handle,
parser,
tag_name,
level,
&normalized,
output,
options,
ctx,
depth,
dom_ctx,
);
#[cfg(not(feature = "visitor"))]
let heading_output = {
let mut buf = String::new();
push_heading(&mut buf, ctx, options, level, normalized.as_ref());
Some(buf)
};
if let Some(heading_text) = heading_output {
output.push_str(&heading_text);
}
#[cfg(feature = "metadata")]
if ctx.metadata_wants_headers {
if let Some(ref collector) = ctx.metadata_collector {
if let Some(node) = node_handle.get(parser) {
if let tl::Node::Tag(tag) = node {
let id = tag
.attributes()
.get("id")
.flatten()
.map(|v| v.as_utf8_str().to_string());
collector
.borrow_mut()
.add_header(level as u8, normalized.to_string(), id, depth, 0);
}
}
}
}
if !ctx.in_table_cell {
if let Some(ref sc) = ctx.structure_collector {
if let Some(node) = node_handle.get(parser) {
if let tl::Node::Tag(tag) = node {
let id = tag
.attributes()
.get("id")
.flatten()
.map(|v| v.as_utf8_str().to_string());
sc.borrow_mut()
.push_heading(level as u8, normalized.as_ref(), id.as_deref());
}
}
}
}
}
}
pub fn heading_allows_inline_images(
tag_name: &str,
keep_inline_images_in: &std::rc::Rc<std::collections::HashSet<String>>,
) -> bool {
keep_inline_images_in.contains(tag_name)
}
fn normalize_heading_text(text: &str) -> Cow<'_, str> {
if !text.contains('\n') && !text.contains('\r') {
return Cow::Borrowed(text);
}
let mut normalized = String::with_capacity(text.len());
let mut pending_space = false;
for ch in text.chars() {
match ch {
'\n' | '\r' => {
if !normalized.is_empty() {
pending_space = true;
}
}
' ' | '\t' if pending_space => {}
_ => {
if pending_space {
if !normalized.ends_with(' ') {
normalized.push(' ');
}
pending_space = false;
}
normalized.push(ch);
}
}
}
Cow::Owned(normalized)
}
pub fn push_heading(output: &mut String, ctx: &Context, options: &ConversionOptions, level: usize, text: &str) {
if text.is_empty() {
return;
}
if ctx.convert_as_inline {
output.push_str(text);
return;
}
if ctx.in_table_cell {
let is_table_continuation =
!output.is_empty() && !output.ends_with('|') && !output.ends_with(' ') && !output.ends_with("<br>");
if is_table_continuation {
output.push_str("<br>");
}
output.push_str(text);
return;
}
if ctx.in_list_item {
if output.ends_with('\n') {
if let Some(indent) = continuation_indent_string(ctx.list_depth, options) {
output.push_str(&indent);
}
} else if !output.ends_with(' ') && !output.is_empty() {
output.push(' ');
}
} else if !output.is_empty() && !output.ends_with("\n\n") {
if output.ends_with('\n') {
output.push('\n');
} else {
crate::converter::trim_trailing_whitespace(output);
output.push_str("\n\n");
}
}
let heading_suffix = if ctx.in_list_item || ctx.blockquote_depth > 0 {
"\n"
} else {
"\n\n"
};
match options.heading_style {
HeadingStyle::Underlined => {
if level == 1 {
output.push_str(text);
output.push('\n');
for _ in 0..text.len() {
output.push('=');
}
} else if level == 2 {
output.push_str(text);
output.push('\n');
for _ in 0..text.len() {
output.push('-');
}
} else {
for _ in 0..level {
output.push('#');
}
output.push(' ');
output.push_str(text);
}
}
HeadingStyle::Atx => {
for _ in 0..level {
output.push('#');
}
output.push(' ');
output.push_str(text);
}
HeadingStyle::AtxClosed => {
for _ in 0..level {
output.push('#');
}
output.push(' ');
output.push_str(text);
output.push(' ');
for _ in 0..level {
output.push('#');
}
}
}
output.push_str(heading_suffix);
}
fn continuation_indent_string(list_depth: usize, _options: &ConversionOptions) -> Option<String> {
if list_depth == 0 {
return None;
}
let mut indent = String::new();
for _ in 0..(4 * list_depth) {
indent.push(' ');
}
Some(indent)
}
#[cfg(feature = "visitor")]
fn visitor_heading_output(
node_handle: &NodeHandle,
parser: &Parser,
tag_name: &str,
level: usize,
normalized: &str,
_output: &mut String,
options: &ConversionOptions,
ctx: &Context,
depth: usize,
dom_ctx: &DomContext,
) -> Option<String> {
use crate::visitor::{NodeContext, NodeType, VisitResult};
if let Some(ref visitor_handle) = ctx.visitor {
if let Some(node) = node_handle.get(parser) {
if let tl::Node::Tag(tag) = node {
let id_attr = tag
.attributes()
.get("id")
.flatten()
.map(|v| v.as_utf8_str().to_string());
let attributes: BTreeMap<String, String> = collect_tag_attributes(tag);
let node_id = node_handle.get_inner();
let parent_tag = dom_ctx.parent_tag_name(node_id, parser);
let index_in_parent = dom_ctx.get_sibling_index(node_id).unwrap_or(0);
let node_ctx = NodeContext {
node_type: NodeType::Heading,
tag_name: tag_name.to_string(),
attributes,
depth,
index_in_parent,
parent_tag,
is_inline: false,
};
let visit_result = {
let mut visitor = visitor_handle.borrow_mut();
visitor.visit_heading(&node_ctx, level as u32, normalized, id_attr.as_deref())
};
match visit_result {
VisitResult::Continue => {
let mut buf = String::new();
push_heading(&mut buf, ctx, options, level, normalized);
Some(buf)
}
VisitResult::Custom(custom) => Some(custom),
VisitResult::Skip => None,
VisitResult::Error(err) => {
if ctx.visitor_error.borrow().is_none() {
*ctx.visitor_error.borrow_mut() = Some(err);
}
None
}
VisitResult::PreserveHtml => {
let mut buf = String::new();
push_heading(&mut buf, ctx, options, level, normalized);
Some(buf)
}
}
} else {
None
}
} else {
None
}
} else {
let mut buf = String::new();
push_heading(&mut buf, ctx, options, level, normalized);
Some(buf)
}
}
pub fn find_single_heading_child(node_handle: NodeHandle, parser: &Parser) -> Option<(usize, NodeHandle)> {
let node = node_handle.get(parser)?;
let tl::Node::Tag(tag) = node else {
return None;
};
let children = tag.children();
let mut heading_data: Option<(usize, NodeHandle)> = None;
for child_handle in children.top().iter() {
let Some(child_node) = child_handle.get(parser) else {
continue;
};
match child_node {
tl::Node::Raw(bytes) => {
if !bytes.as_utf8_str().trim().is_empty() {
return None;
}
}
tl::Node::Tag(child_tag) => {
let name = crate::converter::utility::content::normalized_tag_name(child_tag.name().as_utf8_str());
{
let level = heading_level_from_name(name.as_ref())?;
if heading_data.is_some() {
return None;
}
heading_data = Some((level, *child_handle));
}
}
tl::Node::Comment(_) => return None,
}
}
heading_data
}
fn heading_level_from_name(name: &str) -> Option<usize> {
match name {
"h1" => Some(1),
"h2" => Some(2),
"h3" => Some(3),
"h4" => Some(4),
"h5" => Some(5),
"h6" => Some(6),
_ => None,
}
}