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)
82 help="Only build, don't send the message",
88 help="Specify temporary directory to use for attachments",
94 help="Turn on debug logging of commands generated to stderr",
97 subp = parser.add_subparsers(help="Sub-command parsers", dest="mode")
98 massage_p = subp.add_parser(
99 "massage", help="Massaging phase (internal use)"
102 massage_p.add_argument(
103 "--write-commands-to",
108 help="Temporary file path to write commands to",
111 massage_p.add_argument(
114 help="Turn on debugging to stderr of the MIME tree walk",
117 massage_p.add_argument(
120 help="If provided, the script is invoked as editor on the mail draft",
123 return parser.parse_args(*args, **kwargs)
126 # [ MARKDOWN WRAPPING ] #######################################################
129 InlineImageInfo = namedtuple(
130 "InlineImageInfo", ["cid", "desc"], defaults=[None]
134 class InlineImageExtension(Extension):
135 class RelatedImageInlineProcessor(ImageInlineProcessor):
136 def __init__(self, re, md, ext):
137 super().__init__(re, md)
140 def handleMatch(self, m, data):
141 el, start, end = super().handleMatch(m, data)
142 if "src" in el.attrib:
143 src = el.attrib["src"]
144 if "://" not in src or src.startswith("file://"):
145 # We only inline local content
146 cid = self._ext.get_cid_for_image(el.attrib)
147 el.attrib["src"] = f"cid:{cid}"
148 return el, start, end
152 self._images = OrderedDict()
154 def extendMarkdown(self, md):
155 md.registerExtension(self)
156 inline_image_proc = self.RelatedImageInlineProcessor(
157 IMAGE_LINK_RE, md, self
159 md.inlinePatterns.register(inline_image_proc, "image_link", 150)
161 def get_cid_for_image(self, attrib):
162 msgid = make_msgid()[1:-1]
164 if path.startswith("/"):
165 path = f"file://{path}"
166 self._images[path] = InlineImageInfo(
167 msgid, attrib.get("title", attrib.get("alt"))
171 def get_images(self):
175 def markdown_with_inline_image_support(
176 text, *, extensions=None, extension_configs=None
178 inline_image_handler = InlineImageExtension()
179 extensions = extensions or []
180 extensions.append(inline_image_handler)
181 mdwn = markdown.Markdown(
182 extensions=extensions, extension_configs=extension_configs
184 htmltext = mdwn.convert(text)
186 images = inline_image_handler.get_images()
188 def replace_image_with_cid(matchobj):
189 for m in (matchobj.group(1), f"file://{matchobj.group(1)}"):
191 return f"(cid:{images[m].cid}"
192 return matchobj.group(0)
194 text = re.sub(r"\(([^)\s]+)", replace_image_with_cid, text)
195 return text, htmltext, images
198 # [ CSS STYLING ] #############################################################
209 from pygments.formatters import get_formatter_by_name
211 _CODEHILITE_CLASS = "codehilite"
213 _PYGMENTS_CSS = get_formatter_by_name(
214 "html", style="default"
215 ).get_style_defs(f".{_CODEHILITE_CLASS}")
221 def apply_styling(html, css):
225 .with_cssString("\n".join(s for s in [_PYGMENTS_CSS, css] if s))
230 # [ PARTS GENERATION ] ########################################################
236 ["type", "subtype", "path", "desc", "cid", "orig"],
237 defaults=[None, None, False],
241 ret = f"<{self.type}/{self.subtype}>"
243 ret = f"{ret} cid:{self.cid}"
245 ret = f"{ret} ORIGINAL"
250 namedtuple("Multipart", ["subtype", "children", "desc"], defaults=[None])
253 return f"<multipart/{self.subtype}> children={len(self.children)}"
256 return hash(str(self.subtype) + "".join(str(self.children)))
259 def filewriter_fn(path, content, mode="w", **kwargs):
260 with open(path, mode, **kwargs) as out_f:
264 def collect_inline_images(
265 images, *, tempdir=None, filewriter_fn=filewriter_fn
268 for path, info in images.items():
269 if path.startswith("cid:"):
272 data = request.urlopen(path)
274 mimetype = data.headers["Content-Type"]
275 ext = mimetypes.guess_extension(mimetype)
276 tempfilename = tempfile.mkstemp(prefix="img", suffix=ext, dir=tempdir)
277 path = pathlib.Path(tempfilename[1])
279 filewriter_fn(path, data.read(), "w+b")
283 *mimetype.split("/"),
286 desc=f"Image: {info.desc}",
293 def convert_markdown_to_html(
298 filewriter_fn=filewriter_fn,
301 extension_configs=None,
303 # TODO extension_configs need to be handled differently
304 extension_configs = extension_configs or {}
305 extension_configs.setdefault("pymdownx.highlight", {})
306 extension_configs["pymdownx.highlight"]["css_class"] = _CODEHILITE_CLASS
308 origtext, htmltext, images = markdown_with_inline_image_support(
309 origtext, extensions=extensions, extension_configs=extension_configs
312 filewriter_fn(draftpath, origtext, encoding="utf-8")
314 "text", "plain", draftpath, "Plain-text version", orig=True
317 htmltext = apply_styling(htmltext, cssfile)
319 htmlpath = draftpath.with_suffix(".html")
321 htmlpath, htmltext, encoding="utf-8", errors="xmlcharrefreplace"
323 htmlpart = Part("text", "html", htmlpath, "HTML version")
326 "alternative", [textpart, htmlpart], "Group of alternative content"
329 imgparts = collect_inline_images(
330 images, tempdir=tempdir, filewriter_fn=filewriter_fn
334 "relative", [altpart] + imgparts, "Group of related content"
340 class MIMETreeDFWalker:
341 def __init__(self, *, visitor_fn=None, debug=False):
342 self._visitor_fn = visitor_fn or self._echovisit
345 def _echovisit(self, node, ancestry, debugprint):
346 debugprint(f"node={node} ancestry={ancestry}")
348 def walk(self, root, *, visitor_fn=None):
350 Recursive function to implement a depth-dirst walk of the MIME-tree
353 if isinstance(root, list):
355 root = Multipart("mixed", children=root)
362 visitor_fn=visitor_fn or self._visitor_fn,
365 def _walk(self, node, *, ancestry, visitor_fn):
366 # Let's start by enumerating the parts at the current level. At the
367 # root level, ancestry will be the empty list, and we expect a
368 # multipart/* container at this level. Later, e.g. within a
369 # mutlipart/alternative container, the subtree will just be the
370 # alternative parts, while the top of the ancestry will be the
371 # multipart/alternative container, which we will process after the
374 lead = f"{'│ '*len(ancestry)}"
375 if isinstance(node, Multipart):
377 f"{lead}├{node} ancestry={[s.subtype for s in ancestry]}"
380 # Depth-first, so push the current container onto the ancestry
381 # stack, then descend …
382 ancestry.append(node)
383 self.debugprint(lead + "│ " * 2)
384 for child in node.children:
388 visitor_fn=visitor_fn,
390 assert ancestry.pop() == node
393 self.debugprint(f"{lead}├{node}")
395 if False and ancestry:
396 self.debugprint(lead[:-1] + " │")
399 visitor_fn(node, ancestry, debugprint=self.debugprint)
401 def debugprint(self, s, **kwargs):
403 print(s, file=sys.stderr, **kwargs)
406 # [ RUN MODES ] ###############################################################
411 Stupid class to interface writing out Mutt commands. This is quite a hack
412 to deal with the fact that Mutt runs "push" commands in reverse order, so
413 all of a sudden, things become very complicated when mixing with "real"
416 Hence we keep two sets of commands, and one set of pushes. Commands are
417 added to the first until a push is added, after which commands are added to
418 the second set of commands.
420 On flush(), the first set is printed, followed by the pushes in reverse,
421 and then the second set is printed. All 3 sets are then cleared.
424 def __init__(self, out_f=sys.stdout, *, debug=False):
425 self._cmd1, self._push, self._cmd2 = [], [], []
437 s = s.replace('"', '"')
440 self._push.insert(0, s)
444 "\n".join(self._cmd1 + self._push + self._cmd2), file=self._out_f
446 self._cmd1, self._push, self._cmd2 = [], [], []
448 def debugprint(self, s, **kwargs):
450 print(s, file=sys.stderr, **kwargs)
458 debug_commands=False,
460 temppath = temppath or pathlib.Path(
461 tempfile.mkstemp(prefix="muttmdwn-", dir=tempdir)[1]
463 cmds = MuttCommands(out_f, debug=debug_commands)
465 editor = f"{' '.join(sys.argv)} massage --write-commands-to {temppath}"
467 cmds.cmd('set my_editor="$editor"')
468 cmds.cmd('set my_edit_headers="$edit_headers"')
469 cmds.cmd(f'set editor="{editor}"')
470 cmds.cmd("unset edit_headers")
471 cmds.cmd(f"set my_mdwn_postprocess_cmd_file={temppath}")
472 cmds.push("<first-entry><edit-file>")
483 converter=convert_markdown_to_html,
486 debug_commands=False,
489 # Here's the big picture: we're being invoked as the editor on the email
490 # draft, and whatever commands we write to the file given as cmdpath will
491 # be run by the second source command in the macro definition.
493 # Let's start by cleaning up what the setup did (see above), i.e. we
494 # restore the $editor and $edit_headers variables, and also unset the
495 # variable used to identify the command file we're currently writing
497 cmds = MuttCommands(cmd_f, debug=debug_commands)
498 cmds.cmd('set editor="$my_editor"')
499 cmds.cmd('set edit_headers="$my_edit_headers"')
500 cmds.cmd("unset my_editor")
501 cmds.cmd("unset my_edit_headers")
503 # let's flush those commands, as there'll be a lot of pushes from now
504 # on, which need to be run in reverse order
507 extensions = extensions.split(",") if extensions else []
513 extensions=extensions,
516 mimetree = MIMETreeDFWalker(debug=debug_walk)
518 state = dict(pos=1, tags={}, parts=1)
520 def visitor_fn(item, ancestry, *, debugprint=None):
522 Visitor function called for every node (part) of the MIME tree,
523 depth-first, and responsible for telling NeoMutt how to assemble
526 KILL_LINE = r"\Ca\Ck"
528 if isinstance(item, Part):
529 # We've hit a leaf-node, i.e. an alternative or a related part
530 # with actual content.
534 # The original source already exists in the NeoMutt tree, but
535 # the underlying file may have been modified, so we need to
536 # update the encoding, but that's it:
537 cmds.push("<first-entry>")
538 cmds.push("<update-encoding>")
540 # We really just need to be able to assume that at this point,
541 # NeoMutt is at position 1, and that we've processed only this
542 # part so far. Nevermind about actual attachments, we can
543 # safely ignore those as they stay at the end.
544 assert state["pos"] == 1
545 assert state["parts"] == 1
547 # … whereas all other parts need to be added, and they're all
548 # considered to be temporary and inline:
549 cmds.push(f"<attach-file>{item.path}<enter>")
550 cmds.push("<toggle-unlink><toggle-disposition>")
552 # This added a part at the end of the list of parts, and that's
553 # just how many parts we've seen so far, so it's position in
554 # the NeoMutt compose list is the count of parts
556 state["pos"] = state["parts"]
558 # If the item (including the original) comes with additional
559 # information, then we might just as well update the NeoMutt
562 cmds.push(f"<edit-content-id>{KILL_LINE}{item.cid}<enter>")
564 elif isinstance(item, Multipart):
565 # This node has children, but we already visited them (see
566 # above). The tags dictionary of State should contain a list of
567 # their positions in the NeoMutt compose window, so iterate those
568 # and tag the parts there:
569 for tag in state["tags"][item]:
570 cmds.push(f"<jump>{tag}<enter><tag-entry>")
572 if item.subtype == "alternative":
573 cmds.push("<group-alternatives>")
574 elif item.subtype in ("relative", "related"):
575 cmds.push("<group-related>")
576 elif item.subtype == "multilingual":
577 cmds.push("<group-multilingual>")
579 raise NotImplementedError(
580 f"Handling of multipart/{item.subtype} is not implemented"
583 state["pos"] -= len(state["tags"][item]) - 1
585 del state["tags"][item]
588 # We should never get here
589 raise RuntimeError(f"Type {type(item)} is unexpected: {item}")
591 # If the item has a description, we might just as well add it
593 cmds.push(f"<edit-description>{KILL_LINE}{item.desc}<enter>")
596 # If there's an ancestry, record the current (assumed) position in
597 # the NeoMutt compose window as needed-to-tag by our direct parent
598 # (i.e. the last item of the ancestry)
599 state["tags"].setdefault(ancestry[-1], []).append(state["pos"])
601 lead = "│ " * (len(ancestry) + 1) + "* "
603 f"{lead}ancestry={[a.subtype for a in ancestry]}\n"
604 f"{lead}children_positions={state['tags'][ancestry[-1]]}\n"
605 f"{lead}pos={state['pos']}, parts={state['parts']}"
611 # Let's walk the tree and visit every node with our fancy visitor
613 mimetree.walk(tree, visitor_fn=visitor_fn)
616 cmds.push("<send-message>")
618 # Finally, cleanup. Since we're responsible for removing the temporary
619 # file, how's this for a little hack?
621 filename = cmd_f.name
622 except AttributeError:
623 filename = "pytest_internal_file"
624 cmds.cmd(f"source 'rm -f {filename}|'")
625 cmds.cmd("unset my_mdwn_postprocess_cmd_file")
629 # [ CLI ENTRY ] ###############################################################
631 if __name__ == "__main__":
632 args = parse_cli_args()
634 if args.mode is None:
636 tempdir=args.tempdir,
637 debug_commands=args.debug_commands,
640 elif args.mode == "massage":
641 with open(args.MAILDRAFT, "r") as draft_f, open(
646 pathlib.Path(args.MAILDRAFT),
648 extensions=args.extensions,
649 cssfile=args.css_file,
650 only_build=args.only_build,
651 tempdir=args.tempdir,
652 debug_commands=args.debug_commands,
653 debug_walk=args.debug_walk,
657 # [ TESTS ] ###################################################################
661 from io import StringIO
666 return "CONSTANT STRING 1"
670 return "CONSTANT STRING 2"
672 # NOTE: tests using the capsys fixture must specify sys.stdout to the
673 # functions they call, else old stdout is used and not captured
675 def test_MuttCommands_cmd(self, const1, const2, capsys):
676 "Assert order of commands"
677 cmds = MuttCommands(out_f=sys.stdout)
681 captured = capsys.readouterr()
682 assert captured.out == "\n".join((const1, const2, ""))
684 def test_MuttCommands_push(self, const1, const2, capsys):
685 "Assert reverse order of pushes"
686 cmds = MuttCommands(out_f=sys.stdout)
690 captured = capsys.readouterr()
693 == ('"\npush "'.join(("", const2, const1, "")))[2:-6]
696 def test_MuttCommands_cmd_push_mixed(self, const1, const2, capsys):
697 "Assert reverse order of pushes"
698 cmds = MuttCommands(out_f=sys.stdout)
699 lines = ["000", "001", "010", "011", "100", "101", "110", "111"]
701 cmds.cmd(lines[4 * i + 0])
702 cmds.cmd(lines[4 * i + 1])
703 cmds.push(lines[4 * i + 2])
704 cmds.push(lines[4 * i + 3])
707 captured = capsys.readouterr()
708 lines_out = captured.out.splitlines()
709 assert lines[0] in lines_out[0]
710 assert lines[1] in lines_out[1]
711 assert lines[7] in lines_out[2]
712 assert lines[6] in lines_out[3]
713 assert lines[3] in lines_out[4]
714 assert lines[2] in lines_out[5]
715 assert lines[4] in lines_out[6]
716 assert lines[5] in lines_out[7]
719 def basic_mime_tree(self):
733 Part("text", "html", "part.html", desc="HTML"),
738 "text", "png", "logo.png", cid="logo.png", desc="Logo"
744 def test_MIMETreeDFWalker_depth_first_walk(self, basic_mime_tree):
745 mimetree = MIMETreeDFWalker()
749 def visitor_fn(item, stack, debugprint):
750 items.append((item, len(stack)))
752 mimetree.walk(basic_mime_tree, visitor_fn=visitor_fn)
753 assert len(items) == 5
754 assert items[0][0].subtype == "plain"
755 assert items[0][1] == 2
756 assert items[1][0].subtype == "html"
757 assert items[1][1] == 2
758 assert items[2][0].subtype == "alternative"
759 assert items[2][1] == 1
760 assert items[3][0].subtype == "png"
761 assert items[3][1] == 1
762 assert items[4][0].subtype == "relative"
763 assert items[4][1] == 0
765 def test_MIMETreeDFWalker_list_to_mixed(self, const1):
766 mimetree = MIMETreeDFWalker()
769 def visitor_fn(item, ancestry, debugprint):
772 p = Part("text", "plain", const1)
773 mimetree.walk([p], visitor_fn=visitor_fn)
774 assert items[-1].subtype == "plain"
775 mimetree.walk([p, p], visitor_fn=visitor_fn)
776 assert items[-1].subtype == "mixed"
778 def test_MIMETreeDFWalker_visitor_in_constructor(
779 self, basic_mime_tree
783 def visitor_fn(item, ancestry, debugprint):
786 mimetree = MIMETreeDFWalker(visitor_fn=visitor_fn)
787 mimetree.walk(basic_mime_tree)
788 assert len(items) == 5
791 def string_io(self, const1, text=None):
792 return StringIO(text or const1)
794 def test_do_massage_basic(self, const1, string_io, capsys):
795 def converter(drafttext, draftpath, cssfile, extensions, tempdir):
796 return Part("text", "plain", draftpath, orig=True)
805 captured = capsys.readouterr()
806 lines = captured.out.splitlines()
807 assert '="$my_editor"' in lines.pop(0)
808 assert '="$my_edit_headers"' in lines.pop(0)
809 assert "unset my_editor" == lines.pop(0)
810 assert "unset my_edit_headers" == lines.pop(0)
811 assert "send-message" in lines.pop(0)
812 assert "update-encoding" in lines.pop(0)
813 assert "source 'rm -f " in lines.pop(0)
814 assert "unset my_mdwn_postprocess_cmd_file" == lines.pop(0)
816 def test_do_massage_fulltree(
817 self, string_io, const1, basic_mime_tree, capsys
819 def converter(drafttext, draftpath, cssfile, extensions, tempdir):
820 return basic_mime_tree
829 captured = capsys.readouterr()
830 lines = captured.out.splitlines()[4:]
831 assert "send-message" in lines.pop(0)
832 assert "Related" in lines.pop(0)
833 assert "group-related" in lines.pop(0)
834 assert "tag-entry" in lines.pop(0)
835 assert "Logo" in lines.pop(0)
836 assert "content-id" in lines.pop(0)
837 assert "toggle-unlink" in lines.pop(0)
838 assert "logo.png" in lines.pop(0)
839 assert "tag-entry" in lines.pop(0)
840 assert "Alternative" in lines.pop(0)
841 assert "group-alternatives" in lines.pop(0)
842 assert "tag-entry" in lines.pop(0)
843 assert "HTML" in lines.pop(0)
844 assert "toggle-unlink" in lines.pop(0)
845 assert "part.html" in lines.pop(0)
846 assert "tag-entry" in lines.pop(0)
847 assert "Plain" in lines.pop(0)
848 assert "update-encoding" in lines.pop(0)
849 assert len(lines) == 2
852 def fake_filewriter(self):
857 def __call__(self, path, content, mode="w", **kwargs):
858 self._writes.append((path, content))
860 def pop(self, index=-1):
861 return self._writes.pop(index)
866 def markdown_non_converter(self, const1, const2):
867 return lambda s, text: f"{const1}{text}{const2}"
869 def test_converter_tree_basic(
870 self, const1, const2, fake_filewriter, markdown_non_converter
872 path = pathlib.Path(const2)
873 tree = convert_markdown_to_html(
874 const1, path, filewriter_fn=fake_filewriter
877 assert tree.subtype == "alternative"
878 assert len(tree.children) == 2
879 assert tree.children[0].subtype == "plain"
880 assert tree.children[0].path == path
881 assert tree.children[0].orig
882 assert tree.children[1].subtype == "html"
883 assert tree.children[1].path == path.with_suffix(".html")
885 def test_converter_writes(
891 markdown_non_converter,
893 path = pathlib.Path(const2)
895 with monkeypatch.context() as m:
896 m.setattr(markdown.Markdown, "convert", markdown_non_converter)
897 convert_markdown_to_html(
898 const1, path, filewriter_fn=fake_filewriter
901 assert (path, const1) == fake_filewriter.pop(0)
903 path.with_suffix(".html"),
904 markdown_non_converter(None, const1),
905 ) == fake_filewriter.pop(0)
907 def test_markdown_inline_image_processor(self):
908 imgpath1 = "file:/path/to/image.png"
909 imgpath2 = "file:///path/to/image.png?url=params"
910 imgpath3 = "/path/to/image.png"
911 text = f"""![inline local image]({imgpath1})
913 with newline]({imgpath2})
914 ![image local path]({imgpath3})"""
915 text, html, images = markdown_with_inline_image_support(text)
917 # local paths have been normalised to URLs:
918 imgpath3 = f"file://{imgpath3}"
920 assert 'src="cid:' in html
921 assert "](cid:" in text
922 assert len(images) == 3
923 assert imgpath1 in images
924 assert imgpath2 in images
925 assert imgpath3 in images
926 assert images[imgpath1].cid != images[imgpath2].cid
927 assert images[imgpath1].cid != images[imgpath3].cid
928 assert images[imgpath2].cid != images[imgpath3].cid
930 def test_markdown_inline_image_processor_title_to_desc(self, const1):
931 imgpath = "file:///path/to/image.png"
932 text = f'![inline local image]({imgpath} "{const1}")'
933 text, html, images = markdown_with_inline_image_support(text)
934 assert images[imgpath].desc == const1
936 def test_markdown_inline_image_processor_alt_to_desc(self, const1):
937 imgpath = "file:///path/to/image.png"
938 text = f"![{const1}]({imgpath})"
939 text, html, images = markdown_with_inline_image_support(text)
940 assert images[imgpath].desc == const1
942 def test_markdown_inline_image_processor_title_over_alt_desc(
945 imgpath = "file:///path/to/image.png"
946 text = f'![{const1}]({imgpath} "{const2}")'
947 text, html, images = markdown_with_inline_image_support(text)
948 assert images[imgpath].desc == const2
950 def test_markdown_inline_image_not_external(self):
951 imgpath = "https://path/to/image.png"
952 text = f"![inline image]({imgpath})"
953 text, html, images = markdown_with_inline_image_support(text)
955 assert 'src="cid:' not in html
956 assert "](cid:" not in text
957 assert len(images) == 0
959 def test_markdown_inline_image_local_file(self):
960 imgpath = "/path/to/image.png"
961 text = f"![inline image]({imgpath})"
962 text, html, images = markdown_with_inline_image_support(text)
964 for k, v in images.items():
965 assert k == f"file://{imgpath}"
971 "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAE"
972 "AAAABCAAAAAA6fptVAAAACklEQVQI12P4DwABAQEAG7buVgAA"
975 def test_markdown_inline_image_processor_base64(self, test_png):
976 text = f"![1px white inlined]({test_png})"
977 text, html, images = markdown_with_inline_image_support(text)
979 assert 'src="cid:' in html
980 assert "](cid:" in text
981 assert len(images) == 1
982 assert test_png in images
984 def test_converter_tree_inline_image_base64(
985 self, test_png, const1, fake_filewriter
987 text = f"![inline base64 image]({test_png})"
988 path = pathlib.Path(const1)
989 tree = convert_markdown_to_html(
990 text, path, filewriter_fn=fake_filewriter
993 assert tree.subtype == "relative"
994 assert tree.children[1].subtype == "png"
995 written = fake_filewriter.pop()
996 assert tree.children[1].path == written[0]
997 assert written[1] == request.urlopen(test_png).read()
999 def test_converter_tree_inline_image_cid(
1000 self, const1, fake_filewriter
1002 text = f"![inline base64 image](cid:{const1})"
1003 path = pathlib.Path(const1)
1004 tree = convert_markdown_to_html(
1007 filewriter_fn=fake_filewriter,
1008 related_to_html_only=False,
1010 assert len(tree.children) == 2
1011 assert tree.children[0].cid != const1
1012 assert tree.children[0].type != "image"
1013 assert tree.children[1].cid != const1
1014 assert tree.children[1].type != "image"
1016 def test_inline_image_collection(
1017 self, test_png, const1, const2, fake_filewriter
1019 test_images = {test_png: InlineImageInfo(cid=const1, desc=const2)}
1020 relparts = collect_inline_images(
1021 test_images, filewriter_fn=fake_filewriter
1024 written = fake_filewriter.pop()
1025 assert b"PNG" in written[1]
1027 assert relparts[0].subtype == "png"
1028 assert relparts[0].path == written[0]
1029 assert relparts[0].cid == const1
1030 assert relparts[0].desc.endswith(const2)
1032 def test_apply_stylesheet(self):
1034 html = "<p>Hello, world!</p>"
1035 css = "p { color:red }"
1036 out = apply_styling(html, css)
1037 assert 'p style="color' in out
1039 def test_apply_stylesheet_pygments(self):
1042 f'<div class="{_CODEHILITE_CLASS}">'
1043 "<pre>def foo():\n return</pre></div>"
1045 out = apply_styling(html, _PYGMENTS_CSS)
1046 assert f'{_CODEHILITE_CLASS}" style="' in out