m13o

2021-03-21 Sun 21:57
サイトのmetaを整備するEmacs orgmode

このサイトのヘッダ情報にOpen Graph Protocol用のmeta情報を追加しました. 今回追加した情報は

  • og:url
  • og:title
  • og:type
  • og:description
  • og:image

ですが, 上記Open Graph Protocolのmetadata以外にも, twitter向けのmetadataも同時に生成するようにしました.

まずは埋め込む情報のテンプレートを作ります.

(defconst blog-posts-html-head-extra-base "
#+HTML_HEAD_EXTRA: <meta property=\"og:url\" content=\"https://m13o.net/__FILEBASE__.html\" />
#+HTML_HEAD_EXTRA: <meta property=\"og:title\" content=\"__TITLE__\" />
#+HTML_HEAD_EXTRA: <meta property=\"og:type\" content=\"__TYPE__\" />
#+HTML_HEAD_EXTRA: <meta property=\"og:description\" content=\"__DESCRIPTION__\" />
#+HTML_HEAD_EXTRA: <meta property=\"og:image\" content=\"https://m13o.net/__FILEBASE__.png\" />
#+HTML_HEAD_EXTRA: <meta name=\"twitter:site\" content=\"@mas_fj\" />
#+HTML_HEAD_EXTRA: <meta name=\"twitter:card\" content=\"summary_large_image\" />")

これはorg文書のプロパティとなっています. これを対象のorg文書をパースする前に追記するようにします. また, この文字列にある以下の物は追記前に記事毎に置き換える必要があります.

__FILEBASE__
ファイルのベース名
__TITLE__
記事のタイトル
__TYPE__
記事の型
__DESCRIPTION__
記事の概要

org文書パース前にこれを埋め込む必要があるため, org-export-before-parsing-hook にmetadataを追記するためのフック blog-posts-add-ogp-metadata を登録します.

(add-hook 'org-export-before-parsing-hook 'blog-posts-add-ogp-metadata)

org-export-before-parsing-hook に登録するフック関数では, 現在パース対象としているorg文書のコピーバッファを走査して, テキストの追加や削除などを行う事で, htmlへ変換する前のorg文書の再構築が可能となります. また, 内部的にはコピーバッファを開きそれをカレントバッファとした状態となっているので, org-element-系の関数が利用可能です. ですので, org-element-map を利用して, 必要な要素をplistとして取得, その情報を元にmetadataを構築する事にします.

(defun blog-posts-add-ogp-metadata (backend)
  "Add HTML_HEAD_EXTRA into current org buffer, and generate og:image.
    `BACKEND' is the export back-end being used, as a symbol."
  (when (eq backend 'html)
    (let ((element-map (org-element-map (org-element-parse-buffer)
                           'headline
                         (lambda (hl)
                           (when (and (= (org-element-property :level hl) 1) (org-element-property :tags hl))
                             (let ((tags (org-element-property :tags hl)))
                               (when tags
                                 (list :row-value (org-element-property :raw-value hl)
                                       :begin (org-element-property :begin hl)))))))))
      ;; -- TOOD: implements
      )))

このサイトの各記事ではタイトル名はorg文書としては最初のタグを有しているトップレベルヘッドラインの事です. それ以外は無関係な情報ですので, 余計なパースをしないように除外しています. そして, ここで取得可能なmetadataとして必要な情報はヘッドラインの生のテキストから取得できるタイトルと, blog-posts-html-head-extra-baseを置換した情報を埋め込むためのバッファポジションなので, :row-valueと:beginをorg-element-property経由で取得してplistとしてまとめます.

ヘッドラインからはデスクリプションは取得できないので, 元々のorg文書に #+DESCRIPTION: がある場合にそれを取得するようにします.

(defun blog-description-text ()
  "Get blog description."
  (save-excursion
    (goto-char (point-min))
    (if (re-search-forward "^#\\+DESCRIPTION:\\(.+\\)$" nil 1)
        (string-trim (match-string 1))
      "")))

このサイトの記事のトップレベルヘッドラインはマクロによって, 投稿日, タイトル名, タグが並んでいます. org-export-before-parsing-hook はマクロを置換した後に呼ばれるフックですので, raw-valueプロパティには投稿日とタイトル名からなる文字列となっています. このままこの文字列を利用すると投稿日も含まれてしまうので, それを除外します.

(string-trim (replace-regexp-in-string
              "^<[0-9][0-9][0-9][0-9]-[0-1][0-9]-[0-2][0-9].+?[0-9][0-9]?+:[0-5]?+[0-9]> "
              ""
              (plist-get plist :row-value)))

これらを組み合わせて, blog-posts-html-head-extra-base を置換して現在処理しているorg文書に挿入します.

(defun blog-posts-add-ogp-metadata (backend)
  "Add HTML_EXTRA into current org buffer, and generate og:image.
  `BACKEND' is the export back-end being used, as a symbol."
  (when (eq backend 'html)
    (let ((element-map (org-element-map (org-element-parse-buffer)
                   'headline
                 (lambda (hl)
                   (when (and (= (org-element-property :level hl) 1) (org-element-property :tags hl))
                     (let ((tags (org-element-property :tags hl)))
                       (when tags
                         (list :row-value (org-element-property :raw-value hl)
                               :begin (org-element-property :begin hl)))))))))
      (when (and element-map (listp element-map))
        (let* ((plist (car element-map))
               (org-file-base (file-name-base (buffer-file-name)))
               (headline-begin-point (plist-get plist :begin))
               (title-text (string-trim (replace-regexp-in-string
                                         "^<[0-9][0-9][0-9][0-9]-[0-1][0-9]-[0-2][0-9].+?[0-9][0-9]?+:[0-5]?+[0-9]> "
                                         ""
                                         (plist-get plist :row-value))))
               (description (blog-post-description-text)))
          (save-excursion
            (goto-char headline-begin-point)
            ;; replace-regexp-in-string の第四引数を非nilにしておかないと置換元に合わせて全部大文字になる
            (insert (replace-regexp-in-string
                     "__DESCRIPTION__"
                     description
                     (replace-regexp-in-string
                      "__TYPE__"
                      "article"
                      (replace-regexp-in-string
                       "__FILEBASE__"
                       org-file-base
                       (replace-regexp-in-string
                        "__TITLE__"
                        title-text
                        blog-posts-html-head-extra-base
                        t)
                       t)
                      t)
                     t))))))))

ここまでで, metadataは構築されました. しかし, og:image に該当する画像ファイルがまだないので, この画像を生成する必要があります.

現在このサイトはCloudflare Pagesでホストされており, Cloudflare Pagesではimagemagickが利用可能となっています. ですので, imagemagickでタイトルを含む画像を生成して, それをog:imageの画像とします.

まずは背景となる画像を作成します. AdobeやAffinity系のツールで作成する能力がないので, imagemagickで適当に作ります.

convert -size 650x315 xc:#4169E1 -bordercolor white -border 6x6 ./og_images/template.png

これで単色の背景と周囲に枠を置く簡単な画像が, og_images/template.png として作成されました.

次にこの画像に各記事のタイトルを設定して記事用の画像を生成する処理を記述します. タイトルはblog-posts-add-ogp-metadata 関数内で取得しているので, その中でimagemagickを起動する関数を呼び出すようにすると簡単そうです.

(defun blog-posts-add-ogp-metadata (backend)
  ;; ...... 略 .......
  (save-excursion
    (goto-char headline-begin-point)
    ;; replace-regexp-in-string の第四引数を非nilにしておかないと置換元に合わせて全部大文字になる
    (insert (replace-regexp-in-string
             "__DESCRIPTION__"
             description
             (replace-regexp-in-string
              "__TYPE__"
              "article"
              (replace-regexp-in-string
               "__FILEBASE__"
               org-file-base
               (replace-regexp-in-string
                "__TITLE__"
                title-text
                blog-posts-html-head-extra-base
                t)
               t)
              t)
             t)))
  (blog-generate-og-image-file title-text org-file-base) ;; 画像生成用関数を最後に呼び出すようにする
  )

imagemagickで文字を画像にするためにはフォントファイルが別途必要となります. 私は NotoSansCJKjp を利用するようにしました. そして, 先程作ったテンプレート画像のファイルと出力ファイルを設定し, call-processでconvertを呼び出すようにします.

(defvar blog-og-image-font-file "/path/to/you/font-file")

(blog-generate-og-image-file (title base-file-name)
                             ""
                             (let ((output-png (expand-file-name (format "../og_images/%s.png" base-file-name) default-directory))
                                   (template-png (expand-file-name (format "../og_images/template.png") default-directory)))
                               (call-process "convert" nil nil nil
                                             "-size" "650x315"
                                             "-font" blog-og-image-font-file
                                             "-gravity" "center"
                                             "-background" "none"
                                             "-fill" "white"
                                             "-pointsize" "32"
                                             "-annotate" "0" (format "%s" (blog-generate-og-image-title title))
                                             template-png
                                             output-png)))


画像サイズの幅は650x315, 文字のサイズを32に指定しているので, だいたい20文字くらいで横幅が一杯になります. そのため横幅を超えそうになると自動で改行を入れる調整機構が必要となります. その調整をしているのが, blog-generate-og-image-title です. 汎用的に作っているわけではないので(駄目なやつ), 横に収まる文字数を最大20文字として, タイトル文字列の文字数がそれを超えたら自動改行を入れるようにしました. ただし, 句読点などは禁則処理として改行ポイントに来た場合に限りそれを残した上で改行するようにしています.

(unless (require 'tinysegmenter nil t)
  (message "tinysegmenter not found."))

;; inputの先頭から数えて最初に末尾禁則にあたらない文字がきたindexを返す
;; 全て禁則の場合はnil
(defun blog-utility-japanese-hyphenation-point (input)
  (let ((ignore-chars ",. \t]\"'>)}、。」;!?!?") ;; タイトルに使う分にはこんなもんじゃろ
        (split-input (split-string input "" t)))
    (dolist (ch split-input)
      (unless (search ch ignore-chars)
        (return (search ch input))))))

(defun blog-generate-og-image-title (title)
  (let* ((input-length (length title))
         (current-length 0)
         (segments (tseg-segment title))
         (output "")
         (max-length 20))
    (dolist (segment segments)
      (let ((segment-length (length segment)))
        (if (<= (+ current-length segment-length) max-length)
            (progn
              (setq output (concat output segment))
              (setq current-length (+ current-length segment-length)))
          (let ((japanese-hyphenation-point (blog-utility-japanese-hyphenation-point segment)))
            (unless (null japanese-hyphenation-point) ;; 禁則処理をする
              (setq output (concat output
                                   (substring segment
                                              0
                                              japanese-hyphenation-point))))
            (setq current-length 0)
            (setq output (concat output "\n"));; 改行をして2行目に移る
            (unless japanese-hyphenation-point
              (setq japanese-hyphenation-point 0))
            (setq output (concat output
                                 (substring segment
                                            japanese-hyphenation-point)))))))
    output))

英語の事を考えた時に正しくハイフネーションをするのも面倒だったので, tinysegmenter を利用してワード毎に分割, その分割したワード毎に処理をするようにしています.

最後に, og_imaegs内の画像をpublish先ディレクトリにコピーするprojectを設定します. この時, og_images/template.pngまでコピーされるのを防ぐため, :exclude で除外するようにします.

(add-to-list 'org-publish-project-alist
             '("og-images"
               :base-directory "./og_images"
               :base-extension "png"
               :publishing-directory "./publish"
               :publishing-function org-publish-attachment
               :exclude "template.png"
               :recursive t
               :author nil))

;; 事前に記事をpublishした上で……
(org-publish "og-images")

これで各記事毎のメタ情報の設定を自動で行えるようになりました.

生成されたこの記事の画像

このままだと, og:imageの画像生成は毎回作るようになっているため, 記事がうっかり増えてしまうと色々大変なので, 追々キャッシュするようにすると思いますが, ひとまずはこんな形で.