using System.Diagnostics; using System.Text; namespace JobTrackerApi.Services; public sealed record CvPdfArtifact(string FileName, string StoragePath, byte[] Bytes); public interface ICvPdfExporter { Task ExportAsync(TailoredCvRenderResult renderResult, CancellationToken cancellationToken); } public sealed class PlaywrightCvPdfExporter : ICvPdfExporter { private static readonly string[] BrowserCandidates = { "chromium", "chromium-browser", "google-chrome", "google-chrome-stable", "/usr/bin/chromium", "/usr/bin/chromium-browser", "/usr/bin/google-chrome", "/usr/bin/google-chrome-stable" }; private readonly AppPaths _paths; private readonly ILogger _logger; public PlaywrightCvPdfExporter(AppPaths paths, ILogger logger) { _paths = paths; _logger = logger; } public async Task ExportAsync(TailoredCvRenderResult renderResult, CancellationToken cancellationToken) { var now = DateTimeOffset.UtcNow; var folder = Path.Combine(_paths.CvExportsRoot, now.ToString("yyyyMMdd")); Directory.CreateDirectory(folder); var fileName = string.IsNullOrWhiteSpace(renderResult.SuggestedFileName) ? $"tailored-cv-{now:yyyyMMddHHmmss}.pdf" : renderResult.SuggestedFileName; var storagePath = Path.Combine(folder, fileName); var tempRoot = Path.Combine(Path.GetTempPath(), "jobtracker-cv-pdf", Guid.NewGuid().ToString("n")); var htmlPath = Path.Combine(tempRoot, "document.html"); var userDataDir = Path.Combine(tempRoot, "profile"); Directory.CreateDirectory(tempRoot); Directory.CreateDirectory(userDataDir); try { await File.WriteAllTextAsync(htmlPath, renderResult.Html ?? string.Empty, Encoding.UTF8, cancellationToken); var browserPath = ResolveBrowserPath(); if (string.IsNullOrWhiteSpace(browserPath)) { throw new InvalidOperationException("CV PDF export is unavailable. Install Chromium/Google Chrome or set CV_PDF_BROWSER_PATH."); } var arguments = BuildArguments(userDataDir, storagePath, htmlPath); var startInfo = new ProcessStartInfo(); startInfo.FileName = browserPath; startInfo.Arguments = arguments; startInfo.RedirectStandardOutput = true; startInfo.RedirectStandardError = true; startInfo.UseShellExecute = false; startInfo.CreateNoWindow = true; using var process = new Process(); process.StartInfo = startInfo; process.Start(); await process.WaitForExitAsync(cancellationToken); var stdout = await process.StandardOutput.ReadToEndAsync(); var stderr = await process.StandardError.ReadToEndAsync(); if (process.ExitCode != 0) { throw new InvalidOperationException($"CV PDF export failed via browser CLI. ExitCode={process.ExitCode}. Stdout={stdout}. Stderr={stderr}"); } if (!File.Exists(storagePath)) { throw new InvalidOperationException($"CV PDF export did not create the expected file at {storagePath}."); } var bytes = await File.ReadAllBytesAsync(storagePath, cancellationToken); return new CvPdfArtifact(fileName, storagePath, bytes); } catch (Exception ex) { _logger.LogError(ex, "Failed to export CV PDF to {Path}", storagePath); throw; } finally { TryDeleteDirectory(tempRoot); } } private static string BuildArguments(string userDataDir, string storagePath, string htmlPath) { var parts = new List { "--headless=new", "--disable-gpu", "--no-sandbox", "--disable-dev-shm-usage", "--allow-file-access-from-files", "--enable-local-file-accesses", "--user-data-dir=" + Quote(userDataDir), "--print-to-pdf=" + Quote(storagePath), Quote(htmlPath) }; return string.Join(' ', parts); } private static string? ResolveBrowserPath() { var configured = Environment.GetEnvironmentVariable("CV_PDF_BROWSER_PATH"); if (!string.IsNullOrWhiteSpace(configured) && File.Exists(configured)) { return configured; } foreach (var candidate in BrowserCandidates) { if (Path.IsPathRooted(candidate)) { if (File.Exists(candidate)) return candidate; continue; } var resolved = FindOnPath(candidate); if (!string.IsNullOrWhiteSpace(resolved)) return resolved; } return null; } private static string? FindOnPath(string fileName) { var path = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; var parts = path.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); foreach (var dir in parts) { var fullPath = Path.Combine(dir, fileName); if (File.Exists(fullPath)) return fullPath; } return null; } private static void TryDeleteDirectory(string path) { try { if (Directory.Exists(path)) { Directory.Delete(path, recursive: true); } } catch { // best effort temp cleanup } } private static string Quote(string value) { return '"' + value.Replace("\\", "\\\\").Replace("\"", "\\\"") + '"'; } }