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