[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:
@ -56,12 +56,14 @@ export function FormsPage() {
|
|||||||
queryFn: async () => (await api.get<ContractTemplate[]>('/forms/templates', { params: { onlyActive: false } })).data,
|
queryFn: async () => (await api.get<ContractTemplate[]>('/forms/templates', { params: { onlyActive: false } })).data,
|
||||||
})
|
})
|
||||||
|
|
||||||
const render = useMutation({
|
function useExport(endpoint: 'render' | 'export-pdf', successMsg: string) {
|
||||||
|
return useMutation({
|
||||||
mutationFn: async ({ id, data }: { id: string; data: Record<string, string | null> }) => {
|
mutationFn: async ({ id, data }: { id: string; data: Record<string, string | null> }) => {
|
||||||
const res = await api.post(`/forms/templates/${id}/render`, data, { responseType: 'blob' })
|
const res = await api.post(`/forms/templates/${id}/${endpoint}`, data, { responseType: 'blob' })
|
||||||
|
const fallback = endpoint === 'export-pdf' ? 'contract.pdf' : 'render.docx'
|
||||||
return {
|
return {
|
||||||
blob: res.data as Blob,
|
blob: res.data as Blob,
|
||||||
filename: res.headers['content-disposition']?.match(/filename="?([^";]+)"?/)?.[1] ?? 'render.docx',
|
filename: res.headers['content-disposition']?.match(/filename="?([^";]+)"?/)?.[1] ?? fallback,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSuccess: ({ blob, filename }) => {
|
onSuccess: ({ blob, filename }) => {
|
||||||
@ -71,11 +73,15 @@ export function FormsPage() {
|
|||||||
a.download = filename
|
a.download = filename
|
||||||
a.click()
|
a.click()
|
||||||
URL.revokeObjectURL(url)
|
URL.revokeObjectURL(url)
|
||||||
toast.success('Đã tải file render')
|
toast.success(successMsg)
|
||||||
setRenderDialog(null)
|
setRenderDialog(null)
|
||||||
},
|
},
|
||||||
onError: err => toast.error(getErrorMessage(err)),
|
onError: err => toast.error(getErrorMessage(err)),
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const render = useExport('render', 'Đã tải file gốc')
|
||||||
|
const exportPdf = useExport('export-pdf', 'Đã tải PDF')
|
||||||
|
|
||||||
const upload = useMutation({
|
const upload = useMutation({
|
||||||
mutationFn: async (params: { file: File; meta: EditState }) => {
|
mutationFn: async (params: { file: File; meta: EditState }) => {
|
||||||
@ -123,18 +129,28 @@ export function FormsPage() {
|
|||||||
onError: err => toast.error(getErrorMessage(err)),
|
onError: err => toast.error(getErrorMessage(err)),
|
||||||
})
|
})
|
||||||
|
|
||||||
function handleRender() {
|
function parseJsonOrToast(): Record<string, string | null> | null {
|
||||||
if (!renderDialog) return
|
if (!renderDialog) return null
|
||||||
let data: Record<string, string | null>
|
|
||||||
try {
|
try {
|
||||||
data = JSON.parse(dataJson)
|
return JSON.parse(dataJson)
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('JSON không hợp lệ')
|
toast.error('JSON không hợp lệ')
|
||||||
return
|
return null
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRender() {
|
||||||
|
const data = parseJsonOrToast()
|
||||||
|
if (!data || !renderDialog) return
|
||||||
render.mutate({ id: renderDialog.id, data })
|
render.mutate({ id: renderDialog.id, data })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleExportPdf() {
|
||||||
|
const data = parseJsonOrToast()
|
||||||
|
if (!data || !renderDialog) return
|
||||||
|
exportPdf.mutate({ id: renderDialog.id, data })
|
||||||
|
}
|
||||||
|
|
||||||
function handleSaveEdit(e: FormEvent) {
|
function handleSaveEdit(e: FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!edit) return
|
if (!edit) return
|
||||||
@ -256,8 +272,15 @@ export function FormsPage() {
|
|||||||
<Button variant="outline" onClick={() => setRenderDialog(null)}>
|
<Button variant="outline" onClick={() => setRenderDialog(null)}>
|
||||||
Hủy
|
Hủy
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleRender} disabled={render.isPending}>
|
<Button
|
||||||
{render.isPending ? 'Đang render…' : 'Render & tải xuống'}
|
variant="outline"
|
||||||
|
onClick={handleExportPdf}
|
||||||
|
disabled={exportPdf.isPending || render.isPending}
|
||||||
|
>
|
||||||
|
{exportPdf.isPending ? 'Đang export PDF…' : 'Tải PDF'}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleRender} disabled={render.isPending || exportPdf.isPending}>
|
||||||
|
{render.isPending ? 'Đang render…' : `Tải file gốc (.${renderDialog?.format ?? 'docx'})`}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
@ -265,8 +288,8 @@ export function FormsPage() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="rounded-md bg-amber-50 px-3 py-2 text-xs text-amber-800">
|
<div className="rounded-md bg-amber-50 px-3 py-2 text-xs text-amber-800">
|
||||||
<strong>Hướng dẫn:</strong> Template chứa placeholder dạng{' '}
|
<strong>Hướng dẫn:</strong> Template chứa placeholder dạng{' '}
|
||||||
<code className="rounded bg-white px-1">{'{{fieldName}}'}</code>. Điền key-value JSON dưới đây, backend sẽ
|
<code className="rounded bg-white px-1">{'{{fieldName}}'}</code>. Điền JSON bên dưới. <strong>Tải file gốc</strong>{' '}
|
||||||
replace placeholder trong file gốc.
|
để chỉnh sửa trong Word/Excel, <strong>Tải PDF</strong> để in/gửi không chỉnh sửa được.
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label>Form Code</Label>
|
<Label>Form Code</Label>
|
||||||
|
|||||||
16
scripts/install-libreoffice.ps1
Normal file
16
scripts/install-libreoffice.ps1
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$url = 'https://download.documentfoundation.org/libreoffice/stable/25.8.6/win/x86_64/LibreOffice_25.8.6_Win_x86-64.msi'
|
||||||
|
$msi = 'C:\Windows\Temp\lo.msi'
|
||||||
|
Write-Host "Downloading LibreOffice 25.8.6..."
|
||||||
|
Invoke-WebRequest -Uri $url -OutFile $msi -UseBasicParsing
|
||||||
|
Write-Host ("Downloaded {0:N1} MB" -f ((Get-Item $msi).Length / 1MB))
|
||||||
|
Write-Host "Installing silently (4-6 min)..."
|
||||||
|
$p = Start-Process msiexec.exe -ArgumentList '/i', $msi, '/quiet', '/norestart', 'ADDLOCAL=ALL', 'CREATEDESKTOPLINK=0' -Wait -PassThru
|
||||||
|
Write-Host "Exit code: $($p.ExitCode)"
|
||||||
|
Remove-Item $msi -ErrorAction SilentlyContinue
|
||||||
|
if (Test-Path 'C:\Program Files\LibreOffice\program\soffice.exe') {
|
||||||
|
Write-Host "OK: soffice.exe installed"
|
||||||
|
& 'C:\Program Files\LibreOffice\program\soffice.exe' --version
|
||||||
|
} else {
|
||||||
|
Write-Error "soffice.exe NOT found after install"
|
||||||
|
}
|
||||||
@ -29,6 +29,13 @@ public class FormsController(IMediator mediator) : ControllerBase
|
|||||||
return File(result.Content, result.ContentType, result.FileName);
|
return File(result.Content, result.ContentType, result.FileName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("templates/{id:guid}/export-pdf")]
|
||||||
|
public async Task<IActionResult> ExportPdf(Guid id, [FromBody] Dictionary<string, string?> data, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await mediator.Send(new ExportTemplatePdfCommand(id, data), ct);
|
||||||
|
return File(result.Content, result.ContentType, result.FileName);
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== Admin CRUD ====================
|
// ==================== Admin CRUD ====================
|
||||||
|
|
||||||
// Upload new template — multipart form with file + metadata fields
|
// Upload new template — multipart form with file + metadata fields
|
||||||
|
|||||||
@ -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; }
|
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 ==========
|
// ========== UPLOAD new template ==========
|
||||||
|
|
||||||
public record UploadContractTemplateCommand(
|
public record UploadContractTemplateCommand(
|
||||||
|
|||||||
@ -29,6 +29,7 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<IJwtTokenService, JwtTokenService>();
|
services.AddScoped<IJwtTokenService, JwtTokenService>();
|
||||||
|
|
||||||
services.AddSingleton<IFormRenderer, FormRenderer>();
|
services.AddSingleton<IFormRenderer, FormRenderer>();
|
||||||
|
services.AddSingleton<IPdfConverter, LibreOfficePdfConverter>();
|
||||||
services.AddScoped<IContractCodeGenerator, ContractCodeGenerator>();
|
services.AddScoped<IContractCodeGenerator, ContractCodeGenerator>();
|
||||||
services.AddScoped<IContractWorkflowService, ContractWorkflowService>();
|
services.AddScoped<IContractWorkflowService, ContractWorkflowService>();
|
||||||
services.AddScoped<IContractExcelExporter, ContractExcelExporter>();
|
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