本記事は生成AIと共同で執筆しています。事実関係は可能な範囲で公式ドキュメント等と照合していますが、誤りが含まれている可能性があります。重要な判断を行う前にご自身でも一次情報をご確認ください。

スキャンした版面画像を OCR にかけて、各行の認識テキストとバウンディングボックスを TEI/XML の <facsimile> / <zone> 構造に落とし込む macOS アプリ TEI Scanner を作りました。

最新版は GitHub で公開しています。

起動直後フォルダ読込後
Empty stateFolder loaded
OCR 完了TEI/XML 表示
OCR doneTEI/XML view

本記事では、SwiftUI による実装、xcodegen でのプロジェクト管理、App Store Connect API キーを使った Notarization、GitHub Releases での .dmg 配布までの流れを記録します。スクリーンショットはアプリ自身に組み込んだ自己スクリーンショットモードと AppleScript ベースの自動化で生成しています。Mac App Store への提出は別記事で扱う予定です。

使ってみる

  1. Releases から最新の .dmg をダウンロード
  2. ダブルクリックでマウントし、TEIScanner.appApplications にドラッグ
  3. Launchpad または Spotlight から起動。空画面の「サンプルを試す」を押すと同梱の英字ページ 2 枚が読み込まれます
  4. OCR を実行」→「TEI/XML を書き出し…」で TEI/XML として保存できます

Apple による Notarization 済みのため、初回起動で「開発元未確認」警告は出ません。

通常の使い方

サンプルではなく自分の画像を OCR したい場合、以下の流れになります。

  1. ページごとにスキャンした画像を 1 つのフォルダに入れる(JPG / PNG / TIFF / HEIC / BMP / GIF)。ファイル名は 001.png 002.png のように 0 詰めにしておくと自然順で並びます
  2. フォルダを選択…」または同じ場所にそのフォルダをドラッグ
  3. サイドバーの言語セレクタを「自動判別」または対象言語に切り替え
  4. OCR を実行」 → 「TEI/XML を書き出し…

以降の節は実装の詳細に入っていきます。

動機

刊本の英字資料を TEI 化する過程で、版面画像の各行とテキストを <zone> で対応付けたいという要件がありました。手元には「フォルダにスキャン PNG が並んでいる状態」が多く、Web 系の OCR ツールに 1 ページずつ投げ直すのは効率が悪かったため、macOS 上でフォルダ単位で一括処理し、最後に 1 ファイルの TEI/XML を吐き出すアプリが欲しかった、という背景があります。

最初のターゲットは英字資料ですが、Apple Vision がサポートする他言語(日本語、中文、한국어、欧米諸言語)も同じ仕組みで扱える設計にしました。一方、Vision のサポート範囲を超える満州文字や古ハングルといったスクリプトは現時点では対象外です。

技術スタック

レイヤ採用
言語 / UISwift(swift-tools-version: 6.2、ターゲットの SWIFT_VERSION6.0) / SwiftUI
OCRVision.framework (VNRecognizeTextRequest)
プロジェクト管理Swift Package Manager + xcodegen
デプロイメントターゲットmacOS 26.0
配布Developer ID Application + Notarization → GitHub Releases (.dmg)

開発時は swift run で軽量に立ち上げ、配布アーカイブを切る時だけ xcodegen generate.xcodeproj を生成し xcodebuild archive を回す、という二系統運用にしています。

OCR レイヤ

Vision の VNRecognizeTextRequestaccurate モードで呼び、各 VNRecognizedTextObservation から topCandidates(1) のテキストと正規化済みの bounding box を取り出します。Vision の座標系は左下原点・正規化 (0–1) ですが、TEI で <zone> の座標を書く都合上、ピクセル単位・左上原点に変換してから保持しています。

struct OCRLine: Identifiable, Hashable {
    let id = UUID()
    var text: String
    /// pixel coordinates, top-left origin
    var box: CGRect
}

enum OCRService {
    static func recognize(imageURL: URL, language: OCRLanguage) throws -> OCRPageResult {
        guard let nsImage = NSImage(contentsOf: imageURL),
              let cg = nsImage.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
            throw OCRError.cannotLoadImage(imageURL)
        }
        let size = CGSize(width: cg.width, height: cg.height)

        let request = VNRecognizeTextRequest()
        request.recognitionLevel = .accurate
        request.usesLanguageCorrection = true
        switch language {
        case .auto:
            request.automaticallyDetectsLanguage = true
        default:
            request.recognitionLanguages = [language.rawValue]
        }

        let handler = VNImageRequestHandler(cgImage: cg, options: [:])
        try handler.perform([request])

        let lines: [OCRLine] = (request.results ?? []).compactMap { obs in
            guard let cand = obs.topCandidates(1).first else { return nil }
            let bb = obs.boundingBox
            let x = bb.origin.x * size.width
            let y = (1.0 - bb.origin.y - bb.size.height) * size.height
            let w = bb.size.width * size.width
            let h = bb.size.height * size.height
            return OCRLine(text: cand.string, box: CGRect(x: x, y: y, width: w, height: h))
        }
        return OCRPageResult(imageSize: size, lines: lines)
    }
}

言語選択は OCRLanguage 列挙体で .auto を含めて 9 種類用意し、.auto の場合は automaticallyDetectsLanguage = true に切り替えています。混在ページでは精度が落ちるため、原則は単一言語固定が無難でした。

Vision の request.perform([request]) は同期 API で時間がかかります。OCRService.recognize は非同期化せず同期メソッドのままにし、呼び出し側(AppState.runOCRAll)で Task.detached(priority: .userInitiated) { try OCRService.recognize(...) }.value として明示的にバックグラウンド実行に逃がす構成にしました。これにより OCR 中も UI スレッドが空き、進捗バーや行番号の更新が滑らかになります。

TEI/XML 生成

各画像が 1 つの <surface> に対応し、認識した行 1 つにつき <zone><ab facs="#…"> を 1 組ずつ作る、という最小構造でテンプレ化しています。

private static func facsimile(pages: [TEIPage]) -> String {
    var s = "  <facsimile>\n"
    for (i, page) in pages.enumerated() {
        let surface = "f\(i + 1)"
        let w = Int(page.imageSize.width.rounded())
        let h = Int(page.imageSize.height.rounded())
        s += "    <surface xml:id=\"\(surface)\" ulx=\"0\" uly=\"0\" lrx=\"\(w)\" lry=\"\(h)\">\n"
        s += "      <graphic url=\"\(esc(page.imageURL.lastPathComponent))\" width=\"\(w)px\" height=\"\(h)px\"/>\n"
        for (j, line) in page.lines.enumerated() {
            let zone = "f\(i + 1)_l\(j + 1)"
            let ulx = Int(line.box.origin.x.rounded())
            let uly = Int(line.box.origin.y.rounded())
            let lrx = Int((line.box.origin.x + line.box.size.width).rounded())
            let lry = Int((line.box.origin.y + line.box.size.height).rounded())
            s += "      <zone xml:id=\"\(zone)\" ulx=\"\(ulx)\" uly=\"\(uly)\" lrx=\"\(lrx)\" lry=\"\(lry)\"/>\n"
        }
        s += "    </surface>\n"
    }
    s += "  </facsimile>\n"
    return s
}

出力例(英単語リストのスキャン画像 1 枚を入力した場合)です。

<?xml version="1.0" encoding="UTF-8"?>
<TEI xmlns="http://www.tei-c.org/ns/1.0" xml:lang="en">
  <teiHeader>...</teiHeader>
  <facsimile>
    <surface xml:id="f1" ulx="0" uly="0" lrx="497" lry="643">
      <graphic url="06_table1.png" width="497px" height="643px"/>
      <zone xml:id="f1_l1" ulx="52" uly="36" lrx="114" lry="54"/>
      <zone xml:id="f1_l2" ulx="68" uly="64" lrx="100" lry="82"/>
      <!-- … -->
    </surface>
  </facsimile>
  <text>
    <body>
      <pb n="1" facs="#f1"/>
      <ab facs="#f1_l1">**)</ab>
      <ab facs="#f1_l2">ask</ab>
      <!-- … -->
    </body>
  </text>
</TEI>

公式 RelaxNG (tei_all.rng) で検証し、well-formed かつ valid TEI として通ることを確認しています。

$ xmllint --noout --relaxng /tmp/tei-schema/tei_all.rng aaa.xml
aaa.xml validates

<sp><ab type="…"> のような意味付けは出力時には行わず、後段の手作業に委ねる方針です。最終形のリッチな多層 TEI(例:『清語老乞大』のような満州語ローマ字+ハングル音注+ハングル訳+漢文+日本語訳の 5 層構造)に整形する作業は本ツールの守備範囲外、という整理にしています。

UX 上の判断

実装初期は「フォルダ選択」「OCR 実行」「保存」をすべてツールバーボタンに集約していましたが、初見ユーザーから動線が見つけにくいという指摘があり、3 点に分けて手を入れました。

空状態のドロップゾーン

フォルダを 1 つも開いていない状態では、ウィンドウ全体を点線枠のドロップゾーンで覆い、中央に folder.badge.plus アイコンと Choose Folder… ボタンを置く構成にしました。NavigationSplitViewpages.isEmpty で分岐させ、ロード後に通常のスプリットビューへ切り替えます。

var body: some View {
    Group {
        if state.pages.isEmpty {
            emptyState
        } else {
            NavigationSplitView { sidebar } detail: { detail }
        }
    }
    .onDrop(of: [.fileURL], isTargeted: nil, perform: handleDrop)  // window-wide
}

ドロップ対象がフォルダではなく単一画像だった場合は、その親ディレクトリを採用するようにしています。

OCR 実行と Export を「次の一手」として可視化

主要操作(フォルダ選択 / OCR 実行 / TEI/XML 書き出し)はサイドバー最上段のアクションパネルに集約し、状態に応じて強調表示が遷移するようにしました。

  • フォルダ未選択時は中央のドロップゾーンが主役で、サイドバーは表示されません。
  • フォルダ読込済・OCR 実行前は Run OCR が .borderedProminent、Export は .bordered
  • OCR 結果が出たら Export TEI/XML が .borderedProminent に切り替わり、Run OCR は .bordered(再実行用)に戻ります。

サイドバーのページ一覧でも、現在 OCR 中の行は薄い青、失敗行は薄い赤で背景をティントし、サムネイル右上に ProgressView を重ねます。ScrollViewReader で「処理中の行に自動スクロール」も入れています。

初期実装では画像プレビュー直下に .regularMaterial の CTA カードを重ねていましたが、ツールバーアイコンとの重複や視認性の検討の結果、「サイドバー単一の入口」に揃える形に整理しました。

保存アイコンの選定

最初は square.and.arrow.down を Save 用として使っていましたが、これは Apple の慣例では Receive / Download に近いシンボルでした。Save / Export 用途なら arrow.down.doc のほうが目的が明確です。Share / Export と意図的に区別するため、ツールバーアイコンとラベルを以下に揃えました。

場所アイコンラベル
ツールバーarrow.down.docExport TEI/XML
TEI/XML プレビュー直下のフッターarrow.down.docExport TEI/XML…
File メニュー (⌘S)Export TEI/XML…

File メニューは SwiftUI の CommandsFocusedValue で実装しています。

private struct ExportTEIKey: FocusedValueKey { typealias Value = () -> Void }
extension FocusedValues {
    var exportTEIAction: (() -> Void)? {
        get { self[ExportTEIKey.self] }
        set { self[ExportTEIKey.self] = newValue }
    }
}

@main struct TEIScannerApp: App {
    @FocusedValue(\.exportTEIAction) private var exportAction
    var body: some Scene {
        WindowGroup { ContentView() }
            .commands {
                CommandGroup(replacing: .saveItem) {
                    Button("Export TEI/XML…") { exportAction?() }
                        .keyboardShortcut("s", modifiers: .command)
                        .disabled(exportAction == nil)
                }
            }
    }
}

保存後はウィンドウ下部に「Saved to /path/to/foo.xml [Show in Finder]」というトーストを 4 秒間表示するようにし、書き出し先がすぐ分かるようにしています。

配布パイプライン

App Store と Developer ID の両対応を見据え、.env に資格情報を集約して、ほぼすべての操作を CLI から実行できる構成にしました。

app/
├── .env                  # API キー、Bundle ID、Team ID 等
├── project.yml           # xcodegen 仕様
├── exportOptions/
│   ├── AppStore.plist
│   └── DeveloperID.plist
├── scripts/
│   ├── _asc.py           # JWT・request 共通ヘルパー
│   ├── asc_check.py      # ASC 状態確認
│   ├── asc_register.py   # Bundle ID 登録(apps の作成は API 不可)
│   ├── archive.sh        # xcodegen + xcodebuild archive
│   ├── export-appstore.sh
│   ├── export-devid.sh   # .dmg 化 + notarize + staple
│   └── build_icon.py
└── Sources/TEIScanner/   # SwiftUI 一式

Apple 側の事前準備で詰まった点

新規アカウントで API を叩いた直後に FORBIDDEN.REQUIRED_AGREEMENTS_MISSING_OR_EXPIRED が返ってくる挙動に遭遇しました。原因は 2 段階で、Apple Developer 側の Program License Agreement(PLA)と、App Store Connect 側の Free Apps Agreement(無料アプリでも必要)が両方とも署名済みである必要がありました。

また、ASC API は /v1/apps リソースに対して GET / UPDATE のみ許可されており、新規アプリレコードの作成は Web UI からしか行えませんでした。Bundle ID の登録(/v1/bundleIds)は API で完結します。

Developer ID Application 証明書の自動作成

ローカルの keychain には Apple Development 証明書しかありませんでしたが、xcodebuild archive-allowProvisioningUpdates を渡すと、クラウド管理の Developer ID Application 証明書が自動で作成・取得されました。手動で CSR を作って ASC API に POST する経路も用意してありますが、今回は使わずに済みました。

Notarization を API キーで

xcrun notarytool は keychain プロファイルの登録 (store-credentials) なしでも、--key / --key-id / --issuer の 3 つを直接渡すだけで認証できます。.env の値をそのまま流し込めるため、CI でも扱いやすい形になりました。

xcrun notarytool submit "$DMG_PATH" \
  --key "$KEY_PATH" \
  --key-id "$APP_STORE_API_KEY" \
  --issuer "$APP_STORE_API_ISSUER" \
  --wait

--wait を付けると Apple の処理完了まで同期で待ちます。経験上 5〜10 分程度で Accepted が返ります。

.dmg + ステープル

.dmg の作成は hdiutil create で十分でした。/Applications へのシンボリックリンクを同梱し、ドロップ目的地が明確に見えるようにしています。

DMG_SRC="$(mktemp -d -t teiscanner-dmg)"
ditto "$APP_PATH" "$DMG_SRC/TEIScanner.app"
ln -s /Applications "$DMG_SRC/Applications"
hdiutil create \
  -volname "TEI Scanner" \
  -srcfolder "$DMG_SRC" \
  -ov -format UDZO \
  "$DMG_PATH"

.dmg を notarize した後、xcrun stapler staple.dmg 自体と中身の .app の両方に当てています。これで配布された .dmg をオフライン環境で開いても Gatekeeper 検証が通ります。

.dmg の仕組みを整理

.dmg は Apple Disk Image の略で、HFS+ または APFS のディスクイメージを 1 ファイルにパッケージしたものです。ダブルクリックすると Finder がボリュームとしてマウントし、中身を読めるようになります。配布の見た目に関わるレイヤーは 4 つあって、混同しやすいので整理します。

レイヤ内容触る道具
.dmg ファイル自体のアイコンDownloads フォルダに表示される、まだ開かれていない .dmg のアイコンfileicon 等で xattr を書く(ただし HTTP 転送で剥がれる)
マウントしたボリュームのアイコン.dmg をダブルクリックして Finder ウインドウが開いた時の左側のアイコン、デスクトップ上のディスクアイコン.VolumeIcon.icns をルートに置き、ボリュームに kHasCustomIcon フラグを立てる
ボリュームウインドウのレイアウト開いた Finder ウインドウのサイズ、背景画像、アプリと Applications ショートカットの位置.DS_Store にウインドウ設定を書き込む(hdiutil 単体では難しい)
中身の .app のアイコンApplications にコピーした後の Dock や Spotlight でのアイコンアプリの Assets.xcassets/AppIcon.appiconset

hdiutil create だけで作る .dmg は、.VolumeIcon.icns をソースフォルダに置いてもボリュームに kHasCustomIcon フラグが立たないため、マウントしてもアイコンが切り替わりませんでした(これは初期実装で詰まった点です)。RW で作って → マウント → xattr -wx com.apple.FinderInfo "...0400..." でフラグを立てる → UDZO に変換、という手順を踏めば動きますが、ウインドウのサイズ・背景画像・アイコン位置を .DS_Store に書く工程がさらに必要で、シェルスクリプトで全部書くと脆くなりがちです。

そこで create-dmg(Homebrew)に切り替えました。これは hdiutil のラッパー + AppleScript で、上の表のレイヤ 2・3 をオプション 1 つずつで設定できます。

create-dmg \
  --volname "TEI Scanner" \
  --volicon AppIcon.icns \
  --background docs/dmg-background.png \
  --window-pos 200 120 \
  --window-size 600 400 \
  --icon-size 120 \
  --icon "TEIScanner.app" 150 200 \
  --hide-extension "TEIScanner.app" \
  --app-drop-link 450 200 \
  TEIScanner.dmg \
  TEIScanner.app

背景画像は Pillowscripts/make_dmg_background.py を 1 本書いて生成しています(warm cream のグラデーション + ドラッグ矢印 + 「Drag TEI Scanner into Applications」キャプション)。Retina 対応のため dmg-background.png (600×400) と dmg-background@2x.png (1200×800) の両方を出力。

.dmg のアイコン(ファイル自体)

.dmg 配布で見栄えに関わるアイコンが 2 種類あります。

場所見える条件設定方法
マウントしたボリュームのアイコン.dmg をダブルクリックして開いた直後の Finder ウインドウ.dmg のソースフォルダに .VolumeIcon.icns を置いてから hdiutil create
.dmg ファイル自体のアイコンDownloads フォルダや Web ブラウザのダウンロード一覧fileicon set TEIScanner.dmg AppIcon.icns(Homebrew の fileicon を利用)

前者だけ設定するとマウント後は綺麗になりますが、ダウンロード直後に見える .dmg ファイルは汎用ディスクイメージのアイコンのままです。本ツールでは export-devid.sh の最後で fileicon を呼び、.dmg 自体にもアイコンを焼き込みました。

ただし注意点として、fileicon が書き込む com.apple.ResourceFork / com.apple.FinderInfo は HTTP 転送(GitHub Releases のダウンロード経路など)で剥がれ落ちます。受け取った側の Downloads フォルダでは結局汎用ディスクイメージのアイコンに戻ってしまうため、.dmg ファイル自体のアイコンを保ったまま配布したい場合は .zipditto -c -k --keepParent --rsrc で xattr を保つ)でラップするか、.pkg 形式に切り替える必要があります。本ツールは現状 .dmg のまま配布しているので、ダウンロード直後のアイコンは汎用のままです(マウント後のボリュームアイコンと中身の .app のアイコンは正しく出ます)。

hdiutil create のオプションには -volicon という名前のオプションがあると誤解しやすいですが、実際のオプションは存在せず、-srcfolder 経由で .VolumeIcon.icns を仕込むのが現代的な流儀でした。

最終確認は次の通りです。

$ xcrun stapler validate TEIScanner.dmg
The validate action worked!
$ spctl -a -vv TEIScanner.app
TEIScanner.app: accepted
source=Notarized Developer ID
origin=Developer ID Application: Satoru Nakamura (Q6S8JS6GWV)

GitHub Releases へのアップロード

gh release create.dmg をそのまま添付しています。

gh release create v0.2.0 \
  --title "v0.2.0 — UX overhaul + app icon" \
  --notes-file RELEASE_NOTES.md \
  build/Export/DeveloperID/TEIScanner.dmg

ここまでで、誰でもダウンロードしてダブルクリックで起動できる配布物が一通り揃いました。

表示パフォーマンスで気をつけた点

OCR を実行すると <facsimile> <surface> <zone> <ab> を組み立てた長い XML 文字列が生成されます。実装当初は SwiftUI の Text にそのまま渡し、メタデータ入力欄の TextFieldset でも毎キーストローク文字列全体を再構築していたため、ページ数が増えると入力遅延と描画の重さが目立ちました。

入れた対策は次の 2 点です。

  • メタデータ入力の debouncescheduleRebuild(after:) で 300 ms の静止を待ってから再構築するようにし、連続入力を 1 回にまとめました。
  • NSTextView ベースのプレビューText(state.xmlPreview)NSViewRepresentable 経由の NSTextView (CodeTextView) に置換しました。SwiftUI の Text は数 KB 程度から急速に重くなる印象でしたが、NSTextView は同等の長さでもスムーズに表示・選択できます。

アプリアイコン

別記事で紹介されているプロンプトテンプレート 1 2 を流用し、深紺ラジアルグラデーション + アイボリーの線画 + 一色アクセントという既存アプリ群の系統に合わせて生成しました。中央に開いた写本、両脇に角括弧 < > を配し、本文の 1 行に琥珀色のバウンディングボックス(TEI <zone> を意識)を重ねる構図にしています。

Gemini は出力右下に小さい ✦ ウォーターマークを必ず入れるため、生成後に Python で対称トリミングして除去しました。同時に macOS の AppIcon.appiconset 用に 16 / 32 / 128 / 256 / 512 ポイントを 1x と 2x で書き出した 10 ファイル(実ピクセル数では 16 / 32 / 64 / 128 / 256 / 512 / 1024 の 7 種類)を生成するスクリプトを用意しています。

from PIL import Image

def crop_and_resize(source: str, master_size: int = 1024, margin: int = 130):
    img = Image.open(source)
    w, h = img.size
    cropped = img.crop((margin, margin, w - margin, h - margin))
    return cropped.resize((master_size, master_size), Image.LANCZOS).convert("RGB")

各 (size, scale) で icon_{size}x{size}@{scale}x.png を出力し、合わせて Contents.json を生成しています。project.yml 側では Sources の path に Resources/Assets.xcassets を加え、ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon を建てるだけで Xcode が拾ってくれました。

残作業と今後

ここまでで Developer ID 経由の配布は完了しています。Mac App Store への提出は別軸の作業がまだ残っており、次回別記事で扱う予定です。

  • App Store 用メタデータ(説明文、キーワード、プロモーションテキスト)の API 設定
  • macOS 版スクリーンショットの解像度要件と sips リサイズ
  • App Privacy のデータ使用宣言(API 未対応のため Web UI のみ)
  • 暗号化コンプライアンス、価格、年齢レーティング、レビュー詳細
  • reviewSubmissions + reviewSubmissionItems での審査提出

これらは App Store Connect API だけで iOS アプリを審査提出する手順 で iOS 向けに整理されている流れを macOS にそのまま転用できそうな見込みで、次回の記事ではその検証と差分(特にスクリーンショットの screenshotDisplayType)を中心にまとめる予定です。

参考

Footnotes

  1. AI画像生成でiOSアプリアイコンを作るためのプロンプトテンプレート

  2. AI画像生成で3つのiOSアプリアイコンをリデザインした実践記録