Nelze vybrat více než 25 témat Téma musí začínat písmenem nebo číslem, může obsahovat pomlčky („-“) a může být dlouhé až 35 znaků.

425 řádky
17KB

  1. using DebenuPDFLibraryDLL1013;
  2. namespace EnvelopeRenderer.Cli.Render;
  3. /// <summary>
  4. /// Wraps Debenu Quick PDF Library 10.13 (loaded dynamically from <paramref name="dllPath"/> via
  5. /// its own LoadLibrary/GetProcAddress interop — see EnvelopeRenderer.Debenu). Every DPL* call in
  6. /// this library returns 0 on failure by convention (inferred from the vendor wrapper's own
  7. /// `if (dll == null) return 0;` fallback on every method — there's no documented error-text
  8. /// lookup beyond a numeric <see cref="PDFLibrary.LastErrorCode"/>), so every call here is
  9. /// checked and turned into a descriptive error instead of failing silently.
  10. ///
  11. /// <para><b>Batching mitigation (Sprint 3):</b> Debenu's in-memory document model gets
  12. /// progressively more expensive to append to as more pages accumulate in a single open
  13. /// document — confirmed in <c>code/BENCHMARK.md</c>'s root-cause investigation (a scaled-down
  14. /// probe showed throughput falling from ~550 pages/sec to ~13-15 pages/sec within a few thousand
  15. /// pages of a single document, and resetting back to ~550/s immediately after a fresh
  16. /// <see cref="PDFLibrary"/> instance was created). To work around this without changing any
  17. /// observable behavior for small/typical renders, this renderer periodically saves the
  18. /// in-progress document to its own temporary file every <see cref="_pagesPerBatch"/> pages,
  19. /// releases the Debenu instance, and opens a brand-new one for the next batch of pages. If more
  20. /// than one batch was ever created, <see cref="Save"/> merges every batch file into the final
  21. /// output via Debenu's own <c>MergeFileListFast</c> file-list merge API — a real Debenu
  22. /// operation, not a byte-level PDF concatenation implemented in this repo. If only one batch was
  23. /// ever needed (the common case for renders under <see cref="_pagesPerBatch"/> pages), <see
  24. /// cref="Save"/> falls back to the original single <c>SaveToFile</c> call with no temp files and
  25. /// no merge step at all, so small renders are byte-for-byte unaffected by this change.</para>
  26. /// </summary>
  27. public sealed class DebenuPdfRenderer : IPdfRenderer
  28. {
  29. /// <summary>
  30. /// Default number of pages added to a single underlying Debenu document before it is saved
  31. /// to a temporary batch file and a fresh document is opened for the next batch. Chosen from
  32. /// real measurements in <c>code/BENCHMARK.md</c> ("Batch size selection"): small enough to
  33. /// keep sustained throughput close to the ~400-550 pages/sec "fresh document" rate (a batch
  34. /// size of 1,000+ let the per-page cost climb enough to meaningfully hurt throughput again),
  35. /// large enough that the extra Debenu overhead per batch — a full font re-embed each time a
  36. /// document is reopened, observed at roughly +1 MB per extra batch for this template's single
  37. /// TrueType font — stays a modest fraction of total output size at the product's 100,000-record
  38. /// target scale rather than one closer to it (see the file-size-vs-throughput trade-off table
  39. /// in <c>code/BENCHMARK.md</c>).
  40. /// </summary>
  41. public const int DefaultPagesPerBatch = 300;
  42. private readonly string _dllPath;
  43. private readonly string? _licenseKey;
  44. private readonly int _pagesPerBatch;
  45. private readonly List<string> _batchFilePaths = new();
  46. private readonly string _batchTempDirectory;
  47. private PDFLibrary _pdf;
  48. private readonly Dictionary<string, int> _fontHandles = new(StringComparer.OrdinalIgnoreCase);
  49. private int _pagesInCurrentBatch;
  50. private bool _disposed;
  51. // A freshly-created Debenu document already has one page (verified: PageCount() == 1 right
  52. // after construction, at the library's default Letter size). Calling NewPage() before the
  53. // first record would leave a spurious blank page 1 in front of every render, so the first
  54. // AddPage call (of the whole render, and again of every subsequent batch after a
  55. // release/reopen) reuses the document's existing page instead of creating a new one.
  56. private bool _firstPageUsed;
  57. private DebenuPdfRenderer(string dllPath, string? licenseKey, PDFLibrary pdf, int pagesPerBatch)
  58. {
  59. _dllPath = dllPath;
  60. _licenseKey = licenseKey;
  61. _pdf = pdf;
  62. _pagesPerBatch = pagesPerBatch;
  63. _batchTempDirectory = Path.Combine(Path.GetTempPath(), $"EnvelopeRenderer-render-{Guid.NewGuid():N}");
  64. }
  65. public static bool TryCreate(
  66. string dllPath, string? licenseKey, out DebenuPdfRenderer? renderer, out string? error)
  67. => TryCreate(dllPath, licenseKey, DefaultPagesPerBatch, out renderer, out error);
  68. /// <summary>Overload allowing tests (and future tuning) to pick a batch size other than
  69. /// <see cref="DefaultPagesPerBatch"/> without changing production behavior.</summary>
  70. public static bool TryCreate(
  71. string dllPath, string? licenseKey, int pagesPerBatch, out DebenuPdfRenderer? renderer, out string? error)
  72. {
  73. if (pagesPerBatch <= 0)
  74. {
  75. throw new ArgumentOutOfRangeException(nameof(pagesPerBatch), "Pages per batch must be positive.");
  76. }
  77. if (!TryOpenLibrary(dllPath, licenseKey, out var pdf, out error))
  78. {
  79. renderer = null;
  80. return false;
  81. }
  82. renderer = new DebenuPdfRenderer(dllPath, licenseKey, pdf!, pagesPerBatch);
  83. error = null;
  84. return true;
  85. }
  86. private static bool TryOpenLibrary(
  87. string dllPath, string? licenseKey, out PDFLibrary? pdf, out string? error)
  88. {
  89. var opened = new PDFLibrary(dllPath);
  90. if (!opened.LibraryLoaded())
  91. {
  92. pdf = null;
  93. error = $"Could not load Debenu Quick PDF Library from '{dllPath}'.";
  94. return false;
  95. }
  96. if (!string.IsNullOrEmpty(licenseKey))
  97. {
  98. opened.UnlockKey(licenseKey);
  99. }
  100. pdf = opened;
  101. error = null;
  102. return true;
  103. }
  104. public bool AddPage(double pageWidth, double pageHeight, IReadOnlyList<TextDraw> draws, out string? error)
  105. {
  106. if (_firstPageUsed && _pagesInCurrentBatch >= _pagesPerBatch)
  107. {
  108. if (!FlushCurrentBatchAndReopen(out error))
  109. {
  110. return false;
  111. }
  112. }
  113. if (_firstPageUsed)
  114. {
  115. if (_pdf.NewPage() == 0)
  116. {
  117. error = $"Failed to start a new page (error code {_pdf.LastErrorCode()}).";
  118. return false;
  119. }
  120. }
  121. else
  122. {
  123. _firstPageUsed = true;
  124. }
  125. if (_pdf.SetPageDimensions(pageWidth, pageHeight) == 0)
  126. {
  127. error = $"Failed to set page size to {pageWidth} x {pageHeight} points " +
  128. $"(error code {_pdf.LastErrorCode()}).";
  129. return false;
  130. }
  131. if (_pdf.SetFillColor(0, 0, 0) == 0)
  132. {
  133. error = $"Failed to set text fill color (error code {_pdf.LastErrorCode()}).";
  134. return false;
  135. }
  136. foreach (var draw in draws)
  137. {
  138. if (string.IsNullOrEmpty(draw.Text))
  139. {
  140. continue;
  141. }
  142. if (!TryGetFontHandle(draw.FontName, out var fontHandle, out error))
  143. {
  144. return false;
  145. }
  146. if (_pdf.SelectFont(fontHandle) == 0)
  147. {
  148. error = $"Failed to select font '{draw.FontName}' (error code {_pdf.LastErrorCode()}).";
  149. return false;
  150. }
  151. if (_pdf.SetTextSize(draw.Size) == 0)
  152. {
  153. error = $"Failed to set text size {draw.Size} for font '{draw.FontName}' " +
  154. $"(error code {_pdf.LastErrorCode()}).";
  155. return false;
  156. }
  157. if (draw.Angle == 0)
  158. {
  159. // Unrotated path — byte-for-byte the same call this renderer made before Sprint
  160. // 4's rotation story, so every pre-existing template (angle always 0) renders
  161. // identically and pays no extra GetTextWidth/Ascent/Descent cost per draw.
  162. if (_pdf.DrawText(draw.X, draw.Y, draw.Text) == 0)
  163. {
  164. error = $"Failed to draw text with font '{draw.FontName}' at ({draw.X}, {draw.Y}) " +
  165. $"(error code {_pdf.LastErrorCode()}).";
  166. return false;
  167. }
  168. }
  169. else
  170. {
  171. // Rotate around the element's own bounding-box center, not the (X, Y) anchor
  172. // DrawRotatedText itself rotates around — see RotatedTextAnchorCalculator for the
  173. // anchor-offset math and the empirically-confirmed sign convention.
  174. var width = _pdf.GetTextWidth(draw.Text);
  175. var ascent = _pdf.GetTextAscent();
  176. var descent = _pdf.GetTextDescent();
  177. var (anchorX, anchorY) = RotatedTextAnchorCalculator.ComputeAnchor(
  178. draw.X, draw.Y, width, ascent, descent, draw.Angle);
  179. // Confirmed empirically against the real DLL: DrawRotatedText rejects a negative
  180. // Angle outright (returns 0 with LastErrorCode() also 0 — no descriptive error at
  181. // all), even though it is mathematically periodic. Normalize to Debenu's expected
  182. // [0, 360) range before the call; the anchor-offset math above already used the
  183. // original signed angle (trig functions handle negative angles natively and
  184. // correctly), so this normalization is purely for the vendor call's own input
  185. // validation, not a behavior change.
  186. var normalizedAngle = ((draw.Angle % 360) + 360) % 360;
  187. if (_pdf.DrawRotatedText(anchorX, anchorY, normalizedAngle, draw.Text) == 0)
  188. {
  189. error = $"Failed to draw rotated text with font '{draw.FontName}' at " +
  190. $"({draw.X}, {draw.Y}), angle {draw.Angle} (error code {_pdf.LastErrorCode()}).";
  191. return false;
  192. }
  193. }
  194. }
  195. _pagesInCurrentBatch++;
  196. error = null;
  197. return true;
  198. }
  199. public bool Save(string outputPath, out string? error)
  200. {
  201. if (_batchFilePaths.Count == 0)
  202. {
  203. // Fast path: no periodic flush was ever triggered (the whole render fit in one
  204. // batch), so this behaves exactly as it did before batching existed — one direct
  205. // SaveToFile call, no temp files, no merge step. This is the common case for renders
  206. // under DefaultPagesPerBatch pages and keeps this change a no-op for them.
  207. if (_pdf.SaveToFile(outputPath) == 0)
  208. {
  209. error = $"Failed to save PDF to '{outputPath}' (error code {_pdf.LastErrorCode()}).";
  210. return false;
  211. }
  212. error = null;
  213. return true;
  214. }
  215. // Batching occurred: flush the final (possibly partial) batch to its own file, then
  216. // merge every batch — in order — into the requested output path via Debenu's own
  217. // file-list merge API.
  218. var finalBatchPath = NextBatchFilePath();
  219. if (_pdf.SaveToFile(finalBatchPath) == 0)
  220. {
  221. error = $"Failed to save final batch to '{finalBatchPath}' (error code {_pdf.LastErrorCode()}).";
  222. return false;
  223. }
  224. _batchFilePaths.Add(finalBatchPath);
  225. ReleaseCurrentInstance();
  226. return MergeBatchesInto(outputPath, out error);
  227. }
  228. /// <summary>Saves the current in-progress batch to its own temp file, releases the current
  229. /// Debenu instance, and opens a fresh one for the next batch of pages — the exact
  230. /// "periodic Save + release/reopen a fresh PDFLibrary instance" mitigation confirmed in
  231. /// <c>code/BENCHMARK.md</c> to reset the per-page cost curve back to its initial rate.</summary>
  232. private bool FlushCurrentBatchAndReopen(out string? error)
  233. {
  234. var batchPath = NextBatchFilePath();
  235. if (_pdf.SaveToFile(batchPath) == 0)
  236. {
  237. error = $"Failed to save batch {_batchFilePaths.Count + 1} to '{batchPath}' " +
  238. $"(error code {_pdf.LastErrorCode()}).";
  239. return false;
  240. }
  241. _batchFilePaths.Add(batchPath);
  242. ReleaseCurrentInstance();
  243. if (!TryOpenLibrary(_dllPath, _licenseKey, out var reopened, out error))
  244. {
  245. return false;
  246. }
  247. _pdf = reopened!;
  248. _fontHandles.Clear();
  249. _firstPageUsed = false;
  250. _pagesInCurrentBatch = 0;
  251. error = null;
  252. return true;
  253. }
  254. private bool MergeBatchesInto(string outputPath, out string? error)
  255. {
  256. if (!TryOpenLibrary(_dllPath, _licenseKey, out var mergePdf, out error))
  257. {
  258. return false;
  259. }
  260. const string listName = "EnvelopeRendererBatches";
  261. try
  262. {
  263. // ClearFileList's return value reflects how many entries it removed (0 is the
  264. // expected, non-error result for a brand-new list on this freshly-opened instance,
  265. // confirmed against the real DLL — not every DPL* function follows the "0 = failure"
  266. // convention documented on the class), so its result is intentionally not treated as
  267. // a pass/fail signal here.
  268. mergePdf!.ClearFileList(listName);
  269. foreach (var batchPath in _batchFilePaths)
  270. {
  271. if (mergePdf.AddToFileList(listName, batchPath) == 0)
  272. {
  273. error = $"Failed to add batch '{batchPath}' to the merge list " +
  274. $"(error code {mergePdf.LastErrorCode()}).";
  275. return false;
  276. }
  277. }
  278. if (mergePdf.MergeFileListFast(listName, outputPath) == 0)
  279. {
  280. error = $"Failed to merge {_batchFilePaths.Count} render batches into " +
  281. $"'{outputPath}' (error code {mergePdf.LastErrorCode()}).";
  282. return false;
  283. }
  284. error = null;
  285. return true;
  286. }
  287. finally
  288. {
  289. if (mergePdf!.LibraryLoaded())
  290. {
  291. mergePdf.ReleaseLibrary();
  292. }
  293. CleanupBatchFiles();
  294. }
  295. }
  296. private string NextBatchFilePath()
  297. {
  298. Directory.CreateDirectory(_batchTempDirectory);
  299. return Path.Combine(_batchTempDirectory, $"batch-{_batchFilePaths.Count:D6}.pdf");
  300. }
  301. private void ReleaseCurrentInstance()
  302. {
  303. if (_pdf.LibraryLoaded())
  304. {
  305. _pdf.ReleaseLibrary();
  306. }
  307. }
  308. private void CleanupBatchFiles()
  309. {
  310. foreach (var path in _batchFilePaths)
  311. {
  312. try
  313. {
  314. if (File.Exists(path))
  315. {
  316. File.Delete(path);
  317. }
  318. }
  319. catch (IOException)
  320. {
  321. // Best-effort cleanup only; a leftover temp file under %TEMP% is not worth
  322. // failing an otherwise-successful render over.
  323. }
  324. }
  325. try
  326. {
  327. if (Directory.Exists(_batchTempDirectory))
  328. {
  329. Directory.Delete(_batchTempDirectory, recursive: true);
  330. }
  331. }
  332. catch (IOException)
  333. {
  334. // Best-effort cleanup only, as above.
  335. }
  336. }
  337. /// <summary>
  338. /// Fonts are added by TrueType family name (e.g. "Arial") rather than Debenu's numeric
  339. /// AddStandardFont IDs, because those IDs aren't documented anywhere in this repo and
  340. /// guessing them risks silently rendering the wrong font — a real defect for a print job.
  341. /// A missing/unresolvable font is a blocking failure per the Definition of Done's
  342. /// "missing-font behavior remains blocking" rule.
  343. /// </summary>
  344. private bool TryGetFontHandle(string fontName, out int handle, out string? error)
  345. {
  346. if (_fontHandles.TryGetValue(fontName, out handle))
  347. {
  348. error = null;
  349. return true;
  350. }
  351. handle = _pdf.AddTrueTypeFont(fontName, Embed: 1);
  352. if (handle == 0)
  353. {
  354. error = $"Font not found or could not be embedded: '{fontName}'.";
  355. return false;
  356. }
  357. _fontHandles[fontName] = handle;
  358. error = null;
  359. return true;
  360. }
  361. public void Dispose()
  362. {
  363. if (_disposed)
  364. {
  365. return;
  366. }
  367. if (_pdf.LibraryLoaded())
  368. {
  369. _pdf.ReleaseLibrary();
  370. }
  371. // Covers the failure path: if a mid-render AddPage/Save call failed after one or more
  372. // batches were already flushed to disk, Save's own cleanup never ran. Safe to call again
  373. // even when Save already cleaned up successfully (CleanupBatchFiles is idempotent).
  374. CleanupBatchFiles();
  375. _disposed = true;
  376. }
  377. }

Powered by TurnKey Linux.