]> git.madduck.net Git - etc/neomutt.git/blob - .config/neomutt/buildmimetree.py

madduck's git repository

Every one of the projects in this repository is available at the canonical URL git://git.madduck.net/madduck/pub/<projectpath> — see each project's metadata for the exact URL.

All patches and comments are welcome. Please squash your changes to logical commits before using git-format-patch and git-send-email to patches@git.madduck.net. If you'd read over the Git project's submission guidelines and adhered to them, I'd be especially grateful.

SSH access, as well as push access can be individually arranged.

If you use my repositories frequently, consider adding the following snippet to ~/.gitconfig and using the third clone URL listed for each project:

[url "git://git.madduck.net/madduck/"]
  insteadOf = madduck:

buildmimetree.py: Copyright update
[etc/neomutt.git] / .config / neomutt / buildmimetree.py
1 #!/usr/bin/python3
2 #
3 # NeoMutt helper script to create multipart/* emails with Markdown → HTML
4 # alternative conversion, and handling of inline images, using NeoMutt's
5 # ability to manually craft MIME trees, but automating this process.
6 #
7 # Configuration:
8 #   neomuttrc (needs to be a single line):
9 #     set my_mdwn_extensions="extra,admonition,codehilite,sane_lists,smarty"
10 #     macro compose B "\
11 #       <enter-command> source '$my_confdir/buildmimetree.py \
12 #       --tempdir $tempdir --extensions $my_mdwn_extensions|'<enter>\
13 #       <enter-command> sourc e \$my_mdwn_postprocess_cmd_file<enter>\
14 #     " "Convert message into a modern MIME tree with inline images"
15 #
16 #     (Yes, we need to call source twice, as mutt only starts to process output
17 #     from a source command when the command exits, and since we need to react
18 #     to the output, we need to be invoked again, using a $my_ variable to pass
19 #     information)
20 #
21 # Requirements:
22 #   - python3
23 #   - python3-markdown
24 # Optional:
25 #   - pytest
26 #   - Pynliner
27 #   - Pygments, if installed, then syntax highlighting is enabled
28 #
29 # Latest version:
30 #   https://git.madduck.net/etc/neomutt.git/blob_plain/HEAD:/.config/neomutt/buildmimetree.py
31 #
32 # Copyright © 2023 martin f. krafft <madduck@madduck.net>
33 # Released under the GPL-2+ licence, just like Mutt itself.
34 #
35
36 import sys
37 import pathlib
38 import markdown
39 import tempfile
40 import argparse
41 import re
42 import mimetypes
43 from collections import namedtuple, OrderedDict
44 from markdown.extensions import Extension
45 from markdown.inlinepatterns import ImageInlineProcessor, IMAGE_LINK_RE
46 from email.utils import make_msgid
47 from urllib import request
48
49
50 def parse_cli_args(*args, **kwargs):
51     parser = argparse.ArgumentParser(
52         description=(
53             "NeoMutt helper to turn text/markdown email parts "
54             "into full-fledged MIME trees"
55         )
56     )
57     parser.epilog = (
58         "Copyright © 2023 martin f. krafft <madduck@madduck.net>.\n"
59         "Released under the MIT licence"
60     )
61
62     parser.add_argument(
63         "--extensions",
64         type=str,
65         default="",
66         help="Markdown extension to use (comma-separated list)"
67     )
68
69     parser.add_argument(
70         "--only-build",
71         action="store_true",
72         help="Only build, don't send the message",
73     )
74
75     parser.add_argument(
76         "--tempdir",
77         default=None,
78         help="Specify temporary directory to use for attachments",
79     )
80
81     parser.add_argument(
82         "--debug-commands",
83         action="store_true",
84         help="Turn on debug logging of commands generated to stderr",
85     )
86
87     subp = parser.add_subparsers(help="Sub-command parsers", dest="mode")
88     massage_p = subp.add_parser("massage", help="Massaging phase (internal use)")
89
90     massage_p.add_argument(
91         "--write-commands-to",
92         metavar="PATH",
93         dest="cmdpath",
94         help="Temporary file path to write commands to",
95     )
96
97     massage_p.add_argument(
98         "--debug-walk",
99         action="store_true",
100         help="Turn on debugging to stderr of the MIME tree walk",
101     )
102
103     massage_p.add_argument(
104         "MAILDRAFT",
105         nargs="?",
106         help="If provided, the script is invoked as editor on the mail draft",
107     )
108
109     return parser.parse_args(*args, **kwargs)
110
111
112 # [ MARKDOWN WRAPPING ] #######################################################
113
114
115 InlineImageInfo = namedtuple(
116     "InlineImageInfo", ["cid", "desc"], defaults=[None]
117 )
118
119
120 class InlineImageExtension(Extension):
121     class RelatedImageInlineProcessor(ImageInlineProcessor):
122         def __init__(self, re, md, ext):
123             super().__init__(re, md)
124             self._ext = ext
125
126         def handleMatch(self, m, data):
127             el, start, end = super().handleMatch(m, data)
128             if "src" in el.attrib:
129                 src = el.attrib["src"]
130                 if "://" not in src or src.startswith("file://"):
131                     # We only inline local content
132                     cid = self._ext.get_cid_for_image(el.attrib)
133                     el.attrib["src"] = f"cid:{cid}"
134             return el, start, end
135
136     def __init__(self):
137         super().__init__()
138         self._images = OrderedDict()
139
140     def extendMarkdown(self, md):
141         md.registerExtension(self)
142         inline_image_proc = self.RelatedImageInlineProcessor(
143             IMAGE_LINK_RE, md, self
144         )
145         md.inlinePatterns.register(inline_image_proc, "image_link", 150)
146
147     def get_cid_for_image(self, attrib):
148         msgid = make_msgid()[1:-1]
149         path = attrib["src"]
150         if path.startswith("/"):
151             path = f"file://{path}"
152         self._images[path] = InlineImageInfo(
153             msgid, attrib.get("title", attrib.get("alt"))
154         )
155         return msgid
156
157     def get_images(self):
158         return self._images
159
160
161 def markdown_with_inline_image_support(text, *, extensions=None):
162     inline_image_handler = InlineImageExtension()
163     extensions = extensions or []
164     extensions.append(inline_image_handler)
165     mdwn = markdown.Markdown(extensions=extensions)
166     htmltext = mdwn.convert(text)
167
168     images = inline_image_handler.get_images()
169
170     def replace_image_with_cid(matchobj):
171         for m in (matchobj.group(1), f"file://{matchobj.group(1)}"):
172             if m in images:
173                 return f"(cid:{images[m].cid}"
174         return matchobj.group(0)
175
176     text = re.sub(r"\(([^)\s]+)", replace_image_with_cid, text)
177     return text, htmltext, images
178
179
180 # [ PARTS GENERATION ] ########################################################
181
182
183 class Part(
184     namedtuple(
185         "Part",
186         ["type", "subtype", "path", "desc", "cid", "orig"],
187         defaults=[None, None, False],
188     )
189 ):
190     def __str__(self):
191         ret = f"<{self.type}/{self.subtype}>"
192         if self.cid:
193             ret = f"{ret} cid:{self.cid}"
194         if self.orig:
195             ret = f"{ret} ORIGINAL"
196         return ret
197
198
199 class Multipart(
200     namedtuple("Multipart", ["subtype", "children", "desc"], defaults=[None])
201 ):
202     def __str__(self):
203         return f"<multipart/{self.subtype}> children={len(self.children)}"
204
205
206 def filewriter_fn(path, content, mode="w", **kwargs):
207     with open(path, mode, **kwargs) as out_f:
208         out_f.write(content)
209
210
211 def collect_inline_images(
212     images, *, tempdir=None, filewriter_fn=filewriter_fn
213 ):
214     relparts = []
215     for path, info in images.items():
216         data = request.urlopen(path)
217
218         mimetype = data.headers["Content-Type"]
219         ext = mimetypes.guess_extension(mimetype)
220         tempfilename = tempfile.mkstemp(prefix="img", suffix=ext, dir=tempdir)
221         path = pathlib.Path(tempfilename[1])
222
223         filewriter_fn(path, data.read(), "w+b")
224
225         relparts.append(
226             Part(*mimetype.split("/"), path, cid=info.cid, desc=f"Image: {info.desc}")
227         )
228
229     return relparts
230
231
232 def convert_markdown_to_html(
233     origtext,
234     draftpath,
235     *,
236     filewriter_fn=filewriter_fn,
237     tempdir=None,
238     extensions=None,
239 ):
240     origtext, htmltext, images = markdown_with_inline_image_support(
241         origtext, extensions=extensions
242     )
243
244     filewriter_fn(draftpath, origtext, encoding="utf-8")
245     textpart = Part(
246         "text", "plain", draftpath, "Plain-text version", orig=True
247     )
248
249     htmlpath = draftpath.with_suffix(".html")
250     filewriter_fn(
251         htmlpath, htmltext, encoding="utf-8", errors="xmlcharrefreplace"
252     )
253     htmlpart = Part("text", "html", htmlpath, "HTML version")
254
255     altpart = Multipart(
256         "alternative", [textpart, htmlpart], "Group of alternative content"
257     )
258
259     imgparts = collect_inline_images(
260         images, tempdir=tempdir, filewriter_fn=filewriter_fn
261     )
262     if imgparts:
263         return Multipart(
264             "relative", [altpart] + imgparts, "Group of related content"
265         )
266     else:
267         return altpart
268
269
270 class MIMETreeDFWalker:
271     def __init__(self, *, visitor_fn=None, debug=False):
272         self._visitor_fn = visitor_fn
273         self._debug = debug
274
275     def walk(self, root, *, visitor_fn=None):
276         """
277         Recursive function to implement a depth-dirst walk of the MIME-tree
278         rooted at `root`.
279         """
280
281         if isinstance(root, list):
282             root = Multipart("mixed", children=root)
283
284         self._walk(
285             root,
286             stack=[],
287             visitor_fn=visitor_fn or self._visitor_fn,
288         )
289
290     def _walk(self, node, *, stack, visitor_fn):
291         # Let's start by enumerating the parts at the current level. At the
292         # root level, stack will be the empty list, and we expect a multipart/*
293         # container at this level. Later, e.g. within a mutlipart/alternative
294         # container, the subtree will just be the alternative parts, while the
295         # top of the stack will be the multipart/alternative container, which
296         # we will process after the following loop.
297
298         lead = f"{'| '*len(stack)}|-"
299         if isinstance(node, Multipart):
300             self.debugprint(
301                 f"{lead}{node} parents={[s.subtype for s in stack]}"
302             )
303
304             # Depth-first, so push the current container onto the stack,
305             # then descend …
306             stack.append(node)
307             self.debugprint("| " * (len(stack) + 1))
308             for child in node.children:
309                 self._walk(
310                     child,
311                     stack=stack,
312                     visitor_fn=visitor_fn,
313                 )
314             self.debugprint("| " * len(stack))
315             assert stack.pop() == node
316
317         else:
318             self.debugprint(f"{lead}{node}")
319
320         if visitor_fn:
321             visitor_fn(node, stack, debugprint=self.debugprint)
322
323     def debugprint(self, s, **kwargs):
324         if self._debug:
325             print(s, file=sys.stderr, **kwargs)
326
327
328 # [ RUN MODES ] ###############################################################
329
330
331 class MuttCommands:
332     """
333     Stupid class to interface writing out Mutt commands. This is quite a hack
334     to deal with the fact that Mutt runs "push" commands in reverse order, so
335     all of a sudden, things become very complicated when mixing with "real"
336     commands.
337
338     Hence we keep two sets of commands, and one set of pushes. Commands are
339     added to the first until a push is added, after which commands are added to
340     the second set of commands.
341
342     On flush(), the first set is printed, followed by the pushes in reverse,
343     and then the second set is printed. All 3 sets are then cleared.
344     """
345
346     def __init__(self, out_f=sys.stdout, *, debug=False):
347         self._cmd1, self._push, self._cmd2 = [], [], []
348         self._out_f = out_f
349         self._debug = debug
350
351     def cmd(self, s):
352         self.debugprint(s)
353         if self._push:
354             self._cmd2.append(s)
355         else:
356             self._cmd1.append(s)
357
358     def push(self, s):
359         s = s.replace('"', '"')
360         s = f'push "{s}"'
361         self.debugprint(s)
362         self._push.insert(0, s)
363
364     def flush(self):
365         print(
366             "\n".join(self._cmd1 + self._push + self._cmd2), file=self._out_f
367         )
368         self._cmd1, self._push, self._cmd2 = [], [], []
369
370     def debugprint(self, s, **kwargs):
371         if self._debug:
372             print(s, file=sys.stderr, **kwargs)
373
374
375 def do_setup(
376     *,
377     out_f=sys.stdout,
378     temppath=None,
379     tempdir=None,
380     debug_commands=False,
381 ):
382     temppath = temppath or pathlib.Path(
383         tempfile.mkstemp(prefix="muttmdwn-", dir=tempdir)[1]
384     )
385     cmds = MuttCommands(out_f, debug=debug_commands)
386
387     editor = f"{' '.join(sys.argv)} massage --write-commands-to {temppath}"
388
389     cmds.cmd('set my_editor="$editor"')
390     cmds.cmd('set my_edit_headers="$edit_headers"')
391     cmds.cmd(f'set editor="{editor}"')
392     cmds.cmd("unset edit_headers")
393     cmds.cmd(f"set my_mdwn_postprocess_cmd_file={temppath}")
394     cmds.push("<first-entry><edit-file>")
395     cmds.flush()
396
397
398 def do_massage(
399     draft_f,
400     draftpath,
401     cmd_f,
402     *,
403     extensions=None,
404     converter=convert_markdown_to_html,
405     only_build=False,
406     tempdir=None,
407     debug_commands=False,
408     debug_walk=False,
409 ):
410     # Here's the big picture: we're being invoked as the editor on the email
411     # draft, and whatever commands we write to the file given as cmdpath will
412     # be run by the second source command in the macro definition.
413
414     # Let's start by cleaning up what the setup did (see above), i.e. we
415     # restore the $editor and $edit_headers variables, and also unset the
416     # variable used to identify the command file we're currently writing
417     # to.
418     cmds = MuttCommands(cmd_f, debug=debug_commands)
419     cmds.cmd('set editor="$my_editor"')
420     cmds.cmd('set edit_headers="$my_edit_headers"')
421     cmds.cmd("unset my_editor")
422     cmds.cmd("unset my_edit_headers")
423
424     # let's flush those commands, as there'll be a lot of pushes from now
425     # on, which need to be run in reverse order
426     cmds.flush()
427
428     extensions = extensions.split(",") if extensions else []
429     tree = converter(draft_f.read(), draftpath, tempdir=tempdir, extensions=extensions)
430
431     mimetree = MIMETreeDFWalker(debug=debug_walk)
432
433     def visitor_fn(item, stack, *, debugprint=None):
434         """
435         Visitor function called for every node (part) of the MIME tree,
436         depth-first, and responsible for telling NeoMutt how to assemble
437         the tree.
438         """
439         KILL_LINE=r'\Ca\Ck'
440
441         if isinstance(item, Part):
442             # We've hit a leaf-node, i.e. an alternative or a related part
443             # with actual content.
444
445             # Let's add the part
446             if item.orig:
447                 # The original source already exists in the NeoMutt tree, but
448                 # the underlying file may have been modified, so we need to
449                 # update the encoding, but that's it:
450                 cmds.push("<update-encoding>")
451             else:
452                 # … whereas all other parts need to be added, and they're all
453                 # considered to be temporary and inline:
454                 cmds.push(f"<attach-file>{item.path}<enter>")
455                 cmds.push("<toggle-unlink><toggle-disposition>")
456
457             # If the item (including the original) comes with additional
458             # information, then we might just as well update the NeoMutt
459             # tree now:
460             if item.cid:
461                 cmds.push(f"<edit-content-id>{KILL_LINE}{item.cid}<enter>")
462
463         elif isinstance(item, Multipart):
464             # This node has children, but we already visited them (see
465             # above), and so they have been tagged in NeoMutt's compose
466             # window. Now it's just a matter of telling NeoMutt to do the
467             # appropriate grouping:
468             if item.subtype == "alternative":
469                 cmds.push("<group-alternatives>")
470             elif item.subtype in ("relative", "related"):
471                 cmds.push("<group-related>")
472             elif item.subtype == "multilingual":
473                 cmds.push("<group-multilingual>")
474
475         else:
476             # We should never get here
477             assert not "is valid part"
478
479         # If the item has a description, we might just as well add it
480         if item.desc:
481             cmds.push(f"<edit-description>{KILL_LINE}{item.desc}<enter>")
482
483         # Finally, if we're at non-root level, tag the new container,
484         # as it might itself be part of a container, to be processed
485         # one level up:
486         if stack:
487             cmds.push("<tag-entry>")
488
489     # -----------------
490     # End of visitor_fn
491
492     # Let's walk the tree and visit every node with our fancy visitor
493     # function
494     mimetree.walk(tree, visitor_fn=visitor_fn)
495
496     if not only_build:
497         cmds.push("<send-message>")
498
499     # Finally, cleanup. Since we're responsible for removing the temporary
500     # file, how's this for a little hack?
501     try:
502         filename = cmd_f.name
503     except AttributeError:
504         filename = "pytest_internal_file"
505     cmds.cmd(f"source 'rm -f {filename}|'")
506     cmds.cmd("unset my_mdwn_postprocess_cmd_file")
507     cmds.flush()
508
509
510 # [ CLI ENTRY ] ###############################################################
511
512 if __name__ == "__main__":
513     args = parse_cli_args()
514
515     if args.mode is None:
516         do_setup(
517             tempdir=args.tempdir,
518             debug_commands=args.debug_commands,
519         )
520
521     elif args.mode == "massage":
522         with open(args.MAILDRAFT, "r") as draft_f, open(
523             args.cmdpath, "w"
524         ) as cmd_f:
525             do_massage(
526                 draft_f,
527                 pathlib.Path(args.MAILDRAFT),
528                 cmd_f,
529                 extensions=args.extensions,
530                 only_build=args.only_build,
531                 tempdir=args.tempdir,
532                 debug_commands=args.debug_commands,
533                 debug_walk=args.debug_walk,
534             )
535
536
537 # [ TESTS ] ###################################################################
538
539 try:
540     import pytest
541     from io import StringIO
542
543     class Tests:
544         @pytest.fixture
545         def const1(self):
546             return "CONSTANT STRING 1"
547
548         @pytest.fixture
549         def const2(self):
550             return "CONSTANT STRING 2"
551
552         # NOTE: tests using the capsys fixture must specify sys.stdout to the
553         # functions they call, else old stdout is used and not captured
554
555         def test_MuttCommands_cmd(self, const1, const2, capsys):
556             "Assert order of commands"
557             cmds = MuttCommands(out_f=sys.stdout)
558             cmds.cmd(const1)
559             cmds.cmd(const2)
560             cmds.flush()
561             captured = capsys.readouterr()
562             assert captured.out == "\n".join((const1, const2, ""))
563
564         def test_MuttCommands_push(self, const1, const2, capsys):
565             "Assert reverse order of pushes"
566             cmds = MuttCommands(out_f=sys.stdout)
567             cmds.push(const1)
568             cmds.push(const2)
569             cmds.flush()
570             captured = capsys.readouterr()
571             assert (
572                 captured.out
573                 == ('"\npush "'.join(("", const2, const1, "")))[2:-6]
574             )
575
576         def test_MuttCommands_cmd_push_mixed(self, const1, const2, capsys):
577             "Assert reverse order of pushes"
578             cmds = MuttCommands(out_f=sys.stdout)
579             lines = ["000", "001", "010", "011", "100", "101", "110", "111"]
580             for i in range(2):
581                 cmds.cmd(lines[4 * i + 0])
582                 cmds.cmd(lines[4 * i + 1])
583                 cmds.push(lines[4 * i + 2])
584                 cmds.push(lines[4 * i + 3])
585             cmds.flush()
586
587             captured = capsys.readouterr()
588             lines_out = captured.out.splitlines()
589             assert lines[0] in lines_out[0]
590             assert lines[1] in lines_out[1]
591             assert lines[7] in lines_out[2]
592             assert lines[6] in lines_out[3]
593             assert lines[3] in lines_out[4]
594             assert lines[2] in lines_out[5]
595             assert lines[4] in lines_out[6]
596             assert lines[5] in lines_out[7]
597
598         @pytest.fixture
599         def basic_mime_tree(self):
600             return Multipart(
601                 "relative",
602                 children=[
603                     Multipart(
604                         "alternative",
605                         children=[
606                             Part(
607                                 "text",
608                                 "plain",
609                                 "part.txt",
610                                 desc="Plain",
611                                 orig=True,
612                             ),
613                             Part("text", "html", "part.html", desc="HTML"),
614                         ],
615                         desc="Alternative",
616                     ),
617                     Part(
618                         "text", "png", "logo.png", cid="logo.png", desc="Logo"
619                     ),
620                 ],
621                 desc="Related",
622             )
623
624         def test_MIMETreeDFWalker_depth_first_walk(self, basic_mime_tree):
625             mimetree = MIMETreeDFWalker()
626
627             items = []
628
629             def visitor_fn(item, stack, debugprint):
630                 items.append((item, len(stack)))
631
632             mimetree.walk(basic_mime_tree, visitor_fn=visitor_fn)
633             assert len(items) == 5
634             assert items[0][0].subtype == "plain"
635             assert items[0][1] == 2
636             assert items[1][0].subtype == "html"
637             assert items[1][1] == 2
638             assert items[2][0].subtype == "alternative"
639             assert items[2][1] == 1
640             assert items[3][0].subtype == "png"
641             assert items[3][1] == 1
642             assert items[4][0].subtype == "relative"
643             assert items[4][1] == 0
644
645         def test_MIMETreeDFWalker_list_to_mixed(self, basic_mime_tree):
646             mimetree = MIMETreeDFWalker()
647             items = []
648
649             def visitor_fn(item, stack, debugprint):
650                 items.append(item)
651
652             mimetree.walk([basic_mime_tree], visitor_fn=visitor_fn)
653             assert items[-1].subtype == "mixed"
654
655         def test_MIMETreeDFWalker_visitor_in_constructor(
656             self, basic_mime_tree
657         ):
658             items = []
659
660             def visitor_fn(item, stack, debugprint):
661                 items.append(item)
662
663             mimetree = MIMETreeDFWalker(visitor_fn=visitor_fn)
664             mimetree.walk(basic_mime_tree)
665             assert len(items) == 5
666
667         @pytest.fixture
668         def string_io(self, const1, text=None):
669             return StringIO(text or const1)
670
671         def test_do_massage_basic(self, const1, string_io, capsys):
672             def converter(drafttext, draftpath, extensions, tempdir):
673                 return Part("text", "plain", draftpath, orig=True)
674
675             do_massage(
676                 draft_f=string_io,
677                 draftpath=const1,
678                 cmd_f=sys.stdout,
679                 converter=converter,
680             )
681
682             captured = capsys.readouterr()
683             lines = captured.out.splitlines()
684             assert '="$my_editor"' in lines.pop(0)
685             assert '="$my_edit_headers"' in lines.pop(0)
686             assert "unset my_editor" == lines.pop(0)
687             assert "unset my_edit_headers" == lines.pop(0)
688             assert "send-message" in lines.pop(0)
689             assert "update-encoding" in lines.pop(0)
690             assert "source 'rm -f " in lines.pop(0)
691             assert "unset my_mdwn_postprocess_cmd_file" == lines.pop(0)
692
693         def test_do_massage_fulltree(
694             self, string_io, const1, basic_mime_tree, capsys
695         ):
696             def converter(drafttext, draftpath, extensions, tempdir):
697                 return basic_mime_tree
698
699             do_massage(
700                 draft_f=string_io,
701                 draftpath=const1,
702                 cmd_f=sys.stdout,
703                 converter=converter,
704             )
705
706             captured = capsys.readouterr()
707             lines = captured.out.splitlines()[4:]
708             assert "send-message" in lines.pop(0)
709             assert "Related" in lines.pop(0)
710             assert "group-related" in lines.pop(0)
711             assert "tag-entry" in lines.pop(0)
712             assert "Logo" in lines.pop(0)
713             assert "content-id" in lines.pop(0)
714             assert "toggle-unlink" in lines.pop(0)
715             assert "logo.png" in lines.pop(0)
716             assert "tag-entry" in lines.pop(0)
717             assert "Alternative" in lines.pop(0)
718             assert "group-alternatives" in lines.pop(0)
719             assert "tag-entry" in lines.pop(0)
720             assert "HTML" in lines.pop(0)
721             assert "toggle-unlink" in lines.pop(0)
722             assert "part.html" in lines.pop(0)
723             assert "tag-entry" in lines.pop(0)
724             assert "Plain" in lines.pop(0)
725             assert "update-encoding" in lines.pop(0)
726             assert len(lines) == 2
727
728         @pytest.fixture
729         def fake_filewriter(self):
730             class FileWriter:
731                 def __init__(self):
732                     self._writes = []
733
734                 def __call__(self, path, content, mode="w", **kwargs):
735                     self._writes.append((path, content))
736
737                 def pop(self, index=-1):
738                     return self._writes.pop(index)
739
740             return FileWriter()
741
742         @pytest.fixture
743         def markdown_non_converter(self, const1, const2):
744             return lambda s, text: f"{const1}{text}{const2}"
745
746         def test_converter_tree_basic(
747             self, const1, const2, fake_filewriter, markdown_non_converter
748         ):
749             path = pathlib.Path(const2)
750             tree = convert_markdown_to_html(
751                 const1, path, filewriter_fn=fake_filewriter
752             )
753
754             assert tree.subtype == "alternative"
755             assert len(tree.children) == 2
756             assert tree.children[0].subtype == "plain"
757             assert tree.children[0].path == path
758             assert tree.children[0].orig
759             assert tree.children[1].subtype == "html"
760             assert tree.children[1].path == path.with_suffix(".html")
761
762         def test_converter_writes(
763             self,
764             const1,
765             const2,
766             fake_filewriter,
767             monkeypatch,
768             markdown_non_converter,
769         ):
770             path = pathlib.Path(const2)
771
772             with monkeypatch.context() as m:
773                 m.setattr(markdown.Markdown, "convert", markdown_non_converter)
774                 convert_markdown_to_html(
775                     const1, path, filewriter_fn=fake_filewriter
776                 )
777
778             assert (path, const1) == fake_filewriter.pop(0)
779             assert (
780                 path.with_suffix(".html"),
781                 markdown_non_converter(None, const1),
782             ) == fake_filewriter.pop(0)
783
784         def test_markdown_inline_image_processor(self):
785             imgpath1 = "file:/path/to/image.png"
786             imgpath2 = "file:///path/to/image.png?url=params"
787             imgpath3 = "/path/to/image.png"
788             text = f"""![inline local image]({imgpath1})
789                        ![image inlined
790                          with newline]({imgpath2})
791                        ![image local path]({imgpath3})"""
792             text, html, images = markdown_with_inline_image_support(text)
793
794             # local paths have been normalised to URLs:
795             imgpath3 = f"file://{imgpath3}"
796
797             assert 'src="cid:' in html
798             assert "](cid:" in text
799             assert len(images) == 3
800             assert imgpath1 in images
801             assert imgpath2 in images
802             assert imgpath3 in images
803             assert images[imgpath1].cid != images[imgpath2].cid
804             assert images[imgpath1].cid != images[imgpath3].cid
805             assert images[imgpath2].cid != images[imgpath3].cid
806
807         def test_markdown_inline_image_processor_title_to_desc(self, const1):
808             imgpath = "file:///path/to/image.png"
809             text = f'![inline local image]({imgpath} "{const1}")'
810             text, html, images = markdown_with_inline_image_support(text)
811             assert images[imgpath].desc == const1
812
813         def test_markdown_inline_image_processor_alt_to_desc(self, const1):
814             imgpath = "file:///path/to/image.png"
815             text = f"![{const1}]({imgpath})"
816             text, html, images = markdown_with_inline_image_support(text)
817             assert images[imgpath].desc == const1
818
819         def test_markdown_inline_image_processor_title_over_alt_desc(
820             self, const1, const2
821         ):
822             imgpath = "file:///path/to/image.png"
823             text = f'![{const1}]({imgpath} "{const2}")'
824             text, html, images = markdown_with_inline_image_support(text)
825             assert images[imgpath].desc == const2
826
827         def test_markdown_inline_image_not_external(self):
828             imgpath = "https://path/to/image.png"
829             text = f"![inline image]({imgpath})"
830             text, html, images = markdown_with_inline_image_support(text)
831
832             assert 'src="cid:' not in html
833             assert "](cid:" not in text
834             assert len(images) == 0
835
836         def test_markdown_inline_image_local_file(self):
837             imgpath = "/path/to/image.png"
838             text = f"![inline image]({imgpath})"
839             text, html, images = markdown_with_inline_image_support(text)
840
841             for k, v in images.items():
842                 assert k == f"file://{imgpath}"
843                 break
844
845         @pytest.fixture
846         def test_png(self):
847             return (
848                 "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAE"
849                 "AAAABCAAAAAA6fptVAAAACklEQVQI12P4DwABAQEAG7buVgAA"
850             )
851
852         def test_markdown_inline_image_processor_base64(self, test_png):
853             text = f"![1px white inlined]({test_png})"
854             text, html, images = markdown_with_inline_image_support(text)
855
856             assert 'src="cid:' in html
857             assert "](cid:" in text
858             assert len(images) == 1
859             assert test_png in images
860
861         def test_converter_tree_inline_image_base64(
862             self, test_png, const1, fake_filewriter
863         ):
864             text = f"![inline base64 image]({test_png})"
865             path = pathlib.Path(const1)
866             tree = convert_markdown_to_html(
867                 text, path, filewriter_fn=fake_filewriter
868             )
869
870             assert tree.subtype == "relative"
871             assert tree.children[1].subtype == "png"
872             written = fake_filewriter.pop()
873             assert tree.children[1].path == written[0]
874             assert written[1] == request.urlopen(test_png).read()
875
876         def test_inline_image_collection(
877             self, test_png, const1, const2, fake_filewriter
878         ):
879             test_images = {test_png: InlineImageInfo(cid=const1, desc=const2)}
880             relparts = collect_inline_images(
881                 test_images, filewriter_fn=fake_filewriter
882             )
883
884             written = fake_filewriter.pop()
885             assert b"PNG" in written[1]
886
887             assert relparts[0].subtype == "png"
888             assert relparts[0].path == written[0]
889             assert relparts[0].cid == const1
890             assert relparts[0].desc.endswith(const2)
891
892 except ImportError:
893     pass