From 82d1d6f6d6b705b2042a585a8ce800dac027e8f9 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Tue, 22 Sep 2026 16:45:05 +0100 Subject: [PATCH] Draw estimate signatures as vector TikZ instead of PNG The estimate foot now renders the provider and client signatures as TikZ \draw/\fill paths straight from the captured stroke data, in two side-by-side minipages with a \useasboundingbox so the boxes align regardless of stroke coverage. A side that hasn't signed shows a signature rule. - TeXHelpers.SignatureToTikz walks the PostIt wire format ([k, x0,y0, ...] per stroke, coords in [0, CoordinateMax], y-down) and emits one \draw per stroke (round caps/joins, 1.5pt), flipping y so the signature is upright on paper; a single-point tap becomes a filled dot, mirroring the Avalonia round-capped Polyline. - FrontOfficeApiController.LoadEstimateForRenderAsync includes Estimate.Signatures so SignaturePro/SignatureClient resolve. - Estimate_tex.cshtml drops the \includegraphics of a PNG (which failed the build when the file was absent) and adds \usepackage{tikz}. - README: document texlive-pictures (tikz) and texlive-fonts-extra (bera) as runtime deps; bera.sty missing is the likely cause of the remaining host Emergency stop. Co-Authored-By: Claude Code --- README.md | 7 +- .../Business/FrontOfficeApiController.cs | 4 +- src/Yavsc.Api/Helpers/TeXHelpers.cs | 66 +++++++++++++++++++ .../Views/Shared/Estimate_tex.cshtml | 55 ++++++++++++---- 4 files changed, 116 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 8392a4be6..fefbdd48d 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ systemd `yavscApi`. Sous Debian/Ubuntu : ```bash -sudo apt install texlive-binaries texlive-luatex +sudo apt install texlive-binaries texlive-luatex texlive-pictures texlive-fonts-extra ``` - `texlive-binaries` fournit l'exécutable `lualatex` (collection basic). @@ -82,6 +82,11 @@ sudo apt install texlive-binaries texlive-luatex `module 'luaotfload-main' not found … Emergency stop` et ne produit aucun PDF. (Sur les Debian récents, `luaotfload` a migré de `texlive-latex-extra` vers `texlive-luatex` — avoir `texlive-latex-extra` seul ne suffit plus.) +- `texlive-pictures` fournit `tikz`/`pgf`, utilisé pour dessiner les signatures + du client et du fournisseur en bas du devis (vecteur, pas de PNG). +- `texlive-fonts-extra` fournit la fonte `bera` chargée par le modèle de devis. + Sans elle, lualatex s'arrête sur `! LaTeX Error: File 'bera.sty' not found` + (`Emergency stop`, aucun PDF). Sur un hôte sans `lualatex`, les endpoints PDF renvoient une erreur 500 « PDF generation failed » ; l'endpoint TeX (`estimate.tex`) reste utilisable et permet diff --git a/src/Yavsc.Api/Controllers/Business/FrontOfficeApiController.cs b/src/Yavsc.Api/Controllers/Business/FrontOfficeApiController.cs index 71ceca3d5..f9b694e21 100644 --- a/src/Yavsc.Api/Controllers/Business/FrontOfficeApiController.cs +++ b/src/Yavsc.Api/Controllers/Business/FrontOfficeApiController.cs @@ -157,12 +157,14 @@ namespace Yavsc.ApiControllers } // Loads the estimate linked to a query with the navigation the - // Estimate_tex template reads (client + performer profile + bill). + // Estimate_tex template reads (client + performer profile + bill + // + the captured signatures, drawn as tikz at the foot of the devis). private Task LoadEstimateForRenderAsync(long queryId, CancellationToken token) => dbContext.Estimates .Where(e => e.CommandId == queryId) .OrderByDescending(e => e.Id) .Include(e => e.Bill) + .Include(e => e.Signatures) .Include(e => e.Query).ThenInclude(q => q!.Client).ThenInclude(c => c!.PostalAddress) .Include(e => e.Query).ThenInclude(q => q!.PerformerProfile).ThenInclude(p => p!.OrganizationAddress) .Include(e => e.Query).ThenInclude(q => q!.PerformerProfile).ThenInclude(p => p!.Performer) diff --git a/src/Yavsc.Api/Helpers/TeXHelpers.cs b/src/Yavsc.Api/Helpers/TeXHelpers.cs index a36e23e64..62439e6ea 100644 --- a/src/Yavsc.Api/Helpers/TeXHelpers.cs +++ b/src/Yavsc.Api/Helpers/TeXHelpers.cs @@ -1,9 +1,12 @@ using System.Diagnostics; +using System.Globalization; +using System.Text; using Microsoft.AspNetCore.Html; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.AspNetCore.Mvc.ViewEngines; using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Yavsc.Models.Billing; namespace Yavsc.Helpers { @@ -116,6 +119,69 @@ namespace Yavsc.Helpers return new TeXString(string.Join(lineSeparator,texlines)); } + /// + /// Renders a captured signature as TikZ \draw/\fill + /// paths, suitable for embedding in a tikzpicture at the + /// foot of a devis. The signature is the PostIt wire format — a + /// length-prefixed sequence of strokes, each stroke being + /// [k, x0, y0, x1, y1, …] with coordinates normalised to + /// [0, CoordinateMax] and y growing downward (screen + /// orientation). We denormalise to a × + /// canvas and flip y so the signature + /// appears upright on paper. A single-point stroke (a pen-down + /// tap) is rendered as a filled dot, mirroring the round-capped + /// Polyline the Avalonia pad draws for it. + /// + /// The persisted signature. Null/empty yields + /// , so the caller can gate the + /// tikzpicture on a non-empty result. + public static HtmlString SignatureToTikz( + this Signature sig, double widthCm, double heightCm) + { + if (sig is null || sig.Strokes is null || sig.Strokes.Length == 0) + return HtmlString.Empty; + + double max = sig.CoordinateMax > 0 ? sig.CoordinateMax : 10_000.0; + var strokes = sig.Strokes; + var sb = new StringBuilder(); + + int i = 0; + while (i < strokes.Length) + { + int k = strokes[i]; + if (k <= 0) break; + if (i + 1 + 2 * k > strokes.Length) break; + i++; // skip the length prefix + + // A single-point stroke: a tap. Draw a round dot so it + // shows instead of a zero-length, invisible \draw. + if (k == 1) + { + double px = strokes[i] / max * widthCm; + double py = heightCm - strokes[i + 1] / max * heightCm; + sb.Append(CultureInfo.InvariantCulture, + $"\\fill ({px:F3},{py:F3}) circle (0.8pt); "); + i += 2; + continue; + } + + sb.Append("\\draw[line cap=round, line join=round, line width=1.5pt] "); + for (int p = 0; p < k; p++) + { + double px = strokes[i + 2 * p] / max * widthCm; + double py = heightCm - strokes[i + 2 * p + 1] / max * heightCm; + if (p == 0) sb.Append(CultureInfo.InvariantCulture, + $"({px:F3},{py:F3})"); + else sb.Append(CultureInfo.InvariantCulture, + $" -- ({px:F3},{py:F3})"); + } + sb.Append("; "); + i += 2 * k; + } + + return new HtmlString(sb.ToString()); + } + public static FileInfo GenerateEstimatePdf(this PdfGenerationViewModel Model) { string errorMsg = null; diff --git a/src/Yavsc.Api/Views/Shared/Estimate_tex.cshtml b/src/Yavsc.Api/Views/Shared/Estimate_tex.cshtml index 1209ce3b6..bbe4326b4 100644 --- a/src/Yavsc.Api/Views/Shared/Estimate_tex.cshtml +++ b/src/Yavsc.Api/Views/Shared/Estimate_tex.cshtml @@ -11,7 +11,6 @@ var proaddr = Model.Query.PerformerProfile.OrganizationAddress.Address.SplitAddressToTeX(); var proaddrm = new HtmlString(Model.Query.PerformerProfile.OrganizationAddress.Address); var isestimate = !ViewBag.AsBill; - var prosign = new FileInfo($"{ViewBag.BillsDir}/estimate-prosign-{Model.Id}.png"); } \documentclass[french,11pt]{article} @@ -25,6 +24,7 @@ \usepackage{graphicx} \usepackage{fancyhdr} \usepackage{fp} +\usepackage{tikz} \def\TVA{20} % Taux de la TVA @@ -188,19 +188,46 @@ } -@if (!(Model.ProviderValidationDate==null)) { - -\begin{flushright} - @(new HtmlString(isestimate?"Devis validé":"Facture validée")) le @TeXHelpers.ToTeX(Model.ProviderValidationDate.ToString("dddd dd MMMM yyyy")) -\end{flushright} -@if (prosign.Exists) { - -\begin{center} - \hspace{263pt} - \includegraphics[height=60pt]{@(ViewBag.BillsDir)/estimate-prosign-@(Model.Id).png} -\end{center} - +@{ + // Captured signatures, if any. The Estimate.Signatures collection + // is loaded by the controller; SignaturePro/SignatureClient project + // the row by type. We draw them as vector TikZ at the foot of the + // devis (see TeXHelpers.SignatureToTikz), so there is no PNG to + // produce or \includegraphics to fail on. + var fr = CultureInfo.CreateSpecificCulture("fr-FR"); + var proSig = Model.SignaturePro; + var cliSig = Model.SignatureClient; } - + +\vspace{2em} +\noindent +\begin{minipage}[t]{0.45\textwidth} + \textbf{Signature du fournisseur}\\ +@if (Model.ProviderValidationDate != default(DateTime)) { +\small Validé le @TeXHelpers.ToTeX(Model.ProviderValidationDate.ToString("dddd dd MMMM yyyy", fr))\\ } +@if (proSig != null && proSig.Strokes != null && proSig.Strokes.Length > 0) { +\begin{tikzpicture} + \useasboundingbox (0,0) rectangle (5,2.5); + @proSig.SignatureToTikz(5, 2.5) +\end{tikzpicture} +} else { +\rule[-2.2cm]{5cm}{0.4pt} +} +\end{minipage}\hfill +\begin{minipage}[t]{0.45\textwidth} + \textbf{Signature du client}\\ +@if (Model.ClientValidationDate != default(DateTime)) { +\small Validé le @TeXHelpers.ToTeX(Model.ClientValidationDate.ToString("dddd dd MMMM yyyy", fr))\\ +} +@if (cliSig != null && cliSig.Strokes != null && cliSig.Strokes.Length > 0) { +\begin{tikzpicture} + \useasboundingbox (0,0) rectangle (5,2.5); + @cliSig.SignatureToTikz(5, 2.5) +\end{tikzpicture} +} else { +\rule[-2.2cm]{5cm}{0.4pt} +} +\end{minipage} + \end{document}