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.
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.
8 # neomuttrc (needs to be a single line):
9 # set my_mdwn_extensions="extra,admonition,codehilite,sane_lists,smarty"
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"
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
26 # - Pynliner, provides --css-file and thus inline styling of HTML output
27 # - Pygments, then syntax highlighting for fenced code is enabled
30 # https://git.madduck.net/etc/neomutt.git/blob_plain/HEAD:/.config/neomutt/buildmimetree.py
32 # Copyright © 2023 martin f. krafft <madduck@madduck.net>
33 # Released under the GPL-2+ licence, just like Mutt itself.
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
50 def parse_cli_args(*args, **kwargs):
51 parser = argparse.ArgumentParser(
53 "NeoMutt helper to turn text/markdown email parts "
54 "into full-fledged MIME trees"
58 "Copyright © 2023 martin f. krafft <madduck@madduck.net>.\n"
59 "Released under the MIT licence"
66 help="Markdown extension to use (comma-separated list)",
73 help="CSS file to merge with the final HTML",
76 parser.set_defaults(css_file=None)
79 "--related-to-html-only",
81 help="Make related content be sibling to HTML parts only",
84 def positive_integer(value):
92 raise ValueError(f"Must be a positive integer")
95 "--max-number-other-attachments",
96 type=positive_integer,
97 help="Make related content be sibling to HTML parts only",
103 help="Only build, don't send the message",
109 help="Specify temporary directory to use for attachments",
115 help="Turn on debug logging of commands generated to stderr",
118 subp = parser.add_subparsers(help="Sub-command parsers", dest="mode")
119 massage_p = subp.add_parser(
120 "massage", help="Massaging phase (internal use)"
123 massage_p.add_argument(
124 "--write-commands-to",
130 help="Temporary file path to write commands to",
133 massage_p.add_argument(
136 help="Turn on debugging to stderr of the MIME tree walk",
139 massage_p.add_argument(
143 help="If provided, the script is invoked as editor on the mail draft",
146 return parser.parse_args(*args, **kwargs)
149 # [ MARKDOWN WRAPPING ] #######################################################
152 InlineImageInfo = namedtuple(
153 "InlineImageInfo", ["cid", "desc"], defaults=[None]
157 class InlineImageExtension(Extension):
158 class RelatedImageInlineProcessor(ImageInlineProcessor):
159 def __init__(self, re, md, ext):
160 super().__init__(re, md)
163 def handleMatch(self, m, data):
164 el, start, end = super().handleMatch(m, data)
165 if "src" in el.attrib:
166 src = el.attrib["src"]
167 if "://" not in src or src.startswith("file://"):
168 # We only inline local content
169 cid = self._ext.get_cid_for_image(el.attrib)
170 el.attrib["src"] = f"cid:{cid}"
171 return el, start, end
175 self._images = OrderedDict()
177 def extendMarkdown(self, md):
178 md.registerExtension(self)
179 inline_image_proc = self.RelatedImageInlineProcessor(
180 IMAGE_LINK_RE, md, self
182 md.inlinePatterns.register(inline_image_proc, "image_link", 150)
184 def get_cid_for_image(self, attrib):
185 msgid = make_msgid()[1:-1]
187 if path.startswith("/"):
188 path = f"file://{path}"
189 self._images[path] = InlineImageInfo(
190 msgid, attrib.get("title", attrib.get("alt"))
194 def get_images(self):
198 def markdown_with_inline_image_support(
199 text, *, extensions=None, extension_configs=None
201 inline_image_handler = InlineImageExtension()
202 extensions = extensions or []
203 extensions.append(inline_image_handler)
204 mdwn = markdown.Markdown(
205 extensions=extensions, extension_configs=extension_configs
207 htmltext = mdwn.convert(text)
209 images = inline_image_handler.get_images()
211 def replace_image_with_cid(matchobj):
212 for m in (matchobj.group(1), f"file://{matchobj.group(1)}"):
214 return f"(cid:{images[m].cid}"
215 return matchobj.group(0)
217 text = re.sub(r"\(([^)\s]+)", replace_image_with_cid, text)
218 return text, htmltext, images
221 # [ CSS STYLING ] #############################################################
232 from pygments.formatters import get_formatter_by_name
234 _CODEHILITE_CLASS = "codehilite"
236 _PYGMENTS_CSS = get_formatter_by_name(
237 "html", style="default"
238 ).get_style_defs(f".{_CODEHILITE_CLASS}")
244 def apply_styling(html, css):
248 .with_cssString("\n".join(s for s in [_PYGMENTS_CSS, css] if s))
253 # [ PARTS GENERATION ] ########################################################
259 ["type", "subtype", "path", "desc", "cid", "orig"],
260 defaults=[None, None, False],
264 ret = f"<{self.type}/{self.subtype}>"
266 ret = f"{ret} cid:{self.cid}"
268 ret = f"{ret} ORIGINAL"
273 namedtuple("Multipart", ["subtype", "children", "desc"], defaults=[None])
276 return f"<multipart/{self.subtype}> children={len(self.children)}"
279 return hash(str(self.subtype) + "".join(str(self.children)))
282 def filewriter_fn(path, content, mode="w", **kwargs):
283 with open(path, mode, **kwargs) as out_f:
287 def collect_inline_images(
288 images, *, tempdir=None, filewriter_fn=filewriter_fn
291 for path, info in images.items():
292 if path.startswith("cid:"):
295 data = request.urlopen(path)
297 mimetype = data.headers["Content-Type"]
298 ext = mimetypes.guess_extension(mimetype)
299 tempfilename = tempfile.mkstemp(prefix="img", suffix=ext, dir=tempdir)
300 path = pathlib.Path(tempfilename[1])
302 filewriter_fn(path, data.read(), "w+b")
306 *mimetype.split("/"),
309 desc=f"Image: {info.desc}",
316 def convert_markdown_to_html(
320 related_to_html_only=False,
322 filewriter_fn=filewriter_fn,
325 extension_configs=None,
327 # TODO extension_configs need to be handled differently
328 extension_configs = extension_configs or {}
329 extension_configs.setdefault("pymdownx.highlight", {})
330 extension_configs["pymdownx.highlight"]["css_class"] = _CODEHILITE_CLASS
332 origtext, htmltext, images = markdown_with_inline_image_support(
333 origtext, extensions=extensions, extension_configs=extension_configs
336 filewriter_fn(draftpath, origtext, encoding="utf-8")
338 "text", "plain", draftpath, "Plain-text version", orig=True
341 htmltext = apply_styling(htmltext, cssfile)
343 htmlpath = draftpath.with_suffix(".html")
345 htmlpath, htmltext, encoding="utf-8", errors="xmlcharrefreplace"
347 htmlpart = Part("text", "html", htmlpath, "HTML version")
349 imgparts = collect_inline_images(
350 images, tempdir=tempdir, filewriter_fn=filewriter_fn
353 if related_to_html_only:
354 # If there are inline image part, they will be contained within a
355 # multipart/related part along with the HTML part only
357 # replace htmlpart with a multipart/related container of the HTML
358 # parts and the images
359 htmlpart = Multipart(
360 "relative", [htmlpart] + imgparts, "Group of related content"
364 "alternative", [textpart, htmlpart], "Group of alternative content"
368 # If there are inline image part, they will be siblings to the
369 # multipart/alternative tree within a multipart/related part
371 "alternative", [textpart, htmlpart], "Group of alternative content"
375 "relative", [altpart] + imgparts, "Group of related content"
381 class MIMETreeDFWalker:
382 def __init__(self, *, visitor_fn=None, debug=False):
383 self._visitor_fn = visitor_fn or self._echovisit
386 def _echovisit(self, node, ancestry, debugprint):
387 debugprint(f"node={node} ancestry={ancestry}")
389 def walk(self, root, *, visitor_fn=None):
391 Recursive function to implement a depth-dirst walk of the MIME-tree
394 if isinstance(root, list):
396 root = Multipart("mixed", children=root)
404 visitor_fn=visitor_fn or self._visitor_fn,
407 def _walk(self, node, *, ancestry, descendents, visitor_fn):
408 # Let's start by enumerating the parts at the current level. At the
409 # root level, ancestry will be the empty list, and we expect a
410 # multipart/* container at this level. Later, e.g. within a
411 # mutlipart/alternative container, the subtree will just be the
412 # alternative parts, while the top of the ancestry will be the
413 # multipart/alternative container, which we will process after the
416 lead = f"{'│ '*len(ancestry)}"
417 if isinstance(node, Multipart):
419 f"{lead}├{node} ancestry={[s.subtype for s in ancestry]}"
422 # Depth-first, so push the current container onto the ancestry
423 # stack, then descend …
424 ancestry.append(node)
425 self.debugprint(lead + "│ " * 2)
426 for child in node.children:
430 descendents=descendents,
431 visitor_fn=visitor_fn,
433 assert ancestry.pop() == node
434 sibling_descendents = descendents
435 descendents.extend(node.children)
438 self.debugprint(f"{lead}├{node}")
439 sibling_descendents = descendents
441 if False and ancestry:
442 self.debugprint(lead[:-1] + " │")
446 node, ancestry, sibling_descendents, debugprint=self.debugprint
449 def debugprint(self, s, **kwargs):
451 print(s, file=sys.stderr, **kwargs)
454 # [ RUN MODES ] ###############################################################
459 Stupid class to interface writing out Mutt commands. This is quite a hack
460 to deal with the fact that Mutt runs "push" commands in reverse order, so
461 all of a sudden, things become very complicated when mixing with "real"
464 Hence we keep two sets of commands, and one set of pushes. Commands are
465 added to the first until a push is added, after which commands are added to
466 the second set of commands.
468 On flush(), the first set is printed, followed by the pushes in reverse,
469 and then the second set is printed. All 3 sets are then cleared.
472 def __init__(self, out_f=sys.stdout, *, debug=False):
473 self._cmd1, self._push, self._cmd2 = [], [], []
485 s = s.replace('"', '"')
488 self._push.insert(0, s)
492 "\n".join(self._cmd1 + self._push + self._cmd2), file=self._out_f
494 self._cmd1, self._push, self._cmd2 = [], [], []
496 def debugprint(self, s, **kwargs):
498 print(s, file=sys.stderr, **kwargs)
506 debug_commands=False,
508 temppath = temppath or pathlib.Path(
509 tempfile.mkstemp(prefix="muttmdwn-", dir=tempdir)[1]
511 cmds = MuttCommands(out_f, debug=debug_commands)
513 editor = f"{' '.join(sys.argv)} massage --write-commands-to {temppath}"
515 cmds.cmd('set my_editor="$editor"')
516 cmds.cmd('set my_edit_headers="$edit_headers"')
517 cmds.cmd(f'set editor="{editor}"')
518 cmds.cmd("unset edit_headers")
519 cmds.cmd(f"set my_mdwn_postprocess_cmd_file={temppath}")
520 cmds.push("<first-entry><edit-file>")
531 converter=convert_markdown_to_html,
532 related_to_html_only=True,
534 max_other_attachments=20,
536 debug_commands=False,
539 # Here's the big picture: we're being invoked as the editor on the email
540 # draft, and whatever commands we write to the file given as cmdpath will
541 # be run by the second source command in the macro definition.
543 # Let's start by cleaning up what the setup did (see above), i.e. we
544 # restore the $editor and $edit_headers variables, and also unset the
545 # variable used to identify the command file we're currently writing
547 cmds = MuttCommands(cmd_f, debug=debug_commands)
548 cmds.cmd('set editor="$my_editor"')
549 cmds.cmd('set edit_headers="$my_edit_headers"')
550 cmds.cmd("unset my_editor")
551 cmds.cmd("unset my_edit_headers")
553 # let's flush those commands, as there'll be a lot of pushes from now
554 # on, which need to be run in reverse order
557 extensions = extensions.split(",") if extensions else []
562 related_to_html_only=related_to_html_only,
564 extensions=extensions,
567 mimetree = MIMETreeDFWalker(debug=debug_walk)
569 state = dict(pos=1, tags={}, parts=1)
571 def visitor_fn(item, ancestry, descendents, *, debugprint=None):
573 Visitor function called for every node (part) of the MIME tree,
574 depth-first, and responsible for telling NeoMutt how to assemble
577 KILL_LINE = r"\Ca\Ck"
579 if isinstance(item, Part):
580 # We've hit a leaf-node, i.e. an alternative or a related part
581 # with actual content.
585 # The original source already exists in the NeoMutt tree, but
586 # the underlying file may have been modified, so we need to
587 # update the encoding, but that's it:
588 cmds.push("<first-entry>")
589 cmds.push("<update-encoding>")
591 # We really just need to be able to assume that at this point,
592 # NeoMutt is at position 1, and that we've processed only this
593 # part so far. Nevermind about actual attachments, we can
594 # safely ignore those as they stay at the end.
595 assert state["pos"] == 1
596 assert state["parts"] == 1
598 # … whereas all other parts need to be added, and they're all
599 # considered to be temporary and inline:
600 cmds.push(f"<attach-file>{item.path}<enter>")
601 cmds.push("<toggle-unlink><toggle-disposition>")
603 # This added a part at the end of the list of parts, and that's
604 # just how many parts we've seen so far, so it's position in
605 # the NeoMutt compose list is the count of parts
607 state["pos"] = state["parts"]
609 # If the item (including the original) comes with additional
610 # information, then we might just as well update the NeoMutt
613 cmds.push(f"<edit-content-id>{KILL_LINE}{item.cid}<enter>")
615 # Now for the biggest hack in this script, which is to handle
616 # attachments, such as PDFs, that aren't related or alternatives.
617 # The problem is that when we add an inline image, it always gets
618 # appended to the list, i.e. inserted *after* other attachments.
619 # Since we don't know the number of attachments, we also cannot
620 # infer the postition of the new attachment. Therefore, we bubble
621 # it all the way to the top, only to then move it down again:
622 if state["pos"] > 1: # skip for the first part
623 for i in range(max_other_attachments):
624 # could use any number here, but has to be larger than the
625 # number of possible attachments. The performance
626 # difference of using a high number is negligible.
627 # Bubble up the new part
628 cmds.push(f"<move-up>")
630 # As we push the part to the right position in the list (i.e.
631 # the last of the subset of attachments this script added), we
632 # must handle the situation that subtrees are skipped by
633 # NeoMutt. Hence, the actual number of positions to move down
634 # is decremented by the number of descendents so far
636 for i in range(1, state["pos"] - len(descendents)):
637 cmds.push(f"<move-down>")
639 elif isinstance(item, Multipart):
640 # This node has children, but we already visited them (see
641 # above). The tags dictionary of State should contain a list of
642 # their positions in the NeoMutt compose window, so iterate those
643 # and tag the parts there:
644 n_tags = len(state["tags"][item])
645 for tag in state["tags"][item]:
646 cmds.push(f"<jump>{tag}<enter><tag-entry>")
648 if item.subtype == "alternative":
649 cmds.push("<group-alternatives>")
650 elif item.subtype in ("relative", "related"):
651 cmds.push("<group-related>")
652 elif item.subtype == "multilingual":
653 cmds.push("<group-multilingual>")
655 raise NotImplementedError(
656 f"Handling of multipart/{item.subtype} is not implemented"
659 state["pos"] -= n_tags - 1
663 # We should never get here
664 raise RuntimeError(f"Type {type(item)} is unexpected: {item}")
666 # If the item has a description, we might just as well add it
668 cmds.push(f"<edit-description>{KILL_LINE}{item.desc}<enter>")
671 # If there's an ancestry, record the current (assumed) position in
672 # the NeoMutt compose window as needed-to-tag by our direct parent
673 # (i.e. the last item of the ancestry)
674 state["tags"].setdefault(ancestry[-1], []).append(state["pos"])
676 lead = "│ " * (len(ancestry) + 1) + "* "
678 f"{lead}ancestry={[a.subtype for a in ancestry]}\n"
679 f"{lead}descendents={[d.subtype for d in descendents]}\n"
680 f"{lead}children_positions={state['tags'][ancestry[-1]]}\n"
681 f"{lead}pos={state['pos']}, parts={state['parts']}"
687 # Let's walk the tree and visit every node with our fancy visitor
689 mimetree.walk(tree, visitor_fn=visitor_fn)
692 cmds.push("<send-message>")
694 # Finally, cleanup. Since we're responsible for removing the temporary
695 # file, how's this for a little hack?
697 filename = cmd_f.name
698 except AttributeError:
699 filename = "pytest_internal_file"
700 cmds.cmd(f"source 'rm -f {filename}|'")
701 cmds.cmd("unset my_mdwn_postprocess_cmd_file")
705 # [ CLI ENTRY ] ###############################################################
707 if __name__ == "__main__":
708 args = parse_cli_args()
710 if args.mode is None:
712 tempdir=args.tempdir,
713 debug_commands=args.debug_commands,
716 elif args.mode == "massage":
717 with open(args.MAILDRAFT, "r") as draft_f, open(
724 extensions=args.extensions,
725 cssfile=args.css_file,
726 related_to_html_only=args.related_to_html_only,
727 max_other_attachments=args.max_number_other_attachments,
728 only_build=args.only_build,
729 tempdir=args.tempdir,
730 debug_commands=args.debug_commands,
731 debug_walk=args.debug_walk,
735 # [ TESTS ] ###################################################################
739 from io import StringIO
744 return "CONSTANT STRING 1"
748 return "CONSTANT STRING 2"
750 # NOTE: tests using the capsys fixture must specify sys.stdout to the
751 # functions they call, else old stdout is used and not captured
753 def test_MuttCommands_cmd(self, const1, const2, capsys):
754 "Assert order of commands"
755 cmds = MuttCommands(out_f=sys.stdout)
759 captured = capsys.readouterr()
760 assert captured.out == "\n".join((const1, const2, ""))
762 def test_MuttCommands_push(self, const1, const2, capsys):
763 "Assert reverse order of pushes"
764 cmds = MuttCommands(out_f=sys.stdout)
768 captured = capsys.readouterr()
771 == ('"\npush "'.join(("", const2, const1, "")))[2:-6]
774 def test_MuttCommands_cmd_push_mixed(self, const1, const2, capsys):
775 "Assert reverse order of pushes"
776 cmds = MuttCommands(out_f=sys.stdout)
777 lines = ["000", "001", "010", "011", "100", "101", "110", "111"]
779 cmds.cmd(lines[4 * i + 0])
780 cmds.cmd(lines[4 * i + 1])
781 cmds.push(lines[4 * i + 2])
782 cmds.push(lines[4 * i + 3])
785 captured = capsys.readouterr()
786 lines_out = captured.out.splitlines()
787 assert lines[0] in lines_out[0]
788 assert lines[1] in lines_out[1]
789 assert lines[7] in lines_out[2]
790 assert lines[6] in lines_out[3]
791 assert lines[3] in lines_out[4]
792 assert lines[2] in lines_out[5]
793 assert lines[4] in lines_out[6]
794 assert lines[5] in lines_out[7]
797 def mime_tree_related_to_alternative(self):
811 Part("text", "html", "part.html", desc="HTML"),
816 "text", "png", "logo.png", cid="logo.png", desc="Logo"
823 def mime_tree_related_to_html(self):
837 Part("text", "html", "part.html", desc="HTML"),
852 def test_MIMETreeDFWalker_depth_first_walk(
853 self, mime_tree_related_to_alternative
855 mimetree = MIMETreeDFWalker()
859 def visitor_fn(item, ancestry, descendents, debugprint):
860 items.append((item, len(ancestry), len(descendents)))
863 mime_tree_related_to_alternative, visitor_fn=visitor_fn
865 assert len(items) == 5
866 assert items[0][0].subtype == "plain"
867 assert items[0][1] == 2
868 assert items[0][2] == 0
869 assert items[1][0].subtype == "html"
870 assert items[1][1] == 2
871 assert items[1][2] == 0
872 assert items[2][0].subtype == "alternative"
873 assert items[2][1] == 1
874 assert items[2][2] == 2
875 assert items[3][0].subtype == "png"
876 assert items[3][1] == 1
877 assert items[3][2] == 2
878 assert items[4][0].subtype == "relative"
879 assert items[4][1] == 0
880 assert items[4][2] == 4
882 def test_MIMETreeDFWalker_list_to_mixed(self, const1):
883 mimetree = MIMETreeDFWalker()
886 def visitor_fn(item, ancestry, descendents, debugprint):
889 p = Part("text", "plain", const1)
890 mimetree.walk([p], visitor_fn=visitor_fn)
891 assert items[-1].subtype == "plain"
892 mimetree.walk([p, p], visitor_fn=visitor_fn)
893 assert items[-1].subtype == "mixed"
895 def test_MIMETreeDFWalker_visitor_in_constructor(
896 self, mime_tree_related_to_alternative
900 def visitor_fn(item, ancestry, descendents, debugprint):
903 mimetree = MIMETreeDFWalker(visitor_fn=visitor_fn)
904 mimetree.walk(mime_tree_related_to_alternative)
905 assert len(items) == 5
908 def string_io(self, const1, text=None):
909 return StringIO(text or const1)
911 def test_do_massage_basic(self, const1, string_io, capsys):
916 related_to_html_only,
920 return Part("text", "plain", draftpath, orig=True)
929 captured = capsys.readouterr()
930 lines = captured.out.splitlines()
931 assert '="$my_editor"' in lines.pop(0)
932 assert '="$my_edit_headers"' in lines.pop(0)
933 assert "unset my_editor" == lines.pop(0)
934 assert "unset my_edit_headers" == lines.pop(0)
935 assert "send-message" in lines.pop(0)
936 assert "update-encoding" in lines.pop(0)
937 assert "first-entry" in lines.pop(0)
938 assert "source 'rm -f " in lines.pop(0)
939 assert "unset my_mdwn_postprocess_cmd_file" == lines.pop(0)
941 def test_do_massage_fulltree(
942 self, string_io, const1, mime_tree_related_to_alternative, capsys
948 related_to_html_only,
952 return mime_tree_related_to_alternative
959 max_other_attachments=max_attachments,
963 captured = capsys.readouterr()
964 lines = captured.out.splitlines()[4:-2]
965 assert "first-entry" in lines.pop()
966 assert "update-encoding" in lines.pop()
967 assert "Plain" in lines.pop()
968 assert "part.html" in lines.pop()
969 assert "toggle-unlink" in lines.pop()
970 for i in range(max_attachments):
971 assert "move-up" in lines.pop()
972 assert "move-down" in lines.pop()
973 assert "HTML" in lines.pop()
974 assert "jump>1" in lines.pop()
975 assert "jump>2" in lines.pop()
976 assert "group-alternatives" in lines.pop()
977 assert "Alternative" in lines.pop()
978 assert "logo.png" in lines.pop()
979 assert "toggle-unlink" in lines.pop()
980 assert "content-id" in lines.pop()
981 for i in range(max_attachments):
982 assert "move-up" in lines.pop()
983 assert "move-down" in lines.pop()
984 assert "Logo" in lines.pop()
985 assert "jump>1" in lines.pop()
986 assert "jump>4" in lines.pop()
987 assert "group-related" in lines.pop()
988 assert "Related" in lines.pop()
989 assert "send-message" in lines.pop()
990 assert len(lines) == 0
993 def fake_filewriter(self):
998 def __call__(self, path, content, mode="w", **kwargs):
999 self._writes.append((path, content))
1001 def pop(self, index=-1):
1002 return self._writes.pop(index)
1007 def markdown_non_converter(self, const1, const2):
1008 return lambda s, text: f"{const1}{text}{const2}"
1010 def test_converter_tree_basic(self, const1, const2, fake_filewriter):
1011 path = pathlib.Path(const2)
1012 tree = convert_markdown_to_html(
1013 const1, path, filewriter_fn=fake_filewriter
1016 assert tree.subtype == "alternative"
1017 assert len(tree.children) == 2
1018 assert tree.children[0].subtype == "plain"
1019 assert tree.children[0].path == path
1020 assert tree.children[0].orig
1021 assert tree.children[1].subtype == "html"
1022 assert tree.children[1].path == path.with_suffix(".html")
1024 def test_converter_writes(
1030 markdown_non_converter,
1032 path = pathlib.Path(const2)
1034 with monkeypatch.context() as m:
1035 m.setattr(markdown.Markdown, "convert", markdown_non_converter)
1036 convert_markdown_to_html(
1037 const1, path, filewriter_fn=fake_filewriter
1040 assert (path, const1) == fake_filewriter.pop(0)
1042 path.with_suffix(".html"),
1043 markdown_non_converter(None, const1),
1044 ) == fake_filewriter.pop(0)
1046 def test_markdown_inline_image_processor(self):
1047 imgpath1 = "file:/path/to/image.png"
1048 imgpath2 = "file:///path/to/image.png?url=params"
1049 imgpath3 = "/path/to/image.png"
1050 text = f"""![inline local image]({imgpath1})
1052 with newline]({imgpath2})
1053 ![image local path]({imgpath3})"""
1054 text, html, images = markdown_with_inline_image_support(text)
1056 # local paths have been normalised to URLs:
1057 imgpath3 = f"file://{imgpath3}"
1059 assert 'src="cid:' in html
1060 assert "](cid:" in text
1061 assert len(images) == 3
1062 assert imgpath1 in images
1063 assert imgpath2 in images
1064 assert imgpath3 in images
1065 assert images[imgpath1].cid != images[imgpath2].cid
1066 assert images[imgpath1].cid != images[imgpath3].cid
1067 assert images[imgpath2].cid != images[imgpath3].cid
1069 def test_markdown_inline_image_processor_title_to_desc(self, const1):
1070 imgpath = "file:///path/to/image.png"
1071 text = f'![inline local image]({imgpath} "{const1}")'
1072 text, html, images = markdown_with_inline_image_support(text)
1073 assert images[imgpath].desc == const1
1075 def test_markdown_inline_image_processor_alt_to_desc(self, const1):
1076 imgpath = "file:///path/to/image.png"
1077 text = f"![{const1}]({imgpath})"
1078 text, html, images = markdown_with_inline_image_support(text)
1079 assert images[imgpath].desc == const1
1081 def test_markdown_inline_image_processor_title_over_alt_desc(
1082 self, const1, const2
1084 imgpath = "file:///path/to/image.png"
1085 text = f'![{const1}]({imgpath} "{const2}")'
1086 text, html, images = markdown_with_inline_image_support(text)
1087 assert images[imgpath].desc == const2
1089 def test_markdown_inline_image_not_external(self):
1090 imgpath = "https://path/to/image.png"
1091 text = f"![inline image]({imgpath})"
1092 text, html, images = markdown_with_inline_image_support(text)
1094 assert 'src="cid:' not in html
1095 assert "](cid:" not in text
1096 assert len(images) == 0
1098 def test_markdown_inline_image_local_file(self):
1099 imgpath = "/path/to/image.png"
1100 text = f"![inline image]({imgpath})"
1101 text, html, images = markdown_with_inline_image_support(text)
1103 for k, v in images.items():
1104 assert k == f"file://{imgpath}"
1110 "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAE"
1111 "AAAABCAAAAAA6fptVAAAACklEQVQI12P4DwABAQEAG7buVgAA"
1114 def test_markdown_inline_image_processor_base64(self, test_png):
1115 text = f"![1px white inlined]({test_png})"
1116 text, html, images = markdown_with_inline_image_support(text)
1118 assert 'src="cid:' in html
1119 assert "](cid:" in text
1120 assert len(images) == 1
1121 assert test_png in images
1123 def test_converter_tree_inline_image_base64(
1124 self, test_png, const1, fake_filewriter
1126 text = f"![inline base64 image]({test_png})"
1127 path = pathlib.Path(const1)
1128 tree = convert_markdown_to_html(
1131 filewriter_fn=fake_filewriter,
1132 related_to_html_only=False,
1134 assert tree.subtype == "relative"
1135 assert tree.children[0].subtype == "alternative"
1136 assert tree.children[1].subtype == "png"
1137 written = fake_filewriter.pop()
1138 assert tree.children[1].path == written[0]
1139 assert written[1] == request.urlopen(test_png).read()
1141 def test_converter_tree_inline_image_base64_related_to_html(
1142 self, test_png, const1, fake_filewriter
1144 text = f"![inline base64 image]({test_png})"
1145 path = pathlib.Path(const1)
1146 tree = convert_markdown_to_html(
1149 filewriter_fn=fake_filewriter,
1150 related_to_html_only=True,
1152 assert tree.subtype == "alternative"
1153 assert tree.children[1].subtype == "relative"
1154 assert tree.children[1].children[1].subtype == "png"
1155 written = fake_filewriter.pop()
1156 assert tree.children[1].children[1].path == written[0]
1157 assert written[1] == request.urlopen(test_png).read()
1159 def test_converter_tree_inline_image_cid(
1160 self, const1, fake_filewriter
1162 text = f"![inline base64 image](cid:{const1})"
1163 path = pathlib.Path(const1)
1164 tree = convert_markdown_to_html(
1167 filewriter_fn=fake_filewriter,
1168 related_to_html_only=False,
1170 assert len(tree.children) == 2
1171 assert tree.children[0].cid != const1
1172 assert tree.children[0].type != "image"
1173 assert tree.children[1].cid != const1
1174 assert tree.children[1].type != "image"
1176 def test_inline_image_collection(
1177 self, test_png, const1, const2, fake_filewriter
1179 test_images = {test_png: InlineImageInfo(cid=const1, desc=const2)}
1180 relparts = collect_inline_images(
1181 test_images, filewriter_fn=fake_filewriter
1184 written = fake_filewriter.pop()
1185 assert b"PNG" in written[1]
1187 assert relparts[0].subtype == "png"
1188 assert relparts[0].path == written[0]
1189 assert relparts[0].cid == const1
1190 assert relparts[0].desc.endswith(const2)
1192 def test_apply_stylesheet(self):
1194 html = "<p>Hello, world!</p>"
1195 css = "p { color:red }"
1196 out = apply_styling(html, css)
1197 assert 'p style="color' in out
1199 def test_apply_stylesheet_pygments(self):
1202 f'<div class="{_CODEHILITE_CLASS}">'
1203 "<pre>def foo():\n return</pre></div>"
1205 out = apply_styling(html, _PYGMENTS_CSS)
1206 assert f'{_CODEHILITE_CLASS}" style="' in out
1208 def test_mime_tree_relative_within_alternative(
1209 self, string_io, const1, capsys, mime_tree_related_to_html
1215 related_to_html_only,
1219 return mime_tree_related_to_html
1225 converter=converter,
1228 captured = capsys.readouterr()
1229 lines = captured.out.splitlines()[4:-2]
1230 assert "first-entry" in lines.pop()
1231 assert "update-encoding" in lines.pop()
1232 assert "Plain" in lines.pop()
1233 assert "part.html" in lines.pop()
1234 assert "toggle-unlink" in lines.pop()
1235 assert "move-up" in lines.pop()
1238 if "move-up" not in top:
1240 assert "move-down" in top
1241 assert "HTML" in lines.pop()
1242 assert "logo.png" in lines.pop()
1243 assert "toggle-unlink" in lines.pop()
1244 assert "content-id" in lines.pop()
1245 assert "move-up" in lines.pop()
1248 if "move-up" not in top:
1250 assert "move-down" in top
1251 assert "move-down" in lines.pop()
1252 assert "Logo" in lines.pop()
1253 assert "jump>2" in lines.pop()
1254 assert "jump>3" in lines.pop()
1255 assert "group-related" in lines.pop()
1256 assert "Related" in lines.pop()
1257 assert "jump>1" in lines.pop()
1258 assert "jump>2" in lines.pop()
1259 assert "group-alternative" in lines.pop()
1260 assert "Alternative" in lines.pop()
1261 assert "send-message" in lines.pop()
1262 assert len(lines) == 0
1264 def test_mime_tree_nested_trees_does_not_break_positioning(
1265 self, string_io, const1, capsys
1271 related_to_html_only,
1295 desc="Nested plain",
1304 desc="Nested alternative",
1324 converter=converter,
1327 captured = capsys.readouterr()
1328 lines = captured.out.splitlines()
1329 while not "logo.png" in lines.pop():
1332 assert "content-id" in lines.pop()
1333 assert "move-up" in lines.pop()
1336 if "move-up" not in top:
1338 assert "move-down" in top
1339 # Due to the nested trees, the number of descendents of the sibling
1340 # actually needs to be considered, not just the nieces. So to move
1341 # from position 1 to position 6, it only needs one <move-down>
1342 # because that jumps over the entire sibling tree. Thus what
1343 # follows next must not be another <move-down>
1344 assert "Logo" in lines.pop()