From 7e13161f27bad354762ba5f728a584f7e2306900 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Tue, 22 Sep 2026 18:33:47 +0100 Subject: [PATCH] Fix lualatex stdin PDF gen + unit test for signed estimate PDF MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GenerateEstimatePdf passed /dev/stdin as the input filename so lualatex treats stdin as a regular input file instead of the terminal — without it, lualatex emergency-stops after the first line of a multi-line document (the real root cause of the production "Emergency stop", not bera/luaotfload which were separate host-package gaps already fixed). EstimatePdf endpoint calls GenerateEstimatePdf directly and streams the returned FileInfo, guarding on fo.Exists. QueryAcceptanceRequest is extracted into its own file under ViewModels/FrontOffice. Add EstimatePdfGenerationTests: unit tests for SignatureToTikz (vector draws of both signatures) and GenerateEstimatePdf on a devis signed by both parties — asserts the PDF exists at the expected DestDir path, is non-empty, and that only the PDF persists (aux/log cleaned). Skips cleanly when lualatex is absent. Co-Authored-By: Claude Code --- .../EstimatePdfGenerationTests.cs | 191 ++++++++++++++++++ .../Business/FrontOfficeApiController.cs | 41 +--- src/Yavsc.Api/Helpers/TeXHelpers.cs | 11 +- .../FrontOffice/QueryAcceptanceRequest.cs | 40 ++++ 4 files changed, 242 insertions(+), 41 deletions(-) create mode 100644 src/Yavsc.Api.Test/EstimatePdfGenerationTests.cs create mode 100644 src/Yavsc.Api/ViewModels/FrontOffice/QueryAcceptanceRequest.cs diff --git a/src/Yavsc.Api.Test/EstimatePdfGenerationTests.cs b/src/Yavsc.Api.Test/EstimatePdfGenerationTests.cs new file mode 100644 index 000000000..e0f156048 --- /dev/null +++ b/src/Yavsc.Api.Test/EstimatePdfGenerationTests.cs @@ -0,0 +1,191 @@ +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Reflection; +using Yavsc.Helpers; +using Yavsc.Models.Billing; +using Yavsc.ViewModels.Gen; + +namespace Yavsc.Api.Test; + +// Unit test of the estimate PDF generation pipeline (no HTTP fixture +// needed): it exercises TeXHelpers.SignatureToTikz — the vector drawing +// of the provider and client signatures — and TeXHelpers.GenerateEstimatePdf +// — the lualatex stdin compile that writes .pdf into DestDir. The +// TeX source mirrors what the Estimate_tex template emits for a devis +// signed by both parties: two side-by-side minipages, each with a +// tikzpicture bounded by \useasboundingbox and the strokes drawn by +// SignatureToTikz. +// +// The PDF test requires the system `lualatex` (and the TeX Live packages +// listed in the README runtime-deps section) on the host running the +// tests. On hosts without lualatex it skips cleanly rather than failing. +public sealed class EstimatePdfGenerationTests +{ + // A provider signature: a downstroke, a cross, and a tap (dot). + // PostIt wire format — [k, x0,y0, x1,y1, …] per stroke, coords in + // [0, 10000], y-down. + private static readonly int[] ProStrokes = + { + 4, 2000, 8000, 2000, 5000, 2000, 2000, 2100, 1900, + 3, 2000, 5000, 3500, 4500, 3500, 5100, + 1, 4000, 3000, + }; + + // A client signature: a wavy stroke and a shorter one. + private static readonly int[] CliStrokes = + { + 5, 1000, 7000, 1500, 4000, 2200, 3000, 3000, 2500, 4000, 1500, + 2, 3000, 6000, 4200, 5500, + }; + + [Fact] + public void SignatureToTikz_RendersBothSignaturesAsVectorDraws() + { + var pro = MakeSig(SignatureType.Pro, ProStrokes); + var cli = MakeSig(SignatureType.Client, CliStrokes); + + var proTex = pro.SignatureToTikz(5, 2.5).ToString(); + var cliTex = cli.SignatureToTikz(5, 2.5).ToString(); + + Assert.Contains("\\draw[line cap=round, line join=round, line width=1.5pt]", proTex); + Assert.Contains("\\fill", proTex); // the single-point tap → dot + Assert.Contains("(1.000,0.500)", proTex); // first point (2000,8000), y flipped + Assert.Contains("\\draw[line cap=round, line join=round, line width=1.5pt]", cliTex); + Assert.Contains("--", cliTex); + } + + [Fact] + public void SignatureToTikz_EmptySignatureIsEmpty() + { + var empty = MakeSig(SignatureType.Pro, Array.Empty()); + Assert.Equal("", empty.SignatureToTikz(5, 2.5).ToString()); + } + + [Fact] + public void GenerateEstimatePdf_WithBothSignatures_ProducesPdfInDestDir() + { + if (!IsLualatexAvailable()) + throw Xunit.Sdk.SkipException.ForSkip( + "lualatex is not installed on this host; skipping PDF generation test. " + + "Install the TeX Live runtime deps documented in the README."); + + var destDir = Path.Combine(Path.GetTempPath(), + "yavsc-estimate-pdf-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(destDir); + + try + { + var pro = MakeSig(SignatureType.Pro, ProStrokes); + var cli = MakeSig(SignatureType.Client, CliStrokes); + var baseName = "estimate-test-both-signed"; + + var model = new PdfGenerationViewModel + { + TeXSource = BuildEstimateTeX(pro, cli), + BaseFileName = baseName, + DestDir = destDir, + }; + + var fo = model.GenerateEstimatePdf(); + + // The PDF must exist, at the expected path, and be non-empty. + var expectedPath = Path.Combine(destDir, baseName + ".pdf"); + Assert.True(fo is not null && fo.Exists, + "GenerateEstimatePdf returned a non-existent file. Error: " + + model.GenerationErrorMessage?.Value); + Assert.Equal(expectedPath, fo.FullName); + Assert.True(fo.Length > 0, "generated PDF is empty"); + + // Only the PDF should persist in DestDir — aux/log artifacts + // are cleaned up by GenerateEstimatePdf on success. + var leftovers = Directory.GetFiles(destDir) + .Where(p => !Path.GetFileName(p).Equals(baseName + ".pdf", StringComparison.Ordinal)) + .ToArray(); + Assert.Empty(leftovers); + } + finally + { + if (Directory.Exists(destDir)) + Directory.Delete(destDir, recursive: true); + } + } + + private static Signature MakeSig(SignatureType type, int[] strokes) + => new() + { + Type = type, + CoordinateMax = 10_000, + Strokes = strokes, + CapturedAtUtc = new DateTime(2026, 9, 22, 0, 0, 0, DateTimeKind.Utc), + }; + + // Builds the LaTeX source the Estimate_tex template would emit for a + // devis signed by both parties: same preamble (incl. tikz + bera) and + // the two-minipage signature foot, with the strokes drawn by + // SignatureToTikz. + private static string BuildEstimateTeX(Signature pro, Signature cli) + { + var fr = CultureInfo.CreateSpecificCulture("fr-FR"); + var date = new DateTime(2026, 9, 22).ToString("dddd dd MMMM yyyy", fr); + var proTikz = pro.SignatureToTikz(5, 2.5).ToString(); + var cliTikz = cli.SignatureToTikz(5, 2.5).ToString(); + + return $@"\documentclass[french,11pt]{{article}} +\usepackage{{eurosym}} +\usepackage{{babel}} +\usepackage[T1]{{fontenc}} +\usepackage[utf8]{{inputenc}} +\usepackage[a4paper]{{geometry}} +\usepackage{{units}} +\usepackage{{bera}} +\usepackage{{graphicx}} +\usepackage{{fancyhdr}} +\usepackage{{fp}} +\usepackage{{tikz}} +\geometry{{verbose,tmargin=4em,bmargin=8em,lmargin=6em,rmargin=6em}} +\setlength{{\parindent}}{{0pt}} +\begin{{document}} +Devis n°1 --- test signe par les deux parties + +\vspace{{2em}} +\noindent +\begin{{minipage}}[t]{{0.45\textwidth}} + \textbf{{Signature du fournisseur}}\\ +\small Valide le {date}\\ +\begin{{tikzpicture}} + \useasboundingbox (0,0) rectangle (5,2.5); + {proTikz} +\end{{tikzpicture}} +\end{{minipage}}\hfill +\begin{{minipage}}[t]{{0.45\textwidth}} + \textbf{{Signature du client}}\\ +\small Valide le {date}\\ +\begin{{tikzpicture}} + \useasboundingbox (0,0) rectangle (5,2.5); + {cliTikz} +\end{{tikzpicture}} +\end{{minipage}} +\end{{document}} +"; + } + + private static bool IsLualatexAvailable() + { + try + { + using var p = Process.Start(new ProcessStartInfo("lualatex", "--version") + { + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + }); + if (p is null) return false; + return p.WaitForExit(5000) && p.ExitCode == 0; + } + catch + { + return false; + } + } +} \ No newline at end of file diff --git a/src/Yavsc.Api/Controllers/Business/FrontOfficeApiController.cs b/src/Yavsc.Api/Controllers/Business/FrontOfficeApiController.cs index f9b694e21..eb7995f5e 100644 --- a/src/Yavsc.Api/Controllers/Business/FrontOfficeApiController.cs +++ b/src/Yavsc.Api/Controllers/Business/FrontOfficeApiController.cs @@ -140,7 +140,7 @@ namespace Yavsc.ApiControllers // we stream the generated PDF bytes — but driving the // generation through the view keeps the template as the // output producer, per the original design. - if (model.GenerateEstimatePdf() is FileInfo fo) + if (model.GenerateEstimatePdf() is FileInfo fo && fo.Exists) { return File(fo.OpenRead(), "application/pdf"); } @@ -370,42 +370,3 @@ namespace Yavsc.ApiControllers internal enum ActorRole { None, Provider, Client, Admin } } - -namespace Yavsc.ViewModels.FrontOffice -{ - /// - /// Body of POST /api/v1/front/query/accept and - /// .../reject. The field is optional: - /// when present (non-empty) on accept, it is the PostIt wire-format - /// signature captured with the acceptance. All fields are optional - /// so a caller may post {} to accept/reject without a - /// signature. - /// - public class QueryAcceptanceRequest - { - /// - /// Wire-format strokes. See - /// PostIt.Models.SignaturePadData. Null or empty means - /// no signature. - /// - public int[]? Strokes { get; set; } - - public int CoordinateMax { get; set; } = 10_000; - - public DateTime? CapturedAtUtc { get; set; } - - /// - /// Which signature is being submitted: - /// (the provider) or - /// . When - /// is present this declares the side the caller is signing as; - /// the server honors it only if the caller is that party - /// (query.PerformerId for Pro, query.ClientId - /// for Client). This disambiguates the case where one user is - /// both parties, and makes "write only for its author" - /// explicit. When null, the server falls back to the role - /// inferred from the caller's identity. - /// - public SignatureType? SignatureType { get; set; } - } -} diff --git a/src/Yavsc.Api/Helpers/TeXHelpers.cs b/src/Yavsc.Api/Helpers/TeXHelpers.cs index 62439e6ea..c35806bda 100644 --- a/src/Yavsc.Api/Helpers/TeXHelpers.cs +++ b/src/Yavsc.Api/Helpers/TeXHelpers.cs @@ -202,6 +202,14 @@ namespace Yavsc.Helpers // the PDF on stdout — so the pdf lands at its final destination // and we only clean the transient aux/log artifacts beside it. // + // We pass "/dev/stdin" as the input filename so lualatex treats + // stdin as a regular input *file* rather than the terminal: with + // no filename argument it reads stdin in terminal mode, which + // emergency-stops after the first line of a multi-line document. + // /dev/stdin makes the full multi-line TeX compile cleanly. (The + // API host is Linux — the systemd service and lualatex dep are + // Linux-only too.) + // // We capture stdout/stderr so a LaTeX error (missing .sty, // missing \includegraphics target, …) is surfaced in // GenerationErrorMessage instead of being buried in the .log @@ -218,7 +226,8 @@ namespace Yavsc.Helpers WorkingDirectory = billdir, FileName = "lualatex", Arguments = $"-interaction=nonstopmode -halt-on-error" - + $" -jobname=\"{name}\" -output-directory=\"{billdir}\"", + + $" -jobname=\"{name}\" -output-directory=\"{billdir}\"" + + " /dev/stdin", RedirectStandardInput = true, RedirectStandardOutput = true, RedirectStandardError = true, diff --git a/src/Yavsc.Api/ViewModels/FrontOffice/QueryAcceptanceRequest.cs b/src/Yavsc.Api/ViewModels/FrontOffice/QueryAcceptanceRequest.cs new file mode 100644 index 000000000..41e4dd650 --- /dev/null +++ b/src/Yavsc.Api/ViewModels/FrontOffice/QueryAcceptanceRequest.cs @@ -0,0 +1,40 @@ +using Yavsc.Models.Billing; + +namespace Yavsc.ViewModels.FrontOffice +{ + /// + /// Body of POST /api/v1/front/query/accept and + /// .../reject. The field is optional: + /// when present (non-empty) on accept, it is the PostIt wire-format + /// signature captured with the acceptance. All fields are optional + /// so a caller may post {} to accept/reject without a + /// signature. + /// + public class QueryAcceptanceRequest + { + /// + /// Wire-format strokes. See + /// PostIt.Models.SignaturePadData. Null or empty means + /// no signature. + /// + public int[]? Strokes { get; set; } + + public int CoordinateMax { get; set; } = 10_000; + + public DateTime? CapturedAtUtc { get; set; } + + /// + /// Which signature is being submitted: + /// (the provider) or + /// . When + /// is present this declares the side the caller is signing as; + /// the server honors it only if the caller is that party + /// (query.PerformerId for Pro, query.ClientId + /// for Client). This disambiguates the case where one user is + /// both parties, and makes "write only for its author" + /// explicit. When null, the server falls back to the role + /// inferred from the caller's identity. + /// + public SignatureType? SignatureType { get; set; } + } +}