///|
/// Selects whether conversion renders HTML or Markdown.
pub(all) enum OutputFormat {
Html
Markdown
} derive(Debug, Eq)
///|
priv struct ConvertOptions {
style_map : Array[StyleMapping]
ignore_empty_paragraphs : Bool
id_prefix : String
}
///|
priv struct ConvertedNodes {
nodes : Array[HtmlNode]
messages : Array[Message]
}
///|
/// Rendered image nodes plus any diagnostics from image conversion.
pub(all) struct ImageConversion {
nodes : Array[HtmlNode]
messages : Array[Message]
} derive(Debug, Eq)
///|
/// Builds an image conversion result from rendered nodes and diagnostics.
pub fn image_conversion(
nodes : Array[HtmlNode],
messages? : Array[Message] = [],
) -> ImageConversion {
{ nodes, messages }
}
///|
/// Converts a document tree to HTML.
pub fn convert_document_to_html(
document : DocumentElement,
style_map? : Array[String] = [],
include_default_style_map? : Bool = true,
ignore_empty_paragraphs? : Bool = true,
id_prefix? : String = "",
pretty_print? : Bool = false,
convert_image? : (Image) -> ImageConversion = data_uri_image_converter,
transform_document? : (DocumentElement) -> DocumentElement = identity_document_transform,
) -> ConversionResult {
convert_document(
document,
output_format=Html,
style_map~,
include_default_style_map~,
ignore_empty_paragraphs~,
id_prefix~,
pretty_print~,
convert_image~,
transform_document~,
)
}
///|
/// Converts a document tree to Markdown.
pub fn convert_document_to_markdown(
document : DocumentElement,
style_map? : Array[String] = [],
include_default_style_map? : Bool = true,
ignore_empty_paragraphs? : Bool = true,
id_prefix? : String = "",
convert_image? : (Image) -> ImageConversion = data_uri_image_converter,
transform_document? : (DocumentElement) -> DocumentElement = identity_document_transform,
) -> ConversionResult {
convert_document(
document,
output_format=Markdown,
style_map~,
include_default_style_map~,
ignore_empty_paragraphs~,
id_prefix~,
convert_image~,
transform_document~,
)
}
///|
/// Converts a document tree using the requested output format.
pub fn convert_document(
document : DocumentElement,
output_format? : OutputFormat = Html,
style_map? : Array[String] = [],
include_default_style_map? : Bool = true,
ignore_empty_paragraphs? : Bool = true,
id_prefix? : String = "",
pretty_print? : Bool = false,
convert_image? : (Image) -> ImageConversion = data_uri_image_converter,
transform_document? : (DocumentElement) -> DocumentElement = identity_document_transform,
) -> ConversionResult {
let converted = convert_document_to_nodes(
document,
style_map~,
include_default_style_map~,
ignore_empty_paragraphs~,
id_prefix~,
convert_image~,
transform_document~,
)
let nodes = simplify_html(converted.nodes)
let value = match output_format {
Html => write_html(nodes, pretty_print~)
Markdown => write_markdown(nodes)
}
{ value, messages: converted.messages }
}
///|
fn convert_document_to_nodes(
document : DocumentElement,
style_map? : Array[String] = [],
include_default_style_map? : Bool = true,
ignore_empty_paragraphs? : Bool = true,
id_prefix? : String = "",
convert_image? : (Image) -> ImageConversion = data_uri_image_converter,
transform_document? : (DocumentElement) -> DocumentElement = identity_document_transform,
) -> ConvertedNodes {
let document = transform_document(document)
let all_style_lines : Array[String] = []
all_style_lines.append(style_map)
if include_default_style_map {
all_style_lines.append(default_style_map_lines())
}
let parsed_style_map = @style_map.read_style_map_with_messages(
all_style_lines,
)
let options = ConvertOptions::{
style_map: parsed_style_map.mappings,
ignore_empty_paragraphs,
id_prefix,
}
let converter = DocumentConverter::{
options,
messages: parsed_style_map.messages,
note_references: [],
note_number: 1,
comments: document_comments(document),
comment_references: [],
comment_number: 1,
image_converter: convert_image,
}
let nodes = converter.convert_element(document)
{ nodes, messages: @core.dedupe_messages(converter.messages) }
}
///|
priv struct DocumentConverter {
options : ConvertOptions
messages : Array[Message]
note_references : Array[NoteReferenceInfo]
mut note_number : Int
comments : Array[Comment]
comment_references : Array[CommentReferenceInfo]
mut comment_number : Int
image_converter : (Image) -> ImageConversion
}
///|
fn document_comments(document : DocumentElement) -> Array[Comment] {
match document {
Document(comments~, ..) => comments
_ => []
}
}
///|
priv struct NoteReferenceInfo {
note_type : String
note_id : String
}
///|
priv struct CommentReferenceInfo {
comment_id : String
label : String
}
///|
fn DocumentConverter::convert_element(
self : DocumentConverter,
element : DocumentElement,
) -> Array[HtmlNode] {
match element {
Document(children~, notes~, comments~) => {
let nodes = self.convert_children(children)
let note_nodes = self.convert_referenced_notes(notes)
let comment_nodes = self.convert_comments(comments)
nodes + note_nodes + comment_nodes
}
Paragraph(children~, properties~) =>
self.convert_paragraph(element, children, properties)
Run(children~, properties~) =>
self.convert_run(element, children, properties)
Text(value) => [html_text(value)]
Tab => [html_text("\t")]
Checkbox(checked) => {
let attrs : Map[String, String] = { "type": "checkbox" }
if checked {
attrs["checked"] = "checked"
}
[html_element("input", attributes=attrs)]
}
Hyperlink(children~, href~, anchor~, target_frame~) => {
let attrs : Map[String, String] = Map([])
match (href, anchor) {
(Some(value), None) => attrs["href"] = value
(Some(value), Some(anchor)) =>
attrs["href"] = replace_url_fragment(value, anchor)
(None, Some(value)) =>
attrs["href"] = "#" + self.options.id_prefix + value
(None, None) => ()
}
match target_frame {
Some(value) => if value != "" { attrs["target"] = value }
None => ()
}
let converted_children = self.convert_children(children)
if attrs.is_empty() {
converted_children
} else {
[html_element("a", attributes=attrs, children=converted_children)]
}
}
Break(break_type) => self.convert_break(element, break_type)
BookmarkStart(name) =>
[
html_element(
"a",
attributes={ "id": self.options.id_prefix + name },
children=[ForceWrite],
fresh=true,
),
]
Table(children~, properties~) =>
self.convert_table(element, children, properties)
TableRow(children~, is_header~) =>
[self.convert_table_row(children, as_header=is_header)]
TableCell(children~, col_span~, row_span~) =>
[self.convert_table_cell(children, col_span~, row_span~, as_header=false)]
Image(image) => self.convert_image(image)
NoteReference(note_type~, note_id~) => {
let label = self.note_number.to_string()
self.note_number = self.note_number + 1
self.note_references.push({ note_type, note_id })
let id = self.options.id_prefix + note_type + "-ref-" + note_id
let href = "#" + self.options.id_prefix + note_type + "-" + note_id
[
html_element("sup", children=[
html_element("a", attributes={ "href": href, "id": id }, children=[
html_text("[" + label + "]"),
]),
]),
]
}
CommentReference(comment_id) =>
self.convert_comment_reference(element, comment_id)
}
}
///|
fn DocumentConverter::convert_break(
self : DocumentConverter,
element : DocumentElement,
break_type : BreakType,
) -> Array[HtmlNode] {
match @style_map.find_style_mapping(element, self.options.style_map) {
Some(mapping) => wrap_html_path(mapping.to, [])
None =>
match break_type {
Line => [html_element("br")]
Page | Column => []
}
}
}
///|
fn replace_url_fragment(href : String, fragment : String) -> String {
let base = match href.find("#") {
Some(index) => href[:index].to_owned()
None => href
}
base + "#" + fragment
}
///|
fn DocumentConverter::convert_children(
self : DocumentConverter,
children : Array[DocumentElement],
) -> Array[HtmlNode] {
let nodes : Array[HtmlNode] = []
for child in children {
nodes.append(self.convert_element(child))
}
nodes
}
///|
fn DocumentConverter::convert_paragraph(
self : DocumentConverter,
element : DocumentElement,
children : Array[DocumentElement],
properties : ParagraphProperties,
) -> Array[HtmlNode] {
let content = self.convert_children(children)
if self.options.ignore_empty_paragraphs && html_nodes_are_empty(content) {
[]
} else {
let default_path : Array[HtmlPathElement] = [
{ tag: "p", attributes: Map([]), fresh: true, separator: None },
]
if @style_map.find_style_mapping(element, self.options.style_map) == None {
self.warn_unrecognised_style(
"paragraph",
properties.style_id,
properties.style_name,
)
}
let content = if self.options.ignore_empty_paragraphs {
content
} else {
force_write_nodes(content)
}
self.wrap_with_style(element, content, default_path)
}
}
///|
fn DocumentConverter::convert_run(
self : DocumentConverter,
element : DocumentElement,
children : Array[DocumentElement],
properties : RunProperties,
) -> Array[HtmlNode] {
let mut nodes = self.convert_children(children)
let paths : Array[Array[HtmlPathElement]] = []
match properties.highlight {
Some(_) =>
match
@style_map.find_style_mapping(
Run(children~, properties~),
[
for mapping in self.options.style_map if mapping.from
is HighlightMatcher(_) => mapping
],
) {
Some(mapping) => paths.push(mapping.to)
None => ()
}
None => ()
}
if properties.is_small_caps {
paths.push(path_for_property(self, element, "smallCaps", None))
}
if properties.is_all_caps {
paths.push(path_for_property(self, element, "allCaps", None))
}
if properties.is_strikethrough {
paths.push(path_for_property(self, element, "strikethrough", Some("s")))
}
if properties.is_underline {
paths.push(path_for_property(self, element, "underline", None))
}
match properties.vertical_alignment {
Superscript =>
paths.push([
{ tag: "sup", attributes: Map([]), fresh: false, separator: None },
])
Subscript =>
paths.push([
{ tag: "sub", attributes: Map([]), fresh: false, separator: None },
])
Baseline => ()
}
if properties.is_italic {
paths.push(path_for_property(self, element, "italic", Some("em")))
}
if properties.is_bold {
paths.push(path_for_property(self, element, "bold", Some("strong")))
}
match find_run_style_mapping(element, self.options.style_map) {
Some(mapping) => paths.push(mapping.to)
None =>
self.warn_unrecognised_style(
"run",
properties.style_id,
properties.style_name,
)
}
for path in paths {
nodes = wrap_html_path(path, nodes)
}
nodes
}
///|
fn DocumentConverter::warn_unrecognised_style(
self : DocumentConverter,
kind : String,
style_id : String?,
style_name : String?,
) -> Unit {
match (style_id, style_name) {
(Some(id), Some(name)) =>
self.messages.push(
Warning(
"Unrecognised " +
kind +
" style: '" +
name +
"' (Style ID: " +
id +
")",
),
)
_ => ()
}
}
///|
fn path_for_property(
converter : DocumentConverter,
_element : DocumentElement,
property : String,
default_tag : String?,
) -> Array[HtmlPathElement] {
let matcher = @style_map.RunPropertyMatcher(property)
for mapping in converter.options.style_map {
if mapping.from == matcher {
return mapping.to
}
}
match default_tag {
Some(tag) => [{ tag, attributes: Map([]), fresh: false, separator: None }]
None => []
}
}
///|
fn find_run_style_mapping(
element : DocumentElement,
style_map : Array[StyleMapping],
) -> StyleMapping? {
for mapping in style_map {
match mapping.from {
RunMatcher(..) =>
if mapping.from.matches(element) {
return Some(mapping)
}
_ => ()
}
}
None
}
///|
fn DocumentConverter::convert_table(
self : DocumentConverter,
element : DocumentElement,
children : Array[DocumentElement],
properties : TableProperties,
) -> Array[HtmlNode] {
let rows = force_write_nodes(self.convert_table_children(children))
let default_path : Array[HtmlPathElement] = [
{ tag: "table", attributes: Map([]), fresh: true, separator: None },
]
ignore(properties)
self.wrap_with_style(element, rows, default_path)
}
///|
fn DocumentConverter::convert_table_children(
self : DocumentConverter,
children : Array[DocumentElement],
) -> Array[HtmlNode] {
let body_index = first_body_row_index(children)
if body_index == 0 {
self.convert_children(children)
} else {
[
html_element(
"thead",
children=self.convert_table_segment(
children,
start=0,
end=body_index,
as_header=true,
),
fresh=true,
),
html_element(
"tbody",
children=self.convert_table_segment(
children,
start=body_index,
end=children.length(),
as_header=false,
),
fresh=true,
),
]
}
}
///|
fn first_body_row_index(children : Array[DocumentElement]) -> Int {
let mut index = 0
for child in children {
match child {
TableRow(is_header=true, ..) => index = index + 1
_ => return index
}
}
index
}
///|
fn DocumentConverter::convert_table_segment(
self : DocumentConverter,
children : Array[DocumentElement],
start~ : Int,
end~ : Int,
as_header~ : Bool,
) -> Array[HtmlNode] {
let nodes : Array[HtmlNode] = []
for index in start..
nodes.push(self.convert_table_row(row_children, as_header~))
other => nodes.append(self.convert_element(other))
}
}
nodes
}
///|
fn DocumentConverter::convert_table_row(
self : DocumentConverter,
children : Array[DocumentElement],
as_header~ : Bool,
) -> HtmlNode {
let nodes : Array[HtmlNode] = []
for child in children {
match child {
TableCell(children=cell_children, col_span~, row_span~) =>
nodes.push(
self.convert_table_cell(
cell_children,
col_span~,
row_span~,
as_header~,
),
)
_ => nodes.append(self.convert_element(child))
}
}
html_element("tr", children=force_write_nodes(nodes), fresh=true)
}
///|
fn DocumentConverter::convert_table_cell(
self : DocumentConverter,
children : Array[DocumentElement],
col_span~ : Int,
row_span~ : Int,
as_header~ : Bool,
) -> HtmlNode {
let attrs : Map[String, String] = Map([])
if col_span != 1 {
attrs["colspan"] = col_span.to_string()
}
if row_span != 1 {
attrs["rowspan"] = row_span.to_string()
}
let tag = if as_header { "th" } else { "td" }
html_element(
tag,
attributes=attrs,
children=force_write_nodes(self.convert_children(children)),
fresh=true,
)
}
///|
fn force_write_nodes(children : Array[HtmlNode]) -> Array[HtmlNode] {
let nodes = [@html.ForceWrite]
nodes.append(children)
nodes
}
///|
fn DocumentConverter::convert_image(
self : DocumentConverter,
image : Image,
) -> Array[HtmlNode] {
let result = (self.image_converter)(image)
self.messages.append(result.messages)
result.nodes
}
///|
/// Converts images to inline data URI image nodes.
pub fn data_uri_image_converter(image : Image) -> ImageConversion {
img_element(fn(image) {
{
"src": "data:" +
image.content_type +
";base64," +
@base64.encode(image.data),
}
})(image)
}
///|
/// Converts one image to an inline data URI image result.
pub fn data_uri_image(image : Image) -> ImageConversion {
data_uri_image_converter(image)
}
///|
/// Creates an image converter that emits an `img` element.
pub fn img_element(
read_attributes : (Image) -> Map[String, String],
) -> (Image) -> ImageConversion {
fn(image) {
let attrs : Map[String, String] = Map([])
match image.alt_text {
Some(alt) => attrs["alt"] = alt
None => ()
}
for item in read_attributes(image) {
let (key, value) = item
attrs[key] = value
}
image_conversion([fresh_html_element("img", attributes=attrs)])
}
}
///|
/// Alias for `img_element` matching Mammoth image API style.
pub fn inline_image(
read_attributes : (Image) -> Map[String, String],
) -> (Image) -> ImageConversion {
img_element(read_attributes)
}
///|
fn DocumentConverter::convert_referenced_notes(
self : DocumentConverter,
notes : Array[Note],
) -> Array[HtmlNode] {
if self.note_references.is_empty() {
return []
}
let items : Array[HtmlNode] = []
for reference in self.note_references {
match find_note(notes, reference.note_type, reference.note_id) {
Some(note) => items.push(self.convert_note(note, reference))
None => ()
}
}
if items.is_empty() {
[]
} else {
[html_element("ol", children=items, fresh=true)]
}
}
///|
fn find_note(
notes : Array[Note],
note_type : String,
note_id : String,
) -> Note? {
for note in notes {
if note.note_type == note_type && note.note_id == note_id {
return Some(note)
}
}
None
}
///|
fn DocumentConverter::convert_note(
self : DocumentConverter,
note : Note,
_reference : NoteReferenceInfo,
) -> HtmlNode {
let body = self.convert_children(note.body)
let backlink = html_element(
"a",
attributes={
"href": "#" +
self.options.id_prefix +
note.note_type +
"-ref-" +
note.note_id,
},
children=[html_text("↑")],
fresh=true,
)
append_note_backlink(body, backlink)
html_element(
"li",
attributes={
"id": self.options.id_prefix + note.note_type + "-" + note.note_id,
},
children=body,
fresh=true,
)
}
///|
fn append_note_backlink(nodes : Array[HtmlNode], backlink : HtmlNode) -> Unit {
if nodes.length() == 0 {
nodes.push(html_element("p", children=[html_text(" "), backlink]))
} else {
let last_index = nodes.length() - 1
match nodes[last_index] {
Element(tag="p", attributes~, children~, fresh~, separator~) =>
nodes[last_index] = Element(
tag="p",
attributes~,
children=children + [html_text(" "), backlink],
fresh~,
separator~,
)
_ => nodes.push(html_element("p", children=[html_text(" "), backlink]))
}
}
}
///|
fn DocumentConverter::convert_comment_reference(
self : DocumentConverter,
element : DocumentElement,
comment_id : String,
) -> Array[HtmlNode] {
match @style_map.find_style_mapping(element, self.options.style_map) {
Some(mapping) =>
match self.find_comment(comment_id) {
Some(comment) => {
let label = "[" +
comment_author_label(comment) +
self.comment_number.to_string() +
"]"
self.comment_number = self.comment_number + 1
self.comment_references.push({ comment_id, label })
let id = self.options.id_prefix + "comment-ref-" + comment_id
let href = "#" + self.options.id_prefix + "comment-" + comment_id
wrap_html_path(mapping.to, [
html_element("a", attributes={ "href": href, "id": id }, children=[
html_text(label),
]),
])
}
None => []
}
None => []
}
}
///|
fn comment_author_label(comment : Comment) -> String {
comment.author_initials
}
///|
fn DocumentConverter::find_comment(
self : DocumentConverter,
comment_id : String,
) -> Comment? {
for comment in self.comments {
if comment.comment_id == comment_id {
return Some(comment)
}
}
None
}
///|
fn DocumentConverter::convert_comments(
self : DocumentConverter,
comments : Array[Comment],
) -> Array[HtmlNode] {
if self.comment_references.is_empty() {
return []
}
let nodes : Array[HtmlNode] = []
for reference in self.comment_references {
match find_comment(comments, reference.comment_id) {
Some(comment) => {
let body = self.convert_children(comment.body)
let backlink = html_element(
"a",
attributes={
"href": "#" +
self.options.id_prefix +
"comment-ref-" +
comment.comment_id,
},
children=[html_text("↑")],
fresh=true,
)
append_note_backlink(body, backlink)
nodes.append([
html_element(
"dt",
attributes={
"id": self.options.id_prefix + "comment-" + comment.comment_id,
},
children=[html_text("Comment " + reference.label)],
fresh=true,
),
html_element("dd", children=body, fresh=true),
])
}
None => ()
}
}
if nodes.is_empty() {
[]
} else {
[html_element("dl", children=nodes, fresh=true)]
}
}
///|
fn find_comment(comments : Array[Comment], comment_id : String) -> Comment? {
for comment in comments {
if comment.comment_id == comment_id {
return Some(comment)
}
}
None
}
///|
fn DocumentConverter::wrap_with_style(
self : DocumentConverter,
element : DocumentElement,
content : Array[HtmlNode],
default_path : Array[HtmlPathElement],
) -> Array[HtmlNode] {
let path = match
@style_map.find_style_mapping(element, self.options.style_map) {
Some(mapping) => mapping.to
None => default_path
}
wrap_html_path(path, content)
}
///|
fn wrap_html_path(
path : Array[HtmlPathElement],
nodes : Array[HtmlNode],
) -> Array[HtmlNode] {
if path.length() == 1 && path[0].tag == "!" {
return []
}
let mut wrapped = nodes
for index = path.length() - 1; index >= 0; index = index - 1 {
let part = path[index]
wrapped = [
html_element(
part.tag,
attributes=part.attributes,
children=wrapped,
fresh=part.fresh,
separator=part.separator.unwrap_or(""),
),
]
}
wrapped
}
///|
fn html_nodes_are_empty(nodes : Array[HtmlNode]) -> Bool {
for node in nodes {
match node {
TextNode(value) => if value != "" { return false }
ForceWrite => return false
Element(tag~, children~, ..) =>
if @html.is_void_html_tag(tag) || !html_nodes_are_empty(children) {
return false
}
}
}
true
}