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, *, stack, visitor_fn):
366 # Let's start by enumerating the parts at the current level. At the
367 # root level, stack will be the empty list, and we expect a multipart/*
368 # container at this level. Later, e.g. within a mutlipart/alternative
369 # container, the subtree will just be the alternative parts, while the
370 # top of the stack will be the multipart/alternative container, which
371 # we will process after the following loop.
373 lead = f"{'| '*len(stack)}|-"
374 if isinstance(node, Multipart):
376 f"{lead}{node} parents={[s.subtype for s in stack]}"
379 # Depth-first, so push the current container onto the stack,
382 self.debugprint("| " * (len(stack) + 1))
383 for child in node.children:
387 visitor_fn=visitor_fn,
389 self.debugprint("| " * len(stack))
390 assert stack.pop() == node
393 self.debugprint(f"{lead}{node}")
396 visitor_fn(node, stack, debugprint=self.debugprint)
398 def debugprint(self, s, **kwargs):
400 print(s, file=sys.stderr, **kwargs)
403 # [ RUN MODES ] ###############################################################
408 Stupid class to interface writing out Mutt commands. This is quite a hack
409 to deal with the fact that Mutt runs "push" commands in reverse order, so
410 all of a sudden, things become very complicated when mixing with "real"
413 Hence we keep two sets of commands, and one set of pushes. Commands are
414 added to the first until a push is added, after which commands are added to
415 the second set of commands.
417 On flush(), the first set is printed, followed by the pushes in reverse,
418 and then the second set is printed. All 3 sets are then cleared.
421 def __init__(self, out_f=sys.stdout, *, debug=False):
422 self._cmd1, self._push, self._cmd2 = [], [], []
434 s = s.replace('"', '"')
437 self._push.insert(0, s)
441 "\n".join(self._cmd1 + self._push + self._cmd2), file=self._out_f
443 self._cmd1, self._push, self._cmd2 = [], [], []
445 def debugprint(self, s, **kwargs):
447 print(s, file=sys.stderr, **kwargs)
455 debug_commands=False,
457 temppath = temppath or pathlib.Path(
458 tempfile.mkstemp(prefix="muttmdwn-", dir=tempdir)[1]
460 cmds = MuttCommands(out_f, debug=debug_commands)
462 editor = f"{' '.join(sys.argv)} massage --write-commands-to {temppath}"
464 cmds.cmd('set my_editor="$editor"')
465 cmds.cmd('set my_edit_headers="$edit_headers"')
466 cmds.cmd(f'set editor="{editor}"')
467 cmds.cmd("unset edit_headers")
468 cmds.cmd(f"set my_mdwn_postprocess_cmd_file={temppath}")
469 cmds.push("<first-entry><edit-file>")
480 converter=convert_markdown_to_html,
483 debug_commands=False,
486 # Here's the big picture: we're being invoked as the editor on the email
487 # draft, and whatever commands we write to the file given as cmdpath will
488 # be run by the second source command in the macro definition.
490 # Let's start by cleaning up what the setup did (see above), i.e. we
491 # restore the $editor and $edit_headers variables, and also unset the
492 # variable used to identify the command file we're currently writing
494 cmds = MuttCommands(cmd_f, debug=debug_commands)
495 cmds.cmd('set editor="$my_editor"')
496 cmds.cmd('set edit_headers="$my_edit_headers"')
497 cmds.cmd("unset my_editor")
498 cmds.cmd("unset my_edit_headers")
500 # let's flush those commands, as there'll be a lot of pushes from now
501 # on, which need to be run in reverse order
504 extensions = extensions.split(",") if extensions else []
510 extensions=extensions,
513 mimetree = MIMETreeDFWalker(debug=debug_walk)
515 def visitor_fn(item, stack, *, debugprint=None):
517 Visitor function called for every node (part) of the MIME tree,
518 depth-first, and responsible for telling NeoMutt how to assemble
521 KILL_LINE = r"\Ca\Ck"
523 if isinstance(item, Part):
524 # We've hit a leaf-node, i.e. an alternative or a related part
525 # with actual content.
529 # The original source already exists in the NeoMutt tree, but
530 # the underlying file may have been modified, so we need to
531 # update the encoding, but that's it:
532 cmds.push("<update-encoding>")
534 # … whereas all other parts need to be added, and they're all
535 # considered to be temporary and inline:
536 cmds.push(f"<attach-file>{item.path}<enter>")
537 cmds.push("<toggle-unlink><toggle-disposition>")
539 # If the item (including the original) comes with additional
540 # information, then we might just as well update the NeoMutt
543 cmds.push(f"<edit-content-id>{KILL_LINE}{item.cid}<enter>")
545 elif isinstance(item, Multipart):
546 # This node has children, but we already visited them (see
547 # above), and so they have been tagged in NeoMutt's compose
548 # window. Now it's just a matter of telling NeoMutt to do the
549 # appropriate grouping:
550 if item.subtype == "alternative":
551 cmds.push("<group-alternatives>")
552 elif item.subtype in ("relative", "related"):
553 cmds.push("<group-related>")
554 elif item.subtype == "multilingual":
555 cmds.push("<group-multilingual>")
558 # We should never get here
559 assert not "is valid part"
561 # If the item has a description, we might just as well add it
563 cmds.push(f"<edit-description>{KILL_LINE}{item.desc}<enter>")
565 # Finally, if we're at non-root level, tag the new container,
566 # as it might itself be part of a container, to be processed
569 cmds.push("<tag-entry>")
574 # Let's walk the tree and visit every node with our fancy visitor
576 mimetree.walk(tree, visitor_fn=visitor_fn)
579 cmds.push("<send-message>")
581 # Finally, cleanup. Since we're responsible for removing the temporary
582 # file, how's this for a little hack?
584 filename = cmd_f.name
585 except AttributeError:
586 filename = "pytest_internal_file"
587 cmds.cmd(f"source 'rm -f {filename}|'")
588 cmds.cmd("unset my_mdwn_postprocess_cmd_file")
592 # [ CLI ENTRY ] ###############################################################
594 if __name__ == "__main__":
595 args = parse_cli_args()
597 if args.mode is None:
599 tempdir=args.tempdir,
600 debug_commands=args.debug_commands,
603 elif args.mode == "massage":
604 with open(args.MAILDRAFT, "r") as draft_f, open(
609 pathlib.Path(args.MAILDRAFT),
611 extensions=args.extensions,
612 cssfile=args.css_file,
613 only_build=args.only_build,
614 tempdir=args.tempdir,
615 debug_commands=args.debug_commands,
616 debug_walk=args.debug_walk,
620 # [ TESTS ] ###################################################################
624 from io import StringIO
629 return "CONSTANT STRING 1"
633 return "CONSTANT STRING 2"
635 # NOTE: tests using the capsys fixture must specify sys.stdout to the
636 # functions they call, else old stdout is used and not captured
638 def test_MuttCommands_cmd(self, const1, const2, capsys):
639 "Assert order of commands"
640 cmds = MuttCommands(out_f=sys.stdout)
644 captured = capsys.readouterr()
645 assert captured.out == "\n".join((const1, const2, ""))
647 def test_MuttCommands_push(self, const1, const2, capsys):
648 "Assert reverse order of pushes"
649 cmds = MuttCommands(out_f=sys.stdout)
653 captured = capsys.readouterr()
656 == ('"\npush "'.join(("", const2, const1, "")))[2:-6]
659 def test_MuttCommands_cmd_push_mixed(self, const1, const2, capsys):
660 "Assert reverse order of pushes"
661 cmds = MuttCommands(out_f=sys.stdout)
662 lines = ["000", "001", "010", "011", "100", "101", "110", "111"]
664 cmds.cmd(lines[4 * i + 0])
665 cmds.cmd(lines[4 * i + 1])
666 cmds.push(lines[4 * i + 2])
667 cmds.push(lines[4 * i + 3])
670 captured = capsys.readouterr()
671 lines_out = captured.out.splitlines()
672 assert lines[0] in lines_out[0]
673 assert lines[1] in lines_out[1]
674 assert lines[7] in lines_out[2]
675 assert lines[6] in lines_out[3]
676 assert lines[3] in lines_out[4]
677 assert lines[2] in lines_out[5]
678 assert lines[4] in lines_out[6]
679 assert lines[5] in lines_out[7]
682 def basic_mime_tree(self):
696 Part("text", "html", "part.html", desc="HTML"),
701 "text", "png", "logo.png", cid="logo.png", desc="Logo"
707 def test_MIMETreeDFWalker_depth_first_walk(self, basic_mime_tree):
708 mimetree = MIMETreeDFWalker()
712 def visitor_fn(item, stack, debugprint):
713 items.append((item, len(stack)))
715 mimetree.walk(basic_mime_tree, visitor_fn=visitor_fn)
716 assert len(items) == 5
717 assert items[0][0].subtype == "plain"
718 assert items[0][1] == 2
719 assert items[1][0].subtype == "html"
720 assert items[1][1] == 2
721 assert items[2][0].subtype == "alternative"
722 assert items[2][1] == 1
723 assert items[3][0].subtype == "png"
724 assert items[3][1] == 1
725 assert items[4][0].subtype == "relative"
726 assert items[4][1] == 0
728 def test_MIMETreeDFWalker_list_to_mixed(self, const1):
729 mimetree = MIMETreeDFWalker()
732 def visitor_fn(item, stack, debugprint):
735 p = Part("text", "plain", const1)
736 mimetree.walk([p], visitor_fn=visitor_fn)
737 assert items[-1].subtype == "plain"
738 mimetree.walk([p, p], visitor_fn=visitor_fn)
739 assert items[-1].subtype == "mixed"
741 def test_MIMETreeDFWalker_visitor_in_constructor(
742 self, basic_mime_tree
746 def visitor_fn(item, stack, debugprint):
749 mimetree = MIMETreeDFWalker(visitor_fn=visitor_fn)
750 mimetree.walk(basic_mime_tree)
751 assert len(items) == 5
754 def string_io(self, const1, text=None):
755 return StringIO(text or const1)
757 def test_do_massage_basic(self, const1, string_io, capsys):
758 def converter(drafttext, draftpath, cssfile, extensions, tempdir):
759 return Part("text", "plain", draftpath, orig=True)
768 captured = capsys.readouterr()
769 lines = captured.out.splitlines()
770 assert '="$my_editor"' in lines.pop(0)
771 assert '="$my_edit_headers"' in lines.pop(0)
772 assert "unset my_editor" == lines.pop(0)
773 assert "unset my_edit_headers" == lines.pop(0)
774 assert "send-message" in lines.pop(0)
775 assert "update-encoding" in lines.pop(0)
776 assert "source 'rm -f " in lines.pop(0)
777 assert "unset my_mdwn_postprocess_cmd_file" == lines.pop(0)
779 def test_do_massage_fulltree(
780 self, string_io, const1, basic_mime_tree, capsys
782 def converter(drafttext, draftpath, cssfile, extensions, tempdir):
783 return basic_mime_tree
792 captured = capsys.readouterr()
793 lines = captured.out.splitlines()[4:]
794 assert "send-message" in lines.pop(0)
795 assert "Related" in lines.pop(0)
796 assert "group-related" in lines.pop(0)
797 assert "tag-entry" in lines.pop(0)
798 assert "Logo" in lines.pop(0)
799 assert "content-id" in lines.pop(0)
800 assert "toggle-unlink" in lines.pop(0)
801 assert "logo.png" in lines.pop(0)
802 assert "tag-entry" in lines.pop(0)
803 assert "Alternative" in lines.pop(0)
804 assert "group-alternatives" in lines.pop(0)
805 assert "tag-entry" in lines.pop(0)
806 assert "HTML" in lines.pop(0)
807 assert "toggle-unlink" in lines.pop(0)
808 assert "part.html" in lines.pop(0)
809 assert "tag-entry" in lines.pop(0)
810 assert "Plain" in lines.pop(0)
811 assert "update-encoding" in lines.pop(0)
812 assert len(lines) == 2
815 def fake_filewriter(self):
820 def __call__(self, path, content, mode="w", **kwargs):
821 self._writes.append((path, content))
823 def pop(self, index=-1):
824 return self._writes.pop(index)
829 def markdown_non_converter(self, const1, const2):
830 return lambda s, text: f"{const1}{text}{const2}"
832 def test_converter_tree_basic(
833 self, const1, const2, fake_filewriter, markdown_non_converter
835 path = pathlib.Path(const2)
836 tree = convert_markdown_to_html(
837 const1, path, filewriter_fn=fake_filewriter
840 assert tree.subtype == "alternative"
841 assert len(tree.children) == 2
842 assert tree.children[0].subtype == "plain"
843 assert tree.children[0].path == path
844 assert tree.children[0].orig
845 assert tree.children[1].subtype == "html"
846 assert tree.children[1].path == path.with_suffix(".html")
848 def test_converter_writes(
854 markdown_non_converter,
856 path = pathlib.Path(const2)
858 with monkeypatch.context() as m:
859 m.setattr(markdown.Markdown, "convert", markdown_non_converter)
860 convert_markdown_to_html(
861 const1, path, filewriter_fn=fake_filewriter
864 assert (path, const1) == fake_filewriter.pop(0)
866 path.with_suffix(".html"),
867 markdown_non_converter(None, const1),
868 ) == fake_filewriter.pop(0)
870 def test_markdown_inline_image_processor(self):
871 imgpath1 = "file:/path/to/image.png"
872 imgpath2 = "file:///path/to/image.png?url=params"
873 imgpath3 = "/path/to/image.png"
874 text = f"""![inline local image]({imgpath1})
876 with newline]({imgpath2})
877 ![image local path]({imgpath3})"""
878 text, html, images = markdown_with_inline_image_support(text)
880 # local paths have been normalised to URLs:
881 imgpath3 = f"file://{imgpath3}"
883 assert 'src="cid:' in html
884 assert "](cid:" in text
885 assert len(images) == 3
886 assert imgpath1 in images
887 assert imgpath2 in images
888 assert imgpath3 in images
889 assert images[imgpath1].cid != images[imgpath2].cid
890 assert images[imgpath1].cid != images[imgpath3].cid
891 assert images[imgpath2].cid != images[imgpath3].cid
893 def test_markdown_inline_image_processor_title_to_desc(self, const1):
894 imgpath = "file:///path/to/image.png"
895 text = f'![inline local image]({imgpath} "{const1}")'
896 text, html, images = markdown_with_inline_image_support(text)
897 assert images[imgpath].desc == const1
899 def test_markdown_inline_image_processor_alt_to_desc(self, const1):
900 imgpath = "file:///path/to/image.png"
901 text = f"![{const1}]({imgpath})"
902 text, html, images = markdown_with_inline_image_support(text)
903 assert images[imgpath].desc == const1
905 def test_markdown_inline_image_processor_title_over_alt_desc(
908 imgpath = "file:///path/to/image.png"
909 text = f'![{const1}]({imgpath} "{const2}")'
910 text, html, images = markdown_with_inline_image_support(text)
911 assert images[imgpath].desc == const2
913 def test_markdown_inline_image_not_external(self):
914 imgpath = "https://path/to/image.png"
915 text = f"![inline image]({imgpath})"
916 text, html, images = markdown_with_inline_image_support(text)
918 assert 'src="cid:' not in html
919 assert "](cid:" not in text
920 assert len(images) == 0
922 def test_markdown_inline_image_local_file(self):
923 imgpath = "/path/to/image.png"
924 text = f"![inline image]({imgpath})"
925 text, html, images = markdown_with_inline_image_support(text)
927 for k, v in images.items():
928 assert k == f"file://{imgpath}"
934 "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAE"
935 "AAAABCAAAAAA6fptVAAAACklEQVQI12P4DwABAQEAG7buVgAA"
938 def test_markdown_inline_image_processor_base64(self, test_png):
939 text = f"![1px white inlined]({test_png})"
940 text, html, images = markdown_with_inline_image_support(text)
942 assert 'src="cid:' in html
943 assert "](cid:" in text
944 assert len(images) == 1
945 assert test_png in images
947 def test_converter_tree_inline_image_base64(
948 self, test_png, const1, fake_filewriter
950 text = f"![inline base64 image]({test_png})"
951 path = pathlib.Path(const1)
952 tree = convert_markdown_to_html(
953 text, path, filewriter_fn=fake_filewriter
956 assert tree.subtype == "relative"
957 assert tree.children[1].subtype == "png"
958 written = fake_filewriter.pop()
959 assert tree.children[1].path == written[0]
960 assert written[1] == request.urlopen(test_png).read()
962 def test_converter_tree_inline_image_cid(
963 self, const1, fake_filewriter
965 text = f"![inline base64 image](cid:{const1})"
966 path = pathlib.Path(const1)
967 tree = convert_markdown_to_html(
970 filewriter_fn=fake_filewriter,
971 related_to_html_only=False,
973 assert len(tree.children) == 2
974 assert tree.children[0].cid != const1
975 assert tree.children[0].type != "image"
976 assert tree.children[1].cid != const1
977 assert tree.children[1].type != "image"
979 def test_inline_image_collection(
980 self, test_png, const1, const2, fake_filewriter
982 test_images = {test_png: InlineImageInfo(cid=const1, desc=const2)}
983 relparts = collect_inline_images(
984 test_images, filewriter_fn=fake_filewriter
987 written = fake_filewriter.pop()
988 assert b"PNG" in written[1]
990 assert relparts[0].subtype == "png"
991 assert relparts[0].path == written[0]
992 assert relparts[0].cid == const1
993 assert relparts[0].desc.endswith(const2)
995 def test_apply_stylesheet(self):
997 html = "<p>Hello, world!</p>"
998 css = "p { color:red }"
999 out = apply_styling(html, css)
1000 assert 'p style="color' in out
1002 def test_apply_stylesheet_pygments(self):
1005 f'<div class="{_CODEHILITE_CLASS}">'
1006 "<pre>def foo():\n return</pre></div>"
1008 out = apply_styling(html, _PYGMENTS_CSS)
1009 assert f'{_CODEHILITE_CLASS}" style="' in out