[CLAUDE] App+Infra+Api+FE-Admin: PDF export (LibreOffice headless)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 2m33s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 2m33s
Pipeline: template.docx → FormRenderer fill placeholders → LibreOffice
soffice --headless --convert-to pdf → PDF byte[] → File() stream to
browser.
Clean-arch split:
- Application: IPdfConverter abstraction (swap to QuestPDF/Aspose later
without touching caller).
- Infrastructure: LibreOfficePdfConverter — shells out to soffice.exe
path from config (Pdf:SofficePath, default
`C:\Program Files\LibreOffice\program\soffice.exe` on Windows).
Per-request temp workDir để tránh filename collision + -env:
UserInstallation isolate mỗi conversion (chống "soffice already
running" khi concurrent). Timeout 60s (configurable). Best-effort
cleanup. Kill entire process tree nếu timeout.
- Application: ExportTemplatePdfCommand — reuses existing FormRenderer
+ pipes bytes through IPdfConverter. Same data dict signature as
Render để UI code share.
- Api: POST /api/forms/templates/{id}/export-pdf (same JSON body as
/render, returns PDF stream).
FE:
- useExport hook chung cho 2 endpoints (DRY render + export-pdf mutations)
- Render dialog thêm nút "Tải PDF" (outline variant) cạnh "Tải file gốc".
Disabled khi mutation khác đang chạy.
- Hướng dẫn dialog nâng cấp: "file gốc để edit Word/Excel, PDF để
in/gửi không chỉnh sửa được".
Ops: scripts/install-libreoffice.ps1 — silent MSI install 25.8.6 cho
VPS (đã chạy trên prod).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@ -29,6 +29,7 @@ public static class DependencyInjection
|
||||
services.AddScoped<IJwtTokenService, JwtTokenService>();
|
||||
|
||||
services.AddSingleton<IFormRenderer, FormRenderer>();
|
||||
services.AddSingleton<IPdfConverter, LibreOfficePdfConverter>();
|
||||
services.AddScoped<IContractCodeGenerator, ContractCodeGenerator>();
|
||||
services.AddScoped<IContractWorkflowService, ContractWorkflowService>();
|
||||
services.AddScoped<IContractExcelExporter, ContractExcelExporter>();
|
||||
|
||||
@ -0,0 +1,91 @@
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SolutionErp.Application.Common.Interfaces;
|
||||
|
||||
namespace SolutionErp.Infrastructure.Forms;
|
||||
|
||||
// Shells out to LibreOffice headless to convert .docx/.xlsx → PDF.
|
||||
// Requires soffice.exe installed on the host. Config Pdf:SofficePath overrides
|
||||
// the default "C:\Program Files\LibreOffice\program\soffice.exe" / "soffice"
|
||||
// so dev on macOS/Linux can point to a different binary.
|
||||
public class LibreOfficePdfConverter(IConfiguration config, ILogger<LibreOfficePdfConverter> logger) : IPdfConverter
|
||||
{
|
||||
private readonly string _sofficePath = config["Pdf:SofficePath"]
|
||||
?? (OperatingSystem.IsWindows()
|
||||
? @"C:\Program Files\LibreOffice\program\soffice.exe"
|
||||
: "soffice");
|
||||
|
||||
private readonly TimeSpan _timeout = TimeSpan.FromSeconds(
|
||||
config.GetValue<int?>("Pdf:TimeoutSeconds") ?? 60);
|
||||
|
||||
public async Task<byte[]> ConvertAsync(byte[] sourceBytes, string sourceExt, CancellationToken ct = default)
|
||||
{
|
||||
var ext = sourceExt.TrimStart('.').ToLowerInvariant();
|
||||
if (ext != "docx" && ext != "xlsx" && ext != "doc" && ext != "xls")
|
||||
throw new InvalidOperationException($"Unsupported source extension: {ext}");
|
||||
|
||||
// Per-conversion temp directory — avoids filename collisions when multiple
|
||||
// requests run concurrently and makes cleanup atomic.
|
||||
var workDir = Path.Combine(Path.GetTempPath(), "solutionerp-pdf", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(workDir);
|
||||
var inputName = $"input.{ext}";
|
||||
var inputPath = Path.Combine(workDir, inputName);
|
||||
var outputPath = Path.Combine(workDir, "input.pdf");
|
||||
|
||||
try
|
||||
{
|
||||
await File.WriteAllBytesAsync(inputPath, sourceBytes, ct);
|
||||
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = _sofficePath,
|
||||
// --headless: no GUI
|
||||
// --norestore: don't try to resurrect prior session
|
||||
// -env:UserInstallation: isolate user profile per worker (avoids
|
||||
// "soffice already running" conflict when multiple requests hit)
|
||||
Arguments = $"--headless --norestore --convert-to pdf " +
|
||||
$"-env:UserInstallation=file:///{workDir.Replace('\\', '/')}/profile " +
|
||||
$"--outdir \"{workDir}\" \"{inputPath}\"",
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true,
|
||||
WorkingDirectory = workDir,
|
||||
};
|
||||
|
||||
using var proc = new Process { StartInfo = psi };
|
||||
proc.Start();
|
||||
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(_timeout);
|
||||
|
||||
try
|
||||
{
|
||||
await proc.WaitForExitAsync(cts.Token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
try { proc.Kill(entireProcessTree: true); } catch { /* best effort */ }
|
||||
throw new TimeoutException($"LibreOffice conversion exceeded {_timeout.TotalSeconds}s.");
|
||||
}
|
||||
|
||||
if (proc.ExitCode != 0)
|
||||
{
|
||||
var stderr = await proc.StandardError.ReadToEndAsync(ct);
|
||||
var stdout = await proc.StandardOutput.ReadToEndAsync(ct);
|
||||
logger.LogError("soffice exit {Code}. stderr={Stderr} stdout={Stdout}", proc.ExitCode, stderr, stdout);
|
||||
throw new InvalidOperationException($"LibreOffice conversion failed (exit {proc.ExitCode}).");
|
||||
}
|
||||
|
||||
if (!File.Exists(outputPath))
|
||||
throw new InvalidOperationException("LibreOffice did not produce a PDF output.");
|
||||
|
||||
return await File.ReadAllBytesAsync(outputPath, ct);
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { Directory.Delete(workDir, recursive: true); } catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user