Files
marker_api/marker-convert-powershell/marker-convert.ps1
T

445 lines
15 KiB
PowerShell

#requires -Version 5.1
<#
.SYNOPSIS
Recursively convert supported documents to markdown via marker-api.
.DESCRIPTION
Scans a target folder for PDFs, DOCX, XLSX, PPTX, EPUB, images, and HTML.
Sends each file to the marker-api /marker endpoint (multipart/form-data)
and writes the resulting .md file beside the original.
.EXAMPLE
.\marker-convert.ps1 -TargetFolder .\documents
.\marker-convert.ps1 .\documents -Force -MaxConcurrency 8
.\marker-convert.ps1 .\documents -ApiUrl http://10.0.0.5:8000/marker
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true, Position = 0)]
[string]$TargetFolder,
[Uri]$ApiUrl = "http://localhost:8000/marker",
[ValidateSet("markdown", "json", "html", "chunks")]
[string]$OutputFormat = "markdown",
[switch]$Force,
[ValidateRange(1, 32)]
[int]$MaxConcurrency = 4,
[ValidateRange(30, 1800)]
[int]$Timeout = 300,
[string]$PageRange = "",
[switch]$ForceOcr,
[switch]$DisableImageExtraction,
[switch]$UseLlm,
[string]$LlmService = "marker.services.ollama.OllamaService",
[string]$Processors = "",
[string]$ConfigJson = "",
[string]$ConverterCls = ""
)
$ErrorActionPreference = "Stop"
# ════════════ 1. Validate ════════════
$TargetFolder = (Resolve-Path $TargetFolder).Path
if (-not (Test-Path $TargetFolder)) {
Write-Error "Target folder does not exist: $TargetFolder"
exit 1
}
Write-Host "====================================================" -ForegroundColor Cyan
Write-Host " Force overwrite: $(if ($Force) { 'Yes' } else { 'No' })" -ForegroundColor White
Write-Host " Force OCR : $(if ($ForceOcr) { 'Yes' } else { 'No' })" -ForegroundColor White
Write-Host " Use LLM : $(if ($UseLlm) { 'Yes' } else { 'No' })" -ForegroundColor White
Write-Host " LLM Service : $LlmService" -ForegroundColor White
Write-Host " Disable ImgExt : $(if ($DisableImageExtraction) { 'Yes' } else { 'No' })" -ForegroundColor White
if ($PageRange) { Write-Host " Page range : $PageRange" -ForegroundColor White }
if ($Processors) { Write-Host " Processors : $Processors" -ForegroundColor White }
if ($ConverterCls) { Write-Host " Converter : $ConverterCls" -ForegroundColor White }
Write-Host "====================================================" -ForegroundColor Cyan
Write-Host ""
# ════════════ 2. Discover files ════════════
$supportedExtensions = @('.pdf', '.docx', '.xlsx', '.pptx', '.epub',
'.png', '.jpg', '.jpeg', '.bmp', '.gif',
'.tiff', '.tif', '.webp', '.heic', '.html', '.htm')
$files = Get-ChildItem -Path $TargetFolder -Recurse -File |
Where-Object { $_.Extension -in $supportedExtensions } |
Sort-Object FullName
if ($files.Count -eq 0) {
Write-Host "No supported files found in $TargetFolder." -ForegroundColor Yellow
exit 0
}
Write-Host "Found $($files.Count) document(s) to convert." -ForegroundColor Green
Write-Host ""
# ════════════ 3. Worker function ════════════
# This script block runs inside Start-Job for each file.
$ScriptBlock = {
param(
[Parameter(Mandatory=$true)]
[string]$FilePath,
[Parameter(Mandatory=$true)]
[Uri]$ApiUrl,
[Parameter(Mandatory=$true)]
[string]$Ofmt,
[Parameter(Mandatory=$true)]
[bool]$Force,
[Parameter(Mandatory=$true)]
[int]$Timeout,
[string]$PageRange,
[bool]$ForceOcr,
[bool]$DisableImageExtraction,
[bool]$UseLlm,
[string]$LlmService,
[string]$Processors,
[string]$ConfigJson,
[string]$ConverterCls
)
# Build output path (same directory, same base name with .md extension)
$baseName = [System.IO.Path]::GetFileNameWithoutExtension($FilePath)
$dirPath = Split-Path $FilePath
$mdPath = Join-Path $dirPath "$baseName.md"
$result = [ordered]@{}
$result.File = (Split-Path $FilePath -Leaf)
$result.SizeBytes = (Get-Item $FilePath).Length
$result.Status = "pending"
$result.ElapsedMs = 0
$result.Error = ""
$result.OutputFile = ""
# Skip if output already exists and -Force was not used
if ((Test-Path $mdPath) -and ($Force -eq $false)) {
$result.Status = "skipped"
$result.OutputFile = $mdPath
return $result
}
$sw = [System.Diagnostics.Stopwatch]::StartNew()
try {
$bin = [System.IO.File]::ReadAllBytes($FilePath)
$name = [System.IO.Path]::GetFileName($FilePath)
$boundary = "----marker-convert-$(Get-Random -Minimum 100000 -Maximum 999999)"
$enc = [System.Text.Encoding]::UTF8
# ── Build multipart body ──
$contentParts = [System.Collections.ArrayList]::new()
# Part 1: file
$fileHeader = $enc.GetBytes(
"--$boundary`r`n" +
"Content-Disposition: form-data; name=`"file`"; filename=`"$name`"`r`n" +
"Content-Type: application/octet-stream`r`n`r`n"
)
$contentParts.Add($fileHeader) | Out-Null
$contentParts.Add($bin) | Out-Null
$contentParts.Add($enc.GetBytes("`r`n")) | Out-Null
# Part 2: output_format
$fmtPart = $enc.GetBytes(
"--$boundary`r`n" +
"Content-Disposition: form-data; name=`"output_format`"`r`n`r`n" +
"$Ofmt`r`n"
)
$contentParts.Add($fmtPart) | Out-Null
# Part 3: force_ocr
$ocrPart = $enc.GetBytes(
"--$boundary`r`n" +
"Content-Disposition: form-data; name=`"force_ocr`"`r`n`r`n" +
"$(if ($ForceOcr) { 'true' } else { 'false' })`r`n"
)
$contentParts.Add($ocrPart) | Out-Null
# Part 4: paginate_output (default false)
$pagPart = $enc.GetBytes(
"--$boundary`r`n" +
"Content-Disposition: form-data; name=`"paginate_output`"`r`n`r`n" +
"false`r`n"
)
$contentParts.Add($pagPart) | Out-Null
# Part 5: use_llm
$llmPart = $enc.GetBytes(
"--$boundary`r`n" +
"Content-Disposition: form-data; name=`"use_llm`"`r`n`r`n" +
"$(if ($UseLlm) { 'true' } else { 'false' })`r`n"
)
$contentParts.Add($llmPart) | Out-Null
# Part 6a: llm_service (only if UseLlm or custom service)
if ($UseLlm -or $LlmService -ne "marker.services.ollama.OllamaService") {
$svcPart = $enc.GetBytes(
"--$boundary`r`n" +
"Content-Disposition: form-data; name=`"llm_service`"`r`n`r`n" +
"$LlmService`r`n"
)
$contentParts.Add($svcPart) | Out-Null
}
# Part 6b: page_range
if ($PageRange) {
$prPart = $enc.GetBytes(
"--$boundary`r`n" +
"Content-Disposition: form-data; name=`"page_range`"`r`n`r`n" +
"$PageRange`r`n"
)
$contentParts.Add($prPart) | Out-Null
}
# Part 6c: disable_image_extraction
if ($DisableImageExtraction) {
$diePart = $enc.GetBytes(
"--$boundary`r`n" +
"Content-Disposition: form-data; name=`"disable_image_extraction`"`r`n`r`n" +
"true`r`n"
)
$contentParts.Add($diePart) | Out-Null
}
# Part 6d: processors
if ($Processors) {
$prcPart = $enc.GetBytes(
"--$boundary`r`n" +
"Content-Disposition: form-data; name=`"processors`"`r`n`r`n" +
"$Processors`r`n"
)
$contentParts.Add($prcPart) | Out-Null
}
# Part 6e: config_json
if ($ConfigJson) {
$cfgPart = $enc.GetBytes(
"--$boundary`r`n" +
"Content-Disposition: form-data; name=`"config_json`"`r`n`r`n" +
"$ConfigJson`r`n"
)
$contentParts.Add($cfgPart) | Out-Null
}
# Part 6f: converter_cls
if ($ConverterCls) {
$ccPart = $enc.GetBytes(
"--$boundary`r`n" +
"Content-Disposition: form-data; name=`"converter_cls`"`r`n`r`n" +
"$ConverterCls`r`n"
)
$contentParts.Add($ccPart) | Out-Null
}
# Closing boundary
$closing = $enc.GetBytes("--$boundary--`r`n")
# Calculate total content-length
$contentLength = 0
foreach ($p in $contentParts) {
$contentLength += $p.Length
}
$contentLength += $closing.Length
# ── Create HTTP request ──
$request = [System.Net.HttpWebRequest]::Create($ApiUrl)
$request.Method = "POST"
$request.ContentType = "multipart/form-data; boundary=$boundary"
$request.ContentLength = $contentLength
$request.Timeout = $Timeout * 1000
$request.KeepAlive = $true
$request.ProtocolVersion = [System.Net.HttpVersion]::Version11
$reqStream = $request.GetRequestStream()
# Write content parts
foreach ($p in $contentParts) {
$reqStream.Write($p, 0, $p.Length)
}
# Write closing boundary
$reqStream.Write($closing, 0, $closing.Length)
$reqStream.Flush()
$reqStream.Close()
# ── Get response ──
$response = $request.GetResponse()
$resStream = $response.GetResponseStream()
$reader = [System.IO.StreamReader]::new($resStream, $enc)
$content = $reader.ReadToEnd()
$reader.Close()
$resStream.Close()
$response.Close()
# ── Write output file ──
[System.IO.File]::WriteAllText($mdPath, $content, $enc)
$sw.Stop()
$result.Status = "success"
$result.ElapsedMs = $sw.ElapsedMilliseconds
$result.OutputFile = $mdPath
$result.ResponseSize = $content.Length
} catch {
$sw.Stop()
$result.Status = "failed"
$result.ElapsedMs = $sw.ElapsedMilliseconds
$result.Error = $_.Exception.Message
if ($_.Exception.Response) {
$result.Error += "`n Status: $([int]$_.Exception.Response.StatusCode)"
}
}
return $result
}
# ════════════ 4. Dispatch ════════════
Write-Host "Dispatching $($files.Count) conversion(s) with $($MaxConcurrency) worker(s)..." -ForegroundColor Yellow
$allResults = @()
$runningJobs = @()
for ($i = 0; $i -lt $files.Count; $i++) {
# If we've reached concurrency limit, wait for at least one job to complete
while ($runningJobs.Count -ge $MaxConcurrency) {
$completed = $runningJobs | Where-Object { $_.State -in @('Completed','Failed') }
if ($completed) {
foreach ($job in $completed) {
try {
$allResults += Receive-Job $job
} catch {
$r = [ordered]@{}
$r.File = "unknown"
$r.SizeBytes = 0
$r.Status = "failed"
$r.ElapsedMs = 0
$r.Error = $_.Exception.Message
$r.OutputFile = ""
$allResults += $r
}
$runningJobs = @($runningJobs | Where-Object { $_.Id -ne $job.Id })
Remove-Job $job 2>&1 | Out-Null
}
} else {
Start-Sleep -Milliseconds 200
}
}
# Launch new job
$f = $files[$i]
$safeName = [System.Security.SecurityElement]::Escape($f.Name)
$jobName = "mk_$safeName"
$job = Start-Job `
-Name $jobName `
-ScriptBlock $ScriptBlock `
-ArgumentList @(
$f.FullName
$ApiUrl
$OutputFormat
[bool]$Force
$Timeout
$PageRange
[bool]$ForceOcr
[bool]$DisableImageExtraction
[bool]$UseLlm
$LlmService
$Processors
$ConfigJson
$ConverterCls
)
$runningJobs += $job
Write-Host " [$($i + 1)/$($files.Count)] Queued: $($f.Name)" -ForegroundColor Gray
}
# Wait for remaining jobs
while ($runningJobs.Count -gt 0) {
$completed = $runningJobs | Where-Object { $_.State -in @('Completed','Failed') }
if ($completed) {
foreach ($job in $completed) {
try {
$allResults += Receive-Job $job
} catch {
$r = [ordered]@{}
$r.File = "unknown"
$r.SizeBytes = 0
$r.Status = "failed"
$r.ElapsedMs = 0
$r.Error = $_.Exception.Message
$r.OutputFile = ""
$allResults += $r
}
$runningJobs = @($runningJobs | Where-Object { $_.Id -ne $job.Id })
Remove-Job $job 2>&1 | Out-Null
}
} else {
Start-Sleep -Milliseconds 200
}
}
# ════════════ 5. Report ════════════
$ok = @($allResults | Where-Object { $_.Status -eq "success" })
$fail = @($allResults | Where-Object { $_.Status -eq "failed" })
$skip = @($allResults | Where-Object { $_.Status -eq "skipped" })
Write-Host ""
Write-Host "====== Conversion Results ======" -ForegroundColor Cyan
Write-Host " Total files : $($files.Count)" -ForegroundColor White
Write-Host " Successful : $($ok.Count) " -ForegroundColor Green
Write-Host " Failed : $($fail.Count) " -ForegroundColor Red
Write-Host " Skipped : $($skip.Count) " -ForegroundColor Yellow
if ($ok.Count -gt 0) {
$avgMs = [Math]::Round(($ok | Measure-Object -Property ElapsedMs -Sum).Sum / $ok.Count)
Write-Host " Avg time : $avgMs ms" -ForegroundColor Green
}
if ($fail.Count -gt 0) {
Write-Host ""
Write-Host "Errors:" -ForegroundColor Red
foreach ($r in $fail) {
Write-Host " [FAIL] $($r.File)" -ForegroundColor DarkRed
Write-Host " $($r.Error)" -ForegroundColor DarkGray
}
}
# Save CSV report
$csvPath = Join-Path $TargetFolder "_marker_convert_results.csv"
$resultsForCsv = @($allResults | ForEach-Object {
[PSCustomObject]@{
FileName = $_.File
OutputFile = if ($_.OutputFile) { $_.OutputFile } else { "N/A" }
Status = $_.Status
SizeBytes = $_.SizeBytes
ElapsedMs = $_.ElapsedMs
Error = $_.Error
}
})
$resultsForCsv | Sort-Object Status, File | Export-Csv $csvPath -NoTypeInformation -Encoding UTF8
Write-Host ""
Write-Host "CSV report: $csvPath" -ForegroundColor Magenta
Write-Host "====================================================" -ForegroundColor Cyan