foxygit / digitalskrivare_merge Log in
commit 2466aa3ed34ba3838f47d276d605acb27ef24f41
Author:     Jens Kristoffersson <jens.kristoffersson.se@gmail.com>
AuthorDate: Tue Sep 1 10:13:48 2026 +0200
Commit:     Jens Kristoffersson <jens.kristoffersson.se@gmail.com>
CommitDate: Tue Sep 1 10:13:48 2026 +0200

    Fas 0: OCR-kontrakt för försättsbladet

    Nedströms läses mottagningen via server-side text-OCR. Vi definierar
    formatet: ankarordet REMISS + en maskinläsbar rad "HSA-ID: <id>" i
    monospace på egen rad.

    - OcrContract: delad formattering + tolerant parser (tål versaler/
      gemener, tappat kolon/bindestreck, OCR-blanksteg i id:t, kringtext)
    - CoverPageGenerator: ny OCR-anpassad layout, Arial/Consolas
    - WindowsFontResolver: PdfSharp 6.1 resolvar inte namngivna typsnitt
      automatiskt – vi pekar ut C:\Windows\Fonts själva
    - tools/Remiss.CoverPreview: dev-verktyg för att öga-testa layouten
    - docs/forsattsblad-ocr.md: kontraktet dokumenterat
    - OcrContractTests (9 st), totalt 15/15 gröna
---
 .gitignore                                         |  3 +
 README.md                                          | 21 +++++--
 Remiss.sln                                         |  9 +++
 docs/forsattsblad-ocr.md                           | 46 ++++++++++++++++
 src/Remiss.Core/Pdf/CoverPageGenerator.cs          | 64 +++++++++++++++-------
 src/Remiss.Core/Pdf/OcrContract.cs                 | 49 +++++++++++++++++
 src/Remiss.Core/Pdf/WindowsFontResolver.cs         | 38 +++++++++++++
 tests/Remiss.Core.Tests/OcrContractTests.cs        | 42 ++++++++++++++
 tools/Remiss.CoverPreview/Program.cs               | 19 +++++++
 .../Remiss.CoverPreview/Remiss.CoverPreview.csproj | 15 +++++
 10 files changed, 282 insertions(+), 24 deletions(-)

diff --git a/.gitignore b/.gitignore
index 6b44f5f..a738e2c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -24,6 +24,9 @@ tools/*.zip
 audit/
 *.jsonl

+# Generated preview / test PDFs
+*.pdf
+
 # OS
 Thumbs.db
 desktop.ini
diff --git a/README.md b/README.md
index 693a015..a08ecb8 100644
--- a/README.md
+++ b/README.md
@@ -11,7 +11,7 @@ Se [Plan.md](Plan.md) för arkitektur, teknikval och faser.

 | Fas | Innehåll | Läge |
 |-----|----------|------|
-| 0 | Spika hur nedströms-skrivaren läser av mottagning | **Blockerad – ej påbörjad** |
+| 0 | Spika hur nedströms-skrivaren läser av mottagning | **Klart: text-OCR server-side. [Kontrakt](docs/forsattsblad-ocr.md).** |
 | 1 | Fristående C#-app, körs manuellt med test-PDF | **Bygger + tester gröna. Kvar: manuellt dialog+utskriftstest.** |
 | 2 | Koppla på fångst (clawPDF) | Ej påbörjad |
 | 3 | Härdning (HSA, config, audit, temp, felflöden) | Delvis förberedd i koden |
@@ -28,6 +28,8 @@ Se [Plan.md](Plan.md) för arkitektur, teknikval och faser.
 - SumatraPDF-binär i `tools/` – se [tools/README.md](tools/README.md).
   Behövs bara för det sista utskriftssteget; dialog + försättsblad + merge
   fungerar utan.
+- Typsnitten **Arial** och **Consolas** (standard på Windows) – försättsbladet
+  renderas mot dem via en egen `WindowsFontResolver`.

 ## Bygga och köra (Fas 1)

@@ -45,6 +47,12 @@ Målskrivaren styrs av `targetPrinter` i `config/appsettings.json`
 Utan SumatraPDF-binär kommer stegen dialog → försättsblad → merge att köras,
 och utskriftssteget kastar ett tydligt fel.

+Iterera på försättsbladets OCR-layout utan hela flödet:
+
+```powershell
+dotnet run --project tools/Remiss.CoverPreview -- preview.pdf SE2321000131-E000000000009 "Testmottagning"
+```
+
 ## Projektstruktur

 ```
@@ -52,13 +60,17 @@ Remiss.sln
 config/
   appsettings.json        sökvägar, målskrivare, retry, flaggor
   mottagningar.json        POC-register (namn → HSA-id)
-tools/                     buntad SumatraPDF (checkas ej in)
+docs/
+  forsattsblad-ocr.md      OCR-kontraktet för försättsbladet (Fas 0)
+tools/
+  README.md                buntad SumatraPDF (checkas ej in)
+  Remiss.CoverPreview/     dev-verktyg: rendera försättsblad för öga-test
 src/
   Remiss.Core/             all logik, UI-oberoende
     Abstractions/          IMottagningRepository, IPrinter
     Models/                Mottagning
     Repositories/          JsonMottagningRepository
-    Pdf/                   CoverPageGenerator (MigraDoc), PdfMerger (PdfSharp)
+    Pdf/                   CoverPageGenerator, PdfMerger, OcrContract, WindowsFontResolver
     Printing/              SumatraPdfPrinter (retry)
     Security/              SecureTempStore (ACL + shred), SecureFile
     Audit/                 AuditLogger (JSONL + separat error-logg)
@@ -99,7 +111,8 @@ Remiss-PDF:erna är känsliga personuppgifter.

 ## Kända begränsningar (Fas 1)

-- Försättsbladets layout är preliminär tills Fas 0 är löst.
+- Försättsbladets layout följer OCR-kontrakt v1. Den bör verifieras mot
+  nedströmssystemets faktiska OCR innan pilot – ingen checksumma ännu.
 - Mottagningsregistret är statisk JSON; HSA-katalog kopplas i Fas 3 bakom
   `IMottagningRepository`.
 - Tester täcker Core (PDF, register, temp). Dialog + utskrift är inte
diff --git a/Remiss.sln b/Remiss.sln
index 96df4fb..473e890 100644
--- a/Remiss.sln
+++ b/Remiss.sln
@@ -10,6 +10,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{98039F5D
 EndProject
 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Remiss.Core.Tests", "tests\Remiss.Core.Tests\Remiss.Core.Tests.csproj", "{8CE7B5D2-E03B-44A3-9D0B-CF114500A874}"
 EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tools", "tools", "{B883032E-2606-471A-9495-DB672695790D}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Remiss.CoverPreview", "tools\Remiss.CoverPreview\Remiss.CoverPreview.csproj", "{FAC4DDCA-F1A4-4AA1-B8D5-BBF934E78288}"
+EndProject
 Global
 	GlobalSection(SolutionConfigurationPlatforms) = preSolution
 		Debug|Any CPU = Debug|Any CPU
@@ -28,11 +32,16 @@ Global
 		{8CE7B5D2-E03B-44A3-9D0B-CF114500A874}.Debug|Any CPU.Build.0 = Debug|Any CPU
 		{8CE7B5D2-E03B-44A3-9D0B-CF114500A874}.Release|Any CPU.ActiveCfg = Release|Any CPU
 		{8CE7B5D2-E03B-44A3-9D0B-CF114500A874}.Release|Any CPU.Build.0 = Release|Any CPU
+		{FAC4DDCA-F1A4-4AA1-B8D5-BBF934E78288}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{FAC4DDCA-F1A4-4AA1-B8D5-BBF934E78288}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{FAC4DDCA-F1A4-4AA1-B8D5-BBF934E78288}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{FAC4DDCA-F1A4-4AA1-B8D5-BBF934E78288}.Release|Any CPU.Build.0 = Release|Any CPU
 	EndGlobalSection
 	GlobalSection(SolutionProperties) = preSolution
 		HideSolutionNode = FALSE
 	EndGlobalSection
 	GlobalSection(NestedProjects) = preSolution
 		{8CE7B5D2-E03B-44A3-9D0B-CF114500A874} = {98039F5D-2EEF-48E3-9B66-B40FE8971177}
+		{FAC4DDCA-F1A4-4AA1-B8D5-BBF934E78288} = {B883032E-2606-471A-9495-DB672695790D}
 	EndGlobalSection
 EndGlobal
diff --git a/docs/forsattsblad-ocr.md b/docs/forsattsblad-ocr.md
new file mode 100644
index 0000000..5e902a8
--- /dev/null
+++ b/docs/forsattsblad-ocr.md
@@ -0,0 +1,46 @@
+# Försättsbladets OCR-kontrakt (v1)
+
+Fas 0-beslutet: nedströmssystemet läser mottagningen från försättsbladet med
+**server-side OCR på text** (ingen streckkod, ingen metadatafil). Vi äger formatet.
+
+## Vad som renderas på sida 1
+
+| Element | Syfte | Formatering |
+|---------|-------|-------------|
+| `REMISS` | Ankare: "det här är ett remiss-försättsblad" | Arial, 48 pt, fet |
+| `HSA-ID: <id>` | **Maskinläsbar rad** – den enda parsern behöver | Consolas (monospace), 20 pt, fet, egen rad, ≥0,8 cm luft över och under |
+| `Mottagning: <namn>` | Människoläsbar kontroll | Arial, 14 pt |
+| `Remiss bifogad som sida 2.` | Instruktion till mottagaren | Arial, 16 pt, fet |
+| Genereringstidpunkt | Spårbarhet | Arial, 9 pt |
+
+Allt vänsterställt, överst på A4, svart på vitt. Ingen patientdata på bladet.
+
+## Parserkontrakt
+
+Kontraktet är kod, inte bara text: [`OcrContract`](../src/Remiss.Core/Pdf/OcrContract.cs).
+
+- **Skriva:** `OcrContract.FormatHsaIdLine(hsaId)` → `"HSA-ID: SE2321000131-E000000000001"`
+- **Läsa:** `OcrContract.TryParseHsaId(ocrText, out var hsaId)` körs på OCR-texten
+  från sida 1.
+
+Parsern tål vanligt OCR-brus:
+
+- versaler/gemener spelar ingen roll (`HSA-ID`, `hsa id`)
+- tappat kolon eller bindestreck i etiketten
+- enstaka blanksteg som OCR:n lagt in mitt i id:t (`SE2321000131 - E000…`)
+- omgivande text och radbrytningar före/efter
+
+Den stannar vid radslut, så den läcker inte in i `Mottagning:`-raden under.
+
+## Inte med i v1
+
+- **Kontrollsiffra / checksumma.** Medvetet utelämnad nu. Läggs till i Fas 3 om
+  pilottestet visar att OCR:n läser fel – då kan servern validera i stället för
+  att skicka remissen till fel mottagning.
+- Fast geometrisk OCR-zon. Vi ankrar på nyckelordet `HSA-ID:`, inte på koordinater.
+
+## Iterera på layouten
+
+```powershell
+dotnet run --project tools/Remiss.CoverPreview -- preview.pdf SE2321000131-E000000000009 "Testmottagning"
+```
diff --git a/src/Remiss.Core/Pdf/CoverPageGenerator.cs b/src/Remiss.Core/Pdf/CoverPageGenerator.cs
index 98e2bce..5b9c4e6 100644
--- a/src/Remiss.Core/Pdf/CoverPageGenerator.cs
+++ b/src/Remiss.Core/Pdf/CoverPageGenerator.cs
@@ -1,5 +1,6 @@
 using MigraDoc.DocumentObjectModel;
 using MigraDoc.Rendering;
+using PdfSharp.Fonts;

 namespace Remiss.Core.Pdf;

@@ -7,46 +8,69 @@ namespace Remiss.Core.Pdf;
 public sealed record CoverInfo(string MottagningNamn, string HsaId, DateTime Skapad);

 /// <summary>
-/// Genererar försättsbladet (sida 1). Layouten är preliminär – den spikas först
-/// när Fas 0 (hur nedströms-skrivaren läser av mottagning) är löst.
+/// Genererar försättsbladet (sida 1). Layouten följer <see cref="OcrContract"/>:
+/// ankarordet REMISS överst, därefter den maskinläsbara HSA-ID-raden i monospace
+/// på egen rad med gott om luft omkring, så att server-OCR:n träffar rätt.
 /// </summary>
 public static class CoverPageGenerator
 {
+    // Consolas finns på alla Windows-installationer sedan Vista. Byt till
+    // "Courier New" om målmiljön saknar den.
+    private const string MonospaceFont = "Consolas";
+    private const string SansFont = "Arial";
+
+    static CoverPageGenerator()
+    {
+        // PdfSharp 6.1 kräver en explicit resolver för namngivna typsnitt.
+        GlobalFontSettings.FontResolver ??= new WindowsFontResolver();
+    }
+
     public static void Generate(string outputPath, CoverInfo info)
     {
-        // PdfSharp 6.1 har en inbyggd font-resolver för Windows – ingen manuell
-        // GlobalFontSettings.FontResolver behövs så länge appen är Windows-only.
         var doc = new Document();
         doc.Info.Title = "Remiss – försättsblad";

+        var normal = doc.Styles["Normal"]!;
+        normal.Font.Name = SansFont;
+        normal.Font.Size = 12;
+        normal.Font.Color = Colors.Black;
+
         var section = doc.AddSection();
         section.PageSetup.PageFormat = PageFormat.A4;
-        section.PageSetup.TopMargin = Unit.FromCentimeter(3);
+        section.PageSetup.TopMargin = Unit.FromCentimeter(2.5);
         section.PageSetup.LeftMargin = Unit.FromCentimeter(2.5);
         section.PageSetup.RightMargin = Unit.FromCentimeter(2.5);

-        var heading = section.AddParagraph("REMISS");
-        heading.Format.Font.Size = 42;
+        // Ankare: dokumenttyp, stort och fetstilt.
+        var heading = section.AddParagraph(OcrContract.DocumentMarker);
+        heading.Format.Font.Name = SansFont;
+        heading.Format.Font.Size = 48;
         heading.Format.Font.Bold = true;
-        heading.Format.SpaceAfter = Unit.FromCentimeter(1.5);
-
-        var to = section.AddParagraph();
-        to.Format.Font.Size = 16;
-        to.Format.SpaceAfter = Unit.FromCentimeter(2);
-        to.AddText("Till mottagning:");
-        to.AddLineBreak();
-        var name = to.AddFormattedText(info.MottagningNamn, TextFormat.Bold);
-        name.Font.Size = 22;
-        to.AddLineBreak();
-        to.AddText($"HSA-id: {info.HsaId}");
+        heading.Format.SpaceAfter = Unit.FromCentimeter(1.4);
+
+        // Maskinläsbar rad: egen rad, monospace, stor, svart på vitt, mycket luft.
+        var machine = section.AddParagraph(OcrContract.FormatHsaIdLine(info.HsaId));
+        machine.Format.Font.Name = MonospaceFont;
+        machine.Format.Font.Size = 20;
+        machine.Format.Font.Bold = true;
+        machine.Format.SpaceBefore = Unit.FromCentimeter(0.8);
+        machine.Format.SpaceAfter = Unit.FromCentimeter(0.8);
+
+        // Människoläsbar kontext – används inte av parsern.
+        var human = section.AddParagraph();
+        human.Format.Font.Size = 14;
+        human.Format.SpaceBefore = Unit.FromCentimeter(1.2);
+        human.AddText("Mottagning: ");
+        human.AddFormattedText(info.MottagningNamn, TextFormat.Bold);

         var note = section.AddParagraph("Remiss bifogad som sida 2.");
-        note.Format.Font.Size = 20;
+        note.Format.Font.Size = 16;
         note.Format.Font.Bold = true;
+        note.Format.SpaceBefore = Unit.FromCentimeter(1.5);

         var foot = section.AddParagraph($"Försättsblad genererat {info.Skapad:yyyy-MM-dd HH:mm}");
         foot.Format.Font.Size = 9;
-        foot.Format.SpaceBefore = Unit.FromCentimeter(3);
+        foot.Format.SpaceBefore = Unit.FromCentimeter(4);

         var renderer = new PdfDocumentRenderer { Document = doc };
         renderer.RenderDocument();
diff --git a/src/Remiss.Core/Pdf/OcrContract.cs b/src/Remiss.Core/Pdf/OcrContract.cs
new file mode 100644
index 0000000..6328b4f
--- /dev/null
+++ b/src/Remiss.Core/Pdf/OcrContract.cs
@@ -0,0 +1,49 @@
+using System.Text.RegularExpressions;
+
+namespace Remiss.Core.Pdf;
+
+/// <summary>
+/// Kontrakt (v1) för det maskinläsbara försättsbladet. Vi äger formatet.
+///
+/// Sida 1 i den sammanfogade PDF:en innehåller:
+///   • ordet <see cref="DocumentMarker"/> stort – ankare för "det här är en remiss"
+///   • en egen rad enligt <see cref="FormatHsaIdLine"/>: nyckelordet
+///     <see cref="HsaIdLabel"/> följt av HSA-id:t, i monospace, överst till vänster
+///
+/// Nedströms server-OCR läser sida 1 och kör <see cref="TryParseHsaId"/> på texten.
+/// Parsern är avsiktligt tolerant mot vanliga OCR-fel (tappat bindestreck/kolon,
+/// blandade versaler/gemener, extra blanksteg).
+/// </summary>
+public static class OcrContract
+{
+    public const string DocumentMarker = "REMISS";
+    public const string HsaIdLabel = "HSA-ID:";
+
+    /// <summary>Exakt den rad som ska renderas maskinläsbart på försättsbladet.</summary>
+    public static string FormatHsaIdLine(string hsaId) => $"{HsaIdLabel} {hsaId.Trim()}";
+
+    // Etiketten följt av id:t på samma rad. Tål tappat bindestreck/kolon och
+    // enstaka blanksteg som OCR lagt in i id:t – men inte radbrytningar, så
+    // matchningen inte rinner vidare in i människoläsbar text under.
+    private static readonly Regex HsaIdPattern = new(
+        @"HSA[-\s]?ID[:\s]*([A-Za-z0-9][A-Za-z0-9 \-]{2,}[A-Za-z0-9])",
+        RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled);
+
+    /// <summary>
+    /// Plockar ut HSA-id ur (ev. brusig) OCR-text från sida 1.
+    /// Blanksteg som OCR:n råkat lägga in mitt i id:t tas bort.
+    /// </summary>
+    public static bool TryParseHsaId(string ocrText, out string hsaId)
+    {
+        hsaId = "";
+        if (string.IsNullOrWhiteSpace(ocrText))
+            return false;
+
+        var match = HsaIdPattern.Match(ocrText);
+        if (!match.Success)
+            return false;
+
+        hsaId = Regex.Replace(match.Groups[1].Value, @"\s+", "");
+        return hsaId.Length > 0;
+    }
+}
diff --git a/src/Remiss.Core/Pdf/WindowsFontResolver.cs b/src/Remiss.Core/Pdf/WindowsFontResolver.cs
new file mode 100644
index 0000000..3010970
--- /dev/null
+++ b/src/Remiss.Core/Pdf/WindowsFontResolver.cs
@@ -0,0 +1,38 @@
+using PdfSharp.Fonts;
+
+namespace Remiss.Core.Pdf;
+
+/// <summary>
+/// Minimal font-resolver för Windows. PdfSharp 6.1 resolvar inte namngivna
+/// typsnitt automatiskt – vi pekar ut TTF-filerna i C:\Windows\Fonts själva.
+/// Appen är Windows-only, så det räcker gott.
+///
+/// Endast de typsnitt försättsbladet använder stöds; allt annat faller tillbaka
+/// på Arial. (Remissen sida 2 renderas aldrig av oss – den importeras bara.)
+/// </summary>
+internal sealed class WindowsFontResolver : IFontResolver
+{
+    private static readonly string FontDir = Environment.GetFolderPath(Environment.SpecialFolder.Fonts);
+    private readonly Dictionary<string, byte[]> _cache = new(StringComparer.OrdinalIgnoreCase);
+
+    public FontResolverInfo ResolveTypeface(string familyName, bool isBold, bool isItalic)
+    {
+        var isConsolas = familyName.Trim().Equals("Consolas", StringComparison.OrdinalIgnoreCase);
+
+        var face = isConsolas
+            ? "consola" + (isBold, isItalic) switch { (true, true) => "z", (true, false) => "b", (false, true) => "i", _ => "" }
+            : "arial" + (isBold, isItalic) switch { (true, true) => "bi", (true, false) => "bd", (false, true) => "i", _ => "" };
+
+        return new FontResolverInfo(face);
+    }
+
+    public byte[] GetFont(string faceName)
+    {
+        if (_cache.TryGetValue(faceName, out var cached))
+            return cached;
+
+        var bytes = File.ReadAllBytes(Path.Combine(FontDir, faceName + ".ttf"));
+        _cache[faceName] = bytes;
+        return bytes;
+    }
+}
diff --git a/tests/Remiss.Core.Tests/OcrContractTests.cs b/tests/Remiss.Core.Tests/OcrContractTests.cs
new file mode 100644
index 0000000..8a4f3b6
--- /dev/null
+++ b/tests/Remiss.Core.Tests/OcrContractTests.cs
@@ -0,0 +1,42 @@
+using Remiss.Core.Pdf;
+using Xunit;
+
+namespace Remiss.Core.Tests;
+
+public sealed class OcrContractTests
+{
+    private const string Id = "SE2321000131-E000000000001";
+
+    [Fact]
+    public void FormatHsaIdLine_usesTheAgreedLabel()
+    {
+        Assert.Equal("HSA-ID: " + Id, OcrContract.FormatHsaIdLine("  " + Id + "  "));
+    }
+
+    [Fact]
+    public void TryParseHsaId_roundTripsTheRenderedLine()
+    {
+        Assert.True(OcrContract.TryParseHsaId(OcrContract.FormatHsaIdLine(Id), out var parsed));
+        Assert.Equal(Id, parsed);
+    }
+
+    [Theory]
+    [InlineData("REMISS\nHSA-ID: SE2321000131-E000000000001\nMottagning: Hud SU", "SE2321000131-E000000000001")]
+    [InlineData("hsa id  SE2321000131-E000000000001", "SE2321000131-E000000000001")]          // tappat kolon + bindestreck
+    [InlineData("HSA-ID: SE2321000131 - E000000000001", "SE2321000131-E000000000001")]         // OCR-blanksteg i id:t
+    [InlineData("brus...\r\n  HSA-ID:SE2321000131-E000000000001  \r\nmer brus", "SE2321000131-E000000000001")]
+    public void TryParseHsaId_toleratesCommonOcrNoise(string ocrText, string expected)
+    {
+        Assert.True(OcrContract.TryParseHsaId(ocrText, out var parsed));
+        Assert.Equal(expected, parsed);
+    }
+
+    [Theory]
+    [InlineData("")]
+    [InlineData("En helt vanlig textrad utan id")]
+    [InlineData("HSA-ID:")]
+    public void TryParseHsaId_returnsFalseWhenNothingToFind(string ocrText)
+    {
+        Assert.False(OcrContract.TryParseHsaId(ocrText, out _));
+    }
+}
diff --git a/tools/Remiss.CoverPreview/Program.cs b/tools/Remiss.CoverPreview/Program.cs
new file mode 100644
index 0000000..5526f21
--- /dev/null
+++ b/tools/Remiss.CoverPreview/Program.cs
@@ -0,0 +1,19 @@
+using Remiss.Core.Pdf;
+
+// Dev-verktyg: renderar ett försättsblad så man kan öga-testa OCR-layouten
+// utan hela utskriftsflödet.
+//
+//   dotnet run --project tools/Remiss.CoverPreview -- [utfil.pdf] [HSA-id] [mottagningsnamn]
+
+var outPath = args.ElementAtOrDefault(0) ?? Path.Combine(Environment.CurrentDirectory, "forsattsblad-preview.pdf");
+var hsaId = args.ElementAtOrDefault(1) ?? "SE2321000131-E000000000001";
+var namn = args.ElementAtOrDefault(2) ?? "Hudmottagningen Sahlgrenska";
+
+CoverPageGenerator.Generate(outPath, new CoverInfo(namn, hsaId, DateTime.Now));
+
+Console.WriteLine($"Skrev {outPath}");
+Console.WriteLine($"Maskinläsbar rad: {OcrContract.FormatHsaIdLine(hsaId)}");
+
+// Sanity: läs tillbaka som om det vore OCR-text.
+if (OcrContract.TryParseHsaId(OcrContract.FormatHsaIdLine(hsaId), out var parsed))
+    Console.WriteLine($"Parser läser tillbaka: {parsed}  ({(parsed == hsaId ? "OK" : "AVVIKER")})");
diff --git a/tools/Remiss.CoverPreview/Remiss.CoverPreview.csproj b/tools/Remiss.CoverPreview/Remiss.CoverPreview.csproj
new file mode 100644
index 0000000..45510d4
--- /dev/null
+++ b/tools/Remiss.CoverPreview/Remiss.CoverPreview.csproj
@@ -0,0 +1,15 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+  <PropertyGroup>
+    <OutputType>Exe</OutputType>
+    <TargetFramework>net8.0-windows</TargetFramework>
+    <Nullable>enable</Nullable>
+    <ImplicitUsings>enable</ImplicitUsings>
+    <RootNamespace>Remiss.CoverPreview</RootNamespace>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <ProjectReference Include="..\..\src\Remiss.Core\Remiss.Core.csproj" />
+  </ItemGroup>
+
+</Project>