Passed
Push — develop ( 575c83...8f05cd )
by Christophe
01:18
created

pandoc_latex_tip._main.IconsAddCommand.handle()   C

Complexity

Conditions 11

Size

Total Lines 73
Code Lines 50

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 50
dl 0
loc 73
rs 5.4
c 0
b 0
f 0
cc 11
nop 1

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

Complexity

Complex classes like pandoc_latex_tip._main.IconsAddCommand.handle() often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

1
#!/usr/bin/env python
2
3
# pylint: disable=too-many-lines
4
5
"""
6
Pandoc filter for adding tip in LaTeX.
7
"""
8
from __future__ import annotations
9
10
import operator
11
import os
12
import pathlib
13
import re
14
import shutil
15
import sys
16
import tempfile
17
from typing import Any
18
19
import PIL.Image
20
import PIL.ImageColor
21
import PIL.ImageDraw
22
import PIL.ImageFont
23
24
from cleo.application import Application
25
from cleo.commands.command import Command
26
from cleo.helpers import argument, option
27
28
import fontTools.ttLib
29
30
from panflute import (
31
    BulletList,
32
    Code,
33
    CodeBlock,
34
    DefinitionList,
35
    Div,
36
    Doc,
37
    Element,
38
    Figure,
39
    HorizontalRule,
40
    Image,
41
    LineBlock,
42
    Link,
43
    MetaInlines,
44
    MetaList,
45
    OrderedList,
46
    Para,
47
    Plain,
48
    RawBlock,
49
    RawInline,
50
    Span,
51
    convert_text,
52
    debug,
53
    run_filter,
54
)
55
56
import platformdirs
57
58
import tinycss2
59
60
import yaml
61
62
63
class IconFont:
64
    """
65
    Base class that represents web icon font.
66
67
    This class has been gratly inspired by the code found
68
    in https://github.com/Pythonity/icon-font-to-png
69
70
    Arguments
71
    ---------
72
    css_file
73
        path to icon font CSS file
74
    ttf_file
75
        path to icon font TTF file
76
    prefix
77
        new prefix if any
78
    """
79
80
    def __init__(
81
        self,
82
        css_file: pathlib.Path,
83
        ttf_file: pathlib.Path,
84
        prefix: str | None = None,
85
    ) -> None:
86
        self.css_file = css_file
87
        self.ttf_file = ttf_file
88
        self.css_icons = self.load_css(prefix)
89
90
    def load_css(self, prefix: str | None) -> dict[str, str]:
91
        """
92
        Create a dict of all icons available in CSS file.
93
94
        Arguments
95
        ---------
96
        prefix
97
            new prefix if any
98
99
        Returns
100
        -------
101
        dict[str, str]
102
            sorted icons dict
103
        """
104
        # pylint: disable=too-many-locals
105
        icons = {}
106
        common = None
107
        with self.css_file.open() as stream:
108
            rules = tinycss2.parse_stylesheet(stream.read())
109
        font = fontTools.ttLib.TTFont(self.ttf_file)
110
        prelude_regex = re.compile("\\.([^:]*):?:before,?")
111
        content_regex = re.compile("\\s*content:\\s*([^;]+);")
112
113
        # pylint: disable=too-many-nested-blocks
114
        for rule in rules:
115
            if rule.type == "qualified-rule":
116
                prelude = tinycss2.serialize(rule.prelude)
117
                content = tinycss2.serialize(rule.content)
118
                prelude_result = prelude_regex.match(prelude)
119
                content_result = content_regex.match(content)
120
                if prelude_result and content_result:
121
                    name = prelude_result.group(1)
122
                    character = content_result.group(1)[1:-1]
123
                    for cmap in font["cmap"].tables:
124
                        if cmap.isUnicode() and ord(character) in cmap.cmap:
125
                            if common is None:
126
                                common = name
127
                            else:
128
                                common = os.path.commonprefix((common, name))
129
                            icons[name] = character
130
                            break
131
132
        common = common or ""
133
134
        # Remove common prefix
135
        if prefix:
136
            icons = {
137
                prefix + name[len(common) :]: value for name, value in icons.items()
138
            }
139
140
        return dict(sorted(icons.items(), key=operator.itemgetter(0)))
141
142
    # pylint: disable=too-many-arguments,too-many-locals
143
    def export_icon(
144
        self,
145
        icon: str,
146
        size: int,
147
        color: str = "black",
148
        scale: float | str = "auto",
149
        filename: str | None = None,
150
        export_dir: str = "exported",
151
    ) -> None:
152
        """
153
        Export given icon with provided parameters.
154
155
        If the desired icon size is less than 150x150 pixels, we will first
156
        create a 150x150 pixels image and then scale it down, so that
157
        it's much less likely that the edges of the icon end up cropped.
158
159
        Parameters
160
        ----------
161
        icon
162
            valid icon name
163
        size
164
            icon size in pixels
165
        color
166
            color name or hex value
167
        scale
168
            scaling factor between 0 and 1, or 'auto' for automatic scaling
169
        filename
170
            name of the output file
171
        export_dir
172
            path to export directory
173
        """
174
        org_size = size
175
        size = max(150, size)
176
177
        image = PIL.Image.new("RGBA", (size, size), color=(0, 0, 0, 0))
178
        draw = PIL.ImageDraw.Draw(image)
179
180
        if scale == "auto":
181
            scale_factor = 1.0
182
        else:
183
            scale_factor = float(scale)
184
185
        font_size = int(size * scale_factor)
186
        font = PIL.ImageFont.truetype(self.ttf_file, font_size)
187
        width = draw.textlength(self.css_icons[icon], font=font)
188
        height = font_size  # always, as long as single-line of text
189
190
        # If auto-scaling is enabled, we need to make sure the resulting
191
        # graphic fits inside the boundary. The values are rounded and may be
192
        # off by a pixel or two, so we may need to do a few iterations.
193
        # The use of a decrementing multiplication factor protects us from
194
        # getting into an infinite loop.
195
        if scale == "auto":
196
            iteration = 0
197
            factor = 1.0
198
199
            while True:
200
                width = draw.textlength(self.css_icons[icon], font=font)
201
202
                # Check if the image fits
203
                dim = max(width, height)
204
                if dim > size:
205
                    font = PIL.ImageFont.truetype(
206
                        self.ttf_file,
207
                        int(size * size / dim * factor),
208
                    )
209
                else:
210
                    break
211
212
                # Adjust the factor every two iterations
213
                iteration += 1
214
                if iteration % 2 == 0:
215
                    factor *= 0.99
216
217
        draw.text(
218
            ((size - width) / 2, (size - height) / 2),
219
            self.css_icons[icon],
220
            font=font,
221
            fill=color,
222
        )
223
224
        # Get bounding box
225
        bbox = image.getbbox()
226
227
        # Create an alpha mask
228
        image_mask = PIL.Image.new("L", (size, size))
229
        draw_mask = PIL.ImageDraw.Draw(image_mask)
230
231
        # Draw the icon on the mask
232
        draw_mask.text(
233
            ((size - width) / 2, (size - height) / 2),
234
            self.css_icons[icon],
235
            font=font,
236
            fill=255,
237
        )
238
239
        # Create a solid color image and apply the mask
240
        icon_image = PIL.Image.new("RGBA", (size, size), color)
241
        icon_image.putalpha(image_mask)
242
243
        if bbox:
244
            icon_image = icon_image.crop(bbox)
245
246
        border_w = int((size - (bbox[2] - bbox[0])) / 2)  # type: ignore
247
        border_h = int((size - (bbox[3] - bbox[1])) / 2)  # type: ignore
248
249
        # Create output image
250
        out_image = PIL.Image.new("RGBA", (size, size), (0, 0, 0, 0))
251
        out_image.paste(icon_image, (border_w, border_h))
252
253
        # If necessary, scale the image to the target size
254
        if org_size != size:
255
            out_image = out_image.resize(
256
                (org_size, org_size),
257
                PIL.Image.Resampling.LANCZOS,
258
            )
259
260
        # Make sure export directory exists
261
        if not pathlib.Path(export_dir).exists():
262
            pathlib.Path(export_dir).mkdir(parents=True)
263
264
        # Default filename
265
        if not filename:
266
            filename = icon + ".png"
267
268
        # Save file
269
        out_image.save(os.path.join(export_dir, filename))
270
271
272
def get_core_icons() -> list[dict[str, str]]:
273
    """
274
    Get the core icons.
275
276
    Returns
277
    -------
278
    list[dict[str, str]]
279
        The core icons.
280
    """
281
    return [
282
        {
283
            "collection": "fontawesome",
284
            "css": "fontawesome.css",
285
            "ttf": "fa-solid-900.ttf",
286
            "prefix": "fa-",
287
        },
288
        {
289
            "collection": "fontawesome",
290
            "css": "fontawesome.css",
291
            "ttf": "fa-regular-400.ttf",
292
            "prefix": "far-",
293
        },
294
        {
295
            "collection": "fontawesome",
296
            "css": "brands.css",
297
            "ttf": "fa-brands-400.ttf",
298
            "prefix": "fab-",
299
        },
300
    ]
301
302
303
def load_icons() -> dict[str, IconFont]:
304
    """
305
    Get the icons.
306
307
    Returns
308
    -------
309
    dict["str", IconFont]
310
        A dictionnary from icon name to IconFont.
311
    """
312
    icons = {}
313
    for definition in get_core_icons():
314
        icon_font = IconFont(
315
            css_file=pathlib.Path(
316
                sys.prefix,
317
                "share",
318
                "pandoc_latex_tip",
319
                definition["collection"],
320
                definition["css"],
321
            ),
322
            ttf_file=pathlib.Path(
323
                sys.prefix,
324
                "share",
325
                "pandoc_latex_tip",
326
                definition["collection"],
327
                definition["ttf"],
328
            ),
329
            prefix=definition["prefix"],
330
        )
331
        icons.update({key: icon_font for key in icon_font.css_icons})
332
333
    config_path = pathlib.Path(sys.prefix, "share", "pandoc_latex_tip", "config.yml")
334
    if config_path.exists():
335
        with config_path.open(encoding="utf-8") as stream:
336
            config = yaml.safe_load(stream)
337
            for definition in config:
338
                if "collection" not in definition:
339
                    break
340
                collection = definition["collection"]
341
                if "css" not in definition:
342
                    break
343
                css_file = definition["css"]
344
                if "ttf" not in definition:
345
                    break
346
                ttf_file = definition["ttf"]
347
                if "prefix" not in definition:
348
                    break
349
                prefix = definition["prefix"]
350
                icon_font = IconFont(
351
                    css_file=pathlib.Path(
352
                        sys.prefix,
353
                        "share",
354
                        "pandoc_latex_tip",
355
                        collection,
356
                        css_file,
357
                    ),
358
                    ttf_file=pathlib.Path(
359
                        sys.prefix,
360
                        "share",
361
                        "pandoc_latex_tip",
362
                        collection,
363
                        ttf_file,
364
                    ),
365
                    prefix=prefix,
366
                )
367
                icons.update({key: icon_font for key in icon_font.css_icons})
368
369
    return icons
370
371
372
def tip(elem: Element, doc: Doc) -> list[Element] | None:
373
    """
374
    Apply tip transformation to element.
375
376
    Parameters
377
    ----------
378
    elem
379
        The element
380
    doc
381
        The original document.
382
383
    Returns
384
    -------
385
    list[Element] | None
386
        The additional elements if any.
387
    """
388
    # Is it in the right format and is it a Span, Div?
389
    if doc.format in ("latex", "beamer") and isinstance(
390
        elem, (Span, Div, Code, CodeBlock)
391
    ):
392
        # Is there a latex-tip-icon attribute?
393
        if "latex-tip-icon" in elem.attributes:
394
            return add_latex(
395
                elem,
396
                latex_code(
397
                    doc,
398
                    elem.attributes,
399
                    {
400
                        "icon": "latex-tip-icon",
401
                        "position": "latex-tip-position",
402
                        "size": "latex-tip-size",
403
                        "color": "latex-tip-color",
404
                        "link": "latex-tip-link",
405
                    },
406
                ),
407
            )
408
409
        # Get the classes
410
        classes = set(elem.classes)
411
412
        # Loop on all font size definition
413
        # noinspection PyUnresolvedReferences
414
        for definition in doc.defined:
415
            # Are the classes correct?
416
            if classes >= definition["classes"]:
417
                return add_latex(elem, definition["latex"])
418
419
    return None
420
421
422
def add_latex(elem: Element, latex: str) -> list[Element] | None:
423
    """
424
    Add latex code.
425
426
    Parameters
427
    ----------
428
    elem
429
        Current element
430
    latex
431
        Latex code
432
433
    Returns
434
    -------
435
    list[Element] | None
436
        The additional elements if any.
437
    """
438
    # pylint: disable=too-many-return-statements
439
    if bool(latex):
440
        # Is it a Span or a Code?
441
        if isinstance(elem, (Span, Code)):
442
            return [elem, RawInline(latex, "tex")]
443
444
        # It is a CodeBlock: create a minipage to ensure the
445
        # _tip to be on the same page as the codeblock
446
        if isinstance(elem, CodeBlock):
447
            return [
448
                RawBlock(f"\\begin{{minipage}}{{\\textwidth}}{latex}", "tex"),
449
                elem,
450
                RawBlock("\\end{minipage}", "tex"),
451
            ]
452
453
        while elem.content and isinstance(elem.content[0], Div):
454
            elem = elem.content[0]
455
456
        if not elem.content or isinstance(
457
            elem.content[0], (HorizontalRule, Figure, RawBlock, DefinitionList)
458
        ):
459
            elem.content.insert(0, RawBlock(latex, "tex"))
460
            return None
461
        if isinstance(elem.content[0], (Plain, Para)):
462
            elem.content[0].content.insert(1, RawInline(latex, "tex"))
463
            return None
464
        if isinstance(elem.content[0], LineBlock):
465
            elem.content[0].content[0].content.insert(1, RawInline(latex, "tex"))
466
            return None
467
        if isinstance(elem.content[0], CodeBlock):
468
            elem.content.insert(
469
                0,
470
                RawBlock(f"\\begin{{minipage}}{{\\textwidth}}{latex}", "tex"),
471
            )
472
            elem.content.insert(2, RawBlock("\\end{minipage}", "tex"))
473
            return None
474
        if isinstance(elem.content[0], (BulletList, OrderedList)):
475
            elem.content[0].content[0].content[0].content.insert(  # noqa: ECE001
476
                1,
477
                RawInline(latex, "tex"),
478
            )
479
            return None
480
        debug("[WARNING] pandoc-latex-tip: Bad usage")
481
    return None
482
483
484
# pylint: disable=too-many-arguments,too-many-locals
485
def latex_code(doc: Doc, definition: dict[str, Any], keys: dict[str, str]) -> str:
486
    """
487
    Get the latex code.
488
489
    Parameters
490
    ----------
491
    doc
492
        The original document
493
    definition
494
        The defition
495
    keys
496
        Key mapping
497
498
    Returns
499
    -------
500
    str
501
        The latex code.
502
    """
503
    # Get the default color
504
    color = str(definition.get(keys["color"], "black"))
505
506
    # Get the size
507
    size = get_size(str(definition.get(keys["size"], "18")))
508
509
    # Get the prefixes
510
    # noinspection PyArgumentEqualDefault
511
    prefix_odd = get_prefix(str(definition.get(keys["position"], "")), odd=True)
512
    prefix_even = get_prefix(str(definition.get(keys["position"], "")), odd=False)
513
514
    # Get the link
515
    link = str(definition.get(keys["link"], ""))
516
517
    # Get the icons
518
    icons = get_icons(doc, definition, keys["icon"], color, link)
519
520
    # Get the images
521
    images = create_images(doc, icons, size)
522
523
    if bool(images):
524
        # pylint: disable=consider-using-f-string
525
        return f"""
526
\\checkoddpage%%
527
\\ifoddpage%%
528
{prefix_odd}%%
529
\\else%%
530
{prefix_even}%%
531
\\fi%%
532
\\marginnote{{{''.join(images)}}}[0pt]\\vspace{{0cm}}%%
533
"""
534
535
    return ""
536
537
538
def get_icons(
539
    doc: Doc,
540
    definition: dict[str, Any],
541
    key_icons: str,
542
    color: str,
543
    link: str,
544
) -> list[dict[str, Any]]:
545
    """
546
    Get tge icons.
547
548
    Parameters
549
    ----------
550
    doc
551
        The original document
552
    definition
553
        The definition
554
    key_icons
555
        A key mapping
556
    color
557
        The color
558
    link
559
        The link
560
561
    Returns
562
    -------
563
    list[dict[str, Any]]
564
        A list of icon definitions.
565
    """
566
    # Test the icons definition
567
    if key_icons in definition:
568
        icons: list[dict[str, str]] = []
569
        # pylint: disable=invalid-name
570
        if isinstance(definition[key_icons], list):
571
            for icon in definition[key_icons]:
572
                try:
573
                    icon["color"] = icon.get("color", color)
574
                    icon["link"] = icon.get("link", link)
575
                except AttributeError:
576
                    icon = {
577
                        "name": icon,
578
                        "color": color,
579
                        "link": link,
580
                    }
581
582
                add_icon(doc, icons, icon)
583
        else:
584
            # noinspection PyUnresolvedReferences
585
            if definition[key_icons] in doc.icons:
586
                icons = [
587
                    {
588
                        "name": definition[key_icons],
589
                        "color": color,
590
                        "link": link,
591
                    }
592
                ]
593
            else:
594
                icons = []
595
    else:
596
        icons = [
597
            {
598
                "name": "fa-exclamation-circle",
599
                "color": color,
600
                "link": link,
601
            }
602
        ]
603
604
    return icons
605
606
607
def add_icon(doc: Doc, icons: list[dict[str, str]], icon: dict[str, str]) -> None:
608
    """
609
    Add icon.
610
611
    Parameters
612
    ----------
613
    doc
614
        The original document.
615
    icons
616
        A list of icon definition
617
    icon
618
        A potential new icon
619
    """
620
    if "name" not in icon:
621
        # Bad formed icon
622
        debug("[WARNING] pandoc-latex-tip: Bad formed icon")
623
        return
624
625
    # Lower the color
626
    lower_color = icon["color"].lower()
627
628
    # Convert the color to black if unexisting
629
    # pylint: disable=import-outside-toplevel
630
631
    if lower_color not in PIL.ImageColor.colormap:
632
        debug(
633
            f"[WARNING] pandoc-latex-tip: {lower_color}"
634
            " is not a correct color name; using black"
635
        )
636
        lower_color = "black"
637
638
    # Is the icon correct?
639
    try:
640
        # noinspection PyUnresolvedReferences
641
        if icon["name"] in doc.icons:
642
            icons.append(
643
                {
644
                    "name": icon["name"],
645
                    "color": lower_color,
646
                    "link": icon["link"],
647
                }
648
            )
649
        else:
650
            debug(
651
                f"[WARNING] pandoc-latex-tip: {icon['name']}"
652
                " is not a correct icon name"
653
            )
654
    except FileNotFoundError:
655
        debug("[WARNING] pandoc-latex-tip: error in accessing to icons definition")
656
657
658
# pylint:disable=too-many-return-statements
659
def get_prefix(position: str, odd: bool = True) -> str:
660
    """
661
    Get the latex prefix.
662
663
    Parameters
664
    ----------
665
    position
666
        The icon position
667
    odd
668
        Is the page is odd ?
669
670
    Returns
671
    -------
672
    str
673
        The latex prefix.
674
    """
675
    if position == "right":
676
        if odd:
677
            return "\\pandoclatextipoddright"
678
        return "\\pandoclatextipevenright"
679
    if position in ("left", ""):
680
        if odd:
681
            return "\\pandoclatextipoddleft"
682
        return "\\pandoclatextipevenleft"
683
    if position == "inner":
684
        if odd:
685
            return "\\pandoclatextipoddinner"
686
        return "\\pandoclatextipeveninner"
687
    if position == "outer":
688
        if odd:
689
            return "\\pandoclatextipoddouter"
690
        return "\\pandoclatextipevenouter"
691
    debug(
692
        f"[WARNING] pandoc-latex-tip: {position}"
693
        " is not a correct position; using left"
694
    )
695
    if odd:
696
        return "\\pandoclatextipoddleft"
697
    return "\\pandoclatextipevenleft"
698
699
700
def get_size(size: str) -> str:
701
    """
702
    Get the correct size.
703
704
    Parameters
705
    ----------
706
    size
707
        The initial size
708
709
    Returns
710
    -------
711
    str
712
        The correct size.
713
    """
714
    try:
715
        int_value = int(size)
716
        if int_value > 0:
717
            size = str(int_value)
718
        else:
719
            debug(
720
                f"[WARNING] pandoc-latex-tip: size must be greater than 0; using {size}"
721
            )
722
    except ValueError:
723
        debug(f"[WARNING] pandoc-latex-tip: size must be a number; using {size}")
724
    return size
725
726
727
def create_images(doc: Doc, icons: list[dict[str, Any]], size: str) -> list[str]:
728
    """
729
    Create the images.
730
731
    Parameters
732
    ----------
733
    doc
734
        The original document
735
    icons
736
        A list of icon definitions
737
    size
738
        The icon size.
739
740
    Returns
741
    -------
742
    list[str]
743
        A list of latex code.
744
    """
745
    # Generate the LaTeX image code
746
    images = []
747
748
    for icon in icons:
749
        # Get the image from the App cache folder
750
        # noinspection PyUnresolvedReferences
751
        image = os.path.join(doc.folder, icon["color"], icon["name"] + ".png")
752
753
        # Create the image if not existing in the cache
754
        try:
755
            if not os.path.isfile(image):
756
                # Create the image in the cache
757
                # noinspection PyUnresolvedReferences
758
                doc.icons[icon["name"]].export_icon(
759
                    icon["name"],
760
                    512,
761
                    color=icon["color"],
762
                    export_dir=os.path.join(doc.folder, icon["color"]),
763
                )
764
765
            # Add the LaTeX image
766
            image = Image(
767
                url=str(image), attributes={"width": size + "pt", "height": size + "pt"}
768
            )
769
            if icon["link"] == "":
770
                elem = image
771
            else:
772
                elem = Link(image, url=icon["link"])
773
            images.append(
774
                convert_text(
775
                    Plain(elem), input_format="panflute", output_format="latex"
776
                )
777
            )
778
        except TypeError:
779
            debug(
780
                f"[WARNING] pandoc-latex-tip: icon name "
781
                f"{icon['name']} does not exist"
782
            )
783
        except FileNotFoundError:
784
            debug("[WARNING] pandoc-latex-tip: error in generating image")
785
786
    return images
787
788
789
def add_definition(doc: Doc, definition: dict[str, Any]) -> None:
790
    """
791
    Add definition to document.
792
793
    Parameters
794
    ----------
795
    doc
796
        The original document
797
    definition
798
        The definition
799
    """
800
    # Get the classes
801
    classes = definition["classes"]
802
803
    # Add a definition if correct
804
    if bool(classes):
805
        latex = latex_code(
806
            doc,
807
            definition,
808
            {
809
                "icon": "icons",
810
                "position": "position",
811
                "size": "size",
812
                "color": "color",
813
                "link": "link",
814
            },
815
        )
816
        if latex:
817
            # noinspection PyUnresolvedReferences
818
            doc.defined.append({"classes": set(classes), "latex": latex})
819
820
821
def prepare(doc: Doc) -> None:
822
    """
823
    Prepare the document.
824
825
    Parameters
826
    ----------
827
    doc
828
        The original document.
829
    """
830
    # Add getIconFont library to doc
831
    doc.icons = load_icons()
832
833
    # Prepare the definitions
834
    doc.defined = []
835
836
    # Prepare the folder
837
    try:
838
        # Use user cache dir if possible
839
        doc.folder = platformdirs.AppDirs(
840
            "pandoc_latex_tip",
841
        ).user_cache_dir
842
        if not pathlib.Path(doc.folder).exists():
843
            pathlib.Path(doc.folder).mkdir(parents=True)
844
    except PermissionError:
845
        # Fallback to a temporary dir
846
        doc.folder = tempfile.mkdtemp(
847
            prefix="pandoc_latex_tip_",
848
            suffix="_cache",
849
        )
850
851
    # Get the meta data
852
    # noinspection PyUnresolvedReferences
853
    meta = doc.get_metadata("pandoc-latex-tip")
854
855
    if isinstance(meta, list):
856
        # Loop on all definitions
857
        for definition in meta:
858
            # Verify the definition
859
            if (
860
                isinstance(definition, dict)
861
                and "classes" in definition
862
                and isinstance(definition["classes"], list)
863
            ):
864
                add_definition(doc, definition)
865
866
867
def finalize(doc: Doc) -> None:
868
    """
869
    Finalize the document.
870
871
    Parameters
872
    ----------
873
    doc
874
        The original document
875
    """
876
    # Add header-includes if necessary
877
    if "header-includes" not in doc.metadata:
878
        doc.metadata["header-includes"] = MetaList()
879
    # Convert header-includes to MetaList if necessary
880
    elif not isinstance(doc.metadata["header-includes"], MetaList):
881
        doc.metadata["header-includes"] = MetaList(doc.metadata["header-includes"])
882
883
    doc.metadata["header-includes"].append(
884
        MetaInlines(RawInline("\\usepackage{graphicx,grffile}", "tex"))
885
    )
886
    doc.metadata["header-includes"].append(
887
        MetaInlines(RawInline("\\usepackage{marginnote}", "tex"))
888
    )
889
    doc.metadata["header-includes"].append(
890
        MetaInlines(RawInline("\\usepackage{etoolbox}", "tex"))
891
    )
892
    doc.metadata["header-includes"].append(
893
        MetaInlines(RawInline("\\usepackage[strict]{changepage}", "tex"))
894
    )
895
    doc.metadata["header-includes"].append(
896
        MetaInlines(
897
            RawInline(
898
                r"""
899
\makeatletter%
900
\newcommand{\pandoclatextipoddinner}{\reversemarginpar}%
901
\newcommand{\pandoclatextipeveninner}{\reversemarginpar}%
902
\newcommand{\pandoclatextipoddouter}{\normalmarginpar}%
903
\newcommand{\pandoclatextipevenouter}{\normalmarginpar}%
904
\newcommand{\pandoclatextipoddleft}{\reversemarginpar}%
905
\newcommand{\pandoclatextipoddright}{\normalmarginpar}%
906
\if@twoside%
907
\newcommand{\pandoclatextipevenright}{\reversemarginpar}%
908
\newcommand{\pandoclatextipevenleft}{\normalmarginpar}%
909
\else%
910
\newcommand{\pandoclatextipevenright}{\normalmarginpar}%
911
\newcommand{\pandoclatextipevenleft}{\reversemarginpar}%
912
\fi%
913
\makeatother%
914
\checkoddpage
915
    """,
916
                "tex",
917
            )
918
        )
919
    )
920
921
922
def main(doc: Doc | None = None) -> Doc:
923
    """
924
    Transform the pandoc document.
925
926
    Arguments
927
    ---------
928
    doc
929
        The pandoc document
930
931
    Returns
932
    -------
933
    Doc
934
        The transformed document
935
    """
936
    return run_filter(tip, prepare=prepare, finalize=finalize, doc=doc)
937
938
939
class CollectionsAddCommand(Command):
940
    """
941
    CollectionsAddCommand.
942
    """
943
944
    name = "collections add"
945
    description = "Add a file to a collection"
946
    arguments = [
947
        argument("name", description="Collection name"),
948
        argument("file", description="File name"),
949
    ]
950
951
    def handle(self) -> int:
952
        """
953
        Handle collections add command.
954
955
        Returns
956
        -------
957
        int
958
            status code
959
        """
960
        if self.argument("name") == "fontawesome":
961
            self.line("<error>You cannot modify core collection</error>")
962
            return 1
963
964
        try:
965
            dir_path = pathlib.Path(
966
                sys.prefix, "share", "pandoc_latex_tip", self.argument("name")
967
            )
968
            if not dir_path.exists():
969
                dir_path.mkdir(parents=True)
970
            file_path = pathlib.Path(self.argument("file"))
971
            dest_path = pathlib.Path(dir_path, file_path.parts[-1])
972
            shutil.copy(file_path, dest_path)
973
974
            self.line(
975
                f"Add file '{self.argument('file')}' to "
976
                f"collection '{self.argument('name')}'"
977
            )
978
979
        except PermissionError as error:
980
            self.line(f"<error>{error}</error>")
981
            return 1
982
        return 0
983
984
985
class CollectionsDeleteCommand(Command):
986
    """
987
    CollectionDeleteCommand.
988
    """
989
990
    name = "collections delete"
991
    description = "Delete a collection"
992
    arguments = [
993
        argument("name", description="Collection name"),
994
    ]
995
996
    def handle(self) -> int:
997
        """
998
        Handle collections delete command.
999
1000
        Returns
1001
        -------
1002
        int
1003
            status code
1004
        """
1005
        if self.argument("name") == "fontawesome":
1006
            self.line("<error>You cannot modify core collection</error>")
1007
            return 1
1008
        try:
1009
            dir_path = pathlib.Path(
1010
                sys.prefix, "share", "pandoc_latex_tip", self.argument("name")
1011
            )
1012
            config_path = pathlib.Path(
1013
                sys.prefix,
1014
                "share",
1015
                "pandoc_latex_tip",
1016
                "config.yml",
1017
            )
1018
            try:
1019
                if config_path.exists():
1020
                    with config_path.open(encoding="utf-8") as stream:
1021
                        icons = yaml.safe_load(stream)
1022
                        for definition in icons:
1023
                            if definition["collection"] == self.argument("name"):
1024
                                self.line(
1025
                                    f"<error>Collection '{self.argument('name')}' "
1026
                                    f"is in use</error>"
1027
                                )
1028
                                return 1
1029
            except PermissionError as error:
1030
                self.line(f"<error>{error}</error>")
1031
                return 1
1032
1033
            if dir_path.exists():
1034
                shutil.rmtree(dir_path)
1035
                self.line(f"Delete collection '{self.argument('name')}'")
1036
            else:
1037
                self.line(
1038
                    f"<error>Collection '{self.argument('name')}' "
1039
                    f"does not exist</error>"
1040
                )
1041
                return 1
1042
        except PermissionError as error:
1043
            self.line(f"<error>{error}</error>")
1044
            return 1
1045
        return 0
1046
1047
1048
class CollectionsListCommand(Command):
1049
    """
1050
    CollectionsListCommand.
1051
    """
1052
1053
    name = "collections"
1054
    description = "List the collections"
1055
1056
    def handle(self) -> int:
1057
        """
1058
        Handle collections command.
1059
1060
        Returns
1061
        -------
1062
        int
1063
            status code
1064
        """
1065
        dir_path = pathlib.Path(sys.prefix, "share", "pandoc_latex_tip")
1066
        for folder in dir_path.iterdir():
1067
            if folder.parts[-1] == "fontawesome":
1068
                self.line("<info>fontawesome *</info>")
1069
            elif folder.is_dir():
1070
                self.line(folder.parts[-1])
1071
        return 0
1072
1073
1074
class CollectionsInfoCommand(Command):
1075
    """
1076
    CollectionsInfoCommand.
1077
    """
1078
1079
    name = "collections info"
1080
    description = "Display a collection"
1081
    arguments = [
1082
        argument("name", description="Collection name"),
1083
    ]
1084
1085
    def handle(self) -> int:
1086
        """
1087
        Handle collections info command.
1088
1089
        Returns
1090
        -------
1091
        int
1092
            status code
1093
        """
1094
        dir_path = pathlib.Path(
1095
            sys.prefix,
1096
            "share",
1097
            "pandoc_latex_tip",
1098
            self.argument("name"),
1099
        )
1100
        if dir_path.exists():
1101
            for filename in dir_path.iterdir():
1102
                self.line(filename.parts[-1])
1103
        else:
1104
            self.line(
1105
                f"<error>Collection '{self.argument('name')}' "
1106
                f"does not exist</error>"
1107
            )
1108
            return 1
1109
        return 0
1110
1111
1112
class IconsAddCommand(Command):
1113
    """
1114
    IconsAddCommand.
1115
    """
1116
1117
    name = "icons add"
1118
    description = "Add a set of icons from a collection"
1119
    arguments = [
1120
        argument("name", description="Collection name"),
1121
    ]
1122
    options = [
1123
        option("css", flag=False, description="css filename from the collection"),
1124
        option("ttf", flag=False, description="ttf filename from the collection"),
1125
        option("prefix", flag=False, description="icon prefix"),
1126
    ]
1127
1128
    def handle(self) -> int:
1129
        """
1130
        Handle icons add command.
1131
1132
        Returns
1133
        -------
1134
        int
1135
            status code
1136
        """
1137
        if self.argument("name") == "fontawesome":
1138
            self.line("<error>You cannot modify core collection</error>")
1139
            return 1
1140
        dir_path = pathlib.Path(
1141
            sys.prefix,
1142
            "share",
1143
            "pandoc_latex_tip",
1144
            self.argument("name"),
1145
        )
1146
        if dir_path.exists():
1147
            css_file = pathlib.Path(dir_path, self.option("css"))
1148
            if not css_file.exists():
1149
                self.line(
1150
                    f"<error>Collection '{self.argument('name')}' "
1151
                    f"does not contain '{self.option('css')}'</error>"
1152
                )
1153
                return 1
1154
            ttf_file = pathlib.Path(dir_path, self.option("ttf"))
1155
            if not ttf_file.exists():
1156
                self.line(
1157
                    f"<error>Collection '{self.argument('name')}' "
1158
                    f"does not contain '{self.option('ttf')}'</error>"
1159
                )
1160
                return 1
1161
            config_path = pathlib.Path(
1162
                sys.prefix,
1163
                "share",
1164
                "pandoc_latex_tip",
1165
                "config.yml",
1166
            )
1167
            try:
1168
                if config_path.exists():
1169
                    with config_path.open(encoding="utf-8") as stream:
1170
                        icons = yaml.safe_load(stream)
1171
                        for definition in icons:
1172
                            if definition["prefix"] == self.option("prefix"):
1173
                                self.line(
1174
                                    f"<error>Prefix '{self.option('prefix')}' "
1175
                                    f"is already used</error>"
1176
                                )
1177
                                return 1
1178
                else:
1179
                    icons = []
1180
                icons.append(
1181
                    {
1182
                        "collection": self.argument("name"),
1183
                        "css": self.option("css"),
1184
                        "ttf": self.option("ttf"),
1185
                        "prefix": self.option("prefix"),
1186
                    },
1187
                )
1188
                with config_path.open(mode="w", encoding="utf-8") as stream:
1189
                    stream.write(yaml.dump(icons, sort_keys=False))
1190
            except PermissionError as error:
1191
                self.line(f"<error>{error}</error>")
1192
                return 1
1193
1194
        else:
1195
            self.line(
1196
                f"<error>Collection '{self.argument('name')}' "
1197
                f"does not exist</error>"
1198
            )
1199
            return 1
1200
        return 0
1201
1202
1203
class IconsDeleteCommand(Command):
1204
    """
1205
    IconsDeleteCommand.
1206
    """
1207
1208
    name = "icons delete"
1209
    description = "Delete a set of icons"
1210
    options = [
1211
        option("prefix", flag=False, description="icon prefix"),
1212
    ]
1213
1214
    def handle(self) -> int:
1215
        """
1216
        Handle icons delete command.
1217
1218
        Returns
1219
        -------
1220
        int
1221
            status code
1222
        """
1223
        if self.option("prefix") in ("fa-", "far-", "fab-"):
1224
            self.line("<error>You cannot modify core icons</error>")
1225
            return 1
1226
        config_path = pathlib.Path(
1227
            sys.prefix,
1228
            "share",
1229
            "pandoc_latex_tip",
1230
            "config.yml",
1231
        )
1232
        try:
1233
            if config_path.exists():
1234
                with config_path.open(encoding="utf-8") as stream:
1235
                    icons = yaml.safe_load(stream)
1236
                keep = [
1237
                    definition
1238
                    for definition in icons
1239
                    if definition["prefix"] != self.option("prefix")
1240
                ]
1241
                if keep != icons:
1242
                    with config_path.open(mode="w", encoding="utf-8") as stream:
1243
                        stream.write(yaml.dump(keep, sort_keys=False))
1244
                else:
1245
                    self.line("<error>Unexisting prefix</error>")
1246
                    return 1
1247
            else:
1248
                self.line("<error>Unexisting config file</error>")
1249
                return 1
1250
1251
        except PermissionError as error:
1252
            self.line(f"<error>{error}</error>")
1253
            return 1
1254
        return 0
1255
1256
1257
class IconsListCommand(Command):
1258
    """
1259
    IconsListCommand.
1260
    """
1261
1262
    name = "icons"
1263
    description = "List the set of icons"
1264
1265
    def handle(self) -> int:
1266
        """
1267
        Handle icons command.
1268
1269
        Returns
1270
        -------
1271
        int
1272
            status code
1273
        """
1274
        icons = get_core_icons()
1275
        config_path = pathlib.Path(
1276
            sys.prefix,
1277
            "share",
1278
            "pandoc_latex_tip",
1279
            "config.yml",
1280
        )
1281
        try:
1282
            if config_path.exists():
1283
                with config_path.open(encoding="utf-8") as stream:
1284
                    icons.extend(yaml.safe_load(stream))
1285
        except PermissionError as error:
1286
            self.line(f"<error>{error}</error>")
1287
            return 1
1288
1289
        self.line(yaml.dump(icons, sort_keys=False))
1290
1291
        return 0
1292
1293
1294
class PandocLaTeXFilterCommand(Command):
1295
    """
1296
    PandocLaTeXFilterCommand.
1297
    """
1298
1299
    name = "latex"
1300
    description = "Run pandoc filter for LaTeX document"
1301
1302
    def handle(self) -> int:
1303
        """
1304
        Handle latex command.
1305
1306
        Returns
1307
        -------
1308
        int
1309
            status code
1310
        """
1311
        main()
1312
        return 0
1313
1314
1315
class PandocBeamerFilterCommand(Command):
1316
    """
1317
    PandocBeamerFilterCommand.
1318
    """
1319
1320
    name = "beamer"
1321
    description = "Run pandoc filter for Beamer document"
1322
1323
    def handle(self) -> int:
1324
        """
1325
        Handle beamer command.
1326
1327
        Returns
1328
        -------
1329
        int
1330
            status code
1331
        """
1332
        main()
1333
        return 0
1334
1335
1336
def app() -> None:
1337
    """
1338
    Create a cleo application.
1339
    """
1340
    application = Application("Pandoc LaTeX Tip Filter")
1341
    application.add(CollectionsAddCommand())
1342
    application.add(CollectionsDeleteCommand())
1343
    application.add(CollectionsListCommand())
1344
    application.add(CollectionsInfoCommand())
1345
    application.add(IconsAddCommand())
1346
    application.add(IconsDeleteCommand())
1347
    application.add(IconsListCommand())
1348
    application.add(PandocLaTeXFilterCommand())
1349
    application.add(PandocBeamerFilterCommand())
1350
    application.run()
1351
1352
1353
if __name__ == "__main__":
1354
    main()
1355