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 +}