]> 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:

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