chdemko/pandoc-latex-tip

View on GitHub
pandoc_latex_tip.py

Summary

Maintainability
B
6 hrs
Test Coverage
##!/usr/bin/env python

"""
Pandoc filter for adding tip in LaTeX.
"""

import os
import pathlib
import sys
import tempfile

import icon_font_to_png

from panflute import (
    Code,
    CodeBlock,
    Image,
    Inline,
    Link,
    MetaInlines,
    MetaList,
    Plain,
    RawBlock,
    RawInline,
    Span,
    convert_text,
    debug,
    run_filter,
)

import platformdirs

# pylint: disable=used-before-assignment,redefined-builtin
try:
    FileNotFoundError
except NameError:
    # py2
    FileNotFoundError = IOError  # noqa: A001,VNE003


def _icon_font(collection, version, css, ttf):
    folder = os.path.join(
        sys.prefix,
        "share",
        "pandoc_latex_tip",
    )

    try:
        return icon_font_to_png.IconFont(
            os.path.join(folder, collection, version, css),
            os.path.join(folder, collection, version, ttf),
            True,
        )
    except FileNotFoundError as exception:
        debug("[ERROR] pandoc-latex-tip: " + str(exception))
        return None


_ICON_FONTS = {
    "fontawesome-4.7-regular": {
        "font": _icon_font(
            "fontawesome", "4.7", "font-awesome.css", "fontawesome-webfont.ttf"
        ),
        "prefix": "fa-",
    },
    "fontawesome-5.x-brands": {
        "font": _icon_font(
            "fontawesome", "5.x", "fontawesome.css", "fa-brands-400.ttf"
        ),
        "prefix": "fa-",
    },
    "fontawesome-5.x-regular": {
        "font": _icon_font(
            "fontawesome", "5.x", "fontawesome.css", "fa-regular-400.ttf"
        ),
        "prefix": "fa-",
    },
    "fontawesome-5.x-solid": {
        "font": _icon_font("fontawesome", "5.x", "fontawesome.css", "fa-solid-900.ttf"),
        "prefix": "fa-",
    },
    "glyphicons-3.3-regular": {
        "font": _icon_font(
            "glyphicons",
            "3.3",
            "bootstrap-modified.css",
            "glyphicons-halflings-regular.ttf",
        ),
        "prefix": "glyphicon-",
    },
    "materialdesign-3.x-regular": {
        "font": _icon_font(
            "materialdesign",
            "3.x",
            "materialdesignicons.css",
            "materialdesignicons-webfont.ttf",
        ),
        "prefix": "mdi-",
    },
}


def _tip(elem, doc):
    # Is it in the right format and is it a Span, Div?
    if doc.format in ("latex", "beamer") and elem.tag in (
        "Span",
        "Div",
        "Code",
        "CodeBlock",
    ):
        # Is there a latex-tip-icon attribute?
        if "latex-tip-icon" in elem.attributes:
            return _add_latex(
                elem,
                _latex_code(
                    doc,
                    elem.attributes,
                    {
                        "icon": "latex-tip-icon",
                        "position": "latex-tip-position",
                        "size": "latex-tip-size",
                        "color": "latex-tip-color",
                        "collection": "latex-tip-collection",
                        "version": "latex-tip-version",
                        "variant": "latex-tip-variant",
                        "link": "latex-tip-link",
                    },
                ),
            )

        # Get the classes
        classes = set(elem.classes)

        # Loop on all font size definition
        for definition in doc.defined:
            # Are the classes correct?
            if classes >= definition["classes"]:
                return _add_latex(elem, definition["latex"])

    return None


def _add_latex(elem, latex):
    if bool(latex):
        # Is it a Span or a Code?
        if isinstance(elem, (Span, Code)):
            return [elem, RawInline(latex, "tex")]

        # It is a CodeBlock: create a minipage to ensure the
        # _tip to be on the same page as the codeblock
        if isinstance(elem, CodeBlock):
            return [
                RawBlock("\\begin{minipage}{\\textwidth}" + latex, "tex"),
                elem,
                RawBlock("\\end{minipage}", "tex"),
            ]

        # It is a Div: try to insert an inline raw before the first inline element
        inserted = [False]

        def insert(element, _):
            if (
                not inserted[0]
                and isinstance(element, Inline)
                and not isinstance(element.parent, Inline)
            ):
                inserted[0] = True
                return [RawInline(latex, "tex"), element]
            return None

        elem.walk(insert)
        if not inserted[0]:
            return [RawBlock("\\needspace{5em}", "tex"), RawBlock(latex, "tex"), elem]
        return [RawBlock("\\needspace{5em}", "tex"), elem]

    return None


# pylint: disable=too-many-arguments,too-many-locals
def _latex_code(doc, definition, keys):
    # Get the default color
    color = str(definition.get(keys["color"], "black"))

    # Get the size
    size = _get_size(str(definition.get(keys["size"], "18")))

    # Get the prefixes
    prefix_odd = _get_prefix(str(definition.get(keys["position"], "")), True)
    prefix_even = _get_prefix(str(definition.get(keys["position"], "")), False)

    # Get the collection
    collection = str(definition.get(keys["collection"], "fontawesome"))

    # Get the version
    version = str(definition.get(keys["version"], "4.7"))

    # Get the variant
    variant = str(definition.get(keys["variant"], "regular"))

    # Get the link
    link = str(definition.get(keys["link"], ""))

    # Get the icons
    icons = _get_icons(
        doc, definition, keys["icon"], color, collection, version, variant, link
    )

    # Get the images
    images = _create_images(doc, icons, size)

    if bool(images):
        # pylint: disable=consider-using-f-string
        return f"""
\\checkoddpage%%
\\ifoddpage%%
{prefix_odd}%%
\\else%%
{prefix_even}%%
\\fi%%
\\marginnote{{{''.join(images)}}}[0pt]\\vspace{{0cm}}%%
"""

    return ""


def _get_icons(doc, definition, key_icons, color, collection, version, variant, link):
    # Test the icons definition
    if key_icons in definition:
        icons = []
        # pylint: disable=invalid-name
        if isinstance(definition[key_icons], (str, unicode)):
            def_icons = [
                {
                    "name": definition[key_icons],
                    "color": color,
                    "collection": collection,
                    "version": version,
                    "variant": variant,
                    "link": link,
                }
            ]
        else:
            def_icons = definition[key_icons]
        if isinstance(def_icons, list):
            for icon in def_icons:
                try:
                    icon["color"] = icon.get("color", color)
                    icon["collection"] = icon.get("collection", collection)
                    icon["version"] = icon.get("version", version)
                    icon["variant"] = icon.get("variant", variant)
                    icon["link"] = icon.get("link", link)
                except AttributeError:
                    icon = {
                        "name": icon,
                        "color": color,
                        "collection": collection,
                        "version": version,
                        "variant": variant,
                        "link": link,
                    }

                _add_icon(doc, icons, icon)
    else:
        icons = [
            {
                "extended-name": "fa-exclamation-circle",
                "name": "exclamation-circle",
                "color": color,
                "collection": collection,
                "version": version,
                "variant": variant,
                "link": link,
            }
        ]

    return icons


# Fix unicode for python3
# pylint: disable=invalid-name
try:
    # pylint: disable=redefined-builtin,self-assigning-variable
    unicode = unicode  # noqa: FURB160,SIM909
except NameError:
    unicode = str


def _add_icon(doc, icons, icon):
    if "name" not in icon:
        # Bad formed icon
        debug("[WARNING] pandoc-latex-tip: Bad formed icon")
        return

    # Lower the color
    lower_color = icon["color"].lower()

    # Convert the color to black if unexisting
    # pylint: disable=import-outside-toplevel
    from PIL import ImageColor

    if lower_color not in ImageColor.colormap:
        debug(
            "[WARNING] pandoc-latex-tip: "
            + lower_color
            + " is not a correct color name; using black"
        )
        lower_color = "black"

    # Is the icon correct?
    try:
        category = _category(icon["collection"], icon["version"], icon["variant"])
        if category in doc.get_icon_font:
            extended_name = doc.get_icon_font[category]["prefix"] + icon["name"]
            if extended_name in doc.get_icon_font[category]["font"].css_icons:
                icons.append(
                    {
                        "name": icon["name"],
                        "extended-name": extended_name,
                        "color": lower_color,
                        "collection": icon["collection"],
                        "version": icon["version"],
                        "variant": icon["variant"],
                        "link": icon["link"],
                    }
                )
            else:
                debug(
                    "[WARNING] pandoc-latex-tip: "
                    + icon["name"]
                    + " is not a correct icon name"
                )
        else:
            debug(
                "[WARNING] pandoc-latex-tip: "
                + icon["variant"]
                + " does not exist in version "
                + icon["version"]
            )
    except FileNotFoundError:
        debug("[WARNING] pandoc-latex-tip: error in accessing to icons definition")


def _category(collection, version, variant):
    return collection + "-" + version + "-" + variant


# pylint:disable=too-many-return-statements
def _get_prefix(position, odd=True):
    if position == "right":
        if odd:
            return "\\pandoclatextipoddright"
        return "\\pandoclatextipevenright"
    if position in ("left", ""):
        if odd:
            return "\\pandoclatextipoddleft"
        return "\\pandoclatextipevenleft"
    if position == "inner":
        if odd:
            return "\\pandoclatextipoddinner"
        return "\\pandoclatextipeveninner"
    if position == "outer":
        if odd:
            return "\\pandoclatextipoddouter"
        return "\\pandoclatextipevenouter"
    debug(
        "[WARNING] pandoc-latex-tip: "
        + position
        + " is not a correct position; using left"
    )
    if odd:
        return "\\pandoclatextipoddleft"
    return "\\pandoclatextipevenleft"


def _get_size(size):
    try:
        int_value = int(size)
        if int_value > 0:
            size = str(int_value)
        else:
            debug(
                "[WARNING] pandoc-latex-tip: size must be greater than 0; using " + size
            )
    except ValueError:
        debug("[WARNING] pandoc-latex-tip: size must be a number; using " + size)
    return size


def _create_images(doc, icons, size):
    # Generate the LaTeX image code
    images = []

    for icon in icons:
        # Get the image from the App cache folder
        image_dir = os.path.join(
            doc.folder,
            icon["collection"],
            icon["version"],
            icon["variant"],
            icon["color"],
        )
        image = os.path.join(image_dir, icon["extended-name"] + ".png")

        # Create the image if not existing in the cache
        try:
            if not os.path.isfile(image):
                # Create the image in the cache
                category = _category(
                    icon["collection"], icon["version"], icon["variant"]
                )
                doc.get_icon_font[category]["font"].export_icon(
                    icon["extended-name"],
                    512,
                    color=icon["color"],
                    export_dir=image_dir,
                )

            # Add the LaTeX image
            image = Image(
                url=image, attributes={"width": size + "pt", "height": size + "pt"}
            )
            if icon["link"] == "":
                elem = image
            else:
                elem = Link(image, url=icon["link"])
            images.append(
                convert_text(
                    Plain(elem), input_format="panflute", output_format="latex"
                )
            )
        except TypeError:
            debug(
                f"[WARNING] pandoc-latex-tip: icon name "
                f"{icon['name']}"
                f" does not exist in variant "
                f"{icon['variant']}"
                f" for collection "
                f"{icon['collection']}"
                f"-"
                f"{icon['version']}"
            )
        except FileNotFoundError:
            debug("[WARNING] pandoc-latex-tip: error in generating image")

    return images


def _add_definition(doc, definition):
    # Get the classes
    classes = definition["classes"]

    # Add a definition if correct
    if bool(classes):
        latex = _latex_code(
            doc,
            definition,
            {
                "icon": "icons",
                "position": "position",
                "size": "size",
                "color": "color",
                "collection": "collection",
                "version": "version",
                "variant": "variant",
                "link": "link",
            },
        )
        if latex:
            doc.defined.append({"classes": set(classes), "latex": latex})


def _prepare(doc):
    # Add getIconFont library to doc
    doc.get_icon_font = _ICON_FONTS

    # Prepare the definitions
    doc.defined = []

    # Prepare the folder
    try:
        # Use user cache dir if possible
        doc.folder = platformdirs.AppDirs(
            "pandoc_latex_tip",
        ).user_cache_dir
        if not pathlib.Path(doc.folder).exists():
            pathlib.Path(doc.folder).mkdir(parents=True)
    except PermissionError:
        # Fallback to a temporary dir
        doc.folder = tempfile.mkdtemp(
            prefix="pandoc_latex_tip_",
            suffix="_cache",
        )

    # Get the meta data
    meta = doc.get_metadata("pandoc-latex-tip")

    if isinstance(meta, list):
        # Loop on all definitions
        for definition in meta:
            # Verify the definition
            if (
                isinstance(definition, dict)
                and "classes" in definition
                and isinstance(definition["classes"], list)
            ):
                _add_definition(doc, definition)


def _finalize(doc):
    # Add header-includes if necessary
    if "header-includes" not in doc.metadata:
        doc.metadata["header-includes"] = MetaList()
    # Convert header-includes to MetaList if necessary
    elif not isinstance(doc.metadata["header-includes"], MetaList):
        doc.metadata["header-includes"] = MetaList(doc.metadata["header-includes"])

    doc.metadata["header-includes"].append(
        MetaInlines(RawInline("\\usepackage{needspace}", "tex"))
    )
    doc.metadata["header-includes"].append(
        MetaInlines(RawInline("\\usepackage{graphicx,grffile}", "tex"))
    )
    doc.metadata["header-includes"].append(
        MetaInlines(RawInline("\\usepackage{marginnote}", "tex"))
    )
    doc.metadata["header-includes"].append(
        MetaInlines(RawInline("\\usepackage{etoolbox}", "tex"))
    )
    doc.metadata["header-includes"].append(
        MetaInlines(RawInline("\\usepackage[strict]{changepage}", "tex"))
    )
    doc.metadata["header-includes"].append(
        MetaInlines(
            RawInline(
                r"""
\makeatletter%
\newcommand{\pandoclatextipoddinner}{\reversemarginpar}%
\newcommand{\pandoclatextipeveninner}{\reversemarginpar}%
\newcommand{\pandoclatextipoddouter}{\normalmarginpar}%
\newcommand{\pandoclatextipevenouter}{\normalmarginpar}%
\newcommand{\pandoclatextipoddleft}{\reversemarginpar}%
\newcommand{\pandoclatextipoddright}{\normalmarginpar}%
\if@twoside%
\newcommand{\pandoclatextipevenright}{\reversemarginpar}%
\newcommand{\pandoclatextipevenleft}{\normalmarginpar}%
\else%
\newcommand{\pandoclatextipevenright}{\normalmarginpar}%
\newcommand{\pandoclatextipevenleft}{\reversemarginpar}%
\fi%
\makeatother%
\checkoddpage
    """,
                "tex",
            )
        )
    )


def main(doc=None):
    """
    Transform the pandoc document.

    Arguments
    ---------
    doc
        The pandoc document

    Returns
    -------
        The transformed document
    """
    return run_filter(_tip, prepare=_prepare, finalize=_finalize, doc=doc)


if __name__ == "__main__":
    main()