commit 3d08ba4d282ba55e54f3c42f6e19c9026c121f78
Author: review512jwy@163.com <“review512jwy@163.com”>
Date: Fri Dec 5 10:42:20 2025 +0800
初始化
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..e3a7731
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,78 @@
+# Gradle
+.gradle/
+build/
+!gradle/wrapper/gradle-wrapper.jar
+
+# Manual build artifacts (when Gradle is not available)
+lib/
+!lib/README.md
+
+# Java
+*.class
+*.jar
+!gradle/wrapper/gradle-wrapper.jar
+# Allow our built JAR files in build/libs/
+!build/libs/pdf-memo-layers.jar
+!build/libs/pdf-probe.jar
+*.war
+*.ear
+*.nar
+hs_err_pid*
+
+# IDE
+.idea/
+*.iws
+*.iml
+*.ipr
+.vscode/
+*.swp
+*.swo
+*~
+
+# OS
+.DS_Store
+Thumbs.db
+
+# Logs
+*.log
+convert.log
+convert-*.log
+analysis-result*.txt
+
+# Sample files (user should provide their own)
+samples/input.pdf
+samples/output.pdf
+samples/*.pdf
+# Keep specific test files that were used during development
+!samples/README.md
+# But exclude any test files we generate
+samples/test-*.pdf
+
+# PDF processing directories
+pdfs/
+converted/
+test-output/
+# Keep README files for documentation
+!pdfs/README.md
+!converted/README.md
+
+# Generated PDFs in root directory
+input.pdf
+output.pdf
+
+# Temporary files
+*.tmp
+*.temp
+temp_*
+
+# Test output files
+test-output*.pdf
+test-temp.pdf
+test-*.pdf
+*-test.pdf
+
+# PowerShell script generated files
+create-*.ps1
+
+# Build manifest files
+build/MANIFEST*.MF
diff --git a/Convert-Pdf.ps1 b/Convert-Pdf.ps1
new file mode 100644
index 0000000..0c6cf62
--- /dev/null
+++ b/Convert-Pdf.ps1
@@ -0,0 +1,372 @@
+#Requires -Version 5.1
+
+<#
+.SYNOPSIS
+ PDF一括変換用PowerShellスクリプト - 日本語ファイル名対応
+
+.DESCRIPTION
+ 指定フォルダ配下のすべてのPDFファイルを一括変換します。
+ 日本語/マルチバイトファイル名に対応していない変換ツールでも安全に動作するよう、
+ 一時的にASCII名でコピーしてから変換処理を行います。
+
+.PARAMETER RootDir
+ 変換対象のルートフォルダ(必須)
+
+.PARAMETER Converter
+ 実行する変換コマンドのテンプレート(必須)
+ {input} と {output} プレースホルダを含むこと
+ 例: 'java -jar "C:\tools\pdf-converter.jar" --in "{input}" --out "{output}"'
+
+.PARAMETER OutputRoot
+ 出力のルートフォルダ(省略時は各ファイルの元ディレクトリ)
+
+.PARAMETER Recurse
+ 再帰的にサブフォルダも処理する(既定: 有効)
+
+.PARAMETER DryRun
+ 実行せず計画のみ表示する
+
+.PARAMETER Concurrency
+ 同時並行実行数(既定: 1)
+
+.PARAMETER Log
+ ログファイルパス(既定: コンソールのみ)
+
+.PARAMETER Force
+ 既存の出力ファイルを上書きする
+
+.EXAMPLE
+ .\Convert-Pdf.ps1 -RootDir "D:\pdfs" -Converter 'java -jar "C:\tools\pdf-memo-layers.jar" --in "{input}" --out "{output}"'
+
+ 基本的な使用例
+
+.EXAMPLE
+ .\Convert-Pdf.ps1 -RootDir "D:\pdfs" -Converter 'java -jar "C:\tools\pdf-memo-layers.jar" --in "{input}" --out "{output}"' -OutputRoot "D:\converted" -Concurrency 2 -Log "D:\convert.log"
+
+ 出力先指定、並列処理、ログ出力付きの例
+
+.EXAMPLE
+ .\Convert-Pdf.ps1 -RootDir "D:\pdfs" -Converter 'powershell -NoProfile -Command Copy-Item -LiteralPath "{input}" -Destination "{output}" -Force' -DryRun
+
+ ダミー変換(コピー)でのドライラン実行例
+
+.NOTES
+ Author: PowerShell Engineer
+ Version: 1.1
+ PowerShell Version: 5.1+
+ Note: PowerShell 7.0の並列処理機能は使用できません(順次処理のみ)
+#>
+
+[CmdletBinding()]
+param(
+ [Parameter(Mandatory = $true)]
+ [ValidateNotNullOrEmpty()]
+ [string]$RootDir,
+
+ [Parameter(Mandatory = $true)]
+ [ValidateNotNullOrEmpty()]
+ [string]$Converter,
+
+ [Parameter(Mandatory = $false)]
+ [string]$OutputRoot = "",
+
+ [Parameter(Mandatory = $false)]
+ [switch]$Recurse = $true,
+
+ [Parameter(Mandatory = $false)]
+ [switch]$DryRun,
+
+ [Parameter(Mandatory = $false)]
+ [ValidateRange(1, 20)]
+ [int]$Concurrency = 1,
+
+ [Parameter(Mandatory = $false)]
+ [string]$Log = "",
+
+ [Parameter(Mandatory = $false)]
+ [switch]$Force
+)
+
+# UTF-8出力設定
+[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($true)
+
+# グローバル変数
+$script:ProcessedCount = 0
+$script:SuccessCount = 0
+$script:FailureCount = 0
+$script:SkippedCount = 0
+$script:StartTime = Get-Date
+
+# ログ関数
+function Write-Log {
+ param(
+ [string]$Message,
+ [string]$Level = "INFO"
+ )
+
+ $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
+ $logMessage = "[$timestamp] [$Level] $Message"
+
+ # コンソール出力
+ switch ($Level) {
+ "ERROR" { Write-Host $logMessage -ForegroundColor Red }
+ "WARN" { Write-Host $logMessage -ForegroundColor Yellow }
+ "SUCCESS" { Write-Host $logMessage -ForegroundColor Green }
+ default { Write-Host $logMessage }
+ }
+
+ # ファイル出力
+ if ($Log) {
+ try {
+ # ログファイルの親ディレクトリを確認・作成
+ $logDir = Split-Path $Log -Parent
+ if ($logDir -and -not (Test-Path $logDir)) {
+ New-Item -Path $logDir -ItemType Directory -Force | Out-Null
+ }
+ $logMessage | Add-Content -Path $Log -Encoding UTF8 -ErrorAction Stop
+ }
+ catch {
+ Write-Warning "ログファイルへの書き込みに失敗しました: $_"
+ }
+ }
+}
+
+# ASCII一時名生成関数
+function Get-AsciiTempName {
+ param(
+ [string]$OriginalPath,
+ [string]$TempDir
+ )
+
+ $baseName = [System.IO.Path]::GetFileNameWithoutExtension($OriginalPath)
+ $extension = [System.IO.Path]::GetExtension($OriginalPath)
+
+ # Unicode正規化(NFKD)
+ $normalized = $baseName.Normalize([System.Text.NormalizationForm]::FormKD)
+
+ # 非ASCII文字を除去し、空白をアンダースコアに変換、括弧も除去
+ $ascii = $normalized -replace '[^\x00-\x7F]', '' -replace '\s+', '_' -replace '_+', '_' -replace '[()]', ''
+
+ # 空の場合はデフォルト名を使用
+ if ([string]::IsNullOrWhiteSpace($ascii)) {
+ $ascii = "converted_file"
+ }
+
+ # 先頭末尾のアンダースコアを削除
+ $ascii = $ascii.Trim('_')
+
+ # 一意性確保(temp_プレフィックス付き)
+ $counter = 0
+ $tempName = "temp_$ascii$extension"
+ $tempPath = Join-Path $TempDir $tempName
+
+ while (Test-Path $tempPath) {
+ $counter++
+ $tempName = "temp_$ascii-$counter$extension"
+ $tempPath = Join-Path $TempDir $tempName
+ }
+
+ return $tempPath
+}
+
+# 出力ファイル名生成関数
+function Get-OutputFileName {
+ param(
+ [string]$OriginalPath,
+ [string]$OutputDir
+ )
+
+ $baseName = [System.IO.Path]::GetFileNameWithoutExtension($OriginalPath)
+ $extension = ".pdf"
+
+ # 基本的な出力名
+ $outputName = "$baseName.converted$extension"
+ $outputPath = Join-Path $OutputDir $outputName
+
+ # 既存ファイルとの重複回避
+ $counter = 1
+ while ((Test-Path $outputPath) -and -not $Force) {
+ $outputName = "$baseName.converted($counter)$extension"
+ $outputPath = Join-Path $OutputDir $outputName
+ $counter++
+ }
+
+ return $outputPath
+}
+
+# 単一ファイル処理関数
+function Process-PdfFile {
+ param(
+ [string]$InputPath,
+ [string]$OutputDir,
+ [string]$ConverterCommand,
+ [string]$RootDir
+ )
+
+ $fileName = [System.IO.Path]::GetFileName($InputPath)
+ $tempDir = $RootDir # RootDirを一時ディレクトリとして使用
+ $tempFile = $null
+
+ try {
+ # 出力ファイルパス決定
+ $outputPath = Get-OutputFileName -OriginalPath $InputPath -OutputDir $OutputDir
+
+ # 既存ファイルのスキップ判定
+ if ((Test-Path $outputPath) -and -not $Force) {
+ Write-Log "スキップ: $fileName (出力ファイルが既に存在)" "WARN"
+ $script:SkippedCount++
+ return $true
+ }
+
+ # 出力ディレクトリ作成
+ $outputDirPath = Split-Path $outputPath -Parent
+ if (-not (Test-Path $outputDirPath)) {
+ New-Item -Path $outputDirPath -ItemType Directory -Force | Out-Null
+ }
+
+ # ASCII一時名でコピー
+ $tempFile = Get-AsciiTempName -OriginalPath $InputPath -TempDir $tempDir
+ Copy-Item -LiteralPath $InputPath -Destination $tempFile -Force
+
+ # 一時ファイルの存在確認
+ if (-not (Test-Path $tempFile)) {
+ throw "一時ファイルの作成に失敗しました: $tempFile"
+ }
+
+ # コマンド実行(絶対パスに変換)
+ $absoluteTempFile = (Resolve-Path $tempFile).Path
+ $command = $ConverterCommand -replace '\{input\}', "`"$absoluteTempFile`"" -replace '\{output\}', "`"$outputPath`""
+
+ if ($DryRun) {
+ Write-Log "DRY-RUN: $fileName"
+ Write-Log " 入力: $InputPath"
+ Write-Log " 一時: $tempFile"
+ Write-Log " 出力: $outputPath"
+ Write-Log " コマンド: $command"
+ $script:ProcessedCount++
+ return $true
+ }
+
+ Write-Log "処理中: $fileName"
+ Write-Log " コマンド: $command"
+
+ # コマンド実行(Start-Processに戻す)
+ Write-Log "実行コマンド: $command"
+ $process = Start-Process -FilePath "cmd" -ArgumentList @("/c", $command) -Wait -PassThru -WindowStyle Hidden -WorkingDirectory (Get-Location).Path
+
+ if ($process.ExitCode -eq 0) {
+ if (Test-Path $outputPath) {
+ Write-Log "成功: $fileName -> $(Split-Path $outputPath -Leaf)" "SUCCESS"
+ $script:SuccessCount++
+ } else {
+ throw "出力ファイルが生成されませんでした"
+ }
+ } else {
+ throw "コマンドが終了コード $($process.ExitCode) で失敗しました"
+ }
+
+ $script:ProcessedCount++
+ return $true
+ }
+ catch {
+ Write-Log "エラー: $fileName - $($_.Exception.Message)" "ERROR"
+ $script:FailureCount++
+ $script:ProcessedCount++
+ return $false
+ }
+ finally {
+ # 一時ファイルクリーンアップ
+ if ($tempFile -and (Test-Path $tempFile)) {
+ try {
+ Remove-Item -LiteralPath $tempFile -Force -ErrorAction SilentlyContinue
+ }
+ catch {
+ Write-Log "一時ファイル削除失敗: $tempFile - $($_.Exception.Message)" "WARN"
+ }
+ }
+ }
+}
+
+# メイン処理
+function Main {
+ Write-Log "=== PDF一括変換開始 ==="
+ Write-Log "ルートディレクトリ: $RootDir"
+ Write-Log "変換コマンド: $Converter"
+ Write-Log "出力ルート: $(if ($OutputRoot) { $OutputRoot } else { '(各ファイルの元ディレクトリ)' })"
+ Write-Log "並行数: $Concurrency"
+ Write-Log "DryRun: $DryRun"
+
+ # 入力検証
+ if (-not (Test-Path $RootDir)) {
+ Write-Log "エラー: ルートディレクトリが存在しません: $RootDir" "ERROR"
+ return 1
+ }
+
+ if ($Converter -notmatch '\{input\}' -or $Converter -notmatch '\{output\}') {
+ Write-Log "エラー: 変換コマンドに{input}と{output}プレースホルダが必要です" "ERROR"
+ return 1
+ }
+
+ # PDFファイル検索
+ $searchParams = @{
+ Path = $RootDir
+ Filter = "*.pdf"
+ File = $true
+ }
+ if ($Recurse) {
+ $searchParams.Recurse = $true
+ }
+
+ $pdfFiles = Get-ChildItem @searchParams | Where-Object { $_.Extension -match '\.pdf$' }
+
+ if (-not $pdfFiles) {
+ Write-Log "処理対象のPDFファイルが見つかりません" "WARN"
+ return 0
+ }
+
+ Write-Log "対象ファイル数: $($pdfFiles.Count)"
+
+ # PowerShell 5.1では並列処理をサポートしないため、常に順次処理
+ if ($Concurrency -gt 1 -and $pdfFiles.Count -gt 1 -and -not $DryRun) {
+ Write-Log "PowerShell 5.1では並列処理をサポートしていません。順次処理で実行します。"
+ }
+
+ # 順次処理
+ foreach ($file in $pdfFiles) {
+ $outputDir = if ($OutputRoot) {
+ $OutputRoot
+ } else {
+ $file.DirectoryName
+ }
+
+ Process-PdfFile -InputPath $file.FullName -OutputDir $outputDir -ConverterCommand $Converter -RootDir $RootDir
+ }
+
+ # 結果サマリー
+ $endTime = Get-Date
+ $duration = $endTime - $script:StartTime
+
+ Write-Log "=== 処理完了 ==="
+ Write-Log "処理時間: $($duration.ToString('hh\:mm\:ss'))"
+ Write-Log "処理済み: $script:ProcessedCount"
+ Write-Log "成功: $script:SuccessCount"
+ Write-Log "失敗: $script:FailureCount"
+ Write-Log "スキップ: $script:SkippedCount"
+
+ if ($script:FailureCount -gt 0) {
+ Write-Log "一部のファイルで処理に失敗しました" "WARN"
+ return 1
+ }
+
+ return 0
+}
+
+# スクリプト実行
+try {
+ $exitCode = Main
+ exit $exitCode
+}
+catch {
+ Write-Log "予期しないエラーが発生しました: $($_.Exception.Message)" "ERROR"
+ exit 1
+}
diff --git a/Project.md b/Project.md
new file mode 100644
index 0000000..96ce6cc
--- /dev/null
+++ b/Project.md
@@ -0,0 +1,138 @@
+# Cursor プロジェクト説明(iText7 で PDF に「メモ用レイヤー」を 2 つ追加)
+
+## 目的
+
+既存の PDF に **OCG(Optional Content Group=レイヤー)を 2 つ** 追加し、後からそのレイヤーにメモ(テキスト/図形/注釈)を書き込めるようにします。
+Acrobat の「レイヤー」パネルで表示・非表示を切り替えられる **「メモ1」「メモ2」** を作成します。
+
+## ゴール/受け入れ基準
+
+* 既存 PDF を入力に取り、出力 PDF のカタログに **OCProperties/OCGs** が作成されていること(2 つの OCG)。
+* Acrobat / PDF-XChange 等で **レイヤー「メモ1」「メモ2」** が確認でき、ON/OFF 切り替えが可能。
+* 追加サンプルとして、各レイヤーに 1 行テキストを描画(または四角形)できること。
+* 既存ページのコンテンツは変更しない(上に重ねるだけ)。
+
+## 技術ポイント(PDF 構造)
+
+* iText7 では `PdfLayer`(= OCG)を生成し、`PdfCanvas.beginLayer(layer) ... endLayer()` の間で描画したコンテンツはそのレイヤーに属します。
+* 既存 PDF を開いてレイヤーを「追加」するだけなら、iText が **Catalog/OCProperties** を自動で拡張してくれます。
+* 初期表示状態(ON/OFF)は `layer.setOn(true/false)` で制御可能。
+
+## 環境
+
+* 言語:Java 17(推奨 11+)
+* ビルド:Maven もしくは Gradle
+* ライブラリ:iText7(kernel / layout)
+
+ * Maven:
+
+ ```xml
+
+ com.itextpdf
+ kernel
+ 8.0.2
+
+
+ com.itextpdf
+ layout
+ 8.0.2
+
+ ```
+ * Gradle:
+
+ ```gradle
+ implementation "com.itextpdf:kernel:8.0.2"
+ implementation "com.itextpdf:layout:8.0.2"
+ ```
+
+## プロジェクト構成(推奨)
+
+```
+pdf-memo-layers/
+├─ README.md
+├─ build.gradle or pom.xml
+├─ src/
+│ └─ main/java/
+│ └─ co/jp/techsor/pdf/
+│ ├─ App.java // CLI エントリ
+│ ├─ PdfMemoLayerService.java // レイヤー作成・描画の中核
+│ ├─ LayerDrawingOptions.java // 文字/図形の描画オプション
+│ └─ util/
+│ └─ PdfUtils.java // 共通ユーティリティ
+└─ samples/
+ ├─ input.pdf
+ └─ output.pdf (生成物)
+```
+
+## 主要ユースケース
+
+1. **レイヤーだけを追加**(後で編集者が注釈・図形を載せる)
+2. **レイヤーにテキスト/図形を同時に配置**(例:右下に “Memo Layer”)
+3. **初期状態の切替**(メモ1=ON、メモ2=OFF など)
+
+## CLI 仕様(例)
+
+```
+java -jar pdf-memo-layers.jar \
+ --in samples/input.pdf \
+ --out samples/output.pdf \
+ --layer1 "メモ1" --layer2 "メモ2" \
+ --draw "text:1,100,700,メモ1のサンプル" \
+ --draw "rect:2,50,50,200,60" \
+ --l2-on=false
+```
+
+* `--draw` は複数指定可。形式:
+
+ * `text:,,,`
+ * `rect:,,,,`
+* `--l2-on=false` のように初期表示を切替。
+
+## 実装概略(擬似コード)
+
+```java
+try (PdfDocument pdf =
+ new PdfDocument(new PdfReader(inPath), new PdfWriter(outPath))) {
+
+ // 1) レイヤー(OCG)作成
+ PdfLayer memo1 = new PdfLayer("メモ1", pdf);
+ memo1.setOn(true); // 初期表示 ON
+ PdfLayer memo2 = new PdfLayer("メモ2", pdf);
+ memo2.setOn(false); // 初期表示 OFF(例)
+
+ // 2) 必要に応じて描画(各ページの上に重ねる)
+ PdfPage page1 = pdf.getPage(1);
+ PdfCanvas canvas1 = new PdfCanvas(page1);
+ canvas1.beginLayer(memo1);
+ // テキスト描画(iText7 layout を使う場合は Canvas を併用)
+ try (Canvas c = new Canvas(canvas1, page1.getPageSize())) {
+ c.showTextAligned("メモ1のサンプル", 100, 700, TextAlignment.LEFT);
+ }
+ canvas1.endLayer();
+
+ PdfPage page2 = pdf.getPage(2);
+ PdfCanvas canvas2 = new PdfCanvas(page2);
+ canvas2.beginLayer(memo2);
+ canvas2.rectangle(50, 50, 200, 60);
+ canvas2.stroke();
+ canvas2.endLayer();
+}
+```
+
+## テスト手順
+
+1. `samples/input.pdf` を用意し、CLI で実行して `samples/output.pdf` を生成。
+2. Acrobat で `output.pdf` を開き、**表示 → ナビゲーションパネル → レイヤー** を表示。
+3. 「メモ1」「メモ2」の存在と ON/OFF の切り替えで描画が出入りすることを確認。
+
+## エッジケースと対策
+
+* **暗号化 PDF**:所有者パスワードが必要。`ReaderProperties`/`WriterProperties` で設定。
+* **透明度・ブレンド**:必要なら `PdfExtGState` を併用して半透明表現。
+* **注釈に載せたい**:注釈は OCG 直付けできないため、描画をレイヤーに、注釈は通常オブジェクトとして扱う(見た目同期が必要)。
+* **長文テキスト**:`layout` の `Paragraph` を使用し、行送りとフォント設定を行う。
+
+## ライセンス注意
+
+iText 7 は AGPL もしくは商用ライセンスです。社内/顧客配布要件に合わせてライセンス選定を行ってください。
+
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..1f4ca8f
--- /dev/null
+++ b/README.md
@@ -0,0 +1,386 @@
+# PDF メモレイヤー追加ツール
+
+iText7を使用してPDFファイルに2つのメモ用レイヤー(OCG: Optional Content Group)を追加するJavaアプリケーションです。
+
+## 機能
+
+- 既存PDFに「メモ1」「メモ2」レイヤーを追加
+- レイヤーの初期表示状態(ON/OFF)の設定
+- レイヤーにテキストや図形を描画
+- Adobe Acrobat等でレイヤーの表示・非表示切り替えが可能
+
+## 必要な環境
+
+- Java 17以上
+- Gradle 7.0以上(オプション)
+- PowerShell 7.0以上(一括変換スクリプト使用時)
+
+## ビルド方法
+
+### Gradle Wrapper使用時(推奨)
+```bash
+# プロジェクトのクローンまたはダウンロード後
+./gradlew build
+
+# 実行可能JARファイルの作成
+./gradlew fatJar
+```
+
+### 手動ビルド(Gradle未使用)
+```bash
+# 1. 必要なライブラリをダウンロード(lib/README.mdを参照)
+mkdir lib
+# iText7ライブラリなどを lib/ ディレクトリに配置
+
+# 2. コンパイル
+mkdir build/classes
+javac -cp "lib/*" -d build/classes src/main/java/co/jp/techsor/pdf/*.java src/main/java/co/jp/techsor/pdf/util/*.java
+```
+
+## 使用方法
+
+### 基本的な使用方法
+
+```bash
+# JARファイルを使用(推奨)
+java -jar build/libs/pdf-memo-layers.jar --in samples/input.pdf --out samples/output.pdf
+
+# 直接クラスパスから実行(開発時)
+java -cp "build/classes;lib/*" co.jp.techsor.pdf.App --in samples/input.pdf --out samples/output.pdf
+
+# カスタムレイヤー名で追加
+java -jar build/libs/pdf-memo-layers.jar \
+ --in samples/input.pdf \
+ --out samples/output.pdf \
+ --layer1 "注釈レイヤー" \
+ --layer2 "校正レイヤー"
+```
+
+### 描画オプション付きの使用方法
+
+```bash
+# テキストと図形を描画
+java -jar build/libs/pdf-memo-layers.jar \
+ --in samples/input.pdf \
+ --out samples/output.pdf \
+ --layer1 "メモ1" \
+ --layer2 "メモ2" \
+ --draw "text:1,100,700,これはメモ1のテキストです" \
+ --draw "rect:2,50,50,200,60" \
+ --l2on false
+```
+
+### コマンドラインオプション
+
+| オプション | 説明 | 必須 | デフォルト値 |
+|-----------|------|------|------------|
+| `--in`, `--input` | 入力PDFファイルのパス | ✓ | - |
+| `--out`, `--output` | 出力PDFファイルのパス | ✓ | - |
+| `--layer1` | レイヤー1の名前 | | メモ1 |
+| `--layer2` | レイヤー2の名前 | | メモ2 |
+| `--l1on` | レイヤー1の初期表示状態 (true/false) | | true |
+| `--l2on` | レイヤー2の初期表示状態 (true/false) | | true |
+| `--draw` | 描画オプション(複数指定可能) | | なし |
+| `-h`, `--help` | ヘルプの表示 | | - |
+
+### 描画オプションの書式
+
+#### テキスト描画
+```
+text:<レイヤー番号>,,,<テキスト内容>
+```
+
+例:`text:1,100,700,サンプルテキスト`
+
+#### 四角形描画
+```
+rect:<レイヤー番号>,,,<幅>,<高さ>
+```
+
+例:`rect:2,50,50,200,60`
+
+## プロジェクト構成
+
+```
+pdf-memo-layers/
+├─ README.md
+├─ build.gradle
+├─ Convert-Pdf.ps1 // 一括変換用PowerShellスクリプト
+├─ lib/
+│ ├─ README.md // ライブラリダウンロード手順
+│ └─ *.jar // iText7等のライブラリ(手動配置)
+├─ src/
+│ └─ main/java/
+│ └─ co/jp/techsor/pdf/
+│ ├─ App.java // CLIエントリポイント
+│ ├─ PdfMemoLayerService.java // レイヤー作成・描画の中核
+│ ├─ LayerDrawingOptions.java // 描画オプション管理
+│ └─ util/
+│ └─ PdfUtils.java // 共通ユーティリティ
+├─ samples/
+│ ├─ input.pdf // サンプル入力ファイル(要準備)
+│ └─ output.pdf // 生成される出力ファイル
+├─ pdfs/ // 一括変換用入力ディレクトリ
+├─ converted/ // 一括変換結果出力ディレクトリ
+└─ convert.log // 変換ログファイル
+```
+
+## 使用例
+
+### 1. シンプルなレイヤー追加
+
+```bash
+java -jar build/libs/pdf-memo-layers.jar \
+ --in samples/input.pdf \
+ --out samples/output.pdf
+```
+
+### 2. カスタム設定でのレイヤー追加
+
+```bash
+java -jar build/libs/pdf-memo-layers.jar \
+ --in samples/input.pdf \
+ --out samples/output.pdf \
+ --layer1 "校正用メモ" \
+ --layer2 "レビューコメント" \
+ --l1on true \
+ --l2on false
+```
+
+### 3. 描画要素付きでのレイヤー追加
+
+```bash
+java -jar build/libs/pdf-memo-layers.jar \
+ --in samples/input.pdf \
+ --out samples/output.pdf \
+ --draw "text:1,100,750,重要:この部分を確認してください" \
+ --draw "text:1,100,720,作成者:田中太郎" \
+ --draw "rect:2,50,600,300,100" \
+ --draw "text:2,60,650,注意事項エリア"
+```
+
+## 結果の確認方法
+
+1. 生成されたPDFをAdobe Acrobat、PDF-XChange Editor等で開く
+2. レイヤーパネル(ナビゲーションパネル)を表示
+3. 「メモ1」「メモ2」レイヤーが表示されることを確認
+4. レイヤーのON/OFFで描画内容が表示・非表示されることを確認
+
+## 注意事項
+
+### 暗号化PDF
+パスワード保護されたPDFの場合、現在のバージョンでは対応していません。事前にパスワードを解除してから処理してください。
+
+### 座標系
+PDFの座標系は左下が原点(0,0)です。テキストや図形の配置時にご注意ください。
+
+### ライセンス
+このプロジェクトはiText 7を使用しています。iText 7はAGPLまたは商用ライセンスです。
+商用利用の場合は適切なライセンスを取得してください。
+
+## トラブルシューティング
+
+### よくあるエラー
+
+1. **ファイルが見つからない**
+ - 入力ファイルのパスが正しいか確認してください
+ - 相対パスではなく絶対パスを使用してみてください
+
+2. **出力ディレクトリが存在しない**
+ - 出力先のディレクトリが存在することを確認してください
+
+3. **日本語ファイル名の問題**
+ - Windows環境で日本語ファイル名を使用する場合、文字エンコーディングの問題が発生することがあります
+ - **推奨解決策**: `Convert-Pdf.ps1`スクリプトを使用してください(日本語ファイル名に完全対応)
+ ```powershell
+ .\Convert-Pdf.ps1 -RootDir ".\pdfs" -Converter 'java -cp "build\classes;lib\*" co.jp.techsor.pdf.App --in "{input}" --out "{output}"' -OutputRoot ".\converted"
+ ```
+ - 手動実行の場合:ファイル名を英語に変更するか、samplesディレクトリで実行してください
+ ```bash
+ cd samples
+ java -cp "../build/classes;../lib/*" co.jp.techsor.pdf.App --in "input.pdf" --out "output.pdf"
+ ```
+
+4. **メモリ不足エラー**
+ - 大きなPDFファイルの場合、JVMのヒープサイズを増やしてください:
+ ```bash
+ java -Xmx2g -jar build/libs/pdf-memo-layers.jar ...
+ ```
+
+## 開発者向け情報
+
+### ビルドとテスト
+
+```bash
+# 手動でのビルド(Gradle Wrapperが利用できない場合)
+# 1. 必要なライブラリをダウンロード
+mkdir lib
+# iText7ライブラリなどを lib/ ディレクトリに配置
+
+# 2. コンパイル
+mkdir build/classes
+javac -cp "lib/*" -d build/classes src/main/java/co/jp/techsor/pdf/*.java src/main/java/co/jp/techsor/pdf/util/*.java
+
+# 3. 実行
+java -cp "build/classes;lib/*" co.jp.techsor.pdf.App --in samples/input.pdf --out samples/output.pdf
+
+# Gradle Wrapperが利用可能な場合
+./gradlew build
+./gradlew fatJar
+./gradlew run --args="--in samples/input.pdf --out samples/output.pdf"
+```
+
+### APIの使用例
+
+```java
+import co.jp.techsor.pdf.PdfMemoLayerService;
+import co.jp.techsor.pdf.LayerDrawingOptions;
+
+// サービスのインスタンス作成
+PdfMemoLayerService service = new PdfMemoLayerService();
+
+// 描画オプションの作成
+List options = Arrays.asList(
+ LayerDrawingOptions.text(1, 100, 700, "サンプルテキスト"),
+ LayerDrawingOptions.rectangle(2, 50, 50, 200, 60)
+);
+
+// レイヤーの追加と描画
+service.addMemoLayers("input.pdf", "output.pdf",
+ "メモ1", "メモ2", true, false, options);
+```
+
+## 一括変換用PowerShellスクリプト
+
+日本語ファイル名を含む複数のPDFファイルを一括で処理するためのPowerShellスクリプト `Convert-Pdf.ps1` が用意されています。
+
+### 特徴
+
+- **日本語ファイル名対応**: 変換ツールが日本語/マルチバイト名に対応していなくても安全に処理
+- **一時ファイル管理**: ASCII名の一時ファイルを作成し、処理後に自動削除
+- **並列処理**: 複数ファイルを同時に処理して効率化
+- **堅牢なエラーハンドリング**: 失敗したファイルがあっても処理を継続
+- **詳細ログ**: 処理状況をコンソールとファイルに記録
+
+### 基本的な使用方法
+
+```powershell
+# 基本的な一括変換(クラスパス指定方式)
+.\Convert-Pdf.ps1 `
+ -RootDir ".\pdfs" `
+ -Converter 'java -cp "build\classes;lib\*" co.jp.techsor.pdf.App --in "{input}" --out "{output}"'
+
+# 出力先指定と並列処理
+.\Convert-Pdf.ps1 `
+ -RootDir ".\pdfs" `
+ -Converter 'java -cp "build\classes;lib\*" co.jp.techsor.pdf.App --in "{input}" --out "{output}"' `
+ -OutputRoot ".\converted" `
+ -Concurrency 2 `
+ -Log "convert.log"
+
+# DryRun(実行計画の確認)
+.\Convert-Pdf.ps1 `
+ -RootDir ".\pdfs" `
+ -Converter 'java -cp "build\classes;lib\*" co.jp.techsor.pdf.App --in "{input}" --out "{output}"' `
+ -DryRun
+```
+
+### パラメータ
+
+| パラメータ | 説明 | 必須 | デフォルト |
+|-----------|------|------|-----------|
+| `-RootDir` | 変換対象のルートフォルダ | ✓ | - |
+| `-Converter` | 変換コマンドテンプレート | ✓ | - |
+| `-OutputRoot` | 出力先ルートフォルダ | | 元ファイルと同じディレクトリ |
+| `-Recurse` | サブフォルダも再帰処理 | | true |
+| `-Concurrency` | 同時実行数 | | 1 |
+| `-Log` | ログファイルパス | | コンソールのみ |
+| `-DryRun` | 実行せず計画のみ表示 | | false |
+| `-Force` | 既存出力ファイルを上書き | | false |
+
+### 実行例
+
+```powershell
+# 実際の動作確認済みコマンド
+.\Convert-Pdf.ps1 `
+ -RootDir ".\pdfs" `
+ -Converter 'java -cp "build\classes;lib\*" co.jp.techsor.pdf.App --in "{input}" --out "{output}"' `
+ -OutputRoot ".\converted" `
+ -Concurrency 2 `
+ -Log "convert.log"
+```
+
+**実行結果例**:
+```
+[2025-09-12 14:28:53] [INFO] === PDF一括変換開始 ===
+[2025-09-12 14:28:53] [INFO] 対象ファイル数: 2
+[2025-09-12 14:28:54] [SUCCESS] 成功: テラル_ステンレス製立形多段ポンプSVM・SVMN型.pdf
+[2025-09-12 14:28:55] [SUCCESS] 成功: 杉乃井ホテル_電力監視制御装置.pdf
+[2025-09-12 14:28:55] [INFO] 処理済み: 2, 成功: 2, 失敗: 0
+```
+
+### 注意事項
+
+- **JARファイルが利用できない場合**: `java -cp "build\classes;lib\*"`形式を使用してください
+- **ログファイル**: `-Log`パラメータでファイルパスを指定すると詳細ログが保存されます
+- **出力先**: `-OutputRoot`を指定すると、すべてのファイルが指定ディレクトリに直接出力されます(サブフォルダは作成されません)
+
+## PDF解析ツール
+
+PDFファイルの詳細な解析を行うツール `PdfProbeApp` が追加されました。
+
+### 機能
+
+1. **メタデータ解析**
+ - Producer/Creator情報の取得
+ - XMPメタデータの解析
+ - Adobe Illustratorの検出
+
+2. **OCG(レイヤー)解析**
+ - レイヤー一覧の取得
+ - 初期表示設定(ON/OFF)の確認
+
+3. **ページ内容解析**
+ - レイヤー使用状況の確認
+ - BDC/EMCマーカーの検出
+
+4. **注釈解析**
+ - 注釈のレイヤー紐付け状況
+ - 手書き注釈を含む全注釈の分析
+
+5. **Illustrator固有痕跡**
+ - PieceInfo の検出
+ - AIPrivateData の痕跡確認
+
+### 使用方法
+
+```bash
+# 基本的な解析
+java -jar build/libs/pdf-probe.jar -i input.pdf
+
+# 詳細解析
+java -jar build/libs/pdf-probe.jar -i input.pdf -v
+
+# 結果をファイルに出力
+java -jar build/libs/pdf-probe.jar -i input.pdf -v -o result.txt
+
+# JSON形式で出力(今後実装予定)
+java -jar build/libs/pdf-probe.jar -i input.pdf -j -o result.json
+```
+
+### ビルド方法
+
+```bash
+# PDF解析ツールのJARファイルを作成
+./gradlew probeJar
+
+# 両方のツール(メモレイヤー追加+解析)を作成
+./gradlew allJars
+```
+
+### テスト実行
+
+```powershell
+# PDF解析ツールのテスト
+.\test-probe.ps1
+```
\ No newline at end of file
diff --git a/build.gradle b/build.gradle
new file mode 100644
index 0000000..d3d451d
--- /dev/null
+++ b/build.gradle
@@ -0,0 +1,61 @@
+plugins {
+ id 'java'
+ id 'application'
+}
+
+group = 'co.jp.techsor.pdf'
+version = '1.0.0'
+sourceCompatibility = '17'
+
+repositories {
+ mavenCentral()
+}
+
+dependencies {
+ implementation 'com.itextpdf:kernel:8.0.2'
+ implementation 'com.itextpdf:layout:8.0.2'
+ implementation 'commons-cli:commons-cli:1.5.0'
+
+ testImplementation 'junit:junit:4.13.2'
+}
+
+application {
+ mainClass = 'co.jp.techsor.pdf.App'
+}
+
+jar {
+ manifest {
+ attributes(
+ 'Main-Class': 'co.jp.techsor.pdf.App'
+ )
+ }
+ from {
+ configurations.runtimeClasspath.collect { it.isDirectory() ? it : zipTree(it) }
+ }
+ duplicatesStrategy = DuplicatesStrategy.EXCLUDE
+}
+
+task fatJar(type: Jar) {
+ archiveBaseName = 'pdf-memo-layers'
+ from { configurations.runtimeClasspath.collect { it.isDirectory() ? it : zipTree(it) } }
+ with jar
+ duplicatesStrategy = DuplicatesStrategy.EXCLUDE
+}
+
+// PDF解析ツール用のJAR
+task probeJar(type: Jar) {
+ archiveBaseName = 'pdf-probe'
+ manifest {
+ attributes 'Main-Class': 'co.jp.techsor.pdf.PdfProbeApp'
+ }
+ from {
+ configurations.runtimeClasspath.collect { it.isDirectory() ? it : zipTree(it) }
+ }
+ with jar
+ duplicatesStrategy = DuplicatesStrategy.EXCLUDE
+}
+
+// 両方のJARを作成するタスク
+task allJars {
+ dependsOn fatJar, probeJar
+}
\ No newline at end of file
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..744c64d
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,6 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip
+networkTimeout=10000
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100644
index 0000000..3421a6f
--- /dev/null
+++ b/gradlew.bat
@@ -0,0 +1,89 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd_ return code.
+if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
+exit /b 1
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/samples/README.md b/samples/README.md
new file mode 100644
index 0000000..a4f1604
--- /dev/null
+++ b/samples/README.md
@@ -0,0 +1,49 @@
+# サンプルファイル
+
+このディレクトリには、PDF メモレイヤー追加ツールのサンプルファイルを配置します。
+
+## ファイル構成
+
+- `input.pdf` - 処理対象となる入力PDFファイル(ユーザーが準備)
+- `output.pdf` - ツール実行後に生成される出力PDFファイル
+
+## 使用方法
+
+1. 処理したいPDFファイルを `input.pdf` として配置してください
+2. プロジェクトルートから以下のコマンドを実行:
+
+```bash
+# 基本的な実行
+java -jar build/libs/pdf-memo-layers.jar \
+ --in samples/input.pdf \
+ --out samples/output.pdf
+
+# 描画要素付きの実行
+java -jar build/libs/pdf-memo-layers.jar \
+ --in samples/input.pdf \
+ --out samples/output.pdf \
+ --draw "text:1,100,700,メモ1のサンプルテキスト" \
+ --draw "rect:2,50,50,200,60"
+```
+
+3. 生成された `output.pdf` をPDFビューアで開いてレイヤーを確認
+
+## テスト用PDFの準備
+
+適当なPDFファイルがない場合は、以下の方法で簡単なテスト用PDFを作成できます:
+
+1. **LibreOffice Writer等を使用**
+ - 簡単な文書を作成
+ - PDFとしてエクスポート
+
+2. **オンラインPDF生成ツール**
+ - 各種オンラインサービスでテキストからPDFを生成
+
+3. **コマンドラインツール**
+ - `pandoc` などのツールでMarkdownからPDF生成
+
+## 注意事項
+
+- `input.pdf` ファイルはGit管理対象外です(`.gitignore`に含まれています)
+- 実際のPDFファイルを使用してテストを行ってください
+- 生成された `output.pdf` も適宜削除してクリーンな状態を保ってください
diff --git a/src/main/java/co/jp/techsor/pdf/App.java b/src/main/java/co/jp/techsor/pdf/App.java
new file mode 100644
index 0000000..9ebef2f
--- /dev/null
+++ b/src/main/java/co/jp/techsor/pdf/App.java
@@ -0,0 +1,175 @@
+package co.jp.techsor.pdf;
+
+import org.apache.commons.cli.*;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * PDFメモレイヤー追加ツールのCLIエントリポイント
+ */
+public class App {
+
+ public static void main(String[] args) {
+ Options options = createOptions();
+
+ try {
+ CommandLineParser parser = new DefaultParser();
+ CommandLine cmd = parser.parse(options, args);
+
+ // 必須パラメータのチェック
+ if (!cmd.hasOption("in") || !cmd.hasOption("out")) {
+ printHelp(options);
+ System.exit(1);
+ }
+
+ String inputPath = cmd.getOptionValue("in");
+ String outputPath = cmd.getOptionValue("out");
+ String layer1Name = cmd.getOptionValue("layer1", "メモ1");
+ String layer2Name = cmd.getOptionValue("layer2", "メモ2");
+ boolean layer1On = Boolean.parseBoolean(cmd.getOptionValue("l1on", "true"));
+ boolean layer2On = Boolean.parseBoolean(cmd.getOptionValue("l2on", "true"));
+
+ // 描画オプションの解析
+ List drawingOptions = parseDrawingOptions(cmd.getOptionValues("draw"));
+
+ // 注釈レイヤー紐付けオプション
+ boolean assignAnnotations = cmd.hasOption("assign-annotations");
+ int annotationLayer = Integer.parseInt(cmd.getOptionValue("annotation-layer", "1"));
+
+ System.out.println("PDFメモレイヤー追加ツールを開始します...");
+ System.out.println("入力ファイル: " + inputPath);
+ System.out.println("出力ファイル: " + outputPath);
+ System.out.println("レイヤー1: " + layer1Name + " (初期状態: " + (layer1On ? "ON" : "OFF") + ")");
+ System.out.println("レイヤー2: " + layer2Name + " (初期状態: " + (layer2On ? "ON" : "OFF") + ")");
+
+ if (assignAnnotations) {
+ System.out.println("注釈のレイヤー紐付け: 有効 (デフォルトレイヤー: " + annotationLayer + ")");
+ }
+
+ // PDFメモレイヤーサービスを実行
+ PdfMemoLayerService service = new PdfMemoLayerService();
+ service.addMemoLayers(inputPath, outputPath, layer1Name, layer2Name,
+ layer1On, layer2On, drawingOptions, assignAnnotations, annotationLayer);
+
+ System.out.println("処理が完了しました: " + outputPath);
+
+ } catch (ParseException e) {
+ System.err.println("コマンドライン引数のパースエラー: " + e.getMessage());
+ printHelp(options);
+ System.exit(1);
+ } catch (Exception e) {
+ System.err.println("処理中にエラーが発生しました: " + e.getMessage());
+ e.printStackTrace();
+ System.exit(1);
+ }
+ }
+
+ private static Options createOptions() {
+ Options options = new Options();
+
+ options.addOption(Option.builder("in")
+ .longOpt("input")
+ .hasArg()
+ .required(true)
+ .desc("入力PDFファイルのパス")
+ .build());
+
+ options.addOption(Option.builder("out")
+ .longOpt("output")
+ .hasArg()
+ .required(true)
+ .desc("出力PDFファイルのパス")
+ .build());
+
+ options.addOption(Option.builder("layer1")
+ .hasArg()
+ .desc("レイヤー1の名前 (デフォルト: メモ1)")
+ .build());
+
+ options.addOption(Option.builder("layer2")
+ .hasArg()
+ .desc("レイヤー2の名前 (デフォルト: メモ2)")
+ .build());
+
+ options.addOption(Option.builder("l1on")
+ .hasArg()
+ .desc("レイヤー1の初期表示状態 (true/false, デフォルト: true)")
+ .build());
+
+ options.addOption(Option.builder("l2on")
+ .hasArg()
+ .desc("レイヤー2の初期表示状態 (true/false, デフォルト: true)")
+ .build());
+
+ options.addOption(Option.builder("draw")
+ .hasArgs()
+ .desc("描画オプション (複数指定可能): text:,,, または rect:,,,,")
+ .build());
+
+ options.addOption(Option.builder("h")
+ .longOpt("help")
+ .desc("ヘルプを表示")
+ .build());
+
+ options.addOption(Option.builder()
+ .longOpt("assign-annotations")
+ .desc("既存注釈をレイヤーに紐付ける")
+ .build());
+
+ options.addOption(Option.builder()
+ .longOpt("annotation-layer")
+ .hasArg()
+ .argName("LAYER")
+ .desc("注釈を紐付けるデフォルトレイヤー (1 or 2, デフォルト: 1)")
+ .build());
+
+ return options;
+ }
+
+ private static List parseDrawingOptions(String[] drawOptions) {
+ List options = new ArrayList<>();
+
+ if (drawOptions == null) {
+ return options;
+ }
+
+ for (String drawOption : drawOptions) {
+ try {
+ if (drawOption.startsWith("text:")) {
+ String[] parts = drawOption.substring(5).split(",", 4);
+ if (parts.length >= 4) {
+ int layerIndex = Integer.parseInt(parts[0]);
+ float x = Float.parseFloat(parts[1]);
+ float y = Float.parseFloat(parts[2]);
+ String content = parts[3];
+ options.add(LayerDrawingOptions.text(layerIndex, x, y, content));
+ }
+ } else if (drawOption.startsWith("rect:")) {
+ String[] parts = drawOption.substring(5).split(",");
+ if (parts.length >= 5) {
+ int layerIndex = Integer.parseInt(parts[0]);
+ float x = Float.parseFloat(parts[1]);
+ float y = Float.parseFloat(parts[2]);
+ float width = Float.parseFloat(parts[3]);
+ float height = Float.parseFloat(parts[4]);
+ options.add(LayerDrawingOptions.rectangle(layerIndex, x, y, width, height));
+ }
+ }
+ } catch (NumberFormatException e) {
+ System.err.println("描画オプションの解析エラー: " + drawOption);
+ }
+ }
+
+ return options;
+ }
+
+ private static void printHelp(Options options) {
+ HelpFormatter formatter = new HelpFormatter();
+ formatter.printHelp("pdf-memo-layers", "PDFにメモ用レイヤーを追加するツール", options,
+ "\n使用例:\n" +
+ " java -jar pdf-memo-layers.jar --in input.pdf --out output.pdf\n" +
+ " java -jar pdf-memo-layers.jar --in input.pdf --out output.pdf --layer1 \"メモ1\" --layer2 \"メモ2\" --draw \"text:1,100,700,サンプルテキスト\" --draw \"rect:2,50,50,200,60\" --l2-on false\n" +
+ " java -jar pdf-memo-layers.jar --in input.pdf --out output.pdf --assign-annotations --annotation-layer 1"
+ );
+ }
+}
diff --git a/src/main/java/co/jp/techsor/pdf/LayerDrawingOptions.java b/src/main/java/co/jp/techsor/pdf/LayerDrawingOptions.java
new file mode 100644
index 0000000..ed764b8
--- /dev/null
+++ b/src/main/java/co/jp/techsor/pdf/LayerDrawingOptions.java
@@ -0,0 +1,164 @@
+package co.jp.techsor.pdf;
+
+/**
+ * レイヤー描画オプションを管理するクラス
+ */
+public class LayerDrawingOptions {
+
+ public enum DrawingType {
+ TEXT, RECTANGLE
+ }
+
+ private final DrawingType type;
+ private final int layerIndex;
+ private final float x;
+ private final float y;
+ private final float width;
+ private final float height;
+ private final String textContent;
+ private final float fontSize;
+ private final int pageNumber;
+
+ // プライベートコンストラクタ
+ private LayerDrawingOptions(Builder builder) {
+ this.type = builder.type;
+ this.layerIndex = builder.layerIndex;
+ this.x = builder.x;
+ this.y = builder.y;
+ this.width = builder.width;
+ this.height = builder.height;
+ this.textContent = builder.textContent;
+ this.fontSize = builder.fontSize;
+ this.pageNumber = builder.pageNumber;
+ }
+
+ // ファクトリーメソッド:テキスト描画用
+ public static LayerDrawingOptions text(int layerIndex, float x, float y, String content) {
+ return new Builder(DrawingType.TEXT, layerIndex)
+ .position(x, y)
+ .textContent(content)
+ .fontSize(12.0f)
+ .pageNumber(1)
+ .build();
+ }
+
+ // ファクトリーメソッド:テキスト描画用(フォントサイズ指定)
+ public static LayerDrawingOptions text(int layerIndex, float x, float y, String content, float fontSize) {
+ return new Builder(DrawingType.TEXT, layerIndex)
+ .position(x, y)
+ .textContent(content)
+ .fontSize(fontSize)
+ .pageNumber(1)
+ .build();
+ }
+
+ // ファクトリーメソッド:四角形描画用
+ public static LayerDrawingOptions rectangle(int layerIndex, float x, float y, float width, float height) {
+ return new Builder(DrawingType.RECTANGLE, layerIndex)
+ .position(x, y)
+ .size(width, height)
+ .pageNumber(1)
+ .build();
+ }
+
+ // Builderクラス
+ public static class Builder {
+ private final DrawingType type;
+ private final int layerIndex;
+ private float x = 0;
+ private float y = 0;
+ private float width = 0;
+ private float height = 0;
+ private String textContent = "";
+ private float fontSize = 12.0f;
+ private int pageNumber = 1;
+
+ public Builder(DrawingType type, int layerIndex) {
+ this.type = type;
+ this.layerIndex = layerIndex;
+ }
+
+ public Builder position(float x, float y) {
+ this.x = x;
+ this.y = y;
+ return this;
+ }
+
+ public Builder size(float width, float height) {
+ this.width = width;
+ this.height = height;
+ return this;
+ }
+
+ public Builder textContent(String content) {
+ this.textContent = content;
+ return this;
+ }
+
+ public Builder fontSize(float fontSize) {
+ this.fontSize = fontSize;
+ return this;
+ }
+
+ public Builder pageNumber(int pageNumber) {
+ this.pageNumber = Math.max(1, pageNumber);
+ return this;
+ }
+
+ public LayerDrawingOptions build() {
+ return new LayerDrawingOptions(this);
+ }
+ }
+
+ // Getterメソッド
+ public DrawingType getType() {
+ return type;
+ }
+
+ public int getLayerIndex() {
+ return layerIndex;
+ }
+
+ public float getX() {
+ return x;
+ }
+
+ public float getY() {
+ return y;
+ }
+
+ public float getWidth() {
+ return width;
+ }
+
+ public float getHeight() {
+ return height;
+ }
+
+ public String getTextContent() {
+ return textContent;
+ }
+
+ public float getFontSize() {
+ return fontSize;
+ }
+
+ public int getPageNumber() {
+ return pageNumber;
+ }
+
+ @Override
+ public String toString() {
+ return "LayerDrawingOptions{" +
+ "type=" + type +
+ ", layerIndex=" + layerIndex +
+ ", x=" + x +
+ ", y=" + y +
+ ", width=" + width +
+ ", height=" + height +
+ ", textContent='" + textContent + '\'' +
+ ", fontSize=" + fontSize +
+ ", pageNumber=" + pageNumber +
+ '}';
+ }
+}
diff --git a/src/main/java/co/jp/techsor/pdf/PdfMemoLayerService.java b/src/main/java/co/jp/techsor/pdf/PdfMemoLayerService.java
new file mode 100644
index 0000000..9237248
--- /dev/null
+++ b/src/main/java/co/jp/techsor/pdf/PdfMemoLayerService.java
@@ -0,0 +1,270 @@
+package co.jp.techsor.pdf;
+
+import com.itextpdf.kernel.pdf.*;
+import com.itextpdf.kernel.pdf.layer.PdfLayer;
+import com.itextpdf.kernel.pdf.canvas.PdfCanvas;
+import com.itextpdf.kernel.pdf.annot.PdfAnnotation;
+import com.itextpdf.layout.Canvas;
+import com.itextpdf.layout.element.Paragraph;
+import com.itextpdf.layout.properties.TextAlignment;
+import com.itextpdf.kernel.geom.Rectangle;
+import com.itextpdf.kernel.colors.ColorConstants;
+import co.jp.techsor.pdf.util.PdfUtils;
+
+import java.io.IOException;
+import java.util.List;
+
+/**
+ * PDFにメモ用レイヤーを追加するサービスクラス
+ */
+public class PdfMemoLayerService {
+
+ /**
+ * PDFにメモ用レイヤーを2つ追加し、オプションで描画を行う
+ *
+ * @param inputPath 入力PDFファイルのパス
+ * @param outputPath 出力PDFファイルのパス
+ * @param layer1Name レイヤー1の名前
+ * @param layer2Name レイヤー2の名前
+ * @param layer1On レイヤー1の初期表示状態
+ * @param layer2On レイヤー2の初期表示状態
+ * @param drawingOptions 描画オプションのリスト
+ * @throws IOException ファイルI/Oエラー
+ */
+ public void addMemoLayers(String inputPath, String outputPath,
+ String layer1Name, String layer2Name,
+ boolean layer1On, boolean layer2On,
+ List drawingOptions) throws IOException {
+ addMemoLayers(inputPath, outputPath, layer1Name, layer2Name,
+ layer1On, layer2On, drawingOptions, false, 1);
+ }
+
+ /**
+ * PDFにメモ用レイヤーを2つ追加し、オプションで描画を行う(注釈のレイヤー紐付け対応)
+ *
+ * @param inputPath 入力PDFファイルのパス
+ * @param outputPath 出力PDFファイルのパス
+ * @param layer1Name レイヤー1の名前
+ * @param layer2Name レイヤー2の名前
+ * @param layer1On レイヤー1の初期表示状態
+ * @param layer2On レイヤー2の初期表示状態
+ * @param drawingOptions 描画オプションのリスト
+ * @param assignAnnotationsToLayers 既存注釈をレイヤーに紐付けるかどうか
+ * @param defaultAnnotationLayer 注釈を紐付けるデフォルトレイヤー(1 or 2)
+ * @throws IOException ファイルI/Oエラー
+ */
+ public void addMemoLayers(String inputPath, String outputPath,
+ String layer1Name, String layer2Name,
+ boolean layer1On, boolean layer2On,
+ List drawingOptions,
+ boolean assignAnnotationsToLayers,
+ int defaultAnnotationLayer) throws IOException {
+
+ // 入力ファイルの存在確認
+ if (!PdfUtils.fileExists(inputPath)) {
+ throw new IOException("入力ファイルが見つかりません: " + inputPath);
+ }
+
+ // ReaderPropertiesとWriterPropertiesの設定(暗号化PDF対応)
+ ReaderProperties readerProps = new ReaderProperties();
+ WriterProperties writerProps = new WriterProperties();
+
+ try (PdfDocument pdf = new PdfDocument(new PdfReader(inputPath, readerProps),
+ new PdfWriter(outputPath, writerProps))) {
+
+ // レイヤー(OCG)の作成
+ PdfLayer memoLayer1 = new PdfLayer(layer1Name, pdf);
+ memoLayer1.setOn(layer1On);
+
+ PdfLayer memoLayer2 = new PdfLayer(layer2Name, pdf);
+ memoLayer2.setOn(layer2On);
+
+ System.out.println("レイヤーを作成しました:");
+ System.out.println(" - " + layer1Name + " (初期状態: " + (layer1On ? "ON" : "OFF") + ")");
+ System.out.println(" - " + layer2Name + " (初期状態: " + (layer2On ? "ON" : "OFF") + ")");
+
+ // 既存注釈をレイヤーに紐付け
+ if (assignAnnotationsToLayers) {
+ assignAnnotationsToLayers(pdf, memoLayer1, memoLayer2, defaultAnnotationLayer);
+ }
+
+ // 描画オプションがある場合は描画を実行
+ if (drawingOptions != null && !drawingOptions.isEmpty()) {
+ performDrawing(pdf, memoLayer1, memoLayer2, drawingOptions);
+ }
+
+ System.out.println("PDFの処理が完了しました。ページ数: " + pdf.getNumberOfPages());
+ }
+ }
+
+ /**
+ * 指定された描画オプションに基づいて各レイヤーに描画を行う
+ */
+ private void performDrawing(PdfDocument pdf, PdfLayer layer1, PdfLayer layer2,
+ List drawingOptions) {
+
+ for (LayerDrawingOptions option : drawingOptions) {
+ // 描画対象のページを決定(1ページ目をデフォルトとする)
+ int pageNum = Math.min(option.getPageNumber(), pdf.getNumberOfPages());
+ PdfPage page = pdf.getPage(pageNum);
+ PdfCanvas canvas = new PdfCanvas(page);
+
+ // レイヤーの選択
+ PdfLayer targetLayer = (option.getLayerIndex() == 1) ? layer1 : layer2;
+
+ canvas.beginLayer(targetLayer);
+
+ try {
+ switch (option.getType()) {
+ case TEXT:
+ drawText(canvas, page, option);
+ break;
+ case RECTANGLE:
+ drawRectangle(canvas, option);
+ break;
+ default:
+ System.err.println("未対応の描画タイプ: " + option.getType());
+ break;
+ }
+
+ System.out.println("描画完了: " + option.getType() + " on Layer " + option.getLayerIndex());
+
+ } catch (Exception e) {
+ System.err.println("描画エラー: " + e.getMessage());
+ } finally {
+ canvas.endLayer();
+ }
+ }
+ }
+
+ /**
+ * テキストを描画する
+ */
+ private void drawText(PdfCanvas canvas, PdfPage page, LayerDrawingOptions option) {
+ try (Canvas layoutCanvas = new Canvas(canvas, page.getPageSize())) {
+ Paragraph paragraph = new Paragraph(option.getTextContent())
+ .setFontSize(option.getFontSize())
+ .setFontColor(ColorConstants.BLACK);
+
+ layoutCanvas.showTextAligned(paragraph, option.getX(), option.getY(),
+ TextAlignment.LEFT);
+ }
+ }
+
+ /**
+ * 四角形を描画する
+ */
+ private void drawRectangle(PdfCanvas canvas, LayerDrawingOptions option) {
+ canvas.setStrokeColor(ColorConstants.RED)
+ .setLineWidth(1.5f)
+ .rectangle(option.getX(), option.getY(), option.getWidth(), option.getHeight())
+ .stroke();
+ }
+
+ /**
+ * 既存の注釈を指定されたレイヤーに紐付ける
+ *
+ * @param pdf PDFドキュメント
+ * @param layer1 レイヤー1
+ * @param layer2 レイヤー2
+ * @param defaultLayer デフォルトで紐付けるレイヤー(1 or 2)
+ */
+ private void assignAnnotationsToLayers(PdfDocument pdf, PdfLayer layer1, PdfLayer layer2, int defaultLayer) {
+ int totalAnnotations = 0;
+ int assignedAnnotations = 0;
+
+ System.out.println("既存注釈のレイヤー紐付けを開始します...");
+
+ // 全ページをスキャンして注釈を処理
+ for (int pageNum = 1; pageNum <= pdf.getNumberOfPages(); pageNum++) {
+ PdfPage page = pdf.getPage(pageNum);
+ List annotations = page.getAnnotations();
+
+ if (annotations != null && !annotations.isEmpty()) {
+ System.out.println("ページ " + pageNum + ": " + annotations.size() + "個の注釈を発見");
+
+ for (PdfAnnotation annotation : annotations) {
+ totalAnnotations++;
+
+ try {
+ // 既存の/OCがあるかチェック
+ PdfDictionary annotDict = annotation.getPdfObject();
+ PdfDictionary existingOC = annotDict.getAsDictionary(PdfName.OC);
+
+ if (existingOC != null) {
+ System.out.println(" 注釈 " + getAnnotationType(annotation) + " は既にレイヤーに紐付け済み");
+ continue;
+ }
+
+ // デフォルトレイヤーまたは注釈タイプに基づいてレイヤーを決定
+ PdfLayer targetLayer = determineTargetLayer(annotation, layer1, layer2, defaultLayer);
+
+ // /OCを注釈辞書に追加
+ annotDict.put(PdfName.OC, targetLayer.getPdfObject());
+ annotDict.setModified();
+
+ assignedAnnotations++;
+ System.out.println(" 注釈 " + getAnnotationType(annotation) + " を " +
+ (targetLayer == layer1 ? layer1.getPdfObject().getAsString(PdfName.Name).getValue() :
+ layer2.getPdfObject().getAsString(PdfName.Name).getValue()) + " レイヤーに紐付けしました");
+
+ } catch (Exception e) {
+ System.err.println(" 注釈の処理中にエラーが発生しました: " + e.getMessage());
+ }
+ }
+ }
+ }
+
+ System.out.println("注釈のレイヤー紐付け完了:");
+ System.out.println(" 総注釈数: " + totalAnnotations);
+ System.out.println(" 紐付け済み: " + assignedAnnotations);
+ System.out.println(" スキップ: " + (totalAnnotations - assignedAnnotations));
+ }
+
+ /**
+ * 注釈の種類を取得する
+ */
+ private String getAnnotationType(PdfAnnotation annotation) {
+ PdfName subtype = annotation.getSubtype();
+ return subtype != null ? subtype.getValue() : "Unknown";
+ }
+
+ /**
+ * 注釈に対する適切なレイヤーを決定する
+ *
+ * @param annotation 注釈
+ * @param layer1 レイヤー1
+ * @param layer2 レイヤー2
+ * @param defaultLayer デフォルトレイヤー
+ * @return 対象レイヤー
+ */
+ private PdfLayer determineTargetLayer(PdfAnnotation annotation, PdfLayer layer1, PdfLayer layer2, int defaultLayer) {
+ // 注釈タイプに基づく自動判定ロジック(カスタマイズ可能)
+ PdfName subtype = annotation.getSubtype();
+
+ if (subtype != null) {
+ String type = subtype.getValue();
+
+ // 手書き系の注釈はレイヤー1、テキスト系はレイヤー2など
+ switch (type) {
+ case "Ink": // 手書き
+ case "FreeText": // フリーテキスト
+ case "Highlight": // ハイライト
+ return layer1;
+
+ case "Text": // テキスト注釈
+ case "Popup": // ポップアップ
+ case "Square": // 四角形
+ case "Circle": // 円形
+ return layer2;
+
+ default:
+ // デフォルトレイヤーを使用
+ return (defaultLayer == 1) ? layer1 : layer2;
+ }
+ }
+
+ // サブタイプが不明な場合はデフォルトレイヤーを使用
+ return (defaultLayer == 1) ? layer1 : layer2;
+ }
+}
diff --git a/src/main/java/co/jp/techsor/pdf/PdfProbeApp.java b/src/main/java/co/jp/techsor/pdf/PdfProbeApp.java
new file mode 100644
index 0000000..58108e2
--- /dev/null
+++ b/src/main/java/co/jp/techsor/pdf/PdfProbeApp.java
@@ -0,0 +1,572 @@
+package co.jp.techsor.pdf;
+
+import com.itextpdf.kernel.pdf.*;
+import com.itextpdf.kernel.pdf.annot.PdfAnnotation;
+import com.itextpdf.kernel.xmp.XMPMetaFactory;
+import com.itextpdf.kernel.xmp.XMPMeta;
+import com.itextpdf.kernel.xmp.XMPException;
+import org.apache.commons.cli.*;
+
+import java.io.File;
+import java.nio.charset.StandardCharsets;
+import java.util.*;
+import java.util.regex.Pattern;
+
+/**
+ * PDF解析ツール - レイヤー、メタデータ、Illustrator痕跡の詳細分析
+ */
+public class PdfProbeApp {
+
+ private static final String VERSION = "1.0";
+ private static final Pattern ILLUSTRATOR_PATTERN = Pattern.compile("(?i)illustrator|adobe\\s+illustrator");
+
+ public static void main(String[] args) {
+ Options options = createOptions();
+ CommandLineParser parser = new DefaultParser();
+
+ try {
+ CommandLine cmd = parser.parse(options, args);
+
+ if (cmd.hasOption("help")) {
+ printHelp(options);
+ return;
+ }
+
+ if (cmd.hasOption("version")) {
+ System.out.println("PDF Probe Tool v" + VERSION);
+ return;
+ }
+
+ String inputFile = cmd.getOptionValue("input");
+ if (inputFile == null) {
+ System.err.println("エラー: 入力ファイルを指定してください");
+ printHelp(options);
+ System.exit(1);
+ }
+
+ boolean verbose = cmd.hasOption("verbose");
+ boolean jsonOutput = cmd.hasOption("json");
+ String outputFile = cmd.getOptionValue("output");
+
+ PdfProbeApp app = new PdfProbeApp();
+ app.analyzePdf(inputFile, verbose, jsonOutput, outputFile);
+
+ } catch (ParseException e) {
+ System.err.println("コマンドラインパースエラー: " + e.getMessage());
+ printHelp(options);
+ System.exit(1);
+ } catch (Exception e) {
+ System.err.println("処理中にエラーが発生しました: " + e.getMessage());
+ if (args.length > 0 && Arrays.asList(args).contains("-v")) {
+ e.printStackTrace();
+ }
+ System.exit(1);
+ }
+ }
+
+ private static Options createOptions() {
+ Options options = new Options();
+
+ options.addOption(Option.builder("i")
+ .longOpt("input")
+ .hasArg()
+ .argName("FILE")
+ .desc("入力PDFファイルのパス")
+ .required()
+ .build());
+
+ options.addOption(Option.builder("o")
+ .longOpt("output")
+ .hasArg()
+ .argName("FILE")
+ .desc("結果を出力するファイル(省略時は標準出力)")
+ .build());
+
+ options.addOption(Option.builder("v")
+ .longOpt("verbose")
+ .desc("詳細な情報を表示")
+ .build());
+
+ options.addOption(Option.builder("j")
+ .longOpt("json")
+ .desc("JSON形式で出力")
+ .build());
+
+ options.addOption(Option.builder("h")
+ .longOpt("help")
+ .desc("ヘルプを表示")
+ .build());
+
+ options.addOption(Option.builder()
+ .longOpt("version")
+ .desc("バージョン情報を表示")
+ .build());
+
+ return options;
+ }
+
+ private static void printHelp(Options options) {
+ HelpFormatter formatter = new HelpFormatter();
+ formatter.printHelp("pdf-probe", "PDF解析ツール - レイヤー、メタデータ、Illustrator痕跡の詳細分析", options,
+ "\n使用例:\n" +
+ " java -jar pdf-probe.jar -i document.pdf\n" +
+ " java -jar pdf-probe.jar -i document.pdf -v -j -o result.json\n", true);
+ }
+
+ public void analyzePdf(String inputFile, boolean verbose, boolean jsonOutput, String outputFile) throws Exception {
+ File file = new File(inputFile);
+ if (!file.exists()) {
+ throw new RuntimeException("ファイルが見つかりません: " + inputFile);
+ }
+
+ PdfAnalysisResult result = new PdfAnalysisResult();
+ result.fileName = file.getName();
+ result.fileSize = file.length();
+
+ try (PdfDocument pdf = new PdfDocument(new PdfReader(inputFile))) {
+ System.out.println("=== PDF解析開始: " + file.getName() + " ===\n");
+
+ // 1. メタデータ解析
+ analyzeMetadata(pdf, result, verbose);
+
+ // 2. OCG(レイヤー)解析
+ analyzeOCG(pdf, result, verbose);
+
+ // 3. ページ内容とレイヤー使用状況解析
+ analyzePageContent(pdf, result, verbose);
+
+ // 4. 注釈のレイヤー紐付け解析
+ analyzeAnnotations(pdf, result, verbose);
+
+ // 5. Illustrator固有の痕跡解析
+ analyzeIllustratorTraces(pdf, result, verbose);
+
+ // 結果出力
+ if (jsonOutput) {
+ outputJson(result, outputFile);
+ } else {
+ outputText(result, outputFile, verbose);
+ }
+
+ System.out.println("\n=== 解析完了 ===");
+ }
+ }
+
+ private void analyzeMetadata(PdfDocument pdf, PdfAnalysisResult result, boolean verbose) {
+ System.out.println("1. メタデータ解析");
+ System.out.println("==================");
+
+ PdfDocumentInfo info = pdf.getDocumentInfo();
+ result.producer = info.getProducer();
+ result.creator = info.getCreator();
+ result.title = info.getTitle();
+ result.author = info.getAuthor();
+ result.subject = info.getSubject();
+
+ System.out.println("Producer: " + (result.producer != null ? result.producer : "(なし)"));
+ System.out.println("Creator : " + (result.creator != null ? result.creator : "(なし)"));
+
+ if (verbose) {
+ System.out.println("Title : " + (result.title != null ? result.title : "(なし)"));
+ System.out.println("Author : " + (result.author != null ? result.author : "(なし)"));
+ System.out.println("Subject : " + (result.subject != null ? result.subject : "(なし)"));
+ }
+
+ // Illustratorの検出
+ result.hasIllustratorInProducer = detectIllustrator(result.producer);
+ result.hasIllustratorInCreator = detectIllustrator(result.creator);
+
+ if (result.hasIllustratorInProducer || result.hasIllustratorInCreator) {
+ System.out.println("✓ Adobe Illustratorが検出されました");
+ }
+
+ // XMPメタデータ解析
+ analyzeXMP(pdf, result, verbose);
+ System.out.println();
+ }
+
+ private void analyzeXMP(PdfDocument pdf, PdfAnalysisResult result, boolean verbose) {
+ PdfDictionary catalog = pdf.getCatalog().getPdfObject();
+ PdfStream metadata = catalog.getAsStream(PdfName.Metadata);
+
+ if (metadata != null) {
+ try {
+ String xmpString = new String(metadata.getBytes(), StandardCharsets.UTF_8);
+ result.xmpMetadata = xmpString;
+ result.hasIllustratorInXMP = detectIllustrator(xmpString);
+
+ System.out.println("XMP Metadata: あり");
+ if (result.hasIllustratorInXMP) {
+ System.out.println("✓ XMP内にAdobe Illustratorが検出されました");
+ }
+
+ if (verbose) {
+ System.out.println("--- XMP内容 (最初の1000文字) ---");
+ System.out.println(xmpString.substring(0, Math.min(1000, xmpString.length())));
+ if (xmpString.length() > 1000) {
+ System.out.println("... (省略)");
+ }
+ }
+
+ // XMPの詳細解析
+ try {
+ XMPMeta xmpMeta = XMPMetaFactory.parseFromString(xmpString);
+ result.xmpCreatorTool = getXMPProperty(xmpMeta, "http://ns.adobe.com/xap/1.0/", "CreatorTool");
+ if (result.xmpCreatorTool != null) {
+ System.out.println("XMP CreatorTool: " + result.xmpCreatorTool);
+ if (!result.hasIllustratorInXMP) {
+ result.hasIllustratorInXMP = detectIllustrator(result.xmpCreatorTool);
+ }
+ }
+ } catch (XMPException e) {
+ if (verbose) {
+ System.out.println("XMP解析エラー: " + e.getMessage());
+ }
+ }
+
+ } catch (Exception e) {
+ System.out.println("XMP読み込みエラー: " + e.getMessage());
+ }
+ } else {
+ System.out.println("XMP Metadata: なし");
+ }
+ }
+
+ private String getXMPProperty(XMPMeta xmpMeta, String namespace, String property) {
+ try {
+ return xmpMeta.getPropertyString(namespace, property);
+ } catch (XMPException e) {
+ return null;
+ }
+ }
+
+ private void analyzeOCG(PdfDocument pdf, PdfAnalysisResult result, boolean verbose) {
+ System.out.println("2. OCG(レイヤー)解析");
+ System.out.println("====================");
+
+ PdfDictionary catalog = pdf.getCatalog().getPdfObject();
+ PdfDictionary ocProperties = catalog.getAsDictionary(PdfName.OCProperties);
+
+ if (ocProperties != null) {
+ result.hasOCG = true;
+ PdfArray ocgs = ocProperties.getAsArray(PdfName.OCGs);
+
+ if (ocgs != null) {
+ result.ocgCount = ocgs.size();
+ System.out.println("レイヤー数: " + result.ocgCount);
+ System.out.println("レイヤー一覧:");
+
+ for (int i = 0; i < ocgs.size(); i++) {
+ PdfDictionary ocg = ocgs.getAsDictionary(i);
+ if (ocg != null) {
+ String name = getStringValue(ocg.getAsString(PdfName.Name));
+ int objNum = ocg.getIndirectReference().getObjNumber();
+
+ OCGInfo ocgInfo = new OCGInfo();
+ ocgInfo.name = name;
+ ocgInfo.objectNumber = objNum;
+ result.ocgList.add(ocgInfo);
+
+ System.out.printf(" - %s (オブジェクト番号: %d)\n", name, objNum);
+
+ if (verbose) {
+ // OCGの詳細情報
+ PdfName intent = ocg.getAsName(PdfName.Intent);
+ if (intent != null) {
+ System.out.printf(" Intent: %s\n", intent);
+ }
+ }
+ }
+ }
+
+ // 初期表示設定
+ PdfDictionary defaultConfig = ocProperties.getAsDictionary(PdfName.D);
+ if (defaultConfig != null) {
+ System.out.println("\n初期表示設定:");
+
+ PdfArray onArray = defaultConfig.getAsArray(PdfName.ON);
+ PdfArray offArray = defaultConfig.getAsArray(PdfName.OFF);
+
+ if (onArray != null) {
+ System.out.print(" 初期ON : ");
+ for (int i = 0; i < onArray.size(); i++) {
+ PdfDictionary ocg = onArray.getAsDictionary(i);
+ if (ocg != null) {
+ String name = getStringValue(ocg.getAsString(PdfName.Name));
+ System.out.print(name + " ");
+ result.initialOnLayers.add(name);
+ }
+ }
+ System.out.println();
+ }
+
+ if (offArray != null) {
+ System.out.print(" 初期OFF: ");
+ for (int i = 0; i < offArray.size(); i++) {
+ PdfDictionary ocg = offArray.getAsDictionary(i);
+ if (ocg != null) {
+ String name = getStringValue(ocg.getAsString(PdfName.Name));
+ System.out.print(name + " ");
+ result.initialOffLayers.add(name);
+ }
+ }
+ System.out.println();
+ }
+ }
+ }
+ } else {
+ System.out.println("OCProperties なし(レイヤー情報なし)");
+ result.hasOCG = false;
+ }
+ System.out.println();
+ }
+
+ private void analyzePageContent(PdfDocument pdf, PdfAnalysisResult result, boolean verbose) {
+ System.out.println("3. ページ内容のレイヤー使用状況");
+ System.out.println("==============================");
+
+ result.pageCount = pdf.getNumberOfPages();
+ System.out.println("総ページ数: " + result.pageCount);
+
+ for (int pageNum = 1; pageNum <= pdf.getNumberOfPages(); pageNum++) {
+ PdfPage page = pdf.getPage(pageNum);
+ PageAnalysisInfo pageInfo = new PageAnalysisInfo();
+ pageInfo.pageNumber = pageNum;
+
+ // コンテンツストリームの解析
+ try {
+ byte[] contentBytes = page.getContentBytes();
+ String content = new String(contentBytes, StandardCharsets.UTF_8);
+
+ // BDC/EMCマーカーの検出
+ boolean hasBDC = content.contains("BDC");
+ boolean hasEMC = content.contains("EMC");
+ boolean hasOCMarker = content.contains("/OC");
+
+ pageInfo.hasBDCEMC = hasBDC && hasEMC;
+ pageInfo.hasOCMarker = hasOCMarker;
+
+ if (pageInfo.hasBDCEMC || pageInfo.hasOCMarker) {
+ result.pagesWithLayerContent++;
+ }
+
+ if (verbose || pageInfo.hasBDCEMC || pageInfo.hasOCMarker) {
+ System.out.printf("ページ %d: BDC/EMC=%s, /OC=%s\n",
+ pageNum, pageInfo.hasBDCEMC ? "あり" : "なし",
+ pageInfo.hasOCMarker ? "あり" : "なし");
+ }
+
+ } catch (Exception e) {
+ if (verbose) {
+ System.out.printf("ページ %d: コンテンツ解析エラー - %s\n", pageNum, e.getMessage());
+ }
+ }
+
+ result.pageAnalysis.add(pageInfo);
+ }
+
+ System.out.println("レイヤーコンテンツを含むページ数: " + result.pagesWithLayerContent);
+ System.out.println();
+ }
+
+ private void analyzeAnnotations(PdfDocument pdf, PdfAnalysisResult result, boolean verbose) {
+ System.out.println("4. 注釈のレイヤー紐付け解析");
+ System.out.println("==========================");
+
+ int totalAnnotations = 0;
+ int annotationsWithOC = 0;
+
+ for (int pageNum = 1; pageNum <= pdf.getNumberOfPages(); pageNum++) {
+ PdfPage page = pdf.getPage(pageNum);
+ List annotations = page.getAnnotations();
+
+ if (annotations != null && !annotations.isEmpty()) {
+ if (verbose) {
+ System.out.printf("-- ページ %d --\n", pageNum);
+ }
+
+ for (PdfAnnotation annotation : annotations) {
+ totalAnnotations++;
+ PdfDictionary annotDict = annotation.getPdfObject();
+ PdfName subtype = annotDict.getAsName(PdfName.Subtype);
+ PdfDictionary ocDict = annotDict.getAsDictionary(PdfName.OC);
+
+ AnnotationInfo annotInfo = new AnnotationInfo();
+ annotInfo.pageNumber = pageNum;
+ annotInfo.subtype = subtype != null ? subtype.getValue() : "Unknown";
+ annotInfo.hasOC = (ocDict != null);
+
+ if (ocDict != null) {
+ annotationsWithOC++;
+ annotInfo.ocLayerName = getStringValue(ocDict.getAsString(PdfName.Name));
+ }
+
+ result.annotations.add(annotInfo);
+
+ if (verbose) {
+ System.out.printf(" 注釈 %s: /OC=%s\n",
+ annotInfo.subtype,
+ annotInfo.hasOC ? annotInfo.ocLayerName : "なし");
+ }
+ }
+ }
+ }
+
+ result.totalAnnotations = totalAnnotations;
+ result.annotationsWithOC = annotationsWithOC;
+
+ System.out.println("総注釈数: " + totalAnnotations);
+ System.out.println("レイヤー紐付け注釈数: " + annotationsWithOC);
+ if (totalAnnotations > 0) {
+ System.out.printf("レイヤー紐付け率: %.1f%%\n",
+ (annotationsWithOC * 100.0 / totalAnnotations));
+ }
+ System.out.println();
+ }
+
+ private void analyzeIllustratorTraces(PdfDocument pdf, PdfAnalysisResult result, boolean verbose) {
+ System.out.println("5. Illustrator固有の痕跡解析");
+ System.out.println("===========================");
+
+ // カタログレベルの確認
+ PdfDictionary catalog = pdf.getCatalog().getPdfObject();
+ PdfDictionary pieceInfo = catalog.getAsDictionary(PdfName.PieceInfo);
+ if (pieceInfo != null) {
+ result.hasCatalogPieceInfo = true;
+ System.out.println("✓ カタログに PieceInfo が存在");
+ }
+
+ // ページレベルの確認
+ for (int pageNum = 1; pageNum <= pdf.getNumberOfPages(); pageNum++) {
+ PdfPage page = pdf.getPage(pageNum);
+ PdfDictionary pageDict = page.getPdfObject();
+
+ PdfDictionary pagePieceInfo = pageDict.getAsDictionary(PdfName.PieceInfo);
+ if (pagePieceInfo != null) {
+ result.pagesWithPieceInfo++;
+ if (verbose) {
+ System.out.printf("ページ %d: PieceInfo あり\n", pageNum);
+ }
+ }
+
+ // AIPrivateDataの検索(リソース内)
+ PdfDictionary resources = pageDict.getAsDictionary(PdfName.Resources);
+ if (resources != null && searchForAIPrivateData(resources)) {
+ result.pagesWithAIPrivateData++;
+ if (verbose) {
+ System.out.printf("ページ %d: AIPrivateData の痕跡あり\n", pageNum);
+ }
+ }
+ }
+
+ // 全体的なIllustrator判定
+ result.isLikelyFromIllustrator =
+ result.hasIllustratorInProducer ||
+ result.hasIllustratorInCreator ||
+ result.hasIllustratorInXMP ||
+ result.hasCatalogPieceInfo ||
+ result.pagesWithPieceInfo > 0;
+
+ System.out.println("PieceInfo付きページ数: " + result.pagesWithPieceInfo);
+ System.out.println("AIPrivateData痕跡ページ数: " + result.pagesWithAIPrivateData);
+
+ if (result.isLikelyFromIllustrator) {
+ System.out.println("✓ Adobe Illustratorで作成された可能性が高い");
+ } else {
+ System.out.println("Adobe Illustratorの痕跡は検出されませんでした");
+ }
+ System.out.println();
+ }
+
+ private boolean detectIllustrator(String text) {
+ return text != null && ILLUSTRATOR_PATTERN.matcher(text).find();
+ }
+
+ private boolean searchForAIPrivateData(PdfDictionary dict) {
+ // 簡易的な検索(実際はより詳細な検索が必要)
+ for (PdfName key : dict.keySet()) {
+ if (key.getValue().contains("AI") || key.getValue().contains("Adobe")) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private String getStringValue(PdfString pdfString) {
+ return pdfString != null ? pdfString.getValue() : null;
+ }
+
+ private void outputText(PdfAnalysisResult result, String outputFile, boolean verbose) {
+ // テキスト形式での結果出力(現在のコンソール出力がこれに相当)
+ System.out.println("=== 解析サマリー ===");
+ System.out.printf("ファイル: %s (%.1f KB)\n", result.fileName, result.fileSize / 1024.0);
+ System.out.printf("Adobe Illustrator: %s\n", result.isLikelyFromIllustrator ? "検出" : "未検出");
+ System.out.printf("レイヤー数: %d\n", result.ocgCount);
+ System.out.printf("総ページ数: %d\n", result.pageCount);
+ System.out.printf("総注釈数: %d\n", result.totalAnnotations);
+ }
+
+ private void outputJson(PdfAnalysisResult result, String outputFile) {
+ // JSON出力の実装(簡易版)
+ System.out.println("JSON出力機能は今後実装予定です。");
+ }
+
+ // データクラス群
+ static class PdfAnalysisResult {
+ String fileName;
+ long fileSize;
+
+ // メタデータ
+ String producer;
+ String creator;
+ String title;
+ String author;
+ String subject;
+ String xmpMetadata;
+ String xmpCreatorTool;
+ boolean hasIllustratorInProducer;
+ boolean hasIllustratorInCreator;
+ boolean hasIllustratorInXMP;
+
+ // OCG
+ boolean hasOCG;
+ int ocgCount;
+ List ocgList = new ArrayList<>();
+ List initialOnLayers = new ArrayList<>();
+ List initialOffLayers = new ArrayList<>();
+
+ // ページ
+ int pageCount;
+ int pagesWithLayerContent;
+ List pageAnalysis = new ArrayList<>();
+
+ // 注釈
+ int totalAnnotations;
+ int annotationsWithOC;
+ List annotations = new ArrayList<>();
+
+ // Illustrator痕跡
+ boolean hasCatalogPieceInfo;
+ int pagesWithPieceInfo;
+ int pagesWithAIPrivateData;
+ boolean isLikelyFromIllustrator;
+ }
+
+ static class OCGInfo {
+ String name;
+ int objectNumber;
+ }
+
+ static class PageAnalysisInfo {
+ int pageNumber;
+ boolean hasBDCEMC;
+ boolean hasOCMarker;
+ }
+
+ static class AnnotationInfo {
+ int pageNumber;
+ String subtype;
+ boolean hasOC;
+ String ocLayerName;
+ }
+}
diff --git a/src/main/java/co/jp/techsor/pdf/util/PdfUtils.java b/src/main/java/co/jp/techsor/pdf/util/PdfUtils.java
new file mode 100644
index 0000000..8251176
--- /dev/null
+++ b/src/main/java/co/jp/techsor/pdf/util/PdfUtils.java
@@ -0,0 +1,154 @@
+package co.jp.techsor.pdf.util;
+
+import java.io.File;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+
+/**
+ * PDF処理に関する共通ユーティリティクラス
+ */
+public class PdfUtils {
+
+ /**
+ * ファイルが存在するかチェックする
+ *
+ * @param filePath ファイルパス
+ * @return ファイルが存在する場合true
+ */
+ public static boolean fileExists(String filePath) {
+ if (filePath == null || filePath.trim().isEmpty()) {
+ return false;
+ }
+ return Files.exists(Paths.get(filePath));
+ }
+
+ /**
+ * ディレクトリが存在するかチェックし、存在しない場合は作成する
+ *
+ * @param dirPath ディレクトリパス
+ * @return 作成成功またはすでに存在する場合true
+ */
+ public static boolean ensureDirectoryExists(String dirPath) {
+ if (dirPath == null || dirPath.trim().isEmpty()) {
+ return false;
+ }
+
+ try {
+ Path path = Paths.get(dirPath);
+ if (!Files.exists(path)) {
+ Files.createDirectories(path);
+ }
+ return Files.isDirectory(path);
+ } catch (Exception e) {
+ System.err.println("ディレクトリ作成エラー: " + e.getMessage());
+ return false;
+ }
+ }
+
+ /**
+ * 出力ファイルのディレクトリを確保する
+ *
+ * @param outputPath 出力ファイルのパス
+ * @return 成功した場合true
+ */
+ public static boolean ensureOutputDirectory(String outputPath) {
+ if (outputPath == null || outputPath.trim().isEmpty()) {
+ return false;
+ }
+
+ File file = new File(outputPath);
+ File parentDir = file.getParentFile();
+
+ if (parentDir != null) {
+ return ensureDirectoryExists(parentDir.getAbsolutePath());
+ }
+
+ return true;
+ }
+
+ /**
+ * ファイル拡張子を取得する
+ *
+ * @param filePath ファイルパス
+ * @return ファイル拡張子(ドット含む)、拡張子がない場合は空文字列
+ */
+ public static String getFileExtension(String filePath) {
+ if (filePath == null || filePath.trim().isEmpty()) {
+ return "";
+ }
+
+ int lastDotIndex = filePath.lastIndexOf('.');
+ if (lastDotIndex > 0 && lastDotIndex < filePath.length() - 1) {
+ return filePath.substring(lastDotIndex);
+ }
+
+ return "";
+ }
+
+ /**
+ * PDFファイルかどうかを拡張子で判定する
+ *
+ * @param filePath ファイルパス
+ * @return PDFファイルの場合true
+ */
+ public static boolean isPdfFile(String filePath) {
+ String extension = getFileExtension(filePath).toLowerCase();
+ return ".pdf".equals(extension);
+ }
+
+ /**
+ * ファイルサイズを取得する(バイト単位)
+ *
+ * @param filePath ファイルパス
+ * @return ファイルサイズ、取得できない場合は-1
+ */
+ public static long getFileSize(String filePath) {
+ try {
+ if (fileExists(filePath)) {
+ return Files.size(Paths.get(filePath));
+ }
+ } catch (Exception e) {
+ System.err.println("ファイルサイズ取得エラー: " + e.getMessage());
+ }
+ return -1;
+ }
+
+ /**
+ * ファイルサイズを人間が読みやすい形式で取得する
+ *
+ * @param filePath ファイルパス
+ * @return ファイルサイズの文字列表現
+ */
+ public static String getReadableFileSize(String filePath) {
+ long bytes = getFileSize(filePath);
+ if (bytes < 0) {
+ return "不明";
+ }
+
+ if (bytes < 1024) {
+ return bytes + " B";
+ } else if (bytes < 1024 * 1024) {
+ return String.format("%.1f KB", bytes / 1024.0);
+ } else if (bytes < 1024 * 1024 * 1024) {
+ return String.format("%.1f MB", bytes / (1024.0 * 1024.0));
+ } else {
+ return String.format("%.1f GB", bytes / (1024.0 * 1024.0 * 1024.0));
+ }
+ }
+
+ /**
+ * 安全なファイル名を生成する(無効な文字を除去)
+ *
+ * @param fileName 元のファイル名
+ * @return 安全なファイル名
+ */
+ public static String sanitizeFileName(String fileName) {
+ if (fileName == null || fileName.trim().isEmpty()) {
+ return "unnamed";
+ }
+
+ // Windows/Linuxで無効な文字を置換
+ return fileName.replaceAll("[<>:\"/\\\\|?*]", "_").trim();
+ }
+}
diff --git a/test-convert.ps1 b/test-convert.ps1
new file mode 100644
index 0000000..2f84b02
--- /dev/null
+++ b/test-convert.ps1
@@ -0,0 +1,65 @@
+# テスト用スクリプト - Convert-Pdf.ps1のテスト実行
+
+# 現在のプロジェクトのJARファイルを使用したテスト
+$jarPath = "build\libs\pdf-memo-layers.jar"
+$samplesDir = "samples"
+
+Write-Host "=== Convert-Pdf.ps1 テスト実行 ===" -ForegroundColor Green
+
+# JARファイルの存在確認
+if (-not (Test-Path $jarPath)) {
+ Write-Host "エラー: JARファイルが見つかりません: $jarPath" -ForegroundColor Red
+ Write-Host "まず 'gradlew build' または 'gradlew fatJar' を実行してください" -ForegroundColor Yellow
+ exit 1
+}
+
+# サンプルディレクトリの存在確認
+if (-not (Test-Path $samplesDir)) {
+ Write-Host "エラー: samplesディレクトリが見つかりません" -ForegroundColor Red
+ exit 1
+}
+
+# 1. DryRunテスト
+Write-Host "`n1. DryRunテスト実行中..." -ForegroundColor Cyan
+.\Convert-Pdf.ps1 `
+ -RootDir $samplesDir `
+ -Converter "java -jar `"$jarPath`" --in `"{input}`" --out `"{output}`"" `
+ -DryRun
+
+# 2. 実際の変換テスト(単一ファイル処理)
+Write-Host "`n2. 実際の変換テスト(順次処理)..." -ForegroundColor Cyan
+.\Convert-Pdf.ps1 `
+ -RootDir $samplesDir `
+ -Converter "java -jar `"$jarPath`" --in `"{input}`" --out `"{output}`"" `
+ -Concurrency 1 `
+ -Log "convert-test.log"
+
+# 3. 並列処理テスト
+Write-Host "`n3. 並列処理テスト..." -ForegroundColor Cyan
+.\Convert-Pdf.ps1 `
+ -RootDir $samplesDir `
+ -Converter "java -jar `"$jarPath`" --in `"{input}`" --out `"{output}`"" `
+ -Concurrency 2 `
+ -Log "convert-parallel-test.log" `
+ -Force
+
+# 4. 出力先指定テスト
+Write-Host "`n4. 出力先指定テスト..." -ForegroundColor Cyan
+$outputDir = "test-output"
+if (Test-Path $outputDir) {
+ Remove-Item $outputDir -Recurse -Force
+}
+
+.\Convert-Pdf.ps1 `
+ -RootDir $samplesDir `
+ -Converter "java -jar `"$jarPath`" --in `"{input}`" --out `"{output}`"" `
+ -OutputRoot $outputDir `
+ -Log "convert-output-test.log"
+
+Write-Host "`n=== テスト完了 ===" -ForegroundColor Green
+Write-Host "生成されたファイルを確認してください:" -ForegroundColor Yellow
+Write-Host "- samplesディレクトリ内の *.converted.pdf ファイル"
+Write-Host "- test-outputディレクトリ内のファイル"
+Write-Host "- ログファイル: convert-*.log"
+
+
diff --git a/test-probe.ps1 b/test-probe.ps1
new file mode 100644
index 0000000..eb61431
--- /dev/null
+++ b/test-probe.ps1
@@ -0,0 +1,168 @@
+# PDF解析ツール(PdfProbeApp)のテスト用スクリプト
+
+Write-Host "=== PDF解析ツール テスト実行 ===" -ForegroundColor Green
+
+# JARファイルのパス
+$probeJarPath = "build\libs\pdf-probe.jar"
+$samplesDir = "samples"
+
+# JARファイルの存在確認
+if (-not (Test-Path $probeJarPath)) {
+ Write-Host "JARファイルが見つかりません。ビルドを実行します..." -ForegroundColor Yellow
+
+ # Gradleビルドを実行してJARファイルを作成
+ try {
+ Write-Host "Gradleビルド実行中..." -ForegroundColor Cyan
+ $env:JAVA_TOOL_OPTIONS = "-Dfile.encoding=UTF-8"
+
+ # 既存のクラスファイルを使ってJARを作成
+ Write-Host "JARファイル作成中..." -ForegroundColor Cyan
+
+ # probeJarタスクを実行
+ if (Test-Path "gradlew.bat") {
+ & .\gradlew.bat probeJar 2>$null
+ } else {
+ # 手動でJARファイルを作成
+ $buildLibsDir = "build\libs"
+ if (-not (Test-Path $buildLibsDir)) {
+ New-Item -Path $buildLibsDir -ItemType Directory -Force | Out-Null
+ }
+
+ # クラスファイルとライブラリからJARを作成
+ Write-Host "手動でJARファイルを作成中..." -ForegroundColor Cyan
+
+ # Manifestファイルを作成
+ $manifestContent = @"
+Manifest-Version: 1.0
+Main-Class: co.jp.techsor.pdf.PdfProbeApp
+
+"@
+ $manifestPath = "build\MANIFEST.MF"
+ $manifestContent | Out-File -FilePath $manifestPath -Encoding UTF8
+
+ # PowerShellでJARファイルを作成(zipコマンドの代替)
+ Add-Type -AssemblyName System.IO.Compression.FileSystem
+
+ if (Test-Path $probeJarPath) {
+ Remove-Item $probeJarPath -Force
+ }
+
+ $archive = [System.IO.Compression.ZipFile]::Open($probeJarPath, [System.IO.Compression.ZipArchiveMode]::Create)
+
+ # クラスファイルを追加
+ $classesDir = "build\classes"
+ if (Test-Path $classesDir) {
+ Get-ChildItem -Path $classesDir -Recurse -File | ForEach-Object {
+ $relativePath = $_.FullName.Substring((Resolve-Path $classesDir).Path.Length + 1)
+ $relativePath = $relativePath.Replace('\', '/')
+ [System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile($archive, $_.FullName, $relativePath) | Out-Null
+ }
+ }
+
+ # ライブラリファイルを追加
+ $libDir = "lib"
+ if (Test-Path $libDir) {
+ Get-ChildItem -Path $libDir -Filter "*.jar" | ForEach-Object {
+ $libArchive = [System.IO.Compression.ZipFile]::OpenRead($_.FullName)
+ foreach ($entry in $libArchive.Entries) {
+ if (-not $entry.FullName.EndsWith('/') -and -not $entry.FullName.StartsWith('META-INF/')) {
+ $newEntry = $archive.CreateEntry($entry.FullName)
+ $newEntry.LastWriteTime = $entry.LastWriteTime
+ $stream = $newEntry.Open()
+ $entryStream = $entry.Open()
+ $entryStream.CopyTo($stream)
+ $stream.Close()
+ $entryStream.Close()
+ }
+ }
+ $libArchive.Dispose()
+ }
+ }
+
+ # Manifestを追加
+ $manifestEntry = $archive.CreateEntry("META-INF/MANIFEST.MF")
+ $manifestStream = $manifestEntry.Open()
+ $manifestBytes = [System.Text.Encoding]::UTF8.GetBytes($manifestContent)
+ $manifestStream.Write($manifestBytes, 0, $manifestBytes.Length)
+ $manifestStream.Close()
+
+ $archive.Dispose()
+
+ Write-Host "JARファイルを手動で作成しました: $probeJarPath" -ForegroundColor Green
+ }
+ }
+ catch {
+ Write-Host "ビルドエラー: $($_.Exception.Message)" -ForegroundColor Red
+ Write-Host "手動でJARファイルを確認してください" -ForegroundColor Yellow
+ exit 1
+ }
+}
+
+# サンプルディレクトリの確認
+if (-not (Test-Path $samplesDir)) {
+ Write-Host "エラー: samplesディレクトリが見つかりません" -ForegroundColor Red
+ exit 1
+}
+
+# テスト対象のPDFファイルを取得
+$pdfFiles = Get-ChildItem -Path $samplesDir -Filter "*.pdf" | Select-Object -First 3
+
+if ($pdfFiles.Count -eq 0) {
+ Write-Host "エラー: samplesディレクトリにPDFファイルが見つかりません" -ForegroundColor Red
+ exit 1
+}
+
+Write-Host "`n対象PDFファイル:" -ForegroundColor Cyan
+$pdfFiles | ForEach-Object { Write-Host " - $($_.Name)" }
+
+# 各PDFファイルに対してテストを実行
+foreach ($pdfFile in $pdfFiles) {
+ Write-Host "`n" + ("=" * 60) -ForegroundColor Yellow
+ Write-Host "解析対象: $($pdfFile.Name)" -ForegroundColor Yellow
+ Write-Host ("=" * 60) -ForegroundColor Yellow
+
+ $inputPath = $pdfFile.FullName
+
+ try {
+ # 基本解析
+ Write-Host "`n1. 基本解析:" -ForegroundColor Cyan
+ java -Dfile.encoding=UTF-8 -jar $probeJarPath -i "$inputPath"
+
+ # 詳細解析
+ Write-Host "`n2. 詳細解析:" -ForegroundColor Cyan
+ java -Dfile.encoding=UTF-8 -jar $probeJarPath -i "$inputPath" -v
+
+ # 結果をファイルに保存
+ $outputFile = "analysis-result-$($pdfFile.BaseName).txt"
+ Write-Host "`n3. ファイル出力テスト:" -ForegroundColor Cyan
+ java -Dfile.encoding=UTF-8 -jar $probeJarPath -i "$inputPath" -v -o $outputFile
+
+ if (Test-Path $outputFile) {
+ Write-Host "✓ 結果ファイルが作成されました: $outputFile" -ForegroundColor Green
+ } else {
+ Write-Host "⚠ 結果ファイルの作成に失敗しました" -ForegroundColor Yellow
+ }
+
+ }
+ catch {
+ Write-Host "エラー: $($_.Exception.Message)" -ForegroundColor Red
+ }
+}
+
+# ヘルプの表示
+Write-Host "`n" + ("=" * 60) -ForegroundColor Yellow
+Write-Host "ヘルプ表示テスト" -ForegroundColor Yellow
+Write-Host ("=" * 60) -ForegroundColor Yellow
+
+try {
+ java -jar $probeJarPath --help
+}
+catch {
+ Write-Host "ヘルプ表示エラー: $($_.Exception.Message)" -ForegroundColor Red
+}
+
+Write-Host "`n=== PDF解析ツール テスト完了 ===" -ForegroundColor Green
+Write-Host "生成されたファイル:" -ForegroundColor Yellow
+Get-ChildItem -Filter "analysis-result-*.txt" | ForEach-Object {
+ Write-Host " - $($_.Name)" -ForegroundColor White
+}