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
345 def walk(self, root, *, visitor_fn=None):
347 Recursive function to implement a depth-dirst walk of the MIME-tree
351 if isinstance(root, list):
352 root = Multipart("mixed", children=root)
357 visitor_fn=visitor_fn or self._visitor_fn,
360 def _walk(self, node, *, stack, visitor_fn):
361 # Let's start by enumerating the parts at the current level. At the
362 # root level, stack will be the empty list, and we expect a multipart/*
363 # container at this level. Later, e.g. within a mutlipart/alternative
364 # container, the subtree will just be the alternative parts, while the
365 # top of the stack will be the multipart/alternative container, which
366 # we will process after the following loop.
368 lead = f"{'| '*len(stack)}|-"
369 if isinstance(node, Multipart):
371 f"{lead}{node} parents={[s.subtype for s in stack]}"
374 # Depth-first, so push the current container onto the stack,
377 self.debugprint("| " * (len(stack) + 1))
378 for child in node.children:
382 visitor_fn=visitor_fn,
384 self.debugprint("| " * len(stack))
385 assert stack.pop() == node
388 self.debugprint(f"{lead}{node}")
391 visitor_fn(node, stack, debugprint=self.debugprint)
393 def debugprint(self, s, **kwargs):
395 print(s, file=sys.stderr, **kwargs)
398 # [ RUN MODES ] ###############################################################
403 Stupid class to interface writing out Mutt commands. This is quite a hack
404 to deal with the fact that Mutt runs "push" commands in reverse order, so
405 all of a sudden, things become very complicated when mixing with "real"
408 Hence we keep two sets of commands, and one set of pushes. Commands are
409 added to the first until a push is added, after which commands are added to
410 the second set of commands.
412 On flush(), the first set is printed, followed by the pushes in reverse,
413 and then the second set is printed. All 3 sets are then cleared.
416 def __init__(self, out_f=sys.stdout, *, debug=False):
417 self._cmd1, self._push, self._cmd2 = [], [], []
429 s = s.replace('"', '"')
432 self._push.insert(0, s)
436 "\n".join(self._cmd1 + self._push + self._cmd2), file=self._out_f
438 self._cmd1, self._push, self._cmd2 = [], [], []
440 def debugprint(self, s, **kwargs):
442 print(s, file=sys.stderr, **kwargs)
450 debug_commands=False,
452 temppath = temppath or pathlib.Path(
453 tempfile.mkstemp(prefix="muttmdwn-", dir=tempdir)[1]
455 cmds = MuttCommands(out_f, debug=debug_commands)
457 editor = f"{' '.join(sys.argv)} massage --write-commands-to {temppath}"
459 cmds.cmd('set my_editor="$editor"')
460 cmds.cmd('set my_edit_headers="$edit_headers"')
461 cmds.cmd(f'set editor="{editor}"')
462 cmds.cmd("unset edit_headers")
463 cmds.cmd(f"set my_mdwn_postprocess_cmd_file={temppath}")
464 cmds.push("<first-entry><edit-file>")
475 converter=convert_markdown_to_html,
478 debug_commands=False,
481 # Here's the big picture: we're being invoked as the editor on the email
482 # draft, and whatever commands we write to the file given as cmdpath will
483 # be run by the second source command in the macro definition.
485 # Let's start by cleaning up what the setup did (see above), i.e. we
486 # restore the $editor and $edit_headers variables, and also unset the
487 # variable used to identify the command file we're currently writing
489 cmds = MuttCommands(cmd_f, debug=debug_commands)
490 cmds.cmd('set editor="$my_editor"')
491 cmds.cmd('set edit_headers="$my_edit_headers"')
492 cmds.cmd("unset my_editor")
493 cmds.cmd("unset my_edit_headers")
495 # let's flush those commands, as there'll be a lot of pushes from now
496 # on, which need to be run in reverse order
499 extensions = extensions.split(",") if extensions else []
505 extensions=extensions,
508 mimetree = MIMETreeDFWalker(debug=debug_walk)
510 def visitor_fn(item, stack, *, debugprint=None):
512 Visitor function called for every node (part) of the MIME tree,
513 depth-first, and responsible for telling NeoMutt how to assemble
516 KILL_LINE = r"\Ca\Ck"
518 if isinstance(item, Part):
519 # We've hit a leaf-node, i.e. an alternative or a related part
520 # with actual content.
524 # The original source already exists in the NeoMutt tree, but
525 # the underlying file may have been modified, so we need to
526 # update the encoding, but that's it:
527 cmds.push("<update-encoding>")
529 # … whereas all other parts need to be added, and they're all
530 # considered to be temporary and inline:
531 cmds.push(f"<attach-file>{item.path}<enter>")
532 cmds.push("<toggle-unlink><toggle-disposition>")
534 # If the item (including the original) comes with additional
535 # information, then we might just as well update the NeoMutt
538 cmds.push(f"<edit-content-id>{KILL_LINE}{item.cid}<enter>")
540 elif isinstance(item, Multipart):
541 # This node has children, but we already visited them (see
542 # above), and so they have been tagged in NeoMutt's compose
543 # window. Now it's just a matter of telling NeoMutt to do the
544 # appropriate grouping:
545 if item.subtype == "alternative":
546 cmds.push("<group-alternatives>")
547 elif item.subtype in ("relative", "related"):
548 cmds.push("<group-related>")
549 elif item.subtype == "multilingual":
550 cmds.push("<group-multilingual>")
553 # We should never get here
554 assert not "is valid part"
556 # If the item has a description, we might just as well add it
558 cmds.push(f"<edit-description>{KILL_LINE}{item.desc}<enter>")
560 # Finally, if we're at non-root level, tag the new container,
561 # as it might itself be part of a container, to be processed
564 cmds.push("<tag-entry>")
569 # Let's walk the tree and visit every node with our fancy visitor
571 mimetree.walk(tree, visitor_fn=visitor_fn)
574 cmds.push("<send-message>")
576 # Finally, cleanup. Since we're responsible for removing the temporary
577 # file, how's this for a little hack?
579 filename = cmd_f.name
580 except AttributeError:
581 filename = "pytest_internal_file"
582 cmds.cmd(f"source 'rm -f {filename}|'")
583 cmds.cmd("unset my_mdwn_postprocess_cmd_file")
587 # [ CLI ENTRY ] ###############################################################
589 if __name__ == "__main__":
590 args = parse_cli_args()
592 if args.mode is None:
594 tempdir=args.tempdir,
595 debug_commands=args.debug_commands,
598 elif args.mode == "massage":
599 with open(args.MAILDRAFT, "r") as draft_f, open(
604 pathlib.Path(args.MAILDRAFT),
606 extensions=args.extensions,
607 cssfile=args.css_file,
608 only_build=args.only_build,
609 tempdir=args.tempdir,
610 debug_commands=args.debug_commands,
611 debug_walk=args.debug_walk,
615 # [ TESTS ] ###################################################################
619 from io import StringIO
624 return "CONSTANT STRING 1"
628 return "CONSTANT STRING 2"
630 # NOTE: tests using the capsys fixture must specify sys.stdout to the
631 # functions they call, else old stdout is used and not captured
633 def test_MuttCommands_cmd(self, const1, const2, capsys):
634 "Assert order of commands"
635 cmds = MuttCommands(out_f=sys.stdout)
639 captured = capsys.readouterr()
640 assert captured.out == "\n".join((const1, const2, ""))
642 def test_MuttCommands_push(self, const1, const2, capsys):
643 "Assert reverse order of pushes"
644 cmds = MuttCommands(out_f=sys.stdout)
648 captured = capsys.readouterr()
651 == ('"\npush "'.join(("", const2, const1, "")))[2:-6]
654 def test_MuttCommands_cmd_push_mixed(self, const1, const2, capsys):
655 "Assert reverse order of pushes"
656 cmds = MuttCommands(out_f=sys.stdout)
657 lines = ["000", "001", "010", "011", "100", "101", "110", "111"]
659 cmds.cmd(lines[4 * i + 0])
660 cmds.cmd(lines[4 * i + 1])
661 cmds.push(lines[4 * i + 2])
662 cmds.push(lines[4 * i + 3])
665 captured = capsys.readouterr()
666 lines_out = captured.out.splitlines()
667 assert lines[0] in lines_out[0]
668 assert lines[1] in lines_out[1]
669 assert lines[7] in lines_out[2]
670 assert lines[6] in lines_out[3]
671 assert lines[3] in lines_out[4]
672 assert lines[2] in lines_out[5]
673 assert lines[4] in lines_out[6]
674 assert lines[5] in lines_out[7]
677 def basic_mime_tree(self):
691 Part("text", "html", "part.html", desc="HTML"),
696 "text", "png", "logo.png", cid="logo.png", desc="Logo"
702 def test_MIMETreeDFWalker_depth_first_walk(self, basic_mime_tree):
703 mimetree = MIMETreeDFWalker()
707 def visitor_fn(item, stack, debugprint):
708 items.append((item, len(stack)))
710 mimetree.walk(basic_mime_tree, visitor_fn=visitor_fn)
711 assert len(items) == 5
712 assert items[0][0].subtype == "plain"
713 assert items[0][1] == 2
714 assert items[1][0].subtype == "html"
715 assert items[1][1] == 2
716 assert items[2][0].subtype == "alternative"
717 assert items[2][1] == 1
718 assert items[3][0].subtype == "png"
719 assert items[3][1] == 1
720 assert items[4][0].subtype == "relative"
721 assert items[4][1] == 0
723 def test_MIMETreeDFWalker_list_to_mixed(self, basic_mime_tree):
724 mimetree = MIMETreeDFWalker()
727 def visitor_fn(item, stack, debugprint):
730 mimetree.walk([basic_mime_tree], visitor_fn=visitor_fn)
731 assert items[-1].subtype == "mixed"
733 def test_MIMETreeDFWalker_visitor_in_constructor(
734 self, basic_mime_tree
738 def visitor_fn(item, stack, debugprint):
741 mimetree = MIMETreeDFWalker(visitor_fn=visitor_fn)
742 mimetree.walk(basic_mime_tree)
743 assert len(items) == 5
746 def string_io(self, const1, text=None):
747 return StringIO(text or const1)
749 def test_do_massage_basic(self, const1, string_io, capsys):
750 def converter(drafttext, draftpath, cssfile, extensions, tempdir):
751 return Part("text", "plain", draftpath, orig=True)
760 captured = capsys.readouterr()
761 lines = captured.out.splitlines()
762 assert '="$my_editor"' in lines.pop(0)
763 assert '="$my_edit_headers"' in lines.pop(0)
764 assert "unset my_editor" == lines.pop(0)
765 assert "unset my_edit_headers" == lines.pop(0)
766 assert "send-message" in lines.pop(0)
767 assert "update-encoding" in lines.pop(0)
768 assert "source 'rm -f " in lines.pop(0)
769 assert "unset my_mdwn_postprocess_cmd_file" == lines.pop(0)
771 def test_do_massage_fulltree(
772 self, string_io, const1, basic_mime_tree, capsys
774 def converter(drafttext, draftpath, cssfile, extensions, tempdir):
775 return basic_mime_tree
784 captured = capsys.readouterr()
785 lines = captured.out.splitlines()[4:]
786 assert "send-message" in lines.pop(0)
787 assert "Related" in lines.pop(0)
788 assert "group-related" in lines.pop(0)
789 assert "tag-entry" in lines.pop(0)
790 assert "Logo" in lines.pop(0)
791 assert "content-id" in lines.pop(0)
792 assert "toggle-unlink" in lines.pop(0)
793 assert "logo.png" in lines.pop(0)
794 assert "tag-entry" in lines.pop(0)
795 assert "Alternative" in lines.pop(0)
796 assert "group-alternatives" in lines.pop(0)
797 assert "tag-entry" in lines.pop(0)
798 assert "HTML" in lines.pop(0)
799 assert "toggle-unlink" in lines.pop(0)
800 assert "part.html" in lines.pop(0)
801 assert "tag-entry" in lines.pop(0)
802 assert "Plain" in lines.pop(0)
803 assert "update-encoding" in lines.pop(0)
804 assert len(lines) == 2
807 def fake_filewriter(self):
812 def __call__(self, path, content, mode="w", **kwargs):
813 self._writes.append((path, content))
815 def pop(self, index=-1):
816 return self._writes.pop(index)
821 def markdown_non_converter(self, const1, const2):
822 return lambda s, text: f"{const1}{text}{const2}"
824 def test_converter_tree_basic(
825 self, const1, const2, fake_filewriter, markdown_non_converter
827 path = pathlib.Path(const2)
828 tree = convert_markdown_to_html(
829 const1, path, filewriter_fn=fake_filewriter
832 assert tree.subtype == "alternative"
833 assert len(tree.children) == 2
834 assert tree.children[0].subtype == "plain"
835 assert tree.children[0].path == path
836 assert tree.children[0].orig
837 assert tree.children[1].subtype == "html"
838 assert tree.children[1].path == path.with_suffix(".html")
840 def test_converter_writes(
846 markdown_non_converter,
848 path = pathlib.Path(const2)
850 with monkeypatch.context() as m:
851 m.setattr(markdown.Markdown, "convert", markdown_non_converter)
852 convert_markdown_to_html(
853 const1, path, filewriter_fn=fake_filewriter
856 assert (path, const1) == fake_filewriter.pop(0)
858 path.with_suffix(".html"),
859 markdown_non_converter(None, const1),
860 ) == fake_filewriter.pop(0)
862 def test_markdown_inline_image_processor(self):
863 imgpath1 = "file:/path/to/image.png"
864 imgpath2 = "file:///path/to/image.png?url=params"
865 imgpath3 = "/path/to/image.png"
866 text = f"""![inline local image]({imgpath1})
868 with newline]({imgpath2})
869 ![image local path]({imgpath3})"""
870 text, html, images = markdown_with_inline_image_support(text)
872 # local paths have been normalised to URLs:
873 imgpath3 = f"file://{imgpath3}"
875 assert 'src="cid:' in html
876 assert "](cid:" in text
877 assert len(images) == 3
878 assert imgpath1 in images
879 assert imgpath2 in images
880 assert imgpath3 in images
881 assert images[imgpath1].cid != images[imgpath2].cid
882 assert images[imgpath1].cid != images[imgpath3].cid
883 assert images[imgpath2].cid != images[imgpath3].cid
885 def test_markdown_inline_image_processor_title_to_desc(self, const1):
886 imgpath = "file:///path/to/image.png"
887 text = f'![inline local image]({imgpath} "{const1}")'
888 text, html, images = markdown_with_inline_image_support(text)
889 assert images[imgpath].desc == const1
891 def test_markdown_inline_image_processor_alt_to_desc(self, const1):
892 imgpath = "file:///path/to/image.png"
893 text = f"![{const1}]({imgpath})"
894 text, html, images = markdown_with_inline_image_support(text)
895 assert images[imgpath].desc == const1
897 def test_markdown_inline_image_processor_title_over_alt_desc(
900 imgpath = "file:///path/to/image.png"
901 text = f'![{const1}]({imgpath} "{const2}")'
902 text, html, images = markdown_with_inline_image_support(text)
903 assert images[imgpath].desc == const2
905 def test_markdown_inline_image_not_external(self):
906 imgpath = "https://path/to/image.png"
907 text = f"![inline image]({imgpath})"
908 text, html, images = markdown_with_inline_image_support(text)
910 assert 'src="cid:' not in html
911 assert "](cid:" not in text
912 assert len(images) == 0
914 def test_markdown_inline_image_local_file(self):
915 imgpath = "/path/to/image.png"
916 text = f"![inline image]({imgpath})"
917 text, html, images = markdown_with_inline_image_support(text)
919 for k, v in images.items():
920 assert k == f"file://{imgpath}"
926 "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAE"
927 "AAAABCAAAAAA6fptVAAAACklEQVQI12P4DwABAQEAG7buVgAA"
930 def test_markdown_inline_image_processor_base64(self, test_png):
931 text = f"![1px white inlined]({test_png})"
932 text, html, images = markdown_with_inline_image_support(text)
934 assert 'src="cid:' in html
935 assert "](cid:" in text
936 assert len(images) == 1
937 assert test_png in images
939 def test_converter_tree_inline_image_base64(
940 self, test_png, const1, fake_filewriter
942 text = f"![inline base64 image]({test_png})"
943 path = pathlib.Path(const1)
944 tree = convert_markdown_to_html(
945 text, path, filewriter_fn=fake_filewriter
948 assert tree.subtype == "relative"
949 assert tree.children[1].subtype == "png"
950 written = fake_filewriter.pop()
951 assert tree.children[1].path == written[0]
952 assert written[1] == request.urlopen(test_png).read()
954 def test_converter_tree_inline_image_cid(
955 self, const1, fake_filewriter
957 text = f"![inline base64 image](cid:{const1})"
958 path = pathlib.Path(const1)
959 tree = convert_markdown_to_html(
962 filewriter_fn=fake_filewriter,
963 related_to_html_only=False,
965 assert len(tree.children) == 2
966 assert tree.children[0].cid != const1
967 assert tree.children[0].type != "image"
968 assert tree.children[1].cid != const1
969 assert tree.children[1].type != "image"
971 def test_inline_image_collection(
972 self, test_png, const1, const2, fake_filewriter
974 test_images = {test_png: InlineImageInfo(cid=const1, desc=const2)}
975 relparts = collect_inline_images(
976 test_images, filewriter_fn=fake_filewriter
979 written = fake_filewriter.pop()
980 assert b"PNG" in written[1]
982 assert relparts[0].subtype == "png"
983 assert relparts[0].path == written[0]
984 assert relparts[0].cid == const1
985 assert relparts[0].desc.endswith(const2)
987 def test_apply_stylesheet(self):
989 html = "<p>Hello, world!</p>"
990 css = "p { color:red }"
991 out = apply_styling(html, css)
992 assert 'p style="color' in out
994 def test_apply_stylesheet_pygments(self):
997 f'<div class="{_CODEHILITE_CLASS}">'
998 "<pre>def foo():\n return</pre></div>"
1000 out = apply_styling(html, _PYGMENTS_CSS)
1001 assert f'{_CODEHILITE_CLASS}" style="' in out