Maschine MK1 を USB-MIDI 機器にするブリッジを作る

Native Instruments Maschine MK1 は2009年の発売から17年が経っているが、その打鍵感や感度などは現行機と負けず劣らず素晴らしい。 Mottainai の精神からも、できれば MK1 を使い続けたいが、Native Instruments によるソフトウェアサポートは終了しており 、Maschine MK1 自体は USB-MIDI 機器ではなく独自の USB 通信プロトコルを用いているため、Maschine ソフトウェアと連携できなければ MIDI 機器として使うことはできない。

Maschine MK1 の USB 通信プロトコルを解析したとする記事はいくつか存在するが、USB-MIDI 機器として可搬性のある形で Maschine MK1 を再活用するという記事は存在しなかったため、自ら制作してみた。


www.youtube.com

tl;dr

  1. Teensy 4.1, USB Host Cable For Teensy 3.6 or Teensy 4.1, 2.54mmピッチピンヘッダ(5本), Maschine MK1 を入手して組み合わせる。
  2. Teensy のサイト を参考に Arduino IDE で Teensy 用のコードをコンパイルできるようにする。
  3. GitHub にあるコードをコンパイルし Teensy 4.1 に書き込む。
  4. You now have a USB-MIDI Maschine MK1!

以下はここに至るまでの苦労譚なので読まなくともよい。

情報収集

同様のことをやろうとしてまず得られる情報は shaduzlabs/cabl のプロジェクトだろう。Maschine MK1 のみならず他の Native Instruments 社製コントローラーの独自ドライバを制作している野心的なプロジェクトだが、あくまでもドライバなので PC がないと使用することができない(Arduino などでも使えるとの記載もあるが、詳細不明)。しかし このプロジェクトのコード には、どうやって Maschine MK1 と通信すればいいか書かれており、これは USB-MIDI ブリッジ制作のための重要な情報源となった。

また、実は Linux には Maschine MK1 のドライバが搭載されている ので、このコードも参考になった。パッド・ノブ・ボタン・MIDI端子・LED・バックライトのサポートは実装されているが、LCD のサポートは実装されていない。幸い LCD との通信は cabl に実装されているのでそちらを参考にすればよい。

マイコンの選定

今回は Teensy 4.1 というかなり高速 (600 MHz) なマイコンを使用した。価格も高くオーバーキルに思えるかもしれないが、Maschine MK1 は USB 2.0(最大転送速度 480 Mbps)を要求するため、それをホストするためにはある程度高速なマイコンが必要となる。おそらく USB 2.0 のホストとなれるマイコンであればほかのマイコンでも同様のものが制作可能だと思われるが、Teensy 4.1 であれば USB ホストケーブルも販売されており利用開始までのハードルが低めだと思うのでお勧めである。

Vibe coding by Codex

情報収集と技術選定さえできてしまえば、あとは AI でポン! できる時代になった。実際、今回のコードは自分ではほぼ書いていないし見てもいない(スレッショルドなどの定数をすこしいじった程度)。 ポイントとしては、上で収集した情報をごちゃっとまとめて作業ディレクトリの references に入れておいて、適宜参照してね、と指示することで、その内容から大きく外れない実装をしてくれる。 生成されたコードは こちら

コンパイル

Teensy を Arduino IDE でコンパイルする方法については 公式サイト を参照。 Arduino IDE にボードを追加すればコンパイルできるようになる。

  • Tools > Board は Teensy 4.1 を選択。
  • Tools > USB Type は Serial + MIDI を選択。

ボードへのアップロード方法が通常の Arduino と異なり、IDE 上で Verify をしてコンパイルが完了すると自動的に Teensy Loader が起動され、その状態でボード側のボタンを押すとコンパイルした内容がアップロードされる。Arduino IDE の Upload ボタンを押す必要はない。

おすすめの鼻水吸引器

育児をしていると、子供の鼻水を吸う必要があることがある。鼻水を吸引するための道具は各社から販売されているが、どれも使いづらく感じていた。4社ぐらいの吸引器を使用してみた感想として、

Smart Angel (西松屋プライベートブランド) 鼻水吸引ポンプ(手動ピストン式)

https://www.24028.jp/smartangel/sa-product/detail?_type=post&_id=160236

のポンプ部分に、

丹平製薬 ママ鼻水トッテ

https://www.tampei.co.jp/products/MH/

のボトル以遠を接続したものが使いやすいと感じたので共有する。接続すると次の画像のようになる。

鼻水吸引器

ポンプ部分はピストン式が使いやすい一方で、ノズル部分はママ鼻水トッテのものの形状が小児科でも使われていて使いやすい。これらのいいとこ取りをするには接続すればよいのだと思いついてやってみたら大変使いやすかった。チューブの径がピッタリ合ったのが幸いであった。試すときはもちろん自己責任でお願いします。

PDF に見開きや綴じ方向などの情報を付与する

PDF ファイルは推奨される表示方法(見開き・綴じ方向など)をファイル内に情報として持つことができる。しかし書籍を自炊して得られた PDF ファイルはこの情報を持たないことが多い(少なくとも ScanSnap には自動でこの情報を付与する機能はなさそうである)。これらの情報は、Adobe Acrobat があれば GUI で設定可能だが、無い場合は設定できるソフトが意外と無い。これらの情報の付与を C# で行うコードを公開してくださっているかたがいたので(下のURL)、ChatGPT を用いた vibe coding によってそのロジックを Python (+pikepdf) に移植した。コードはこのブログの末尾に示す。

https://qiita.com/TETSURO1999/items/2bf3bb129b6a7045ac01

Tkinter および pikepdf が利用可能な Python 環境で以下のようにコードを実行すると GUI で操作できる。

% python pdf_catalog_set_gui.py

1ページ目が表紙になっている右綴じ本(右から左に読む本)は「和書など」のプリセットを、同様の左綴じ本は「洋書など」のプリセットを使用すればよい。設定される見開き・綴じ方向が想定の通りとなることは、Adobe Acrobat Reader と Skim (いずれも macOS 版)で確認した。

PDF ファイルの catalog 設定と実際の表示との間の齟齬について

このプログラムを作っている際に、PDF の仕様に関して違和感を覚えた点を記しておく。

下の URL に記載があるように、PDF の見開き設定は PageLayout 情報、綴じ方向の設定は ViewerPreferences の Direction 情報として保存されている。

https://qiita.com/TETSURO1999/items/e7a69026bdf8b5e8c631

PageLayoutTwoPageLeft は「一度に2ページを、奇数ページを左側に表示する」という意味で、 TwoPageRight は「一度に2ページを、奇数ページを右側に表示する」という意味となっている。

また、ViewerPreferences の Direction は、 L2R は「左から右へ読む」、 R2L は「右から左へ読む」という意味となっている。

表紙の有無はこれらの情報の組み合わせで表現されている( TwoPageRight でありかつ L2R ならば、1ページ目は単独で表紙として存在するだろう、といった形)。

この定義をそのまま受け取ると、日本語の縦書き書籍は奇数ページが左にあり、かつ右から左に読むので、 TwoPageLeft かつ R2L を同時に設定するのが正しいように思える。しかしこう設定すると想定される挙動とはならず、書籍と同じように表示するためには TwoPageRight かつ R2L を設定しなければならない。Adobe Acrobat Reader でも Skim でも挙動は共通である。おそらく、 PageLayout の左右の定義が R2L では逆転するという実装になっていると思われる。

PDF の仕様書(下のURL)を見ると、TwoPageLeft / TwoPageRight は “Display the pages two at a time, with odd-numbered pages on the left/right” と定義されているようなので、これを文字通り取るならば Adobe Acrobat Reader や Skim の挙動は誤った実装と思われる。

PDF注意! https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf

しかしデファクトスタンダードである Adobe Acrobat Reader がそのような実装になっている以上、それに従うのが良いだろう。仕様より実装が優先する(ダメ人間)。

コード

以下のコードに pdf_catalog_set_gui.py というファイル名を付けて保存して使用してください。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
pdf_catalog_set_gui.py

GUI tool to set PDF Catalog entries:
  - /PageLayout
  - /ViewerPreferences << /Direction /R2L|L2R >>

Workflow:
  Open PDF with pikepdf -> edit Catalog -> save (xref rebuilt)

Features:
- Japanese presets (radio buttons) such as:
    "右綴じ(縦書き)表紙あり(和書など)" => TwoPageRight + R2L
    "左綴じ(横書き)表紙あり(洋書など)" => TwoPageRight + L2R
    "右綴じ(縦書き)表紙なし" => TwoPageLeft  + R2L
    "左綴じ(横書き)表紙なし" => TwoPageLeft  + L2R
- Manual controls via checkboxes + comboboxes
- Drag & drop (if tkinterdnd2 installed), otherwise file picker
- Outputs files to the same folder with a suffix indicating the conversion.

Requirements:
- Python 3.x
- tkinter (usually bundled)
- pikepdf (required): pip install pikepdf
- Optional for drag&drop: pip install tkinterdnd2

Run:
  python3 pdf_catalog_set_gui.py
"""

import sys
import tkinter as tk
from tkinter import ttk, filedialog, messagebox
from dataclasses import dataclass
from pathlib import Path
from typing import Optional, List, Dict


# =============================================================================
# Presets (Japanese-friendly)
# =============================================================================

PRESETS: Dict[str, Optional[Dict[str, Optional[str]]]] = {
    "右綴じ(縦書き)表紙あり(和書など)": {"pagelayout": "TwoPageRight", "direction": "R2L"},
    "左綴じ(横書き)表紙あり(洋書など)": {"pagelayout": "TwoPageRight", "direction": "L2R"},
    "右綴じ(縦書き)表紙なし": {"pagelayout": "TwoPageLeft",  "direction": "R2L"},
    "左綴じ(横書き)表紙なし": {"pagelayout": "TwoPageLeft",  "direction": "L2R"},
    "単ページ表示": {"pagelayout": "SinglePage", "direction": None},
    "設定しない(手動指定)": None,
}

LAYOUT_CHOICES = [
    "SinglePage",
    "OneColumn",
    "TwoColumnLeft",
    "TwoColumnRight",
    "TwoPageLeft",
    "TwoPageRight",
]

DIRECTION_CHOICES = ["L2R", "R2L"]


@dataclass(frozen=True)
class CatalogEdits:
    set_pagelayout: bool
    pagelayout_value: Optional[str]  # e.g. "TwoPageRight"
    set_direction: bool
    direction_value: Optional[str]   # "R2L" or "L2R"
    remove_pagelayout: bool
    remove_direction: bool


def _normalize_layout(layout: Optional[str]) -> Optional[str]:
    if layout is None:
        return None
    layout = layout.strip()
    if layout.startswith("/"):
        layout = layout[1:]
    return layout or None


def _normalize_direction(direction: Optional[str]) -> Optional[str]:
    if direction is None:
        return None
    d = direction.strip()
    if d.startswith("/"):
        d = d[1:]
    d = d.upper()
    if d not in ("R2L", "L2R"):
        raise ValueError("direction must be R2L or L2R")
    return d
# =============================================================================
# Orchestration
# =============================================================================

def process_pdf(in_pdf: Path, out_pdf: Path, edits: CatalogEdits) -> None:
    try:
        import pikepdf  # type: ignore
    except Exception as e:
        raise RuntimeError(
            "pikepdf is required for editing. Install it with: pip install pikepdf"
        ) from e

    with pikepdf.Pdf.open(str(in_pdf)) as pdf:
        root = pdf.Root

        if edits.remove_pagelayout and not edits.set_pagelayout:
            if "/PageLayout" in root:
                del root["/PageLayout"]

        if edits.set_pagelayout:
            val = _normalize_layout(edits.pagelayout_value)
            if val is None:
                raise RuntimeError("PageLayout is enabled but empty.")
            root["/PageLayout"] = pikepdf.Name("/" + val)

        if edits.remove_direction and not edits.set_direction:
            vp = root.get("/ViewerPreferences")
            if isinstance(vp, pikepdf.Dictionary) and "/Direction" in vp:
                del vp["/Direction"]
                if len(vp) == 0:
                    del root["/ViewerPreferences"]

        if edits.set_direction:
            dv = _normalize_direction(edits.direction_value)
            if dv is None:
                raise RuntimeError("Direction is enabled but empty.")
            vp = root.get("/ViewerPreferences")
            if not isinstance(vp, pikepdf.Dictionary):
                vp = pikepdf.Dictionary()
                root["/ViewerPreferences"] = vp
            vp["/Direction"] = pikepdf.Name("/" + dv)

        pdf.save(str(out_pdf))


# =============================================================================
# GUI helpers
# =============================================================================

def make_output_path(input_path: Path, suffix_tag: str) -> Path:
    stem = input_path.stem
    ext = input_path.suffix or ".pdf"
    return input_path.with_name(f"{stem}{suffix_tag}{ext}")


def safe_suffix(edits: CatalogEdits) -> str:
    parts = []
    if edits.set_pagelayout and edits.pagelayout_value:
        parts.append(f"PL-{edits.pagelayout_value}")
    if edits.set_direction and edits.direction_value:
        parts.append(f"DIR-{edits.direction_value}")
    if edits.remove_pagelayout and not edits.set_pagelayout:
        parts.append("rmPL")
    if edits.remove_direction and not edits.set_direction:
        parts.append("rmDIR")
    if not parts:
        parts.append("noedit")
    return "_converted_" + "_".join(parts)


def parse_dnd_files(data: str) -> List[str]:
    """
    Parse DND payload like:
      {path with spaces} {path2} ...
    """
    data = data.strip()
    if not data:
        return []
    out: List[str] = []
    cur = ""
    in_brace = False
    for ch in data:
        if ch == "{":
            in_brace = True
            cur = ""
        elif ch == "}":
            in_brace = False
            if cur:
                out.append(cur)
            cur = ""
        elif ch.isspace() and not in_brace:
            if cur:
                out.append(cur)
                cur = ""
        else:
            cur += ch
    if cur:
        out.append(cur)
    return out


# =============================================================================
# GUI App
# =============================================================================

class App:
    def __init__(self, root: tk.Tk):
        self.root = root
        root.title("PDF 表示設定(PageLayout / Direction)")
        root.geometry("820x820")

        self.files: List[Path] = []

        # Preset selection
        self.var_preset = tk.StringVar(value="設定しない(手動指定)")

        # Manual options
        self.var_set_pl = tk.BooleanVar(value=True)
        self.var_pl = tk.StringVar(value="TwoPageRight")
        self.var_rm_pl = tk.BooleanVar(value=False)

        self.var_set_dir = tk.BooleanVar(value=True)
        self.var_dir = tk.StringVar(value="R2L")
        self.var_rm_dir = tk.BooleanVar(value=False)

        self.var_suffix_mode = tk.StringVar(value="auto")  # auto / custom
        self.var_custom_suffix = tk.StringVar(value="_converted")

        # Layout
        frm = ttk.Frame(root, padding=12)
        frm.pack(fill="both", expand=True)

        # ---- Presets ----
        preset_frame = ttk.LabelFrame(frm, text="表示プリセット(おすすめ)", padding=10)
        preset_frame.pack(fill="x")

        # Use 2 columns for readability
        preset_names = list(PRESETS.keys())
        cols = 2
        for idx, name in enumerate(preset_names):
            r = idx // cols
            c = idx % cols
            rb = ttk.Radiobutton(
                preset_frame,
                text=name,
                value=name,
                variable=self.var_preset,
                command=self.apply_preset
            )
            rb.grid(row=r, column=c, sticky="w", padx=6, pady=2)
        for c in range(cols):
            preset_frame.grid_columnconfigure(c, weight=1)

        # ---- Manual ----
        opt = ttk.LabelFrame(frm, text="手動指定(必要なら微調整)", padding=10)
        opt.pack(fill="x", pady=(10, 0))

        row1 = ttk.Frame(opt)
        row1.pack(fill="x", pady=4)
        ttk.Checkbutton(row1, text="PageLayout を設定", variable=self.var_set_pl).pack(side="left")
        ttk.Label(row1, text="値:").pack(side="left", padx=(10, 4))
        ttk.Combobox(row1, textvariable=self.var_pl, values=LAYOUT_CHOICES, width=18, state="readonly").pack(side="left")
        ttk.Checkbutton(row1, text="PageLayout を削除", variable=self.var_rm_pl).pack(side="left", padx=(16, 0))

        row2 = ttk.Frame(opt)
        row2.pack(fill="x", pady=4)
        ttk.Checkbutton(row2, text="Direction を設定", variable=self.var_set_dir).pack(side="left")
        ttk.Label(row2, text="値:").pack(side="left", padx=(10, 4))
        ttk.Combobox(row2, textvariable=self.var_dir, values=DIRECTION_CHOICES, width=8, state="readonly").pack(side="left")
        ttk.Checkbutton(row2, text="Direction を削除", variable=self.var_rm_dir).pack(side="left", padx=(16, 0))

        # ---- Output naming ----
        outname = ttk.LabelFrame(frm, text="出力ファイル名(入力と同じフォルダに保存)", padding=10)
        outname.pack(fill="x", pady=(10, 0))

        row3 = ttk.Frame(outname)
        row3.pack(fill="x")
        ttk.Radiobutton(row3, text="自動(設定内容を付与)", value="auto", variable=self.var_suffix_mode).pack(side="left")
        ttk.Radiobutton(row3, text="固定サフィックス:", value="custom", variable=self.var_suffix_mode).pack(side="left", padx=(10, 0))
        ttk.Entry(row3, textvariable=self.var_custom_suffix, width=20).pack(side="left", padx=(6, 0))
        ttk.Label(row3, text="例: _converted").pack(side="left", padx=(8, 0))

        # ---- Files ----
        lf = ttk.LabelFrame(frm, text="入力PDF(ドラッグ&ドロップ または 追加)", padding=10)
        lf.pack(fill="both", expand=True, pady=(10, 0))

        btnrow = ttk.Frame(lf)
        btnrow.pack(fill="x")

        ttk.Button(btnrow, text="ファイル追加…", command=self.add_files_dialog).pack(side="left")
        ttk.Button(btnrow, text="リストクリア", command=self.clear_files).pack(side="left", padx=8)
        ttk.Button(btnrow, text="変換実行", command=self.run).pack(side="right")

        self.listbox = tk.Listbox(lf, height=10)
        self.listbox.pack(fill="both", expand=True, pady=(8, 0))

        self.status = ttk.Label(frm, text="準備完了", relief="sunken", anchor="w")
        self.status.pack(fill="x", pady=(10, 0))

        self._setup_dnd_if_available()
        self.apply_preset()  # initialize from preset selection

    # --- DnD support ---
    def _setup_dnd_if_available(self):
        try:
            from tkinterdnd2 import DND_FILES  # type: ignore
            self.listbox.drop_target_register(DND_FILES)  # type: ignore
            self.listbox.dnd_bind("<<Drop>>", self._on_drop)  # type: ignore
            self.status.config(text="ドラッグ&ドロップ有効(tkinterdnd2)")
        except Exception:
            self.status.config(text="ドラッグ&ドロップ無効(tkinterdnd2未導入): 「ファイル追加…」を使用してください")

    def _on_drop(self, event):
        paths = parse_dnd_files(event.data)
        self.add_files([Path(p) for p in paths])

    # --- Preset logic ---
    def apply_preset(self):
        name = self.var_preset.get()
        preset = PRESETS.get(name)

        if preset is None:
            # manual mode: do not override manual settings
            return

        # Apply preset as "set" operations
        self.var_set_pl.set(True)
        self.var_rm_pl.set(False)
        self.var_pl.set(preset["pagelayout"] or "TwoPageRight")

        if preset.get("direction") is None:
            self.var_set_dir.set(False)
            self.var_rm_dir.set(False)
        else:
            self.var_set_dir.set(True)
            self.var_rm_dir.set(False)
            self.var_dir.set(preset["direction"] or "R2L")

    # --- File list operations ---
    def add_files_dialog(self):
        paths = filedialog.askopenfilenames(
            title="PDFを選択",
            filetypes=[("PDF files", "*.pdf"), ("All files", "*.*")]
        )
        if not paths:
            return
        self.add_files([Path(p) for p in paths])

    def add_files(self, paths: List[Path]):
        added = 0
        for p in paths:
            if not p.exists() or p.is_dir():
                continue
            if p not in self.files:
                self.files.append(p)
                self.listbox.insert("end", str(p))
                added += 1
        self.status.config(text=f"{added} 件追加(合計 {len(self.files)} 件)")

    def clear_files(self):
        self.files.clear()
        self.listbox.delete(0, "end")
        self.status.config(text="リストをクリアしました")

    # --- Build edits from GUI ---
    def _get_edits(self) -> CatalogEdits:
        set_pl = bool(self.var_set_pl.get())
        rm_pl = bool(self.var_rm_pl.get())
        set_dir = bool(self.var_set_dir.get())
        rm_dir = bool(self.var_rm_dir.get())

        # Resolve conflicts: prefer "set"
        if set_pl and rm_pl:
            messagebox.showwarning("注意", "PageLayout は「設定」と「削除」が両方ONです。設定を優先します。")
            rm_pl = False
        if set_dir and rm_dir:
            messagebox.showwarning("注意", "Direction は「設定」と「削除」が両方ONです。設定を優先します。")
            rm_dir = False

        return CatalogEdits(
            set_pagelayout=set_pl,
            pagelayout_value=self.var_pl.get() if set_pl else None,
            set_direction=set_dir,
            direction_value=self.var_dir.get() if set_dir else None,
            remove_pagelayout=rm_pl,
            remove_direction=rm_dir,
        )

    def _output_suffix(self, edits: CatalogEdits) -> str:
        if self.var_suffix_mode.get() == "custom":
            s = self.var_custom_suffix.get().strip()
            if not s:
                s = "_converted"
            if not s.startswith("_"):
                s = "_" + s
            return s
        return safe_suffix(edits)

    # --- Run conversion ---
    def run(self):
        if not self.files:
            messagebox.showinfo("情報", "入力ファイルがありません。ファイルを追加してください。")
            return

        try:
            edits = self._get_edits()
            if not any([edits.set_pagelayout, edits.set_direction, edits.remove_pagelayout, edits.remove_direction]):
                messagebox.showinfo("情報", "変更内容が指定されていません。")
                return

            suffix = self._output_suffix(edits)

            ok = 0
            failed = 0
            errors: List[str] = []

            for p in self.files:
                outp = make_output_path(p, suffix)
                self.status.config(text=f"処理中: {p.name} → {outp.name}")
                self.root.update_idletasks()

                try:
                    process_pdf(p, outp, edits)
                    ok += 1
                except Exception as e:
                    failed += 1
                    errors.append(f"{p.name}: {e}")

            if failed == 0:
                self.status.config(text=f"完了: {ok} 件変換しました")
                messagebox.showinfo("完了", f"{ok} 件変換しました。\n出力は入力と同じフォルダに保存しました。")
            else:
                self.status.config(text=f"完了: 成功 {ok} 件 / 失敗 {failed} 件")
                messagebox.showwarning("一部失敗", "いくつかのファイルで失敗しました:\n\n" + "\n".join(errors))

        except Exception as e:
            messagebox.showerror("エラー", str(e))


def main():
    # Prefer TkinterDnD root if available
    try:
        from tkinterdnd2 import TkinterDnD  # type: ignore
        root = TkinterDnD.Tk()
    except Exception:
        root = tk.Tk()

    # macOS scaling tweak (optional)
    try:
        if sys.platform == "darwin":
            root.tk.call("tk", "scaling", 1.0)
    except Exception:
        pass

    App(root)
    root.mainloop()


if __name__ == "__main__":
    main()

Skim がPDFファイルの見開きや表紙の有無、開き方向などの設定を記憶できるようにする

Skim はオープンソースの PDF ビューワである。

https://sourceforge.net/projects/skim-app/

2ページを見開きで並べる設定や、最初の1ページは表紙扱いとして見開きに含めない設定、ページを右から左の順に並べる設定などが可能であり、書籍を自炊した PDF ファイルの閲覧に適している。しかし残念ながらこれらの設定はファイルを閉じると忘れられてしまい、ファイルを開くたびに毎回設定し直さなければならない。

ファイル毎にこれらの設定を記憶する機能の追加が提案されたことはあるが、却下されている。

https://sourceforge.net/p/skim-app/feature-requests/1318/

この機能を実現するパッチを作ってくれた人(上の機能追加の提案者)がいるが、2013年のパッチであり、残念ながら2026年現在の Skim のコードにそのまま適用することはできない。

https://divnull.com/blog/2013/skim-hack

このパッチを最新版に適用できるように、また Right to Left の設定も記憶できるように、OpenAI Codex と vibe coding したところ、たぶん実装できたので、共有する。パッチはこの記事の末尾に掲載する。

ビルド方法

Skim のリポジトリから最新の安定版のコードをダウンロードする。たとえば次の URL にアクセスして、2026-01-27 時点の最新安定版である 1.7.12 のタグ (REL_1_7_12) を探し、リンク先の Download Snapshot からダウンロードする。

https://sourceforge.net/p/skim-app/code/HEAD/tree/tags/

ダウンロードしたファイルを解凍して、そのディレクトリ内に移動し、まずはパッチを dry-run して、

% patch -p1 --dry-run < skim_remember_view_settings.patch 
patching file NSDocument_SKExtensions.m
patching file SKBookmark.h
patching file SKBookmark.m
patching file SKBookmarkController.h
patching file SKBookmarkController.m
patching file SKMainDocument.m
patching file SKMainWindowController.m

dry run が上手くいったら実際にパッチを当てる。

% patch -p1 < skim_remember_view_settings.patch 

その後、XcodeSkim.xcodeproj を開き、

Product > Scheme > Skim

を選んだ上で、

Product > Scheme > Edit SchemeBuild ConfigurationRelease にする。

そして Product > Build としてビルドする。

Product > Show Build Folder in Finder からビルドしたファイルのあるディレクトリを開き、 Products/Release/Skim.appApplications フォルダにコピーする。以上。

ショートカットの作成

ついでに、前述の設定を素早く変更できるように、Settings アプリの Keyboard Shortcuts の App Shortcuts で Skim.app の Book Mode に Shift+Cmd+B を、Right to Left に Shift+Cmd+R を割り当てておき、Skim の設定からデフォルトの PDF 表示方法を Two Pages にしておくと、閲覧方法の設定がより快適になる。

皆様よき読書ライフを。

パッチ

このパッチを skim_remember_view_settings.patch というファイル名で保存して使用してください。

diff --git a/NSDocument_SKExtensions.m b/NSDocument_SKExtensions.m
index 86e0302..8042e48 100644
--- a/NSDocument_SKExtensions.m
+++ b/NSDocument_SKExtensions.m
@@ -105,7 +105,7 @@ - (BOOL)canExitPresentation { return NO; }
 #pragma mark Document Setup
 
 - (void)saveRecentDocumentInfo {
-    if ([[SKBookmarkController sharedBookmarkController] addRecentDocumentForURL:[self fileURL] pageIndex:NSNotFound snapshots:nil])
+    if ([[SKBookmarkController sharedBookmarkController] addRecentDocumentForURL:[self fileURL] pageIndex:NSNotFound displayMode:NSNotFound displaysAsBook:NO displaysRTL:NO snapshots:nil])
         [self setRecentInfoNeedsUpdate:NO];
 }
 
diff --git a/SKBookmark.h b/SKBookmark.h
index c6b89b5..664ef01 100644
--- a/SKBookmark.h
+++ b/SKBookmark.h
@@ -55,6 +55,7 @@ typedef NS_ENUM(NSInteger, SKBookmarkType) {
 + (NSArray<SKBookmark *> *)bookmarksForURLs:(NSArray<NSURL *> *)urls;
 
 - (nullable instancetype)initWithURL:(NSURL *)aURL pageIndex:(NSUInteger)aPageIndex snapshots:(nullable NSArray<NSDictionary<NSString *, id> *> *)aSnapshots label:(nullable NSString *)aLabel;
+- (nullable instancetype)initWithURL:(NSURL *)aURL pageIndex:(NSUInteger)aPageIndex displayMode:(PDFDisplayMode)displayMode displaysAsBook:(BOOL)displaysAsBook displaysRTL:(BOOL)displaysRTL snapshots:(nullable NSArray<NSDictionary<NSString *, id> *> *)aSnapshots label:(nullable NSString *)aLabel;
 - (nullable instancetype)initWithSetup:(NSDictionary<NSString *, id> *)aSetupDict label:(nullable NSString *)aLabel;
 - (nullable instancetype)initFolderWithChildren:(nullable NSArray<SKBookmark *> *)aChildren label:(nullable NSString *)aLabel;
 - (nullable instancetype)initRootWithChildrenProperties:(nullable NSArray<NSDictionary<NSString *, id> *> *)childrenProperties;
diff --git a/SKBookmark.m b/SKBookmark.m
index 86bfb54..ede9d51 100644
--- a/SKBookmark.m
+++ b/SKBookmark.m
@@ -57,6 +57,9 @@ This software is Copyright (c) 2007
 #define CHILDREN_KEY    @"children"
 #define LABEL_KEY       @"label"
 #define PAGEINDEX_KEY   @"pageIndex"
+#define DISPLAYMODE_KEY @"displayMode"
+#define BOOKMODE_KEY    @"displayAsBook"
+#define DISPLAYSRTL_KEY @"displaysRTL"
 #define ALIASDATA_KEY   @"_BDAlias"
 #define BOOKMARK_KEY    @"bookmark"
 #define TYPE_KEY        @"type"
@@ -148,6 +151,11 @@ - (instancetype)initWithURL:(NSURL *)aURL pageIndex:(NSUInteger)aPageIndex snaps
     return nil;
 }
 
+- (instancetype)initWithURL:(NSURL *)aURL pageIndex:(NSUInteger)aPageIndex displayMode:(PDFDisplayMode)displayMode displaysAsBook:(BOOL)displaysAsBook displaysRTL:(BOOL)displaysRTL snapshots:(NSArray *)aSnapshots label:(NSString *)aLabel {
+    [self doesNotRecognizeSelector:_cmd];
+    return nil;
+}
+
 - (instancetype)initWithSetup:(NSDictionary *)aSetupDict label:(NSString *)aLabel {
     [self doesNotRecognizeSelector:_cmd];
     return nil;
@@ -324,6 +332,10 @@ - (instancetype)initWithURL:(NSURL *)aURL pageIndex:(NSUInteger)aPageIndex snaps
     return (id)[[SKFileBookmark alloc] initWithURL:aURL pageIndex:aPageIndex snapshots:aSnapshots label:aLabel];
 }
 
+- (instancetype)initWithURL:(NSURL *)aURL pageIndex:(NSUInteger)aPageIndex displayMode:(PDFDisplayMode)displayMode displaysAsBook:(BOOL)displaysAsBook displaysRTL:(BOOL)displaysRTL snapshots:(NSArray *)aSnapshots label:(NSString *)aLabel {
+    return (id)[[SKFileBookmark alloc] initWithURL:aURL pageIndex:aPageIndex displayMode:displayMode displaysAsBook:displaysAsBook displaysRTL:displaysRTL snapshots:aSnapshots label:aLabel];
+}
+
 - (instancetype)initWithSetup:(NSDictionary *)aSetupDict label:(NSString *)aLabel {
     return (id)[[SKFileBookmark alloc] initWithSetup:aSetupDict label:aLabel];
 }
@@ -454,6 +466,31 @@ - (instancetype)initWithURL:(NSURL *)aURL pageIndex:(NSUInteger)aPageIndex snaps
     return self;
 }
 
+- (instancetype)initWithURL:(NSURL *)aURL pageIndex:(NSUInteger)aPageIndex displayMode:(PDFDisplayMode)displayMode displaysAsBook:(BOOL)displaysAsBook displaysRTL:(BOOL)displaysRTL snapshots:(NSArray *)aSnapshots label:(NSString *)aLabel {
+    self = [super init];
+    if (self) {
+        alias = [[SKAlias alloc] initWithURL:aURL];
+        if (alias) {
+            pageIndex = aPageIndex;
+            label = [aLabel copy];
+            NSMutableDictionary *mutableSetup = nil;
+            if ([aSnapshots count] > 0)
+                mutableSetup = [NSMutableDictionary dictionaryWithObject:aSnapshots forKey:SNAPSHOTS_KEY];
+            if (displayMode != NSNotFound) {
+                if (mutableSetup == nil)
+                    mutableSetup = [NSMutableDictionary dictionary];
+                [mutableSetup setObject:[NSNumber numberWithInteger:displayMode] forKey:DISPLAYMODE_KEY];
+                [mutableSetup setObject:[NSNumber numberWithBool:displaysAsBook] forKey:BOOKMODE_KEY];
+                [mutableSetup setObject:[NSNumber numberWithBool:displaysRTL] forKey:DISPLAYSRTL_KEY];
+            }
+            setup = [mutableSetup count] > 0 ? [mutableSetup copy] : nil;
+        } else {
+            self = nil;
+        }
+    }
+    return self;
+}
+
 - (instancetype)initWithSetup:(NSDictionary *)aSetupDict label:(NSString *)aLabel {
     self = [super init];
     if (self) {
@@ -466,14 +503,32 @@ - (instancetype)initWithSetup:(NSDictionary *)aSetupDict label:(NSString *)aLabe
             NSNumber *pageIndexNumber = [aSetupDict objectForKey:PAGEINDEX_KEY];
             pageIndex = pageIndexNumber ? [pageIndexNumber unsignedIntegerValue] : NSNotFound;
             label = [aLabel copy];
+            NSMutableDictionary *mutableSetup = nil;
             if ([aSetupDict objectForKey:SKDocumentSetupWindowFrameKey]) {
-                setup = [aSetupDict mutableCopy];
-                [(NSMutableDictionary *)setup removeObjectsForKeys:@[ALIASDATA_KEY, BOOKMARK_KEY, PAGEINDEX_KEY, LABEL_KEY]];
+                mutableSetup = [aSetupDict mutableCopy];
+                [mutableSetup removeObjectsForKeys:@[ALIASDATA_KEY, BOOKMARK_KEY, PAGEINDEX_KEY, LABEL_KEY]];
             } else if ([aSetupDict objectForKey:SNAPSHOTS_KEY]) {
-                setup = @{SNAPSHOTS_KEY:[aSetupDict objectForKey:SNAPSHOTS_KEY]};
-            } else {
-                setup = nil;
+                mutableSetup = [NSMutableDictionary dictionaryWithObject:[aSetupDict objectForKey:SNAPSHOTS_KEY] forKey:SNAPSHOTS_KEY];
+            }
+            NSNumber *displayModeNumber = [aSetupDict objectForKey:DISPLAYMODE_KEY];
+            if (displayModeNumber) {
+                if (mutableSetup == nil)
+                    mutableSetup = [NSMutableDictionary dictionary];
+                [mutableSetup setObject:displayModeNumber forKey:DISPLAYMODE_KEY];
+            }
+            NSNumber *bookModeNumber = [aSetupDict objectForKey:BOOKMODE_KEY];
+            if (bookModeNumber) {
+                if (mutableSetup == nil)
+                    mutableSetup = [NSMutableDictionary dictionary];
+                [mutableSetup setObject:bookModeNumber forKey:BOOKMODE_KEY];
+            }
+            NSNumber *displaysRTLNumber = [aSetupDict objectForKey:DISPLAYSRTL_KEY];
+            if (displaysRTLNumber) {
+                if (mutableSetup == nil)
+                    mutableSetup = [NSMutableDictionary dictionary];
+                [mutableSetup setObject:displaysRTLNumber forKey:DISPLAYSRTL_KEY];
             }
+            setup = [mutableSetup count] > 0 ? [mutableSetup copy] : nil;
         } else {
             self = nil;
         }
diff --git a/SKBookmarkController.h b/SKBookmarkController.h
index 37d49d9..45d32e4 100644
--- a/SKBookmarkController.h
+++ b/SKBookmarkController.h
@@ -96,7 +96,10 @@ NS_ASSUME_NONNULL_BEGIN
 - (void)removeBookmarkAtIndex:(NSUInteger)anIndex ofBookmark:(SKBookmark *)parent animate:(BOOL)animate;
 - (void)replaceBookmarkAtIndex:(NSUInteger)anIndex ofBookmark:(SKBookmark *)parent withBookmark:(SKBookmark *)bookmark animate:(BOOL)animate;
 
-- (BOOL)addRecentDocumentForURL:(NSURL *)fileURL pageIndex:(NSUInteger)pageIndex snapshots:(nullable NSArray<NSDictionary<NSString *, id> *> *)snapshots;
+- (BOOL)addRecentDocumentForURL:(NSURL *)fileURL pageIndex:(NSUInteger)pageIndex  displayMode:(PDFDisplayMode)displayMode displaysAsBook:(BOOL)displaysAsBook displaysRTL:(BOOL)displaysRTL snapshots:(nullable NSArray<NSDictionary<NSString *, id> *> *)snapshots;
+- (NSUInteger)displayModeForRecentDocumentAtURL:(NSURL *)fileURL;
+- (NSUInteger)displaysAsBookForRecentDocumentAtURL:(NSURL *)fileURL;
+- (NSUInteger)displaysRTLForRecentDocumentAtURL:(NSURL *)fileURL;
 - (nullable SKBookmark *)recentDocumentAtURL:(NSURL *)fileURL;
 
 - (BOOL)isBookmarkExpanded:(SKBookmark *)bookmark;
diff --git a/SKBookmarkController.m b/SKBookmarkController.m
index 9783b02..8aeabab 100644
--- a/SKBookmarkController.m
+++ b/SKBookmarkController.m
@@ -80,6 +80,9 @@ This software is Copyright (c) 2007
 #define RECENTDOCUMENTS_KEY @"recentDocuments"
 
 #define PAGEINDEX_KEY @"pageIndex"
+#define BOOKMODE_KEY  @"displayAsBook"
+#define DISPLAYMODE_KEY    @"displayMode"
+#define DISPLAYSRTL_KEY    @"displaysRTL"
 #define CHILDREN_KEY @"children"
 #define LABEL_KEY    @"label"
 
@@ -105,6 +108,7 @@ - (void)handleApplicationWillTerminateNotification:(NSNotification *)notificatio
 - (void)endEditing;
 - (void)startObservingBookmarks:(NSArray *)newBookmarks;
 - (void)stopObservingBookmarks:(NSArray *)oldBookmarks;
+- (NSDictionary *)recentDocumentInfoAtURL:(NSURL *)fileURL;
 @property (nonatomic, readonly) NSUndoManager *undoManager;
 @end
 
@@ -260,12 +264,17 @@ - (SKBookmark *)recentDocumentAtURL:(NSURL *)fileURL {
     return nil;
 }
 
-- (BOOL)addRecentDocumentForURL:(NSURL *)fileURL pageIndex:(NSUInteger)pageIndex snapshots:(NSArray *)snapshots {
+- (NSDictionary *)recentDocumentInfoAtURL:(NSURL *)fileURL {
+    SKBookmark *recentDocument = [self recentDocumentAtURL:fileURL];
+    return [recentDocument properties];
+}
+
+- (BOOL)addRecentDocumentForURL:(NSURL *)fileURL pageIndex:(NSUInteger)pageIndex displayMode:(PDFDisplayMode)displayMode displaysAsBook:(BOOL)displaysAsBook displaysRTL:(BOOL)displaysRTL snapshots:(NSArray *)snapshots {
     if (maxRecentDocumentsCount <= 0 || appIsTerminating)
         return YES;
     
     if (fileURL) {
-        SKBookmark *info = [[SKBookmark alloc] initWithURL:fileURL pageIndex:pageIndex snapshots:snapshots label:nil];
+        SKBookmark *info = [[SKBookmark alloc] initWithURL:fileURL pageIndex:pageIndex displayMode:displayMode displaysAsBook:displaysAsBook displaysRTL:displaysRTL snapshots:snapshots label:nil];
         if (info) {
             SKBookmark *oldInfo = [self recentDocumentAtURL:fileURL];
             if (oldInfo)
@@ -284,6 +293,27 @@ - (BOOL)addRecentDocumentForURL:(NSURL *)fileURL pageIndex:(NSUInteger)pageIndex
     return NO;
 }
 
+- (NSUInteger)displayModeForRecentDocumentAtURL:(NSURL *)fileURL {
+    if (fileURL == nil)
+        return NSNotFound;
+    NSNumber *displayMode = [[self recentDocumentInfoAtURL:fileURL] objectForKey:DISPLAYMODE_KEY];
+    return displayMode == nil ? NSNotFound : [displayMode unsignedIntegerValue];
+}
+
+- (NSUInteger)displaysAsBookForRecentDocumentAtURL:(NSURL *)fileURL {
+    if (fileURL == nil)
+        return NSNotFound;
+    NSNumber *displaysAsBook = [[self recentDocumentInfoAtURL:fileURL] objectForKey:BOOKMODE_KEY];
+    return displaysAsBook == nil ? NSNotFound : [displaysAsBook unsignedIntegerValue];
+}
+
+- (NSUInteger)displaysRTLForRecentDocumentAtURL:(NSURL *)fileURL {
+    if (fileURL == nil)
+        return NSNotFound;
+    NSNumber *displaysRTL = [[self recentDocumentInfoAtURL:fileURL] objectForKey:DISPLAYSRTL_KEY];
+    return displaysRTL == nil ? NSNotFound : [displaysRTL unsignedIntegerValue];
+}
+
 #pragma mark Bookmarks support
 
 - (SKBookmark *)bookmarkForURL:(NSURL *)bookmarkURL {
diff --git a/SKMainDocument.m b/SKMainDocument.m
index d2c89d5..4fe4e4d 100644
--- a/SKMainDocument.m
+++ b/SKMainDocument.m
@@ -239,8 +239,11 @@ - (void)saveRecentDocumentInfo {
         return;
     NSURL *fileURL = [self fileURL];
     NSUInteger pageIndex = [[[self pdfView] currentPage] pageIndex];
+    PDFDisplayMode displayMode = [[self pdfView] displayMode];
+    BOOL displaysAsBook = [[self pdfView] displaysAsBook];
+    BOOL displaysRTL = [[self pdfView] displaysRTL];
     NSArray *snapshots = [[[self primaryWindowController] snapshots] valueForKey:SKSnapshotCurrentSetupKey];
-    if ([[SKBookmarkController sharedBookmarkController] addRecentDocumentForURL:fileURL pageIndex:pageIndex snapshots:[snapshots count] > 0 ? snapshots : nil])
+    if ([[SKBookmarkController sharedBookmarkController] addRecentDocumentForURL:fileURL pageIndex:pageIndex displayMode:displayMode displaysAsBook:displaysAsBook displaysRTL:displaysRTL snapshots:[snapshots count] > 0 ? snapshots : nil])
         [self setRecentInfoNeedsUpdate:NO];
 }
 
diff --git a/SKMainWindowController.m b/SKMainWindowController.m
index c495844..ae6072e 100644
--- a/SKMainWindowController.m
+++ b/SKMainWindowController.m
@@ -150,6 +150,10 @@ This software is Copyright (c) 2006
 #define LOCKED_KEY                  @"locked"
 #define CROPBOXES_KEY               @"cropBoxes"
 
+#define BOOKMODE_KEY  @"displayAsBook"
+#define DISPLAYMODE_KEY    @"displayMode"
+#define DISPLAYSRTL_KEY    @"displaysRTL"
+
 #define TRANSITION_KEY      @"transition"
 #define PAGETRANSITIONS_KEY @"pageTransitions"
 
@@ -446,6 +450,48 @@ - (void)windowDidLoad{
             }
         }
         
+        // Set display as book
+        NSUInteger displayAsBook = NSNotFound;
+        if (hasWindowSetup)
+            displayAsBook = [[savedNormalSetup objectForKey:BOOKMODE_KEY] boolValue];
+        else if ([sud boolForKey:SKRememberLastPageViewedKey])
+            displayAsBook = [[SKBookmarkController sharedBookmarkController] displaysAsBookForRecentDocumentAtURL:[[self document] fileURL]];
+        if (displayAsBook != NSNotFound) {
+            if ([[pdfView document] isLocked]) {
+                [savedNormalSetup setObject:[NSNumber numberWithBool:displayAsBook] forKey:BOOKMODE_KEY];
+            } else {
+                [pdfView setDisplaysAsBook:[[NSNumber numberWithBool:displayAsBook] boolValue]];
+            }
+        }
+        
+        // Set display mode
+        NSUInteger displayMode = NSNotFound;
+        if (hasWindowSetup)
+            displayMode = [[savedNormalSetup objectForKey:DISPLAYMODE_KEY] unsignedIntegerValue];
+        else if ([sud boolForKey:SKRememberLastPageViewedKey])
+            displayMode = [[SKBookmarkController sharedBookmarkController] displayModeForRecentDocumentAtURL:[[self document] fileURL]];
+        if (displayMode != NSNotFound) {
+            if ([[pdfView document] isLocked]) {
+                [savedNormalSetup setObject:[NSNumber numberWithUnsignedInteger:displayMode] forKey:DISPLAYMODE_KEY];
+            } else {
+                [pdfView setDisplayMode:[[NSNumber numberWithUnsignedInteger:displayMode] unsignedIntegerValue]];
+            }
+        }
+
+        // Set display RTL
+        NSUInteger displaysRTL = NSNotFound;
+        if (hasWindowSetup)
+            displaysRTL = [[savedNormalSetup objectForKey:DISPLAYSRTL_KEY] boolValue];
+        else if ([sud boolForKey:SKRememberLastPageViewedKey])
+            displaysRTL = [[SKBookmarkController sharedBookmarkController] displaysRTLForRecentDocumentAtURL:[[self document] fileURL]];
+        if (displaysRTL != NSNotFound) {
+            if ([[pdfView document] isLocked]) {
+                [savedNormalSetup setObject:[NSNumber numberWithBool:displaysRTL] forKey:DISPLAYSRTL_KEY];
+            } else {
+                [pdfView setDisplaysRTL:[[NSNumber numberWithBool:displaysRTL] boolValue]];
+            }
+        }
+        
         // We can fit only after the PDF has been loaded
         if (windowSizeOption == SKWindowOptionFit) {
             [[window contentView] layoutSubtreeIfNeeded];

2025年の振り返り

2025年初頭に、日記をブログでつけるようにしようかという記事を書いたが、結局19日坊主ぐらいになってしまった。それでもまぁいいかと自分を許せるようになってしまった。

今年の個人的ビッグニュースはやはり子供の誕生であろう。ワンオペや夜の授乳など大変だったが、生まれて数ヶ月もしたら夜はまるまる7時間ぐらい寝るようになったので思ったほど大変じゃなくなってきた。まぁこれから夜泣きとかが始まるのかもしれないけれど。

自己複製子として遺伝的試行を行いました。

楷ノ木かえで (@kainokikaede.bsky.social) 2025-06-21T14:53:50.056Z
bsky.app

ただし保育園に行き始めたら月に1回ぐらいは風邪を引くようになり、それに合わせてこちらも発熱することになったのには閉口している。忘年会の前日に39度出たときはガン萎えした。忘年会に参加するのにも前々からの調整が必要だというのに……。

雑感

今年の世間的な話題としては、やはりAIブームなのかな。エージェントとか使いこなしている友人がいる一方で自分はそのあたりぜんぜん整備できていない。整備する心の余裕がないし、ベストプラクティスが日々変わっていく現状において整備しようという気持ちがいまいち起こってこない。老化ですね。

音楽もAIで高クオリティのものが作れる時代。この作例めっちゃ好きなんだよな。

youtu.be/HN6NQqEceUc

楷ノ木かえで (@kainokikaede.bsky.social) 2025-06-23T08:52:15.542Z
bsky.app

2010年ごろに初めて宅鯖を建てて、それからVPSに移行して、ウェブサイトなどを細々と運営してきたが、セキュリティリスクなどを考慮して2025年内にすべて利用終了した。サークルサイトが無くなるのは惜しいなと思っていたが、Netlify + Hugo の構成でほぼ同一内容で復元できた。この構成なら、たとえば Netlify がサ終になったとしても別サービスで同じ内容を公開できるはずだし、やっぱ静的サイトしか勝たんな。

https://www.unvalance.com/

2月から5月ぐらいにかけてはクロスワードのオタクと化していた。bsky最速レベル(井の中の蛙)だったと思うが、これ以上の高速化が見込めなかったのでやめてしまった。

2025年には Switch2 も発売された。Switch1は楽しく遊ばせてもらった(オデッセイはN64を持っていなかった僕のマリオ欲を満たしてくれたし、2025年になって遊んだBotWは正直ほぼオーパーツレベルの楽しさであった)がSwitch2にはあまり食指が動いていない。理由の一つに、Steamの積みゲーが多すぎるからというのもある。ただし子供がいると、部屋に引きこもってゲームをプレイするという時間が取りづらい……。ということで早めにポータブルゲーミングPCを買って積みゲーの消化に努めたいと考えている。

アニメでいえば、ジークアクスは面白かった。けどなんかこれを面白いと思うのはファースト原理主義老害オタクって感じがしてうーんって気持ちもある。あとは観てたのはアポカリプスホテルぐらいか。いや、Panty & Stocking with Garterbelt の続編も見たな。1期のくだらなさをそのまま2期でもやってくれている感じでありがたかった。

同人活動としては、英京郵便局さんの小説の日本語チェック担当をやらせていただいており、細々と活動を続けられているって感じ。ありがとうございます。

【 #例大祭22 】 翻訳小説サークル・英京郵便局 第二十二回博麗神社例大祭 (5/5)にて参加しております。 SS付きのあきゅすずフリーペーパーを頒布します。無料です! 既刊も持ち込み予定です。 (素敵なイラスト: @nklinak.bsky.social) せ09bです!このポスターを目印に来てください! #東方Project

hungrybookworm (@hungrybkwm.bsky.social) 2025-04-30T12:24:47.927Z
bsky.app

【 #C106 】 翻訳小説サークル・英京郵便局 日曜日 東オ16aにて #東方Project の本を頒布します。 『一線』 A5 /12P / 100円  本居小鈴は、人間が想像しうる最大の罪を犯した。  博麗霊夢は、彼女を処分しなければならない。しかし――  2015年の鈴奈庵ショック直後に書かれた  英語圏の衝撃的なバッドエンドSS、ついに邦訳!

hungrybookworm (@hungrybkwm.bsky.social) 2025-08-15T12:45:57.261Z
bsky.app

ベストバイ

2025年に買って良かったもの:DP100(安定化電源)。家での電子工作が格段にやりやすくなった。これが1万円以下で手に入るのはほんとありがたい。

実験用安定化電源 DP100: 電源・トランス 秋月電子通商-電子部品・ネット通販

2025年に買って良かったもの:DAHON International tbm。厳密には去年の11月ぐらいに購入したような気がするが、まぁいいか。以前はロードバイクに乗っていたのだけれど、見事に盗まれた。輪行もやってみたかったこともあり、折りたたみ自転車に買い換え。極端なものが好きなので14インチ車へ。タイヤを Big Apple に付け替えるのが定番のようだったので、付け替えたところ快適になった。これについてはあとで記事を書きたい。

tbmティービーエム - DAHON International

2025年に買って良かったもの:スゴカルminimo plus エッグショック。電車移動主体で暮らすならベビーカーは小さいのが正義だろうと思って最小クラスのものにしたが正解であった。少しのスペースがあれば180度回転できるので狭い店の中などを移動するときにもストレスフリー。

コンビ ホワイトレーベル スゴカルminimo plus エッグショック AN|コンビ公式ブランドストア

行った

今年は地味に万博に行った。要は国のコミケ。1日では半分も見きれなかったな。世界は広い。

地味に香港・マカオにも行った(こいつ子が生まれてもわりと自由にやってんな)。マカオで大小をやるという『深夜特急ワナビの夢を果たせた(12000円が一瞬で蒸発した)。

突然、到香港!

楷ノ木かえで (@kainokikaede.bsky.social) 2025-10-11T11:03:42.473Z
bsky.app

これがズム・シティちゃんですか

楷ノ木かえで (@kainokikaede.bsky.social) 2025-10-12T05:35:55.319Z
bsky.app

帰国〜。何気に中国圏は初めてだった。香港に沈没する気持ちが分かる気がした。

楷ノ木かえで (@kainokikaede.bsky.social) 2025-10-13T12:38:55.873Z
bsky.app

作った

地味に BLE-MIDI 対応のブレスセンサーを作ったりした。これも記事を書きたい。記事にしたいな〜と思うだけでぜんぜんできてないんだよな。発信する気力がなくなってきつつある……。

2026年に向けて

私生活ではデカいヤマを1つ超えたんだけれど、2026年は次のヤマが控えているのでどうにも……。デカいヤマがあると人生のほかの部分を楽しめないのをどうにかしたいですね。結局みんな楽しんでいたタローマンとかにもあまりコミットできなかった。秋例大祭も行けなかったし……錦上京もクリアできてないし……京都秘封も行けなかったし……。来年の目標は次の私生活のヤマを極力片付けるって感じですかね……。それとも全てを諦めてインターネットだけやって暮らすか……。希望としては後者だが……。暗くなってしまった。とにもかくにも来年もよろしくお願いします。

PowerPoint のすべてのスライドを画像化して新たなファイルにするVBAスクリプト

  • 私は macOS ユーザーである。
  • 時に、発表などでは据え置きに Windows PC を用いて発表しなければならないことがある。
  • macOS で作成した PowerPoint ファイルは Windows で表示が崩れることがある。
  • でも macOS で作成したい。
  • PowerPoint のすべてのスライドを画像化して新たなファイルにするVBAスクリプトを書けば解決かと思って ChatGPT を叩いたら以下のコードが出てきた。
  • 動いたので共有する。
Option Explicit

' Export all *visible* slides as high-resolution images, then create a new PPTX with image-only slides
Sub ExportSlidesToImagePptx()

    Dim srcPres As Presentation
    Dim newPres As Presentation
    Dim s As Slide
    Dim newSld As Slide
    Dim shp As Shape
    
    ' ==== SETTINGS ====
    Dim targetPPI As Long: targetPPI = 300   ' Recommended: 300–600 (higher values increase file size)
    Dim imgFormat As String: imgFormat = "PNG" ' Can also be "JPG"
    Dim keepTempFiles As Boolean: keepTempFiles = False ' Set True to keep exported image files
    
    Set srcPres = ActivePresentation
    
    ' Convert slide size (points) to pixels (PowerPoint uses 72 points per inch)
    Dim slideWpt As Single, slideHpt As Single
    Dim pxW As Long, pxH As Long
    slideWpt = srcPres.PageSetup.SlideWidth
    slideHpt = srcPres.PageSetup.SlideHeight
    pxW = CLng(slideWpt / 72# * targetPPI)
    pxH = CLng(slideHpt / 72# * targetPPI)
    
    ' Create temporary directory (macOS TMPDIR)
    Dim tmpBase As String, tmpDir As String
    tmpBase = Environ$("TMPDIR")
    If Right$(tmpBase, 1) <> "/" Then tmpBase = tmpBase & "/"
    tmpDir = tmpBase & "ppt_img_export/"
    
    On Error Resume Next
    Kill tmpDir & "*.*"
    RmDir tmpDir
    On Error GoTo 0
    
    MkDir tmpDir
    ' MsgBox "Temporary export folder: " & tmpDir, vbInformation, "Debug Info"
    
    ' Export only visible slides as images
    Dim i As Long, idxVisible As Long, f As String
    idxVisible = 0
    For Each s In srcPres.Slides
        If Not s.SlideShowTransition.Hidden Then
            idxVisible = idxVisible + 1
            f = tmpDir & "slide_" & Format$(idxVisible, "000") & IIf(UCase$(imgFormat) = "PNG", ".png", ".jpg")
            s.Export f, imgFormat, pxW, pxH
        End If
    Next s
    
    ' Create a new blank presentation
    Set newPres = Presentations.Add(msoTrue)
    With newPres.PageSetup
        .SlideWidth = slideWpt
        .SlideHeight = slideHpt
    End With
    
    ' Insert each exported image to a new blank slide
    Dim total As Long: total = idxVisible
    For i = 1 To total
        f = tmpDir & "slide_" & Format$(i, "000") & IIf(UCase$(imgFormat) = "PNG", ".png", ".jpg")
        Set newSld = newPres.Slides.Add(i, ppLayoutBlank)
        Set shp = newSld.Shapes.AddPicture( _
                FileName:=f, LinkToFile:=msoFalse, SaveWithDocument:=msoTrue, _
                Left:=0, Top:=0, Width:=newPres.PageSetup.SlideWidth, Height:=newPres.PageSetup.SlideHeight)
        shp.ZOrder msoBringToFront
        shp.Left = 0
        shp.Top = 0
        shp.Width = newPres.PageSetup.SlideWidth
        shp.Height = newPres.PageSetup.SlideHeight
    Next i
    
    ' Save the new presentation to Desktop
    Dim desktop As String
    desktop = Environ$("HOME") & "/Desktop/"
    On Error Resume Next
    newPres.SaveAs desktop & "Slides_as_Images_" & Format$(Now, "yyyymmdd_HHNNSS") & ".pptx"
    On Error GoTo 0
    
    ' Delete temporary files
    If Not keepTempFiles Then
        On Error Resume Next
        Kill tmpDir & "*.*"
        RmDir tmpDir
        On Error GoTo 0
    End If
    
    MsgBox "All *visible* slides have been exported as high-resolution images and reassembled into a new PPTX." & vbCrLf & _
           "PPI: " & targetPPI & " / Image size: " & pxW & " ? " & pxH & " px", vbInformation
End Sub

ESP32 PSRAM Timer Camera でベビーカメラを作成する

ベビーカメラが欲しいと思った。買ったのに使ってなかった ESP32 PSRAM Timer Camera が家に落ちていたので、ChatGPT の力を借りてベビーカメラに仕立て上げた。

M5Stack のボードマネージャの最新版(3.2.1)と Timer-CAM ライブラリの最新版(1.0.1)は相性が悪いのかコンパイルに失敗したので、ボードマネージャのほうをバージョン2系の最新版(2.1.4)に変更した。

あとは下のコードを書き込んで、ルーター側でIP固定して、VLCでストリームを見にいったら映った。

gist44c864cb6d713535426cd732b0df205d