using DebenuPDFLibraryDLL1013;
namespace EnvelopeRenderer.Cli.Render;
///
/// Wraps Debenu Quick PDF Library 10.13 (loaded dynamically from via
/// its own LoadLibrary/GetProcAddress interop — see EnvelopeRenderer.Debenu). Every DPL* call in
/// this library returns 0 on failure by convention (inferred from the vendor wrapper's own
/// `if (dll == null) return 0;` fallback on every method — there's no documented error-text
/// lookup beyond a numeric ), so every call here is
/// checked and turned into a descriptive error instead of failing silently.
///
/// Batching mitigation (Sprint 3): Debenu's in-memory document model gets
/// progressively more expensive to append to as more pages accumulate in a single open
/// document — confirmed in code/BENCHMARK.md's root-cause investigation (a scaled-down
/// probe showed throughput falling from ~550 pages/sec to ~13-15 pages/sec within a few thousand
/// pages of a single document, and resetting back to ~550/s immediately after a fresh
/// instance was created). To work around this without changing any
/// observable behavior for small/typical renders, this renderer periodically saves the
/// in-progress document to its own temporary file every pages,
/// releases the Debenu instance, and opens a brand-new one for the next batch of pages. If more
/// than one batch was ever created, merges every batch file into the final
/// output via Debenu's own MergeFileListFast file-list merge API — a real Debenu
/// operation, not a byte-level PDF concatenation implemented in this repo. If only one batch was
/// ever needed (the common case for renders under pages), falls back to the original single SaveToFile call with no temp files and
/// no merge step at all, so small renders are byte-for-byte unaffected by this change.
///
public sealed class DebenuPdfRenderer : IPdfRenderer
{
///
/// Default number of pages added to a single underlying Debenu document before it is saved
/// to a temporary batch file and a fresh document is opened for the next batch. Chosen from
/// real measurements in code/BENCHMARK.md ("Batch size selection"): small enough to
/// keep sustained throughput close to the ~400-550 pages/sec "fresh document" rate (a batch
/// size of 1,000+ let the per-page cost climb enough to meaningfully hurt throughput again),
/// large enough that the extra Debenu overhead per batch — a full font re-embed each time a
/// document is reopened, observed at roughly +1 MB per extra batch for this template's single
/// TrueType font — stays a modest fraction of total output size at the product's 100,000-record
/// target scale rather than one closer to it (see the file-size-vs-throughput trade-off table
/// in code/BENCHMARK.md).
///
public const int DefaultPagesPerBatch = 300;
private readonly string _dllPath;
private readonly string? _licenseKey;
private readonly int _pagesPerBatch;
private readonly List _batchFilePaths = new();
private readonly string _batchTempDirectory;
private PDFLibrary _pdf;
private readonly Dictionary _fontHandles = new(StringComparer.OrdinalIgnoreCase);
private int _pagesInCurrentBatch;
private bool _disposed;
// A freshly-created Debenu document already has one page (verified: PageCount() == 1 right
// after construction, at the library's default Letter size). Calling NewPage() before the
// first record would leave a spurious blank page 1 in front of every render, so the first
// AddPage call (of the whole render, and again of every subsequent batch after a
// release/reopen) reuses the document's existing page instead of creating a new one.
private bool _firstPageUsed;
private DebenuPdfRenderer(string dllPath, string? licenseKey, PDFLibrary pdf, int pagesPerBatch)
{
_dllPath = dllPath;
_licenseKey = licenseKey;
_pdf = pdf;
_pagesPerBatch = pagesPerBatch;
_batchTempDirectory = Path.Combine(Path.GetTempPath(), $"EnvelopeRenderer-render-{Guid.NewGuid():N}");
}
public static bool TryCreate(
string dllPath, string? licenseKey, out DebenuPdfRenderer? renderer, out string? error)
=> TryCreate(dllPath, licenseKey, DefaultPagesPerBatch, out renderer, out error);
/// Overload allowing tests (and future tuning) to pick a batch size other than
/// without changing production behavior.
public static bool TryCreate(
string dllPath, string? licenseKey, int pagesPerBatch, out DebenuPdfRenderer? renderer, out string? error)
{
if (pagesPerBatch <= 0)
{
throw new ArgumentOutOfRangeException(nameof(pagesPerBatch), "Pages per batch must be positive.");
}
if (!TryOpenLibrary(dllPath, licenseKey, out var pdf, out error))
{
renderer = null;
return false;
}
renderer = new DebenuPdfRenderer(dllPath, licenseKey, pdf!, pagesPerBatch);
error = null;
return true;
}
private static bool TryOpenLibrary(
string dllPath, string? licenseKey, out PDFLibrary? pdf, out string? error)
{
var opened = new PDFLibrary(dllPath);
if (!opened.LibraryLoaded())
{
pdf = null;
error = $"Could not load Debenu Quick PDF Library from '{dllPath}'.";
return false;
}
if (!string.IsNullOrEmpty(licenseKey))
{
opened.UnlockKey(licenseKey);
}
pdf = opened;
error = null;
return true;
}
public bool AddPage(double pageWidth, double pageHeight, IReadOnlyList draws, out string? error)
{
if (_firstPageUsed && _pagesInCurrentBatch >= _pagesPerBatch)
{
if (!FlushCurrentBatchAndReopen(out error))
{
return false;
}
}
if (_firstPageUsed)
{
if (_pdf.NewPage() == 0)
{
error = $"Failed to start a new page (error code {_pdf.LastErrorCode()}).";
return false;
}
}
else
{
_firstPageUsed = true;
}
if (_pdf.SetPageDimensions(pageWidth, pageHeight) == 0)
{
error = $"Failed to set page size to {pageWidth} x {pageHeight} points " +
$"(error code {_pdf.LastErrorCode()}).";
return false;
}
if (_pdf.SetFillColor(0, 0, 0) == 0)
{
error = $"Failed to set text fill color (error code {_pdf.LastErrorCode()}).";
return false;
}
foreach (var draw in draws)
{
if (string.IsNullOrEmpty(draw.Text))
{
continue;
}
if (!TryGetFontHandle(draw.FontName, out var fontHandle, out error))
{
return false;
}
if (_pdf.SelectFont(fontHandle) == 0)
{
error = $"Failed to select font '{draw.FontName}' (error code {_pdf.LastErrorCode()}).";
return false;
}
if (_pdf.SetTextSize(draw.Size) == 0)
{
error = $"Failed to set text size {draw.Size} for font '{draw.FontName}' " +
$"(error code {_pdf.LastErrorCode()}).";
return false;
}
if (draw.Angle == 0)
{
// Unrotated path — byte-for-byte the same call this renderer made before Sprint
// 4's rotation story, so every pre-existing template (angle always 0) renders
// identically and pays no extra GetTextWidth/Ascent/Descent cost per draw.
if (_pdf.DrawText(draw.X, draw.Y, draw.Text) == 0)
{
error = $"Failed to draw text with font '{draw.FontName}' at ({draw.X}, {draw.Y}) " +
$"(error code {_pdf.LastErrorCode()}).";
return false;
}
}
else
{
// Rotate around the element's own bounding-box center, not the (X, Y) anchor
// DrawRotatedText itself rotates around — see RotatedTextAnchorCalculator for the
// anchor-offset math and the empirically-confirmed sign convention.
var width = _pdf.GetTextWidth(draw.Text);
var ascent = _pdf.GetTextAscent();
var descent = _pdf.GetTextDescent();
var (anchorX, anchorY) = RotatedTextAnchorCalculator.ComputeAnchor(
draw.X, draw.Y, width, ascent, descent, draw.Angle);
// Confirmed empirically against the real DLL: DrawRotatedText rejects a negative
// Angle outright (returns 0 with LastErrorCode() also 0 — no descriptive error at
// all), even though it is mathematically periodic. Normalize to Debenu's expected
// [0, 360) range before the call; the anchor-offset math above already used the
// original signed angle (trig functions handle negative angles natively and
// correctly), so this normalization is purely for the vendor call's own input
// validation, not a behavior change.
var normalizedAngle = ((draw.Angle % 360) + 360) % 360;
if (_pdf.DrawRotatedText(anchorX, anchorY, normalizedAngle, draw.Text) == 0)
{
error = $"Failed to draw rotated text with font '{draw.FontName}' at " +
$"({draw.X}, {draw.Y}), angle {draw.Angle} (error code {_pdf.LastErrorCode()}).";
return false;
}
}
}
_pagesInCurrentBatch++;
error = null;
return true;
}
public bool Save(string outputPath, out string? error)
{
if (_batchFilePaths.Count == 0)
{
// Fast path: no periodic flush was ever triggered (the whole render fit in one
// batch), so this behaves exactly as it did before batching existed — one direct
// SaveToFile call, no temp files, no merge step. This is the common case for renders
// under DefaultPagesPerBatch pages and keeps this change a no-op for them.
if (_pdf.SaveToFile(outputPath) == 0)
{
error = $"Failed to save PDF to '{outputPath}' (error code {_pdf.LastErrorCode()}).";
return false;
}
error = null;
return true;
}
// Batching occurred: flush the final (possibly partial) batch to its own file, then
// merge every batch — in order — into the requested output path via Debenu's own
// file-list merge API.
var finalBatchPath = NextBatchFilePath();
if (_pdf.SaveToFile(finalBatchPath) == 0)
{
error = $"Failed to save final batch to '{finalBatchPath}' (error code {_pdf.LastErrorCode()}).";
return false;
}
_batchFilePaths.Add(finalBatchPath);
ReleaseCurrentInstance();
return MergeBatchesInto(outputPath, out error);
}
/// Saves the current in-progress batch to its own temp file, releases the current
/// Debenu instance, and opens a fresh one for the next batch of pages — the exact
/// "periodic Save + release/reopen a fresh PDFLibrary instance" mitigation confirmed in
/// code/BENCHMARK.md to reset the per-page cost curve back to its initial rate.
private bool FlushCurrentBatchAndReopen(out string? error)
{
var batchPath = NextBatchFilePath();
if (_pdf.SaveToFile(batchPath) == 0)
{
error = $"Failed to save batch {_batchFilePaths.Count + 1} to '{batchPath}' " +
$"(error code {_pdf.LastErrorCode()}).";
return false;
}
_batchFilePaths.Add(batchPath);
ReleaseCurrentInstance();
if (!TryOpenLibrary(_dllPath, _licenseKey, out var reopened, out error))
{
return false;
}
_pdf = reopened!;
_fontHandles.Clear();
_firstPageUsed = false;
_pagesInCurrentBatch = 0;
error = null;
return true;
}
private bool MergeBatchesInto(string outputPath, out string? error)
{
if (!TryOpenLibrary(_dllPath, _licenseKey, out var mergePdf, out error))
{
return false;
}
const string listName = "EnvelopeRendererBatches";
try
{
// ClearFileList's return value reflects how many entries it removed (0 is the
// expected, non-error result for a brand-new list on this freshly-opened instance,
// confirmed against the real DLL — not every DPL* function follows the "0 = failure"
// convention documented on the class), so its result is intentionally not treated as
// a pass/fail signal here.
mergePdf!.ClearFileList(listName);
foreach (var batchPath in _batchFilePaths)
{
if (mergePdf.AddToFileList(listName, batchPath) == 0)
{
error = $"Failed to add batch '{batchPath}' to the merge list " +
$"(error code {mergePdf.LastErrorCode()}).";
return false;
}
}
if (mergePdf.MergeFileListFast(listName, outputPath) == 0)
{
error = $"Failed to merge {_batchFilePaths.Count} render batches into " +
$"'{outputPath}' (error code {mergePdf.LastErrorCode()}).";
return false;
}
error = null;
return true;
}
finally
{
if (mergePdf!.LibraryLoaded())
{
mergePdf.ReleaseLibrary();
}
CleanupBatchFiles();
}
}
private string NextBatchFilePath()
{
Directory.CreateDirectory(_batchTempDirectory);
return Path.Combine(_batchTempDirectory, $"batch-{_batchFilePaths.Count:D6}.pdf");
}
private void ReleaseCurrentInstance()
{
if (_pdf.LibraryLoaded())
{
_pdf.ReleaseLibrary();
}
}
private void CleanupBatchFiles()
{
foreach (var path in _batchFilePaths)
{
try
{
if (File.Exists(path))
{
File.Delete(path);
}
}
catch (IOException)
{
// Best-effort cleanup only; a leftover temp file under %TEMP% is not worth
// failing an otherwise-successful render over.
}
}
try
{
if (Directory.Exists(_batchTempDirectory))
{
Directory.Delete(_batchTempDirectory, recursive: true);
}
}
catch (IOException)
{
// Best-effort cleanup only, as above.
}
}
///
/// Fonts are added by TrueType family name (e.g. "Arial") rather than Debenu's numeric
/// AddStandardFont IDs, because those IDs aren't documented anywhere in this repo and
/// guessing them risks silently rendering the wrong font — a real defect for a print job.
/// A missing/unresolvable font is a blocking failure per the Definition of Done's
/// "missing-font behavior remains blocking" rule.
///
private bool TryGetFontHandle(string fontName, out int handle, out string? error)
{
if (_fontHandles.TryGetValue(fontName, out handle))
{
error = null;
return true;
}
handle = _pdf.AddTrueTypeFont(fontName, Embed: 1);
if (handle == 0)
{
error = $"Font not found or could not be embedded: '{fontName}'.";
return false;
}
_fontHandles[fontName] = handle;
error = null;
return true;
}
public void Dispose()
{
if (_disposed)
{
return;
}
if (_pdf.LibraryLoaded())
{
_pdf.ReleaseLibrary();
}
// Covers the failure path: if a mid-render AddPage/Save call failed after one or more
// batches were already flushed to disk, Save's own cleanup never ran. Safe to call again
// even when Save already cleaned up successfully (CleanupBatchFiles is idempotent).
CleanupBatchFiles();
_disposed = true;
}
}