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 data = request.urlopen(path)
271 mimetype = data.headers["Content-Type"]
272 ext = mimetypes.guess_extension(mimetype)
273 tempfilename = tempfile.mkstemp(prefix="img", suffix=ext, dir=tempdir)
274 path = pathlib.Path(tempfilename[1])
276 filewriter_fn(path, data.read(), "w+b")
280 *mimetype.split("/"),
283 desc=f"Image: {info.desc}",
290 def convert_markdown_to_html(
295 filewriter_fn=filewriter_fn,
298 extension_configs=None,
300 # TODO extension_configs need to be handled differently
301 extension_configs = extension_configs or {}
302 extension_configs.setdefault("pymdownx.highlight", {})
303 extension_configs["pymdownx.highlight"]["css_class"] = _CODEHILITE_CLASS
305 origtext, htmltext, images = markdown_with_inline_image_support(
306 origtext, extensions=extensions, extension_configs=extension_configs
309 filewriter_fn(draftpath, origtext, encoding="utf-8")
311 "text", "plain", draftpath, "Plain-text version", orig=True
314 htmltext = apply_styling(htmltext, cssfile)
316 htmlpath = draftpath.with_suffix(".html")
318 htmlpath, htmltext, encoding="utf-8", errors="xmlcharrefreplace"
320 htmlpart = Part("text", "html", htmlpath, "HTML version")
323 "alternative", [textpart, htmlpart], "Group of alternative content"
326 imgparts = collect_inline_images(
327 images, tempdir=tempdir, filewriter_fn=filewriter_fn
331 "relative", [altpart] + imgparts, "Group of related content"
337 class MIMETreeDFWalker:
338 def __init__(self, *, visitor_fn=None, debug=False):
339 self._visitor_fn = visitor_fn
342 def walk(self, root, *, visitor_fn=None):
344 Recursive function to implement a depth-dirst walk of the MIME-tree
348 if isinstance(root, list):
349 root = Multipart("mixed", children=root)
354 visitor_fn=visitor_fn or self._visitor_fn,
357 def _walk(self, node, *, stack, visitor_fn):
358 # Let's start by enumerating the parts at the current level. At the
359 # root level, stack will be the empty list, and we expect a multipart/*
360 # container at this level. Later, e.g. within a mutlipart/alternative
361 # container, the subtree will just be the alternative parts, while the
362 # top of the stack will be the multipart/alternative container, which
363 # we will process after the following loop.
365 lead = f"{'| '*len(stack)}|-"
366 if isinstance(node, Multipart):
368 f"{lead}{node} parents={[s.subtype for s in stack]}"
371 # Depth-first, so push the current container onto the stack,
374 self.debugprint("| " * (len(stack) + 1))
375 for child in node.children:
379 visitor_fn=visitor_fn,
381 self.debugprint("| " * len(stack))
382 assert stack.pop() == node
385 self.debugprint(f"{lead}{node}")
388 visitor_fn(node, stack, debugprint=self.debugprint)
390 def debugprint(self, s, **kwargs):
392 print(s, file=sys.stderr, **kwargs)
395 # [ RUN MODES ] ###############################################################
400 Stupid class to interface writing out Mutt commands. This is quite a hack
401 to deal with the fact that Mutt runs "push" commands in reverse order, so
402 all of a sudden, things become very complicated when mixing with "real"
405 Hence we keep two sets of commands, and one set of pushes. Commands are
406 added to the first until a push is added, after which commands are added to
407 the second set of commands.
409 On flush(), the first set is printed, followed by the pushes in reverse,
410 and then the second set is printed. All 3 sets are then cleared.
413 def __init__(self, out_f=sys.stdout, *, debug=False):
414 self._cmd1, self._push, self._cmd2 = [], [], []
426 s = s.replace('"', '"')
429 self._push.insert(0, s)
433 "\n".join(self._cmd1 + self._push + self._cmd2), file=self._out_f
435 self._cmd1, self._push, self._cmd2 = [], [], []
437 def debugprint(self, s, **kwargs):
439 print(s, file=sys.stderr, **kwargs)
447 debug_commands=False,
449 temppath = temppath or pathlib.Path(
450 tempfile.mkstemp(prefix="muttmdwn-", dir=tempdir)[1]
452 cmds = MuttCommands(out_f, debug=debug_commands)
454 editor = f"{' '.join(sys.argv)} massage --write-commands-to {temppath}"
456 cmds.cmd('set my_editor="$editor"')
457 cmds.cmd('set my_edit_headers="$edit_headers"')
458 cmds.cmd(f'set editor="{editor}"')
459 cmds.cmd("unset edit_headers")
460 cmds.cmd(f"set my_mdwn_postprocess_cmd_file={temppath}")
461 cmds.push("<first-entry><edit-file>")
472 converter=convert_markdown_to_html,
475 debug_commands=False,
478 # Here's the big picture: we're being invoked as the editor on the email
479 # draft, and whatever commands we write to the file given as cmdpath will
480 # be run by the second source command in the macro definition.
482 # Let's start by cleaning up what the setup did (see above), i.e. we
483 # restore the $editor and $edit_headers variables, and also unset the
484 # variable used to identify the command file we're currently writing
486 cmds = MuttCommands(cmd_f, debug=debug_commands)
487 cmds.cmd('set editor="$my_editor"')
488 cmds.cmd('set edit_headers="$my_edit_headers"')
489 cmds.cmd("unset my_editor")
490 cmds.cmd("unset my_edit_headers")
492 # let's flush those commands, as there'll be a lot of pushes from now
493 # on, which need to be run in reverse order
496 extensions = extensions.split(",") if extensions else []
502 extensions=extensions,
505 mimetree = MIMETreeDFWalker(debug=debug_walk)
507 def visitor_fn(item, stack, *, debugprint=None):
509 Visitor function called for every node (part) of the MIME tree,
510 depth-first, and responsible for telling NeoMutt how to assemble
513 KILL_LINE = r"\Ca\Ck"
515 if isinstance(item, Part):
516 # We've hit a leaf-node, i.e. an alternative or a related part
517 # with actual content.
521 # The original source already exists in the NeoMutt tree, but
522 # the underlying file may have been modified, so we need to
523 # update the encoding, but that's it:
524 cmds.push("<update-encoding>")
526 # … whereas all other parts need to be added, and they're all
527 # considered to be temporary and inline:
528 cmds.push(f"<attach-file>{item.path}<enter>")
529 cmds.push("<toggle-unlink><toggle-disposition>")
531 # If the item (including the original) comes with additional
532 # information, then we might just as well update the NeoMutt
535 cmds.push(f"<edit-content-id>{KILL_LINE}{item.cid}<enter>")
537 elif isinstance(item, Multipart):
538 # This node has children, but we already visited them (see
539 # above), and so they have been tagged in NeoMutt's compose
540 # window. Now it's just a matter of telling NeoMutt to do the
541 # appropriate grouping:
542 if item.subtype == "alternative":
543 cmds.push("<group-alternatives>")
544 elif item.subtype in ("relative", "related"):
545 cmds.push("<group-related>")
546 elif item.subtype == "multilingual":
547 cmds.push("<group-multilingual>")
550 # We should never get here
551 assert not "is valid part"
553 # If the item has a description, we might just as well add it
555 cmds.push(f"<edit-description>{KILL_LINE}{item.desc}<enter>")
557 # Finally, if we're at non-root level, tag the new container,
558 # as it might itself be part of a container, to be processed
561 cmds.push("<tag-entry>")
566 # Let's walk the tree and visit every node with our fancy visitor
568 mimetree.walk(tree, visitor_fn=visitor_fn)
571 cmds.push("<send-message>")
573 # Finally, cleanup. Since we're responsible for removing the temporary
574 # file, how's this for a little hack?
576 filename = cmd_f.name
577 except AttributeError:
578 filename = "pytest_internal_file"
579 cmds.cmd(f"source 'rm -f {filename}|'")
580 cmds.cmd("unset my_mdwn_postprocess_cmd_file")
584 # [ CLI ENTRY ] ###############################################################
586 if __name__ == "__main__":
587 args = parse_cli_args()
589 if args.mode is None:
591 tempdir=args.tempdir,
592 debug_commands=args.debug_commands,
595 elif args.mode == "massage":
596 with open(args.MAILDRAFT, "r") as draft_f, open(
601 pathlib.Path(args.MAILDRAFT),
603 extensions=args.extensions,
604 cssfile=args.css_file,
605 only_build=args.only_build,
606 tempdir=args.tempdir,
607 debug_commands=args.debug_commands,
608 debug_walk=args.debug_walk,
612 # [ TESTS ] ###################################################################
616 from io import StringIO
621 return "CONSTANT STRING 1"
625 return "CONSTANT STRING 2"
627 # NOTE: tests using the capsys fixture must specify sys.stdout to the
628 # functions they call, else old stdout is used and not captured
630 def test_MuttCommands_cmd(self, const1, const2, capsys):
631 "Assert order of commands"
632 cmds = MuttCommands(out_f=sys.stdout)
636 captured = capsys.readouterr()
637 assert captured.out == "\n".join((const1, const2, ""))
639 def test_MuttCommands_push(self, const1, const2, capsys):
640 "Assert reverse order of pushes"
641 cmds = MuttCommands(out_f=sys.stdout)
645 captured = capsys.readouterr()
648 == ('"\npush "'.join(("", const2, const1, "")))[2:-6]
651 def test_MuttCommands_cmd_push_mixed(self, const1, const2, capsys):
652 "Assert reverse order of pushes"
653 cmds = MuttCommands(out_f=sys.stdout)
654 lines = ["000", "001", "010", "011", "100", "101", "110", "111"]
656 cmds.cmd(lines[4 * i + 0])
657 cmds.cmd(lines[4 * i + 1])
658 cmds.push(lines[4 * i + 2])
659 cmds.push(lines[4 * i + 3])
662 captured = capsys.readouterr()
663 lines_out = captured.out.splitlines()
664 assert lines[0] in lines_out[0]
665 assert lines[1] in lines_out[1]
666 assert lines[7] in lines_out[2]
667 assert lines[6] in lines_out[3]
668 assert lines[3] in lines_out[4]
669 assert lines[2] in lines_out[5]
670 assert lines[4] in lines_out[6]
671 assert lines[5] in lines_out[7]
674 def basic_mime_tree(self):
688 Part("text", "html", "part.html", desc="HTML"),
693 "text", "png", "logo.png", cid="logo.png", desc="Logo"
699 def test_MIMETreeDFWalker_depth_first_walk(self, basic_mime_tree):
700 mimetree = MIMETreeDFWalker()
704 def visitor_fn(item, stack, debugprint):
705 items.append((item, len(stack)))
707 mimetree.walk(basic_mime_tree, visitor_fn=visitor_fn)
708 assert len(items) == 5
709 assert items[0][0].subtype == "plain"
710 assert items[0][1] == 2
711 assert items[1][0].subtype == "html"
712 assert items[1][1] == 2
713 assert items[2][0].subtype == "alternative"
714 assert items[2][1] == 1
715 assert items[3][0].subtype == "png"
716 assert items[3][1] == 1
717 assert items[4][0].subtype == "relative"
718 assert items[4][1] == 0
720 def test_MIMETreeDFWalker_list_to_mixed(self, basic_mime_tree):
721 mimetree = MIMETreeDFWalker()
724 def visitor_fn(item, stack, debugprint):
727 mimetree.walk([basic_mime_tree], visitor_fn=visitor_fn)
728 assert items[-1].subtype == "mixed"
730 def test_MIMETreeDFWalker_visitor_in_constructor(
731 self, basic_mime_tree
735 def visitor_fn(item, stack, debugprint):
738 mimetree = MIMETreeDFWalker(visitor_fn=visitor_fn)
739 mimetree.walk(basic_mime_tree)
740 assert len(items) == 5
743 def string_io(self, const1, text=None):
744 return StringIO(text or const1)
746 def test_do_massage_basic(self, const1, string_io, capsys):
747 def converter(drafttext, draftpath, cssfile, extensions, tempdir):
748 return Part("text", "plain", draftpath, orig=True)
757 captured = capsys.readouterr()
758 lines = captured.out.splitlines()
759 assert '="$my_editor"' in lines.pop(0)
760 assert '="$my_edit_headers"' in lines.pop(0)
761 assert "unset my_editor" == lines.pop(0)
762 assert "unset my_edit_headers" == lines.pop(0)
763 assert "send-message" in lines.pop(0)
764 assert "update-encoding" in lines.pop(0)
765 assert "source 'rm -f " in lines.pop(0)
766 assert "unset my_mdwn_postprocess_cmd_file" == lines.pop(0)
768 def test_do_massage_fulltree(
769 self, string_io, const1, basic_mime_tree, capsys
771 def converter(drafttext, draftpath, cssfile, extensions, tempdir):
772 return basic_mime_tree
781 captured = capsys.readouterr()
782 lines = captured.out.splitlines()[4:]
783 assert "send-message" in lines.pop(0)
784 assert "Related" in lines.pop(0)
785 assert "group-related" in lines.pop(0)
786 assert "tag-entry" in lines.pop(0)
787 assert "Logo" in lines.pop(0)
788 assert "content-id" in lines.pop(0)
789 assert "toggle-unlink" in lines.pop(0)
790 assert "logo.png" in lines.pop(0)
791 assert "tag-entry" in lines.pop(0)
792 assert "Alternative" in lines.pop(0)
793 assert "group-alternatives" in lines.pop(0)
794 assert "tag-entry" in lines.pop(0)
795 assert "HTML" in lines.pop(0)
796 assert "toggle-unlink" in lines.pop(0)
797 assert "part.html" in lines.pop(0)
798 assert "tag-entry" in lines.pop(0)
799 assert "Plain" in lines.pop(0)
800 assert "update-encoding" in lines.pop(0)
801 assert len(lines) == 2
804 def fake_filewriter(self):
809 def __call__(self, path, content, mode="w", **kwargs):
810 self._writes.append((path, content))
812 def pop(self, index=-1):
813 return self._writes.pop(index)
818 def markdown_non_converter(self, const1, const2):
819 return lambda s, text: f"{const1}{text}{const2}"
821 def test_converter_tree_basic(
822 self, const1, const2, fake_filewriter, markdown_non_converter
824 path = pathlib.Path(const2)
825 tree = convert_markdown_to_html(
826 const1, path, filewriter_fn=fake_filewriter
829 assert tree.subtype == "alternative"
830 assert len(tree.children) == 2
831 assert tree.children[0].subtype == "plain"
832 assert tree.children[0].path == path
833 assert tree.children[0].orig
834 assert tree.children[1].subtype == "html"
835 assert tree.children[1].path == path.with_suffix(".html")
837 def test_converter_writes(
843 markdown_non_converter,
845 path = pathlib.Path(const2)
847 with monkeypatch.context() as m:
848 m.setattr(markdown.Markdown, "convert", markdown_non_converter)
849 convert_markdown_to_html(
850 const1, path, filewriter_fn=fake_filewriter
853 assert (path, const1) == fake_filewriter.pop(0)
855 path.with_suffix(".html"),
856 markdown_non_converter(None, const1),
857 ) == fake_filewriter.pop(0)
859 def test_markdown_inline_image_processor(self):
860 imgpath1 = "file:/path/to/image.png"
861 imgpath2 = "file:///path/to/image.png?url=params"
862 imgpath3 = "/path/to/image.png"
863 text = f"""![inline local image]({imgpath1})
865 with newline]({imgpath2})
866 ![image local path]({imgpath3})"""
867 text, html, images = markdown_with_inline_image_support(text)
869 # local paths have been normalised to URLs:
870 imgpath3 = f"file://{imgpath3}"
872 assert 'src="cid:' in html
873 assert "](cid:" in text
874 assert len(images) == 3
875 assert imgpath1 in images
876 assert imgpath2 in images
877 assert imgpath3 in images
878 assert images[imgpath1].cid != images[imgpath2].cid
879 assert images[imgpath1].cid != images[imgpath3].cid
880 assert images[imgpath2].cid != images[imgpath3].cid
882 def test_markdown_inline_image_processor_title_to_desc(self, const1):
883 imgpath = "file:///path/to/image.png"
884 text = f'![inline local image]({imgpath} "{const1}")'
885 text, html, images = markdown_with_inline_image_support(text)
886 assert images[imgpath].desc == const1
888 def test_markdown_inline_image_processor_alt_to_desc(self, const1):
889 imgpath = "file:///path/to/image.png"
890 text = f"![{const1}]({imgpath})"
891 text, html, images = markdown_with_inline_image_support(text)
892 assert images[imgpath].desc == const1
894 def test_markdown_inline_image_processor_title_over_alt_desc(
897 imgpath = "file:///path/to/image.png"
898 text = f'![{const1}]({imgpath} "{const2}")'
899 text, html, images = markdown_with_inline_image_support(text)
900 assert images[imgpath].desc == const2
902 def test_markdown_inline_image_not_external(self):
903 imgpath = "https://path/to/image.png"
904 text = f"![inline image]({imgpath})"
905 text, html, images = markdown_with_inline_image_support(text)
907 assert 'src="cid:' not in html
908 assert "](cid:" not in text
909 assert len(images) == 0
911 def test_markdown_inline_image_local_file(self):
912 imgpath = "/path/to/image.png"
913 text = f"![inline image]({imgpath})"
914 text, html, images = markdown_with_inline_image_support(text)
916 for k, v in images.items():
917 assert k == f"file://{imgpath}"
923 "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAE"
924 "AAAABCAAAAAA6fptVAAAACklEQVQI12P4DwABAQEAG7buVgAA"
927 def test_markdown_inline_image_processor_base64(self, test_png):
928 text = f"![1px white inlined]({test_png})"
929 text, html, images = markdown_with_inline_image_support(text)
931 assert 'src="cid:' in html
932 assert "](cid:" in text
933 assert len(images) == 1
934 assert test_png in images
936 def test_converter_tree_inline_image_base64(
937 self, test_png, const1, fake_filewriter
939 text = f"![inline base64 image]({test_png})"
940 path = pathlib.Path(const1)
941 tree = convert_markdown_to_html(
942 text, path, filewriter_fn=fake_filewriter
945 assert tree.subtype == "relative"
946 assert tree.children[1].subtype == "png"
947 written = fake_filewriter.pop()
948 assert tree.children[1].path == written[0]
949 assert written[1] == request.urlopen(test_png).read()
951 def test_inline_image_collection(
952 self, test_png, const1, const2, fake_filewriter
954 test_images = {test_png: InlineImageInfo(cid=const1, desc=const2)}
955 relparts = collect_inline_images(
956 test_images, filewriter_fn=fake_filewriter
959 written = fake_filewriter.pop()
960 assert b"PNG" in written[1]
962 assert relparts[0].subtype == "png"
963 assert relparts[0].path == written[0]
964 assert relparts[0].cid == const1
965 assert relparts[0].desc.endswith(const2)
967 def test_apply_stylesheet(self):
969 html = "<p>Hello, world!</p>"
970 css = "p { color:red }"
971 out = apply_styling(html, css)
972 assert 'p style="color' in out
974 def test_apply_stylesheet_pygments(self):
977 f'<div class="{_CODEHILITE_CLASS}">'
978 "<pre>def foo():\n return</pre></div>"
980 out = apply_styling(html, _PYGMENTS_CSS)
981 assert f'{_CODEHILITE_CLASS}" style="' in out