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",
106 help="Temporary file path to write commands to",
109 massage_p.add_argument(
112 help="Turn on debugging to stderr of the MIME tree walk",
115 massage_p.add_argument(
118 help="If provided, the script is invoked as editor on the mail draft",
121 return parser.parse_args(*args, **kwargs)
124 # [ MARKDOWN WRAPPING ] #######################################################
127 InlineImageInfo = namedtuple(
128 "InlineImageInfo", ["cid", "desc"], defaults=[None]
132 class InlineImageExtension(Extension):
133 class RelatedImageInlineProcessor(ImageInlineProcessor):
134 def __init__(self, re, md, ext):
135 super().__init__(re, md)
138 def handleMatch(self, m, data):
139 el, start, end = super().handleMatch(m, data)
140 if "src" in el.attrib:
141 src = el.attrib["src"]
142 if "://" not in src or src.startswith("file://"):
143 # We only inline local content
144 cid = self._ext.get_cid_for_image(el.attrib)
145 el.attrib["src"] = f"cid:{cid}"
146 return el, start, end
150 self._images = OrderedDict()
152 def extendMarkdown(self, md):
153 md.registerExtension(self)
154 inline_image_proc = self.RelatedImageInlineProcessor(
155 IMAGE_LINK_RE, md, self
157 md.inlinePatterns.register(inline_image_proc, "image_link", 150)
159 def get_cid_for_image(self, attrib):
160 msgid = make_msgid()[1:-1]
162 if path.startswith("/"):
163 path = f"file://{path}"
164 self._images[path] = InlineImageInfo(
165 msgid, attrib.get("title", attrib.get("alt"))
169 def get_images(self):
173 def markdown_with_inline_image_support(
174 text, *, extensions=None, extension_configs=None
176 inline_image_handler = InlineImageExtension()
177 extensions = extensions or []
178 extensions.append(inline_image_handler)
179 mdwn = markdown.Markdown(
180 extensions=extensions, extension_configs=extension_configs
182 htmltext = mdwn.convert(text)
184 images = inline_image_handler.get_images()
186 def replace_image_with_cid(matchobj):
187 for m in (matchobj.group(1), f"file://{matchobj.group(1)}"):
189 return f"(cid:{images[m].cid}"
190 return matchobj.group(0)
192 text = re.sub(r"\(([^)\s]+)", replace_image_with_cid, text)
193 return text, htmltext, images
196 # [ CSS STYLING ] #############################################################
207 from pygments.formatters import get_formatter_by_name
209 _CODEHILITE_CLASS = "codehilite"
211 _PYGMENTS_CSS = get_formatter_by_name(
212 "html", style="default"
213 ).get_style_defs(f".{_CODEHILITE_CLASS}")
219 def apply_styling(html, css):
223 .with_cssString("\n".join(s for s in [_PYGMENTS_CSS, css] if s))
228 # [ PARTS GENERATION ] ########################################################
234 ["type", "subtype", "path", "desc", "cid", "orig"],
235 defaults=[None, None, False],
239 ret = f"<{self.type}/{self.subtype}>"
241 ret = f"{ret} cid:{self.cid}"
243 ret = f"{ret} ORIGINAL"
248 namedtuple("Multipart", ["subtype", "children", "desc"], defaults=[None])
251 return f"<multipart/{self.subtype}> children={len(self.children)}"
254 def filewriter_fn(path, content, mode="w", **kwargs):
255 with open(path, mode, **kwargs) as out_f:
259 def collect_inline_images(
260 images, *, tempdir=None, filewriter_fn=filewriter_fn
263 for path, info in images.items():
264 data = request.urlopen(path)
266 mimetype = data.headers["Content-Type"]
267 ext = mimetypes.guess_extension(mimetype)
268 tempfilename = tempfile.mkstemp(prefix="img", suffix=ext, dir=tempdir)
269 path = pathlib.Path(tempfilename[1])
271 filewriter_fn(path, data.read(), "w+b")
275 *mimetype.split("/"),
278 desc=f"Image: {info.desc}",
285 def convert_markdown_to_html(
290 filewriter_fn=filewriter_fn,
293 extension_configs=None,
295 # TODO extension_configs need to be handled differently
296 extension_configs = extension_configs or {}
297 extension_configs.setdefault("pymdownx.highlight", {})
298 extension_configs["pymdownx.highlight"]["css_class"] = _CODEHILITE_CLASS
300 origtext, htmltext, images = markdown_with_inline_image_support(
301 origtext, extensions=extensions, extension_configs=extension_configs
304 filewriter_fn(draftpath, origtext, encoding="utf-8")
306 "text", "plain", draftpath, "Plain-text version", orig=True
309 htmltext = apply_styling(htmltext, cssfile)
311 htmlpath = draftpath.with_suffix(".html")
313 htmlpath, htmltext, encoding="utf-8", errors="xmlcharrefreplace"
315 htmlpart = Part("text", "html", htmlpath, "HTML version")
318 "alternative", [textpart, htmlpart], "Group of alternative content"
321 imgparts = collect_inline_images(
322 images, tempdir=tempdir, filewriter_fn=filewriter_fn
326 "relative", [altpart] + imgparts, "Group of related content"
332 class MIMETreeDFWalker:
333 def __init__(self, *, visitor_fn=None, debug=False):
334 self._visitor_fn = visitor_fn
337 def walk(self, root, *, visitor_fn=None):
339 Recursive function to implement a depth-dirst walk of the MIME-tree
343 if isinstance(root, list):
344 root = Multipart("mixed", children=root)
349 visitor_fn=visitor_fn or self._visitor_fn,
352 def _walk(self, node, *, stack, visitor_fn):
353 # Let's start by enumerating the parts at the current level. At the
354 # root level, stack will be the empty list, and we expect a multipart/*
355 # container at this level. Later, e.g. within a mutlipart/alternative
356 # container, the subtree will just be the alternative parts, while the
357 # top of the stack will be the multipart/alternative container, which
358 # we will process after the following loop.
360 lead = f"{'| '*len(stack)}|-"
361 if isinstance(node, Multipart):
363 f"{lead}{node} parents={[s.subtype for s in stack]}"
366 # Depth-first, so push the current container onto the stack,
369 self.debugprint("| " * (len(stack) + 1))
370 for child in node.children:
374 visitor_fn=visitor_fn,
376 self.debugprint("| " * len(stack))
377 assert stack.pop() == node
380 self.debugprint(f"{lead}{node}")
383 visitor_fn(node, stack, debugprint=self.debugprint)
385 def debugprint(self, s, **kwargs):
387 print(s, file=sys.stderr, **kwargs)
390 # [ RUN MODES ] ###############################################################
395 Stupid class to interface writing out Mutt commands. This is quite a hack
396 to deal with the fact that Mutt runs "push" commands in reverse order, so
397 all of a sudden, things become very complicated when mixing with "real"
400 Hence we keep two sets of commands, and one set of pushes. Commands are
401 added to the first until a push is added, after which commands are added to
402 the second set of commands.
404 On flush(), the first set is printed, followed by the pushes in reverse,
405 and then the second set is printed. All 3 sets are then cleared.
408 def __init__(self, out_f=sys.stdout, *, debug=False):
409 self._cmd1, self._push, self._cmd2 = [], [], []
421 s = s.replace('"', '"')
424 self._push.insert(0, s)
428 "\n".join(self._cmd1 + self._push + self._cmd2), file=self._out_f
430 self._cmd1, self._push, self._cmd2 = [], [], []
432 def debugprint(self, s, **kwargs):
434 print(s, file=sys.stderr, **kwargs)
442 debug_commands=False,
444 temppath = temppath or pathlib.Path(
445 tempfile.mkstemp(prefix="muttmdwn-", dir=tempdir)[1]
447 cmds = MuttCommands(out_f, debug=debug_commands)
449 editor = f"{' '.join(sys.argv)} massage --write-commands-to {temppath}"
451 cmds.cmd('set my_editor="$editor"')
452 cmds.cmd('set my_edit_headers="$edit_headers"')
453 cmds.cmd(f'set editor="{editor}"')
454 cmds.cmd("unset edit_headers")
455 cmds.cmd(f"set my_mdwn_postprocess_cmd_file={temppath}")
456 cmds.push("<first-entry><edit-file>")
467 converter=convert_markdown_to_html,
470 debug_commands=False,
473 # Here's the big picture: we're being invoked as the editor on the email
474 # draft, and whatever commands we write to the file given as cmdpath will
475 # be run by the second source command in the macro definition.
477 # Let's start by cleaning up what the setup did (see above), i.e. we
478 # restore the $editor and $edit_headers variables, and also unset the
479 # variable used to identify the command file we're currently writing
481 cmds = MuttCommands(cmd_f, debug=debug_commands)
482 cmds.cmd('set editor="$my_editor"')
483 cmds.cmd('set edit_headers="$my_edit_headers"')
484 cmds.cmd("unset my_editor")
485 cmds.cmd("unset my_edit_headers")
487 # let's flush those commands, as there'll be a lot of pushes from now
488 # on, which need to be run in reverse order
491 extensions = extensions.split(",") if extensions else []
497 extensions=extensions,
500 mimetree = MIMETreeDFWalker(debug=debug_walk)
502 def visitor_fn(item, stack, *, debugprint=None):
504 Visitor function called for every node (part) of the MIME tree,
505 depth-first, and responsible for telling NeoMutt how to assemble
508 KILL_LINE = r"\Ca\Ck"
510 if isinstance(item, Part):
511 # We've hit a leaf-node, i.e. an alternative or a related part
512 # with actual content.
516 # The original source already exists in the NeoMutt tree, but
517 # the underlying file may have been modified, so we need to
518 # update the encoding, but that's it:
519 cmds.push("<update-encoding>")
521 # … whereas all other parts need to be added, and they're all
522 # considered to be temporary and inline:
523 cmds.push(f"<attach-file>{item.path}<enter>")
524 cmds.push("<toggle-unlink><toggle-disposition>")
526 # If the item (including the original) comes with additional
527 # information, then we might just as well update the NeoMutt
530 cmds.push(f"<edit-content-id>{KILL_LINE}{item.cid}<enter>")
532 elif isinstance(item, Multipart):
533 # This node has children, but we already visited them (see
534 # above), and so they have been tagged in NeoMutt's compose
535 # window. Now it's just a matter of telling NeoMutt to do the
536 # appropriate grouping:
537 if item.subtype == "alternative":
538 cmds.push("<group-alternatives>")
539 elif item.subtype in ("relative", "related"):
540 cmds.push("<group-related>")
541 elif item.subtype == "multilingual":
542 cmds.push("<group-multilingual>")
545 # We should never get here
546 assert not "is valid part"
548 # If the item has a description, we might just as well add it
550 cmds.push(f"<edit-description>{KILL_LINE}{item.desc}<enter>")
552 # Finally, if we're at non-root level, tag the new container,
553 # as it might itself be part of a container, to be processed
556 cmds.push("<tag-entry>")
561 # Let's walk the tree and visit every node with our fancy visitor
563 mimetree.walk(tree, visitor_fn=visitor_fn)
566 cmds.push("<send-message>")
568 # Finally, cleanup. Since we're responsible for removing the temporary
569 # file, how's this for a little hack?
571 filename = cmd_f.name
572 except AttributeError:
573 filename = "pytest_internal_file"
574 cmds.cmd(f"source 'rm -f {filename}|'")
575 cmds.cmd("unset my_mdwn_postprocess_cmd_file")
579 # [ CLI ENTRY ] ###############################################################
581 if __name__ == "__main__":
582 args = parse_cli_args()
584 if args.mode is None:
586 tempdir=args.tempdir,
587 debug_commands=args.debug_commands,
590 elif args.mode == "massage":
591 with open(args.MAILDRAFT, "r") as draft_f, open(
596 pathlib.Path(args.MAILDRAFT),
598 extensions=args.extensions,
599 cssfile=args.css_file,
600 only_build=args.only_build,
601 tempdir=args.tempdir,
602 debug_commands=args.debug_commands,
603 debug_walk=args.debug_walk,
607 # [ TESTS ] ###################################################################
611 from io import StringIO
616 return "CONSTANT STRING 1"
620 return "CONSTANT STRING 2"
622 # NOTE: tests using the capsys fixture must specify sys.stdout to the
623 # functions they call, else old stdout is used and not captured
625 def test_MuttCommands_cmd(self, const1, const2, capsys):
626 "Assert order of commands"
627 cmds = MuttCommands(out_f=sys.stdout)
631 captured = capsys.readouterr()
632 assert captured.out == "\n".join((const1, const2, ""))
634 def test_MuttCommands_push(self, const1, const2, capsys):
635 "Assert reverse order of pushes"
636 cmds = MuttCommands(out_f=sys.stdout)
640 captured = capsys.readouterr()
643 == ('"\npush "'.join(("", const2, const1, "")))[2:-6]
646 def test_MuttCommands_cmd_push_mixed(self, const1, const2, capsys):
647 "Assert reverse order of pushes"
648 cmds = MuttCommands(out_f=sys.stdout)
649 lines = ["000", "001", "010", "011", "100", "101", "110", "111"]
651 cmds.cmd(lines[4 * i + 0])
652 cmds.cmd(lines[4 * i + 1])
653 cmds.push(lines[4 * i + 2])
654 cmds.push(lines[4 * i + 3])
657 captured = capsys.readouterr()
658 lines_out = captured.out.splitlines()
659 assert lines[0] in lines_out[0]
660 assert lines[1] in lines_out[1]
661 assert lines[7] in lines_out[2]
662 assert lines[6] in lines_out[3]
663 assert lines[3] in lines_out[4]
664 assert lines[2] in lines_out[5]
665 assert lines[4] in lines_out[6]
666 assert lines[5] in lines_out[7]
669 def basic_mime_tree(self):
683 Part("text", "html", "part.html", desc="HTML"),
688 "text", "png", "logo.png", cid="logo.png", desc="Logo"
694 def test_MIMETreeDFWalker_depth_first_walk(self, basic_mime_tree):
695 mimetree = MIMETreeDFWalker()
699 def visitor_fn(item, stack, debugprint):
700 items.append((item, len(stack)))
702 mimetree.walk(basic_mime_tree, visitor_fn=visitor_fn)
703 assert len(items) == 5
704 assert items[0][0].subtype == "plain"
705 assert items[0][1] == 2
706 assert items[1][0].subtype == "html"
707 assert items[1][1] == 2
708 assert items[2][0].subtype == "alternative"
709 assert items[2][1] == 1
710 assert items[3][0].subtype == "png"
711 assert items[3][1] == 1
712 assert items[4][0].subtype == "relative"
713 assert items[4][1] == 0
715 def test_MIMETreeDFWalker_list_to_mixed(self, basic_mime_tree):
716 mimetree = MIMETreeDFWalker()
719 def visitor_fn(item, stack, debugprint):
722 mimetree.walk([basic_mime_tree], visitor_fn=visitor_fn)
723 assert items[-1].subtype == "mixed"
725 def test_MIMETreeDFWalker_visitor_in_constructor(
726 self, basic_mime_tree
730 def visitor_fn(item, stack, debugprint):
733 mimetree = MIMETreeDFWalker(visitor_fn=visitor_fn)
734 mimetree.walk(basic_mime_tree)
735 assert len(items) == 5
738 def string_io(self, const1, text=None):
739 return StringIO(text or const1)
741 def test_do_massage_basic(self, const1, string_io, capsys):
742 def converter(drafttext, draftpath, cssfile, extensions, tempdir):
743 return Part("text", "plain", draftpath, orig=True)
752 captured = capsys.readouterr()
753 lines = captured.out.splitlines()
754 assert '="$my_editor"' in lines.pop(0)
755 assert '="$my_edit_headers"' in lines.pop(0)
756 assert "unset my_editor" == lines.pop(0)
757 assert "unset my_edit_headers" == lines.pop(0)
758 assert "send-message" in lines.pop(0)
759 assert "update-encoding" in lines.pop(0)
760 assert "source 'rm -f " in lines.pop(0)
761 assert "unset my_mdwn_postprocess_cmd_file" == lines.pop(0)
763 def test_do_massage_fulltree(
764 self, string_io, const1, basic_mime_tree, capsys
766 def converter(drafttext, draftpath, cssfile, extensions, tempdir):
767 return basic_mime_tree
776 captured = capsys.readouterr()
777 lines = captured.out.splitlines()[4:]
778 assert "send-message" in lines.pop(0)
779 assert "Related" in lines.pop(0)
780 assert "group-related" in lines.pop(0)
781 assert "tag-entry" in lines.pop(0)
782 assert "Logo" in lines.pop(0)
783 assert "content-id" in lines.pop(0)
784 assert "toggle-unlink" in lines.pop(0)
785 assert "logo.png" in lines.pop(0)
786 assert "tag-entry" in lines.pop(0)
787 assert "Alternative" in lines.pop(0)
788 assert "group-alternatives" in lines.pop(0)
789 assert "tag-entry" in lines.pop(0)
790 assert "HTML" in lines.pop(0)
791 assert "toggle-unlink" in lines.pop(0)
792 assert "part.html" in lines.pop(0)
793 assert "tag-entry" in lines.pop(0)
794 assert "Plain" in lines.pop(0)
795 assert "update-encoding" in lines.pop(0)
796 assert len(lines) == 2
799 def fake_filewriter(self):
804 def __call__(self, path, content, mode="w", **kwargs):
805 self._writes.append((path, content))
807 def pop(self, index=-1):
808 return self._writes.pop(index)
813 def markdown_non_converter(self, const1, const2):
814 return lambda s, text: f"{const1}{text}{const2}"
816 def test_converter_tree_basic(
817 self, const1, const2, fake_filewriter, markdown_non_converter
819 path = pathlib.Path(const2)
820 tree = convert_markdown_to_html(
821 const1, path, filewriter_fn=fake_filewriter
824 assert tree.subtype == "alternative"
825 assert len(tree.children) == 2
826 assert tree.children[0].subtype == "plain"
827 assert tree.children[0].path == path
828 assert tree.children[0].orig
829 assert tree.children[1].subtype == "html"
830 assert tree.children[1].path == path.with_suffix(".html")
832 def test_converter_writes(
838 markdown_non_converter,
840 path = pathlib.Path(const2)
842 with monkeypatch.context() as m:
843 m.setattr(markdown.Markdown, "convert", markdown_non_converter)
844 convert_markdown_to_html(
845 const1, path, filewriter_fn=fake_filewriter
848 assert (path, const1) == fake_filewriter.pop(0)
850 path.with_suffix(".html"),
851 markdown_non_converter(None, const1),
852 ) == fake_filewriter.pop(0)
854 def test_markdown_inline_image_processor(self):
855 imgpath1 = "file:/path/to/image.png"
856 imgpath2 = "file:///path/to/image.png?url=params"
857 imgpath3 = "/path/to/image.png"
858 text = f"""![inline local image]({imgpath1})
860 with newline]({imgpath2})
861 ![image local path]({imgpath3})"""
862 text, html, images = markdown_with_inline_image_support(text)
864 # local paths have been normalised to URLs:
865 imgpath3 = f"file://{imgpath3}"
867 assert 'src="cid:' in html
868 assert "](cid:" in text
869 assert len(images) == 3
870 assert imgpath1 in images
871 assert imgpath2 in images
872 assert imgpath3 in images
873 assert images[imgpath1].cid != images[imgpath2].cid
874 assert images[imgpath1].cid != images[imgpath3].cid
875 assert images[imgpath2].cid != images[imgpath3].cid
877 def test_markdown_inline_image_processor_title_to_desc(self, const1):
878 imgpath = "file:///path/to/image.png"
879 text = f'![inline local image]({imgpath} "{const1}")'
880 text, html, images = markdown_with_inline_image_support(text)
881 assert images[imgpath].desc == const1
883 def test_markdown_inline_image_processor_alt_to_desc(self, const1):
884 imgpath = "file:///path/to/image.png"
885 text = f"![{const1}]({imgpath})"
886 text, html, images = markdown_with_inline_image_support(text)
887 assert images[imgpath].desc == const1
889 def test_markdown_inline_image_processor_title_over_alt_desc(
892 imgpath = "file:///path/to/image.png"
893 text = f'![{const1}]({imgpath} "{const2}")'
894 text, html, images = markdown_with_inline_image_support(text)
895 assert images[imgpath].desc == const2
897 def test_markdown_inline_image_not_external(self):
898 imgpath = "https://path/to/image.png"
899 text = f"![inline image]({imgpath})"
900 text, html, images = markdown_with_inline_image_support(text)
902 assert 'src="cid:' not in html
903 assert "](cid:" not in text
904 assert len(images) == 0
906 def test_markdown_inline_image_local_file(self):
907 imgpath = "/path/to/image.png"
908 text = f"![inline image]({imgpath})"
909 text, html, images = markdown_with_inline_image_support(text)
911 for k, v in images.items():
912 assert k == f"file://{imgpath}"
918 "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAE"
919 "AAAABCAAAAAA6fptVAAAACklEQVQI12P4DwABAQEAG7buVgAA"
922 def test_markdown_inline_image_processor_base64(self, test_png):
923 text = f"![1px white inlined]({test_png})"
924 text, html, images = markdown_with_inline_image_support(text)
926 assert 'src="cid:' in html
927 assert "](cid:" in text
928 assert len(images) == 1
929 assert test_png in images
931 def test_converter_tree_inline_image_base64(
932 self, test_png, const1, fake_filewriter
934 text = f"![inline base64 image]({test_png})"
935 path = pathlib.Path(const1)
936 tree = convert_markdown_to_html(
937 text, path, filewriter_fn=fake_filewriter
940 assert tree.subtype == "relative"
941 assert tree.children[1].subtype == "png"
942 written = fake_filewriter.pop()
943 assert tree.children[1].path == written[0]
944 assert written[1] == request.urlopen(test_png).read()
946 def test_inline_image_collection(
947 self, test_png, const1, const2, fake_filewriter
949 test_images = {test_png: InlineImageInfo(cid=const1, desc=const2)}
950 relparts = collect_inline_images(
951 test_images, filewriter_fn=fake_filewriter
954 written = fake_filewriter.pop()
955 assert b"PNG" in written[1]
957 assert relparts[0].subtype == "png"
958 assert relparts[0].path == written[0]
959 assert relparts[0].cid == const1
960 assert relparts[0].desc.endswith(const2)
962 def test_apply_stylesheet(self):
964 html = "<p>Hello, world!</p>"
965 css = "p { color:red }"
966 out = apply_styling(html, css)
967 assert 'p style="color' in out
969 def test_apply_stylesheet_pygments(self):
972 f'<div class="{_CODEHILITE_CLASS}">'
973 "<pre>def foo():\n return</pre></div>"
975 out = apply_styling(html, _PYGMENTS_CSS)
976 assert f'{_CODEHILITE_CLASS}" style="' in out