L1b/2: Kultur und Dateisystem plattformunabhaengig machen

Kultur - Ausgaben und Parsen haengen nicht mehr am Host:

- PdfExporter formatierte Betraege mit ToString("N2") ohne Formatanbieter,
  also CurrentCulture. Auf dem deutschen Desktop "1.234,56", in einem
  Container mit LANG=C "1,234.56" - dieselbe Zahl, fuer einen Leser eine
  andere. Fuer ein ausdruecklich pruefbares Dokument jetzt fest de-DE.
- CapitolTradesScraper.ParseDate nutzte DateOnly.TryParse ohne
  Formatanbieter. Das ist nicht theoretisch: gemessen wurde aus
  "2026-08-04" unter th-TH das Jahr 1483 (buddhistischer Kalender), unter
  fa-IR das Jahr 2647 (persischer Kalender), unter ar-SA schlug das Parsen
  ganz fehl. de-DE und en-US kommen mit ISO klar - genau deshalb faellt so
  etwas auf dem Entwicklungsrechner nie auf. Jetzt TryParseExact mit
  InvariantCulture; ein Formatwechsel der Quelle faellt damit auf, statt
  still ein falsches Datum zu erzeugen. Regressionstest ueber vier Kulturen.
- IBKRGatewayService baute den Query-Parameter mit .ToString().ToLower()
  (Tuerkisch-I) - jetzt fest "true"/"false".

PDF-Schriften: PDFsharp 6 loest auf Nicht-Windows-Plattformen nichts von
selbst auf, "Segoe UI" gibt es dort nicht - der Export waere zur Laufzeit
gescheitert. Neuer DocumentFontResolver: unter Windows bleibt die Plattform
zustaendig (unveraenderte Optik), auf Linux wird eine freie Systemschrift
gesucht (DejaVu/Liberation/Noto/FreeSans). Bewusst keine Schrift im Repo -
das erspart eine Lizenzfrage; fehlt sie, nennt die Fehlermeldung das zu
installierende Paket.

BackupWorker:
- Suchte "mysqldump.exe" in C:\Program Files\... und splittete PATH mit ';'.
  Auf Linux ist das Trennzeichen ':' - der gesamte PATH waere als ein
  Eintrag gelesen worden. Jetzt Path.PathSeparator, plattformabhaengige
  Suchpfade und zusaetzlich "mariadb-dump" (MariaDB hat mysqldump ab 10.5
  umbenannt).
- Das DB-Passwort stand als Kommandozeilenargument im Prozessbaum. Unter
  Linux ist /proc/<pid>/cmdline fuer jeden lokalen Nutzer lesbar - das waere
  eine neue Offenlegung gewesen, die es unter Windows so nicht gab. Jetzt
  ueber MYSQL_PWD, nur an den Kindprozess vererbt. Argumente einzeln statt
  als Zeichenkette (kein Quoting-Problem bei Pfaden mit Leerzeichen).

Verifiziert: 188 Tests gruen (+5), Build 0 Fehler/0 Warnungen, Core + 3
Module + Tests bauen fuer linux-x64, --smoke-ui konstruiert alle 7 Fenster.

Offen aus L1b und nach L2 verschoben: IAppPaths (Logs/Backups/settings.json/
master.key liegen neben der Binaerdatei; unter /opt ist das nicht schreibbar).
Gehoert zum Daemon, wo die Pfade tatsaechlich gebraucht werden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-08-06 22:58:10 +02:00
co-authored by Claude Opus 5
parent d8273c3a1e
commit 8b9b993d1d
6 changed files with 256 additions and 38 deletions
@@ -0,0 +1,116 @@
using System.Collections.Concurrent;
using PdfSharp.Fonts;
namespace IBKRTrader.Modules.Accounting.Logic;
/// <summary>
/// Schriftauflösung für den PDF-Export.
///
/// <para><b>Warum das nötig ist:</b> Der Export gab MigraDoc bisher „Segoe UI" vor eine
/// Windows-Schrift. PDFsharp 6 löst Schriften auf Nicht-Windows-Plattformen nicht von selbst auf;
/// ohne einen eigenen <see cref="IFontResolver"/> scheitert der Export auf Linux zur Laufzeit.</para>
///
/// <para><b>Vorgehen:</b> Unter Windows übernimmt weiterhin die Plattform (unveränderte Optik)
/// wir geben <c>null</c> zurück und setzen <see cref="GlobalFontSettings.UseWindowsFontsUnderWindows"/>.
/// Auf Linux wird eine der üblichen freien Schriften aus dem System genommen. Bewusst <b>keine</b>
/// Schrift im Repository: das erspart uns eine Lizenzfrage und hält das Paket klein die
/// Abhängigkeit ist dafür ausdrücklich dokumentiert und die Fehlermeldung nennt das Paket.</para>
/// </summary>
public sealed class DocumentFontResolver : IFontResolver
{
/// <summary>Logischer Familienname, den der Export verwendet.</summary>
public const string FamilyName = "IBKRTrader Sans";
/// <summary>Windows-Entsprechung hält die Optik der bisherigen Exporte.</summary>
private const string WindowsFamily = "Segoe UI";
/// <summary>
/// Freie Sans-Serif-Schriften in Vorzugsreihenfolge, je (regular, bold). DejaVu ist auf
/// praktisch jeder Distribution verfügbar, Liberation und Noto sind die üblichen Alternativen.
/// </summary>
private static readonly (string Regular, string Bold)[] Candidates =
[
("DejaVuSans.ttf", "DejaVuSans-Bold.ttf"),
("LiberationSans-Regular.ttf", "LiberationSans-Bold.ttf"),
("NotoSans-Regular.ttf", "NotoSans-Bold.ttf"),
("FreeSans.ttf", "FreeSansBold.ttf"),
];
private static readonly string[] SearchDirs =
[
"/usr/share/fonts",
"/usr/local/share/fonts",
"/run/host/usr/share/fonts", // Flatpak/Toolbox
];
private static readonly ConcurrentDictionary<string, byte[]> Cache = new();
private static readonly Lazy<(string Regular, string Bold)?> Found = new(Locate);
/// <summary>
/// Richtet die Schriftauflösung einmalig ein. Mehrfachaufrufe sind unschädlich
/// PDFsharp lässt den Resolver nur einmal setzen.
/// </summary>
public static void EnsureConfigured()
{
if (GlobalFontSettings.FontResolver is not null) return;
GlobalFontSettings.UseWindowsFontsUnderWindows = true;
GlobalFontSettings.FontResolver = new DocumentFontResolver();
}
public FontResolverInfo? ResolveTypeface(string familyName, bool bold, bool italic)
{
// Unter Windows die Plattform machen lassen: null bedeutet „nicht zuständig".
if (OperatingSystem.IsWindows()) return null;
if (Found.Value is not { } files)
throw new InvalidOperationException(
"Für den PDF-Export wurde keine Schriftart gefunden. Auf dem System muss eine freie " +
"Sans-Serif-Schrift installiert sein unter Debian/Ubuntu z. B. per " +
"\"apt-get install fonts-dejavu-core\", unter Alpine \"apk add font-dejavu\". " +
$"Gesucht wurde in: {string.Join(", ", SearchDirs)}.");
// Kursiv wird von PDFsharp simuliert; wir liefern nur normal und fett.
var path = bold ? files.Bold : files.Regular;
return new FontResolverInfo(path, mustSimulateBold: false, mustSimulateItalic: italic);
}
public byte[]? GetFont(string faceName) =>
Cache.GetOrAdd(faceName, File.ReadAllBytes);
/// <summary>Sucht das erste vollständige Paar (regular + bold) in den üblichen Verzeichnissen.</summary>
private static (string Regular, string Bold)? Locate()
{
var existing = SearchDirs.Where(Directory.Exists).ToArray();
if (existing.Length == 0) return null;
foreach (var (regular, bold) in Candidates)
{
var regularPath = FindFile(existing, regular);
var boldPath = FindFile(existing, bold);
// Ohne Fettschnitt bleibt der Bericht lesbar dann eben beides normal.
if (regularPath is not null) return (regularPath, boldPath ?? regularPath);
}
return null;
}
private static string? FindFile(string[] dirs, string fileName)
{
foreach (var dir in dirs)
{
try
{
var hit = Directory.EnumerateFiles(dir, fileName, SearchOption.AllDirectories).FirstOrDefault();
if (hit is not null) return hit;
}
catch (UnauthorizedAccessException) { /* Verzeichnis nicht lesbar nächstes */ }
catch (DirectoryNotFoundException) { /* zwischenzeitlich weg nächstes */ }
}
return null;
}
/// <summary>Der Familienname, den das Dokument setzen soll.</summary>
public static string DocumentFamily => OperatingSystem.IsWindows() ? WindowsFamily : FamilyName;
}