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 <noreply@anthropic.com>
This commit is contained in:
Paul Schneider 2026-09-22 16:45:05 +01:00
commit 9c1f552bca
Signed by: notazof
GPG key ID: AD4B9B545FBD7D12
4 changed files with 116 additions and 16 deletions

View file

@ -73,7 +73,7 @@ systemd `yavscApi`.
Sous Debian/Ubuntu : Sous Debian/Ubuntu :
```bash ```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). - `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. `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 (Sur les Debian récents, `luaotfload` a migré de `texlive-latex-extra` vers
`texlive-luatex` — avoir `texlive-latex-extra` seul ne suffit plus.) `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 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 generation failed » ; l'endpoint TeX (`estimate.tex`) reste utilisable et permet

View file

@ -157,12 +157,14 @@ namespace Yavsc.ApiControllers
} }
// Loads the estimate linked to a query with the navigation the // 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<Estimate?> LoadEstimateForRenderAsync(long queryId, CancellationToken token) private Task<Estimate?> LoadEstimateForRenderAsync(long queryId, CancellationToken token)
=> dbContext.Estimates => dbContext.Estimates
.Where(e => e.CommandId == queryId) .Where(e => e.CommandId == queryId)
.OrderByDescending(e => e.Id) .OrderByDescending(e => e.Id)
.Include(e => e.Bill) .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!.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!.OrganizationAddress)
.Include(e => e.Query).ThenInclude(q => q!.PerformerProfile).ThenInclude(p => p!.Performer) .Include(e => e.Query).ThenInclude(q => q!.PerformerProfile).ThenInclude(p => p!.Performer)

View file

@ -1,9 +1,12 @@
using System.Diagnostics; using System.Diagnostics;
using System.Globalization;
using System.Text;
using Microsoft.AspNetCore.Html; using Microsoft.AspNetCore.Html;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.ViewEngines; using Microsoft.AspNetCore.Mvc.ViewEngines;
using Microsoft.AspNetCore.Mvc.ViewFeatures; using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Yavsc.Models.Billing;
namespace Yavsc.Helpers namespace Yavsc.Helpers
{ {
@ -116,6 +119,69 @@ namespace Yavsc.Helpers
return new TeXString(string.Join(lineSeparator,texlines)); return new TeXString(string.Join(lineSeparator,texlines));
} }
/// <summary>
/// Renders a captured signature as TikZ <c>\draw</c>/<c>\fill</c>
/// paths, suitable for embedding in a <c>tikzpicture</c> at the
/// foot of a devis. The signature is the PostIt wire format — a
/// length-prefixed sequence of strokes, each stroke being
/// <c>[k, x0, y0, x1, y1, …]</c> with coordinates normalised to
/// <c>[0, CoordinateMax]</c> and y growing downward (screen
/// orientation). We denormalise to a <paramref name="widthCm"/> ×
/// <paramref name="heightCm"/> 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
/// <c>Polyline</c> the Avalonia pad draws for it.
/// </summary>
/// <param name="sig">The persisted signature. Null/empty yields
/// <see cref="HtmlString.Empty"/>, so the caller can gate the
/// <c>tikzpicture</c> on a non-empty result.</param>
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) public static FileInfo GenerateEstimatePdf(this PdfGenerationViewModel Model)
{ {
string errorMsg = null; string errorMsg = null;

View file

@ -11,7 +11,6 @@
var proaddr = Model.Query.PerformerProfile.OrganizationAddress.Address.SplitAddressToTeX(); var proaddr = Model.Query.PerformerProfile.OrganizationAddress.Address.SplitAddressToTeX();
var proaddrm = new HtmlString(Model.Query.PerformerProfile.OrganizationAddress.Address); var proaddrm = new HtmlString(Model.Query.PerformerProfile.OrganizationAddress.Address);
var isestimate = !ViewBag.AsBill; var isestimate = !ViewBag.AsBill;
var prosign = new FileInfo($"{ViewBag.BillsDir}/estimate-prosign-{Model.Id}.png");
} }
\documentclass[french,11pt]{article} \documentclass[french,11pt]{article}
@ -25,6 +24,7 @@
\usepackage{graphicx} \usepackage{graphicx}
\usepackage{fancyhdr} \usepackage{fancyhdr}
\usepackage{fp} \usepackage{fp}
\usepackage{tikz}
\def\TVA{20} % Taux de la TVA \def\TVA{20} % Taux de la TVA
@ -188,19 +188,46 @@
} }
@if (!(Model.ProviderValidationDate==null)) { @{
<text> // Captured signatures, if any. The Estimate.Signatures collection
\begin{flushright} // is loaded by the controller; SignaturePro/SignatureClient project
@(new HtmlString(isestimate?"Devis validé":"Facture validée")) le @TeXHelpers.ToTeX(Model.ProviderValidationDate.ToString("dddd dd MMMM yyyy")) // the row by type. We draw them as vector TikZ at the foot of the
\end{flushright} // devis (see TeXHelpers.SignatureToTikz), so there is no PNG to
@if (prosign.Exists) { // produce or \includegraphics to fail on.
<text> var fr = CultureInfo.CreateSpecificCulture("fr-FR");
\begin{center} var proSig = Model.SignaturePro;
\hspace{263pt} var cliSig = Model.SignatureClient;
\includegraphics[height=60pt]{@(ViewBag.BillsDir)/estimate-prosign-@(Model.Id).png}
\end{center}
</text>
} }
</text>
\vspace{2em}
\noindent
\begin{minipage}[t]{0.45\textwidth}
\textbf{Signature du fournisseur}\\
@if (Model.ProviderValidationDate != default(DateTime)) {
<text>\small Validé le @TeXHelpers.ToTeX(Model.ProviderValidationDate.ToString("dddd dd MMMM yyyy", fr))\\</text>
} }
@if (proSig != null && proSig.Strokes != null && proSig.Strokes.Length > 0) {
<text>\begin{tikzpicture}
\useasboundingbox (0,0) rectangle (5,2.5);
@proSig.SignatureToTikz(5, 2.5)
\end{tikzpicture}</text>
} else {
<text>\rule[-2.2cm]{5cm}{0.4pt}</text>
}
\end{minipage}\hfill
\begin{minipage}[t]{0.45\textwidth}
\textbf{Signature du client}\\
@if (Model.ClientValidationDate != default(DateTime)) {
<text>\small Validé le @TeXHelpers.ToTeX(Model.ClientValidationDate.ToString("dddd dd MMMM yyyy", fr))\\</text>
}
@if (cliSig != null && cliSig.Strokes != null && cliSig.Strokes.Length > 0) {
<text>\begin{tikzpicture}
\useasboundingbox (0,0) rectangle (5,2.5);
@cliSig.SignatureToTikz(5, 2.5)
\end{tikzpicture}</text>
} else {
<text>\rule[-2.2cm]{5cm}{0.4pt}</text>
}
\end{minipage}
\end{document} \end{document}