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)",
74 help="CSS file to merge with the final HTML",
77 parser.set_defaults(css_file=None)
80 "--related-to-html-only",
82 help="Make related content be sibling to HTML parts only"
88 help="Only build, don't send the message",
94 help="Specify temporary directory to use for attachments",
100 help="Turn on debug logging of commands generated to stderr",
103 subp = parser.add_subparsers(help="Sub-command parsers", dest="mode")
104 massage_p = subp.add_parser(
105 "massage", help="Massaging phase (internal use)"
108 massage_p.add_argument(
109 "--write-commands-to",
114 help="Temporary file path to write commands to",
117 massage_p.add_argument(
120 help="Turn on debugging to stderr of the MIME tree walk",
123 massage_p.add_argument(
126 help="If provided, the script is invoked as editor on the mail draft",
129 return parser.parse_args(*args, **kwargs)
132 # [ MARKDOWN WRAPPING ] #######################################################
135 InlineImageInfo = namedtuple(
136 "InlineImageInfo", ["cid", "desc"], defaults=[None]
140 class InlineImageExtension(Extension):
141 class RelatedImageInlineProcessor(ImageInlineProcessor):
142 def __init__(self, re, md, ext):
143 super().__init__(re, md)
146 def handleMatch(self, m, data):
147 el, start, end = super().handleMatch(m, data)
148 if "src" in el.attrib:
149 src = el.attrib["src"]
150 if "://" not in src or src.startswith("file://"):
151 # We only inline local content
152 cid = self._ext.get_cid_for_image(el.attrib)
153 el.attrib["src"] = f"cid:{cid}"
154 return el, start, end
158 self._images = OrderedDict()
160 def extendMarkdown(self, md):
161 md.registerExtension(self)
162 inline_image_proc = self.RelatedImageInlineProcessor(
163 IMAGE_LINK_RE, md, self
165 md.inlinePatterns.register(inline_image_proc, "image_link", 150)
167 def get_cid_for_image(self, attrib):
168 msgid = make_msgid()[1:-1]
170 if path.startswith("/"):
171 path = f"file://{path}"
172 self._images[path] = InlineImageInfo(
173 msgid, attrib.get("title", attrib.get("alt"))
177 def get_images(self):
181 def markdown_with_inline_image_support(
182 text, *, extensions=None, extension_configs=None
184 inline_image_handler = InlineImageExtension()
185 extensions = extensions or []
186 extensions.append(inline_image_handler)
187 mdwn = markdown.Markdown(
188 extensions=extensions, extension_configs=extension_configs
190 htmltext = mdwn.convert(text)
192 images = inline_image_handler.get_images()
194 def replace_image_with_cid(matchobj):
195 for m in (matchobj.group(1), f"file://{matchobj.group(1)}"):
197 return f"(cid:{images[m].cid}"
198 return matchobj.group(0)
200 text = re.sub(r"\(([^)\s]+)", replace_image_with_cid, text)
201 return text, htmltext, images
204 # [ CSS STYLING ] #############################################################
215 from pygments.formatters import get_formatter_by_name
217 _CODEHILITE_CLASS = "codehilite"
219 _PYGMENTS_CSS = get_formatter_by_name(
220 "html", style="default"
221 ).get_style_defs(f".{_CODEHILITE_CLASS}")
227 def apply_styling(html, css):
231 .with_cssString("\n".join(s for s in [_PYGMENTS_CSS, css] if s))
236 # [ PARTS GENERATION ] ########################################################
242 ["type", "subtype", "path", "desc", "cid", "orig"],
243 defaults=[None, None, False],
247 ret = f"<{self.type}/{self.subtype}>"
249 ret = f"{ret} cid:{self.cid}"
251 ret = f"{ret} ORIGINAL"
256 namedtuple("Multipart", ["subtype", "children", "desc"], defaults=[None])
259 return f"<multipart/{self.subtype}> children={len(self.children)}"
262 return hash(str(self.subtype) + "".join(str(self.children)))
265 def filewriter_fn(path, content, mode="w", **kwargs):
266 with open(path, mode, **kwargs) as out_f:
270 def collect_inline_images(
271 images, *, tempdir=None, filewriter_fn=filewriter_fn
274 for path, info in images.items():
275 if path.startswith("cid:"):
278 data = request.urlopen(path)
280 mimetype = data.headers["Content-Type"]
281 ext = mimetypes.guess_extension(mimetype)
282 tempfilename = tempfile.mkstemp(prefix="img", suffix=ext, dir=tempdir)
283 path = pathlib.Path(tempfilename[1])
285 filewriter_fn(path, data.read(), "w+b")
289 *mimetype.split("/"),
292 desc=f"Image: {info.desc}",
299 def convert_markdown_to_html(
303 related_to_html_only=False,
305 filewriter_fn=filewriter_fn,
308 extension_configs=None,
310 # TODO extension_configs need to be handled differently
311 extension_configs = extension_configs or {}
312 extension_configs.setdefault("pymdownx.highlight", {})
313 extension_configs["pymdownx.highlight"]["css_class"] = _CODEHILITE_CLASS
315 origtext, htmltext, images = markdown_with_inline_image_support(
316 origtext, extensions=extensions, extension_configs=extension_configs
319 filewriter_fn(draftpath, origtext, encoding="utf-8")
321 "text", "plain", draftpath, "Plain-text version", orig=True
324 htmltext = apply_styling(htmltext, cssfile)
326 htmlpath = draftpath.with_suffix(".html")
328 htmlpath, htmltext, encoding="utf-8", errors="xmlcharrefreplace"
330 htmlpart = Part("text", "html", htmlpath, "HTML version")
332 imgparts = collect_inline_images(
333 images, tempdir=tempdir, filewriter_fn=filewriter_fn
336 if related_to_html_only:
337 # If there are inline image part, they will be contained within a
338 # multipart/related part along with the HTML part only
340 # replace htmlpart with a multipart/related container of the HTML
341 # parts and the images
342 htmlpart = Multipart(
343 "relative", [htmlpart] + imgparts, "Group of related content"
347 "alternative", [textpart, htmlpart], "Group of alternative content"
351 # If there are inline image part, they will be siblings to the
352 # multipart/alternative tree within a multipart/related part
354 "alternative", [textpart, htmlpart], "Group of alternative content"
358 "relative", [altpart] + imgparts, "Group of related content"
364 class MIMETreeDFWalker:
365 def __init__(self, *, visitor_fn=None, debug=False):
366 self._visitor_fn = visitor_fn or self._echovisit
369 def _echovisit(self, node, ancestry, debugprint):
370 debugprint(f"node={node} ancestry={ancestry}")
372 def walk(self, root, *, visitor_fn=None):
374 Recursive function to implement a depth-dirst walk of the MIME-tree
377 if isinstance(root, list):
379 root = Multipart("mixed", children=root)
386 visitor_fn=visitor_fn or self._visitor_fn,
389 def _walk(self, node, *, ancestry, visitor_fn):
390 # Let's start by enumerating the parts at the current level. At the
391 # root level, ancestry will be the empty list, and we expect a
392 # multipart/* container at this level. Later, e.g. within a
393 # mutlipart/alternative container, the subtree will just be the
394 # alternative parts, while the top of the ancestry will be the
395 # multipart/alternative container, which we will process after the
398 lead = f"{'│ '*len(ancestry)}"
399 if isinstance(node, Multipart):
401 f"{lead}├{node} ancestry={[s.subtype for s in ancestry]}"
404 # Depth-first, so push the current container onto the ancestry
405 # stack, then descend …
406 ancestry.append(node)
407 self.debugprint(lead + "│ " * 2)
408 for child in node.children:
412 visitor_fn=visitor_fn,
414 assert ancestry.pop() == node
417 self.debugprint(f"{lead}├{node}")
419 if False and ancestry:
420 self.debugprint(lead[:-1] + " │")
423 visitor_fn(node, ancestry, debugprint=self.debugprint)
425 def debugprint(self, s, **kwargs):
427 print(s, file=sys.stderr, **kwargs)
430 # [ RUN MODES ] ###############################################################
435 Stupid class to interface writing out Mutt commands. This is quite a hack
436 to deal with the fact that Mutt runs "push" commands in reverse order, so
437 all of a sudden, things become very complicated when mixing with "real"
440 Hence we keep two sets of commands, and one set of pushes. Commands are
441 added to the first until a push is added, after which commands are added to
442 the second set of commands.
444 On flush(), the first set is printed, followed by the pushes in reverse,
445 and then the second set is printed. All 3 sets are then cleared.
448 def __init__(self, out_f=sys.stdout, *, debug=False):
449 self._cmd1, self._push, self._cmd2 = [], [], []
461 s = s.replace('"', '"')
464 self._push.insert(0, s)
468 "\n".join(self._cmd1 + self._push + self._cmd2), file=self._out_f
470 self._cmd1, self._push, self._cmd2 = [], [], []
472 def debugprint(self, s, **kwargs):
474 print(s, file=sys.stderr, **kwargs)
482 debug_commands=False,
484 temppath = temppath or pathlib.Path(
485 tempfile.mkstemp(prefix="muttmdwn-", dir=tempdir)[1]
487 cmds = MuttCommands(out_f, debug=debug_commands)
489 editor = f"{' '.join(sys.argv)} massage --write-commands-to {temppath}"
491 cmds.cmd('set my_editor="$editor"')
492 cmds.cmd('set my_edit_headers="$edit_headers"')
493 cmds.cmd(f'set editor="{editor}"')
494 cmds.cmd("unset edit_headers")
495 cmds.cmd(f"set my_mdwn_postprocess_cmd_file={temppath}")
496 cmds.push("<first-entry><edit-file>")
507 converter=convert_markdown_to_html,
508 related_to_html_only=True,
511 debug_commands=False,
514 # Here's the big picture: we're being invoked as the editor on the email
515 # draft, and whatever commands we write to the file given as cmdpath will
516 # be run by the second source command in the macro definition.
518 # Let's start by cleaning up what the setup did (see above), i.e. we
519 # restore the $editor and $edit_headers variables, and also unset the
520 # variable used to identify the command file we're currently writing
522 cmds = MuttCommands(cmd_f, debug=debug_commands)
523 cmds.cmd('set editor="$my_editor"')
524 cmds.cmd('set edit_headers="$my_edit_headers"')
525 cmds.cmd("unset my_editor")
526 cmds.cmd("unset my_edit_headers")
528 # let's flush those commands, as there'll be a lot of pushes from now
529 # on, which need to be run in reverse order
532 extensions = extensions.split(",") if extensions else []
537 related_to_html_only=related_to_html_only,
539 extensions=extensions,
542 mimetree = MIMETreeDFWalker(debug=debug_walk)
544 state = dict(pos=1, tags={}, parts=1)
546 def visitor_fn(item, ancestry, *, debugprint=None):
548 Visitor function called for every node (part) of the MIME tree,
549 depth-first, and responsible for telling NeoMutt how to assemble
552 KILL_LINE = r"\Ca\Ck"
554 if isinstance(item, Part):
555 # We've hit a leaf-node, i.e. an alternative or a related part
556 # with actual content.
560 # The original source already exists in the NeoMutt tree, but
561 # the underlying file may have been modified, so we need to
562 # update the encoding, but that's it:
563 cmds.push("<first-entry>")
564 cmds.push("<update-encoding>")
566 # We really just need to be able to assume that at this point,
567 # NeoMutt is at position 1, and that we've processed only this
568 # part so far. Nevermind about actual attachments, we can
569 # safely ignore those as they stay at the end.
570 assert state["pos"] == 1
571 assert state["parts"] == 1
573 # … whereas all other parts need to be added, and they're all
574 # considered to be temporary and inline:
575 cmds.push(f"<attach-file>{item.path}<enter>")
576 cmds.push("<toggle-unlink><toggle-disposition>")
578 # This added a part at the end of the list of parts, and that's
579 # just how many parts we've seen so far, so it's position in
580 # the NeoMutt compose list is the count of parts
582 state["pos"] = state["parts"]
584 # If the item (including the original) comes with additional
585 # information, then we might just as well update the NeoMutt
588 cmds.push(f"<edit-content-id>{KILL_LINE}{item.cid}<enter>")
590 elif isinstance(item, Multipart):
591 # This node has children, but we already visited them (see
592 # above). The tags dictionary of State should contain a list of
593 # their positions in the NeoMutt compose window, so iterate those
594 # and tag the parts there:
595 for tag in state["tags"][item]:
596 cmds.push(f"<jump>{tag}<enter><tag-entry>")
598 if item.subtype == "alternative":
599 cmds.push("<group-alternatives>")
600 elif item.subtype in ("relative", "related"):
601 cmds.push("<group-related>")
602 elif item.subtype == "multilingual":
603 cmds.push("<group-multilingual>")
605 raise NotImplementedError(
606 f"Handling of multipart/{item.subtype} is not implemented"
609 state["pos"] -= len(state["tags"][item]) - 1
611 del state["tags"][item]
614 # We should never get here
615 raise RuntimeError(f"Type {type(item)} is unexpected: {item}")
617 # If the item has a description, we might just as well add it
619 cmds.push(f"<edit-description>{KILL_LINE}{item.desc}<enter>")
622 # If there's an ancestry, record the current (assumed) position in
623 # the NeoMutt compose window as needed-to-tag by our direct parent
624 # (i.e. the last item of the ancestry)
625 state["tags"].setdefault(ancestry[-1], []).append(state["pos"])
627 lead = "│ " * (len(ancestry) + 1) + "* "
629 f"{lead}ancestry={[a.subtype for a in ancestry]}\n"
630 f"{lead}children_positions={state['tags'][ancestry[-1]]}\n"
631 f"{lead}pos={state['pos']}, parts={state['parts']}"
637 # Let's walk the tree and visit every node with our fancy visitor
639 mimetree.walk(tree, visitor_fn=visitor_fn)
642 cmds.push("<send-message>")
644 # Finally, cleanup. Since we're responsible for removing the temporary
645 # file, how's this for a little hack?
647 filename = cmd_f.name
648 except AttributeError:
649 filename = "pytest_internal_file"
650 cmds.cmd(f"source 'rm -f {filename}|'")
651 cmds.cmd("unset my_mdwn_postprocess_cmd_file")
655 # [ CLI ENTRY ] ###############################################################
657 if __name__ == "__main__":
658 args = parse_cli_args()
660 if args.mode is None:
662 tempdir=args.tempdir,
663 debug_commands=args.debug_commands,
666 elif args.mode == "massage":
667 with open(args.MAILDRAFT, "r") as draft_f, open(
672 pathlib.Path(args.MAILDRAFT),
674 extensions=args.extensions,
675 cssfile=args.css_file,
676 related_to_html_only=args.related_to_html_only,
677 only_build=args.only_build,
678 tempdir=args.tempdir,
679 debug_commands=args.debug_commands,
680 debug_walk=args.debug_walk,
684 # [ TESTS ] ###################################################################
688 from io import StringIO
693 return "CONSTANT STRING 1"
697 return "CONSTANT STRING 2"
699 # NOTE: tests using the capsys fixture must specify sys.stdout to the
700 # functions they call, else old stdout is used and not captured
702 def test_MuttCommands_cmd(self, const1, const2, capsys):
703 "Assert order of commands"
704 cmds = MuttCommands(out_f=sys.stdout)
708 captured = capsys.readouterr()
709 assert captured.out == "\n".join((const1, const2, ""))
711 def test_MuttCommands_push(self, const1, const2, capsys):
712 "Assert reverse order of pushes"
713 cmds = MuttCommands(out_f=sys.stdout)
717 captured = capsys.readouterr()
720 == ('"\npush "'.join(("", const2, const1, "")))[2:-6]
723 def test_MuttCommands_cmd_push_mixed(self, const1, const2, capsys):
724 "Assert reverse order of pushes"
725 cmds = MuttCommands(out_f=sys.stdout)
726 lines = ["000", "001", "010", "011", "100", "101", "110", "111"]
728 cmds.cmd(lines[4 * i + 0])
729 cmds.cmd(lines[4 * i + 1])
730 cmds.push(lines[4 * i + 2])
731 cmds.push(lines[4 * i + 3])
734 captured = capsys.readouterr()
735 lines_out = captured.out.splitlines()
736 assert lines[0] in lines_out[0]
737 assert lines[1] in lines_out[1]
738 assert lines[7] in lines_out[2]
739 assert lines[6] in lines_out[3]
740 assert lines[3] in lines_out[4]
741 assert lines[2] in lines_out[5]
742 assert lines[4] in lines_out[6]
743 assert lines[5] in lines_out[7]
746 def mime_tree_related_to_alternative(self):
760 Part("text", "html", "part.html", desc="HTML"),
765 "text", "png", "logo.png", cid="logo.png", desc="Logo"
772 def mime_tree_related_to_html(self):
786 Part("text", "html", "part.html", desc="HTML"),
801 def test_MIMETreeDFWalker_depth_first_walk(
802 self, mime_tree_related_to_alternative
804 mimetree = MIMETreeDFWalker()
808 def visitor_fn(item, ancestry, debugprint):
809 items.append((item, len(ancestry)))
812 mime_tree_related_to_alternative, visitor_fn=visitor_fn
814 assert len(items) == 5
815 assert items[0][0].subtype == "plain"
816 assert items[0][1] == 2
817 assert items[1][0].subtype == "html"
818 assert items[1][1] == 2
819 assert items[2][0].subtype == "alternative"
820 assert items[2][1] == 1
821 assert items[3][0].subtype == "png"
822 assert items[3][1] == 1
823 assert items[4][0].subtype == "relative"
824 assert items[4][1] == 0
826 def test_MIMETreeDFWalker_list_to_mixed(self, const1):
827 mimetree = MIMETreeDFWalker()
830 def visitor_fn(item, ancestry, debugprint):
833 p = Part("text", "plain", const1)
834 mimetree.walk([p], visitor_fn=visitor_fn)
835 assert items[-1].subtype == "plain"
836 mimetree.walk([p, p], visitor_fn=visitor_fn)
837 assert items[-1].subtype == "mixed"
839 def test_MIMETreeDFWalker_visitor_in_constructor(
840 self, mime_tree_related_to_alternative
844 def visitor_fn(item, ancestry, debugprint):
847 mimetree = MIMETreeDFWalker(visitor_fn=visitor_fn)
848 mimetree.walk(mime_tree_related_to_alternative)
849 assert len(items) == 5
852 def string_io(self, const1, text=None):
853 return StringIO(text or const1)
855 def test_do_massage_basic(self, const1, string_io, capsys):
860 related_to_html_only,
864 return Part("text", "plain", draftpath, orig=True)
873 captured = capsys.readouterr()
874 lines = captured.out.splitlines()
875 assert '="$my_editor"' in lines.pop(0)
876 assert '="$my_edit_headers"' in lines.pop(0)
877 assert "unset my_editor" == lines.pop(0)
878 assert "unset my_edit_headers" == lines.pop(0)
879 assert "send-message" in lines.pop(0)
880 assert "update-encoding" in lines.pop(0)
881 assert "first-entry" in lines.pop(0)
882 assert "source 'rm -f " in lines.pop(0)
883 assert "unset my_mdwn_postprocess_cmd_file" == lines.pop(0)
885 def test_do_massage_fulltree(
886 self, string_io, const1, mime_tree_related_to_alternative, capsys
892 related_to_html_only,
896 return mime_tree_related_to_alternative
905 captured = capsys.readouterr()
906 lines = captured.out.splitlines()[4:-2]
907 assert "first-entry" in lines.pop()
908 assert "update-encoding" in lines.pop()
909 assert "Plain" in lines.pop()
910 assert "part.html" in lines.pop()
911 assert "toggle-unlink" in lines.pop()
912 assert "HTML" in lines.pop()
913 assert "jump>1" in lines.pop()
914 assert "jump>2" in lines.pop()
915 assert "group-alternatives" in lines.pop()
916 assert "Alternative" in lines.pop()
917 assert "logo.png" in lines.pop()
918 assert "toggle-unlink" in lines.pop()
919 assert "content-id" in lines.pop()
920 assert "Logo" in lines.pop()
921 assert "jump>1" in lines.pop()
922 assert "jump>4" in lines.pop()
923 assert "group-related" in lines.pop()
924 assert "Related" in lines.pop()
925 assert "send-message" in lines.pop()
926 assert len(lines) == 0
929 def fake_filewriter(self):
934 def __call__(self, path, content, mode="w", **kwargs):
935 self._writes.append((path, content))
937 def pop(self, index=-1):
938 return self._writes.pop(index)
943 def markdown_non_converter(self, const1, const2):
944 return lambda s, text: f"{const1}{text}{const2}"
946 def test_converter_tree_basic(self, const1, const2, fake_filewriter):
947 path = pathlib.Path(const2)
948 tree = convert_markdown_to_html(
949 const1, path, filewriter_fn=fake_filewriter
952 assert tree.subtype == "alternative"
953 assert len(tree.children) == 2
954 assert tree.children[0].subtype == "plain"
955 assert tree.children[0].path == path
956 assert tree.children[0].orig
957 assert tree.children[1].subtype == "html"
958 assert tree.children[1].path == path.with_suffix(".html")
960 def test_converter_writes(
966 markdown_non_converter,
968 path = pathlib.Path(const2)
970 with monkeypatch.context() as m:
971 m.setattr(markdown.Markdown, "convert", markdown_non_converter)
972 convert_markdown_to_html(
973 const1, path, filewriter_fn=fake_filewriter
976 assert (path, const1) == fake_filewriter.pop(0)
978 path.with_suffix(".html"),
979 markdown_non_converter(None, const1),
980 ) == fake_filewriter.pop(0)
982 def test_markdown_inline_image_processor(self):
983 imgpath1 = "file:/path/to/image.png"
984 imgpath2 = "file:///path/to/image.png?url=params"
985 imgpath3 = "/path/to/image.png"
986 text = f"""![inline local image]({imgpath1})
988 with newline]({imgpath2})
989 ![image local path]({imgpath3})"""
990 text, html, images = markdown_with_inline_image_support(text)
992 # local paths have been normalised to URLs:
993 imgpath3 = f"file://{imgpath3}"
995 assert 'src="cid:' in html
996 assert "](cid:" in text
997 assert len(images) == 3
998 assert imgpath1 in images
999 assert imgpath2 in images
1000 assert imgpath3 in images
1001 assert images[imgpath1].cid != images[imgpath2].cid
1002 assert images[imgpath1].cid != images[imgpath3].cid
1003 assert images[imgpath2].cid != images[imgpath3].cid
1005 def test_markdown_inline_image_processor_title_to_desc(self, const1):
1006 imgpath = "file:///path/to/image.png"
1007 text = f'![inline local image]({imgpath} "{const1}")'
1008 text, html, images = markdown_with_inline_image_support(text)
1009 assert images[imgpath].desc == const1
1011 def test_markdown_inline_image_processor_alt_to_desc(self, const1):
1012 imgpath = "file:///path/to/image.png"
1013 text = f"![{const1}]({imgpath})"
1014 text, html, images = markdown_with_inline_image_support(text)
1015 assert images[imgpath].desc == const1
1017 def test_markdown_inline_image_processor_title_over_alt_desc(
1018 self, const1, const2
1020 imgpath = "file:///path/to/image.png"
1021 text = f'![{const1}]({imgpath} "{const2}")'
1022 text, html, images = markdown_with_inline_image_support(text)
1023 assert images[imgpath].desc == const2
1025 def test_markdown_inline_image_not_external(self):
1026 imgpath = "https://path/to/image.png"
1027 text = f"![inline image]({imgpath})"
1028 text, html, images = markdown_with_inline_image_support(text)
1030 assert 'src="cid:' not in html
1031 assert "](cid:" not in text
1032 assert len(images) == 0
1034 def test_markdown_inline_image_local_file(self):
1035 imgpath = "/path/to/image.png"
1036 text = f"![inline image]({imgpath})"
1037 text, html, images = markdown_with_inline_image_support(text)
1039 for k, v in images.items():
1040 assert k == f"file://{imgpath}"
1046 "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAE"
1047 "AAAABCAAAAAA6fptVAAAACklEQVQI12P4DwABAQEAG7buVgAA"
1050 def test_markdown_inline_image_processor_base64(self, test_png):
1051 text = f"![1px white inlined]({test_png})"
1052 text, html, images = markdown_with_inline_image_support(text)
1054 assert 'src="cid:' in html
1055 assert "](cid:" in text
1056 assert len(images) == 1
1057 assert test_png in images
1059 def test_converter_tree_inline_image_base64(
1060 self, test_png, const1, fake_filewriter
1062 text = f"![inline base64 image]({test_png})"
1063 path = pathlib.Path(const1)
1064 tree = convert_markdown_to_html(
1067 filewriter_fn=fake_filewriter,
1068 related_to_html_only=False,
1070 assert tree.subtype == "relative"
1071 assert tree.children[0].subtype == "alternative"
1072 assert tree.children[1].subtype == "png"
1073 written = fake_filewriter.pop()
1074 assert tree.children[1].path == written[0]
1075 assert written[1] == request.urlopen(test_png).read()
1077 def test_converter_tree_inline_image_base64_related_to_html(
1078 self, test_png, const1, fake_filewriter
1080 text = f"![inline base64 image]({test_png})"
1081 path = pathlib.Path(const1)
1082 tree = convert_markdown_to_html(
1085 filewriter_fn=fake_filewriter,
1086 related_to_html_only=True,
1088 assert tree.subtype == "alternative"
1089 assert tree.children[1].subtype == "relative"
1090 assert tree.children[1].children[1].subtype == "png"
1091 written = fake_filewriter.pop()
1092 assert tree.children[1].children[1].path == written[0]
1093 assert written[1] == request.urlopen(test_png).read()
1095 def test_converter_tree_inline_image_cid(
1096 self, const1, fake_filewriter
1098 text = f"![inline base64 image](cid:{const1})"
1099 path = pathlib.Path(const1)
1100 tree = convert_markdown_to_html(
1103 filewriter_fn=fake_filewriter,
1104 related_to_html_only=False,
1106 assert len(tree.children) == 2
1107 assert tree.children[0].cid != const1
1108 assert tree.children[0].type != "image"
1109 assert tree.children[1].cid != const1
1110 assert tree.children[1].type != "image"
1112 def test_inline_image_collection(
1113 self, test_png, const1, const2, fake_filewriter
1115 test_images = {test_png: InlineImageInfo(cid=const1, desc=const2)}
1116 relparts = collect_inline_images(
1117 test_images, filewriter_fn=fake_filewriter
1120 written = fake_filewriter.pop()
1121 assert b"PNG" in written[1]
1123 assert relparts[0].subtype == "png"
1124 assert relparts[0].path == written[0]
1125 assert relparts[0].cid == const1
1126 assert relparts[0].desc.endswith(const2)
1128 def test_apply_stylesheet(self):
1130 html = "<p>Hello, world!</p>"
1131 css = "p { color:red }"
1132 out = apply_styling(html, css)
1133 assert 'p style="color' in out
1135 def test_apply_stylesheet_pygments(self):
1138 f'<div class="{_CODEHILITE_CLASS}">'
1139 "<pre>def foo():\n return</pre></div>"
1141 out = apply_styling(html, _PYGMENTS_CSS)
1142 assert f'{_CODEHILITE_CLASS}" style="' in out
1144 def test_mime_tree_relative_within_alternative(
1145 self, string_io, const1, capsys, mime_tree_related_to_html
1151 related_to_html_only,
1155 return mime_tree_related_to_html
1161 converter=converter,
1164 captured = capsys.readouterr()
1165 lines = captured.out.splitlines()[4:-2]
1166 assert "first-entry" in lines.pop()
1167 assert "update-encoding" in lines.pop()
1168 assert "Plain" in lines.pop()
1169 assert "part.html" in lines.pop()
1170 assert "toggle-unlink" in lines.pop()
1171 assert "HTML" in lines.pop()
1172 assert "logo.png" in lines.pop()
1173 assert "toggle-unlink" in lines.pop()
1174 assert "content-id" in lines.pop()
1175 assert "Logo" in lines.pop()
1176 assert "jump>2" in lines.pop()
1177 assert "jump>3" in lines.pop()
1178 assert "group-related" in lines.pop()
1179 assert "Related" in lines.pop()
1180 assert "jump>1" in lines.pop()
1181 assert "jump>2" in lines.pop()
1182 assert "group-alternative" in lines.pop()
1183 assert "Alternative" in lines.pop()
1184 assert "send-message" in lines.pop()
1185 assert len(lines) == 0