use ecow::{EcoString, EcoVec, eco_vec};
use typst_library::diag::{SourceResult, bail, warning};
use typst_library::engine::Engine;
use typst_library::foundations::{Content, Packed, StyleChain, Target, TargetElem};
use typst_library::introspection::{SplitLocator, TagElem};
use typst_library::layout::{
Abs, Axes, BlockBody, BlockElem, BoxElem, HElem, Region, Size,
};
use typst_library::routines::Pair;
use typst_library::text::{
LinebreakElem, SmartQuoteElem, SmartQuoter, SmartQuotes, SpaceElem, TextElem,
is_default_ignorable,
};
use typst_syntax::Span;
use typst_utils::SliceExt;
use crate::fragment::{html_block_fragment, html_inline_fragment, html_math_fragment};
use crate::{
FrameElem, HtmlElem, HtmlElement, HtmlFrame, HtmlNode, attr, css, property, tag,
};
pub enum ConversionLevel<'a> {
Block,
Inline(&'a mut SmartQuoter),
}
#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
pub enum Whitespace {
Normal,
Pre,
}
pub fn convert_to_nodes<'a>(
engine: &mut Engine,
locator: &mut SplitLocator,
children: impl IntoIterator<Item = Pair<'a>>,
level: ConversionLevel,
whitespace: Whitespace,
) -> SourceResult<EcoVec<HtmlNode>> {
let block = matches!(level, ConversionLevel::Block);
let mut converter = Converter {
engine,
locator,
quoter: match level {
ConversionLevel::Inline(quoter) => quoter,
ConversionLevel::Block => &mut SmartQuoter::new(),
},
whitespace,
output: EcoVec::new(),
trailing: None,
};
for (child, styles) in children {
handle(&mut converter, child, styles)?;
}
let mut nodes = converter.finish();
if block && whitespace == Whitespace::Normal {
protect_spaces(&mut nodes);
}
Ok(nodes)
}
fn handle(
converter: &mut Converter,
child: &Content,
styles: StyleChain,
) -> SourceResult<()> {
if let Some(elem) = child.to_packed::<TagElem>() {
converter.push(elem.tag.clone());
} else if let Some(elem) = child.to_packed::<HtmlElem>() {
handle_html_elem(converter, elem, styles)?;
} else if child.is::<SpaceElem>() {
converter.push(HtmlNode::text(' ', child.span()));
} else if let Some(elem) = child.to_packed::<TextElem>() {
let text = if let Some(case) = styles.get(TextElem::case) {
case.apply(&elem.text).into()
} else {
elem.text.clone()
};
handle_text(converter, text, elem.span());
} else if let Some(elem) = child.to_packed::<HElem>()
&& elem.amount.is_zero()
{
} else if let Some(elem) = child.to_packed::<LinebreakElem>() {
converter.push(match converter.whitespace {
Whitespace::Normal => HtmlElement::new(tag::br).spanned(elem.span()).into(),
Whitespace::Pre => HtmlNode::text("\n", elem.span()),
});
} else if let Some(elem) = child.to_packed::<SmartQuoteElem>() {
let double = elem.double.get(styles);
let quote = if elem.enabled.get(styles) {
let before = last_char(&converter.output);
let quotes = SmartQuotes::get(
elem.quotes.get_ref(styles),
styles.get(TextElem::lang),
styles.get(TextElem::region),
elem.alternative.get(styles),
);
converter.quoter.quote(before, "es, double)
} else {
SmartQuotes::fallback(double)
};
handle_text(converter, quote.into(), child.span());
} else if let Some(elem) = child.to_packed::<BoxElem>() {
handle_box(converter, elem, styles)?;
} else if let Some(elem) = child.to_packed::<BlockElem>() {
handle_block(converter, elem, styles)?;
} else if let Some(elem) = child.to_packed::<FrameElem>() {
let locator = converter.locator.next(&elem.span());
let style = TargetElem::target.set(Target::Paged).wrap();
let frame = (converter.engine.library.routines.layout_frame)(
converter.engine,
&elem.body,
locator,
styles.chain(&style),
Region::new(Size::splat(Abs::inf()), Axes::splat(false)),
)?;
let mut node = HtmlFrame::new(frame, styles, elem.span()).into();
make_block_level(&mut node).unwrap();
converter.push(node);
} else {
converter.engine.sink.warn(warning!(
child.span(),
"{} was ignored during HTML export",
child.elem().name(),
));
}
Ok(())
}
fn handle_html_elem(
converter: &mut Converter,
elem: &Packed<HtmlElem>,
styles: StyleChain,
) -> SourceResult<()> {
let role = styles.get_cloned(HtmlElem::role).filter(|_| elem.tag != tag::p);
let mut children = EcoVec::new();
if let Some(body) = elem.body.get_ref(styles) {
let whitespace = if converter.whitespace == Whitespace::Pre
|| elem.tag == tag::pre
|| tag::is_raw(elem.tag)
|| tag::is_escapable_raw(elem.tag)
{
Whitespace::Pre
} else {
Whitespace::Normal
};
let unset;
let styles = if role.is_some() {
unset = HtmlElem::role.set(None).wrap();
styles.chain(&unset)
} else {
styles
};
if property::Display::default_for(elem.tag) == Some(property::Display::Block) {
children = html_block_fragment(
converter.engine,
body,
converter.locator.next(&elem.span()),
styles,
whitespace,
)?;
*converter.quoter = SmartQuoter::new();
} else if tag::mathml::is_mathml(elem.tag) {
children = html_math_fragment(
converter.engine,
body,
converter.locator,
converter.quoter,
styles,
whitespace,
)?;
} else {
children = html_inline_fragment(
converter.engine,
body,
converter.locator,
converter.quoter,
styles,
whitespace,
)?;
}
}
let mut attrs = elem.attrs.get_cloned(styles);
if let Some(role) = role {
attrs.push(attr::role, role);
}
converter.push(HtmlElement {
tag: elem.tag,
attrs,
css: elem.css.get_cloned(styles),
children,
parent: elem.parent,
span: elem.span(),
pre_span: false,
});
Ok(())
}
fn handle_text(converter: &mut Converter, text: EcoString, span: Span) {
#[derive(Debug, Copy, Clone, Eq, PartialEq)]
enum Kind {
Space,
Tab,
Newline,
Ignorable,
}
impl Kind {
fn of(c: char) -> Option<Kind> {
match c {
' ' => Some(Kind::Space),
'\t' => Some(Kind::Tab),
'\r' | '\n' => Some(Kind::Newline),
c if is_default_ignorable(c) => Some(Kind::Ignorable),
_ => None,
}
}
}
if converter.whitespace == Whitespace::Pre {
converter.push(HtmlNode::Text(text, span));
return;
}
let mut emitted = 0;
let mut prev_kind = None;
for (i, c) in text.char_indices() {
let kind = Kind::of(c);
let prev_kind = prev_kind.replace(kind);
let Some(kind) = kind else { continue };
if kind == Kind::Space
&& let Some(None) = prev_kind
&& let Some(after) = text[i + 1..].chars().next()
&& Kind::of(after).is_none()
{
continue;
}
if emitted < i {
converter.push_text(&text[emitted..i], span);
emitted = i;
}
match kind {
Kind::Space => converter.push_text(' ', span),
Kind::Tab => converter.push_text('\t', span),
Kind::Newline => {
if c == '\r' && text[i + 1..].starts_with('\n') {
emitted += 1;
continue;
}
converter.push(HtmlElement::new(tag::br).spanned(span));
}
Kind::Ignorable => converter.push_text(c, span),
}
emitted += c.len_utf8();
}
if emitted < text.len() {
converter.push_text(
if emitted == 0 { text } else { text[emitted..].into() },
span,
);
}
}
fn handle_box(
converter: &mut Converter,
elem: &Packed<BoxElem>,
styles: StyleChain,
) -> SourceResult<()> {
let mut children = EcoVec::new();
if let Some(body) = elem.body.get_ref(styles) {
children = html_inline_fragment(
converter.engine,
body,
converter.locator,
converter.quoter,
styles,
converter.whitespace,
)?;
if let Some(node) = to_lone_element(&mut children) {
make_inline_level(node);
converter.extend(children);
return Ok(());
}
}
converter.push(
HtmlElement::new(tag::span)
.with_css(css::Properties::new().with("display", "inline-block"))
.with_children(children)
.spanned(elem.span()),
);
Ok(())
}
fn make_inline_level(node: &mut HtmlNode) {
let mode = if let HtmlNode::Element(element) = node
&& element.tag == tag::mathml::math
&& element.attrs.get(attr::mathml::display).is_some_and(|v| v == "block")
{
Some(property::Display::InlineMath)
} else {
None
};
set_display(node, mode);
}
fn handle_block(
converter: &mut Converter,
elem: &Packed<BlockElem>,
styles: StyleChain,
) -> SourceResult<()> {
let body = match elem.body.get_ref(styles) {
None => None,
Some(BlockBody::Content(body)) => Some(body),
Some(BlockBody::SingleLayouter(_) | BlockBody::MultiLayouter(_)) => {
bail!(
elem.span(),
"blocks with layout routines should not occur in \
HTML export – this is a bug";
)
}
};
let mut children = EcoVec::new();
if let Some(body) = body {
children = html_block_fragment(
converter.engine,
body,
converter.locator.next(&elem.span()),
styles,
converter.whitespace,
)?;
if let Some(node) = to_lone_element(&mut children)
&& make_block_level(node).is_ok()
{
converter.extend(children);
return Ok(());
}
}
converter.push(
HtmlElement::new(tag::div)
.with_children(children)
.spanned(elem.span()),
);
Ok(())
}
fn make_block_level(node: &mut HtmlNode) -> Result<(), Unblockable> {
let default = match node {
HtmlNode::Element(element)
if element.tag == tag::mathml::math
&& element
.attrs
.get(attr::mathml::display)
.is_some_and(|v| v == "block") =>
{
Some(property::Display::BlockMath)
}
HtmlNode::Element(element) => property::Display::default_for(element.tag),
HtmlNode::Frame(_) => Some(property::Display::Inline),
_ => return Err(Unblockable),
};
let mode = match default {
Some(
property::Display::None
| property::Display::Block
| property::Display::Table
| property::Display::ListItem
| property::Display::Contents
| property::Display::BlockMath,
) => None,
None | Some(property::Display::Inline | property::Display::InlineBlock) => {
Some(property::Display::Block)
}
Some(property::Display::InlineMath) => Some(property::Display::BlockMath),
_ => return Err(Unblockable),
};
set_display(node, mode);
Ok(())
}
#[derive(Debug, Copy, Clone)]
struct Unblockable;
fn to_lone_element(nodes: &mut EcoVec<HtmlNode>) -> Option<&mut HtmlNode> {
let (start, end) = nodes.split_prefix_suffix(|node| matches!(node, HtmlNode::Tag(_)));
matches!(&nodes[start..end], [HtmlNode::Element(_) | HtmlNode::Frame(_)])
.then(|| &mut nodes.make_mut()[start])
}
fn set_display(node: &mut HtmlNode, display: Option<property::Display>) {
let css = match node {
HtmlNode::Element(element) => &mut element.css,
HtmlNode::Frame(frame) => &mut frame.css,
_ => return,
};
match display {
Some(display) => css.push("display", display.as_str()),
None => css.remove("display"),
}
}
struct Converter<'a, 'y, 'z> {
engine: &'a mut Engine<'y>,
locator: &'a mut SplitLocator<'z>,
quoter: &'a mut SmartQuoter,
whitespace: Whitespace,
output: EcoVec<HtmlNode>,
trailing: Option<TrailingWhitespace>,
}
struct TrailingWhitespace {
single: bool,
from: usize,
}
impl Converter<'_, '_, '_> {
fn finish(mut self) -> EcoVec<HtmlNode> {
self.flush_whitespace();
self.output
}
fn push(&mut self, node: impl Into<HtmlNode>) {
let node = node.into();
if let HtmlNode::Text(text, _) = &node
&& (text == " " || text == "\t")
{
if let Some(ws) = &mut self.trailing {
ws.single = false;
} else {
self.trailing = Some(TrailingWhitespace {
single: text == " ",
from: self.output.len(),
});
}
} else if !matches!(node, HtmlNode::Tag(_)) {
self.flush_whitespace();
}
self.output.push(node);
}
fn extend(&mut self, nodes: impl IntoIterator<Item = HtmlNode>) {
for node in nodes {
self.push(node);
}
}
fn push_text(&mut self, text: impl Into<EcoString>, span: Span) {
self.push(HtmlNode::text(text.into(), span));
}
fn flush_whitespace(&mut self) {
if self.whitespace == Whitespace::Normal
&& let Some(TrailingWhitespace { single: false, from }) = self.trailing.take()
{
let nodes: EcoVec<_> = self.output[from..].iter().cloned().collect();
self.output.truncate(from);
self.output.push(HtmlNode::Element(pre_wrap(nodes)));
}
}
}
fn protect_spaces(nodes: &mut EcoVec<HtmlNode>) {
let mut p = Protector::new();
p.visit_nodes(nodes);
p.collapsing();
}
enum Protector<'a> {
Collapsing,
Supportive,
Space(&'a mut HtmlNode),
}
impl<'a> Protector<'a> {
fn new() -> Self {
Self::Collapsing
}
fn visit_nodes(&mut self, nodes: &'a mut EcoVec<HtmlNode>) {
for node in nodes.make_mut().iter_mut() {
match node {
HtmlNode::Tag(_) => {}
HtmlNode::Text(text, _) => {
if text == " " {
match self {
Self::Collapsing => {
protect_space(node);
*self = Self::Supportive;
}
Self::Supportive => {
*self = Self::Space(node);
}
Self::Space(prev) => {
protect_space(prev);
*self = Self::Space(node);
}
}
} else if text.chars().any(|c| !is_default_ignorable(c)) {
self.supportive();
}
}
HtmlNode::Element(element) => {
if tag::is_whitespace_collapsing(element.tag) {
self.collapsing();
} else if tag::is_replaced(element.tag) {
self.supportive();
} else if !element.pre_span {
self.visit_nodes(&mut element.children);
}
}
HtmlNode::Frame(_) => self.supportive(),
}
}
}
fn collapsing(&mut self) {
if let Self::Space(node) = std::mem::replace(self, Self::Collapsing) {
protect_space(node);
}
}
fn supportive(&mut self) {
*self = Self::Supportive;
}
}
fn protect_space(node: &mut HtmlNode) {
*node = pre_wrap(eco_vec![node.clone()]).into();
}
fn pre_wrap(nodes: EcoVec<HtmlNode>) -> HtmlElement {
let span = Span::find(nodes.iter().map(|c| c.span()));
let mut elem = HtmlElement::new(tag::span)
.with_css(css::Properties::new().with("white-space", "pre-wrap"))
.with_children(nodes)
.spanned(span);
elem.pre_span = true;
elem
}
fn last_char(nodes: &[HtmlNode]) -> Option<char> {
for node in nodes.iter().rev() {
if let Some(c) = match node {
HtmlNode::Text(s, _) => s.chars().rev().find(|&c| !is_default_ignorable(c)),
HtmlNode::Element(e) => last_char(&e.children),
_ => None,
} {
return Some(c);
}
}
None
}