The detailed LaTeX error (missing .sty, missing \includegraphics target, …) was being lost: we only redirected stdin, and the engine writes its diagnostics to the .log file we delete in cleanup. So a generation failure surfaced only as the terse "Emergency stop" in the journal, with no clue what actually failed. Redirect stdout and stderr, and on non-zero exit include them in GenerationErrorMessage so the caller sees the real engine diagnostic. Keep the .log file on failure (only clean it on success) so it can be inspected alongside. Co-Authored-By: Claude Code <noreply@anthropic.com>
248 lines
10 KiB
C#
248 lines
10 KiB
C#
using System.Diagnostics;
|
||
using Microsoft.AspNetCore.Html;
|
||
using Microsoft.AspNetCore.Mvc;
|
||
using Microsoft.AspNetCore.Mvc.Rendering;
|
||
using Microsoft.AspNetCore.Mvc.ViewEngines;
|
||
using Microsoft.AspNetCore.Mvc.ViewFeatures;
|
||
|
||
namespace Yavsc.Helpers
|
||
{
|
||
using ViewModels.Gen;
|
||
public class TeXString : HtmlString
|
||
{
|
||
|
||
public TeXString(TeXString teXString): base(teXString.ToString())
|
||
{
|
||
}
|
||
|
||
public TeXString(string str) : base(str)
|
||
{
|
||
|
||
|
||
}
|
||
|
||
public static TeXString operator+ (TeXString a, TeXString b) {
|
||
return new TeXString(a.ToString()+b.ToString());
|
||
}
|
||
|
||
}
|
||
public class Replacement
|
||
{
|
||
string target;
|
||
string replacement;
|
||
public Replacement(string target, string replacement)
|
||
{
|
||
this.target = target;
|
||
this.replacement = replacement;
|
||
}
|
||
public string Execute(string source)
|
||
{
|
||
return source?.Replace(target, replacement) ?? null;
|
||
}
|
||
}
|
||
public static class TeXHelpers
|
||
{
|
||
public static readonly Replacement[] SpecialCharsDefaultRendering =
|
||
{
|
||
new Replacement("<","\\textless"),
|
||
new Replacement(">","\\textgreater"),
|
||
new Replacement("©","\\copyright"),
|
||
new Replacement("®","\\textregistered"),
|
||
new Replacement("\\","\\textbackslash"),
|
||
new Replacement("™","\\texttrademark"),
|
||
new Replacement("¶","\\P"),
|
||
new Replacement("|","\\textbar"),
|
||
new Replacement("%","\\%"),
|
||
new Replacement("{","\\{"),
|
||
new Replacement("}","\\}"),
|
||
new Replacement("_","\\_"),
|
||
new Replacement("#","\\#"),
|
||
new Replacement("$","\\$"),
|
||
new Replacement("_","\\_"),
|
||
new Replacement("¿","\\textquestiondown"),
|
||
new Replacement("§","\\S"),
|
||
new Replacement("£","\\pounds"),
|
||
new Replacement("&","\\&"),
|
||
new Replacement("¡","\\textexclamdown"),
|
||
new Replacement("†","\\dag"),
|
||
new Replacement("–","\\textendash"),
|
||
new Replacement("°","\\textdegree")
|
||
};
|
||
|
||
public static TeXString ToTeX(this string source, string defaultValue="\\textit{néant}")
|
||
{
|
||
if (source==null) return new TeXString(defaultValue);
|
||
string result=source;
|
||
foreach (var r in SpecialCharsDefaultRendering)
|
||
{
|
||
result = r.Execute(result);
|
||
}
|
||
return new TeXString(result);
|
||
}
|
||
|
||
public static TeXString ToTeXCell(this string source, string defaultValue="\\textit{néant}")
|
||
{
|
||
if (source==null) return new TeXString(defaultValue);
|
||
string result=source;
|
||
foreach (var r in SpecialCharsDefaultRendering)
|
||
{
|
||
result = r.Execute(result);
|
||
}
|
||
result = result.Replace("\n","\\tabularnewline ");
|
||
return new TeXString(result);
|
||
}
|
||
|
||
|
||
public static string NewLinesWith(this string target, string separator)
|
||
{
|
||
var items = target.Split(new char[] { '\n' }).Where(
|
||
s => !string.IsNullOrWhiteSpace(s));
|
||
|
||
return string.Join(separator, items);
|
||
}
|
||
|
||
public static TeXString ToTeXLines(this string source, string defaultValue, string lineSeparator = "\n\\\\")
|
||
{
|
||
if (source == null) return new TeXString(defaultValue);
|
||
return new TeXString( source.ToTeX().ToString().NewLinesWith(lineSeparator) );
|
||
}
|
||
|
||
public static TeXString SplitAddressToTeX (this string source, string lineSeparator = "\n\\\\", string defaultValue = "\\textit{pas d'adresse postale}")
|
||
{
|
||
if (string.IsNullOrWhiteSpace(source)) return new TeXString(defaultValue);
|
||
var alines = source.Split(',');
|
||
var texlines = alines.Select(l=>l.ToTeX().ToString());
|
||
return new TeXString(string.Join(lineSeparator,texlines));
|
||
}
|
||
|
||
public static bool GenerateEstimatePdf(this SiteSettings settings, PdfGenerationViewModel Model)
|
||
{
|
||
string errorMsg = null;
|
||
// Resolve to an absolute path: SiteSettings.Bills may be a
|
||
// relative path (e.g. "bills"), and lualatex resolves
|
||
// -output-directory against its working directory — passing a
|
||
// relative path to both would nest them (<cwd>/bills/bills) and
|
||
// the log/pdf write would fail.
|
||
var billdir = new DirectoryInfo(Model.DestDir).FullName;
|
||
string name = Model.BaseFileName;
|
||
FileInfo fo = new FileInfo(System.IO.Path.Combine(billdir, name + ".pdf"));
|
||
System.IO.Directory.CreateDirectory(billdir);
|
||
|
||
// Compile the LaTeX source with the system lualatex, feeding it
|
||
// the TeX on standard input (no intermediate .tex file) and
|
||
// directing the resulting <name>.pdf straight into the bills
|
||
// directory. lualatex is a file-based engine — it cannot emit
|
||
// the PDF on stdout — so the pdf lands at its final destination
|
||
// and we only clean the transient aux/log artifacts beside it.
|
||
//
|
||
// We capture stdout/stderr so a LaTeX error (missing .sty,
|
||
// missing \includegraphics target, …) is surfaced in
|
||
// GenerationErrorMessage instead of being buried in the .log
|
||
// we then delete.
|
||
int exitCode;
|
||
string stdout = null, stderr = null;
|
||
try
|
||
{
|
||
using (Process p = new Process())
|
||
{
|
||
p.StartInfo = new ProcessStartInfo
|
||
{
|
||
UseShellExecute = false,
|
||
WorkingDirectory = billdir,
|
||
FileName = "lualatex",
|
||
Arguments = $"-interaction=nonstopmode -halt-on-error"
|
||
+ $" -jobname=\"{name}\" -output-directory=\"{billdir}\"",
|
||
RedirectStandardInput = true,
|
||
RedirectStandardOutput = true,
|
||
RedirectStandardError = true,
|
||
};
|
||
p.Start();
|
||
// Write the TeX source, then drain stdout/stderr. Read
|
||
// them after WaitForExit — lualatex's output is small
|
||
// (a few KB), well under the pipe buffer, so no deadlock.
|
||
using (var stdin = p.StandardInput)
|
||
{
|
||
stdin.Write(Model.TeXSource);
|
||
}
|
||
p.WaitForExit();
|
||
exitCode = p.ExitCode;
|
||
stdout = p.StandardOutput.ReadToEnd();
|
||
stderr = p.StandardError.ReadToEnd();
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
errorMsg = "lualatex invocation failed: " + ex.Message;
|
||
exitCode = -1;
|
||
}
|
||
|
||
if (exitCode != 0 && errorMsg == null)
|
||
{
|
||
// Surface the engine's own diagnostics (stderr carries the
|
||
// "! Emergency stop" / "! LaTeX Error: …" lines; stdout has
|
||
// the transcript banner) so the caller can see why it failed
|
||
// without having to find the (soon-deleted) .log file.
|
||
var diag = string.IsNullOrWhiteSpace(stderr) ? stdout : stderr;
|
||
errorMsg = $"Pdf generation failed with exit code {exitCode}: {diag}";
|
||
}
|
||
else if (!fo.Exists && errorMsg == null)
|
||
errorMsg = "Pdf generation produced no output";
|
||
|
||
// Remove the LaTeX build artifacts left beside the pdf so only
|
||
// <name>.pdf persists in the bills directory. Keep the .log on
|
||
// failure so it can be inspected.
|
||
foreach (var ext in new[] { ".aux", ".log", ".out", ".fls",
|
||
".fdb_latexmk", ".synctex.gz", ".toc" })
|
||
{
|
||
if (ext == ".log" && !fo.Exists) continue;
|
||
var f = new FileInfo(System.IO.Path.Combine(billdir, name + ext));
|
||
if (f.Exists) { try { f.Delete(); } catch { } }
|
||
}
|
||
|
||
Model.Generated = fo.Exists;
|
||
Model.GenerationErrorMessage = new HtmlString(errorMsg);
|
||
return fo.Exists;
|
||
}
|
||
|
||
public static string RenderViewToString(
|
||
this Controller controller, IViewEngine engine,
|
||
string viewName, object model, bool isMainPage = true)
|
||
{
|
||
if (engine == null)
|
||
throw new InvalidOperationException("no engine");
|
||
|
||
// Build an ActionContext from the controller's own context —
|
||
// .NET 10 deprecated IActionContextAccessor, and the controller
|
||
// already holds the live request context on its ControllerContext.
|
||
var controllerContext = controller.ControllerContext;
|
||
var actionContext = new ActionContext(
|
||
controllerContext.HttpContext,
|
||
controllerContext.RouteData,
|
||
controllerContext.ActionDescriptor);
|
||
|
||
ViewEngineResult viewResult = engine.FindView(actionContext, viewName, isMainPage);
|
||
if (!viewResult.Success)
|
||
throw new InvalidOperationException(
|
||
$"View '{viewName}' not found. Searched locations: "
|
||
+ string.Join(", ", viewResult.SearchedLocations));
|
||
|
||
// Hand the model to the view through ViewData so the template's
|
||
// @model directive resolves it (the previous implementation never
|
||
// set ViewData.Model, leaving Model null inside the template).
|
||
controller.ViewData.Model = model;
|
||
|
||
using (var sw = new StringWriter())
|
||
{
|
||
var viewContext = new ViewContext(
|
||
actionContext,
|
||
viewResult.View,
|
||
controller.ViewData,
|
||
controller.TempData,
|
||
sw,
|
||
new HtmlHelperOptions());
|
||
viewResult.View.RenderAsync(viewContext).GetAwaiter().GetResult();
|
||
return sw.GetStringBuilder().ToString();
|
||
}
|
||
}
|
||
}
|
||
}
|