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:
parent
bbc37d3095
commit
9c1f552bca
4 changed files with 116 additions and 16 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue