[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:
@ -0,0 +1,9 @@
|
||||
namespace SolutionErp.Application.Common.Interfaces;
|
||||
|
||||
// Convert .docx/.xlsx bytes to PDF. The Infrastructure impl shells out to
|
||||
// LibreOffice headless (soffice.exe --headless --convert-to pdf). Future swap:
|
||||
// QuestPDF re-render if we want zero external dep, or Aspose.Words for quality.
|
||||
public interface IPdfConverter
|
||||
{
|
||||
Task<byte[]> ConvertAsync(byte[] sourceBytes, string sourceExt, CancellationToken ct = default);
|
||||
}
|
||||
@ -95,6 +95,37 @@ public interface IWebHostEnvironmentLocator
|
||||
string ContentRootPath { get; }
|
||||
}
|
||||
|
||||
// ========== EXPORT rendered template as PDF ==========
|
||||
// Renders the same docx/xlsx as RenderTemplateCommand, then pipes through
|
||||
// IPdfConverter (LibreOffice headless) to get a PDF. Accepts the SAME data
|
||||
// dictionary so callers can preview PDF without duplicating logic.
|
||||
|
||||
public record ExportTemplatePdfCommand(Guid TemplateId, Dictionary<string, string?> Data)
|
||||
: IRequest<RenderResult>;
|
||||
|
||||
public class ExportTemplatePdfCommandHandler(
|
||||
IApplicationDbContext db,
|
||||
IFormRenderer renderer,
|
||||
IPdfConverter pdfConverter,
|
||||
IWebHostEnvironmentLocator envLocator) : IRequestHandler<ExportTemplatePdfCommand, RenderResult>
|
||||
{
|
||||
public async Task<RenderResult> Handle(ExportTemplatePdfCommand request, CancellationToken ct)
|
||||
{
|
||||
var tpl = await db.ContractTemplates.AsNoTracking()
|
||||
.FirstOrDefaultAsync(t => t.Id == request.TemplateId, ct)
|
||||
?? throw new NotFoundException("ContractTemplate", request.TemplateId);
|
||||
|
||||
var absPath = Path.Combine(envLocator.WebRootPath, tpl.StoragePath);
|
||||
if (!File.Exists(absPath))
|
||||
throw new NotFoundException($"File template không tồn tại: {tpl.StoragePath}");
|
||||
|
||||
var rendered = await renderer.RenderAsync(absPath, tpl.Format, request.Data, "source", ct);
|
||||
var pdfBytes = await pdfConverter.ConvertAsync(rendered.Content, tpl.Format, ct);
|
||||
var outName = $"{tpl.FormCode}.pdf";
|
||||
return new RenderResult(pdfBytes, outName, "application/pdf");
|
||||
}
|
||||
}
|
||||
|
||||
// ========== UPLOAD new template ==========
|
||||
|
||||
public record UploadContractTemplateCommand(
|
||||
|
||||
Reference in New Issue
Block a user