u-ryo's blog

various information for coding...

Markdown to PDF、3年後

| Comments

前回 (といってももうはるか3年も前ですが)、markdown to pdf on CLI という記事で、pandoc/latex の docker image に tlmgr install collection-langjapanese で30分ほどかけて日本語PDF環境を作った話を書きました。

実はあの記事を書いてから割とすぐ、楽にMarkdownをそれっぽいPDFに変換するfrozenbonito/pandoc-eisvogel-ja を見つけました。pandoc + Eisvogel テンプレート + 日本語フォント焼き込み済みの docker image なので、

1
docker run --rm -v $(pwd):/data frozenbonito/pandoc-eisvogel-ja -o doc.pdf doc.md

これ一発。表紙も目次も出ます。爾来、今に至るもずっとこちらを愛用しています。以上。

...で終わってしまうのもなんですんで、そういえばきょうび、この界隈はどうなってるんだろう? と思って軽く見渡してみました。

2026年の様子

一番の変化は Typst の台頭のようです。LaTeX 代替の組版システムで、pandoc が --pdf-engine=typst を直接サポートしたので、

1
2
brew install pandoc typst
pandoc doc.md -o doc.pdf --pdf-engine=typst

で TeX 環境なしの日本語PDFが出る時代になっていました。tlmgr に22分かけた身からすると隔世の感があります。日本語の組版にこだわるなら CSS 組版の Vivliostyle が健在で、技術同人誌方面は大体これみたいですね。

あと身も蓋もない変化として、AI に「これPDFにして」と言うと agent が勝手に pandoc なり headless Chrome なりを使って出してくる、というのがあります。ツール選定という行為自体が消えつつある、のが AI 時代の今、ということでしょうか。

3年前に動かなかった md-to-pdf、今なら動くのか

前回の記事で「どうしようもないエラーが出る」と切り捨てた md-to-pdf、2026年の今ならどうなのか。試してみました。

1
2
3
4
5
$ npx --yes md-to-pdf sample.md
Error: Could not find Chrome (ver. 151.0.7922.71). This can occur if either
 1. you did not perform an installation before running the script
    (e.g. `npx puppeteer browsers install chrome`) or
 2. your cache path is incorrectly configured

一発では動かない。そこは3年前と同じなんですが、理由が進化していて、puppeteer が Chrome を自動ダウンロードしなくなったため、なんですね。エラーメッセージの指示どおりに

1
2
3
4
$ npx puppeteer browsers install chrome
$ npx md-to-pdf sample.md
[12:51:55] generating PDF from sample.md [started]
[12:51:59] generating PDF from sample.md [completed]

今度は4秒で完了。出てきたPDFは GitHub 風スタイルで、日本語の本文・表・コードブロックとも問題なしでした。「一発では動かない」は3年経っても変わらないですが、エラーメッセージが親切になったので自己解決できる。時代は少しずつ良くなっています。

古い Linux でも動くのか

Typst は Rust 製で musl の static build が配られていて、pandoc の公式バイナリも static link なので、原理的には glibc の古い年代物の Linux でも「置くだけ」で動くはずです。docker が使えない・使いたくない環境でこそ試す価値があります。手元の古めの環境で試してみました。

mkdir -p ~/md2pdf/bin && cd ~/md2pdf
curl -sLO https://github.com/typst/typst/releases/latest/download/typst-x8664-unknown-linux-musl.tar.xz
tar xf typst-x8664-unknown-linux-musl.tar.xz && cp typst-*/typst bin/
# pandoc は GitHub releases の linux-amd64 tarball (static link) を同様に bin/ へ
export PATH=~/md2pdf/bin:$PATH
time pandoc sample.md -o out.pdf --pdf-engine=typst -V mainfont=IPAMincho
1
real    0m5.210s

glibc 2.23 という年代物の環境で動きました。初回はフォントキャッシュ構築込みで5秒、以後は1秒級。日本語フォントはシステムに入っているものを typst がそのまま拾ってくれるので (typst fonts で一覧できます)、うちは IPAMincho を指定しただけ。無いマシンでも otf を1個置いて TYPST_FONT_PATHS で指せば済みます。disk 占有は typst + pandoc 合わせて210MB (ほぼ pandoc の static binary です。配布は tar.gz で30MBほどなのに、展開すると5倍に膨らむのは Haskell の static binary らしいところ)。eisvogel-ja の docker image が1.29GBなので、6分の1で済みます。出来上がりの PDF も、表紙情報・見出し・表・コードブロックとも文句なしでした。

結論

今日から pandoc + typst で生きていこうと思います。

凄いですね、ベルリン工科大 Martin Haug さんと Laurenz Mädje さん。「LaTeX のコンパイル遅すぎ・エラー不親切すぎ」に切れて作った、と?! あの Knuth 先生に挑むというその心意気に拍手喝采です。

そういえば、知人が何を使って md to pdf しているのか、気になっています。今度聞いてみます。

Unicode正規化とMySQLの照合順序は別物

| Comments

データベースの検索仕様を考えていると、「正規化」という言葉が2つの意味で会議を飛び交うことがあります。RDB 設計の正規形 (第1〜第3正規形のあれ) と、Unicode の正規化 (NFC/NFD) です。名前が同じだけで完全に別物なんですけれど、話が混線したまま「DB がよしなにやってくれるのでは」という期待だけが残ることがあります。

結論から書きますと、MySQL は Unicode 正規化を「やってくれない」です。代わりに照合順序 (collation) が「何と何を同じとみなすか」を決めているんですけど、これは正規化の代替ではないですし、時々予想外のものまで同じにしてしまいます。手を動かして確かめたので、その記録、です。

用語の整理

  • RDB の正規化 (normalization): テーブル設計から冗長性を取り除く設計論。今日の話には出てきません。
  • Unicode の正規化 (normalization): 同じ見た目の文字を同じバイト列に揃える変換。「ポ」は1文字 (U+30DD) でも「ホ + 結合半濁点」(U+30DB U+309A) でも表現でき、前者に揃えるのが NFC、後者に分解するのが NFD。macOS のファイル名経由で NFD の文字列が混入してくる、というのが実務での典型的な出会い方だと思います。
  • 照合順序 (collation): DB が文字列を比較・整列するときの「一致と順序の定義」。

実験

MySQL 8.0.46 (Docker公式image) で、NFC の「ポ」と NFD の「ポ」を各照合順序で比較してみます。文字列リテラルだとエディタやシェルが勝手に正規化しかねないので、バイト列を明示します。

-- NFC の「ポ」(U+30DD) と NFD の「ホ+結合半濁点」(U+30DB U+309A)
SELECT CHARLENGTH(CONVERT(X'E3839D' USING utf8mb4)) AS nfccharlen,
       CHARLENGTH(CONVERT(X'E3839BE3829A' USING utf8mb4)) AS nfdcharlen;
1
2
3
4
5
+--------------+--------------+
| nfc_char_len | nfd_char_len |
+--------------+--------------+
|            1 |            2 |
+--------------+--------------+

文字数からして違う、正真正銘の別文字列です。これを4つの照合順序で比較しますと:

1
2
3
4
5
6
SELECT CONVERT(X'E3839D' USING utf8mb4) = CONVERT(X'E3839BE3829A' USING utf8mb4)
       COLLATE utf8mb4_bin        AS bin_eq;        -- 0
SELECT ... COLLATE utf8mb4_general_ci AS general_ci_eq; -- 0
SELECT ... COLLATE utf8mb4_unicode_ci AS unicode_ci_eq; -- 1
SELECT ... COLLATE utf8mb4_0900_ai_ci AS ai_ci_eq;      -- 1
SELECT ... COLLATE utf8mb4_0900_as_cs AS as_cs_eq;      -- 1
照合順序NFC「ポ」= NFD「ポ」
utf8mb4_bin一致しない
utf8mb4_general_ci一致しない
utf8mb4_unicode_ci一致する
utf8mb4_0900_ai_ci一致する
utf8mb4_0900_as_cs一致する

Unicode Collation Algorithm (UCA) に基づく照合順序 (unicode_ci、0900系) は、正規化の違いを比較時に吸収します。アクセントも大文字小文字も区別する utf8mb4_0900_as_cs ですら一致させるのは少し意外でした —-- これは「正規化して等しいもの (正準等価) は等しい」という UCA の原則によるもので、accent-insensitivity とは別の話です。一方、旧世代の utf8mb4_general_ci は符号位置ごとの単純な重み比較なので吸収しません。

では照合順序に任せてよいか

よくない、です。照合順序は「一致の定義」を丸ごと入れ替えるダイヤルであって、正規化の違いだけを吸収する装置ではありません。MySQL 8.0 のデフォルトである utf8mb4_0900_ai_ci は accent-insensitive、つまり「アクセント記号を無視する」のですが、日本語では濁点・半濁点がアクセント扱いになります:

SELECT utf8mb4'はは' = utf8mb4'ぱぱ' COLLATE utf8mb40900aici; -- 1
SELECT utf8mb4'はは' = utf8mb4'ばば' COLLATE utf8mb40900aici; -- 1
SELECT utf8mb4'が'   = utf8mb4'か'   COLLATE utf8mb40900aici; -- 1
SELECT utf8mb4'はは' = utf8mb4'ばば' COLLATE utf8mb40900asci; -- 0

母 (はは) と父 (ぱぱ)、婆 (ばば) が一致します。デフォルトのまま日本語データに UNIQUE 制約や検索を載せると、この定義で「同じ」が判定されます。逆に general_ci はこれらを区別しますが、'ABC' = 'abc' は一致させます (case-insensitive)。つまり:

  • general_ci: NFC/NFD は別物扱い、大小文字は同一視
  • 0900_ai_ci: NFC/NFD は同一視、濁点も大小文字も同一視
  • 0900_as_cs: NFC/NFD は同一視、濁点も大小文字も区別

「どの差を無視してどの差を区別するか」のセットを選んでいるのであって、単体で仕様の要求と一致することはまず期待できません。

MySQLに正規化関数は無い

ならば比較の前に正規化すればよいのですが、MySQLにはその関数がありません:

SELECT NORMALIZE(_utf8mb4'ポ');
1
ERROR 1305 (42000): FUNCTION mysql.NORMALIZE does not exist

PostgreSQL には 13 から normalize() があるので、これは MySQL 側の欠落です。つまり MySQL では、正規化はデータベースに入る前 (アプリケーション層) で行うしかありません

まとめ

  • Unicode 正規化と照合順序は別のレイヤーの仕組み。照合順序は正規化の代替にならない
  • 保存前に NFC へ正規化する層をアプリケーション側に置く。DB には正規化済みの文字列だけを入れる
  • 検索や UNIQUE 制約の「一致」が、使っている照合順序のどの定義に当たるのかを仕様として明文化する。デフォルト (0900_ai_ci) は日本語では「はは = ぱぱ」であることを知った上で選ぶ
  • 検証環境: MySQL 8.0.46 (Docker)。この記事の SQL と出力は全て実行結果そのまま

Markdown to Pdf on CLI

| Comments

markdownで快調に書いたものを、「印刷して提出せよ」ということになっていたので、「へ?」とか思って。今時ねぇ、印刷なんてねぇ、何の意味があるのか。一応evidenceってことなんだろうけど。

調べると、意外とないんですよね日本語markdown to pdf。popularなのはMS Visual Studio Codeの拡張機能Markdown PDF使うものですけど、たったそれだけのためにVisual Studio Code入れたかぁないですし。 nodeのmarkdown-pdfmd-to-pdfとかサクッと出来そうだったんですけど、古いのか、どうしようもないエラーが出ます。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
docker run --rm -it -v $PWD:/work -w /work -e https_proxy=http://192.168.120.1:3128 node:alpine sh
# npm i -g markdown-pdf
# markdown-pdf some.md
node:events:497
      throw er; // Unhandled 'error' event
      ^

Error: spawn /usr/local/lib/node_modules/markdown-pdf/node_modules/phantomjs-prebuilt/lib/phantom/bin/phantomjs ENOENT
  :
# npm i -g md-to-pdf
# md-to-pdf some.md
(node:29) [DEP0040] DeprecationWarning: The `punycode` module is deprecated. Please use a userland alternative instead.
(Use `node --trace-deprecation ...` to show where the warning was created)

  Puppeteer old Headless deprecation warning:
    :

  ✖ generating PDF from some.md
    → TROUBLESHOOTING: https://pptr.dev/troubleshooting
ListrError: Something went wrong
    at /usr/local/lib/node_modules/md-to-pdf/node_modules/listr/index.js:102:18
     :

pandocもいいんですが、texの環境色々入れるのがかったるいなー。 というわけで、docker baseで。 探すと、参考文献がありました。proxyも使わないとならないので。

1
2
3
4
5
6
7
8
9
10
$ PROXY=http://192.168.120.1:3128
$ docker run --rm -it -e https_proxy=$PROXY -e http_proxy=$PROXY -e ftp_proxy=$PROXY -e use_proxy=yes -v $PWD:/data --entrypoint sh pandoc/latex
/data # tlmgr install collection-langjapanese
   :
tlmgr: package repository ftp://tug.org/historic/systems/texlive/2022/tlnet-final (not verified: valid signature with expired key)
   :
[108/108, 22:16/22:16] install: collection-langjapanese [1k]
running mktexlsr ...
   :
/data # pandoc some.md -o some.pdf -C --pdf-engine=lualatex -V linkcolor=blue -V documentclass=ltjsarticle -V luatexjapresetoptions=fonts-noto-cjk
  • 何か今どきftpで取りに行くので、ftp_proxyが必要
  • use_proxy=yesもないとproxy経由と認識してくれなかった
  • working directoryはdefaultで/dataなのでそこにmountする
  • entrypointが入っているので、optionで上書きしないとtlmgrを動かせない
  • pandoc/coreではなくpandoc/latexであることに留意
    • 500MB超とsizeも結構ある
  • tlmgr install collection-langjapaneseは22分かかった
    • 総計30分弱はかかる
  • 基本的にはpandoc md_file -o pdf_fileだが、--pdf-enginelualatexを指定したりdocumentclassltjsarticleを指定したり、日本語特有の指定が必要
  • ###が本文と同じ大きさの文字になるので、思っているよりlevelを1段階上げとかないと、思ったような出力にならない

Get Refresh Token for Google API

| Comments

何かいつの間にかGoogle APIの認証方法が変わってて、security上の理由からというので仕方ないんでしょうけど。

AUTHORIZATION_CODEを得ようと、以前のように↓のURLにaccessすると、 https://accounts.google.com/o/oauth2/v2/auth?responsetype=code&clientid=...apps.googleusercontent.com&redirecturi=urn:ietf:wg:oauth:2.0:oob&scope=https://www.googleapis.com/auth/photoslibrary&accesstype=offline アクセスをブロック: ... のリクエストは無効です

と出て、あれ?!と。

エラー 400: invalid_request The out-of-band (OOB) flow has been blocked in order to keep users secure. Follow the Out-of-Band (OOB) flow migration guide linked in the developer docs below to migrate your app to an alternative method.

で、帯域外(OOB)フロー移行ガイドへ誘導され。

このoobフローはなくなったと!? んじゃどうせぃっていうんじゃねん。

「デスクトップクライアント」に相当するから、ループバック IP アドレス(localhost または 127.0.0.1)フローに飛ばされたものの、こっちもなくなってて、ループバック IP アドレスフローの移行ガイドに飛ばされて、結局library使う方法しか書いてなくて。こちとら、shell scriptで使いたいんですけど。refresh_token欲しいだけなのに、なんでこんなに苦労せなかんの??🤔

...って嘆いても仕方ないので、library使ってrefresh_token取得だけします。

ちょっとめんどくさいんですけど、local環境を汚染しない形で。

参考:

Google APIの認証情報のpageから「OAuthクライアントをダウンロード」してclient_secret.jsonとして保存。

1
2
3
4
5
6
7
$ docker run --rm -it -v $PWD/client_secret_....json:/root/client_secret.json python:alpine sh
# apk add w3m screen
# pip install google-api-python-client google-auth-oauthlib
# screen
# python
>>> from google_auth_oauthlib.flow import InstalledAppFlow
>>> credential=InstalledAppFlow.from_client_secrets_file('client_secret.json',["https://www.googleapis.com/auth/photoslibrary.appendonly"]).run_local_server()

ここでw3mが開くので、Qを押して閉じるとURLが表示され、そこをChromeなりで開いて、突き進んで承認します。

そうすると、http://localhost:8080/?state=.... へ回されて止まるので、C-aで別screenを開いて、そこでw3m 'http://localhost:8080/?state=...'とすると、The authentication flow has completed. You may close this window.と言われます。徐に元のscreenに戻るとcredential objectができているので、credential.refresh_tokenとすると、漸くREFRESH_TOKENを得られます。1時間有効なACCESS_TOKENcredentail.tokenで得られます。

これらを使うと、やっと以前のようにshell scriptで回せるようになります。ふぅ。

NHK Taiga Staff Reflection

| Comments

大河ドラマ『鎌倉殿の13人』13日放送分に一眼カメラを構えたマスクおじさんスタッフ映り込み放送事故で公式が謝罪、視聴者「タイムスクープハンターの撮影班だ」#鎌倉殿の13人 たまたまぼくも本放送、再放送どちらも録画してたので、よーく見てみました。

kamakuradonono13nin

12fps で 3コマ約0.2秒、確かによく見付けたなぁと思います。 再放送では編集されるとの由、どうなるんだろうと注視していましたら、 北条義時の走るシーンが例の場面直前まででカットされ、 その分佐竹側の武者達が降りてくる次のシーンが若干長くなっている、 というものでした。 両者足したものを作ってみたら確かに45分を0.2秒程越えてました。