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",
85 def positive_integer(value):
93 raise ValueError(f"Must be a positive integer")
96 "--max-number-other-attachments",
97 type=positive_integer,
98 help="Make related content be sibling to HTML parts only",
104 help="Only build, don't send the message",
110 help="Specify temporary directory to use for attachments",
116 help="Turn on debug logging of commands generated to stderr",
119 subp = parser.add_subparsers(help="Sub-command parsers", dest="mode")
120 massage_p = subp.add_parser(
121 "massage", help="Massaging phase (internal use)"
124 massage_p.add_argument(
125 "--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(
142 help="If provided, the script is invoked as editor on the mail draft",
145 return parser.parse_args(*args, **kwargs)
148 # [ MARKDOWN WRAPPING ] #######################################################
151 InlineImageInfo = namedtuple(
152 "InlineImageInfo", ["cid", "desc"], defaults=[None]
156 class InlineImageExtension(Extension):
157 class RelatedImageInlineProcessor(ImageInlineProcessor):
158 def __init__(self, re, md, ext):
159 super().__init__(re, md)
162 def handleMatch(self, m, data):
163 el, start, end = super().handleMatch(m, data)
164 if "src" in el.attrib:
165 src = el.attrib["src"]
166 if "://" not in src or src.startswith("file://"):
167 # We only inline local content
168 cid = self._ext.get_cid_for_image(el.attrib)
169 el.attrib["src"] = f"cid:{cid}"
170 return el, start, end
174 self._images = OrderedDict()
176 def extendMarkdown(self, md):
177 md.registerExtension(self)
178 inline_image_proc = self.RelatedImageInlineProcessor(
179 IMAGE_LINK_RE, md, self
181 md.inlinePatterns.register(inline_image_proc, "image_link", 150)
183 def get_cid_for_image(self, attrib):
184 msgid = make_msgid()[1:-1]
186 if path.startswith("/"):
187 path = f"file://{path}"
188 self._images[path] = InlineImageInfo(
189 msgid, attrib.get("title", attrib.get("alt"))
193 def get_images(self):
197 def markdown_with_inline_image_support(
198 text, *, extensions=None, extension_configs=None
200 inline_image_handler = InlineImageExtension()
201 extensions = extensions or []
202 extensions.append(inline_image_handler)
203 mdwn = markdown.Markdown(
204 extensions=extensions, extension_configs=extension_configs
206 htmltext = mdwn.convert(text)
208 images = inline_image_handler.get_images()
210 def replace_image_with_cid(matchobj):
211 for m in (matchobj.group(1), f"file://{matchobj.group(1)}"):
213 return f"(cid:{images[m].cid}"
214 return matchobj.group(0)
216 text = re.sub(r"\(([^)\s]+)", replace_image_with_cid, text)
217 return text, htmltext, images
220 # [ CSS STYLING ] #############################################################
231 from pygments.formatters import get_formatter_by_name
233 _CODEHILITE_CLASS = "codehilite"
235 _PYGMENTS_CSS = get_formatter_by_name(
236 "html", style="default"
237 ).get_style_defs(f".{_CODEHILITE_CLASS}")
243 def apply_styling(html, css):
247 .with_cssString("\n".join(s for s in [_PYGMENTS_CSS, css] if s))
252 # [ PARTS GENERATION ] ########################################################
258 ["type", "subtype", "path", "desc", "cid", "orig"],
259 defaults=[None, None, False],
263 ret = f"<{self.type}/{self.subtype}>"
265 ret = f"{ret} cid:{self.cid}"
267 ret = f"{ret} ORIGINAL"
272 namedtuple("Multipart", ["subtype", "children", "desc"], defaults=[None])
275 return f"<multipart/{self.subtype}> children={len(self.children)}"
278 return hash(str(self.subtype) + "".join(str(self.children)))
281 def filewriter_fn(path, content, mode="w", **kwargs):
282 with open(path, mode, **kwargs) as out_f:
286 def collect_inline_images(
287 images, *, tempdir=None, filewriter_fn=filewriter_fn
290 for path, info in images.items():
291 if path.startswith("cid:"):
294 data = request.urlopen(path)
296 mimetype = data.headers["Content-Type"]
297 ext = mimetypes.guess_extension(mimetype)
298 tempfilename = tempfile.mkstemp(prefix="img", suffix=ext, dir=tempdir)
299 path = pathlib.Path(tempfilename[1])
301 filewriter_fn(path, data.read(), "w+b")
305 *mimetype.split("/"),
308 desc=f"Image: {info.desc}",
315 def convert_markdown_to_html(
319 related_to_html_only=False,
321 filewriter_fn=filewriter_fn,
324 extension_configs=None,
326 # TODO extension_configs need to be handled differently
327 extension_configs = extension_configs or {}
328 extension_configs.setdefault("pymdownx.highlight", {})
329 extension_configs["pymdownx.highlight"]["css_class"] = _CODEHILITE_CLASS
331 origtext, htmltext, images = markdown_with_inline_image_support(
332 origtext, extensions=extensions, extension_configs=extension_configs
335 filewriter_fn(draftpath, origtext, encoding="utf-8")
337 "text", "plain", draftpath, "Plain-text version", orig=True
340 htmltext = apply_styling(htmltext, cssfile)
342 htmlpath = draftpath.with_suffix(".html")
344 htmlpath, htmltext, encoding="utf-8", errors="xmlcharrefreplace"
346 htmlpart = Part("text", "html", htmlpath, "HTML version")
348 imgparts = collect_inline_images(
349 images, tempdir=tempdir, filewriter_fn=filewriter_fn
352 if related_to_html_only:
353 # If there are inline image part, they will be contained within a
354 # multipart/related part along with the HTML part only
356 # replace htmlpart with a multipart/related container of the HTML
357 # parts and the images
358 htmlpart = Multipart(
359 "relative", [htmlpart] + imgparts, "Group of related content"
363 "alternative", [textpart, htmlpart], "Group of alternative content"
367 # If there are inline image part, they will be siblings to the
368 # multipart/alternative tree within a multipart/related part
370 "alternative", [textpart, htmlpart], "Group of alternative content"
374 "relative", [altpart] + imgparts, "Group of related content"
380 class MIMETreeDFWalker:
381 def __init__(self, *, visitor_fn=None, debug=False):
382 self._visitor_fn = visitor_fn or self._echovisit
385 def _echovisit(self, node, ancestry, debugprint):
386 debugprint(f"node={node} ancestry={ancestry}")
388 def walk(self, root, *, visitor_fn=None):
390 Recursive function to implement a depth-dirst walk of the MIME-tree
393 if isinstance(root, list):
395 root = Multipart("mixed", children=root)
403 visitor_fn=visitor_fn or self._visitor_fn,
406 def _walk(self, node, *, ancestry, descendents, visitor_fn):
407 # Let's start by enumerating the parts at the current level. At the
408 # root level, ancestry will be the empty list, and we expect a
409 # multipart/* container at this level. Later, e.g. within a
410 # mutlipart/alternative container, the subtree will just be the
411 # alternative parts, while the top of the ancestry will be the
412 # multipart/alternative container, which we will process after the
415 lead = f"{'│ '*len(ancestry)}"
416 if isinstance(node, Multipart):
418 f"{lead}├{node} ancestry={[s.subtype for s in ancestry]}"
421 # Depth-first, so push the current container onto the ancestry
422 # stack, then descend …
423 ancestry.append(node)
424 self.debugprint(lead + "│ " * 2)
425 for child in node.children:
429 descendents=descendents,
430 visitor_fn=visitor_fn,
432 assert ancestry.pop() == node
433 sibling_descendents = descendents
434 descendents.extend(node.children)
437 self.debugprint(f"{lead}├{node}")
438 sibling_descendents = descendents
440 if False and ancestry:
441 self.debugprint(lead[:-1] + " │")
445 node, ancestry, sibling_descendents, debugprint=self.debugprint
448 def debugprint(self, s, **kwargs):
450 print(s, file=sys.stderr, **kwargs)
453 # [ RUN MODES ] ###############################################################
458 Stupid class to interface writing out Mutt commands. This is quite a hack
459 to deal with the fact that Mutt runs "push" commands in reverse order, so
460 all of a sudden, things become very complicated when mixing with "real"
463 Hence we keep two sets of commands, and one set of pushes. Commands are
464 added to the first until a push is added, after which commands are added to
465 the second set of commands.
467 On flush(), the first set is printed, followed by the pushes in reverse,
468 and then the second set is printed. All 3 sets are then cleared.
471 def __init__(self, out_f=sys.stdout, *, debug=False):
472 self._cmd1, self._push, self._cmd2 = [], [], []
484 s = s.replace('"', '"')
487 self._push.insert(0, s)
491 "\n".join(self._cmd1 + self._push + self._cmd2), file=self._out_f
493 self._cmd1, self._push, self._cmd2 = [], [], []
495 def debugprint(self, s, **kwargs):
497 print(s, file=sys.stderr, **kwargs)
505 debug_commands=False,
507 temppath = temppath or pathlib.Path(
508 tempfile.mkstemp(prefix="muttmdwn-", dir=tempdir)[1]
510 cmds = MuttCommands(out_f, debug=debug_commands)
512 editor = f"{' '.join(sys.argv)} massage --write-commands-to {temppath}"
514 cmds.cmd('set my_editor="$editor"')
515 cmds.cmd('set my_edit_headers="$edit_headers"')
516 cmds.cmd(f'set editor="{editor}"')
517 cmds.cmd("unset edit_headers")
518 cmds.cmd(f"set my_mdwn_postprocess_cmd_file={temppath}")
519 cmds.push("<first-entry><edit-file>")
530 converter=convert_markdown_to_html,
531 related_to_html_only=True,
533 max_other_attachments=20,
535 debug_commands=False,
538 # Here's the big picture: we're being invoked as the editor on the email
539 # draft, and whatever commands we write to the file given as cmdpath will
540 # be run by the second source command in the macro definition.
542 # Let's start by cleaning up what the setup did (see above), i.e. we
543 # restore the $editor and $edit_headers variables, and also unset the
544 # variable used to identify the command file we're currently writing
546 cmds = MuttCommands(cmd_f, debug=debug_commands)
547 cmds.cmd('set editor="$my_editor"')
548 cmds.cmd('set edit_headers="$my_edit_headers"')
549 cmds.cmd("unset my_editor")
550 cmds.cmd("unset my_edit_headers")
552 # let's flush those commands, as there'll be a lot of pushes from now
553 # on, which need to be run in reverse order
556 extensions = extensions.split(",") if extensions else []
561 related_to_html_only=related_to_html_only,
563 extensions=extensions,
566 mimetree = MIMETreeDFWalker(debug=debug_walk)
568 state = dict(pos=1, tags={}, parts=1)
570 def visitor_fn(item, ancestry, descendents, *, debugprint=None):
572 Visitor function called for every node (part) of the MIME tree,
573 depth-first, and responsible for telling NeoMutt how to assemble
576 KILL_LINE = r"\Ca\Ck"
578 if isinstance(item, Part):
579 # We've hit a leaf-node, i.e. an alternative or a related part
580 # with actual content.
584 # The original source already exists in the NeoMutt tree, but
585 # the underlying file may have been modified, so we need to
586 # update the encoding, but that's it:
587 cmds.push("<first-entry>")
588 cmds.push("<update-encoding>")
590 # We really just need to be able to assume that at this point,
591 # NeoMutt is at position 1, and that we've processed only this
592 # part so far. Nevermind about actual attachments, we can
593 # safely ignore those as they stay at the end.
594 assert state["pos"] == 1
595 assert state["parts"] == 1
597 # … whereas all other parts need to be added, and they're all
598 # considered to be temporary and inline:
599 cmds.push(f"<attach-file>{item.path}<enter>")
600 cmds.push("<toggle-unlink><toggle-disposition>")
602 # This added a part at the end of the list of parts, and that's
603 # just how many parts we've seen so far, so it's position in
604 # the NeoMutt compose list is the count of parts
606 state["pos"] = state["parts"]
608 # If the item (including the original) comes with additional
609 # information, then we might just as well update the NeoMutt
612 cmds.push(f"<edit-content-id>{KILL_LINE}{item.cid}<enter>")
614 # Now for the biggest hack in this script, which is to handle
615 # attachments, such as PDFs, that aren't related or alternatives.
616 # The problem is that when we add an inline image, it always gets
617 # appended to the list, i.e. inserted *after* other attachments.
618 # Since we don't know the number of attachments, we also cannot
619 # infer the postition of the new attachment. Therefore, we bubble
620 # it all the way to the top, only to then move it down again:
621 if state["pos"] > 1: # skip for the first part
622 for i in range(max_other_attachments):
623 # could use any number here, but has to be larger than the
624 # number of possible attachments. The performance
625 # difference of using a high number is negligible.
626 # Bubble up the new part
627 cmds.push(f"<move-up>")
629 # As we push the part to the right position in the list (i.e.
630 # the last of the subset of attachments this script added), we
631 # must handle the situation that subtrees are skipped by
632 # NeoMutt. Hence, the actual number of positions to move down
633 # is decremented by the number of descendents so far
635 for i in range(1, state["pos"] - len(descendents)):
636 cmds.push(f"<move-down>")
638 elif isinstance(item, Multipart):
639 # This node has children, but we already visited them (see
640 # above). The tags dictionary of State should contain a list of
641 # their positions in the NeoMutt compose window, so iterate those
642 # and tag the parts there:
643 n_tags = len(state["tags"][item])
644 for tag in state["tags"][item]:
645 cmds.push(f"<jump>{tag}<enter><tag-entry>")
647 if item.subtype == "alternative":
648 cmds.push("<group-alternatives>")
649 elif item.subtype in ("relative", "related"):
650 cmds.push("<group-related>")
651 elif item.subtype == "multilingual":
652 cmds.push("<group-multilingual>")
654 raise NotImplementedError(
655 f"Handling of multipart/{item.subtype} is not implemented"
658 state["pos"] -= n_tags - 1
662 # We should never get here
663 raise RuntimeError(f"Type {type(item)} is unexpected: {item}")
665 # If the item has a description, we might just as well add it
667 cmds.push(f"<edit-description>{KILL_LINE}{item.desc}<enter>")
670 # If there's an ancestry, record the current (assumed) position in
671 # the NeoMutt compose window as needed-to-tag by our direct parent
672 # (i.e. the last item of the ancestry)
673 state["tags"].setdefault(ancestry[-1], []).append(state["pos"])
675 lead = "│ " * (len(ancestry) + 1) + "* "
677 f"{lead}ancestry={[a.subtype for a in ancestry]}\n"
678 f"{lead}descendents={[d.subtype for d in descendents]}\n"
679 f"{lead}children_positions={state['tags'][ancestry[-1]]}\n"
680 f"{lead}pos={state['pos']}, parts={state['parts']}"
686 # Let's walk the tree and visit every node with our fancy visitor
688 mimetree.walk(tree, visitor_fn=visitor_fn)
691 cmds.push("<send-message>")
693 # Finally, cleanup. Since we're responsible for removing the temporary
694 # file, how's this for a little hack?
696 filename = cmd_f.name
697 except AttributeError:
698 filename = "pytest_internal_file"
699 cmds.cmd(f"source 'rm -f {filename}|'")
700 cmds.cmd("unset my_mdwn_postprocess_cmd_file")
704 # [ CLI ENTRY ] ###############################################################
706 if __name__ == "__main__":
707 args = parse_cli_args()
709 if args.mode is None:
711 tempdir=args.tempdir,
712 debug_commands=args.debug_commands,
715 elif args.mode == "massage":
716 with open(args.MAILDRAFT, "r") as draft_f, open(
721 pathlib.Path(args.MAILDRAFT),
723 extensions=args.extensions,
724 cssfile=args.css_file,
725 related_to_html_only=args.related_to_html_only,
726 max_other_attachments=args.max_number_other_attachments,
727 only_build=args.only_build,
728 tempdir=args.tempdir,
729 debug_commands=args.debug_commands,
730 debug_walk=args.debug_walk,
734 # [ TESTS ] ###################################################################
738 from io import StringIO
743 return "CONSTANT STRING 1"
747 return "CONSTANT STRING 2"
749 # NOTE: tests using the capsys fixture must specify sys.stdout to the
750 # functions they call, else old stdout is used and not captured
752 def test_MuttCommands_cmd(self, const1, const2, capsys):
753 "Assert order of commands"
754 cmds = MuttCommands(out_f=sys.stdout)
758 captured = capsys.readouterr()
759 assert captured.out == "\n".join((const1, const2, ""))
761 def test_MuttCommands_push(self, const1, const2, capsys):
762 "Assert reverse order of pushes"
763 cmds = MuttCommands(out_f=sys.stdout)
767 captured = capsys.readouterr()
770 == ('"\npush "'.join(("", const2, const1, "")))[2:-6]
773 def test_MuttCommands_cmd_push_mixed(self, const1, const2, capsys):
774 "Assert reverse order of pushes"
775 cmds = MuttCommands(out_f=sys.stdout)
776 lines = ["000", "001", "010", "011", "100", "101", "110", "111"]
778 cmds.cmd(lines[4 * i + 0])
779 cmds.cmd(lines[4 * i + 1])
780 cmds.push(lines[4 * i + 2])
781 cmds.push(lines[4 * i + 3])
784 captured = capsys.readouterr()
785 lines_out = captured.out.splitlines()
786 assert lines[0] in lines_out[0]
787 assert lines[1] in lines_out[1]
788 assert lines[7] in lines_out[2]
789 assert lines[6] in lines_out[3]
790 assert lines[3] in lines_out[4]
791 assert lines[2] in lines_out[5]
792 assert lines[4] in lines_out[6]
793 assert lines[5] in lines_out[7]
796 def mime_tree_related_to_alternative(self):
810 Part("text", "html", "part.html", desc="HTML"),
815 "text", "png", "logo.png", cid="logo.png", desc="Logo"
822 def mime_tree_related_to_html(self):
836 Part("text", "html", "part.html", desc="HTML"),
851 def test_MIMETreeDFWalker_depth_first_walk(
852 self, mime_tree_related_to_alternative
854 mimetree = MIMETreeDFWalker()
858 def visitor_fn(item, ancestry, descendents, debugprint):
859 items.append((item, len(ancestry), len(descendents)))
862 mime_tree_related_to_alternative, visitor_fn=visitor_fn
864 assert len(items) == 5
865 assert items[0][0].subtype == "plain"
866 assert items[0][1] == 2
867 assert items[0][2] == 0
868 assert items[1][0].subtype == "html"
869 assert items[1][1] == 2
870 assert items[1][2] == 0
871 assert items[2][0].subtype == "alternative"
872 assert items[2][1] == 1
873 assert items[2][2] == 2
874 assert items[3][0].subtype == "png"
875 assert items[3][1] == 1
876 assert items[3][2] == 2
877 assert items[4][0].subtype == "relative"
878 assert items[4][1] == 0
879 assert items[4][2] == 4
881 def test_MIMETreeDFWalker_list_to_mixed(self, const1):
882 mimetree = MIMETreeDFWalker()
885 def visitor_fn(item, ancestry, descendents, debugprint):
888 p = Part("text", "plain", const1)
889 mimetree.walk([p], visitor_fn=visitor_fn)
890 assert items[-1].subtype == "plain"
891 mimetree.walk([p, p], visitor_fn=visitor_fn)
892 assert items[-1].subtype == "mixed"
894 def test_MIMETreeDFWalker_visitor_in_constructor(
895 self, mime_tree_related_to_alternative
899 def visitor_fn(item, ancestry, descendents, debugprint):
902 mimetree = MIMETreeDFWalker(visitor_fn=visitor_fn)
903 mimetree.walk(mime_tree_related_to_alternative)
904 assert len(items) == 5
907 def string_io(self, const1, text=None):
908 return StringIO(text or const1)
910 def test_do_massage_basic(self, const1, string_io, capsys):
915 related_to_html_only,
919 return Part("text", "plain", draftpath, orig=True)
928 captured = capsys.readouterr()
929 lines = captured.out.splitlines()
930 assert '="$my_editor"' in lines.pop(0)
931 assert '="$my_edit_headers"' in lines.pop(0)
932 assert "unset my_editor" == lines.pop(0)
933 assert "unset my_edit_headers" == lines.pop(0)
934 assert "send-message" in lines.pop(0)
935 assert "update-encoding" in lines.pop(0)
936 assert "first-entry" in lines.pop(0)
937 assert "source 'rm -f " in lines.pop(0)
938 assert "unset my_mdwn_postprocess_cmd_file" == lines.pop(0)
940 def test_do_massage_fulltree(
941 self, string_io, const1, mime_tree_related_to_alternative, capsys
947 related_to_html_only,
951 return mime_tree_related_to_alternative
958 max_other_attachments=max_attachments,
962 captured = capsys.readouterr()
963 lines = captured.out.splitlines()[4:-2]
964 assert "first-entry" in lines.pop()
965 assert "update-encoding" in lines.pop()
966 assert "Plain" in lines.pop()
967 assert "part.html" in lines.pop()
968 assert "toggle-unlink" in lines.pop()
969 for i in range(max_attachments):
970 assert "move-up" in lines.pop()
971 assert "move-down" in lines.pop()
972 assert "HTML" in lines.pop()
973 assert "jump>1" in lines.pop()
974 assert "jump>2" in lines.pop()
975 assert "group-alternatives" in lines.pop()
976 assert "Alternative" in lines.pop()
977 assert "logo.png" in lines.pop()
978 assert "toggle-unlink" in lines.pop()
979 assert "content-id" in lines.pop()
980 for i in range(max_attachments):
981 assert "move-up" in lines.pop()
982 assert "move-down" in lines.pop()
983 assert "Logo" in lines.pop()
984 assert "jump>1" in lines.pop()
985 assert "jump>4" in lines.pop()
986 assert "group-related" in lines.pop()
987 assert "Related" in lines.pop()
988 assert "send-message" in lines.pop()
989 assert len(lines) == 0
992 def fake_filewriter(self):
997 def __call__(self, path, content, mode="w", **kwargs):
998 self._writes.append((path, content))
1000 def pop(self, index=-1):
1001 return self._writes.pop(index)
1006 def markdown_non_converter(self, const1, const2):
1007 return lambda s, text: f"{const1}{text}{const2}"
1009 def test_converter_tree_basic(self, const1, const2, fake_filewriter):
1010 path = pathlib.Path(const2)
1011 tree = convert_markdown_to_html(
1012 const1, path, filewriter_fn=fake_filewriter
1015 assert tree.subtype == "alternative"
1016 assert len(tree.children) == 2
1017 assert tree.children[0].subtype == "plain"
1018 assert tree.children[0].path == path
1019 assert tree.children[0].orig
1020 assert tree.children[1].subtype == "html"
1021 assert tree.children[1].path == path.with_suffix(".html")
1023 def test_converter_writes(
1029 markdown_non_converter,
1031 path = pathlib.Path(const2)
1033 with monkeypatch.context() as m:
1034 m.setattr(markdown.Markdown, "convert", markdown_non_converter)
1035 convert_markdown_to_html(
1036 const1, path, filewriter_fn=fake_filewriter
1039 assert (path, const1) == fake_filewriter.pop(0)
1041 path.with_suffix(".html"),
1042 markdown_non_converter(None, const1),
1043 ) == fake_filewriter.pop(0)
1045 def test_markdown_inline_image_processor(self):
1046 imgpath1 = "file:/path/to/image.png"
1047 imgpath2 = "file:///path/to/image.png?url=params"
1048 imgpath3 = "/path/to/image.png"
1049 text = f"""![inline local image]({imgpath1})
1051 with newline]({imgpath2})
1052 ![image local path]({imgpath3})"""
1053 text, html, images = markdown_with_inline_image_support(text)
1055 # local paths have been normalised to URLs:
1056 imgpath3 = f"file://{imgpath3}"
1058 assert 'src="cid:' in html
1059 assert "](cid:" in text
1060 assert len(images) == 3
1061 assert imgpath1 in images
1062 assert imgpath2 in images
1063 assert imgpath3 in images
1064 assert images[imgpath1].cid != images[imgpath2].cid
1065 assert images[imgpath1].cid != images[imgpath3].cid
1066 assert images[imgpath2].cid != images[imgpath3].cid
1068 def test_markdown_inline_image_processor_title_to_desc(self, const1):
1069 imgpath = "file:///path/to/image.png"
1070 text = f'![inline local image]({imgpath} "{const1}")'
1071 text, html, images = markdown_with_inline_image_support(text)
1072 assert images[imgpath].desc == const1
1074 def test_markdown_inline_image_processor_alt_to_desc(self, const1):
1075 imgpath = "file:///path/to/image.png"
1076 text = f"![{const1}]({imgpath})"
1077 text, html, images = markdown_with_inline_image_support(text)
1078 assert images[imgpath].desc == const1
1080 def test_markdown_inline_image_processor_title_over_alt_desc(
1081 self, const1, const2
1083 imgpath = "file:///path/to/image.png"
1084 text = f'![{const1}]({imgpath} "{const2}")'
1085 text, html, images = markdown_with_inline_image_support(text)
1086 assert images[imgpath].desc == const2
1088 def test_markdown_inline_image_not_external(self):
1089 imgpath = "https://path/to/image.png"
1090 text = f"![inline image]({imgpath})"
1091 text, html, images = markdown_with_inline_image_support(text)
1093 assert 'src="cid:' not in html
1094 assert "](cid:" not in text
1095 assert len(images) == 0
1097 def test_markdown_inline_image_local_file(self):
1098 imgpath = "/path/to/image.png"
1099 text = f"![inline image]({imgpath})"
1100 text, html, images = markdown_with_inline_image_support(text)
1102 for k, v in images.items():
1103 assert k == f"file://{imgpath}"
1109 "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAE"
1110 "AAAABCAAAAAA6fptVAAAACklEQVQI12P4DwABAQEAG7buVgAA"
1113 def test_markdown_inline_image_processor_base64(self, test_png):
1114 text = f"![1px white inlined]({test_png})"
1115 text, html, images = markdown_with_inline_image_support(text)
1117 assert 'src="cid:' in html
1118 assert "](cid:" in text
1119 assert len(images) == 1
1120 assert test_png in images
1122 def test_converter_tree_inline_image_base64(
1123 self, test_png, const1, fake_filewriter
1125 text = f"![inline base64 image]({test_png})"
1126 path = pathlib.Path(const1)
1127 tree = convert_markdown_to_html(
1130 filewriter_fn=fake_filewriter,
1131 related_to_html_only=False,
1133 assert tree.subtype == "relative"
1134 assert tree.children[0].subtype == "alternative"
1135 assert tree.children[1].subtype == "png"
1136 written = fake_filewriter.pop()
1137 assert tree.children[1].path == written[0]
1138 assert written[1] == request.urlopen(test_png).read()
1140 def test_converter_tree_inline_image_base64_related_to_html(
1141 self, test_png, const1, fake_filewriter
1143 text = f"![inline base64 image]({test_png})"
1144 path = pathlib.Path(const1)
1145 tree = convert_markdown_to_html(
1148 filewriter_fn=fake_filewriter,
1149 related_to_html_only=True,
1151 assert tree.subtype == "alternative"
1152 assert tree.children[1].subtype == "relative"
1153 assert tree.children[1].children[1].subtype == "png"
1154 written = fake_filewriter.pop()
1155 assert tree.children[1].children[1].path == written[0]
1156 assert written[1] == request.urlopen(test_png).read()
1158 def test_converter_tree_inline_image_cid(
1159 self, const1, fake_filewriter
1161 text = f"![inline base64 image](cid:{const1})"
1162 path = pathlib.Path(const1)
1163 tree = convert_markdown_to_html(
1166 filewriter_fn=fake_filewriter,
1167 related_to_html_only=False,
1169 assert len(tree.children) == 2
1170 assert tree.children[0].cid != const1
1171 assert tree.children[0].type != "image"
1172 assert tree.children[1].cid != const1
1173 assert tree.children[1].type != "image"
1175 def test_inline_image_collection(
1176 self, test_png, const1, const2, fake_filewriter
1178 test_images = {test_png: InlineImageInfo(cid=const1, desc=const2)}
1179 relparts = collect_inline_images(
1180 test_images, filewriter_fn=fake_filewriter
1183 written = fake_filewriter.pop()
1184 assert b"PNG" in written[1]
1186 assert relparts[0].subtype == "png"
1187 assert relparts[0].path == written[0]
1188 assert relparts[0].cid == const1
1189 assert relparts[0].desc.endswith(const2)
1191 def test_apply_stylesheet(self):
1193 html = "<p>Hello, world!</p>"
1194 css = "p { color:red }"
1195 out = apply_styling(html, css)
1196 assert 'p style="color' in out
1198 def test_apply_stylesheet_pygments(self):
1201 f'<div class="{_CODEHILITE_CLASS}">'
1202 "<pre>def foo():\n return</pre></div>"
1204 out = apply_styling(html, _PYGMENTS_CSS)
1205 assert f'{_CODEHILITE_CLASS}" style="' in out
1207 def test_mime_tree_relative_within_alternative(
1208 self, string_io, const1, capsys, mime_tree_related_to_html
1214 related_to_html_only,
1218 return mime_tree_related_to_html
1224 converter=converter,
1227 captured = capsys.readouterr()
1228 lines = captured.out.splitlines()[4:-2]
1229 assert "first-entry" in lines.pop()
1230 assert "update-encoding" in lines.pop()
1231 assert "Plain" in lines.pop()
1232 assert "part.html" in lines.pop()
1233 assert "toggle-unlink" in lines.pop()
1234 assert "move-up" in lines.pop()
1237 if "move-up" not in top:
1239 assert "move-down" in top
1240 assert "HTML" in lines.pop()
1241 assert "logo.png" in lines.pop()
1242 assert "toggle-unlink" in lines.pop()
1243 assert "content-id" in lines.pop()
1244 assert "move-up" in lines.pop()
1247 if "move-up" not in top:
1249 assert "move-down" in top
1250 assert "move-down" in lines.pop()
1251 assert "Logo" in lines.pop()
1252 assert "jump>2" in lines.pop()
1253 assert "jump>3" in lines.pop()
1254 assert "group-related" in lines.pop()
1255 assert "Related" in lines.pop()
1256 assert "jump>1" in lines.pop()
1257 assert "jump>2" in lines.pop()
1258 assert "group-alternative" in lines.pop()
1259 assert "Alternative" in lines.pop()
1260 assert "send-message" in lines.pop()
1261 assert len(lines) == 0
1263 def test_mime_tree_nested_trees_does_not_break_positioning(
1264 self, string_io, const1, capsys
1270 related_to_html_only,
1294 desc="Nested plain",
1303 desc="Nested alternative",
1323 converter=converter,
1326 captured = capsys.readouterr()
1327 lines = captured.out.splitlines()
1328 while not "logo.png" in lines.pop():
1331 assert "content-id" in lines.pop()
1332 assert "move-up" in lines.pop()
1335 if "move-up" not in top:
1337 assert "move-down" in top
1338 # Due to the nested trees, the number of descendents of the sibling
1339 # actually needs to be considered, not just the nieces. So to move
1340 # from position 1 to position 6, it only needs one <move-down>
1341 # because that jumps over the entire sibling tree. Thus what
1342 # follows next must not be another <move-down>
1343 assert "Logo" in lines.pop()