macos版の Microsoft WordでPDF出力をする場合、
目次の設定が出来ないようなので、スクリプトを書いてなんとかしてみました。
このエントリでは、その調査を行った際のメモを残しておきます。

# Windows版では、目次付きのPDFが出力できるようですが

作成したスクリプト

実際に作成したスクリプトは、以下のようなスクリプトです。

add_outline_to_pdf.py
https://gist.github.com/takemikami/7ac487f664a72cc25b49229b535b4c9e

# PDFにoutlineをつけるスクリプト
#
# 概要:
#  PDFファイルの本文中にある目次の文字列を解析し、
#  解析結果を元にPDFにアウトラインを設定する
# セットアップ:
#  pip install pdfminer.six
#  pip install pdfrw
#  pip install reportlab
# 実行方法:
#  1. input.pdfファイルをカレントディレクトリに配置
#  2. スクリプトを実行
#      python add_outline_to_pdf.py
# パラメータの設定:
#  本スクリプトの「設定項目」以降にある変数で指定する
# 参考:
#  https://inudaisho.hatenablog.com/entry/20120611
#  https://buildersbox.corp-sansan.com/entry/2020/06/09/110000


import io
import re
from pdfminer.converter import TextConverter
from pdfminer.layout import LAParams
from pdfminer.pdfinterp import PDFResourceManager, PDFPageInterpreter
from pdfminer.pdfpage import PDFPage
from pdfrw import PdfReader
from pdfrw.buildxobj import pagexobj
from pdfrw.toreportlab import makerl
from reportlab.pdfgen.canvas import Canvas

# 設定項目
# 入力ファイル名
input_pdf = "input.pdf"
# 出力ファイル名
output_pdf = "out.pdf"
# 1ページ目の開始位置 (表紙分などページ開始位置をずらす)
page_shift = 0
# 目次のリーダー線 (ここで指定した文字列があるページ・行を目次として扱う)
leader_char = '....'
# 目次を解析する正規表現、対象レベル分指定
re_list = [
    r'第\s+([0-9]*)\s+章\s+(.*)\s+\.\.\.\.+\s+([0-9]+)\s+',
    r'([0-9]+\.[0-9]+)\s+(.*)\s+\.\.\.\.+\s+([0-9]+)\s+',
    r'([0-9.]*)\s+(.*)\s+\.\.\.\.+\s+([0-9]+)\s+',
]


# 目次の解析
def parse_outline(input_file: str, leader_str: str, re_levels: list):
    rsrcmgr = PDFResourceManager()

    outlines = []
    with open(input_file, "rb") as fp:
        index_page = False
        for pidx, page in enumerate(PDFPage.get_pages(fp)):
            out_fp = io.StringIO()
            device = TextConverter(
                rsrcmgr,
                out_fp,
                laparams=LAParams(),
                imagewriter=None
            )
            interpreter = PDFPageInterpreter(rsrcmgr, device)
            interpreter.process_page(page)
            out_fp.seek(0)
            page_str = out_fp.read()
            if leader_str in page_str:
                index_page = True
                for ln in page_str.split('\n'):
                    for lv, re_lv in enumerate(re_levels):
                        m = re.match(re_lv, ln)
                        if m is None:
                            continue
                        title_num, title_str, page_num = m.groups()
                        title = "{} {}".format(title_num, title_str)
                        outlines.append([title, int(page_num) - 1, lv + 1])
                        break
            elif index_page:
                break
    return outlines


# 目次の設定
def add_outline(input_file: str, output_file: str, outlines: list, page_shift: int = 0):
    pages = PdfReader(input_file, decompress=False).pages
    out_canvas = Canvas(output_file)

    out_canvas.bookmarkPage("0")
    out_canvas.addOutlineEntry(u"目次", "0")
    for idx, page in enumerate(pages):
        out_page = pagexobj(page)
        out_canvas.setPageSize(tuple(out_page.BBox[2:]))
        out_canvas.doForm(makerl(out_canvas, out_page))
        target_outlines = [d for d in outlines if idx + page_shift == int(d[1])]
        for bookmark_idx, outline_data in enumerate(target_outlines):
            out_bookmark = str(idx) + "p-" + str(bookmark_idx)
            out_canvas.bookmarkPage(out_bookmark)
            out_canvas.addOutlineEntry(outline_data[0], out_bookmark, outline_data[2])
        out_canvas.showPage()

    out_canvas.showOutline()
    out_canvas.save()


if __name__ == '__main__':
    outline_list = parse_outline(input_pdf, leader_char, re_list)
    add_outline(input_pdf, output_pdf, outline_list, page_shift)

使い方や必要なライブラリのセットアップ方法は、コメントに書いてあるとおりです。

作成したスクリプトの処理の流れ

スクリプトの処理の流れは、ざっくりと以下の通りです。

  • 対象のPDFを読み込み、最初のページから順に処理を行う (parse_outline)
    • ページ内にリーダー線の文字列「….」がある場合
      • 最初の行から、行毎に処理を行う
        • 見出し判定の正規表現に合致している場合は、見出しと判断する
  • 対象のPDFを読み込む (add_outline)
    • 最初のページから順に処理を行う
      • 対象ページに対応する見出しがある場合に見出しをつける

目次の解析では、
以下のような形式の目次があると想定して、解析を行っています。
# 目次の形式が違う場合は、設定項目の正規表現で変更する

  • 第 1 章 大見だし ………. 1
    • 1.1 中見出し ………. 1
      • 1.1.1 小見出し ………. 1

目次の設定では、解析した目次を、対象ページに設定しています。

参考にした記事

目次の解析部分のPDFファイルの読み込みは、以下の記事を参考にしています。

【Techの道も一歩から】第29回「PythonでPDFに文字を埋め込む」| Sansan Builders Blog
https://buildersbox.corp-sansan.com/entry/2020/06/09/110000

目次の設定部分で、以下の記事を参考にしています。

既存PDFに目次をつける | メモ@inudaisho
https://inudaisho.hatenablog.com/entry/20120611

利用したライブラリ

PDF関連で、以下の3つのライブラリを使っています。

ReportLab
https://www.reportlab.com/

pdfrw | GitHub
https://github.com/pmaupin/pdfrw

pdfminer.six | GitHub
https://github.com/pdfminer/pdfminer.six

以下のように、ライブラリで出来ることが異なるため、
今回は3つのライブラリを組み合わせて使っています。

  • ReportLab … 目次(アウトライン)の出力が出来る、但し既存PDFへの加工が出来ない
  • pdfrw … ReportLabと組み合わせて使うと、既存PDFの加工が出来る
  • pdfminer.six … 日本語が扱える

目次の解析部分に pdfminer.six、
目次の設定部分に ReportLab と pdfrw を利用しています。

# ReportLabも、有償版であれば、既存PDFへの加工が出来るらしいですが

検討していた実装方法

最終的な実装の説明は以上ですが、
当初は、docxを解析して目次情報を作ることを考えていました。

以下の記事のように、python-docxを利用すると見出しを取得することは出来ます。

python-docxで見出しの文字列を取得する | インストラクターのネタ帳 https://www.relief.jp/docs/python-docx-heading-texts.html

目次(アウトライン)を設定するためには、
見出しとページ番号が必要ですが、
どの見出しが何ページ目になるのかは、
Microsoft Wordで実際にレンダリングしなければ、分からないようなのです。
# 使用するフォントなどによってもページはズレたりするので、

そのような背景があり、
PDFファイル内の目次を解析する方法での実装にしました。
Microsoft Wordの見出しを解析する方で実装できれば、もっと汎用的だったのですが

以上。