#requires -Version 5.1 <# .SYNOPSIS Stages PDFs as page-addressable UTF-8 JSONL with optional OCR fallback. .DESCRIPTION Reconstructed public demonstration of a provenance-preserving document staging workflow. Requires Poppler: pdfinfo, pdftotext; OCR fallback also requires pdftoppm and tesseract. Refuses to overwrite a nonempty output folder. Originals are read-only. .EXAMPLE .\01-Stage-PdfPages.ps1 -InputDirectory .\examples\pdfs -OutputDirectory .\scratch\corpus #> [CmdletBinding()] param( [Parameter(Mandatory)][string]$InputDirectory, [Parameter(Mandatory)][string]$OutputDirectory, [ValidateRange(0,10000)][int]$MinEmbeddedCharacters = 80, [ValidateRange(100,600)][int]$Dpi = 300, [ValidateRange(0,13)][int]$PageSegmentationMode = 3 ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $utf8 = New-Object System.Text.UTF8Encoding($false) function Require-Command([string]$Name) { if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) { throw "Required executable not found: $Name" } } function Safe-Stem([string]$Name) { $stem = [regex]::Replace($Name, '[^A-Za-z0-9._-]', '_') if ($stem.Length -gt 72) { $stem = $stem.Substring(0,72) } return $stem } function Hash-Text([string]$Value) { $sha = [System.Security.Cryptography.SHA256]::Create() try { $bytes = $utf8.GetBytes($Value) return ([BitConverter]::ToString($sha.ComputeHash($bytes))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() } } foreach ($tool in @('pdfinfo','pdftotext')) { Require-Command $tool } $sourceDirectory = (Resolve-Path -LiteralPath $InputDirectory).ProviderPath $output = [IO.Path]::GetFullPath($OutputDirectory) if ($sourceDirectory -eq $output -or $output.StartsWith($sourceDirectory.TrimEnd('\','/') + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase)) { throw 'OutputDirectory must be outside InputDirectory to prevent recursively ingesting staged artifacts.' } if (-not (Test-Path -LiteralPath $output)) { $null = New-Item -ItemType Directory -Path $output -Force } if (@(Get-ChildItem -LiteralPath $output -Force).Count -gt 0) { throw 'OutputDirectory is not empty. Choose a new directory to preserve existing artifacts.' } $pagesDir = Join-Path $output 'pages' $null = New-Item -ItemType Directory -Path $pagesDir $files = @(Get-ChildItem -LiteralPath $sourceDirectory -Filter '*.pdf' -File | Sort-Object Name) if ($files.Count -eq 0) { throw 'No PDFs found in the input directory.' } $manifest = [ordered]@{ schema = 'public-ocr-stage-v1'; createdUtc = (Get-Date).ToUniversalTime().ToString('o'); dpi = $Dpi; sourceCount = $files.Count; sources = @() } $warnings = New-Object 'System.Collections.Generic.List[string]' $writer = New-Object System.IO.StreamWriter((Join-Path $output 'pages.jsonl'), $false, $utf8) try { foreach ($pdf in $files) { $pdfInfo = & pdfinfo $pdf.FullName 2>&1 $infoText = ($pdfInfo | Out-String) if ($LASTEXITCODE -ne 0 -or $infoText -notmatch '(?m)^Pages:\s*(\d+)') { $warnings.Add("Could not read page count: $($pdf.Name)") continue } $total = [int]$Matches[1] $id = ((Safe-Stem $pdf.BaseName) + '-' + (Get-FileHash -LiteralPath $pdf.FullName -Algorithm SHA256).Hash.Substring(0,10).ToLowerInvariant()) $source = [ordered]@{ sourceFile = $pdf.Name; sha256 = (Get-FileHash -LiteralPath $pdf.FullName -Algorithm SHA256).Hash.ToLowerInvariant(); pageCount = $total; successfulPages = 0; ocrPages = 0; reviewPages = 0; pages = @() } for ($page = 1; $page -le $total; $page++) { $tempBase = Join-Path ([IO.Path]::GetTempPath()) ([IO.Path]::GetRandomFileName()) $tempText = "$tempBase.txt" $method = 'embedded' $status = 'ok' $textValue = '' try { $null = & pdftotext -f $page -l $page -layout -nopgbrk -enc UTF-8 $pdf.FullName $tempText 2>&1 if ($LASTEXITCODE -ne 0) { throw 'pdftotext failed for page' } $textValue = [IO.File]::ReadAllText($tempText) if ($textValue.Trim().Length -lt $MinEmbeddedCharacters) { foreach ($tool in @('pdftoppm','tesseract')) { Require-Command $tool } $method = 'ocr' $null = & pdftoppm -f $page -l $page -singlefile -r $Dpi -png $pdf.FullName $tempBase 2>&1 if ($LASTEXITCODE -ne 0) { throw 'pdftoppm failed for page' } $null = & tesseract "$tempBase.png" $tempBase -l eng --psm $PageSegmentationMode 2>&1 if ($LASTEXITCODE -ne 0) { throw 'tesseract failed for page' } $textValue = [IO.File]::ReadAllText($tempText) $source.ocrPages++ } if ($textValue.Trim().Length -lt 40) { $status = 'review-low-text' $source.reviewPages++ $warnings.Add("Review short text: $($pdf.Name) page $page") } $source.successfulPages++ } catch { $status = 'error' $textValue = '' $warnings.Add("Extraction error: $($pdf.Name) page $page ($($_.Exception.Message))") } finally { foreach ($temporary in @($tempText,"$tempBase.png","$tempBase.ppm")) { if (Test-Path -LiteralPath $temporary) { Remove-Item -LiteralPath $temporary -Force -ErrorAction SilentlyContinue } } } $outputName = ('{0}-p{1:D4}.txt' -f $id,$page) [IO.File]::WriteAllText((Join-Path $pagesDir $outputName),$textValue,$utf8) $digest = Hash-Text $textValue $row = [ordered]@{ id = ('{0}-p{1:D4}' -f $id,$page); sourceFile = $pdf.Name; page = $page; extractionMethod = $method; status = $status; textSha256 = $digest; text = $textValue } $writer.WriteLine(($row | ConvertTo-Json -Compress -Depth 6)) $source.pages += [ordered]@{ page = $page; extractionMethod = $method; status = $status; textSha256 = $digest } } $manifest.sources += $source } } finally { $writer.Dispose() } [IO.File]::WriteAllText((Join-Path $output 'manifest.json'),($manifest | ConvertTo-Json -Depth 12),$utf8) $report = @('# OCR staging review','','Source PDFs: ' + $files.Count,'Processed sources: ' + @($manifest.sources).Count,'') if ($warnings.Count -eq 0) { $report += 'No extraction warnings.' } else { $report += '## Review flags'; $report += @($warnings | ForEach-Object { '- ' + $_ }) } [IO.File]::WriteAllText((Join-Path $output 'quality-report.md'),($report -join "`n"),$utf8) Write-Host "Staged $(@($manifest.sources).Count) PDF(s) to $output. Review flags: $($warnings.Count)."