Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions documents/README.nuget.cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ minipdf report.docx --fonts ./Fonts
# Compress PDF content streams for large outputs
minipdf data.xlsx --compress

# Reduce memory for large simple XLSX tables using temporary disk storage
minipdf data.xlsx --low-memory --compress

# Render a bounded Excel preview
minipdf data.xlsx --max-rows 200 --max-columns 20 --compress

Expand All @@ -45,12 +48,19 @@ minipdf data.xlsx --max-rows 200 --max-columns 20 --compress
| `minipdf <file>` | Convert `.xlsx` / `.docx` / `.pptx` to PDF |
| `minipdf convert <file> -o <out>` | Convert with explicit output path |
| `minipdf convert <file> --compress` | Compress PDF content streams |
| `minipdf data.xlsx --low-memory` | Use opt-in disk-backed XLSX conversion |
| `minipdf data.xlsx --max-rows <n> --max-columns <n>` | Render a bounded Excel preview |
| `minipdf --version` | Show version |
| `minipdf --help` | Show help |

## Features

`--low-memory` preserves the default conversion path when omitted. It requires
writable OS temporary storage and supports simple XLSX tables only. Unsupported
worksheet features and options produce an explicit error; there is no automatic
high-memory fallback. See the [library guide](README.nuget.md#low-memory-xlsx-conversion-opt-in)
for supported features and limitations.

- No COM, no Office installation, no LibreOffice — runs anywhere .NET runs
- Single command, zero config
- Custom font registration for headless / container environments
Expand Down
49 changes: 49 additions & 0 deletions documents/README.nuget.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,55 @@ Sheet selection and Excel layout options apply to XLSX input. `PageSize` and
`Margins` apply to DOCX input and use PDF points (72 points = 1 inch). `Compress`
controls PDF content-stream compression for every supported format.

### Low-memory XLSX conversion (opt-in)

```csharp
using var input = File.OpenRead("large-table.xlsx");
using var output = File.Create("large-table.pdf");
MiniPdf.ConvertToPdf(input, output, new MiniPdfConversionOptions
{
LowMemory = true,
Compress = true,
});
```

`LowMemory` defaults to `false`; the existing conversion path is unchanged.
The opt-in mode streams worksheet XML to temporary storage, keeps rows and
completed pages on disk, and writes PDF content one page at a time. It reuses
the existing layout and font embedding logic, including Unicode text, cell
styles, number formats, column widths, wrapping and horizontal pagination.
Both file and stream overloads support it. Non-seekable inputs are spooled to
disk instead of memory. Temporary files are deleted when conversion finishes
or fails.

This first version supports simple tables. It throws `NotSupportedException`
rather than silently falling back for formulas, merged cells, conditional
formatting, drawings/charts/images, Excel tables, hyperlinks, headers/footers,
worksheet extensions, custom/hidden row heights, vertical centering, print
areas/titles, and fit-to-height pagination. Worksheet feature restrictions
apply to the entire workbook, including unselected sheets. `MaxRows`,
`MaxColumns` and `RowsPerPage` are not supported in this mode.

Use a file or output stream to avoid retaining the final PDF. Overloads that
return `byte[]` still allocate the complete output. Writable OS temporary
storage is required; disk use increases, and extra I/O can slow conversion. This is disk-backed
low-memory conversion, not a single-pass/no-disk pipeline. Offset indexes,
workbook/style metadata and font resources still consume memory proportional
to their size, and a single unusually large row or page can increase the peak.
This mode is not suitable for hosts without writable temporary storage.

To measure the .NET implementation locally (PowerShell 7 and Python required):

```powershell
.\scripts\Measure-DotNet-XlsxMemory.ps1 -RowCounts 20000,60000
```

The script runs each mode in a separate process, verifies identical PDF hashes,
records peak working sets and elapsed times under `artifacts/xlsx-low-memory`,
and requires at least a 30% peak working-set reduction on the largest generated
eight-column case. This is a regression benchmark, not a guarantee for every
workbook.

### Custom Fonts

Register TrueType `.ttf` or `.ttc` fonts once at application startup when the
Expand Down
109 changes: 109 additions & 0 deletions scripts/Measure-DotNet-XlsxMemory.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
#requires -Version 7.0
<#
.SYNOPSIS
Measures isolated-process peak working sets and verifies identical XLSX PDF output.
.EXAMPLE
.\scripts\Measure-DotNet-XlsxMemory.ps1 -RowCounts 20000,60000
#>
param(
[ValidateNotNullOrEmpty()]
[int[]]$RowCounts = @(20000, 60000)
)

$ErrorActionPreference = "Stop"
$RepoRoot = Split-Path -Parent $PSScriptRoot
$OutputDir = Join-Path $RepoRoot "artifacts\xlsx-low-memory\memory"
$Project = Join-Path $RepoRoot "src\MiniPdf.Cli\MiniPdf.Cli.csproj"
$Cli = Join-Path $RepoRoot "src\MiniPdf.Cli\bin\Release\net9.0\MiniPdf.Cli.dll"
$Dotnet = (Get-Command dotnet -ErrorAction Stop).Source
if ($RowCounts | Where-Object { $_ -lt 1 -or $_ -gt 1048576 }) {
throw "RowCounts must be between 1 and 1048576."
}
New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null
& $Dotnet build $Project -c Release --no-restore *> (Join-Path $OutputDir "build.log")
if ($LASTEXITCODE -ne 0) { throw "CLI build failed. See $OutputDir\build.log." }

$Generator = @'
import io, pathlib, sys, zipfile
directory = pathlib.Path(sys.argv[1])
ns = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
for count in map(int, sys.argv[2:]):
with zipfile.ZipFile(directory / f"table-{count}.xlsx", "w", zipfile.ZIP_DEFLATED) as z:
z.writestr("xl/workbook.xml", f'<workbook xmlns="{ns}"><sheets><sheet name="Data" sheetId="1"/></sheets></workbook>')
with io.TextIOWrapper(z.open("xl/sharedStrings.xml", "w"), encoding="utf-8") as out:
out.write(f'<sst xmlns="{ns}">')
for i in range(1, count+1):
out.write(f'<si><t>Record {i:08d}</t></si>')
out.write('</sst>')
with io.TextIOWrapper(z.open("xl/worksheets/sheet1.xml", "w"), encoding="utf-8") as out:
out.write(f'<worksheet xmlns="{ns}"><sheetFormatPr defaultRowHeight="15" defaultColWidth="12"/><cols><col min="1" max="8" width="12" customWidth="1"/></cols><sheetData>')
for i in range(1, count+1):
out.write(f'<row r="{i}"><c r="A{i}" t="s"><v>{i-1}</v></c>')
for col in "BCDEFGH":
out.write(f'<c r="{col}{i}"><v>{i*10+ord(col)}</v></c>')
out.write('</row>')
out.write('</sheetData><pageSetup paperSize="9" fitToHeight="0"/></worksheet>')
'@
$Generator | python - $OutputDir @RowCounts
if ($LASTEXITCODE -ne 0) { throw "Generating the memory benchmark workbooks failed." }

$Results = @()
foreach ($Rows in ($RowCounts | Sort-Object -Unique)) {
foreach ($LowMemory in @($false, $true)) {
$Mode = if ($LowMemory) { "low-memory" } else { "default" }
$Pdf = Join-Path $OutputDir "table-$Rows-$Mode.pdf"
$Info = [System.Diagnostics.ProcessStartInfo]::new($Dotnet)
$Info.UseShellExecute = $false
$Info.RedirectStandardOutput = $true
$Info.RedirectStandardError = $true
foreach ($Argument in @($Cli, (Join-Path $OutputDir "table-$Rows.xlsx"), "-o", $Pdf)) {
$Info.ArgumentList.Add($Argument)
}
if ($LowMemory) { $Info.ArgumentList.Add("--low-memory") }
$Process = [System.Diagnostics.Process]::new()
$Process.StartInfo = $Info
$Timer = [System.Diagnostics.Stopwatch]::StartNew()
$Peak = 0L
$Started = $false
try {
$Started = $Process.Start()
if (-not $Started) { throw "Could not start the conversion process." }
$Stdout = $Process.StandardOutput.ReadToEndAsync()
$Stderr = $Process.StandardError.ReadToEndAsync()
while (-not $Process.HasExited) {
$Process.Refresh()
$Peak = [Math]::Max($Peak, $Process.PeakWorkingSet64)
Start-Sleep -Milliseconds 20
}
$Process.WaitForExit()
$Timer.Stop()
$Output = $Stdout.GetAwaiter().GetResult()
$ErrorOutput = $Stderr.GetAwaiter().GetResult()
[System.IO.File]::WriteAllText((Join-Path $OutputDir "table-$Rows-$Mode.log"), $Output + $ErrorOutput)
if ($Process.ExitCode -ne 0) { throw "Conversion failed: $ErrorOutput" }
if ($Peak -eq 0) { throw "No memory sample was captured." }
$Result = [pscustomobject]@{
Rows = $Rows
Columns = 8
Mode = $Mode
PeakWorkingSetBytes = $Peak
Seconds = [Math]::Round($Timer.Elapsed.TotalSeconds, 3)
PdfBytes = (Get-Item -LiteralPath $Pdf).Length
Sha256 = (Get-FileHash -LiteralPath $Pdf -Algorithm SHA256).Hash
}
$Results += $Result
$Result | Format-List
}
finally {
if ($Started -and -not $Process.HasExited) { Stop-Process -Id $Process.Id }
$Process.Dispose()
}
}
$Pair = @($Results | Where-Object Rows -eq $Rows)
if ($Pair[0].Sha256 -ne $Pair[1].Sha256) { throw "PDF output differs for $Rows rows." }
}
$Results | ConvertTo-Json | Set-Content (Join-Path $OutputDir "results.json") -Encoding utf8
$Largest = @($Results | Where-Object Rows -eq ($RowCounts | Measure-Object -Maximum).Maximum)
$Ratio = $Largest[1].PeakWorkingSetBytes / $Largest[0].PeakWorkingSetBytes
if ($Ratio -gt 0.7) { throw "LowMemory must reduce peak working set by at least 30% on the largest case; ratio was $Ratio." }
Write-Host ("Largest-case peak working-set reduction: {0:P1}" -f (1 - $Ratio))
6 changes: 6 additions & 0 deletions src/MiniPdf.Cli/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ private static int RunConvert(ReadOnlySpan<string> args)
string[]? sheets = null;
int[]? sheetIndexes = null;
bool compress = false;
bool lowMemory = false;
int? maxRows = null;
int? maxColumns = null;
bool? landscape = null;
Expand All @@ -57,6 +58,9 @@ private static int RunConvert(ReadOnlySpan<string> args)
case "--compress":
compress = true;
break;
case "--low-memory":
lowMemory = true;
break;
case "--max-rows":
if (i + 1 >= args.Length || !TryParsePositiveInt(args[++i], out var parsedMaxRows))
{
Expand Down Expand Up @@ -169,6 +173,7 @@ private static int RunConvert(ReadOnlySpan<string> args)
Sheets = sheets,
SheetIndexes = sheetIndexes,
Compress = compress,
LowMemory = lowMemory,
MaxRows = maxRows,
MaxColumns = maxColumns,
Landscape = landscape,
Expand Down Expand Up @@ -259,6 +264,7 @@ private static void ShowConvertHelp()
" --fonts <dir> Directory of .ttf/.ttc fonts to register",
" --sheets <items> Comma-separated Excel sheet names or 1-based indexes to render",
" --compress Compress PDF content streams with FlateDecode",
" --low-memory Use disk-backed XLSX rows and pages (simple tables only)",
" --max-rows <n> Render only the first n rows from each Excel sheet or print area",
" --max-columns <n> Render only the first n columns from each Excel sheet or print area",
" --landscape Render Excel sheets in landscape orientation",
Expand Down
Loading
Loading