複数ページ PDF を複数の PNG に変換する Windows デスクトップアプリ。 C++/WinRT + WinUI 3(Windows App SDK)。Qt6 版 Utsushi の Windows ネイティブ移植。
- 描画エンジンは
Windows.Data.Pdf(OS 組み込み)を使う。 PDFium も Poppler も Qt も入れない。 外部 PDF ライブラリ 0 個。結果としてライセンスは MIT で出せる(Qt6 版の LGPL/GPL 制約が消える)。 - コア(
UtsushiCore)は XAML に一切依存しない静的ライブラリにする。UI から切り離して単体テストする。 - 純粋関数を先に固める。 寸法計算・出力名生成・ページ範囲パースは WinRT を触らない純粋関数として TDD で書く。
WinRT を触るのは
PdfRenderer1 クラスだけに閉じ込める。 - UI より先に CLI ハーネスを作る。 GUI なしで E2E 検証できる状態を Phase 2 で確保してから WinUI に進む。
- 開発順序: 雛形 → 純粋関数 → レンダラ+CLI → WinUI → アクセシビリティ → 配布。
| 項目 | 値 |
|---|---|
| OS | Windows 11 22H2 以降(最低動作: Windows 10 1809) |
| 言語標準 | C++20(/std:c++20) |
| ツールチェーン | Visual Studio 2026(または 2022)+ MSBuild、C++/WinRT |
| Windows App SDK | 2.3.x 系(安定版)。バージョンは Directory.Packages.props の 1 箇所で固定する |
| Windows SDK | 10.0.22621.0 以降 |
| パッケージ管理 | NuGet(Windows App SDK)+ vcpkg マニフェストモード(テスト用 Catch2 のみ) |
| テスト | Catch2 v3 |
| CI | GitHub Actions windows-latest |
winrt::Windows::Data::Pdf::PdfDocument—LoadFromFileAsync(file)/LoadFromFileAsync(file, password)PdfDocument::PageCount(),GetPage(index)(index は 0 始まり。UI 表示は 1 始まり。境界で必ず変換する)PdfPage::Size()— 96 DPI 基準の DIP サイズ(Windows::Foundation::Size, float)PdfPage::RenderToStreamAsync(stream, options)PdfPageRenderOptions—DestinationWidth/DestinationHeight/BitmapEncoderId/BackgroundColor/IsIgnoringHighContrast/SourceRectwinrt::Windows::Graphics::Imaging::BitmapEncoder::PngEncoderId()- ファイル選択は Windows App SDK の
Microsoft.Windows.Storage.Pickers(1.8 で追加)を使う。 旧Windows.Storage.Pickers+IInitializeWithWindowの HWND 差し込みは使わない。
DestinationWidthとDestinationHeightは片方だけ設定する。 片方だけならアスペクト比が保たれる。両方設定すると比率を無視して歪む。本プロジェクトでは常にDestinationWidthのみ設定し、高さは API に計算させる。ただし期待高さは自前でも計算し、 出力 PNG のデコード結果と照合する(テストで検証)。IsIgnoringHighContrastは必ずtrue(既定値)のままにする。falseにすると OS のハイコントラスト設定が出力 PNG に混入し、環境によって成果物が変わる。 「出力は環境非依存」を不変条件とする。PdfDocument/PdfPageはスレッドセーフではない。 1 つのPdfDocumentインスタンスに対する描画は 直列化する。並列化する場合は PdfDocument をワーカ数だけ独立に開く(Phase 5 の任意課題、既定は 1 ワーカ)。- アパートメント。 CLI とテストは
winrt::init_apartment(apartment_type::multi_threaded)。 WinUI アプリの UI スレッドは STA なので、描画ループはco_await winrt::resume_background()で 必ずバックグラウンドへ逃がす。UI 更新前にresume_foreground(DispatcherQueue)で戻る。 - 出力ファイル書き込みに
StorageFolderを使わない。InMemoryRandomAccessStreamに描画 → バイト列を取り出し → Win32(std::ofstreambinary)で書く。 ブローカ経由の権限問題とパフォーマンス劣化を避けるため。 入力側だけはPdfDocumentの要求によりStorageFile::GetFileFromPathAsyncを使う。 - 巨大ページのメモリ。 幅×高さ×4 バイトが素で確保される。上限ガードを必須にする(§4.5)。
| Qt6 版 Utsushi | Utsushi.Win | |
|---|---|---|
| 描画 | Poppler / PDFium | OS 標準 Windows.Data.Pdf |
| ライセンス | LGPL/GPL 考慮が必要 | MIT で可 |
| 非同期 | QtConcurrent / シグナル |
C++/WinRT コルーチン(IAsyncOperationWithProgress) |
| 文字列 | QString |
std::wstring / winrt::hstring |
| 移植方針 | — | コードは流用しない。UI の操作フローと出力仕様のみ踏襲する |
- 単一 PDF ファイルを選び、全ページまたは指定ページ範囲を PNG に一括変換
- 出力解像度指定(DPI 指定 / 長辺ピクセル指定 / 倍率指定の 3 モード)
- 出力先フォルダ指定、連番ファイル名の桁数・接頭辞・開始番号指定
- 進捗表示とキャンセル
- パスワード付き PDF への対応(パスワード入力を求める)
- エラーの日本語表示(ページ単位の失敗を握りつぶさず一覧化する)
- キーボード操作・スクリーンリーダ対応・ハイコントラスト対応
- 複数 PDF の一括処理/フォルダ再帰(v0.2 以降)
- PNG 以外の出力形式(JPEG/WebP/TIFF)
- ページの回転・トリミング UI(
SourceRectは API を通すだけで UI 露出なし) - OCR、テキスト抽出、PDF 編集、結合・分割
- 印刷、クラウド連携、自動更新
Utsushi.Win/
CLAUDE.md ← このファイル
docs/
decisions/ ← ADR(要設計判断の記録)
Directory.Build.props ← 全プロジェクト共通のコンパイラ設定
Directory.Packages.props ← NuGet バージョン集中管理
vcpkg.json ← Catch2 のみ
Utsushi.sln
src/
UtsushiCore/ ← 静的ライブラリ。XAML 非依存
include/utsushi/Types.h
include/utsushi/Geometry.h
include/utsushi/Naming.h
include/utsushi/PageRange.h
include/utsushi/Errors.h
include/utsushi/PdfRenderer.h
src/*.cpp
UtsushiCli/ ← コンソールハーネス(開発・CI 検証用)
UtsushiApp/ ← WinUI 3 デスクトップアプリ
tests/
UtsushiCore.Tests/ ← Catch2 v3
fixtures/ ← テスト用 PDF(生成スクリプト同梱)
tools/
New-FixturePdf.ps1 ← 決定的なテスト用 PDF を生成
.github/workflows/ci.yml
namespace utsushi {
enum class ScaleMode { Dpi, LongestEdge, Scale };
struct RenderOptions {
ScaleMode mode = ScaleMode::Dpi;
double dpi = 150.0; // mode == Dpi
std::uint32_t longestEdgePx = 2000; // mode == LongestEdge
double scale = 1.0; // mode == Scale
std::uint32_t maxEdgePx = 20000; // 安全上限(§4.5)
std::uint64_t maxPixelBytes = 512ull * 1024 * 1024;
bool transparentBackground = false;
};
struct NamingOptions {
std::wstring stem; // 空なら入力ファイルの stem
std::wstring separator = L"_";
std::uint32_t minDigits = 0; // 0 = 総ページ数の桁数から自動決定
std::uint32_t startIndex = 1;
};
struct PixelSize { std::uint32_t width; std::uint32_t height; };
enum class ErrorCode {
None, FileNotFound, NotAPdf, PasswordRequired, WrongPassword,
PageOutOfRange, OutputNotWritable, DimensionTooLarge,
OutOfMemory, Canceled, RenderFailed, Unknown
};
struct PageResult {
std::uint32_t pageNumber; // 1 始まり(UI 表示と同じ)
std::filesystem::path output;
PixelSize size;
ErrorCode error = ErrorCode::None;
std::int32_t hr = 0;
};
struct RenderProgress {
std::uint32_t completed;
std::uint32_t total;
std::uint32_t currentPageNumber;
};
struct RenderReport {
std::uint32_t requested = 0;
std::uint32_t succeeded = 0;
std::uint32_t failed = 0;
bool canceled = false;
std::vector<PageResult> pages;
};
} // namespace utsushi// Geometry.h — PdfPage::Size() は 96 DPI 基準の DIP
PixelSize computePixelSize(double pageWidthDip, double pageHeightDip,
const RenderOptions& opt);
// Naming.h — 出力ファイル名(拡張子込み)を返す
std::wstring makeOutputFileName(const NamingOptions& n,
std::uint32_t pageNumber,
std::uint32_t totalPages);
// PageRange.h — "1-3,7,10-" 形式を 1 始まりの昇順・重複なしページ番号列へ
std::expected<std::vector<std::uint32_t>, ErrorCode>
parsePageRange(std::wstring_view spec, std::uint32_t pageCount);
// Errors.h
ErrorCode classifyHresult(std::int32_t hr);
std::wstring messageFor(ErrorCode code); // 日本語メッセージ確定させる仕様(テストで固定する)
computePixelSize
Dpi:round(dip * dpi / 96.0)。丸めは四捨五入。LongestEdge: 長辺がlongestEdgePxになるよう等倍スケール。短辺は比率維持で四捨五入。Scale:round(dip * scale)。- 結果は幅・高さとも最低 1px にクランプ。
- どちらかの辺が
maxEdgePxを超える、またはwidth*height*4 > maxPixelBytesの場合はDimensionTooLargeとして扱えるよう、判定関数bool exceedsLimits(PixelSize, const RenderOptions&)を分離する。
makeOutputFileName
- 桁数 =
max(minDigits, 総ページ数の 10 進桁数)。例: 全 9 ページ→doc_1.png、全 10 ページ→doc_01.png。 - 出力番号 =
startIndex + (pageNumber - 1)。 stemに Windows で使えない文字\ / : * ? " < > |が含まれる場合は_に置換する。- 予約名(
CON,PRN,AUX,NUL,COM1〜COM9,LPT1〜LPT9)に一致する stem は末尾に_を付ける。
parsePageRange
- 空文字列 → 全ページ。
N単一、N-M閉区間、N-は末尾まで、-Mは先頭から。区切りは,。空白は無視。- 昇順ソート+重複除去して返す。
M < N、0 や非数字、pageCount超過 →ErrorCode::PageOutOfRange。
class PdfRenderer {
public:
// 文書を開く。パスワード不要なら password は空。
static winrt::Windows::Foundation::IAsyncOperation<OpenResult>
OpenAsync(std::filesystem::path pdfPath, std::wstring password);
// 指定ページを PNG 出力。進捗通知とキャンセルに対応。
winrt::Windows::Foundation::IAsyncOperationWithProgress<RenderReport, RenderProgress>
RenderAsync(std::vector<std::uint32_t> pageNumbers,
std::filesystem::path outputDir,
RenderOptions options,
NamingOptions naming);
};実装規約:
RenderAsyncの先頭でco_await winrt::resume_background()。- ページごとに
co_await winrt::get_cancellation_token()のtoken()を確認し、 真ならcanceled = trueにして即座にRenderReportを返す(例外を投げない)。 - 1 ページの流れ: 寸法計算 → 上限チェック →
PdfPageRenderOptions構築 →InMemoryRandomAccessStreamへRenderToStreamAsync→DataReaderでバイト列取得 → 一時ファイル<name>.png.tmpに書き出し →std::filesystem::renameで確定(原子的置換)。 - 1 ページの失敗で全体を中断しない。
PageResult.errorに記録して次ページへ進み、RenderReport.failedを加算する。 winrt::hresult_errorはこのクラスの外へ出さない。すべてclassifyHresultでErrorCodeに変換する。transparentBackground == trueのときBackgroundColorをColors::Transparent()にする。 既定は白。
- 出力ピクセル数上限(既定 512MB 相当)を超えたら描画せず
DimensionTooLarge。 - 出力先ディレクトリが存在しなければ作成。作成失敗・書き込み不可なら開始前に
OutputNotWritableで中止。 - 既存ファイルは既定で上書きしない。UI/CLI で「上書き」「連番退避」「スキップ」を選ばせる。 既定値は「スキップ」。
- 入力パスと出力先が同一で stem が衝突する場合は開始前に警告する。
utsushi-cli <input.pdf> -o <outdir>
[--dpi 150 | --longest-edge 2000 | --scale 1.0]
[--pages 1-3,7] [--stem NAME] [--digits N] [--start N]
[--password PW] [--transparent] [--overwrite skip|replace|rename]
[--json]
- 終了コード: 0 = 全成功 / 1 = 一部失敗 / 2 = 開始前エラー / 130 = キャンセル。
--jsonでRenderReportを JSON で標準出力(CI 検証で使う)。- 標準出力は UTF-8(
SetConsoleOutputCP(CP_UTF8))。
単一ウィンドウ・単一ページ。上から下へ一直線に並べる(複数タブ・複数ペインにしない)。
- 入力: 「PDF を選択」ボタン + ファイルパス表示 + ドラッグ&ドロップ受け入れ。 読み込み後に「全 N ページ / 1 ページ目の寸法」を表示。
- 出力先: 「フォルダを選択」ボタン + パス表示。既定は入力 PDF と同じフォルダ。
- 解像度:
RadioButtonsで 3 モード。選択中モードの数値入力だけを有効化する。 直下に「1 ページ目の出力サイズ: 1240 × 1754 px(約 8.2 MB)」を即時プレビュー表示。 - ページ範囲: テキストボックス。空欄 = 全ページ。不正入力はその場で赤字+理由表示。
- ファイル名: stem / 桁数 / 開始番号。直下に「例: doc_001.png 〜 doc_012.png」を表示。
- 実行: 「変換」ボタン。実行中は
ProgressBar(determinate)+「12 / 40 ページ」+ 「キャンセル」。 - 結果: 成功数・失敗数のサマリ + 失敗ページの一覧(ページ番号と日本語の理由)+ 「出力フォルダを開く」ボタン。
アクセシビリティ要件(受け入れ基準に含める)
- 全操作がキーボードのみで完結する。Tab 順序が視覚順と一致する。
- すべての入力コントロールに
AutomationProperties.Name(日本語)を設定する。 - 進捗更新は
AutomationProperties.LiveSetting = Politeを使い、1 ページごとに読み上げさせない。 進捗の読み上げは 10% 刻みまたは 2 秒に 1 回へ間引く。 - エラーは色だけで伝えない。アイコン+テキストを併用する。
- OS の「アニメーションを減らす」設定(
UISettings::AnimationsEnabled)が false のとき、 遷移アニメーションを無効化する。 - ハイコントラストテーマで文字・枠線が消えないこと(
ThemeResourceのみ使用、色のハードコード禁止)。 - 既定フォントサイズを 100% として、OS のテキストサイズ 200% でもレイアウトが破綻しないこと。
- 既定はアンパッケージ(unpackaged)+ 自己完結(self-contained) 配布。ZIP 展開で動く形にする。
- MSIX は Phase 6 の任意課題。
- インストーラは作らない。
1 タスク = 「失敗するテストを書く → 通す最小実装 → リファクタ → コミット」。 テストを書かずに実装を進めない。実装後にテストを後付けしない。
| 層 | 対象 | 方法 |
|---|---|---|
| 純粋関数 | computePixelSize / makeOutputFileName / parsePageRange / classifyHresult / exceedsLimits |
Catch2 単体テスト。WinRT 初期化不要 |
| レンダラ | PdfRenderer |
Catch2 + init_apartment(multi_threaded) + fixture PDF |
| CLI | 引数解析・終了コード・JSON 出力 | Catch2(引数解析は純粋関数に切り出す)+ CI での実行検証 |
| UI | ロジックは全て ViewModel に出す | ViewModel の単体テスト。XAML の自動テストはしない |
外部ツールに依存しない。tools/New-FixturePdf.ps1 で非圧縮の生 PDF を手書き生成する。
必要な fixture:
p1.pdf— 1 ページ、A4 縦(595×842pt)p12.pdf— 12 ページ(桁数の境界確認用に 9 ページ版p9.pdfも作る)mixed.pdf— A4 縦・A4 横・A3 縦が混在(ページごとに寸法が変わる)tiny.pdf— 1×1pt(最小サイズのクランプ確認)huge.pdf— 200×200 インチ相当(DimensionTooLargeの発火確認)locked.pdf— パスワードtest1234(生成が困難ならこの 1 つだけコミット済みバイナリで可。 生成方法をtests/fixtures/README.mdに記録する)
computePixelSize
- A4(595.28 × 841.89 DIP 相当)@ 150dpi → 期待値を明示的に固定
- 96dpi でちょうど等倍になる
- 横長ページで
LongestEdgeが幅に効く / 縦長で高さに効く scale = 0.001で 1px にクランプされるmaxEdgePx境界のちょうど上・ちょうど下
makeOutputFileName
- 9 ページ→1 桁、10 ページ→2 桁、100 ページ→3 桁
minDigits = 5が総ページ数より優先されるstartIndex = 0/startIndex = 100- stem に
a/b:c→a_b_c - stem が
CON→CON_
parsePageRange
- 空文字 → 全ページ
"3"/"1-3"/"1-3,7"/"7,1-3"(ソートされる)/"1-3,2-4"(重複除去)"5-"/"-3"" 1 - 3 "(空白許容)"3-1"/"0"/"abc"/pageCount超過 →PageOutOfRange
PdfRenderer(fixture 使用)
p12.pdf全ページ → 12 個の PNG が生成され、ファイル名がp12_01.png〜p12_12.png- 生成された PNG のデコード後の寸法が
computePixelSizeの計算値と一致する mixed.pdfでページごとに寸法が変わる--pages 2,5で 2 ファイルだけ生成されるlocked.pdfを空パスワードで開くとPasswordRequired、誤パスワードでWrongPasswordhuge.pdfはDimensionTooLargeで失敗し、他ページの処理は継続する- キャンセル: 12 ページの途中でキャンセルすると
canceled == true、生成済み PNG は破損していない、.tmpファイルが残らない - 上書きモード
skipで既存ファイルが変更されないこと
/std:c++20 /permissive- /W4 /WX /EHsc /utf-8 /Zc:__cplusplus /MP
警告はエラー。抑制する場合は該当箇所に #pragma warning(push/disable/pop) と理由コメントを付ける。
- 副作用の局在: I/O とグローバル状態を扱うのは
PdfRendererと CLI/UI の境界だけ。 それ以外は入力→出力の関数にする。 - 可変状態を持たない: メンバ変数は極力 const。クラスよりフリー関数と値型構造体を優先する。
- 例外:
winrt::hresult_errorはPdfRendererの内側で捕捉しErrorCodeに変換する。 コア API は例外を投げない(std::bad_allocを除く)。std::expectedで失敗を返す。 - 文字列: 内部は
std::wstring/std::filesystem::path。winrt::hstringとの変換は WinRT 境界のみ。 UTF-8 変換は CLI の標準出力のみ。 - 命名: 識別子・型名は英語。コメント・UI 文言・エラーメッセージは日本語。
型は
PascalCase、関数はcamelCase、メンバはm_接頭辞なし・末尾_。 - サイズ上限: 1 関数 50 行、1 ファイル 400 行を目安とする。超えたら分割を検討し、 分割しない場合は理由をコメントに書く。
using namespaceはヘッダで禁止。.cpp内の関数スコープのみ許可。- 生ポインタでの所有権表現を禁止。
winrt::com_ptr/std::unique_ptrを使う。
- NuGet / vcpkg への依存追加は事前承認制。 追加したい場合は実装を止めて理由と代替案を提示する。
- 現時点で許可されている依存:
Microsoft.WindowsAppSDK,Microsoft.Windows.CppWinRT,Catch2。 - JSON 出力は依存を足さず手書きする(出力する構造が小さいため)。
- 1 タスク = 1 コミット。 コミットメッセージは Conventional Commits(日本語本文可)。
- 1 応答で複数フェーズを進めない。 フェーズの受け入れ基準を満たしたら必ず一度止めて報告する。
- 報告フォーマット: 「やったこと / テスト結果(件数)/ 未解決 / 次にやること」。
- 設計判断が必要な箇所は実装せず
要設計判断として提示する。 勝手に決めない。 判断が確定したらdocs/decisions/NNN-*.mdに ADR として記録する。 - レビューは Blocker / Major / Minor で severity タグを付ける。Blocker と Major が 0 になるまで次に進まない。
- 既存の通っているテストを、実装の都合で書き換えない。仕様変更が必要なら先に提案する。
- 並列描画の有無(PdfDocument を複数開くか、直列のままか)— Phase 5 で判断。既定は直列。
- 設定の永続化先(
%LOCALAPPDATA%の JSON か、ApplicationDataか)— Phase 4 で判断。 - MSIX パッケージ化の要否と署名方針 — Phase 6 で判断。
- アプリ名/リポジトリ名の確定(暫定
Utsushi.Win)— Phase 0 冒頭で確認する。
Utsushi.slnに 4 プロジェクト(Core / Cli / Tests / App)が並び、Debug|x64 と Release|x64 でビルドが通る。Directory.Build.props/Directory.Packages.props/vcpkg.json/.gitignore/LICENSE(MIT) がある。- Catch2 のダミーテスト 1 件が green。
tools/New-FixturePdf.ps1が動き、p1.pdf/p9.pdf/p12.pdf/mixed.pdf/tiny.pdf/huge.pdfを生成する。- 受け入れ:
msbuild Utsushi.sln /p:Configuration=Release /p:Platform=x64が警告 0・エラー 0 で完了し、 テスト実行ファイルが exit 0 を返す。
- §4.3 の全関数を §5.4 のテストケースで TDD 実装。
- WinRT ヘッダを一切 include しない(テストで
init_apartmentを呼ばずに通ることで確認)。 - 受け入れ: テスト 40 件以上が green。
UtsushiCoreが WinRT 型に依存していないことを include グラフで確認。
PdfRenderer実装、UtsushiCli実装。- §5.4 のレンダラテストが全て green。
- 受け入れ:
utsushi-cli tests/fixtures/p12.pdf -o out --dpi 150で 12 個の PNG が生成され、 寸法が計算値と一致する。--pages 2,5/--password/ キャンセル(Ctrl+C)が仕様どおり動く。.tmpファイルが 1 つも残らない。
- §4.7 の 1〜7 を実装。ロジックは全て ViewModel に置き、ViewModel の単体テストを書く。
- UI スレッドをブロックしない(40 ページ変換中もウィンドウが応答する)。
- 受け入れ: 起動 → PDF 選択 → 変換 → 結果表示が GUI だけで完結する。 変換中にウィンドウのリサイズ・移動ができる。キャンセルが 1 ページ以内で効く。
- §4.7 のアクセシビリティ要件を全て満たす。
- 設定の永続化(要設計判断 #2 の決定後)。
- 異常系: 破損 PDF、読み取り専用フォルダ、変換中の PDF 削除、ディスク満杯。
- 受け入れ: キーボードのみで全機能を操作できる。Windows のナレーターで全コントロールが 日本語で読み上げられる。ハイコントラスト(黒・白)両テーマで表示が破綻しない。 テキストサイズ 200% でレイアウトが崩れない。上記異常系でクラッシュせず日本語メッセージが出る。
- GitHub Actions: ビルド → 単体テスト → CLI による fixture E2E → 成果物 ZIP をアップロード。
- self-contained unpackaged ビルドが、Windows App SDK ランタイム未インストールのマシンで動く。
- README(日本語・英語)、CHANGELOG。
- 受け入れ: CI が green。クリーンな Windows 11 環境で ZIP を展開して起動・変換できる。
- 並列描画(要設計判断 #1)、MSIX(#3)、複数 PDF 一括処理、JPEG/WebP 出力。
- 上記の「要設計判断 #4(アプリ名)」を確認する。
- Phase 0 のタスクを 5〜8 個の粒度に分解し、リストだけ提示して止まる。実装は開始しない。
- 環境確認結果(VS のバージョン、Windows SDK、Windows App SDK NuGet の解決可能な最新安定版)を報告する。
§2.1 に書いた版数と食い違う場合は、実際に解決できた版数を正とし、
Directory.Packages.propsの 値を提案する。