Qiita Teams that are logged in
You are not logged in to any team

Log in to Qiita Team
Community
OrganizationAdvent CalendarQiitadon (β)
Service
Qiita JobsQiita ZineQiita Blog
Help us understand the problem. What is going on with this article?

iOSでmd2pdf

はじめに

自己紹介

H1ronoです。趣味でiOSでプログラミングをしています。

この記事でやること

Pythonista3ショートカットを使用し、iOSで.mdから.pdfを生成する方法を紹介します。

※筆者の環境はiPhone 8, iOS 13.4.1(OSはこの記事を書いている時点で最新)です。

目次

大まかな目次です。

準備

1. Pythonista

1-1. モジュール

マークダウンからHTMLへの変換にmistuneモジュール、コードハイライト用にpygmentsを使用します。

pygmentsは最初からPythonistaにインストールされています。Pythonistaに標準モジュール以外でインストールされているモジュールのリストはこちら(Pythonista Modules)にあります。

StaSh(Pythonista用のサードパーティシェル)がまだ入っていない方は、こちら(Pythonista 3 で双方向のファイル転送 - Qiita)などを参考にインストールして下さい。
StaShが入ったら、StaShを起動して以下のコマンドを実行します。

pip install mistune

これでmistuneのインストールは完了です。モジュールの準備は以上になります。

1-2. ファイル

Pythonista上で新しくフォルダを作成します。そのフォルダ上には以下のファイルを作成します。

main.py
sample.md
template.html

各ファイルの内容は以下になります(コードの解説は後でします)。

main.py
import re
import mistune
from pygments import highlight
from pygments.lexers import get_lexer_by_name
from pygments.formatters import HtmlFormatter
import shortcuts


class MyRenderer(mistune.Renderer):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.formatter = HtmlFormatter(cssclass='hll')

    def block_code(self, code, lang=None):
        try:
            l, _, filename = lang.partition(':')
        except AttributeError:
            l, filename = '', ''

        try:
            lexer = get_lexer_by_name(l)
        except:
            res = f'\n<div class="hll"><pre>{code}</pre></div>\n'
        else:
            res = highlight(code, lexer, self.formatter)

        if filename:
            new = f'<pre><span class="file">{filename}</span>\n\n'
        else:
            new = '<pre>\n\n'
        res = res.replace('<pre>', new, 1)
        return res


def _md2html():
    renderer = MyRenderer(hard_wrap=True)
    markdown = mistune.Markdown(renderer=renderer)
    def main(source):
        content = markdown(source)
        return content
    return main


md2html = _md2html()


def _render_details():
    flag = re.DOTALL
    detail_ptn = re.compile('<details>.*?</details>', flag)
    summary_ptn = re.compile('<summary>.*?</summary>', flag)
    div_ptn = re.compile('<div>.*?</div>', flag)
    innertext_ptn = re.compile('>.*?</', flag)
    def main(html):
        details = detail_ptn.findall(html)
        result = html
        for dt in details:
            summaries = summary_ptn.findall(dt)
            for smry in summaries:
                ptext = innertext_ptn.findall(smry)[0][1:-2]
                ntext = md2html(ptext)[3:-5]
                result = result.replace(ptext, ntext, 1)
            divs = div_ptn.findall(dt)
            for dv in divs:
                ptext = innertext_ptn.findall(dv)[0][1:-2]
                ntext = md2html(ptext)
                result = result.replace(ptext, ntext, 1)
        return result
    return main


render_details = _render_details()


def _md2html_main():
    with open('template.html', 'r', encoding='utf-8') as f:
        template = f.read()
    def main(source):
        content = md2html(source)
        content = render_details(content)
        return template.replace('{{CONTENT}}', content)
    return main


md2html_main = _md2html_main()
del _md2html, _render_details, _md2html_main


if __name__ == '__main__':
    with open('sample.md', 'r', encoding='utf-8') as f:
        source = f.read()
    html = md2html_main(source)
    shortcuts.open_shortcuts_app('html2pdf', html)
template.html
<!doctype html>
<html>
<head>
 <meta charset="utf-8">
 <title>Title</title>
 <style>
  html {
    background-color: white;
    font-family: "Avenir Next", sans-serif;
    font-size: 15px;
  }
  body {
    padding: 10px;
    margin: 10px;
  }
  h1 { font-size: 29px; }
  h2 { font-size: 24px; }
  h3 { font-size: 21px; }
  h4 { font-size: 19px; }
  h5 { font-size: 18px; }
  h6 { font-size: 17px; }
  blockquote {
    color: gray;
    border-width: 3px;
    border-style: none none none solid;
    border-color: gray;
    padding: 10px;
    margin: 10px;
  }
  table {
    padding: 0px;
    border: 1px solid black;
  }
  th, td{
    border: 0.5px solid gray;
  }
  code, pre {
    font-family: "Menlo", monospace;
  }
  code {
    padding: 0px 5px;
  }
  pre {
    padding: 3px 10px 20px 10px;
    overflow-wrap: break-word;
  }
  .file {
    background-color: gray;
    color: white;
    padding: 3px;
  }

  /* cool-glow style */
  .hll { background-color: #0A0D2A; color: #E0E0E0; }

  .gd { color: #A00000; } /* Generic.Deleted */
  .ge { font-style: italic; } /* Generic.Emph */
  .gr { color: #E5ABB3; } /* Generic.Error */
  .gh { color: #E0E0E0; font-weight: bold; } /* Generic.Heading */
  .gi { color: #E0E0E0; } /* Generic.Inserted */
  .go { color: #E0E0E0; } /* Generic.Output */
  .gp { color: #E0E0E0; font-weight: bold; } /* Generic.Prompt */
  .gs { font-weight: bold; } /* Generic.Strong */
  .gu { color: #E0E0E0; font-weight: bold; } /* Generic.Subheading */
  .gt { color: #E5ABB3; } /* Generic.Traceback */

  .o { color: #E0E0E0; } /* Operator */
  .ow { color: #40F1E2; } /* Operator.Word */

  .k { color: #40F1E2; } /* Keyword */
  .kc { color: #40F1E2; } /* Keyword.Constant */
  .kd { color: #40F1E2; } /* Keyword.Declaration */
  .kn { color: #40F1E2; } /* Keyword.Namespace */
  .kp { color: #40F1E2; } /* Keyword.Pseudo */
  .kr { color: #40F1E2; } /* Keyword.Reserved */
  .kt { color: #40F1E2; } /* Keyword.Type */

  .m { color: #F8FBB1; } /* Literal.Number */
  .mb { color: #F8FBB1; } /* Literal.Number.Bin */
  .mf { color: #F8FBB1; } /* Literal.Number.Float */
  .mh { color: #F8FBB1; } /* Literal.Number.Hex */
  .mi { color: #F8FBB1; } /* Literal.Number.Integer */
  .il { color: #F8FBB1; } /* Literal.Number.Integer.Long */
  .mo { color: #F8FBB1; } /* Literal.Number.Oct */

  .s { color: #A0FAA2; } /* Literal.String */
  .sa { color: #A0FAA2; } /* Literal.String.Affix */
  .sb { color: #A0FAA2; } /* Literal.String.Backtick */
  .sc { color: #A0FAA2; } /* Literal.String.Char */
  .dl { color: #A0FAA2; } /* Literal.String.Delimiter */
  .s2 { color: #A0FAA2; } /* Literal.String.Double */
  .se { color: #A0FAA2; } /* Literal.String.Escape */
  .sh { color: #A0FAA2; } /* Literal.String.Heredoc */
  .si { color: #A0FAA2; } /* Literal.String.Interpol */
  .sr { color: #A0FAA2; } /* Literal.String.Regex */
  .s1 { color: #A0FAA2; } /* Literal.String.Single */
  .ss { color: #A0FAA2; } /* Literal.String.Symbol */
  .sx { color: #A0FAA2; } /* Literal.String.Other */
  .sd { color: #A0FAA2; font-style: italic; } /* Literal.String.Doc */

  .nv { color: #E0E0E0; } /* Name.Variable */
  .vc { color: #6DB6F2; } /* Name.Variable.Class */
  .vg { color: #E0E0E0; } /* Name.Variable.Global */
  .vi { color: #E0E0E0; } /* Name.Variable.Instance */
  .vm { color: #E0E0E0; } /* Name.Variable.Magic */
  .na { color: #E0E0E0; } /* Name.Attribute */
  .nb { color: #40F1E2; } /* Name.Builtin */
  .bp { color: #40F1E2; } /* Name.Builtin.Pseudo */
  .nf { color: #6DB6F2; } /* Name.Function */
  .fm { color: #6DB6F2; } /* Name.Function.Magic */
  .nc { color: #6DB6F2; } /* Name.Class */
  .nn { color: #C29AD3; } /* Name.Namespace */
  .no { color: #E0E0E0; } /* Name.Constant */
  .nd { color: #C1DBF2; } /* Name.Decorator */
  .ni { color: #E0E0E0; font-weight: bold; } /* Name.Entity */
  .ne { color: #E5ABB3; } /* Name.Exception */
  .nl { color: #E0E0E0; } /* Name.Label */
  .nt { color: #40F1E2; } /* Name.Tag */

  .c { color: #AEAEAE; font-style: italic; } /* Comment */
  .ch { color: #AEAEAE; font-style: italic; } /* Comment.Hashbang */
  .cm { color: #AEAEAE; font-style: italic; } /* Comment.Multiline */
  .c1 { color: #AEAEAE; font-style: italic; } /* Comment.Single */
  .cs { color: #AEAEAE; font-style: italic; } /* Comment.Special */
  .cp { color: #AEAEAE; font-style: italic; } /* Comment.Preproc */
  .cpf { color: #AEAEAE; font-style: italic; } /* Comment.PreprocFile */

  .err { border: 1px solid #E5ABB3; } /* Error */
  .w { color: #bbbbbb; } /* Text.Whitespace */
  .p { color: #E0E0E0; } /* Punctuation */
 </style>
 <script type="text/javascript">
   function funcColoring() {
       let pElements = document.getElementsByClassName("p");
       for(let element of pElements) {
           if(element.textContent[0] != "(") { continue; }
           let prev = element.previousSibling;
           if(prev.className != "n") { continue; }
           prev.className = "nf";
       }
   }
 </script>
</head>
<body>
 <div class="md-content">
  {{CONTENT}}
 </div>
 <script type="text/javascript">
   funcColoring();
 </script>
</body>
</html>
sample.md
# H1
## H2
### H3
#### H4
##### H5
###### H6

* 1
 * 1.1
 * 1.2
* 2
 * 2.1
 * 2.1
* 3

*italic*, **bold**, ***italic-bold***, `inline code`, [qiita-link](https://qiita.com/)[^1]

\[^1]:注釈の内容です。

|左寄せ(1-1)|右寄せ(2-1)|中央寄せ(3-1)|
|:-----|-----:|:-----:|
|1-2|2-2|3-2|
|1-3|2-3|3-3|

この記事の冒頭より引用

> **この記事でやること**
> [Pythonista3](https://apps.apple.com/jp/app/pythonista-3/id1085978097)と[ショートカット](https://apps.apple.com/jp/app/ショートカット/id915249334)を使用し、iOSで`.md`から`.pdf`を作成しました。
> 詳細を共有することで、iOSの開発能力が伝わればなと思っています。

100回Hello, world!するプログラム

\```python
exec('print("Hello, world!");' * 100)
`\``

マークダウンのエスケープは全部無視してください。これでPythonista側の準備は完了です。

2. ショートカット

ショートカット側では、ショートカットを1つ作ります。内容は次の写真の通りです。

html2pdf-shortcut.jpeg

ショートカットの名前はhtml2pdfで変えないでください。理由は後で説明します。

解説

main.py

メインのスクリプトです。このコードを走らせると、sample.mdのマークダウンをHTMLに変換し、それをショートカットに渡してPDFを作成します。

グローバル変数を減らすためにクロージャを使用しています。del ...とあるのは、クロージャ生成に使用した関数はもう使わないためです。

Pythonistaにインストールされたmistuneのバージョンは0.8.4です。バージョン0.8.4のドキュメントはこちらになります。このドキュメントのRenderer節にpygmentsを使用したコードハイライトの例があります。MyRendererクラスはこのコードを参考にしました。Qiitaっぽくファイル名を示すことができるようにしました。

各関数の説明:

  • md2html(source): sourceをマークダウンからHTMLに変換して、HTMLを返します。確認した限りだと、detailsの内容がマークダウンのままだという問題があります。
  • render_details(html): html内のdetailsタグの内容のマークダウンをHTMLに置き換えて返します。
  • md2html_main(source): sourceをマークダウンからHTMLに変換します(detailsタグの内容も変換します)。変換したHTMLをtemplate.htmlに埋め込み、その結果を返します。

if __name__ == '__main__':節では以下の処理を行います:

  1. sample.mdの内容を読み取り、md2html_mainに渡します。
  2. 1の結果をhtml2pdfのショートカットに渡します。

2の処理を行うために、shortcuts.open_shortcuts_app関数を使用しています。この関数はx-callbackを使用してショートカットappのショートカットを呼び出します。shortcuts.open_shortcuts_app('html2pdf', html)だとwebbrowser.open(f'shortcuts://run-shortcut?name=html2pdf&input={urllib.parse.quote(html)}')と同値となります。ショートカットの名前を指定したのはPythonistaから呼び出すためです。詳しくはこちら(shortcutsモジュールのドキュメント)

template.html

テンプレートのHTMLです。スタイルの指定が主な役割となっています。

基本的なスタイルはQiita、コードハイライトはPythonistaのCool-glowというテーマを参考にしています。お好みにいじって下さい。フォントはPythonistaでファイル編集時に右下に出る+のボタンから選べるものは使用可能なようです。

pygmentsは変数名が関数呼び出しとして使われていても、それ以外のものと区別せずにハイライトします。funcColoringは、()が直後にある変数名(関数呼び出しとして使用されている変数名)について、そのCSSのクラスを変数名を示すものから関数名を示すものに変換する関数です。()で関数呼び出しを行う言語(Python, JavaScript, Swiftなど)に関しては、関数呼び出しをハイライトします。他の言語には対応していません。ご容赦ください。

{{CONTENT}}の部分にマークダウンから変換したHTMLが埋め込まれます。

sample.md

実験的にpdfに変換するマークダウンです。

html2pdf(ショートカット)

入力として受け取ったHTMLからPDFを作成するショートカットです。リッチテキストというのは、HTMLやマークダウンをレンダリングした後の文書を指すようです。

実行結果

result.jpeg

いい感じですね。

終わりに

iOSネイティブなやり方でmd2pdfを実装しました。最初はPythonistaのみでやる予定だったんですが、HTMLからPDFが難しそうだという結論に至ったため、このような方法を採用しました。苦肉の策だったんですが、使ってみてなかなか便利だと感じました。他にも使い道がありそうです。

もちろんですが、この記事はPythonistaで書きました。マークダウン用のハイライトがあって書きやすかったです。

スクショの加工にはTailorというappを使用しました。

ここおかしいんじゃない、ここわかんないとかあったら、なんでも言って下さい。

おまけ

共有シートからmd2pdf

main.pyなどが置いてあるディレクトリ上に_main.pyを作成します。_main.py内には以下の内容を記述します:

_main.py
import sys
import clipboard
from main import md2html_main


if __name__ == '__main__':
    try:
        md = sys.argv[1]
    except IndexError:
        with open('sample.md', 'r', encoding='utf-8') as f:
            md = f.read()
    html = md2html_main(md)
    print(html)
    clipboard.set(html)
    shortcuts.open_shortcuts_app()

次に、md2pdfという名前のショートカットを作成します。以下の内容を設定します:

md2pdf-shortcut.jpeg

Pythonistaの呼び出しに次のようなURLを使用しました。

pythonista://md2pdf/_main?action=run&argv=[URLエンコード済みのテキスト]&root=icloud

これは_main.pyiCloud/md2pdfのフォルダ内に作成した場合のものです。This iPhone/fooのフォルダ内に作成した場合のURLは次のようになります。

pythonista://foo/_main?action=run&argv=[URLエンコード済みのテキスト]

より詳しい情報はこちら(The Pythonista URL Scheme)

これで準備完了です。共有シートから先ほど作成したショートカットを呼び出すことで、共有対象のマークダウンをPDFに変換できます。

Pythonistaで編集中のマークダウンをクイックルック

ui.WebVieweditorを使うことで実装できます。quicklook_markdown.pymain.pyなどのディレクトリに追加します。

quicklook_markdown.py
import editor, console, ui
from main import md2html_main


def _quicklook():
    size = ui.get_window_size()
    frame = (0, 0, *size)
    v = ui.WebView(frame=frame)
    def main(html):
        v.load_html(html)
        v.present('fullscreen')
    return main


quicklook = _quicklook()
del _quicklook


if __name__ == '__main__':
    console.clear()
    md_source = editor.get_text()
    html = md2html_main(md_source)
    print(html)
    quicklook(html)

次に、このスクリプトを他のファイルの編集中に実行できるようにします。

  1. quicklook_markdown.pyが開かれている画面で右上にあるレンチマーク(スパナのボタン)を押します。
  2. "Reformat Code", "Python 2 to 3"などのオプションの中から"Shortcuts..."を選択します。
  3. "Shortcuts..."を選択した後に表示されるメニューの中から"Editor Action"を選択します。
  4. 今登録されているショートカットのリストが表示されます。最後尾にある"+"を押します。
  5. ショートカット追加のオプションが表示されます。"Run Script"は"quicklook_markdown.py"、"Arguments"は空、"Reset Environment"はオンになっている必要があります。それ以外はお好みに指定して下さい。

これで準備完了です。試しにsample.pyを開いてからレンチマークを押し、先ほど追加したショートカットを選択してみて下さい。マークダウンがリッチテキストで表示されるかと思います。編集中のマークダウンがiPhone内にあって、quicklook_markdown.pyがiCloud上にあっても、このショートカットは使用可能です。

より詳しい情報についてはこちら(App Extensions and Shortcuts)

template.htmlのCSSを外部ファイルに記述

main.pyなどがあるディレクトリでtemplate2.htmlstyle.cssmain2.pyを追加します。

template2.html
<!doctype html>
<html>
<head>
 <meta charset="utf-8">
 <title>Title</title>
 <style>
  /* CSS */
 </style>
 <script type="text/javascript">
   function funcColoring() {
       let pElements = document.getElementsByClassName("p");
       for(let element of pElements) {
           if(element.textContent[0] != "(") { continue; }
           let prev = element.previousSibling;
           if(prev.className != "n") { continue; }
           prev.className = "nf";
       }
   }
 </script>
</head>
<body>
 <div class="md-content">
  {{CONTENT}}
 </div>
 <script type="text/javascript">
   funcColoring();
 </script>
</body>
</html>
style.css
html {
  background-color: white;
  font-family: "Avenir Next", sans-serif;
  font-size: 20px;
}
body {
  padding: 10px;
  margin: 10px;
}
h1 { font-size: 34px; }
h2 { font-size: 29px; }
h3 { font-size: 26px; }
h4 { font-size: 24px; }
h5 { font-size: 23px; }
h6 { font-size: 22px; }
blockquote {
  color: gray;
  border-width: 3px;
  border-style: none none none solid;
  border-color: gray;
  padding: 10px;
  margin: 10px;
}
table {
  padding: 0px;
  border: 1px solid black;
}
th, td{
  border: 0.5px solid gray;
}
code, pre {
  font-family: "Menlo", monospace;
}
code {
  padding: 0px 5px;
}
pre {
  padding: 3px 10px 20px 10px;
  overflow-wrap: break-word;
}
.file {
  background-color: gray;
  padding: 3px;
}

/* cool-glow style */
.hll { background-color: #0A0D2A; color: #E0E0E0; }

.gd { color: #A00000; } /* Generic.Deleted */
.ge { font-style: italic; } /* Generic.Emph */
.gr { color: #E5ABB3; } /* Generic.Error */
.gh { color: #E0E0E0; font-weight: bold; } /* Generic.Heading */
.gi { color: #E0E0E0; } /* Generic.Inserted */
.go { color: #E0E0E0; } /* Generic.Output */
.gp { color: #E0E0E0; font-weight: bold; } /* Generic.Prompt */
.gs { font-weight: bold; } /* Generic.Strong */
.gu { color: #E0E0E0; font-weight: bold; } /* Generic.Subheading */
.gt { color: #E5ABB3; } /* Generic.Traceback */

.o { color: #E0E0E0; } /* Operator */
.ow { color: #40F1E2; } /* Operator.Word */

.k { color: #40F1E2; } /* Keyword */
.kc { color: #40F1E2; } /* Keyword.Constant */
.kd { color: #40F1E2; } /* Keyword.Declaration */
.kn { color: #40F1E2; } /* Keyword.Namespace */
.kp { color: #40F1E2; } /* Keyword.Pseudo */
.kr { color: #40F1E2; } /* Keyword.Reserved */
.kt { color: #40F1E2; } /* Keyword.Type */

.m { color: #F8FBB1; } /* Literal.Number */
.mb { color: #F8FBB1; } /* Literal.Number.Bin */
.mf { color: #F8FBB1; } /* Literal.Number.Float */
.mh { color: #F8FBB1; } /* Literal.Number.Hex */
.mi { color: #F8FBB1; } /* Literal.Number.Integer */
.il { color: #F8FBB1; } /* Literal.Number.Integer.Long */
.mo { color: #F8FBB1; } /* Literal.Number.Oct */

.s { color: #A0FAA2; } /* Literal.String */
.sa { color: #A0FAA2; } /* Literal.String.Affix */
.sb { color: #A0FAA2; } /* Literal.String.Backtick */
.sc { color: #A0FAA2; } /* Literal.String.Char */
.dl { color: #A0FAA2; } /* Literal.String.Delimiter */
.s2 { color: #A0FAA2; } /* Literal.String.Double */
.se { color: #A0FAA2; } /* Literal.String.Escape */
.sh { color: #A0FAA2; } /* Literal.String.Heredoc */
.si { color: #A0FAA2; } /* Literal.String.Interpol */
.sr { color: #A0FAA2; } /* Literal.String.Regex */
.s1 { color: #A0FAA2; } /* Literal.String.Single */
.ss { color: #A0FAA2; } /* Literal.String.Symbol */
.sx { color: #A0FAA2; } /* Literal.String.Other */
.sd { color: #A0FAA2; font-style: italic; } /* Literal.String.Doc */

.nv { color: #E0E0E0; } /* Name.Variable */
.vc { color: #6DB6F2; } /* Name.Variable.Class */
.vg { color: #E0E0E0; } /* Name.Variable.Global */
.vi { color: #E0E0E0; } /* Name.Variable.Instance */
.vm { color: #E0E0E0; } /* Name.Variable.Magic */
.na { color: #E0E0E0; } /* Name.Attribute */
.nb { color: #40F1E2; } /* Name.Builtin */
.bp { color: #40F1E2; } /* Name.Builtin.Pseudo */
.nf { color: #6DB6F2; } /* Name.Function */
.fm { color: #6DB6F2; } /* Name.Function.Magic */
.nc { color: #6DB6F2; } /* Name.Class */
.nn { color: #C29AD3; } /* Name.Namespace */
.no { color: #E0E0E0; } /* Name.Constant */
.nd { color: #C1DBF2; } /* Name.Decorator */
.ni { color: #E0E0E0; font-weight: bold; } /* Name.Entity */
.ne { color: #E5ABB3; } /* Name.Exception */
.nl { color: #E0E0E0; } /* Name.Label */
.nt { color: #40F1E2; } /* Name.Tag */

.c { color: #AEAEAE; font-style: italic; } /* Comment */
.ch { color: #AEAEAE; font-style: italic; } /* Comment.Hashbang */
.cm { color: #AEAEAE; font-style: italic; } /* Comment.Multiline */
.c1 { color: #AEAEAE; font-style: italic; } /* Comment.Single */
.cs { color: #AEAEAE; font-style: italic; } /* Comment.Special */
.cp { color: #AEAEAE; font-style: italic; } /* Comment.Preproc */
.cpf { color: #AEAEAE; font-style: italic; } /* Comment.PreprocFile */

.err { border: 1px solid #E5ABB3; } /* Error */
.w { color: #bbbbbb; } /* Text.Whitespace */
.p { color: #E0E0E0; } /* Punctuation */
main2.py
import shortcuts
from main import md2html, render_details


def _md2html_main():
    with open('template2.html', 'r', encoding='utf-8') as f:
        template = f.read()
    with open('style.css', 'r', encoding='utf-8') as f:
        style = f.read()
    template = template.replace('/* CSS */', style, 1)
    def main(source):
        content = md2html(source)
        content = render_details(content)
        return template.replace('{{CONTENT}}', content)
    return main


md2html_main = _md2html_main()
del _md2html_main


if __name__ == '__main__':
    with open('sample.md', 'r', encoding='utf-8') as f:
        source = f.read()
    html = md2html_main(source)
    shortcuts.open_shortcuts_app('html2pdf', html)

Python側でCSSの内容を埋め込むことで実装しました。linkで指定したかったのですが、上手い方法が見つからなかったため、このような方法にしました。やり方を知っている方はぜひ教えて下さい。

筆者が使用中のコード

現在、筆者のiPhoneに入っているソースコード、ショートカットです(更新日:7月2日)。前述したものとの大きな変更点は、以下の通りです:

  • detailsの内容がマークダウンのままの問題を、前述のものとは別の方法で解決しました。
  • HTML生成時、title属性がマークダウン内に最初に出てくるh1タグの内容に置き換えられるようになりました。h1タグがない場合、title属性はTitleとなります。
  • こちら(ページ内リンク - Qiita)を参考に、見出しにidをつけました(PDFに変換すると意味がなくなります)。
  • 前述のやり方で、CSSを外部ファイルに記述しました。
main.py
import re
import mistune
from pygments import highlight
from pygments.lexers import get_lexer_by_name
from pygments.formatters import HtmlFormatter
import shortcuts


class MyRenderer(mistune.Renderer):
    def __init__(self):
        super().__init__()
        self.formatter = HtmlFormatter(cssclass='hll')

    def convert(self, text):
        res = mistune.markdown(text, renderer=self)
        if res.startswith('<p>') and res.endswith('</p>\n'):
            res = res[3:-5]
        return res

    def header(self, text, level, raw=None):
        h = super().header(text, level)
        if not raw:
            return h
        r = (
            raw.replace('(', '')
               .replace(')', '')
               .replace('.', '')
               .replace(' ', '-')
        )
        return h.replace(
            f'<h{level}>',
            f'<h{level} id="{r}">',
            1)

    def block_code(self, code, lang=None):
        try:
            l, _, filename = lang.partition(':')
        except AttributeError:
            l, filename = '', ''

        try:
            lexer = get_lexer_by_name(l)
        except:
            res = f'\n<div class="hll"><pre>{code}</pre></div>\n'
        else:
            res = highlight(code, lexer, self.formatter)

        if filename:
            new = f'<pre><span class="file">{filename}</span>\n\n'
        else:
            new = '<pre>\n\n'
        res = res.replace('<pre>', new, 1)

        s1, s2, s3 = res.rpartition('</pre>')
        s1 = (
            (s1.endswith('\n\n') and s1)
            or ((s1.endswith('\n') and (s1 + '\n'))
                or (s1 + '\n\n'))
        )
        res = s1 + s2 + s3

        return res

    def inline_html(self, html):
        start_tag = re.findall('<.+?>', html)[0]
        try:
            end_tag = re.findall('</.+?>', html)[-1]
        except IndexError:
            return html
        sl, el = len(start_tag), len(end_tag)
        text = html[sl:-el]
        result = (
            start_tag
            + self.convert(text)
            + end_tag
        )
        return result

    block_html = inline_html


def _md2html():
    renderer = MyRenderer()
    convertor = mistune.Markdown(renderer=renderer)
    def main(source):
        content = convertor(source)
        return content
    return main


md2html = _md2html()


def _md2html_main():
    with open('template.html', 'r', encoding='utf-8') as h,\
         open('style.css', 'r', encoding='utf-8') as c:
        template = h.read()
        style = c.read()
    template = template.replace('/* CSS */', style, 1)
    h1_ptn = re.compile('<h1.*?>.*?</h1>')
    intext_ptn = re.compile('>.*<')
    def get_title(html):
        h1 = h1_ptn.findall(html)[0]
        title = intext_ptn.findall(h1)[0]
        return title[1:-1]

    def main(source):
        content = md2html(source)
        result = template.replace('{{CONTENT}}', content)
        try:
            title = get_title(result)
        except IndexError:
            pass
        else:
            result = result.replace('Title', title, 1)
        return result

    return main


md2html_main = _md2html_main()
del _md2html, _md2html_main


if __name__ == '__main__':
    with open('sample.md', 'r', encoding='utf-8') as f:
        source = f.read()
    html = md2html_main(source)
    shortcuts.open_shortcuts_app('html2pdf', html)
template.html
<!doctype html>
<html>
<head>
 <meta charset="utf-8">
 <title>Title</title>
 <style>
  /* CSS */
 </style>
 <script type="text/javascript">
   function funcColoring() {
       let pElements = document.getElementsByClassName("p");
       for(let element of pElements) {
           if(element.textContent[0] != "(") { continue; }
           let prev = element.previousSibling;
           if(prev.className != 'n') { continue; }
           prev.className = 'nf';
       }
   }
 </script>
</head>
<body>
 <div class="md-content">
  {{CONTENT}}
 </div>
 <script type="text/javascript">
   funcColoring();
 </script>
</body>
</html>
style.css
html {
  background-color: white;
  font-family: "Avenir Next", sans-serif;
  font-size: 15pt;
}
body {
  padding: 10px;
  margin: 10px;
}
h1 { font-size: 29pt; }
h2 { font-size: 24pt; }
h3 { font-size: 21pt; }
h4 { font-size: 19pt; }
h5 { font-size: 18pt; }
h6 { font-size: 17pt; }
blockquote {
  color: gray;
  border-width: 3px;
  border-style: none none none solid;
  border-color: gray;
  padding: 10px;
  margin: 10px;
}
table {
  border-collapse: collapse;
}
th, td{
  border: 1px solid black;
}
dt {
  font-weight: bold;
}
img {
  width: 100%;
}
code, pre {
  font-family: "Menlo", monospace;
}
code {
  padding: 0px 5px;
}
pre {
  padding: 3px 10px;
  overflow-wrap: break-word;
}
.file {
  background-color: gray;
  padding: 3px;
}
/* cool-glow style */
.hll {
  background-color: #0A0D2A;
  color: #E0E0E0;
}

.gd { color: #A00000; } /* Generic.Deleted */
.ge { font-style: italic; } /* Generic.Emph */
.gr { color: #E5ABB3; } /* Generic.Error */
.gh { color: #E0E0E0; font-weight: bold; } /* Generic.Heading */
.gi { color: #E0E0E0; } /* Generic.Inserted */
.go { color: #E0E0E0; } /* Generic.Output */
.gp { color: #E0E0E0; font-weight: bold; } /* Generic.Prompt */
.gs { font-weight: bold; } /* Generic.Strong */
.gu { color: #E0E0E0; font-weight: bold; } /* Generic.Subheading */
.gt { color: #E5ABB3; } /* Generic.Traceback */

.o { color: #E0E0E0; } /* Operator */
.ow { color: #40F1E2; } /* Operator.Word */

.k { color: #40F1E2; } /* Keyword */
.kc { color: #40F1E2; } /* Keyword.Constant */
.kd { color: #40F1E2; } /* Keyword.Declaration */
.kn { color: #40F1E2; } /* Keyword.Namespace */
.kp { color: #40F1E2; } /* Keyword.Pseudo */
.kr { color: #40F1E2; } /* Keyword.Reserved */
.kt { color: #40F1E2; } /* Keyword.Type */

.m { color: #F8FBB1; } /* Literal.Number */
.mb { color: #F8FBB1; } /* Literal.Number.Bin */
.mf { color: #F8FBB1; } /* Literal.Number.Float */
.mh { color: #F8FBB1; } /* Literal.Number.Hex */
.mi { color: #F8FBB1; } /* Literal.Number.Integer */
.il { color: #F8FBB1; } /* Literal.Number.Integer.Long */
.mo { color: #F8FBB1; } /* Literal.Number.Oct */

.s { color: #A0FAA2; } /* Literal.String */
.sa { color: #A0FAA2; } /* Literal.String.Affix */
.sb { color: #A0FAA2; } /* Literal.String.Backtick */
.sc { color: #A0FAA2; } /* Literal.String.Char */
.dl { color: #A0FAA2; } /* Literal.String.Delimiter */
.s2 { color: #A0FAA2; } /* Literal.String.Double */
.se { color: #A0FAA2; } /* Literal.String.Escape */
.sh { color: #A0FAA2; } /* Literal.String.Heredoc */
.si { color: #A0FAA2; } /* Literal.String.Interpol */
.sr { color: #A0FAA2; } /* Literal.String.Regex */
.s1 { color: #A0FAA2; } /* Literal.String.Single */
.ss { color: #A0FAA2; } /* Literal.String.Symbol */
.sx { color: #A0FAA2; } /* Literal.String.Other */
.sd { color: #A0FAA2; font-style: italic; } /* Literal.String.Doc */

.nv { color: #E0E0E0; } /* Name.Variable */
.vc { color: #6DB6F2; } /* Name.Variable.Class */
.vg { color: #E0E0E0; } /* Name.Variable.Global */
.vi { color: #E0E0E0; } /* Name.Variable.Instance */
.vm { color: #E0E0E0; } /* Name.Variable.Magic */
.na { color: #E0E0E0; } /* Name.Attribute */
.nb { color: #40F1E2; } /* Name.Builtin */
.bp { color: #40F1E2; } /* Name.Builtin.Pseudo */
.nf { color: #6DB6F2; } /* Name.Function */
.fm { color: #6DB6F2; } /* Name.Function.Magic */
.nc { color: #6DB6F2; } /* Name.Class */
.nn { color: #C29AD3; } /* Name.Namespace */
.no { color: #E0E0E0; } /* Name.Constant */
.nd { color: #E3C5DF; } /* Name.Decorator */
.ni { color: #E0E0E0; font-weight: bold; } /* Name.Entity */
.ne { color: #E5ABB3; } /* Name.Exception */
.nl { color: #E0E0E0; } /* Name.Label */
.nt { color: #40F1E2; } /* Name.Tag */

.c { color: #AEAEAE; font-style: italic; } /* Comment */
.ch { color: #AEAEAE; font-style: italic; } /* Comment.Hashbang */
.cm { color: #AEAEAE; font-style: italic; } /* Comment.Multiline */
.c1 { color: #AEAEAE; font-style: italic; } /* Comment.Single */
.cs { color: #AEAEAE; font-style: italic; } /* Comment.Special */
.cp { color: #AEAEAE; font-style: italic; } /* Comment.Preproc */
.cpf { color: #AEAEAE; font-style: italic; } /* Comment.PreprocFile */

.err { border: 1px solid #E5ABB3; } /* Error */
.w { color: #bbbbbb; } /* Text.Whitespace */
.p { color: #E0E0E0; } /* Punctuation */

「共有シートからmd2pdf」の節で紹介したコード、ショートカットです:

_main.py
import sys, time
import urllib.parse
import clipboard
from main import *


if __name__ == '__main__':
    try:
        md = sys.argv[1]
    except IndexError:
        with open('sample.md', 'r', encoding='utf-8') as f:
            md = f.read()
    html = md2html_main(md)
    print(html)
    clipboard.set(html)
    shortcuts.open_shortcuts_app()

md2pdf-shortcut.jpeg

「Pythonistaで編集中のマークダウンをクイックルック」節で紹介したコードです:

quicklook_markdown.py
import editor, console, ui
from main import md2html_main


def _quicklook():
    size = ui.get_window_size()
    frame = (0, 0, *size)
    v = ui.WebView(frame=frame)
    def main(html):
        v.load_html(html)
        v.present('fullscreen')
    return main


quicklook = _quicklook()
del _quicklook


if __name__ == '__main__':
    console.clear()
    md_source = editor.get_text()
    html = md2html_main(md_source)
    print(html)
    quicklook(html)

参考

H1rono_K
趣味プログラマ(高校生)です。
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away