This commit is contained in:
@@ -0,0 +1,294 @@
|
||||
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<PdfTextBlock> TextBlocks);
|
||||
|
||||
public sealed record PdfImportDocument(IReadOnlyList<PdfImportPage> Pages);
|
||||
|
||||
public sealed class PdfImportCandidate
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required List<string> 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<PlaceholderType> AvailableTypes { get; } =
|
||||
[PlaceholderType.Text, PlaceholderType.Multiline, PlaceholderType.Date, PlaceholderType.Number];
|
||||
}
|
||||
|
||||
public sealed record PdfImportResult(string LayoutSource, TemplateManifest Manifest,
|
||||
IReadOnlyDictionary<string, byte[]> Assets, IReadOnlyList<PdfImportCandidate> 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<PdfImportPage>();
|
||||
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<List<UglyToad.PdfPig.Content.Word>>();
|
||||
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<PdfImportCandidate> FindCandidates(PdfImportDocument example, PdfImportDocument? blankTemplate)
|
||||
{
|
||||
var result = new List<PdfImportCandidate>();
|
||||
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<PdfImportResult> 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<PdfImportCandidate> 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<string, byte[]>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["pdf-import-background.png"] = RenderPage(backgroundPdfPath),
|
||||
["pdf-import-mask.png"] = WhitePixelPng,
|
||||
};
|
||||
var lines = new List<string>
|
||||
{
|
||||
$"PAGE {N(first.Width)} {N(first.Height)} pt",
|
||||
"BG pdf-import-background.png",
|
||||
};
|
||||
var definitions = new List<PlaceholderDefinition>();
|
||||
var usedNames = new HashSet<string>(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<PdfTextBlock>().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<PdfImportCandidate> candidates,
|
||||
IReadOnlyList<PdfAiClassification> 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<string> 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<string> 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<IReadOnlyList<PdfAiClassification>> ClassifyAsync(string username, string password,
|
||||
PdfImportDocument document, IReadOnlyList<PdfImportCandidate> 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<LoginResult>(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<ErrorResult>(JsonOptions, cancellationToken);
|
||||
throw new InvalidOperationException(error?.Error ?? "Die KI-Klassifikation ist fehlgeschlagen.");
|
||||
}
|
||||
var result = await response.Content.ReadFromJsonAsync<ClassificationResult>(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<PdfAiClassification> Classifications);
|
||||
}
|
||||
Reference in New Issue
Block a user