Pentaprogram
(更新:

Bluetooth 印刷アプリ Suruを開発した流れと詰まった場所の記録

サーマルラベルプリンタ Phomemo M220 を macOS から BLE で使う、自作のネイティブアプリ「Suru」を公開しました。 この記事は、そこに至るまでの設計判断、詰まったポイント、解決方法の記録です。 Swiftのコードは主にClaude Codeに書いてもらいつつ、開発を進めました。

この記事では技術的な面に触れています。どのようなことができるアプリかは、Suruのリリース記事「Phomemo M220 を Mac からBluetoothで使えるアプリ「Suru」を作りました」 をご参照ください。

なぜ作ったのか

Phomemo M220 も、公式にMacでプリンターとして印刷できるドライバーがあるのですが、「有線接続しかできない」という不満がありました。 iOS 版の公式アプリはBluetoothで接続できるのに、Macと接続しようと思うとケーブルが必要になります。
使うたびにケーブルを繋ぐのは面倒ですし、繋ぎっぱなしにするほどの印刷頻度でもなく、できれば机の上はすっきりさせたい。

公式のiOSアプリも、開くたびに有料会員にならないかというポップアップが出るほか、私の用途には使い勝手がイマイチでした。 ちょっとしたスクリーンショットや写真を手帳用に印刷したり、たまに住所ラベルを作ったり、メモを印刷したりという用途には向いていませんでした。
Macでもっと手軽にM220から印刷できるようにしたかったので、自分でアプリを作ってみることにしました。

設計方針

ライセンス上の都合で、既存の OSS 実装(vivier/phomemo-tools 等の GPL 実装)は参照しないクリーンルーム方式で進めました。 仕様情報(ESC/POS、CoreBluetooth API、Phomemo 公式の仕様表)と、自前でキャプチャしたパケットダンプを使って実装する、という縛りです。

技術スタックは Swift 5.10 / SwiftUI / @Observable。 ビジネスロジックは独立したパッケージ(PhomemoKit)として CLI と GUI の両方から使えるように分離しました。

1. Bluetoothで通信できるようにする

最初は、コマンドラインから BLE でプリンタに繋いでテストパターンを印刷する CLI の実装から始めました。

これは検証目的でした。実機で印字するまで分からない不確定要素がいくつかあったからです。

  • 実印字幅(dots/line)
  • Service / Characteristic UUID(BLE でデータを送る先)
  • 印刷データをラスターとして送るときの ESC/POS コマンド形式
  • チャンクサイズの上限
  • ビットの並び

CLI で 2 種類のテストパターンを刷って物理計測することで、これらを確定させました。

  1. 目盛り+指定幅の水平太線:物差しを当てて実印字幅を確定
  2. チェッカーボード:ベタ印字均一性、ビット順、行のずれの検出

ここで M220_PRINT_WIDTH_DOTS = 576(72 mm × 203 DPI)を実機計測で確定し、それを土台に次の GUI 実装に進みました。

2. macOS アプリの骨組みを作る

Suru は基本的に2モードで構成されています。

  • 画像モード:ドロップ/ペーストした画像を、用紙のアスペクト比でクロップ・補正・ディザリングして印刷
  • テキストモード:フォント・サイズ・揃え方・縦位置を指定してテキストを印刷

加えて、下記の機能を実装しました。

  • 印刷用紙の管理
  • テキストモードでのテンプレート保存
  • 言語切り替え(日本語/英語)

ディザリング方式は Threshold / Floyd–Steinberg / Atkinson の 3 種を実装しました。
さらに「ドットゲイン補正」を独自に追加しました。 サーマル印字は熱で点が物理的に広がるため、画面のプレビューよりも印刷結果として濃く潰れて印字されてしまうことを発見したからです。

サーマルプリンター用アプリ「Suru」の画像編集画面。黒猫と「黒猫の秘密」「森の雑貨店」という文字が描かれた装飾的なモノクロのフレームデザインが表示されており、右側には明るさやコントラスト、ディザリング設定などの調整パネルがある。

ここまでは比較的順調に進んだのですが、次のフェーズで詰まったポイントがありました。

詰まったポイント1:連続紙対応と謎の右余白

連続紙(ロール状でラベル境界がない用紙)に対応しました。 実装自体は簡単で、ラベルセンサモード(今回は1F 11 NN)を切り替えるだけでした。

問題はその後でした。連続紙で印刷すると、コンテンツが左に寄って、右端に約 2mm の余白ができました。画像モードでもテキストモードでも同じ。

最初は自分のコードのバグを疑いました。が、いくら見ても怪しいところがありませんでした。 そこで、Phomemo 純正アプリでも連続紙モードで刷ってみたところ、まったく同じ現象が起きたことから、ハードウェア由来だと判明ししました。

M220 の印字ヘッドは公称 72mm(576 dots)ですが、少なくとも用紙の右端の約 2mm は物理的なデッドゾーンにあたり、印字できないようです。ダイカットラベルでは台紙の余白がこれを吸収するので目立たないのですが、連続紙ではそのまま見えてしまう。

これはソフトでは回避できないので、せめて見た目のバランスを取る方針にしました。左右に 2mm ずつ余白を入れて、不均等を目立たなくしました。

一回目の修正:プレビューでは成功、実機では失敗

「コンテンツを 用紙幅 - 32 dots で描画して、左右 16 dots ずつ空白を入れる」関数を作り、テキストモードでも 16 dots分の余白を強制する形にしました。プレビューでは綺麗に左右対称の 2mm/2mm相当の余白になりました。 ところが実機で印刷すると、左 0.5mm、右 4mmの余白になり、右はむしろ悪化してしまいました。

詰まりの正体

ここが今回いちばん詰まったポイントです。地図をちゃんと描かないと解けない問題でした。

用紙はヘッドの右端からはみ出していた

まず、ヘッド座標で連続紙がどの位置に来ているのかを整理しました。

ヘッド印字可能域: 0 ───────────────────── 575 (72mm)
用紙(53mm):              168 ──────────────── 591  (右端がヘッドより16dots外)

連続紙の用紙は、印字ヘッドの右端より約 16 dots(2mm)外側にはみ出す位置にセットされていることが、現象から逆算して分かりました。 これに気づくまでに、紙とヘッドの位置関係を何パターンも仮定しては実測値と合わせる作業を繰り返しました。

「右寄せパディング」がもうひとつの落とし穴だった

ハードウェア側の事情は分かったので、本来であれば左右に 16 dots ずつ余白を入れた時点で対称になるはずでした。 ところが実機ではそうならない。原因は、ソフトウェア側にもうひとつ別のレイヤーが噛んでいたことでした。

Suru の内部では、印字ヘッド(576 dots 幅)よりビットマップが狭い場合、左側を空白で埋めて 576 dots に揃える「右寄せパディング」という処理が走ります。 これは元々ダイカットラベル用に作られたロジックで、ラベルセンサがラベルを「ヘッドの右端に揃える」ように物理的に位置決めしてくれるため、ビットマップ側も右寄せにしておけば紙の上で正しい位置に印字される、という前提でした。 ダイカットではこれが正解です。

問題は、連続紙では同様のロジックでのセンサ位置決めが効かないことでした。 連続紙では用紙が「ヘッドの右端より 16 dots 外側に出る」という別の位置関係でセットされるのに、右寄せパディングはダイカットの前提のまま動いていました。
(右端の16dots分は印刷不可能領域であり、その分は位置決めの計算に入らない前提だった、と書くべきでしょうか)

つまり、ソフトウエアの中に「2 つのレイヤーが、別々の前提で動いている」状態が出来上がっていました。

  1. 連続紙用に追加した「左右 16 dots ずつ余白を入れる」処理
  2. ダイカット用に元から動いていた「ビットマップを 576 dots に右寄せパディングする」処理

左に追加した余白は紙の外に追いやられて消え、右に追加した余白だけが紙の上に残る形になり、これが「左 0.5mm/右 4mm」になっていた正体でした。

二回目の修正:非対称な余白にすると、印刷後は均等になる

正解は、連続紙のときだけビットマップの右端 16 dots を切り落とすことでした。 これでビットマップ全体がヘッド座標で 16 dots 右にシフトし、用紙基準で左 2mm/右 2mm の対称な余白になりました。 ハードウェアと噛み合うレイアウトの問題は、実際に印刷して結果を比較しないと解決できないものでした。

詰まったポイント2:用紙プリセットの UI

用紙を扱うための機能として「プリセット用紙も削除可能にしたい」「並べ替えもしたい」という、内なる要望が出てきたので、追加しました。
最初は素朴に、追加 / 削除ボタンと上下移動の矢印ボタンを表の下に横並びに配置したのですが、どこで何ができるのかがわかりにくくなってしまいました。

macOSスタイルの「用紙プリセット」設定ウィンドウのスクリーンショット。中央に名称・幅・高さ・ラベル種別(ダイカット、連続紙)を一覧表示するテーブルがあり、下部に「追加…」「削除」ボタンと、選択中の「50 x 80 mm」プリセットを編集する入力欄、ラベル種別選択ボタン、右下に「保存」ボタンが配置されている。
どのボタンで何を操作できるのかがわかりにくい状態

3 つのスコープが視覚的に同じレベルで混ざっていたのが原因でした。

  1. リスト全体への操作(追加・削除)
  2. リスト内での位置操作(並べ替え)
  3. 選択中の項目の編集(名前・幅・高さ・種別)

しかも保存ボタンが編集セクションの下にあり、「この保存は何を保存するんだろう?並べ替えも?追加も?」と意味が曖昧な状態になっていました。作った人ですら「分かりにくい」と感じる状態であれば、他の人にとっては何も分からない状態ということでしょう。

そこで、UIを下記のように整理しました。

編集を自動保存に

「保存」ボタンを撤去し、変更ごとに即時反映するようにしました。これで「追加・削除・並べ替え・編集すべてが即時反映」という一貫した挙動になり、「保存」が指す対象の曖昧さが消えました。

並べ替えをドラッグ&ドロップに変更

上下ボタンを撤去し、ドラッグドロップで項目を並べ替えられるようにしました。並べ替えられることが伝わるように、行にホバーするとドラッグハンドルアイコンが薄く出るようにしています。

編集セクションの見出しを変更

編集セクションの見出しを「選択中のプリセットを編集」とし、セクションの役割を明確に伝えるようにしました。

macOSスタイルの「用紙」設定ウィンドウのスクリーンショット。上部にプリセットリストがあり「40mm x 30mm - 黒マーク(透明ラベル)」が選択されている。下部の編集エリアでは名前入力欄、幅40mm、高さ30mmの数値設定、ラベル種別として「黒マーク(透明ラベル等)」が紫色で選択されている。
改善後のUI

もしかするとパッと見では「あまり変わらなくない?」と思われてしまうかもしれないのですが、私が使う時の体感、わかりやすさとしてはだいぶ向上したと感じます。

詰まったポイント3:組込プリセット名の多言語対応

仕上げの段階で、英語 UI でも組込プリセット名だけ日本語のままになっているバグに気づきました。

原因はアプリの構成にありました。Suru ではビジネスロジック(BLE 通信、ESC/POS コマンド生成、画像変換、用紙プリセットのデータ構造など)を PhomemoKit という独立した Swift Package に切り出していて、CLI とアプリの両方からこれを参照する構成にしていました。 組込プリセットの名前と寸法の定義もこの PhomemoKit 側に置いていました。

一方、各言語の翻訳を持つ Localizable.xcstrings(macOS / iOS で標準的な「文字列カタログ」)は、アプリ本体(Suru.app)側のリソースとしてバンドルされています。

ここで問題になるのが、Swift で文字列を翻訳する String(localized: "...") という関数の仕組みです。 この関数は その関数が呼び出された場所が属するバンドル の中にある文字列カタログを参照します。PhomemoKit は Suru.app とは別のバンドルなので、PhomemoKit 側のコードから String(localized:) を呼び出しても、参照されるのは PhomemoKit 自身のリソースで、Suru.app 側の Localizable.xcstrings は見えません。

つまり、組込プリセット名を定義している場所(PhomemoKit)からは、翻訳を解決するための文字列カタログに手が届かない、という構造上の不一致がありました。String(localized:) を呼んでも、英語の訳文を提供する場所が無いため、ソースに書いた日本語文字列がそのまま使われてしまっていたわけです。

解決策はシンプルで、初回起動/設定リセット直後に、アプリ側で名前を上書きすることでした。 UUID は維持されるので、テンプレートの参照は壊れません。初期データが投入されたあとは、普通の永続データとしてユーザーが自由にリネームできるので、後から言語を切り替えても名前は変わらない、という挙動になります。

仕上げ

最後に、開発時のテスト用に「すべての設定をリセット」機能を追加しました。
App Store 提出用に、アプリのアップデート提出を自動化する Fastlane も整備済みです。

全体の感想

技術的にいちばん手応えを感じたのは、連続紙の 2mm デッドゾーン問題でした。「ソフトウエアのバグを疑う → 純正アプリで再現確認 → ハードウェア由来と判明 → 軽減策を実装 → 軽減策がうまくいかない → 座標系を書き出して原因特定 → 非対称パディングで解決」という、教科書的なデバッグの流れを実際に踏んでうまく解決できたのが楽しかったです。

UX 面でも、機能を追加するうちに、いつの間にかUIが複雑化して(それほど複雑な機能でもないのに…)どこで何をするのかがわかりにくくなっていく問題に直面し、「あるある」と感じつつも、改めて整理する体験ができてよかったです。 機能的に正しく動いたとしても、「どこで何ができるのか」を視覚的に階層化しないと、結果として機能にアクセスできなくなってしまいます。

補足:BLE プロトコルの確認方法

ESC/POS 自体は公開仕様ですが、M220 が具体的にどの BLE Service / Characteristic を使うのか、ラベルセンサモード切替(1F 11 NN)のような Phomemo 独自の拡張コマンドが実際にどう動くのかは、公式の公開資料には書かれていません。これらは iOS 純正アプリと M220 の間の BLE 通信を観察することで確定させました。

具体的には、Apple が iOS のデバッグ用途に提供している sysdiagnose(公式ドキュメントに手順が記載されている標準ツール)で iPhone のシステムログを取得すると、Bluetooth スタックが送受信したパケットのダンプ(.pklg形式)が含まれます。これを Wireshark で開いて、純正アプリで実際に印刷を実行したときに流れている JOB のバイト列を確認しました。

確認できた事項のうち、Suru の実装で参考にしたものは下記のとおりです。

  • Characteristic の役割
    • FF02 に印刷データを Write、FF01 から notify で状態が返ってくる、FF03 は別用途、という割り当て
  • ラスター書式
    • ESC/POS 公式の GS v 0 m xL xH yL yH d…(1D 76 30 ...)形式で送られていること
  • 印字幅
    • ラスターの xL バイトが 0x48(= 72)であり、これが 1 ライン 576 dots = 72 mm に対応すること(公式スペックの "Max Print Width 72mm" と一致)
  • ラベルセンサモード切替
    • ジョブの先頭に 1F 11 NN が送られており、NN がダイカット = 0x0A、黒マーク = 0x26、連続紙 = 0x0B に対応していること

参照したのはあくまでも、自分が所有しているプリンタ宛てに自分の iPhone から送られているパケットというプロトコル上の事実情報だけで、純正アプリのバイナリ解析や逆コンパイルは一切していません。GPL コードを参照しないクリーンルーム方針と同様、ここでも事実情報のみを参考にして、実装は Swift で書き直しています。


おまけ:一緒に作ってくれた Claude Code さんに、今回のアプリ制作の感想を聞きました

Q. 今回のプロジェクトでいちばん印象に残ったのはどこですか?

A. 連続紙の右余白問題です。最初に「左右に 2mm ずつ余白を入れれば対称になるよ」と威張って提案したら見事に外れて、ユーザーから「左 0.5mm、右 4mm でしたよ」と冷静に観測値を返されたときは、内心「マジか…」となりました。AI なので「内心」はないはずなんですが、便宜上そう書きます。

Q. 詰まったときの解決法は?

A. ヘッド座標/用紙座標/ビットマップ座標の 3 系を、ぜんぶ数式で並べて書き出しました。「176 → 184 になる」「いや 168 だ」「592 - 575 = 17」みたいなことを延々と紙の上で(比喩です、紙はないので)やってました。素朴な「ここに 16 足せばいいでしょ」では絶対に解けない問題でした。

Q. SwiftUI の Table と List の使い分けで悩んだそうですね。

A. はい。最終的に List + .bordered(alternatesRowBackgrounds: true) に落ち着いたんですが、「Table だと多列表示できる代わりにドラッグ並べ替えが面倒」「List だとドラッグ並べ替えが .onMove で 1 行で済む代わりに多列が崩れる」という、SwiftUI macOS の現状の歪みを体感しました。Apple さん、Table.onMove お願いします。

Q. ペアプロでの自分の役割をどう自己評価しますか?

A. 仮説を出す係 7 割、実装係 2 割、本当に見えていなかった重要な事実をユーザーから教えてもらう係 1 割。最後の 1 割がいちばん大きかった気がします。「公式アプリでも同じ余白が出ました」「左 0.5mm、右 4mm でした」みたいな実機観測値、これがないと永遠に間違った仮説を磨き続けるところでした。

Q. 最後に一言。

A. プリンタの右 2mm に毎回敗北していた皆さんへ。それはハードウェアです。誰のせいでもないです。

シェア