use crate::converter::tier1::bail::BailReason;
use crate::converter::tier1::parse;
use crate::converter::tier1::spec_rules;
use crate::converter::tier1::state::{EscapeCtx, OpenTag, Tier1State};
use crate::converter::tier1::tags::{ListKind, TagKind, TagSpec};
use crate::converter::tier1::{self};
use crate::converter::utility::attributes::NAV_KEYWORDS;
use crate::options::ConversionOptions;
use memchr::{memchr2, memchr3};
const MAX_TAG_NAME_BYTES: usize = 32;
const MAX_ENTITY_NAME_BYTES: usize = 32;
const MIN_SEPARATOR_DASHES: usize = 3;
static CUSTOM_ELEMENT_BLOCK_SPEC: TagSpec = TagSpec {
kind: TagKind::Block,
is_void: false,
is_block: true,
optional_close: None,
is_rawtext: false,
};
const HEADING_PREFIXES: [&str; 6] = ["# ", "## ", "### ", "#### ", "##### ", "###### "];
const LIST_ITEM_INDENTS: [&str; 8] = [
"",
" ",
" ",
" ",
" ",
" ",
" ",
" ",
];
#[derive(Debug, Clone, Default)]
pub struct ScanOutput {
pub body: String,
pub head_range: Option<std::ops::Range<usize>>,
}
pub fn scan(html: &str, options: &ConversionOptions) -> Result<ScanOutput, BailReason> {
let bytes = html.as_bytes();
let mut state = Tier1State::new(html.len());
state.canonicalize_attr_entities = crate::converter::main_helpers::has_custom_element_tags(html);
let mut pos = 0usize;
let mut text_start = 0usize;
while pos < bytes.len() {
match bytes[pos] {
b'<' => {
if text_start < pos {
flush_text(&mut state, &html[text_start..pos], text_start)?;
}
let next = bytes.get(pos + 1).copied().unwrap_or(0);
if next == b'!' {
if html[pos..].starts_with("<![CDATA[") {
return Err(BailReason::Cdata { offset: pos });
}
pos = skip_bang(bytes, pos)?;
text_start = pos;
continue;
}
if next == b'?' {
return Err(BailReason::Classifier);
}
if next == b'/' {
let name_start = pos + 2;
let name_end = parse::scan_tag_name(bytes, name_start);
if name_end == name_start {
return Err(BailReason::LiteralLt { offset: pos });
}
let close_bracket =
parse::find_tag_close(bytes, name_end).ok_or(BailReason::LiteralLt { offset: pos })?;
let tag_name_bytes = &bytes[name_start..name_end];
emit_close(&mut state, tag_name_bytes, options)?;
pos = close_bracket.0 + 1;
text_start = pos;
continue;
}
if !parse::is_tag_name_start(next) {
flush_text(&mut state, "<", pos)?;
pos += 1;
text_start = pos;
continue;
}
let name_start = pos + 1;
let name_end = parse::scan_tag_name(bytes, name_start);
let tag_name_bytes = &bytes[name_start..name_end];
let mut name_buf = [0u8; MAX_TAG_NAME_BYTES];
let name_lower = lowercase_into(tag_name_bytes, &mut name_buf);
if name_lower == b"svg" {
let tag_open_start = pos;
let Some((close_pos, is_self_closing)) = parse::find_tag_close(bytes, name_end) else {
pos = bytes.len();
text_start = pos;
continue;
};
let open_tag_end = close_pos + 1;
let svg_end = if is_self_closing {
open_tag_end
} else {
find_svg_close(bytes, open_tag_end).unwrap_or(bytes.len())
};
let svg_slice = &html[tag_open_start..svg_end];
emit_svg_from_slice(svg_slice, tag_open_start, &mut state, options)?;
pos = svg_end;
text_start = pos;
continue;
}
if name_lower == b"template" {
let Some((close_pos, is_self_closing)) = parse::find_tag_close(bytes, name_end) else {
pos = bytes.len();
text_start = pos;
continue;
};
let open_tag_end = close_pos + 1;
pos = if is_self_closing {
open_tag_end
} else {
find_balanced_close(bytes, open_tag_end, b"template").unwrap_or(bytes.len())
};
text_start = pos;
continue;
}
let spec: &'static TagSpec = if name_lower.contains(&b'-') {
&CUSTOM_ELEMENT_BLOCK_SPEC
} else {
match tier1::lookup(name_lower) {
Some(s) => s,
None => {
return Err(BailReason::UnknownCustomElement {
name: bytes_to_string(tag_name_bytes).into(),
offset: pos,
});
}
}
};
if matches!(spec.kind, TagKind::Ignored) && spec.is_rawtext {
let open_end = match parse::find_tag_close(bytes, name_end) {
Some(close) => close.0 + 1,
None => bytes.len(),
};
pos = find_raw_text_close(bytes, open_end, name_lower).unwrap_or(bytes.len());
text_start = pos;
if name_lower == b"style" {
let dest = state.cell_or_output_mut();
let ends_with_word = !dest.is_empty()
&& !dest.ends_with(' ')
&& !dest.ends_with('\t')
&& !dest.ends_with('\n')
&& !dest.ends_with('<')
&& !dest.ends_with("<br>");
if ends_with_word {
dest.push(' ');
}
}
continue;
}
if matches!(spec.kind, TagKind::Ignored) {
let open_end = match parse::find_tag_close(bytes, name_end) {
Some(close) => close.0 + 1,
None => bytes.len(),
};
if spec.is_void {
pos = open_end;
text_start = pos;
continue;
}
let (close_start, close_end) = match find_close_tag_range(bytes, open_end, name_lower) {
Some(pair) => pair,
None => (bytes.len(), bytes.len()),
};
if state.head_range.is_none() {
state.head_range = Some(open_end..close_start);
}
pos = close_end;
text_start = pos;
continue;
}
bail_unsupported(spec, pos)?;
if is_preprocessing_skip_candidate(name_lower) {
let close = parse::find_tag_close(bytes, name_end).ok_or(BailReason::LiteralLt { offset: pos })?;
let attrs_end = if close.1 { close.0.saturating_sub(1) } else { close.0 };
let skip_attrs = parse::collect_attrs(bytes, name_end, attrs_end);
if should_skip_preprocessing(name_lower, &skip_attrs, options) {
let open_end = close.0 + 1;
if close.1 {
pos = open_end;
} else {
pos = find_balanced_close(bytes, open_end, name_lower).unwrap_or(bytes.len());
}
text_start = pos;
continue;
}
}
if matches!(spec.kind, TagKind::Pre)
&& options.code_block_style == crate::options::CodeBlockStyle::Tildes
{
return Err(BailReason::Classifier);
}
let close = parse::find_tag_close(bytes, name_end).ok_or(BailReason::LiteralLt { offset: pos })?;
let attrs_end = if close.1 {
close.0.saturating_sub(1)
} else {
close.0
};
let needs_attrs = matches!(
spec.kind,
TagKind::Link
| TagKind::Image
| TagKind::List(ListKind::Ordered)
| TagKind::TableCell { .. }
| TagKind::Pre
| TagKind::Code
) || name_lower == b"abbr";
let attrs: Vec<(&[u8], Option<&[u8]>)> = if needs_attrs {
parse::collect_attrs(bytes, name_end, attrs_end)
} else {
Vec::new()
};
pos = close.0 + 1;
if spec.is_void || close.1 {
emit_void(&mut state, spec, &attrs, html, options)?;
text_start = pos;
continue;
}
while let Some(top) = state.stack.last() {
if !spec_rules::should_close_for_new_tag(top.spec, spec) {
break;
}
emit_close_for_implicit(&mut state, options)?;
}
if state.in_table_cell() && spec.is_block {
let inlineable = matches!(
spec.kind,
TagKind::Paragraph
| TagKind::Block
| TagKind::Summary
| TagKind::Figcaption
| TagKind::Blockquote
| TagKind::Pre
| TagKind::List(_)
| TagKind::ListItem
| TagKind::Heading(_)
| TagKind::DefinitionTerm
| TagKind::DefinitionDescription
| TagKind::Table
);
if !inlineable {
return Err(BailReason::TableBlockChildInCell);
}
}
let prev_ctx = state.escape_ctx;
let ol_start = if matches!(spec.kind, TagKind::List(ListKind::Ordered)) {
extract_ol_start(&attrs)
} else {
1
};
if matches!(spec.kind, TagKind::Link) {
let (href, title) = extract_link_attrs(&attrs)?;
state.link_stack.push((href, title));
}
if name_lower == b"abbr" {
let title = find_attr(&attrs, b"title")
.and_then(|b| std::str::from_utf8(b).ok())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_owned);
state.abbr_titles.push(title);
}
emit_open(&mut state, spec, &attrs)?;
let output_content_start = state.cell_or_output_mut().len();
let list_index = 0u16;
state.stack.push(OpenTag {
spec,
content_start: output_content_start,
prev_escape_ctx: prev_ctx,
list_index,
ol_start,
name_range: name_start..name_end,
});
apply_open_escape_ctx(&mut state, spec);
text_start = pos;
}
_ => {
match memchr2(b'<', b'&', &bytes[pos..]) {
Some(offset) if offset > 0 => pos += offset,
Some(_) => pos += 1, None => pos = bytes.len(), }
}
}
}
if text_start < pos {
flush_text(&mut state, &html[text_start..pos], text_start)?;
}
while !state.stack.is_empty() {
let buf = &mut state.output;
while matches!(buf.as_bytes().last(), Some(b' ' | b'\t' | b'\n' | b'\r')) {
buf.pop();
}
emit_close_for_implicit(&mut state, options)?;
}
if state.output.contains("\n\n\n") {
collapse_excess_blank_lines(&mut state.output);
}
if !state.output.is_empty() {
let trimmed_end = state.output.trim_end_matches('\n');
if trimmed_end.is_empty() {
state.output.clear();
} else {
let trimmed_len = trimmed_end.len();
state.output.truncate(trimmed_len);
state.output.push('\n');
}
}
Ok(ScanOutput {
body: state.output,
head_range: state.head_range,
})
}
fn find_close_tag_range(bytes: &[u8], open_end: usize, tag_name: &[u8]) -> Option<(usize, usize)> {
let len = bytes.len();
let mut idx = open_end;
while idx < len {
match memchr3(b'<', b'<', b'<', &bytes[idx..]) {
Some(off) => idx += off,
None => return None,
}
if idx + 2 < len && bytes[idx + 1] == b'/' {
let after_slash = idx + 2;
if after_slash + tag_name.len() <= len
&& bytes[after_slash..after_slash + tag_name.len()].eq_ignore_ascii_case(tag_name)
{
let post_name = after_slash + tag_name.len();
if matches!(bytes.get(post_name), Some(b'>' | b'/' | b' ' | b'\t' | b'\n' | b'\r')) {
let mut j = post_name;
while j < len && bytes[j] != b'>' {
j += 1;
}
if j < len {
return Some((idx, j + 1));
}
return None;
}
}
}
idx += 1;
}
None
}
fn find_svg_close(bytes: &[u8], open_end: usize) -> Option<usize> {
find_balanced_close(bytes, open_end, b"svg")
}
fn find_balanced_close(bytes: &[u8], open_end: usize, tag_name: &[u8]) -> Option<usize> {
let len = bytes.len();
let mut idx = open_end;
let mut depth = 1usize;
while idx < len {
match memchr::memchr(b'<', &bytes[idx..]) {
Some(off) => idx += off,
None => return None,
}
if idx + 1 < len && bytes[idx + 1] == b'/' {
let name_start = idx + 2;
if name_start + tag_name.len() <= len
&& bytes[name_start..name_start + tag_name.len()].eq_ignore_ascii_case(tag_name)
{
let after = name_start + tag_name.len();
if matches!(
bytes.get(after),
Some(b'>' | b'/' | b' ' | b'\t' | b'\n' | b'\r') | None
) {
depth -= 1;
if depth == 0 {
let mut j = after;
while j < len && bytes[j] != b'>' {
j += 1;
}
return Some(j + 1);
}
}
}
} else if idx + 1 < len {
let name_start = idx + 1;
if name_start + tag_name.len() <= len
&& bytes[name_start..name_start + tag_name.len()].eq_ignore_ascii_case(tag_name)
{
let after = name_start + tag_name.len();
if matches!(
bytes.get(after),
Some(b'>' | b'/' | b' ' | b'\t' | b'\n' | b'\r') | None
) {
let mut j = after;
let mut in_q: Option<u8> = None;
let tag_end = loop {
if j >= len {
break len;
}
match bytes[j] {
b'"' | b'\'' => {
if let Some(q) = in_q {
if q == bytes[j] {
in_q = None;
}
} else {
in_q = Some(bytes[j]);
}
}
b'>' if in_q.is_none() => {
break j + 1;
}
_ => {}
}
j += 1;
};
let is_self_closing = tag_end >= 2 && bytes[tag_end - 2] == b'/';
if !is_self_closing {
depth += 1;
}
}
}
}
idx += 1;
}
None
}
fn emit_svg_from_slice(
svg_slice: &str,
svg_start_offset: usize,
state: &mut Tier1State,
options: &ConversionOptions,
) -> Result<(), BailReason> {
if svg_slice.contains("<;
dest.push_str(&base64_svg);
dest.push(')');
Ok(())
}
fn find_raw_text_close(bytes: &[u8], open_end: usize, tag_name: &[u8]) -> Option<usize> {
let len = bytes.len();
let mut idx = open_end;
while idx < len {
match memchr3(b'<', b'<', b'<', &bytes[idx..]) {
Some(off) => idx += off,
None => return None,
}
if idx + 2 < len && bytes[idx + 1] == b'/' {
let after_slash = idx + 2;
if after_slash + tag_name.len() <= len
&& bytes[after_slash..after_slash + tag_name.len()].eq_ignore_ascii_case(tag_name)
{
let post_name = after_slash + tag_name.len();
if matches!(bytes.get(post_name), Some(b'>' | b'/' | b' ' | b'\t' | b'\n' | b'\r')) {
let mut j = post_name;
while j < len && bytes[j] != b'>' {
j += 1;
}
if j < len {
return Some(j + 1);
}
return None;
}
}
}
idx += 1;
}
None
}
#[inline]
const fn bail_unsupported(spec: &TagSpec, _offset: usize) -> Result<(), BailReason> {
match spec.kind {
TagKind::RawText(_) => Err(BailReason::Classifier),
TagKind::Ignored => Err(BailReason::Classifier),
_ => Ok(()),
}
}
fn emit_open(
state: &mut Tier1State,
spec: &'static TagSpec,
attrs: &[(&[u8], Option<&[u8]>)],
) -> Result<(), BailReason> {
if matches!(
spec.kind,
TagKind::Block
| TagKind::Paragraph
| TagKind::Heading(_)
| TagKind::Blockquote
| TagKind::Pre
| TagKind::List(_)
| TagKind::Table
) && state.stack.iter().any(|f| matches!(f.spec.kind, TagKind::Link))
{
return Err(BailReason::Classifier);
}
match spec.kind {
TagKind::Paragraph => open_paragraph(state),
TagKind::Heading(_) => open_heading(state),
TagKind::Blockquote => open_blockquote(state),
TagKind::Pre => open_pre(state, attrs),
TagKind::List(ListKind::Definition) => open_dl(state),
TagKind::List(kind) => open_list(state, kind),
TagKind::ListItem => open_list_item(state),
TagKind::DefinitionTerm => open_dt(state),
TagKind::DefinitionDescription => open_dd(state),
TagKind::Strong => {
if !state.summary_at_top() {
state.cell_or_output_mut().push_str("**");
}
}
TagKind::Emphasis => {
state.cell_or_output_mut().push('*');
}
TagKind::Strikethrough => {
if !state.escape_ctx.contains(EscapeCtx::CODE) && !state.escape_ctx.contains(EscapeCtx::PRE) {
state.cell_or_output_mut().push_str("~~");
}
}
TagKind::Inserted => {
if !state.escape_ctx.contains(EscapeCtx::CODE) && !state.escape_ctx.contains(EscapeCtx::PRE) {
state.cell_or_output_mut().push_str("==");
}
}
TagKind::Code if !state.escape_ctx.contains(EscapeCtx::PRE) && !state.escape_ctx.contains(EscapeCtx::CODE) => {}
TagKind::Code if state.pre_lang.is_none() && state.escape_ctx.contains(EscapeCtx::PRE) => {
if let Some(lang) = extract_language_from_class(attrs) {
state.pre_lang = Some(lang);
}
}
TagKind::Link => open_link(state),
TagKind::Table => open_table(state),
TagKind::TableCaption => open_table_caption(state),
TagKind::TableHead => open_table_head(state)?,
TagKind::TableBody => open_table_body(state)?,
TagKind::TableFoot => open_table_foot(state),
TagKind::TableRow => open_table_row(state),
TagKind::TableCell { is_header } => open_table_cell(state, attrs, is_header)?,
TagKind::Block => {
if state.in_table_cell() {
let cell_buf = state.cell_or_output_mut();
if !cell_buf.is_empty()
&& !cell_buf.ends_with('|')
&& !cell_buf.ends_with("<br>")
&& !cell_buf.ends_with(" \n")
{
while cell_buf.ends_with(' ') || cell_buf.ends_with('\t') {
cell_buf.pop();
}
cell_buf.push_str(" \n");
}
} else {
state.ensure_blank_line();
}
}
TagKind::Summary => open_summary(state),
TagKind::Figcaption => open_figcaption(state),
TagKind::Button => {}
TagKind::Inline => {}
_ => {}
}
Ok(())
}
fn open_paragraph(state: &mut Tier1State) {
if state.in_table_cell() {
let cell_buf = state.cell_or_output_mut();
if !cell_buf.is_empty() && !cell_buf.ends_with("<br>") {
cell_buf.push_str("<br>");
}
return;
}
let dest = state.cell_or_output_mut();
if dest.ends_with("- ") || dest.ends_with("* ") || dest.ends_with("+ ") || ends_with_ordered_marker(dest) {
return;
}
crate::converter::tier1::state::trim_trailing_horizontal(dest);
if !dest.is_empty() && !dest.ends_with("\n\n") {
dest.push_str("\n\n");
}
}
fn open_heading(state: &mut Tier1State) {
if state.in_table_cell() {
return;
}
state.ensure_blank_line();
}
fn open_blockquote(state: &mut Tier1State) {
state.ensure_blank_line();
}
fn open_pre(state: &mut Tier1State, attrs: &[(&[u8], Option<&[u8]>)]) {
state.ensure_blank_line();
if let Some(lang) = extract_language_from_class(attrs) {
state.pre_lang = Some(lang);
}
}
fn extract_language_from_class(attrs: &[(&[u8], Option<&[u8]>)]) -> Option<String> {
let class_bytes = find_attr(attrs, b"class")?;
let class = std::str::from_utf8(class_bytes).ok()?;
for cls in class.split_ascii_whitespace() {
if let Some(rest) = cls.strip_prefix("language-") {
return Some(rest.to_owned());
}
if let Some(rest) = cls.strip_prefix("lang-") {
return Some(rest.to_owned());
}
}
None
}
fn open_list(state: &mut Tier1State, kind: ListKind) {
if state.in_table_cell() {
let cell_buf = state.cell_or_output_mut();
if !cell_buf.is_empty() && !cell_buf.ends_with('|') && !cell_buf.ends_with(' ') && !cell_buf.ends_with("<br>") {
cell_buf.push_str("<br>");
}
state.list_depth = state.list_depth.saturating_add(1);
if matches!(kind, ListKind::Unordered) {
state.ul_depth = state.ul_depth.saturating_add(1);
}
return;
}
let current_list_depth = state.list_depth;
{
let dest = state.cell_or_output_mut();
crate::converter::tier1::state::trim_trailing_horizontal(dest);
if !dest.is_empty() {
if current_list_depth == 0 {
if !dest.ends_with("\n\n") {
if dest.ends_with('\n') {
dest.push('\n');
} else {
dest.push_str("\n\n");
}
}
} else {
if !dest.ends_with('\n') {
dest.push('\n');
}
}
}
}
state.list_depth = state.list_depth.saturating_add(1);
if matches!(kind, ListKind::Unordered) {
state.ul_depth = state.ul_depth.saturating_add(1);
}
}
const TIER1_BULLETS: [u8; 3] = [b'-', b'*', b'+'];
fn open_list_item(state: &mut Tier1State) {
if state.in_table_cell() {
if find_parent_list_kind(&state.stack) == Some(ListKind::Ordered) {
increment_ol_counter(&mut state.stack);
}
return;
}
let parent_kind = find_parent_list_kind(&state.stack);
let indent_depth = state.list_depth.saturating_sub(1);
if parent_kind == Some(ListKind::Ordered) {
let counter = increment_ol_counter(&mut state.stack);
let start = find_ol_start(&state.stack);
let index = start.saturating_sub(1) + counter;
push_list_item_indent(&mut state.output, indent_depth);
#[allow(clippy::format_push_string)]
state.output.push_str(&format!("{index}. "));
} else {
push_list_item_indent(&mut state.output, indent_depth);
let bullet_idx = state.ul_depth.saturating_sub(1) as usize % TIER1_BULLETS.len();
state.output.push(TIER1_BULLETS[bullet_idx] as char);
state.output.push(' ');
}
}
fn open_link(state: &mut Tier1State) {
if let Some(ts) = state.table_stack.last_mut() {
ts.link_count += 1;
}
state.cell_or_output_mut().push('[');
}
fn open_table(state: &mut Tier1State) {
let inline_mode = !state.table_stack.is_empty();
state.table_stack.push(crate::converter::tier1::state::TableState {
inline_mode,
..Default::default()
});
}
fn open_table_caption(state: &mut Tier1State) {
if let Some(ts) = state.table_stack.last_mut() {
ts.caption_buf.clear();
ts.in_caption = true;
}
}
fn open_table_head(state: &mut Tier1State) -> Result<(), BailReason> {
if let Some(ts) = state.table_stack.last_mut() {
if ts.seen_tbody_close || ts.seen_tfoot {
return Err(BailReason::TableSectionOrder);
}
ts.in_thead = true;
}
Ok(())
}
fn open_table_body(state: &mut Tier1State) -> Result<(), BailReason> {
if let Some(ts) = state.table_stack.last_mut() {
if ts.seen_tfoot {
return Err(BailReason::TableSectionOrder);
}
}
Ok(())
}
fn open_table_foot(state: &mut Tier1State) {
if let Some(ts) = state.table_stack.last_mut() {
ts.seen_tfoot = true;
}
}
fn open_table_row(state: &mut Tier1State) {
if let Some(ts) = state.table_stack.last_mut() {
ts.current_row.clear();
}
}
fn open_table_cell(
state: &mut Tier1State,
attrs: &[(&[u8], Option<&[u8]>)],
is_header: bool,
) -> Result<(), BailReason> {
let colspan = find_attr(attrs, b"colspan")
.and_then(|b| std::str::from_utf8(b).ok())
.and_then(|s| s.parse::<u16>().ok())
.unwrap_or(1)
.max(1);
if let Some(ts) = state.table_stack.last_mut() {
ts.current_cell.clear();
ts.in_cell = true;
ts.current_cell_colspan = colspan;
if is_header {
ts.has_th = true;
}
}
Ok(())
}
fn emit_void(
state: &mut Tier1State,
spec: &'static TagSpec,
attrs: &[(&[u8], Option<&[u8]>)],
html: &str,
options: &ConversionOptions,
) -> Result<(), BailReason> {
match spec.kind {
TagKind::Hr => {
{
let dest = state.cell_or_output_mut();
if !dest.is_empty() && !dest.ends_with("\n\n") {
if dest.ends_with('\n') {
dest.push('\n');
} else {
dest.push_str("\n\n");
}
}
}
state.cell_or_output_mut().push_str("---\n");
}
TagKind::LineBreak => {
let in_link = state.stack.iter().any(|f| matches!(f.spec.kind, TagKind::Link));
if in_link {
state.cell_or_output_mut().push(' ');
} else if state.in_table_cell() {
state.cell_or_output_mut().push('\u{0001}');
} else if state.stack.is_empty() {
} else {
state.cell_or_output_mut().push_str(" \n");
}
}
TagKind::Image => {
let src = find_attr(attrs, b"src").unwrap_or_default();
let alt = find_attr(attrs, b"alt").unwrap_or_default();
let title = find_attr(attrs, b"title");
let src = decode_attr(src)?;
let canonicalize = state.canonicalize_attr_entities;
let alt_owned;
let alt: &str = if canonicalize {
alt_owned = canonicalize_attr_entities(&decode_attr(alt)?).into_owned();
&alt_owned
} else {
std::str::from_utf8(alt).map_err(|_| BailReason::Classifier)?
};
let keep_as_markdown = should_keep_image_as_markdown(html, &state.stack, options);
let dest = state.cell_or_output_mut();
if keep_as_markdown {
if let Some(title_bytes) = title {
let title_owned;
let title_str: &str = if canonicalize {
title_owned = canonicalize_attr_entities(&decode_attr(title_bytes)?).into_owned();
&title_owned
} else {
std::str::from_utf8(title_bytes).map_err(|_| BailReason::Classifier)?
};
#[allow(clippy::format_push_string)]
dest.push_str(&format!(""));
} else {
#[allow(clippy::format_push_string)]
dest.push_str(&format!(""));
}
} else {
dest.push_str(alt);
}
}
TagKind::Ignored | TagKind::Inline | TagKind::Block => {}
_ => {}
}
Ok(())
}
#[inline]
#[allow(clippy::missing_const_for_fn)]
fn should_keep_image_as_markdown(html: &str, stack: &[OpenTag], options: &ConversionOptions) -> bool {
#[cfg(feature = "inline-images")]
{
keep_inline_image_for_ancestors(html.as_bytes(), stack, &options.keep_inline_images_in)
}
#[cfg(not(feature = "inline-images"))]
{
let _ = html;
let _ = stack;
let _ = options;
true
}
}
#[cfg(feature = "inline-images")]
fn keep_inline_image_for_ancestors(input: &[u8], stack: &[OpenTag], keep: &[String]) -> bool {
if keep.is_empty() {
return true;
}
for frame in stack.iter().rev() {
if matches!(frame.spec.kind, TagKind::Heading(_)) {
let name = &input[frame.name_range.clone()];
for keep_name in keep {
if eq_ascii_ignore_case(name, keep_name.as_bytes()) {
return true;
}
}
return false;
}
}
true
}
#[cfg(feature = "inline-images")]
fn eq_ascii_ignore_case(a: &[u8], b: &[u8]) -> bool {
a.eq_ignore_ascii_case(b)
}
fn emit_close(state: &mut Tier1State, tag_name_bytes: &[u8], options: &ConversionOptions) -> Result<(), BailReason> {
let mut name_buf = [0u8; MAX_TAG_NAME_BYTES];
let name_lower = lowercase_into(tag_name_bytes, &mut name_buf);
let spec: &'static TagSpec = if name_lower.contains(&b'-') {
&CUSTOM_ELEMENT_BLOCK_SPEC
} else {
match tier1::lookup(name_lower) {
Some(s) => s,
None => {
return Err(BailReason::UnknownCustomElement {
name: bytes_to_string(tag_name_bytes).into(),
offset: 0,
});
}
}
};
while let Some(top) = state.stack.last() {
if kinds_match(&top.spec.kind, &spec.kind) {
break;
}
if top.spec.optional_close.is_some() {
emit_close_for_implicit(state, options)?;
} else {
break;
}
}
let actual_depth = state.stack.len() as u8;
let frame = pop_matching_frame(&mut state.stack, spec).ok_or_else(|| BailReason::DepthMismatch {
tag: bytes_to_string(name_lower),
expected: 1,
actual: actual_depth,
})?;
state.escape_ctx = frame.prev_escape_ctx;
match spec.kind {
TagKind::Paragraph => close_paragraph(state),
TagKind::Heading(n) => close_heading(state, &frame, n, false)?,
TagKind::Blockquote => close_blockquote(state, &frame),
TagKind::Pre => close_pre(state, &frame, options),
TagKind::Strong if state.summary_at_top() => {}
TagKind::Strong => close_inline_marker(state, &frame, "**"),
TagKind::Emphasis => close_inline_marker(state, &frame, "*"),
TagKind::Strikethrough
if state.escape_ctx.contains(EscapeCtx::CODE) || state.escape_ctx.contains(EscapeCtx::PRE) => {}
TagKind::Strikethrough => close_inline_marker(state, &frame, "~~"),
TagKind::Inserted
if state.escape_ctx.contains(EscapeCtx::CODE) || state.escape_ctx.contains(EscapeCtx::PRE) => {}
TagKind::Inserted => close_inline_marker(state, &frame, "=="),
TagKind::Code => close_code(state, &frame),
TagKind::Link => close_link(state, &frame),
TagKind::List(ListKind::Definition) => close_dl(state, &frame),
TagKind::List(kind) => close_list(state, kind),
TagKind::ListItem => close_list_item(state, &frame),
TagKind::DefinitionTerm => close_dt(state),
TagKind::DefinitionDescription => close_dd(state),
TagKind::Hr => {
}
TagKind::Table => close_table(state)?,
TagKind::TableHead => close_table_head(state),
TagKind::TableBody => close_table_body(state),
TagKind::TableFoot => {
}
TagKind::TableRow => close_table_row(state),
TagKind::TableCell { .. } => close_table_cell(state, false)?,
TagKind::TableCaption => close_table_caption(state),
TagKind::Block => close_block_container(state, &frame),
TagKind::Summary => close_summary(state, &frame),
TagKind::Figcaption => close_figcaption(state, &frame),
TagKind::Button => close_button(state, &frame),
TagKind::Inline => {
if name_lower == b"abbr" {
if let Some(Some(title)) = state.abbr_titles.pop() {
let dest = state.cell_or_output_mut();
dest.push_str(" (");
dest.push_str(&title);
dest.push(')');
}
}
}
TagKind::LineBreak | TagKind::Image => {}
TagKind::RawText(_) | TagKind::Ignored => {}
}
Ok(())
}
fn close_block_container(state: &mut Tier1State, frame: &OpenTag) {
if state.in_table_cell() {
return;
}
let buf = state.cell_or_output_mut();
if buf.len() <= frame.content_start {
return;
}
while buf.ends_with(' ') || buf.ends_with('\t') {
buf.pop();
}
if buf.ends_with("\n\n") {
return;
}
if buf.ends_with('\n') {
buf.push('\n');
} else {
buf.push_str("\n\n");
}
}
fn open_summary(state: &mut Tier1State) {
state.push_summary_buf(crate::converter::tier1::state::WrapKind::Summary);
}
fn close_summary(state: &mut Tier1State, _frame: &OpenTag) {
let buf = match state.pop_summary_buf() {
Some(b) => b,
None => return, };
let trimmed = buf.trim();
if trimmed.is_empty() {
return;
}
let writing_to_cell = state.in_table_cell();
let dest = state.cell_or_output_mut();
if !writing_to_cell && !dest.is_empty() && !dest.ends_with("\n\n") {
if dest.ends_with('\n') {
dest.push('\n');
} else {
dest.push_str("\n\n");
}
}
dest.push_str("**");
dest.push_str(trimmed);
dest.push_str("**\n\n");
}
fn open_figcaption(state: &mut Tier1State) {
state.push_summary_buf(crate::converter::tier1::state::WrapKind::Figcaption);
}
fn close_figcaption(state: &mut Tier1State, _frame: &OpenTag) {
let buf = match state.pop_summary_buf() {
Some(b) => b,
None => return,
};
let trimmed = buf.trim();
if trimmed.is_empty() {
return;
}
let writing_to_cell = state.in_table_cell();
let dest = state.cell_or_output_mut();
while dest.ends_with(' ') || dest.ends_with('\t') {
dest.pop();
}
if !writing_to_cell && !dest.is_empty() && !dest.ends_with("\n\n") {
if dest.ends_with('\n') {
dest.push('\n');
} else {
dest.push_str("\n\n");
}
}
dest.push('*');
dest.push_str(trimmed);
dest.push_str("*\n\n");
}
fn close_button(state: &mut Tier1State, frame: &OpenTag) {
if state.in_table_cell() {
return;
}
let dest = state.cell_or_output_mut();
if dest.len() <= frame.content_start {
return;
}
while dest.ends_with(' ') || dest.ends_with('\t') {
dest.pop();
}
if dest.ends_with("\n\n") {
return;
}
if dest.ends_with('\n') {
dest.push('\n');
} else {
dest.push_str("\n\n");
}
}
fn close_inline_marker(state: &mut Tier1State, frame: &OpenTag, marker: &str) {
let buf = state.cell_or_output_mut();
let body_is_empty = buf.len() <= frame.content_start
|| buf[frame.content_start..]
.bytes()
.all(|b| matches!(b, b' ' | b'\t' | b'\n' | b'\r'));
if body_is_empty {
let open_marker_start = frame.content_start.saturating_sub(marker.len());
buf.truncate(open_marker_start);
return;
}
let content_str = &buf[frame.content_start..];
let leading_len = content_str.len() - content_str.trim_start().len();
if leading_len > 0 {
let leading: String = content_str[..leading_len].to_owned();
buf.replace_range(frame.content_start..frame.content_start + leading_len, "");
let marker_start = frame.content_start.saturating_sub(marker.len());
buf.insert_str(marker_start, &leading);
}
buf.push_str(marker);
}
fn emit_close_for_implicit(state: &mut Tier1State, options: &ConversionOptions) -> Result<(), BailReason> {
let frame = state.stack.pop().ok_or_else(|| BailReason::DepthMismatch {
tag: String::from("(implicit)"),
expected: 1,
actual: 0,
})?;
let spec = frame.spec;
state.escape_ctx = frame.prev_escape_ctx;
match spec.kind {
TagKind::Paragraph => close_paragraph(state),
TagKind::Heading(n) => close_heading(state, &frame, n, true)?,
TagKind::Blockquote => close_blockquote(state, &frame),
TagKind::Pre => close_pre(state, &frame, options),
TagKind::Strong if state.summary_at_top() => {}
TagKind::Strong => close_inline_marker(state, &frame, "**"),
TagKind::Emphasis => close_inline_marker(state, &frame, "*"),
TagKind::Strikethrough
if state.escape_ctx.contains(EscapeCtx::CODE) || state.escape_ctx.contains(EscapeCtx::PRE) => {}
TagKind::Strikethrough => close_inline_marker(state, &frame, "~~"),
TagKind::Inserted
if state.escape_ctx.contains(EscapeCtx::CODE) || state.escape_ctx.contains(EscapeCtx::PRE) => {}
TagKind::Inserted => close_inline_marker(state, &frame, "=="),
TagKind::Code => close_code(state, &frame),
TagKind::Link => close_link(state, &frame),
TagKind::List(ListKind::Definition) => close_dl(state, &frame),
TagKind::List(kind) => close_list(state, kind),
TagKind::ListItem => close_list_item(state, &frame),
TagKind::DefinitionTerm => close_dt(state),
TagKind::DefinitionDescription => close_dd(state),
TagKind::TableCell { .. } => close_table_cell(state, true)?,
TagKind::TableRow => close_table_row(state),
TagKind::Summary => close_summary(state, &frame),
TagKind::Figcaption => close_figcaption(state, &frame),
TagKind::Button => close_button(state, &frame),
TagKind::Block | TagKind::Inline => {}
TagKind::LineBreak
| TagKind::Image
| TagKind::Hr
| TagKind::Table
| TagKind::TableHead
| TagKind::TableBody
| TagKind::TableFoot
| TagKind::TableCaption
| TagKind::RawText(_)
| TagKind::Ignored => {}
}
Ok(())
}
fn close_paragraph(state: &mut Tier1State) {
if state.in_table_cell() {
return;
}
trim_trailing_inline_whitespace(state);
state.cell_or_output_mut().push_str("\n\n");
}
fn close_heading(state: &mut Tier1State, frame: &OpenTag, n: u8, is_implicit: bool) -> Result<(), BailReason> {
if state.in_table_cell() {
let cell_buf = state.cell_or_output_mut();
while cell_buf.ends_with(' ') || cell_buf.ends_with('\t') {
cell_buf.pop();
}
if !is_implicit {
let content = &state.cell_or_output_mut()[frame.content_start..];
if content.trim().is_empty() {
let len = frame.content_start;
state.cell_or_output_mut().truncate(len);
}
}
return Ok(());
}
trim_trailing_inline_whitespace(state);
if !is_implicit {
let content = &state.output[frame.content_start..];
if content.trim().is_empty() {
state.output.truncate(frame.content_start);
let trimmed_len = state.output.trim_end_matches('\n').len();
if trimmed_len > 0 {
state.output.truncate(trimmed_len);
state.output.push('\n');
} else {
state.output.clear();
}
return Ok(());
}
}
if state.output[frame.content_start..].contains('\n') {
let content = state.output[frame.content_start..].to_owned();
let mut normalized = String::with_capacity(content.len());
let mut prev_was_space = false;
for ch in content.chars() {
let is_ws = ch == ' ' || ch == '\t' || ch == '\n' || ch == '\r';
if is_ws {
if !prev_was_space {
normalized.push(' ');
prev_was_space = true;
}
} else {
normalized.push(ch);
prev_was_space = false;
}
}
state.output.truncate(frame.content_start);
state.output.push_str(normalized.trim_end());
}
let prefix = heading_prefix(n);
state.output.insert_str(frame.content_start, prefix);
state.ensure_blank_line();
Ok(())
}
fn close_blockquote(state: &mut Tier1State, frame: &OpenTag) {
if state.in_table_cell() {
return;
}
let content = state.output[frame.content_start..].to_owned();
let prefixed = prefix_blockquote_lines(&content);
state.output.truncate(frame.content_start);
if state.output.ends_with("\n\n") {
state.output.pop();
}
state.output.push_str(&prefixed);
}
fn close_pre(state: &mut Tier1State, frame: &OpenTag, options: &ConversionOptions) {
use crate::options::CodeBlockStyle;
if state.in_table_cell() {
return;
}
let raw = state.output[frame.content_start..].to_owned();
state.output.truncate(frame.content_start);
match options.code_block_style {
CodeBlockStyle::Indented => {
let indented = indent_pre_lines(&raw);
state.output.push_str(&indented);
}
CodeBlockStyle::Backticks => {
state.output.push_str("```");
if let Some(lang) = state.pre_lang.take() {
state.output.push_str(&lang);
} else if !options.code_language.is_empty() {
state.output.push_str(&options.code_language);
}
state.output.push('\n');
let raw = raw.strip_prefix('\n').unwrap_or(&raw);
let raw = raw.strip_suffix('\n').unwrap_or(raw);
state.output.push_str(raw);
state.output.push('\n');
state.output.push_str("```\n");
}
CodeBlockStyle::Tildes => {
let indented = indent_pre_lines(&raw);
state.output.push_str(&indented);
}
}
state.pre_lang = None;
}
fn close_code(state: &mut Tier1State, frame: &OpenTag) {
if state.escape_ctx.contains(EscapeCtx::PRE) || state.escape_ctx.contains(EscapeCtx::CODE) {
return;
}
let buf = state.cell_or_output_mut();
let content_start = frame.content_start.min(buf.len());
if content_start >= buf.len() {
return;
}
let contains_backtick = buf[content_start..].contains('`');
let (needs_spaces, num_backticks) = {
let content = &buf[content_start..];
let first_char = content.chars().next();
let last_char = content.chars().last();
let starts_with_space = first_char == Some(' ');
let ends_with_space = last_char == Some(' ');
let starts_with_backtick = first_char == Some('`');
let ends_with_backtick = last_char == Some('`');
let all_spaces = content.chars().all(|c| c == ' ');
let needs_delimiter_spaces = all_spaces
|| starts_with_backtick
|| ends_with_backtick
|| (starts_with_space && ends_with_space && contains_backtick);
let num_backticks = if contains_backtick {
let max_consecutive = content
.chars()
.fold((0usize, 0usize), |(max, current), c| {
if c == '`' {
let new_current = current + 1;
(max.max(new_current), new_current)
} else {
(max, 0)
}
})
.0;
if max_consecutive == 1 { 2 } else { 1 }
} else {
1
};
(needs_delimiter_spaces, num_backticks)
};
let mut prefix = String::with_capacity(num_backticks + 1);
for _ in 0..num_backticks {
prefix.push('`');
}
if needs_spaces {
prefix.push(' ');
}
buf.insert_str(content_start, &prefix);
if needs_spaces {
buf.push(' ');
}
for _ in 0..num_backticks {
buf.push('`');
}
}
fn close_link(state: &mut Tier1State, frame: &OpenTag) {
let (href, title) = state.link_stack.pop().unwrap_or((None, None));
let dest = state.cell_or_output_mut();
let trim_start = frame.content_start.min(dest.len());
let trimmed_end = dest[trim_start..].trim_end_matches(|c: char| c.is_whitespace()).len();
dest.truncate(trim_start + trimmed_end);
if dest[trim_start..].contains('\u{00a0}') {
let normalised: String = dest[trim_start..]
.chars()
.map(|c| if c == '\u{00a0}' { ' ' } else { c })
.collect();
dest.truncate(trim_start);
dest.push_str(&normalised);
}
if let Some(href_str) = href.as_deref() {
if href_str.starts_with('#') && dest.len() == trim_start + 1 && dest.as_bytes()[trim_start] == b'^' {
dest.truncate(trim_start);
dest.push('↑');
}
}
if let Some(href) = href {
if let Some(title) = title {
let escaped_title;
let title_out: &str = if title.contains('"') {
escaped_title = title.replace('"', """);
&escaped_title
} else {
&title
};
#[allow(clippy::format_push_string)]
dest.push_str(&format!("]({href} \"{title_out}\")"));
} else {
#[allow(clippy::format_push_string)]
dest.push_str(&format!("]({href})"));
}
} else {
if let Some(bracket_pos) = dest[..frame.content_start].rfind('[') {
dest.remove(bracket_pos);
}
}
}
fn close_list(state: &mut Tier1State, kind: ListKind) {
state.list_depth = state.list_depth.saturating_sub(1);
if matches!(kind, ListKind::Unordered) {
state.ul_depth = state.ul_depth.saturating_sub(1);
}
if state.in_table_cell() {
return;
}
let dest = state.cell_or_output_mut();
if !dest.ends_with('\n') {
dest.push('\n');
}
}
fn close_list_item(state: &mut Tier1State, frame: &OpenTag) {
if state.in_table_cell() {
let cell_buf = state.cell_or_output_mut();
while cell_buf.ends_with(' ') || cell_buf.ends_with('\t') {
cell_buf.pop();
}
return;
}
trim_trailing_inline_whitespace(state);
let dest = state.cell_or_output_mut();
let had_block_children = {
let start = frame.content_start.min(dest.len());
dest[start..].contains("\n\n")
};
if had_block_children {
if !dest.ends_with("\n\n") {
if dest.ends_with('\n') {
dest.push('\n');
} else {
dest.push_str("\n\n");
}
}
} else if !dest.is_empty() && !dest.ends_with('\n') {
dest.push('\n');
}
}
fn open_dl(state: &mut Tier1State) {
if state.in_table_cell() {
return;
}
state.ensure_blank_line();
}
const fn open_dt(_state: &mut Tier1State) {
}
const fn open_dd(_state: &mut Tier1State) {
}
fn close_dt(state: &mut Tier1State) {
if state.in_table_cell() {
return;
}
trim_trailing_inline_whitespace(state);
let buf = state.cell_or_output_mut();
if buf.is_empty() || buf.ends_with('\n') {
return;
}
buf.push('\n');
}
fn close_dd(state: &mut Tier1State) {
if state.in_table_cell() {
return;
}
trim_trailing_inline_whitespace(state);
let buf = state.cell_or_output_mut();
if buf.is_empty() {
return;
}
if buf.ends_with("\n\n") {
return;
}
if buf.ends_with('\n') {
buf.push('\n');
} else {
buf.push_str("\n\n");
}
}
fn close_dl(state: &mut Tier1State, frame: &OpenTag) {
if state.in_table_cell() {
return;
}
let buf = state.cell_or_output_mut();
if buf.len() <= frame.content_start {
return;
}
while buf.len() > frame.content_start {
let last = buf.as_bytes()[buf.len() - 1];
if matches!(last, b' ' | b'\t' | b'\n' | b'\r') {
buf.pop();
} else {
break;
}
}
if buf.len() == frame.content_start {
return;
}
buf.push_str("\n\n");
}
fn close_table(state: &mut Tier1State) -> Result<(), BailReason> {
let Some(ts) = state.table_stack.pop() else {
return Ok(());
};
let has_caption = ts.caption_text.is_some();
if !ts.has_th && !has_caption {
let row_count = ts.rows.len();
let expanded_cols = |row: &Vec<(String, u16)>| -> usize { row.iter().map(|(_, c)| usize::from(*c)).sum() };
let inconsistent_cols = {
let first = ts.first_row_col_count.unwrap_or(0);
ts.rows.iter().any(|r| expanded_cols(r) != first)
};
let link_heavy = row_count <= 2 && ts.link_count >= 3;
let is_blank = ts.rows.is_empty() || ts.rows.iter().all(|r| r.iter().all(|(c, _)| c.trim().is_empty()));
if inconsistent_cols || link_heavy || is_blank {
return Err(BailReason::Classifier);
}
}
if ts.inline_mode {
if let Some(outer) = state.table_stack.last_mut() {
outer.had_nested_table = true;
}
let target = state.cell_or_output_mut();
emit_gfm_table(target, ts);
} else {
emit_gfm_table(&mut state.output, ts);
}
Ok(())
}
fn close_table_head(state: &mut Tier1State) {
if let Some(ts) = state.table_stack.last_mut() {
ts.in_thead = false;
}
}
fn close_table_body(state: &mut Tier1State) {
if let Some(ts) = state.table_stack.last_mut() {
ts.seen_tbody_close = true;
}
}
fn close_table_caption(state: &mut Tier1State) {
let Some(ts) = state.table_stack.last_mut() else {
return;
};
ts.in_caption = false;
let raw = std::mem::take(&mut ts.caption_buf);
let trimmed = raw.trim();
if !trimmed.is_empty() {
ts.caption_text = Some(trimmed.replace('-', r"\-"));
}
}
fn close_table_row(state: &mut Tier1State) {
let Some(ts) = state.table_stack.last_mut() else {
return;
};
if ts.current_row.is_empty() {
return;
}
let col_count: usize = ts.current_row.iter().map(|(_, c)| usize::from(*c)).sum();
if ts.first_row_col_count.is_none() {
ts.first_row_col_count = Some(col_count);
}
let row = std::mem::take(&mut ts.current_row);
ts.rows.push(row);
}
fn close_table_cell(state: &mut Tier1State, is_implicit: bool) -> Result<(), BailReason> {
let Some(ts) = state.table_stack.last_mut() else {
return Ok(());
};
ts.in_cell = false;
let cell_text_raw = ts.current_cell.trim().to_owned();
let cell_text = if cell_text_raw.contains('\n') {
cell_text_raw.replace('\n', " ")
} else {
cell_text_raw
};
let cell_text = if cell_text.contains('\u{0001}') {
cell_text.replace('\u{0001}', " ")
} else {
cell_text
};
let cell_text = cell_text.trim().to_owned();
let allow_pipes = ts.had_nested_table;
ts.had_nested_table = false;
if !is_implicit && !allow_pipes && cell_text.contains('|') {
return Err(BailReason::TableBlockChildInCell);
}
let colspan = ts.current_cell_colspan;
ts.current_row.push((cell_text, colspan));
ts.current_cell.clear();
ts.current_cell_colspan = 1;
Ok(())
}
fn ends_with_ordered_marker(s: &str) -> bool {
let bytes = s.as_bytes();
let len = bytes.len();
if len < 3 || bytes[len - 1] != b' ' {
return false;
}
let punct = bytes[len - 2];
if punct != b'.' && punct != b')' {
return false;
}
let mut i = len - 2;
while i > 0 && bytes[i - 1].is_ascii_digit() {
i -= 1;
}
i < len - 2 && (i == 0 || !bytes[i - 1].is_ascii_digit())
}
fn output_ends_with_inline_close_marker(output: &str) -> bool {
if output.is_empty() || output.ends_with('\n') || output.ends_with(' ') || output.ends_with('\t') {
return false;
}
if output.ends_with("**") || output.ends_with('`') || output.ends_with(')') {
return true;
}
output.ends_with('*') && !output.ends_with("**")
}
fn output_ends_with_inline_text(output: &str) -> bool {
if output.is_empty() || output.ends_with('\n') || output.ends_with(' ') || output.ends_with('\t') {
return false;
}
!output_ends_with_inline_close_marker(output)
}
fn flush_text(state: &mut Tier1State, raw: &str, base_offset: usize) -> Result<(), BailReason> {
if raw.is_empty() {
return Ok(());
}
if !state.table_stack.is_empty() && !state.in_table_cell() && !state.in_table_caption() {
return Ok(());
}
let in_pre = state.escape_ctx.contains(EscapeCtx::PRE);
let in_code = state.escape_ctx.contains(EscapeCtx::CODE);
const UNICODE_WS_ENTITIES: &[&str] = &[
" ", " ", " ", " ", " ", " ", " ", " ", " ", " ", " ", " ", " ", " ", " ", " ", " ",
];
let raw_owned_nbsp;
let raw: &str = if !in_pre && !in_code {
let has_ws_entity = UNICODE_WS_ENTITIES.iter().any(|p| raw.contains(p));
let has_unicode_ws_literal = raw.bytes().any(|b| b >= 0x80)
&& raw
.chars()
.any(|c| c.is_whitespace() && c != ' ' && c != '\t' && c != '\n' && c != '\r');
if has_ws_entity || has_unicode_ws_literal {
let mut stripped = raw.to_owned();
for p in UNICODE_WS_ENTITIES {
if stripped.contains(p) {
stripped = stripped.replace(p, "");
}
}
let is_logically_whitespace = stripped.chars().all(char::is_whitespace);
if is_logically_whitespace {
raw
} else {
let mut after_entities = raw.to_owned();
for p in UNICODE_WS_ENTITIES {
if after_entities.contains(p) {
after_entities = after_entities.replace(p, " ");
}
}
let mut tmp = String::with_capacity(after_entities.len());
for c in after_entities.chars() {
if c.is_whitespace() && c != ' ' && c != '\t' && c != '\n' && c != '\r' {
tmp.push(' ');
} else {
tmp.push(c);
}
}
raw_owned_nbsp = tmp;
raw_owned_nbsp.as_str()
}
} else {
raw
}
} else {
raw
};
let in_summary_snapshot = state.in_summary();
let at_inline_frame_start = match state.stack.last() {
Some(frame) => {
let cs = frame.content_start;
let kind = frame.spec.kind;
let buf_len = state.cell_or_output_mut().len();
cs >= buf_len
&& (matches!(
kind,
TagKind::Link | TagKind::Strong | TagKind::Emphasis | TagKind::Code
) || (in_summary_snapshot
&& matches!(
kind,
TagKind::Inline | TagKind::Block | TagKind::Paragraph | TagKind::Heading(_)
)))
}
None => false,
};
let (active_empty, active_ends_newline, active_ends_list_marker, active_ends_ordered) = {
let buf: &str = state.cell_or_output_mut();
(
buf.is_empty(),
buf.ends_with('\n'),
buf.ends_with("- ") || buf.ends_with("* ") || buf.ends_with("+ "),
ends_with_ordered_marker(buf),
)
};
let is_block_edge =
active_empty || active_ends_newline || active_ends_list_marker || active_ends_ordered || at_inline_frame_start;
let raw_is_whitespace = raw.bytes().all(|b| b == b' ' || b == b'\t' || b == b'\n' || b == b'\r');
if !in_pre && is_block_edge && raw_is_whitespace {
return Ok(());
}
if !in_pre && state.in_table_cell() && raw_is_whitespace && !is_block_edge {
if matches!(state.stack.last().map(|f| f.spec.kind), Some(TagKind::List(_))) {
return Ok(());
}
let dest = state.cell_or_output_mut();
if !dest.is_empty() && !dest.ends_with(' ') && !dest.ends_with('\n') {
dest.push(' ');
}
return Ok(());
}
if !in_pre && !state.in_table_cell() && raw_is_whitespace {
let inside_inline = state.in_summary()
|| state.stack.iter().any(|frame| {
matches!(
frame.spec.kind,
TagKind::Link | TagKind::Strong | TagKind::Emphasis | TagKind::Code
)
});
if !inside_inline {
let active_tail: &str = state.cell_or_output_mut();
if output_ends_with_inline_close_marker(active_tail) || output_ends_with_inline_text(active_tail) {
let dest = state.cell_or_output_mut();
dest.push(' ');
}
return Ok(());
}
let active_tail: &str = state.cell_or_output_mut();
if !active_tail.is_empty() && !active_tail.ends_with(' ') && !active_tail.ends_with('\n') {
let dest = state.cell_or_output_mut();
dest.push(' ');
}
return Ok(());
}
let block_separator_after = {
let active: &str = state.cell_or_output_mut();
active.ends_with("\n\n")
|| active.ends_with("- ")
|| active.ends_with("* ")
|| active.ends_with("+ ")
|| ends_with_ordered_marker(active)
};
let raw = if !in_pre && !state.in_table_cell() && (at_inline_frame_start || block_separator_after) {
raw.trim_start_matches([' ', '\t', '\n', '\r'])
} else {
raw
};
if raw.is_empty() {
return Ok(());
}
let has_entities = raw.contains('&');
if in_pre || in_code {
if has_entities {
let dest = state.cell_or_output_mut();
decode_entities_into(dest, raw, base_offset)?;
} else {
state.cell_or_output_mut().push_str(raw);
}
return Ok(());
}
let inside_inline = state.in_summary() || state.stack.iter().any(|frame| matches!(frame.spec.kind, TagKind::Link));
let raw_owned;
let raw = if !inside_inline && !state.in_table_cell() {
let trim_chars: &[char] = &['\n', '\r', ' ', '\t'];
let after_lead = raw.trim_start_matches(trim_chars);
let leading_len = raw.len() - after_lead.len();
let lead_has_nl = leading_len > 0 && raw.as_bytes()[..leading_len].iter().any(|&b| b == b'\n' || b == b'\r');
let trimmed_len = raw.trim_end_matches(trim_chars).len();
let trailing_len = raw.len() - trimmed_len;
let trail_has_nl = trailing_len > 0 && raw.as_bytes()[trimmed_len..].iter().any(|&b| b == b'\n' || b == b'\r');
if lead_has_nl || trail_has_nl {
let core_start = leading_len;
let core_end = trimmed_len;
if core_start >= core_end {
raw
} else {
let core = &raw[core_start..core_end];
let trailing = &raw[core_end..];
let prefix = if leading_len > 0 { " " } else { "" };
let suffix = if trailing.contains("\n\n") {
"\n\n"
} else if trailing.bytes().any(|b| b == b' ' || b == b'\t') {
" "
} else if trail_has_nl {
""
} else {
trailing
};
raw_owned = format!("{prefix}{core}{suffix}");
raw_owned.as_str()
}
} else {
raw
}
} else {
raw
};
if raw.is_empty() {
return Ok(());
}
let has_entities = raw.contains('&');
if !has_entities {
let needle_present = if inside_inline {
memchr3(b' ', b'\t', b'\n', raw.as_bytes()).is_some()
} else {
memchr::memchr2(b' ', b'\t', raw.as_bytes()).is_some()
};
if !needle_present {
state.cell_or_output_mut().push_str(raw);
return Ok(());
}
let dest = state.cell_or_output_mut();
return if inside_inline {
decode_and_collapse_into_inline(dest, raw, false, base_offset)
} else {
decode_and_collapse_into(dest, raw, false, base_offset)
};
}
let dest = state.cell_or_output_mut();
if inside_inline {
decode_and_collapse_into_inline(dest, raw, has_entities, base_offset)
} else {
decode_and_collapse_into(dest, raw, has_entities, base_offset)
}
}
fn decode_entities_into(out: &mut String, s: &str, base_offset: usize) -> Result<(), BailReason> {
let bytes = s.as_bytes();
let mut i = 0;
while i < bytes.len() {
if let Some(pos) = memchr::memchr(b'&', &bytes[i..]) {
let amp_pos = i + pos;
if amp_pos > i {
out.push_str(&s[i..amp_pos]);
}
i = decode_entity_at(bytes, s, amp_pos, out, base_offset)?;
} else {
if i < bytes.len() {
out.push_str(&s[i..]);
}
break;
}
}
Ok(())
}
fn decode_and_collapse_into(
out: &mut String,
s: &str,
has_entities: bool,
base_offset: usize,
) -> Result<(), BailReason> {
decode_and_collapse_into_inner(out, s, has_entities, base_offset, false)
}
fn decode_and_collapse_into_inline(
out: &mut String,
s: &str,
has_entities: bool,
base_offset: usize,
) -> Result<(), BailReason> {
decode_and_collapse_into_inner(out, s, has_entities, base_offset, true)
}
fn decode_and_collapse_into_inner(
out: &mut String,
s: &str,
has_entities: bool,
base_offset: usize,
collapse_newlines: bool,
) -> Result<(), BailReason> {
let bytes = s.as_bytes();
let mut i = 0;
let mut prev_was_space = false;
while i < bytes.len() {
let next_special = match (has_entities, collapse_newlines) {
(true, true) => {
let s_pos = memchr3(b' ', b'\t', b'\n', &bytes[i..]).map(|pos| i + pos);
let e_pos = memchr::memchr(b'&', &bytes[i..]).map(|pos| i + pos);
match (s_pos, e_pos) {
(Some(a), Some(b)) => Some(a.min(b)),
(Some(a), None) | (None, Some(a)) => Some(a),
(None, None) => None,
}
}
(true, false) => memchr3(b' ', b'\t', b'&', &bytes[i..]).map(|pos| i + pos),
(false, true) => memchr3(b' ', b'\t', b'\n', &bytes[i..]).map(|pos| i + pos),
(false, false) => memchr::memchr2(b' ', b'\t', &bytes[i..]).map(|pos| i + pos),
};
if let Some(pos) = next_special {
if pos > i {
out.push_str(&s[i..pos]);
prev_was_space = false;
}
match bytes[pos] {
b' ' | b'\t' => {
if !prev_was_space {
out.push(' ');
}
prev_was_space = true;
i = pos + 1;
}
b'\n' if collapse_newlines => {
if !prev_was_space {
out.push(' ');
}
prev_was_space = true;
i = pos + 1;
}
b'&' => {
prev_was_space = false;
i = decode_entity_at(bytes, s, pos, out, base_offset)?;
}
_ => unreachable!(),
}
} else {
if i < bytes.len() {
out.push_str(&s[i..]);
}
break;
}
}
Ok(())
}
fn decode_entity_at(
bytes: &[u8],
s: &str,
amp_pos: usize,
out: &mut String,
_base_offset: usize,
) -> Result<usize, BailReason> {
let amp = amp_pos;
let mut end = amp + 1;
while end < bytes.len() && end - amp <= MAX_ENTITY_NAME_BYTES && bytes[end] != b';' {
end += 1;
}
if end < bytes.len() && bytes[end] == b';' && end > amp + 1 {
let entity = &s[amp + 1..end];
if decode_entity_into(out, entity) {
return Ok(end + 1);
}
out.push_str(&s[amp..=end]);
return Ok(end + 1);
}
out.push('&');
Ok(amp + 1)
}
#[inline]
fn apply_open_escape_ctx(state: &mut Tier1State, spec: &TagSpec) {
if spec.kind == TagKind::Pre {
state.escape_ctx |= EscapeCtx::PRE | EscapeCtx::CODE;
return;
}
let bit = match spec.kind {
TagKind::Code => EscapeCtx::CODE,
TagKind::Link => EscapeCtx::LINK,
TagKind::Blockquote => EscapeCtx::BLOCKQUOTE,
TagKind::Heading(_) => EscapeCtx::HEADING,
_ => return,
};
state.escape_ctx |= bit;
}
fn find_attr<'a>(attrs: &[(&'a [u8], Option<&'a [u8]>)], key: &[u8]) -> Option<&'a [u8]> {
for (k, v) in attrs {
if k.eq_ignore_ascii_case(key) {
return *v;
}
}
None
}
fn is_preprocessing_skip_candidate(name_lower: &[u8]) -> bool {
matches!(name_lower, b"nav" | b"header" | b"footer" | b"aside" | b"form")
}
fn should_skip_preprocessing(name_lower: &[u8], attrs: &[(&[u8], Option<&[u8]>)], options: &ConversionOptions) -> bool {
use crate::options::PreprocessingPreset;
if !options.preprocessing.enabled {
return false;
}
if options.preprocessing.preset == PreprocessingPreset::Minimal {
return false;
}
if options.preprocessing.remove_forms && name_lower == b"form" {
return true;
}
if !options.preprocessing.remove_navigation {
return false;
}
if name_lower == b"nav" {
return true;
}
if matches!(name_lower, b"header" | b"footer" | b"aside") {
return byte_attrs_have_navigation_hint(attrs);
}
false
}
fn byte_attrs_have_navigation_hint(attrs: &[(&[u8], Option<&[u8]>)]) -> bool {
if let Some(role) = find_attr(attrs, b"role") {
let role_lc = role.to_ascii_lowercase();
if matches!(role_lc.as_slice(), b"navigation" | b"menubar" | b"tablist" | b"toolbar") {
return true;
}
}
if let Some(label) = find_attr(attrs, b"aria-label") {
let label_lc = label.to_ascii_lowercase();
const ARIA_SUBSTRINGS: &[&[u8]] = &[b"navigation", b"menu", b"contents", b"table of contents", b"toc"];
if ARIA_SUBSTRINGS
.iter()
.any(|sub| label_lc.windows(sub.len()).any(|w| w == *sub))
{
return true;
}
}
for attr_name in [b"class".as_slice(), b"id".as_slice()] {
if let Some(value) = find_attr(attrs, attr_name) {
if byte_value_has_nav_keyword(value) {
return true;
}
}
}
false
}
fn byte_value_has_nav_keyword(value: &[u8]) -> bool {
let mut start = 0;
let len = value.len();
loop {
while start < len && value[start].is_ascii_whitespace() {
start += 1;
}
if start >= len {
break;
}
let mut end = start;
while end < len && !value[end].is_ascii_whitespace() {
end += 1;
}
let token_bytes = &value[start..end];
let mut buf = [0u8; 64];
let normalised: &[u8] = if token_bytes.len() <= buf.len() {
let n = token_bytes.len();
for (i, &b) in token_bytes.iter().enumerate() {
buf[i] = match b {
b'_' | b':' | b'.' | b'/' => b'-',
_ => b.to_ascii_lowercase(),
};
}
&buf[..n]
} else {
start = end;
continue;
};
if NAV_KEYWORDS.iter().any(|kw| kw.as_bytes() == normalised) {
return true;
}
start = end;
}
false
}
fn extract_link_attrs(attrs: &[(&[u8], Option<&[u8]>)]) -> Result<(Option<String>, Option<String>), BailReason> {
let href = find_attr(attrs, b"href").map(decode_attr).transpose()?;
let title = find_attr(attrs, b"title").map(decode_title_attr).transpose()?;
Ok((href, title))
}
fn decode_title_attr(bytes: &[u8]) -> Result<String, BailReason> {
let s = std::str::from_utf8(bytes).map_err(|_| BailReason::Classifier)?;
if !s.contains("&#") {
return Ok(s.to_owned());
}
let mut out = String::with_capacity(s.len());
let bytes_s = s.as_bytes();
let mut i = 0;
while i < bytes_s.len() {
let Some(rel) = memchr::memchr(b'&', &bytes_s[i..]) else {
out.push_str(&s[i..]);
break;
};
let amp_pos = i + rel;
if amp_pos > i {
out.push_str(&s[i..amp_pos]);
}
if amp_pos + 1 >= bytes_s.len() || bytes_s[amp_pos + 1] != b'#' {
out.push('&');
i = amp_pos + 1;
continue;
}
let mut j = amp_pos + 2;
while j < bytes_s.len() && bytes_s[j] != b';' {
j += 1;
}
if j >= bytes_s.len() {
out.push_str(&s[amp_pos..]);
break;
}
let body = &s[amp_pos + 2..j];
let cp_opt = if let Some(hex) = body.strip_prefix(['x', 'X']) {
u32::from_str_radix(hex, 16).ok()
} else {
body.parse::<u32>().ok()
};
if let Some(cp) = cp_opt {
if let Some(ch) = char::from_u32(cp) {
out.push(ch);
i = j + 1;
continue;
}
}
out.push_str(&s[amp_pos..=j]);
i = j + 1;
}
Ok(out)
}
fn extract_ol_start(attrs: &[(&[u8], Option<&[u8]>)]) -> u16 {
find_attr(attrs, b"start")
.and_then(|b| std::str::from_utf8(b).ok())
.and_then(|s| s.parse::<u16>().ok())
.unwrap_or(1)
}
fn decode_attr(bytes: &[u8]) -> Result<String, BailReason> {
let s = std::str::from_utf8(bytes).map_err(|_| BailReason::Classifier)?;
if !s.contains('&') {
return Ok(s.to_owned());
}
let mut out = String::with_capacity(s.len());
decode_entities_into(&mut out, s, 0)?;
Ok(out)
}
fn canonicalize_attr_entities(input: &str) -> std::borrow::Cow<'_, str> {
let needs_escape = input
.bytes()
.any(|b| matches!(b, b'&' | b'<' | b'>' | b'"') || b == 0xC2);
if !needs_escape {
return std::borrow::Cow::Borrowed(input);
}
let mut out = String::with_capacity(input.len() + 8);
for c in input.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\u{a0}' => out.push_str(" "),
_ => out.push(c),
}
}
std::borrow::Cow::Owned(out)
}
fn pop_matching_frame(stack: &mut Vec<OpenTag>, spec: &'static TagSpec) -> Option<OpenTag> {
let top = stack.last()?;
if kinds_match(&top.spec.kind, &spec.kind) {
stack.pop()
} else {
None
}
}
fn kinds_match(a: &TagKind, b: &TagKind) -> bool {
match (a, b) {
(TagKind::List(la), TagKind::List(lb)) => la == lb,
(TagKind::Heading(_), TagKind::Heading(_)) => true,
(TagKind::TableCell { is_header: a_h }, TagKind::TableCell { is_header: b_h }) => a_h == b_h,
_ => std::mem::discriminant(a) == std::mem::discriminant(b),
}
}
fn find_parent_list_kind(stack: &[OpenTag]) -> Option<ListKind> {
for frame in stack.iter().rev() {
if let TagKind::List(kind) = frame.spec.kind {
return Some(kind);
}
}
None
}
fn increment_ol_counter(stack: &mut [OpenTag]) -> u16 {
for frame in stack.iter_mut().rev() {
if frame.spec.kind == TagKind::List(ListKind::Ordered) {
frame.list_index = frame.list_index.saturating_add(1);
return frame.list_index;
}
}
1
}
fn find_ol_start(stack: &[OpenTag]) -> u16 {
for frame in stack.iter().rev() {
if frame.spec.kind == TagKind::List(ListKind::Ordered) {
return frame.ol_start;
}
}
1
}
fn heading_prefix(n: u8) -> &'static str {
let idx = (n as usize).saturating_sub(1).min(5);
HEADING_PREFIXES[idx]
}
fn push_list_item_indent(out: &mut String, depth: u16) {
let idx = depth as usize;
if idx < LIST_ITEM_INDENTS.len() {
out.push_str(LIST_ITEM_INDENTS[idx]);
} else {
out.reserve(idx * 2);
for _ in 0..idx {
out.push_str(" ");
}
}
}
fn prefix_blockquote_lines(content: &str) -> String {
let content = content.trim_end_matches('\n');
if content.is_empty() {
return String::new();
}
let lines: Vec<&str> = content.split('\n').collect();
let mut result = String::with_capacity(content.len() + lines.len() * 2);
for (i, line) in lines.iter().enumerate() {
if line.is_empty() {
result.push('>');
} else {
result.push_str("> ");
result.push_str(line);
}
if i < lines.len() - 1 {
result.push('\n');
}
}
result.push('\n');
result
}
fn indent_pre_lines(raw: &str) -> String {
let raw = raw.strip_prefix('\n').unwrap_or(raw);
let raw = raw.trim_end_matches('\n');
if raw.is_empty() {
return String::new();
}
let min_indent = raw
.lines()
.filter(|line| !line.trim().is_empty())
.map(|line| line.chars().take_while(|c| c.is_whitespace()).count())
.min()
.unwrap_or(0);
let mut result = String::with_capacity(raw.len() + raw.lines().count() * 4);
for line in raw.lines() {
if line.trim().is_empty() {
} else {
result.push_str(" ");
let mut remaining = min_indent;
let mut cut = 0;
for (idx, ch) in line.char_indices() {
if remaining == 0 {
break;
}
if ch.is_whitespace() {
remaining -= 1;
cut = idx + ch.len_utf8();
} else {
break;
}
}
result.push_str(&line[cut..]);
}
result.push('\n');
}
result
}
fn emit_gfm_table(target: &mut String, ts: crate::converter::tier1::state::TableState) {
if let Some(ref caption) = ts.caption_text {
if !caption.is_empty() {
if !target.is_empty() && !target.ends_with("\n\n") {
if target.ends_with('\n') {
target.push('\n');
} else {
target.push_str("\n\n");
}
}
target.push('*');
target.push_str(caption);
target.push_str("*\n\n");
}
}
if ts.rows.is_empty() {
return;
}
if !target.is_empty() && !target.ends_with("\n\n") {
if target.ends_with('\n') {
target.push('\n');
} else {
target.push_str("\n\n");
}
}
let col_count = ts
.rows
.iter()
.map(|r| r.iter().map(|(_, c)| usize::from(*c)).sum::<usize>())
.max()
.unwrap_or(0);
let mut col_widths: Vec<usize> = vec![0; col_count];
for row in &ts.rows {
let mut col = 0usize;
for (cell, span) in row {
let w = cell.chars().count();
if col < col_widths.len() && w > col_widths[col] {
col_widths[col] = w;
}
col += usize::from(*span);
}
}
for (row_index, row) in ts.rows.iter().enumerate() {
target.push('|');
let mut col = 0usize;
for (cell, span) in row {
target.push(' ');
target.push_str(cell);
let cell_len = cell.chars().count();
let col_w = col_widths.get(col).copied().unwrap_or(0);
for _ in cell_len..col_w {
target.push(' ');
}
for _ in 0..*span {
target.push_str(" |");
}
col += usize::from(*span);
}
target.push('\n');
if row_index == 0 {
target.push_str("| ");
for i in 0..col_count.max(1) {
if i > 0 {
target.push_str(" | ");
}
let dash_count = col_widths.get(i).copied().unwrap_or(0).max(MIN_SEPARATOR_DASHES);
for _ in 0..dash_count {
target.push('-');
}
}
target.push_str(" |\n");
}
}
}
fn trim_trailing_inline_whitespace(state: &mut Tier1State) {
let buf = state.cell_or_output_mut();
while buf.ends_with(' ') || buf.ends_with('\t') {
buf.pop();
}
}
fn collapse_excess_blank_lines(output: &mut String) {
let mut consecutive = 0usize;
output.retain(|c| {
if c == '\n' {
consecutive += 1;
consecutive <= 2
} else {
consecutive = 0;
true
}
});
}
fn decode_entity_into(out: &mut String, name: &str) -> bool {
let s: &str = match name {
"amp" => "&",
"lt" => "<",
"gt" => ">",
"quot" => "\"",
"apos" => "'",
"nbsp" => "\u{00A0}",
"copy" => "\u{00A9}",
"reg" => "\u{00AE}",
"trade" => "\u{2122}",
"mdash" => "\u{2014}",
"ndash" => "\u{2013}",
"hellip" => "\u{2026}",
"laquo" => "\u{00AB}",
"raquo" => "\u{00BB}",
"lsquo" => "\u{2018}",
"rsquo" => "\u{2019}",
"ldquo" => "\u{201C}",
"rdquo" => "\u{201D}",
"prime" => "\u{2032}",
"Prime" => "\u{2033}",
"bull" => "\u{2022}",
"middot" => "\u{00B7}",
"deg" => "\u{00B0}",
"plusmn" => "\u{00B1}",
"times" => "\u{00D7}",
"divide" => "\u{00F7}",
"frac12" => "\u{00BD}",
"frac14" => "\u{00BC}",
"frac34" => "\u{00BE}",
"euro" => "\u{20AC}",
"pound" => "\u{00A3}",
"yen" => "\u{00A5}",
"cent" => "\u{00A2}",
"larr" => "\u{2190}",
"rarr" => "\u{2192}",
"uarr" => "\u{2191}",
"darr" => "\u{2193}",
"harr" => "\u{2194}",
"infin" => "\u{221E}",
"alpha" => "\u{03B1}",
"beta" => "\u{03B2}",
"gamma" => "\u{03B3}",
"delta" => "\u{03B4}",
"pi" => "\u{03C0}",
"sigma" => "\u{03C3}",
"omega" => "\u{03C9}",
"iexcl" => "\u{00A1}",
"brvbar" => "\u{00A6}",
"sect" => "\u{00A7}",
"uml" => "\u{00A8}",
"ordf" => "\u{00AA}",
"not" => "\u{00AC}",
"shy" => "\u{00AD}",
"macr" => "\u{00AF}",
"sup2" => "\u{00B2}",
"sup3" => "\u{00B3}",
"acute" => "\u{00B4}",
"micro" => "\u{00B5}",
"para" => "\u{00B6}",
"cedil" => "\u{00B8}",
"sup1" => "\u{00B9}",
"ordm" => "º",
"iquest" => "\u{00BF}",
"Agrave" => "\u{00C0}",
"Aacute" => "\u{00C1}",
"Acirc" => "\u{00C2}",
"Atilde" => "\u{00C3}",
"Auml" => "\u{00C4}",
"Aring" => "\u{00C5}",
"AElig" => "\u{00C6}",
"Ccedil" => "\u{00C7}",
"Egrave" => "\u{00C8}",
"Eacute" => "\u{00C9}",
"Ecirc" => "\u{00CA}",
"Euml" => "\u{00CB}",
"Igrave" => "\u{00CC}",
"Iacute" => "\u{00CD}",
"Icirc" => "\u{00CE}",
"Iuml" => "\u{00CF}",
"ETH" => "\u{00D0}",
"Ntilde" => "\u{00D1}",
"Ograve" => "\u{00D2}",
"Oacute" => "\u{00D3}",
"Ocirc" => "\u{00D4}",
"Otilde" => "\u{00D5}",
"Ouml" => "\u{00D6}",
"Oslash" => "\u{00D8}",
"Ugrave" => "\u{00D9}",
"Uacute" => "\u{00DA}",
"Ucirc" => "\u{00DB}",
"Uuml" => "\u{00DC}",
"Yacute" => "\u{00DD}",
"THORN" => "\u{00DE}",
"szlig" => "\u{00DF}",
"agrave" => "\u{00E0}",
"aacute" => "\u{00E1}",
"acirc" => "\u{00E2}",
"atilde" => "\u{00E3}",
"auml" => "\u{00E4}",
"aring" => "\u{00E5}",
"aelig" => "\u{00E6}",
"ccedil" => "\u{00E7}",
"egrave" => "\u{00E8}",
"eacute" => "\u{00E9}",
"ecirc" => "\u{00EA}",
"euml" => "\u{00EB}",
"igrave" => "\u{00EC}",
"iacute" => "\u{00ED}",
"icirc" => "\u{00EE}",
"iuml" => "\u{00EF}",
"eth" => "\u{00F0}",
"ntilde" => "\u{00F1}",
"ograve" => "\u{00F2}",
"oacute" => "\u{00F3}",
"ocirc" => "\u{00F4}",
"otilde" => "\u{00F5}",
"ouml" => "\u{00F6}",
"oslash" => "\u{00F8}",
"ugrave" => "\u{00F9}",
"uacute" => "\u{00FA}",
"ucirc" => "\u{00FB}",
"uuml" => "\u{00FC}",
"yacute" => "\u{00FD}",
"thorn" => "\u{00FE}",
"yuml" => "\u{00FF}",
_ => return decode_numeric_entity_into(out, name),
};
out.push_str(s);
true
}
fn decode_numeric_entity_into(out: &mut String, name: &str) -> bool {
let Some(rest) = name.strip_prefix('#') else {
return false;
};
let code_point = if rest.starts_with('x') || rest.starts_with('X') {
match u32::from_str_radix(&rest[1..], 16) {
Ok(n) => n,
Err(_) => return false,
}
} else {
match rest.parse::<u32>() {
Ok(n) => n,
Err(_) => return false,
}
};
match char::from_u32(code_point) {
Some(ch) => {
out.push(ch);
true
}
None => false,
}
}
fn skip_bang(bytes: &[u8], pos: usize) -> Result<usize, BailReason> {
let start = pos + 2;
if bytes.get(start) == Some(&b'-') && bytes.get(start + 1) == Some(&b'-') {
let comment_start = start + 2;
let mut i = comment_start;
while i + 2 < bytes.len() {
if bytes[i] == b'-' && bytes[i + 1] == b'-' && bytes[i + 2] == b'>' {
return Ok(i + 3);
}
i += 1;
}
return Err(BailReason::LiteralLt { offset: pos });
}
let mut i = start;
while i < bytes.len() {
if bytes[i] == b'>' {
return Ok(i + 1);
}
i += 1;
}
Err(BailReason::LiteralLt { offset: pos })
}
fn lowercase_into<'b>(bytes: &[u8], buf: &'b mut [u8; MAX_TAG_NAME_BYTES]) -> &'b [u8] {
let len = bytes.len().min(MAX_TAG_NAME_BYTES);
for (i, &b) in bytes[..len].iter().enumerate() {
buf[i] = b.to_ascii_lowercase();
}
&buf[..len]
}
fn bytes_to_string(b: &[u8]) -> String {
String::from_utf8_lossy(b).into_owned()
}