]> git.madduck.net Git - etc/mutt.git/blob - .mutt/markdown2html

madduck's git repository

Every one of the projects in this repository is available at the canonical URL git://git.madduck.net/madduck/pub/<projectpath> — see each project's metadata for the exact URL.

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.

SSH access, as well as push access can be individually arranged.

If you use my repositories frequently, consider adding the following snippet to ~/.gitconfig and using the third clone URL listed for each project:

[url "git://git.madduck.net/madduck/"]
  insteadOf = madduck:

md2html: reformat quotes
[etc/mutt.git] / .mutt / markdown2html
1 #!/usr/bin/python3
2 #
3 # markdown2html.py — simple Markdown-to-HTML converter for use with Mutt
4 #
5 # Mutt recently learnt [how to compose `multipart/alternative`
6 # emails][1]. This script assumes a message has been composed using Markdown
7 # (with a lot of pandoc extensions enabled), and translates it to `text/html`
8 # for Mutt to tie into such a `multipart/alternative` message.
9 #
10 # [1]: https://gitlab.com/muttmua/mutt/commit/0e566a03725b4ad789aa6ac1d17cdf7bf4e7e354)
11 #
12 # Configuration:
13 #   muttrc:
14 #     set send_multipart_alternative=yes
15 #     set send_multipart_alternative_filter=/path/to/markdown2html.py
16 #
17 # Optionally, Custom CSS styles will be read from `~/.mutt/markdown2html.css`,
18 # if present.
19 #
20 # Requirements:
21 #   - python3
22 #   - PyPandoc (and pandoc installed, or downloaded)
23 #   - Pynliner
24 #
25 # Optional:
26 #   - Pygments, if installed, then syntax highlighting is enabled
27 #
28 # Latest version:
29 #   https://git.madduck.net/etc/mutt.git/blob_plain/HEAD:/.mutt/markdown2html
30 #
31 # Copyright © 2019 martin f. krafft <madduck@madduck.net>
32 # Released under the GPL-2+ licence, just like Mutt itself.
33 #
34
35 import pypandoc
36 import pynliner
37 import re
38 import os
39 import sys
40
41 try:
42     from pygments.formatters import get_formatter_by_name
43     formatter = get_formatter_by_name('html', style='default')
44     DEFAULT_CSS = formatter.get_style_defs('.sourceCode')
45
46 except ImportError:
47     DEFAULT_CSS = ""
48
49
50 DEFAULT_CSS += '''
51 .quote, blockquote {
52     padding: 0 0.5em;
53     margin: 0;
54     font-style: italic;
55     border-left: 2px solid #666;
56     color: #666;
57     font-size: 80%;
58 }
59 .quotelead {
60     margin-bottom: -1em;
61     font-size: 80%;
62 }
63 .quotechar { display: none; }
64 .footnote-ref, .footnote-back { text-decoration: none;}
65 .signature {
66     color: #999;
67     font-family: monospace;
68     white-space: pre;
69     margin: 1em 0 0 0;
70     font-size: 80%;
71 }
72 table, th, td {
73     border-collapse: collapse;
74     border: 1px solid #999;
75 }
76 th, td { padding: 0.5em; }
77 .header {
78     background: #eee;
79 }
80 .even { background: #eee; }
81 '''
82
83 STYLESHEET = os.path.join(os.path.expanduser('~/.mutt'),
84                           'markdown2html.css')
85 if os.path.exists(STYLESHEET):
86     DEFAULT_CSS += open(STYLESHEET).read()
87
88 HTML_DOCUMENT = '''<!DOCTYPE html>
89 <html><head>
90 <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
91 <meta charset="utf-8"/>
92 <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes"/>
93 <title>HTML E-Mail</title>
94 </head><body class="email">
95 {htmlbody}
96 </body></html>'''
97
98
99 SIGNATURE_HTML = \
100         '<div class="signature"><span class="leader">-- </span>{sig}</div>'
101
102
103 def _preprocess_markdown(mdwn):
104     '''
105     Preprocess Markdown for handling by the converter.
106     '''
107     # convert hard line breaks within paragraphs to 2 trailing spaces, which
108     # is the markdown way of representing hard line breaks. Note how the
109     # regexp will not match between paragraphs.
110     ret = re.sub(r'(\S)\n(\s*\S)', r'\g<1>  \n\g<2>', mdwn, flags=re.MULTILINE)
111
112     # Clients like Thunderbird need the leading '>' to be able to properly
113     # create nested quotes, so we duplicate the symbol, the first instance
114     # will tell pandoc to create a blockquote, while the second instance will
115     # be a <span> containing the character, along with a class that causes CSS
116     # to actually hide it from display. However, this does not work with the
117     # text-mode HTML2text converters, and so it's left commented for now.
118     #ret = re.sub(r'\n>', r'  \n>[>]{.quotechar}', ret, flags=re.MULTILINE)
119
120     return ret
121
122
123 def _identify_quotes_for_later(mdwn):
124     '''
125     Email quoting such as:
126
127     ```
128     On 1970-01-01, you said:
129     > The Flat Earth Society has members all around the globe.
130     ```
131
132     isn't really properly handled by Markdown, so let's do our best to
133     identify the individual elements, and mark them, using a syntax similar to
134     what pandoc uses already in some cases. As pandoc won't actually use these
135     data (yet?), we call `self._reformat_quotes` later to use these markers
136     to slap the appropriate classes on the HTML tags.
137     '''
138
139     def generate_lines_with_context(mdwn):
140         '''
141         Iterates the input string line-wise, returning a triplet of
142         previous, current, and next line, the first and last of which
143         will be None on the first and last line of the input data
144         respectively.
145         '''
146         prev = cur = nxt = None
147         lines = iter(mdwn.splitlines())
148         cur = next(lines)
149         for nxt in lines:
150             yield prev, cur, nxt
151             prev = cur
152             cur = nxt
153         yield prev, cur, None
154
155     ret = []
156     for prev, cur, nxt in generate_lines_with_context(mdwn):
157
158         # The lead-in to a quote is a single line immediately preceding the
159         # quote, and ending with ':'. Note that there could be multiple of
160         # these:
161         if re.match(r'^.+:\s*$', cur) and nxt.startswith('>'):
162             ret.append(f'{{.quotelead}}{cur.strip()}')
163             # pandoc needs an empty line before the blockquote, so
164             # we enter one for the purpose of HTML rendition:
165             ret.append('')
166             continue
167
168         # The first blockquote after such a lead-in gets marked as the
169         # "initial" quote:
170         elif prev and re.match(r'^.+:\s*$', prev) and cur.startswith('>'):
171             ret.append(re.sub(r'^(\s*>\s*)+(.+)',
172                               r'\g<1>{.quoteinitial}\g<2>',
173                               cur, flags=re.MULTILINE))
174
175         # All other occurrences of blockquotes get the "subsequent" marker:
176         elif cur.startswith('>') and prev and not prev.startswith('>'):
177             ret.append(re.sub(r'^((?:\s*>\s*)+)(.+)',
178                               r'\g<1>{.quotesubsequent}\g<2>',
179                               cur, flags=re.MULTILINE))
180
181         else: # pass through everything else.
182             ret.append(cur)
183
184     return '\n'.join(ret)
185
186
187 def _reformat_quotes(html):
188     '''
189     Earlier in the pipeline, we marked email quoting, using markers, which we
190     now need to turn into HTML classes, so that we can use CSS to style them.
191     '''
192     ret = html.replace('<p>{.quotelead}', '<p class="quotelead">')
193     ret = re.sub(r'<blockquote>\n((?:<blockquote>\n)*)<p>(?:\{\.quote(\w+)\})',
194                  r'<blockquote class="quote \g<2>">\n\g<1><p>', ret, flags=re.MULTILINE)
195     return ret
196
197
198
199 def _convert_with_pandoc(mdwn, inputfmt='markdown', outputfmt='html5',
200                          ext_enabled=None, ext_disabled=None,
201                          standalone=True, title="HTML E-Mail"):
202     '''
203     Invoke pandoc to do the actual conversion of Markdown to HTML5.
204     '''
205     if not ext_enabled:
206         ext_enabled = [ 'backtick_code_blocks',
207                        'line_blocks',
208                        'fancy_lists',
209                        'startnum',
210                        'definition_lists',
211                        'example_lists',
212                        'table_captions',
213                        'simple_tables',
214                        'multiline_tables',
215                        'grid_tables',
216                        'pipe_tables',
217                        'all_symbols_escapable',
218                        'intraword_underscores',
219                        'strikeout',
220                        'superscript',
221                        'subscript',
222                        'fenced_divs',
223                        'bracketed_spans',
224                        'footnotes',
225                        'inline_notes',
226                        'emoji',
227                        'tex_math_double_backslash',
228                        'autolink_bare_uris'
229                       ]
230     if not ext_disabled:
231         ext_disabled = [ 'tex_math_single_backslash',
232                          'tex_math_dollars',
233                          'smart',
234                          'raw_html'
235                        ]
236
237     enabled = '+'.join(ext_enabled)
238     disabled = '-'.join(ext_disabled)
239     inputfmt = f'{inputfmt}+{enabled}-{disabled}'
240
241     args = []
242     if standalone:
243         args.append('--standalone')
244     if title:
245         args.append(f'--metadata=pagetitle:"{title}"')
246
247     return pypandoc.convert_text(mdwn, format=inputfmt, to=outputfmt,
248                                  extra_args=args)
249
250
251 def _apply_styling(html):
252     '''
253     Inline all styles defined and used into the individual HTML tags.
254     '''
255     return pynliner.Pynliner().from_string(html).with_cssString(DEFAULT_CSS).run()
256
257
258 def _postprocess_html(html):
259     '''
260     Postprocess the generated and styled HTML.
261     '''
262     return html
263
264
265 def convert_markdown_to_html(mdwn):
266     '''
267     Converts the input Markdown to HTML, handling separately the body, as well
268     as an optional signature.
269     '''
270     parts = re.split(r'^-- $', mdwn, 1, flags=re.MULTILINE)
271     body = parts[0]
272     if len(parts) == 2:
273         sig = parts[1]
274     else:
275         sig = None
276
277     html=''
278     if body:
279         body = _preprocess_markdown(body)
280         body = _identify_quotes_for_later(body)
281         html = _convert_with_pandoc(body, standalone=False)
282         html = _reformat_quotes(html)
283
284     if sig:
285         sig = _preprocess_markdown(sig)
286         html += SIGNATURE_HTML.format(sig='<br/>'.join(sig.splitlines()))
287
288     html = HTML_DOCUMENT.format(htmlbody=html)
289     html = _apply_styling(html)
290     html = _postprocess_html(html)
291
292     return html
293
294
295 def main():
296     '''
297     Convert text on stdin to HTML, and print it to stdout, like mutt would
298     expect.
299     '''
300     html = convert_markdown_to_html(sys.stdin.read())
301     if html:
302         # mutt expects the content type in the first line, so:
303         print(f'text/html\n\n{html}')
304
305
306 if __name__ == '__main__':
307     main()