///|
// The `pdf` backend as the core sees it: a converter that turns inline
// nodes into asciidoctor-pdf's formatted-text markup (the `convert_inline_*`
// methods of lib/asciidoctor/pdf/converter.rb). Block nodes are walked by
// `convert_document`, not converted to strings, so they yield nothing here.
///|
priv struct InlineConverter {
mut resolving_xref : Bool
/// the document's state
session : Session
}
///|
/// State shared by the inline conversions of one document and its block
/// conversion. Inline content is converted whenever the core asks (section
/// titles already while parsing), so this lives with the converter the
/// core creates for the document, as asciidoctor-pdf's converter instance
/// does: two documents loaded one after the other, then converted in any
/// order, each keep their own. The converter makes its session the active
/// one (`session()`) whenever it converts, and `convert_document` makes
/// the document's active (see `activate_session`).
priv struct Session {
/// how deep in dry runs (asciidoctor-pdf's scratch document): inline
/// conversions there must leave no trace (index terms are not stored)
mut scratch : Int
/// the index terms met so far
index : IndexCatalog
/// how many footnotes are inked (`@rendered_footnotes`): a book inks
/// them after each chapter, and labels count from there
mut rendered_footnotes : Int
/// the labels footnotes were inked with, by index
footnote_labels : Map[Int, String]
/// the font catalog: the theme's fonts, and the icon fonts, whose glyphs
/// font icons (`icon:name[]`) take; loaded once per document, unless
/// `register` was given one
mut catalog : FontCatalog?
/// the document's theme, once loaded (see `document_theme`)
mut theme : @theme.Theme?
/// the bibliography entries referenced so far (`@bibref_refs`)
bibref_refs : Map[String, Bool]
/// the `subtitle` role's `normal` style was applied once (see
/// `apply_roles`)
normal_spent : Map[String, Bool]
/// the inline images met so far (`` is the Nth)
inline_images : Array[InlineImage]
/// the transformation stacks of the Prawn documents SVG images are drawn
/// into: the document's and the scratch document's (see
/// `Flow::svg_stack`)
svg_stacks : Array[@svg.TransformationStack]
/// the diagnostics reported outside `convert_document` (while the
/// document was loaded, or by `convert` before it), which each of its
/// conversions reports first (see `report`)
pending_diagnostics : Array[String]
/// `convert_document` is converting the document
mut converting : Bool
/// the fonts (`family (style)`) reported as unknown
unknown_fonts : Map[String, Bool]
/// what `register` was given when the document's converter was created:
/// a later `register` leaves the documents loaded before it as they were
registration : Registration
}
///|
fn Session::new() -> Session {
let registration = registration.val
{
scratch: 0,
index: IndexCatalog::new(),
rendered_footnotes: 0,
footnote_labels: Map([]),
catalog: registration.catalog,
theme: None,
bibref_refs: Map([]),
normal_spent: Map([]),
inline_images: [],
svg_stacks: [
@svg.TransformationStack::new(),
@svg.TransformationStack::new(),
],
pending_diagnostics: [],
converting: false,
unknown_fonts: Map([]),
registration,
}
}
///|
/// What `register` was given, which the documents whose converters are
/// created from then on use (each session keeps its own): the catalog
/// every document is set in, where the fonts the theme loader's virtual
/// `GEM_FONTS_DIR` (`@theme.fonts_dir`) stands for really are, and
/// prawn-icon's icon fonts (`icons=font`).
priv struct Registration {
/// the catalog every document is set in, if any
catalog : FontCatalog?
/// the bundled fonts, searched in order (unless `fonts_dir`)
bundles : Array[@bundled.Bundle]
/// the bundled icon fonts (unless `icon_fonts_dir`)
icon_bundle : @bundled.Bundle?
fonts_dir : String?
icon_fonts_dir : String?
/// the `pdf_theme` API option (see `register`)
pdf_theme : ((@theme.Files) -> @theme.Theme raise)?
}
///|
/// The current registration (the defaults until `register` is called).
let registration : Ref[Registration] = {
val: {
catalog: None,
bundles: [@bundled.default_fonts()],
icon_bundle: None,
fonts_dir: None,
icon_fonts_dir: None,
pdf_theme: None,
},
}
///|
/// The session of the document being converted (a placeholder until a
/// document is).
let active_session : Ref[Session] = { val: Session::new(), }
///|
/// The session of the document being converted.
fn session() -> Session {
active_session.val
}
///|
/// Make `state` the active session, and its theme, once loaded, the theme
/// of the conversion.
fn activate(state : Session) -> Unit {
active_session.val = state
match state.theme {
Some(theme) => current_theme.val = theme
None => ()
}
}
///|
/// The transform that makes a document's converter activate its session.
let activate_transform : String = "__pdf_activate_session__"
///|
/// Make the session of `doc`'s converter the active one (see `Session`).
fn activate_session(doc : @core.Node) -> Unit {
ignore(doc.converter().convert(doc, activate_transform))
}
///|
/// Registers the `pdf` backend with the core so that `backend=pdf` loads
/// resolve inline content to formatted text.
///
/// The fonts a theme names in asciidoctor-pdf's own font directory (its
/// `GEM_FONTS_DIR`) come from the bundles in `fonts` (by default the
/// default theme's: `@fonts.default_fonts()`; add `@sans.fonts()` and
/// `@fallback.fonts()` from `bobzhang/asciidoctor-pdf/fonts/sans` and
/// `/fonts/fallback` for the `default-sans` and
/// `default-with-font-fallbacks` themes), or, given `fonts_dir`, from that
/// directory (a copy of the gem's `data/fonts`). prawn-icon's icon fonts
/// (`icons=font`) come from `icon_fonts` (`@icons.fonts()` of
/// `bobzhang/asciidoctor-pdf/fonts/icons`), or, given `icon_fonts_dir`,
/// from that directory (prawn-icon's `data/fonts`). Any other font is read
/// through the document's VFS. With `catalog`, every document is set in
/// that catalog's fonts instead.
///
/// `pdf_theme` is asciidoctor-pdf's `pdf_theme` API option: the theme
/// every document is converted with instead of the one its `pdf-theme`
/// attribute (or `media`) selects, built from the theme files of the
/// document's VFS (see `load_document_theme`).
pub fn register(
catalog? : FontCatalog,
fonts? : Array[@bundled.Bundle] = [@bundled.default_fonts()],
icon_fonts? : @bundled.Bundle,
fonts_dir? : String,
icon_fonts_dir? : String,
pdf_theme? : (@theme.Files) -> @theme.Theme raise,
) -> Unit {
registration.val = {
catalog,
bundles: fonts.copy(),
icon_bundle: icon_fonts,
fonts_dir,
icon_fonts_dir,
pdf_theme,
}
@core.register_converter(["pdf"], fn(_backend, _htmlsyntax, _doc) {
// a new document: its own session (nested documents share their
// parent's converter, and so its session)
let state = Session::new()
activate(state)
({ resolving_xref: false, session: state, } : InlineConverter)
})
}
///|
impl @core.Converter for InlineConverter with fn backend_traits(_self) {
{
basebackend: "html",
filetype: "pdf",
htmlsyntax: Some("html"),
outfilesuffix: ".pdf",
supports_templates: false,
}
}
///|
impl @core.Converter for InlineConverter with fn convert(self, node, transform) {
activate(self.session)
match transform {
"__pdf_activate_session__" => ""
"inline_quoted" => convert_inline_quoted(node)
"inline_anchor" => self.convert_inline_anchor(node)
"inline_break" => "\{node.text().unwrap_or("")} "
"inline_button" => convert_inline_button(node)
"inline_callout" => convert_inline_callout(node)
"inline_footnote" => convert_inline_footnote(node)
"inline_kbd" => convert_inline_kbd(node)
"inline_menu" => convert_inline_menu(node)
"inline_image" if node.inline_type == Some("icon") =>
convert_inline_icon(node)
"inline_image" => convert_inline_image(node)
"inline_indexterm" => convert_inline_indexterm(node)
// block content reached through `content()` (e.g. a pass block)
"pass" => node.content()
// the nested document of an AsciiDoc table cell (`a|`), which the
// spike's tables leave empty
"embedded" => {
unsupported("block", "AsciiDoc table cell", node)
""
}
other => {
unsupported("inline", other, node)
""
}
}
}
///|
/// A string list attribute (`terms`, `see-also`).
fn list_attr(node : @core.Node, name : String) -> Array[String] {
match node.attributes.get(name) {
Some(List(items)) => items
Some(Str(item)) => [item]
_ => []
}
}
///|
/// asciidoctor-pdf's `convert_inline_indexterm`: an anchor named
/// `__indexterm-N` where the term occurs (whose page the index lists),
/// followed by the term when it is visible. A dry run (scratch) keeps only
/// the visible text and stores nothing.
fn convert_inline_indexterm(node : @core.Node) -> String {
let visible = node.inline_type == Some("visible")
if session().scratch > 0 {
return if visible { node.text().unwrap_or("") } else { "" }
}
let index = session().index
let anchor_name = index.next_anchor_name()
let dest : IndexDest = { anchor: anchor_name, page: None, page_sortable: 0, }
let anchor = if visible {
""
} else {
""
}
let see = node.attr("see")
let see_also = list_attr(node, "see-also")
if visible {
let term = node.text().unwrap_or("")
index.store_term([term], dest, see~, see_also~)
anchor + term
} else {
index.store_term(list_attr(node, "terms"), dest, see~, see_also~)
anchor
}
}
///|
fn conum_glyph(text : String) -> String {
let n = @string.parse_int(text) catch { _ => 1 }
if n >= 1 && n <= 20 {
code_to_string(0x2460 + n - 1)
} else {
"(\{text})"
}
}
///|
/// The theme's typographic quote `index` (`quotes`: open and close
/// double, open and close single).
fn theme_quote(node : @core.Node, index : Int) -> String {
match document_theme(node)["quotes"] {
Array(items) => items.get(index).bind(value_str).unwrap_or("")
_ => ""
}
}
///|
fn convert_inline_quoted(node : @core.Node) -> String {
let (open, close, is_tag) = match node.inline_type {
Some("emphasis") => ("", "", true)
Some("strong") => ("", "", true)
Some("monospaced") | Some("asciimath") | Some("latexmath") =>
("", "", true)
Some("superscript") => ("", "", true)
Some("subscript") => ("", "", true)
Some("double") => (theme_quote(node, 0), theme_quote(node, 1), false)
Some("single") => (theme_quote(node, 2), theme_quote(node, 3), false)
Some("mark") => ("", "", true)
_ => ("", "", false)
}
let text = node.text().unwrap_or("")
// quoted text ending in `...` ends in an ellipsis
let text = if (
node.inline_type == Some("double") || node.inline_type == Some("single")
) &&
text.length() > 3 &&
text.has_suffix("...") &&
!text[:text.length() - 3].has_suffix("\\") {
text[:text.length() - 3].to_owned() + "…"
} else {
text
}
let quoted = match node.role() {
Some(roles) =>
if is_tag {
"\{open[:open.length() - 1]} class=\"\{roles}\">\{text}\{close}"
} else {
"\{open}\{text}\{close}"
}
None => "\{open}\{text}\{close}"
}
match node.id {
Some(id) => "\{quoted}"
None => quoted
}
}
///|
fn InlineConverter::convert_inline_anchor(
self : InlineConverter,
node : @core.Node,
) -> String {
let target = node.target.unwrap_or("")
match node.inline_type {
Some("link") => {
let anchor = match node.id {
Some(id) => ""
None => ""
}
let class = match node.role() {
Some(role) => " class=\"\{role}\""
None => ""
}
let doc = node.document()
// for print, a mailto link shows its address (`media`)
let screen = doc.attr("media").unwrap_or("screen") == "screen"
let (text, bare_target) = if !screen && target.has_prefix("mailto:") {
let address = target[7:].to_owned()
let text = node.text().unwrap_or("")
if address == text {
ignore(node.add_role("bare"))
}
(text, if doc.has_attr("hide-uri-scheme") { address } else { target })
} else {
(node.text().unwrap_or(""), target)
}
let show_uri = doc.has_attr("show-link-uri") ||
(!screen && attr_unspecified(doc, "show-link-uri"))
if node.has_role("bare") {
"\{anchor}\{breakable_uri(text)}"
} else if show_uri {
let bare = if doc.has_attr("hide-uri-scheme") {
match bare_target.find("://") {
Some(i) => bare_target[i + 3:].to_owned()
None => bare_target
}
} else {
bare_target
}
"\{anchor}\{text} [\{breakable_uri(bare)}]"
} else {
"\{anchor}\{text}"
}
}
Some("xref") =>
match node.attributes.str("path") {
Some(path) => "\{node.text().unwrap_or(path)}"
None => {
guard node.attributes.str("refid") is Some(refid) else {
// no target: the top of the document
let text = node.text().unwrap_or("[^top]")
return "\{text}"
}
// the first reference to a bibliography entry is where the entry
// links back to (`_bibref_ref_`)
let mut anchor = ""
let text = match node.text() {
Some(text) => text
None => {
let refs = node.document().catalog().refs
match refs.get(refid) {
Some(ref_) if !self.resolving_xref => {
self.resolving_xref = true
let text = ref_.xreftext(
xrefstyle?=node.attr("xrefstyle", inherited=true),
)
if ref_.inline_type == Some("bibref") &&
session().scratch == 0 &&
!session().bibref_refs.contains(refid) {
session().bibref_refs[refid] = true
anchor = ""
}
self.resolving_xref = false
match text {
Some(t) => drop_anchors(t)
None => "[\{refid}]"
}
}
_ => "[\{refid}]"
}
}
}
"\{anchor}\{text}"
}
}
Some("ref") => ""
Some("bibref") => {
let id = node.id.unwrap_or("")
let reftext = match node.reftext() {
Some(r) => "[\{r}]"
None => "[\{id}]"
}
let reftext = if session().bibref_refs.contains(id) {
"\{reftext}"
} else {
reftext
}
"\{reftext}"
}
other => {
unsupported("inline", "anchor type \{other.unwrap_or("none")}", node)
""
}
}
}
///|
/// The Font Awesome icon sets, in the order a `fa` icon name is looked up
/// (`FontAwesomeIconSets`), and all icon sets (`IconSets`).
let font_awesome_icon_sets : Array[String] = ["fab", "far", "fas"]
///|
let icon_set_names : Array[String] = ["fab", "far", "fas", "fi", "pf"]
///|
/// asciidoctor-pdf's `convert_inline_icon`: with `icons=font`, the icon's
/// glyph in its icon set's font (`name@set`, the `set` attribute, else
/// `icon-set`, default `fa`: a Font Awesome 4 name through the legacy
/// mapping, else the first Font Awesome set that has it; a `fas-` style
/// prefix names the set too), sized by the `size` attribute; otherwise, or
/// when the icon is unknown, the alt text in brackets.
fn convert_inline_icon(node : @core.Node) -> String {
let doc = node.document()
let alt = "[\{node.attr("alt").unwrap_or("")}]"
let icons = doc.attr("icons")
let catalog : FontCatalog? = if icons == Some("font") {
Some(document_catalog(doc)) catch {
_ => None
}
} else {
None
}
guard catalog is Some(catalog) && catalog.has_icons() else {
if icons is Some(_) && icons != Some("font") {
unsupported("inline", "image icon", node)
return "[\{node.target.unwrap_or("")}]"
}
if icons == Some("font") {
unsupported("inline", "font icon (no icon fonts)", node)
}
return alt
}
let target = node.target.unwrap_or("")
let (name0, set0, explicit) = match target.find("@") {
Some(i) => (target[:i].to_owned(), target[i + 1:].to_owned(), true)
None =>
match node.attr("set") {
Some(set) => (target, set, true)
None => (target, doc.attr("icon-set").unwrap_or("fa"), false)
}
}
let mut name = name0
let mut set = set0
let mut glyph : String? = None
if set == "fa" || !icon_set_names.contains(set) {
set = "fa"
match catalog.legacy_icons.get("fa-\{name}") {
Some(mapped) =>
match mapped.find("-") {
Some(i) => {
set = mapped[:i].to_owned()
name = mapped[i + 1:].to_owned()
glyph = catalog.icon_glyph(set, name)
}
None => ()
}
None =>
for candidate in font_awesome_icon_sets {
match catalog.icon_glyph(candidate, name) {
Some(g) => {
set = candidate
glyph = Some(g)
break
}
None => ()
}
}
}
} else {
glyph = catalog.icon_glyph(set, name)
}
if glyph is None &&
!explicit &&
icon_set_names.iter().any(s => name.has_prefix(s + "-")) {
match name.find("-") {
Some(i) => {
set = name[:i].to_owned()
name = name[i + 1:].to_owned()
glyph = catalog.icon_glyph(set, name)
}
None => ()
}
}
guard glyph is Some(glyph) else { return alt }
let size_attr = match node.attr("size") {
Some("lg") => " size=\"1.333em\""
// `fw` sets a width Prawn's formatted text does not know
Some("fw") => " width=\"1em\""
Some(size) => " size=\"\{size.replace(old="x", new="em")}\""
None => ""
}
let class_attr = match node.role() {
Some(role) => " class=\"\{role}\""
None => ""
}
"\{glyph}"
}
///|
/// asciidoctor-pdf's `breakable_uri`: a zero-width space after each `/`,
/// `?`, `&` and `#` of the address (after the scheme's `://`) that is
/// not its last character, so a long URL can wrap there; but not one that
/// would leave a single character on the next line.
fn breakable_uri(uri : String) -> String {
let (scheme, address) = match uri.find("://") {
Some(i) => (uri[:i + 3].to_owned(), uri[i + 3:].to_owned())
None => ("", uri)
}
if address.is_empty() {
return uri
}
let sb = StringBuilder()
let n = address.length()
let mut i = 0
while i < n {
let matched = if address[i:].has_prefix("&") {
5
} else if address[i] == '/' || address[i] == '?' || address[i] == '#' {
1
} else {
0
}
if matched == 0 {
sb.write_char(address[i].to_int().unsafe_to_char())
i += 1
continue
}
for k in 0..= 2 && broken[len - 2] == 0x200B {
broken[:len - 2].to_owned() + broken[len - 1:].to_owned()
} else {
broken
}
scheme + result
}
///|
/// Remove `` and `` tags (asciidoctor-pdf's `DropAnchorRx`).
fn drop_anchors(text : String) -> String {
if !text.contains("')
) ||
(text[i + 1] == '/' && i + 2 < n && text[i + 2] == 'a')
) {
while i < n && text[i] != '>' {
i += 1
}
i += 1
continue
}
sb.write_char(text[i].to_int().unsafe_to_char())
i += 1
}
sb.to_string()
}
///|
fn convert_inline_footnote(node : @core.Node) -> String {
match node.attr("index") {
Some(index) => {
let anchor = if node.inline_type == Some("xref") {
""
} else {
""
}
// counted from the footnotes inked so far (a book's chapter), or the
// label it was inked with
let number = @string.parse_int(index) catch { _ => 0 }
let label = match session().footnote_labels.get(number) {
Some(label) => label
None => (number - session().rendered_footnotes).to_string()
}
"\{anchor}[\{label}]"
}
None => {
let color = document_theme(node)["role_unresolved_font_color"].to_ruby_s()
"[\{node.text().unwrap_or("")}]"
}
}
}
///|
/// asciidoctor-pdf's `convert_inline_button`: the text in the theme's
/// `button_content` (`%s` stands for the text).
fn convert_inline_button(node : @core.Node) -> String {
let theme = document_theme(node)
let content = value_str(theme["button_content"]).unwrap_or("%s")
let text = node.text().unwrap_or("")
let content = match content.find("%s") {
Some(i) => content[:i].to_owned() + text + content[i + 2:].to_owned()
None => content
}
""
}
///|
/// asciidoctor-pdf's `convert_inline_callout`: the callout number's glyph
/// in the conum font and colour (the font is named, as callouts in code
/// are set in the code font).
fn convert_inline_callout(node : @core.Node) -> String {
let theme = document_theme(node)
let glyph = conum_glyph(node.text().unwrap_or("1"))
let family = value_str(theme["conum_font_family"]).unwrap_or("")
let result = "\{glyph}"
match theme["conum_font_color"] {
Null | Bool(false) => result
color => "\{result}"
}
}
///|
/// asciidoctor-pdf's `convert_inline_kbd`: the keys joined by the theme's
/// `kbd_separator`.
fn convert_inline_kbd(node : @core.Node) -> String {
let separator = value_str(document_theme(node)["kbd_separator"]).unwrap_or(
"+",
)
match node.attributes.get("keys") {
Some(List([key])) => "\{key}"
Some(List(keys)) => keys.map(key => "\{key}").join(separator)
_ => "\{node.text().unwrap_or("")}"
}
}
///|
/// asciidoctor-pdf's `convert_inline_menu`: the menu, submenus and item
/// joined by the theme's `menu_caret_content`.
fn convert_inline_menu(node : @core.Node) -> String {
let caret = value_str(document_theme(node)["menu_caret_content"]).unwrap_or(
" \u{203a} ",
)
let menu = node.attr("menu").unwrap_or("")
let submenus = match node.attributes.get("submenus") {
Some(List(submenus)) => submenus
_ => []
}
if !submenus.is_empty() {
let parts = [menu, ..submenus, node.attr("menuitem").unwrap_or("")]
""
} else {
match node.attr("menuitem") {
Some(item) => ""
None => ""
}
}
}