Henara.Scan.Client 0.3.0

Henara.Scan.Client

Reads German medical act scans (Muster 13, Muster 56):

  • Detects the page in a photo or scan, then crops and deskews it.
  • Combines several pages into one PDF.
  • Extracts every field of both sheets as typed objects — no JSON parsing on your side.
  • Extracts the front page with Mistral OCR or the Henara (Azure) OCR. Both engines answer in one shape, so a front page read by either can be compared field by field.
  • Cuts the form's declared regions — the doctor's stamp, the receipt-table signatures — out as images.
  • Renders the filled form back as PDF or HTML.
  • Reads arbitrary images against an annotation schema of your own.

Setup

dotnet add package Henara.Scan.Client
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddScanClient(builder.Configuration);

AddScanClient binds from the configuration root, so plain environment variables are enough. It returns the underlying IHttpClientBuilder, so a caller can add a message handler of its own:

builder.Services.AddScanClient(builder.Configuration)
    .AddHttpMessageHandler<MyTracingHandler>();

Configuration

Variable Required Default Meaning
SCAN_SERVICE_URL yes Base address of the scan service.
SCAN_API_TOKEN yes Sent as a bearer token on every call.
SCAN_DEADLINE_SECONDS no 300 Deadline applied to every call. 1 to 3600.
SCAN_MAX_MESSAGE_BYTES no 67108864 Largest request or response message.

Settings are validated when the host starts, so a missing address or token fails at startup rather than on the first call. SCAN_SERVICE_URL takes either an https:// or an http:// address; the token is sent on both.

Calling

There is one method per form, so nothing carries a form parameter and no response has to be narrowed to the form you sent. Every method returns a Result<TValue, ScanError>. No method throws: a rejected request, a failed call, a cancellation and an unexpected error all come back as the error side.

Resolve it with Match:

var message = (await scan.Crop(document, cancellationToken)).Match(
    page => Save(page.Content, page.ContentType),
    error => $"{error.Code}: {error.Message}");

Or take it apart with HasValue to return early:

if (!(await scan.Crop(document, cancellationToken)).HasValue(out var page, out var error))
    return error;

Crop one page

Detects the page in an image, then crops and deskews it. An image no page could be detected in comes back as it was given.

var cropped = await scan.Crop(scannedFront, cancellationToken);

Combine pages into a PDF

One image per page, in page order, at least two.

var pdf = await scan.Combine([front, back], isAutocropEnabled: true, cancellationToken);

Read a Muster 13

One image reads that side alone against its own schema; two are read as one document spanning both sides. Leave IsAutocropEnabled off for images that were cropped already — warping twice degrades them.

var reading = await scan.ReadMuster13Images(
    new Muster13ImageReadRequest(
        [new SheetImage(SheetSide.Front, front), new SheetImage(SheetSide.Back, back)],
        IsAutocropEnabled: true,
        IsRegionCropEnabled: true,
        new ReadOutputs(RenderTargets.Pdf | RenderTargets.Html, false, false)),
    cancellationToken);

Muster13Reading.Front and .Back carry every field of the sheet they name, and are null for a sheet the read produced nothing for. The back page alone is the same call with one image:

var reading = await scan.ReadMuster13Images(
    new Muster13ImageReadRequest(
        [new SheetImage(SheetSide.Back, back)],
        IsAutocropEnabled: false,
        IsRegionCropEnabled: true,
        new ReadOutputs(RenderTargets.None, false, false)),
    cancellationToken);

A Muster 13 that is already a PDF takes ReadMuster13Pdf. A PDF has no frame to detect a page in and no sheet to cut a rectangle out of, so that request carries neither crop option. One page or exactly two.

var reading = await scan.ReadMuster13Pdf(
    new PdfReadRequest(pdf, new ReadOutputs(RenderTargets.None, false, false)),
    cancellationToken);

Read a Muster 56

The same two calls, ReadMuster56Images and ReadMuster56Pdf, returning Muster56Reading. The form declares no regions to cut out, so its image request carries no region-crop option.

var reading = await scan.ReadMuster56Images(
    new Muster56ImageReadRequest(
        [new SheetImage(SheetSide.Front, front), new SheetImage(SheetSide.Back, back)],
        IsAutocropEnabled: true,
        new ReadOutputs(RenderTargets.None, false, false)),
    cancellationToken);

Read a Muster 13 front page with either engine

Both engines answer in one shape, so the same call compares them. Exactly one image. OcrEngine.Mistral always crops and overrides an autocrop left off. Only this call reads with OcrEngine.Azure, and only a Muster 13: the Henara OCR does not read a Muster 56.

var frontPage = await scan.ReadMuster13FrontPage(
    OcrEngine.Azure,
    new FrontPageReadRequest(front, IsAutocropEnabled: false, new ReadOutputs(RenderTargets.None, false, false)),
    cancellationToken);

ActScanFrontPage.Fields is the shape both engines share. ActScanFrontPage.MistralOnly is filled only after an OcrEngine.Mistral read; it carries what Azure does not report at all, and the full values behind the single-valued fields — every ICD-10 code, every remedy, every treatment count.

Read against a schema of your own

No known form stands behind such a read, so it is the one call that answers with the annotation rather than typed fields, and nothing is cropped out or rendered. Several images are composed into one PDF by the service and read as a single document.

var reading = await scan.ReadImagesWithSchemaWithMistral(
    new SchemaReadRequest(schemaJson, [page1, page2], IsAutocropEnabled: true),
    cancellationToken);

What comes back

The typed fields are always there. IsDocumentAnnotationRequested and IsRawJsonRequested decide whether the engine's own annotation and response body come back as well — they are diagnostics, and nothing you need depends on them. Render decides whether the filled form comes back as a PDF, as HTML, or not at all. Asking for less is a smaller message.

A closed set on the paper is an enum here, never a string: Copayment, RemedyField, TherapyChange, ApplicationType, SportType, RehabScope, FunctionalTrainingScope, TrainingForm, Illness, HeartGroup, PrescriptionKind, FollowUpReason, WeeklyFrequency, PayerRehabScope. A field the read did not fill is null; a list it did not fill is empty.

Every date is a DateOnly, whichever way the Vordruck prints it — written out as TT.MM.JJJJ, or as the row of single boxes captioned T T M M J J that reaches the engine as six bare digits. A two-digit year is read as this century. The one field that is not a date is Muster56Back.ParticipatingSince: its four boxes are captioned M M J J and carry no day, so it is a YearMonth and no day is invented for it.

The three printed remedy rows of a Muster 13 arrive as IReadOnlyList<PrescribedRemedy>, each row carrying its own remedy and treatment units. The supplementary remedy is not one of them — it has AdditionalRemedy and AdditionalRemedyUnits of its own, exactly as the form prints it.

The receipt table on the back of a Muster 13 arrives as IReadOnlyList<ReceiptRow>, its three columns already zipped by row, so a row left blank in one column still lines up with the others.

Errors

ScanError carries a ScanErrorCodeInvalidRequest, Unauthenticated, Unavailable, Timeout, Upstream, Cancelled, Unknown — a message, and the upstream engine's name when the failure came from one. Requests the client can refuse on its own, such as an empty image or two images naming the same side, never reach the service.

Other languages

protos/scan.proto ships inside the package.

No packages depend on Henara.Scan.Client.

Version Downloads Last updated
0.10.0 5 09/06/2026
0.9.0 3 09/06/2026
0.8.0 13 09/05/2026
0.7.0 10 09/05/2026
0.6.0 33 08/29/2026
0.5.0 7 08/29/2026
0.4.0 1 08/28/2026
0.3.0 6 08/26/2026
0.2.0 1 08/26/2026