Browse Source

初始化

master
review512jwy@163.com 3 months ago
commit
3d08ba4d28
  1. 78
      .gitignore
  2. 372
      Convert-Pdf.ps1
  3. 138
      Project.md
  4. 386
      README.md
  5. 61
      build.gradle
  6. 6
      gradle/wrapper/gradle-wrapper.properties
  7. 89
      gradlew.bat
  8. 49
      samples/README.md
  9. 175
      src/main/java/co/jp/techsor/pdf/App.java
  10. 164
      src/main/java/co/jp/techsor/pdf/LayerDrawingOptions.java
  11. 270
      src/main/java/co/jp/techsor/pdf/PdfMemoLayerService.java
  12. 572
      src/main/java/co/jp/techsor/pdf/PdfProbeApp.java
  13. 154
      src/main/java/co/jp/techsor/pdf/util/PdfUtils.java
  14. 65
      test-convert.ps1
  15. 168
      test-probe.ps1

78
.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

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

138
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
<dependency>
<groupId>com.itextpdf</groupId>
<artifactId>kernel</artifactId>
<version>8.0.2</version>
</dependency>
<dependency>
<groupId>com.itextpdf</groupId>
<artifactId>layout</artifactId>
<version>8.0.2</version>
</dependency>
```
* 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:<layerIndex(1|2)>,<x>,<y>,<content>`
* `rect:<layerIndex(1|2)>,<x>,<y>,<w>,<h>`
* `--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 もしくは商用ライセンスです。社内/顧客配布要件に合わせてライセンス選定を行ってください。

386
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:<レイヤー番号>,<X座標>,<Y座標>,<テキスト内容>
```
例:`text:1,100,700,サンプルテキスト`
#### 四角形描画
```
rect:<レイヤー番号>,<X座標>,<Y座標>,<>,<高さ>
```
例:`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<LayerDrawingOptions> 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
```

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

6
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

89
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

49
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` も適宜削除してクリーンな状態を保ってください

175
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<LayerDrawingOptions> 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:<layer>,<x>,<y>,<content> または rect:<layer>,<x>,<y>,<w>,<h>")
.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<LayerDrawingOptions> parseDrawingOptions(String[] drawOptions) {
List<LayerDrawingOptions> 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"
);
}
}

164
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 +
'}';
}
}

270
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<LayerDrawingOptions> 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<LayerDrawingOptions> 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<LayerDrawingOptions> 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<PdfAnnotation> 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;
}
}

572
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<PdfAnnotation> 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<OCGInfo> ocgList = new ArrayList<>();
List<String> initialOnLayers = new ArrayList<>();
List<String> initialOffLayers = new ArrayList<>();
// ページ
int pageCount;
int pagesWithLayerContent;
List<PageAnalysisInfo> pageAnalysis = new ArrayList<>();
// 注釈
int totalAnnotations;
int annotationsWithOC;
List<AnnotationInfo> 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;
}
}

154
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();
}
}

65
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"

168
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
}
Loading…
Cancel
Save