Henara.Scan.Client 0.5.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.
  • Extracts the front page of either form with Mistral OCR or the Henara (Azure) OCR, both in one shape, so a page read by either compares 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
builder.Services.AddScanClient(builder.Configuration);

Binds from the configuration root, so environment variables alone are enough. Returns the IHttpClientBuilder:

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

Configuration

Variable Required Default Meaning
SCAN_SERVICE_URL yes Base address of the scan service. https:// or http://.
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.

Validated at host start, so a missing address or token fails there rather than on the first call.

Calling

One method per form. Every method returns Result<TValue, ScanError> and none throws: a rejected request, a failed call, a cancellation and an unexpected error all come back as the error side.

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

IsAutocropEnabled is off by default and the service never overrides it. Leave it off for images that were cropped already — warping twice degrades them.

Crop one page

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.

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

Muster13Reading.Front and .Back carry every field of the sheet they name, and are Option.None for a sheet the read produced nothing for.

ReadMuster13Pdf takes a PDF of one page or two, and carries neither crop option.

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

Read a Muster 56

ReadMuster56Images and ReadMuster56Pdf, returning Muster56Reading. The form declares no regions, 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)],
        new ReadOutputs(RenderTargets.None, false, false),
        IsAutocropEnabled: true),
    cancellationToken);

Read a front page with either engine

The only two calls that take an engine, and the only ones that read with OcrEngine.Azure. Exactly one image.

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

ActScanFrontPage.Fields is the act-scan shape, so a Muster 56 read this way answers only with the fields the two forms share — not sport type, the units-and-months scope or the illness list. Use ReadMuster56Images for those.

ActScanFrontPage.MistralOnly is filled only after an OcrEngine.Mistral read: 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

The one call that answers with the annotation rather than typed fields; nothing is cropped or rendered. Several images are composed into one PDF 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 add the engine's own annotation and response body; they are diagnostics. Render decides whether the filled form comes back as PDF, HTML or not at all.

A closed set on the paper is an enum: Copayment, RemedyField, TherapyChange, ApplicationType, SportType, RehabScope, FunctionalTrainingScope, TrainingForm, Illness, HeartGroup, PrescriptionKind, FollowUpReason, WeeklyFrequency, PayerRehabScope.

A field the read did not fill is an Option<T> carrying nothing, never null; a list it did not fill is empty.

if (front.PatientName.TryGetValue(out var name))
    Console.WriteLine(name);

var city = front.PatientCity.Or("unknown");

var age = front.PatientBirthDate.Match(born => Years(born), () => -1);

Or(fallback), Map, Then, HasValue, and ToString() for the value or an empty string.

Dates are DateOnly, whether the Vordruck prints TT.MM.JJJJ or the row of single boxes captioned T T M M J J; a two-digit year is read as this century. Muster56Back.ParticipatingSince is a YearMonth — its boxes are captioned M M J J and carry no day.

The three printed remedy rows of a Muster 13 are IReadOnlyList<PrescribedRemedy>. The supplementary remedy is not one of them: it has AdditionalRemedy and AdditionalRemedyUnits.

The receipt table on the back is 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 ScanErrorCode, a message, and a ScanEngineNone when no upstream is named. Requests the client refuses on its own, such as an empty image or two images naming the same side, never reach the service.

ScanErrorCode What happened What to do
InvalidRequest The request was malformed or unusable. Fix the request; retrying will not help.
Unreadable The bytes were a kind the service accepts, but nothing usable came out. Rescan the page.
Unauthenticated The token was missing, wrong or not permitted. Fix SCAN_API_TOKEN.
Unavailable The service, or something it needs to answer, could not be reached. Retry.
Timeout The call ran past its deadline. Retry, or raise SCAN_DEADLINE_SECONDS.
Upstream The service reached an OCR engine and that engine failed. Retry; check Engine.
Internal The service itself failed. Report it; retrying will not help.
Cancelled The call was cancelled.
Unknown Anything else.

Other languages

protos/scan.proto ships inside the package. The gRPC status carries the class of failure — FailedPrecondition is Unreadable — and the scan-error-code and scan-upstream trailers carry the exact code and engine.

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