Notionで作成した仕様書をいい感じでPDFに変換する


はじめに

Notionの文章をPDFに変換する際に、下記リンクにあるようにHTML形式でエクスポートし、テキストエディタで編集してから、プラウザの印刷あるいはPDF出力機能でPDFに変換する方法を掲載していました。

最近は特にNotion to PDF の変換をすることが多く、手作業での変換が面倒になったので、生成AIにお願いして自動的に変換するためのPythonのコードを作成してもらったので、紹介します。

Pythonでの利用

Pythonの環境構築手順は省略します。他のWebサイトを参考にしてください。

下記のPythonコードを適当なファイル名(ここではconvert.py)として保存し、次のコマンドで変換します。

python convert.py -i input.html -o output.html

import argparse
from bs4 import BeautifulSoup

def process_html(input_file, output_file):
    with open(input_file, 'r', encoding='utf-8') as f:
        soup = BeautifulSoup(f, 'html.parser')
        
        # imgタグがaタグの中にある場合、aタグを削除してimgタグだけ残す
        for img in soup.find_all('img'):
            if img.parent.name == 'a':
                img.parent.unwrap()

        # <head>タグを取得または作成
        head_tag = soup.find('head')
        if not head_tag:
            head_tag = soup.new_tag('head')
            soup.html.insert(0, head_tag)

        # mermaid.min.js のscriptタグを追加
        script1 = soup.new_tag('script', src='https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.min.js')
        head_tag.append(script1)

        # mermaid.initializeのscriptタグを追加
        script2 = soup.new_tag('script')
        script2.string = "mermaid.initialize({startOnLoad:true,theme: 'default'});"
        head_tag.append(script2)

        # <code class="language-Mermaid">...</code> を <div class="mermaid">...</div> に書き換え
        for code in soup.find_all('code', class_='language-Mermaid'):
            new_div = soup.new_tag('div', **{'class': 'mermaid'})
            for content in code.contents:
                content.extract()
                new_div.append(content)
            code.replace_with(new_div)


        # <h1>目次</h1>を探す
        toc_h1 = soup.find('h1', string='目次')
        if toc_h1:
            # <h1>目次</h1>より前のすべての要素を取得(header以外)
            prev_elements = []
            current = toc_h1.previous_sibling
            while current:
                prev_elements.insert(0, current)
                current = current.previous_sibling
            # 前の要素が存在する場合
            if prev_elements:
                center_div = soup.new_tag('div', id='top-page', style='text-align:center;margin:20px 0;')
                for elem in prev_elements:
                    elem.extract()
                    center_div.append(elem)
                toc_h1.insert_before(center_div)

        # 目次項目削除(table_of_contents-item table_of_contents-indent-0の「目次」)
        for elem in soup.find_all(True, class_=lambda c: c and 'table_of_contents-item' in c.split() and 'table_of_contents-indent-0' in c.split()):
            if elem.text.strip() == '目次':
                # 一つ前のタグ要素を取得
                prev = elem.find_previous_sibling(name=True)
                if prev:
                    prev.decompose()  # 一つ前の要素を削除
                elem.decompose()      # 「目次」要素を削除
                break                 # 複数ある場合はbreakせず続けることも可能

        # CSS追加
        new_css = '''
        header {
            display: none;
        }

        #top-page h1 {
            margin-top: 10rem;
            margin-bottom: 10rem;
        }

        #top-page .column-list {
            display:block;
            width: 100%;
            text-align: center;
        }
        #top-page .column-list .column {
                width: 100% !important;
                padding: 0;
                text-align: center;
        }

        #top-page .column-list .column .image {
            margin-top: 5rem;
            margin-bottom: 5rem;
            text-align: center !important;
        }

        h1 {
            page-break-before: always;
        }
        /* 先頭の見出し1は改ページ無効 */
        /*
        h1:first-of-type {
            page-break-before: auto;
        }
        */

        hr {
            page-break-after: always;
            visibility: hidden !important;
        }

        a, a.visited {
            color: #000;
        }

        .table_of_contents-link {
            opacity: 1;
            border-bottom: none;
        }

        .table_of_contents {
            margin-top:1.5rem;
        }

        .table_of_contents-item {
            font-size: 1rem;
            line-height: 1.5;
        }
        
        table {
            width: 100%;
            table-layout: fixed;
            max-width: 100%;
        }

        tr, th, td {
            color: #000 !important;
            border-color: #000 !important;
        }

        th {
            font-weight: bold !important;
            background: rgb(247, 246, 243);
        }
        
        ul li details td {
            text-align: right;
        }
        
        .toggle > li > details {
            padding-left: 0;
        }

        .code:has(.mermaid) {
            padding: 0 !important;
        }

        @media print {s
            @page {
                size: portrait;
            }
            @page rotated {
                size: landscape;
            }

            body {
                margin: 0;
                padding: 0;
            }

            details:not(ul li details) {
                page: rotated;
            }

            td, th {
                word-wrap: break-word;
                overflow-wrap: break-word;
            }

            .mermaid > svg {
                max-width: 100% !important; /* ページの幅に合わせて最大幅を100%にする */
                max-height: 100vh !important; /* ページの高さに合わせて最大高さを100%にする */
                width: auto !important; /* アスペクト比を保ちつつ幅を自動調整 */
                height: auto !important; /* アスペクト比を保ちつつ高さを自動調整 */
                page-break-inside: avoid !important; /* 図の途中で改ページされるのを防ぐ */
            }

            #top-page .column-list {
                position: absolute;
                bottom: 0;
            }
        }
        '''
        style_tag = head_tag.find('style')
        if style_tag:
            style_tag.string = (style_tag.string or '') + '\n' + new_css
        else:
            style_tag = soup.new_tag('style')
            style_tag.string = new_css
            head_tag.append(style_tag)

    # detailsタグの前後に空白のpタグがある場合、そのpタグを削除
    for details in soup.find_all('details'):
        # 直前のpタグをチェック
        prev_p = details.find_previous_sibling('p')
        if prev_p and (not prev_p.get_text(strip=True)):
            prev_p.decompose()
        # 直後のpタグをチェック
        next_p = details.find_next_sibling('p')
        if next_p and (not next_p.get_text(strip=True)):
            next_p.decompose()

    with open(output_file, 'w', encoding='utf-8') as f:
        f.write(str(soup))

def main():
    parser = argparse.ArgumentParser(description='HTMLから「目次」項目を削除します')
    parser.add_argument('-i', '--input', required=True, help='入力HTMLファイル')
    parser.add_argument('-o', '--output', default='output.html', help='出力HTMLファイル')
    args = parser.parse_args()
    process_html(args.input, args.output)
    print(f'処理が完了しました。出力ファイル: {args.output}')

if __name__ == '__main__':
    main()

専用の記法

Notionに記入する際に、次のルールで記入すると自動変換されます。

表紙

「目次」ブロック前の記載は表紙として扱われ、HTML で id:top-pageが付与されます。

文章のタイトルを見出し1で記載します。
その下に2列のカラムを作成し、左の列に版数、日付、ロゴなどを記載します。
カラム部分は表紙ページの最下部に自動的に配置されます。

目次

見出し1で「目次」というタイトルを作成すると、目次内に「目次」という行が追加されてしまうため、この行を自動的に削除します。また、表紙内に文章のタイトルを見出し1で作成するとこちらも目次に掲載されるため、自動的に削除します。

なお、Notionページ自体の「タイトル」はPDFには含まれないため、見出し1として文章のタイトルを追加する必要があります。これは、タイトル行内で改行したい場合に、Notionページ自体のタイトルを利用すると対応できないためです。

改ページ

見出し1、区切り線は自動的に改ページされます。

Mermaid記法

フローチャート等をCodeブロックにMermaid記法でかきます。
Mermaid記法により作成されたSVGの図は、ちょうど1ページに収まるように拡大・縮小されます。

用紙方向切替(ポートレート・ランドスケープ)

トグル見出し内は横方向(ランドスケープ)になります。

表の要素の右寄せ

トグルリスト内に作成された表の要素は右寄せになります。
数値等で右寄せが見やすい場合に利用します。
なお、表のタイトルは中央寄せです。

Pythonでの変換例

変換元Notionページ

下記のサンプルページをPDFに変換します。

変換後PDF

残念ながら、ページ番号は自動挿入できませんが、Adobe Acrobat PDFオンラインツール等を利用すれば、簡単にページ番号を追加できます。
表紙、目次にはページ番号を記載せず、本文から記載することもできます。

次回は…

今回は、ローカル環境でPythonを使ってNotionからエクスポートしたHTMLを自動編集し、ブラウザを使ってPDFへエクスポートするところまでできました。

次回は、今回作成したPythonのコードをクラウドで動作させることで、Python環境が構築されていないPCからでもブラウザを使うことで、今回と同等の変換処理を行えるようにしてみます。

FAQ

NotionからHTMLやPDFにエクスポートする理由は?

仕様書や資料を外部と共有したり、印刷配布する際にPDF形式が便利だからです。HTML経由で編集することで、レイアウトやスタイルを細かく調整できます。

Pythonスクリプトを使うメリットは?

手作業での編集や変換を自動化できるため、作業効率が大幅に向上します。また、Mermaid記法など特殊な記述も自動で適切に扱えます。

この方法でMermaid図表もPDFに反映されますか?

はい。PythonスクリプトがMermaid記法をHTMLのdivタグに変換し、ブラウザのPDF出力機能で正しく図表として出力されます。

表紙や目次の自動生成はできますか?

はい。スクリプトが「目次」ブロック前の内容を表紙として扱い、目次内の不要な行も自動で削除します。

ページ番号は自動で挿入されますか?

現時点では自動挿入できませんが、Adobe AcrobatなどのPDF編集ツールで後から簡単に追加できます。

用紙の向き(縦・横)は自動で切り替わりますか?

トグル見出し内は横方向(ランドスケープ)に自動切り替えされます。

表の要素を右寄せにしたい場合、どうすればいいですか?

トグルリスト内に作成した表の要素は自動で右寄せになります。

この方法はどんな人におすすめですか?

Notionで仕様書や資料を作成し、定期的にPDFに変換する必要がある方、HTMLやCSSの編集が苦にならない方におすすめです。

変換後のPDFのレイアウト崩れを防ぐには?

HTMLのスタイル(CSS)で印刷用のレイアウトを細かく調整し、ブラウザのPDF出力機能を利用してください。

Notionページタイトルと見出し1の違いは?

NotionのページタイトルはPDF出力時に反映されません。見出し1でタイトルを記載することで、PDFにも反映されます。また、見出し1で改行したい場合も、Notionページタイトルでは対応できないため、見出し1ブロックを使う必要があります。

表紙や目次以外のページでも自動改ページはされますか?

見出し1や区切り線(hrタグ)がある場合、自動で改ページされます。

トグルリスト内の表以外の要素も右寄せになりますか?

現状のCSSでは、トグルリスト内の表の要素のみ右寄せになります。他は標準の配置です。