using System.Globalization; using System.Net; using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Serialization; using System.Runtime.Versioning; using LehrerApp.Templating; using PDFtoImage; using UglyToad.PdfPig; namespace LehrerApp.TemplateDesigner; public enum PdfImportConfidence { Low, Medium, High } public sealed record PdfTextBlock(string Id, int PageNumber, double X, double Y, double Width, double Height, double FontSize, string FontName, bool Bold, bool Italic, string Text); public sealed record PdfImportPage(int PageNumber, double Width, double Height, IReadOnlyList TextBlocks); public sealed record PdfImportDocument(IReadOnlyList Pages); public sealed class PdfImportCandidate { public required string Id { get; init; } public required List BlockIds { get; set; } public required string OriginalText { get; init; } public required string Name { get; set; } public PlaceholderType Type { get; set; } = PlaceholderType.Text; public PdfImportConfidence Confidence { get; set; } public bool Include { get; set; } = true; public string ConfidenceLabel => Confidence switch { PdfImportConfidence.High => "Hoch", PdfImportConfidence.Medium => "Mittel", _ => "Niedrig" }; public string ConfidenceColor => Confidence switch { PdfImportConfidence.High => "#15803D", PdfImportConfidence.Medium => "#A16207", _ => "#B91C1C" }; public IReadOnlyList AvailableTypes { get; } = [PlaceholderType.Text, PlaceholderType.Multiline, PlaceholderType.Date, PlaceholderType.Number]; } public sealed record PdfImportResult(string LayoutSource, TemplateManifest Manifest, IReadOnlyDictionary Assets, IReadOnlyList Candidates); [SupportedOSPlatform("windows")] [SupportedOSPlatform("linux")] [SupportedOSPlatform("macos")] public sealed class PdfImportPipeline { private const double PositionTolerance = 2; public PdfImportDocument Extract(string path) { using var document = PdfDocument.Open(path); var pages = new List(); foreach (var page in document.GetPages()) { var words = page.GetWords().OrderByDescending(x => x.BoundingBox.Top).ThenBy(x => x.BoundingBox.Left).ToList(); var lines = new List>(); foreach (var word in words) { var line = lines.FirstOrDefault(x => Math.Abs(x[0].BoundingBox.Bottom - word.BoundingBox.Bottom) <= Math.Max(1.5, word.BoundingBox.Height * .35)); if (line is null) lines.Add([word]); else line.Add(word); } var blocks = lines.Select((line, index) => { var ordered = line.OrderBy(x => x.BoundingBox.Left).ToList(); var left = ordered.Min(x => x.BoundingBox.Left); var right = ordered.Max(x => x.BoundingBox.Right); var bottom = ordered.Min(x => x.BoundingBox.Bottom); var top = ordered.Max(x => x.BoundingBox.Top); var letters = ordered.SelectMany(x => x.Letters).ToList(); var font = letters.FirstOrDefault()?.FontName ?? ""; return new PdfTextBlock($"p{page.Number}-t{index + 1}", page.Number, left, page.Height - top, right - left, top - bottom, letters.Count == 0 ? top - bottom : letters.Average(x => x.FontSize), font, font.Contains("Bold", StringComparison.OrdinalIgnoreCase), font.Contains("Italic", StringComparison.OrdinalIgnoreCase) || font.Contains("Oblique", StringComparison.OrdinalIgnoreCase), string.Join(' ', ordered.Select(x => x.Text))); }).Where(x => !string.IsNullOrWhiteSpace(x.Text)).ToList(); pages.Add(new(page.Number, page.Width, page.Height, blocks)); } return new(pages); } public List FindCandidates(PdfImportDocument example, PdfImportDocument? blankTemplate) { var result = new List(); foreach (var page in example.Pages) { var templateBlocks = blankTemplate?.Pages.FirstOrDefault(x => x.PageNumber == page.PageNumber)?.TextBlocks ?? []; foreach (var block in page.TextBlocks) { var same = templateBlocks.Any(other => Near(block, other) && other.Text.Equals(block.Text, StringComparison.Ordinal)); if (same) continue; var confidence = blankTemplate is not null ? PdfImportConfidence.High : HeuristicConfidence(block, page); result.Add(new PdfImportCandidate { Id = block.Id, BlockIds = [block.Id], OriginalText = block.Text, Name = SuggestedName(block.Text, block, page), Type = SuggestedType(block.Text), Confidence = confidence, }); } } return result; } public async Task BuildAsync(string examplePath, string? templatePath, string? username, string? password, CancellationToken cancellationToken = default) { var example = Extract(examplePath); var blank = templatePath is null ? null : Extract(templatePath); EnsureCompatible(example, blank); if (example.Pages.Count > 1) throw new InvalidDataException("Der automatische Import unterstützt in v1 einseitige Vorlagen. Weitere PDF-Seiten können anschließend als Folgeseitenlayout ergänzt werden."); var candidates = FindCandidates(example, blank); if (!string.IsNullOrWhiteSpace(username) && !string.IsNullOrWhiteSpace(password)) { var client = new PdfImportAiClient(new HttpClient { BaseAddress = new Uri("https://backapi.science-teaching.de/") }); var classifications = await client.ClassifyAsync(username, password, example, candidates, cancellationToken); ApplyClassifications(candidates, classifications); } return BuildResult(templatePath ?? examplePath, example, candidates); } public PdfImportResult BuildResult(string backgroundPdfPath, PdfImportDocument document, IReadOnlyList candidates) { if (document.Pages.Count == 0) throw new InvalidDataException("Das PDF enthält keine Seiten."); if (document.Pages.Count > 1) throw new InvalidDataException("Der automatische Import unterstützt in v1 einseitige Vorlagen. Weitere PDF-Seiten können anschließend als Folgeseitenlayout ergänzt werden."); var first = document.Pages[0]; var assets = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["pdf-import-background.png"] = RenderPage(backgroundPdfPath), ["pdf-import-mask.png"] = WhitePixelPng, }; var lines = new List { $"PAGE {N(first.Width)} {N(first.Height)} pt", "BG pdf-import-background.png", }; var definitions = new List(); var usedNames = new HashSet(StringComparer.Ordinal); foreach (var candidate in candidates.Where(x => x.Include)) { var blocks = candidate.BlockIds.Select(id => first.TextBlocks.FirstOrDefault(x => x.Id == id)) .Where(x => x is not null).Cast().ToList(); if (blocks.Count == 0) continue; var name = UniqueName(SanitizeName(candidate.Name), usedNames); var x = blocks.Min(b => b.X); var y = blocks.Min(b => b.Y); var right = blocks.Max(b => b.X + b.Width); var bottom = blocks.Max(b => b.Y + b.Height); var padding = Math.Max(1, blocks.Average(b => b.FontSize) * .18); lines.Add($"IMG pdf-import-mask.png {N(x - padding)} {N(y - padding)} {N(right - x + 2 * padding)} {N(bottom - y + 2 * padding)}"); var attrs = $"size={N(blocks.Average(b => b.FontSize))}" + (blocks.Any(b => b.Bold) ? " bold=true" : "") + (blocks.Any(b => b.Italic) ? " italic=true" : ""); var multiline = candidate.Type == PlaceholderType.Multiline || blocks.Count > 1 || candidate.OriginalText.Contains('\n'); lines.Add(multiline ? $"TEXTBOX {N(x)} {N(y)} {N(Math.Max(20, right - x))} {N(Math.Max(bottom - y, blocks.Average(b => b.FontSize) * 2.5))} ${name} {attrs}" : $"TEXT {N(x)} {N(y)} ${name} {attrs}"); definitions.Add(new(name, multiline ? PlaceholderType.Multiline : candidate.Type, false)); candidate.Name = name; } var manifest = new TemplateManifest { Id = "pdf-import", Name = "PDF-Import", Description = "Automatisch aus einem PDF rekonstruiert", PageSize = new((float)first.Width, (float)first.Height, "pt"), Placeholders = definitions, Metadata = new(StringComparer.OrdinalIgnoreCase) { [TemplateMetadataKeys.Language] = "de-DE" }, }; return new(string.Join(Environment.NewLine, lines) + Environment.NewLine, manifest, assets, candidates); } private static void ApplyClassifications(List candidates, IReadOnlyList classifications) { foreach (var classification in classifications) { var candidate = candidates.FirstOrDefault(x => x.Id == classification.Id); if (candidate is null) continue; candidate.Name = classification.Name; candidate.Type = classification.Type; candidate.Confidence = classification.Confidence; if (classification.BlockIds.Count > 0) candidate.BlockIds = classification.BlockIds; } } private static bool Near(PdfTextBlock a, PdfTextBlock b) => Math.Abs(a.X - b.X) <= PositionTolerance && Math.Abs(a.Y - b.Y) <= PositionTolerance && Math.Abs(a.Width - b.Width) <= Math.Max(PositionTolerance, a.Width * .08); private static PdfImportConfidence HeuristicConfidence(PdfTextBlock block, PdfImportPage page) { if (SuggestedType(block.Text) is PlaceholderType.Date or PlaceholderType.Number) return PdfImportConfidence.High; if (block.Y < page.Height * .42 && block.X < page.Width * .6) return PdfImportConfidence.Medium; return PdfImportConfidence.Low; } private static PlaceholderType SuggestedType(string text) { if (System.Text.RegularExpressions.Regex.IsMatch(text, @"\b\d{1,2}[./-]\d{1,2}[./-]\d{2,4}\b")) return PlaceholderType.Date; if (decimal.TryParse(text, NumberStyles.Number, CultureInfo.GetCultureInfo("de-DE"), out _)) return PlaceholderType.Number; return text.Length > 100 ? PlaceholderType.Multiline : PlaceholderType.Text; } private static string SuggestedName(string text, PdfTextBlock block, PdfImportPage page) { if (SuggestedType(text) == PlaceholderType.Date) return "Datum"; if (System.Text.RegularExpressions.Regex.IsMatch(text, @"^\d{5}\s+")) return "PlzOrt"; if (text.StartsWith("Betreff", StringComparison.OrdinalIgnoreCase)) return "Betreff"; if (block.Y < page.Height * .42 && block.X < page.Width * .6) return "Adresse"; return "Feld"; } private static string SanitizeName(string name) { var safe = string.Concat(name.Trim().Where(char.IsLetterOrDigit)); return string.IsNullOrEmpty(safe) ? "Feld" : safe; } private static string UniqueName(string name, HashSet used) { if (used.Add(name)) return name; for (var i = 2; ; i++) if (used.Add(name + i)) return name + i; } private static string N(double value) => value.ToString("0.###", CultureInfo.InvariantCulture); private static void EnsureCompatible(PdfImportDocument example, PdfImportDocument? blank) { if (blank is null) return; if (example.Pages.Count != blank.Pages.Count) throw new InvalidDataException("Template und Beispiel haben unterschiedlich viele Seiten."); for (var i = 0; i < example.Pages.Count; i++) if (Math.Abs(example.Pages[i].Width - blank.Pages[i].Width) > PositionTolerance || Math.Abs(example.Pages[i].Height - blank.Pages[i].Height) > PositionTolerance) throw new InvalidDataException("Template und Beispiel haben unterschiedliche Seitengrößen."); } [SupportedOSPlatform("windows")] [SupportedOSPlatform("linux")] [SupportedOSPlatform("macos")] private static byte[] RenderPage(string path) { var temporary = Path.Combine(Path.GetTempPath(), $"lehrerapp-pdf-import-{Guid.NewGuid():N}.png"); try { using var pdf = File.OpenRead(path); Conversion.SavePng(temporary, pdf, page: 0, options: new RenderOptions(Dpi: 300)); return File.ReadAllBytes(temporary); } finally { if (File.Exists(temporary)) File.Delete(temporary); } } private static readonly byte[] WhitePixelPng = Convert.FromBase64String( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wl2nJkAAAAASUVORK5CYII="); } public sealed record PdfAiClassification(string Id, List BlockIds, string Name, PlaceholderType Type, PdfImportConfidence Confidence); internal sealed class PdfImportAiClient(HttpClient http) { private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { PropertyNameCaseInsensitive = true, Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }, }; public async Task> ClassifyAsync(string username, string password, PdfImportDocument document, IReadOnlyList candidates, CancellationToken cancellationToken) { HttpResponseMessage login; try { login = await http.PostAsJsonAsync("login.php", new { username, password }, JsonOptions, cancellationToken); } catch (HttpRequestException ex) { throw new InvalidOperationException("Der KI-Dienst ist nicht erreichbar.", ex); } if (login.StatusCode == HttpStatusCode.Unauthorized) throw new InvalidOperationException("Benutzername oder Passwort ist falsch."); login.EnsureSuccessStatusCode(); var token = (await login.Content.ReadFromJsonAsync(JsonOptions, cancellationToken))?.Token ?? throw new InvalidOperationException("Das KI-Backend lieferte kein Token."); using var request = new HttpRequestMessage(HttpMethod.Post, "pdf-template.php") { Content = JsonContent.Create(new { document, candidates = candidates.Select(x => new { x.Id, x.BlockIds, x.OriginalText, suggestedName = x.Name, x.Type, x.Confidence }) }, options: JsonOptions), }; request.Headers.Authorization = new("Bearer", token); using var response = await http.SendAsync(request, cancellationToken); if (response.StatusCode == HttpStatusCode.PaymentRequired) throw new InvalidOperationException("Nicht genügend KI-Guthaben."); if (response.StatusCode == HttpStatusCode.Unauthorized) throw new InvalidOperationException("Die KI-Anmeldung ist abgelaufen."); if (!response.IsSuccessStatusCode) { var error = await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken); throw new InvalidOperationException(error?.Error ?? "Die KI-Klassifikation ist fehlgeschlagen."); } var result = await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken); return result?.Classifications ?? throw new InvalidOperationException("Die KI-Antwort ist unvollständig."); } private sealed record LoginResult(string Token); private sealed record ErrorResult(string Error); private sealed record ClassificationResult(List Classifications); }