Showing posts with label .net. Show all posts
Showing posts with label .net. Show all posts

Saturday, 1 August 2026

Kartverket Matrikkel Eiendoms API with MAUI

This article will show how looking up data from Eiendoms API (v1) from Kartverket. The data is retrieved from Matrikkelen, the official registry of properties and land parcels in Norway. The demo will show using a MAUI app and the source code with the demo is available in my Github repository to be cloned from here :


Screenshots

Clicking on a property in the map will show the matrikkelen information for the first hit of the properties around the clicked point.

Using Kartverket's Eiendom API v1

The Eiendom API offers basic Eiendomsinformasjon, property information. A Swagger page and the endpoint of the API is available here:

The service, that is a client to contact and retrieved the Eiendomsinformation for a given point that uses the /punkt API method is shown below. Please note that the json naming dictates we must set the Property naming policy to camelCase, as we can see from the JsonProperty naming convention used further down in the Json POCO classes shown in KartverketResponse.cs code snippet.

KartverketService.cs


using System.Globalization;
using System.Net.Http.Json;
using System.Text.Json;

namespace MauiMapAppDemo.Services
{

    public class KartverketService
    {

        private const string _apiKartverketEiendomV1BaseUrl = "https://api.kartverket.no/eiendom/v1/";

        private readonly HttpClient _httpClient = new HttpClient
        {
            BaseAddress = new Uri(_apiKartverketEiendomV1BaseUrl)
        };

        /// <summary>
        /// Retrieves matrikkel informasjon from given point (Punkt) from Kartverket's Eiendom API v1
        /// </summary>
        /// <param name="latitude">Latitude</param>
        /// <param name="longitude">Longitude</param>
        /// <param name="koordSys">Defaulting here to EUREF89 = 4258 as the coordinate system id, which Kartverket uses and is also what Google maps coords are using</param>
        /// <returns></returns>
        public async Task<KartverketPunktResponse?> GetMatrikkelInformationFromLocationAsync(double latitude, double longitude, int koordSys = 4258)
        {
            string url = $"punkt?ost={longitude.ToString(CultureInfo.InvariantCulture)}&nord={latitude.ToString(CultureInfo.InvariantCulture)}&koordsys={koordSys}&radius=10&utkoordsys={koordSys}&treffPerSide=1&side=1";

            var kartverketResponseForLocation = await _httpClient.GetFromJsonAsync<KartverketPunktResponse>(url, options: new System.Text.Json.JsonSerializerOptions
            {
                PropertyNamingPolicy = JsonNamingPolicy.CamelCase
            });
            return kartverketResponseForLocation;
        }

    }
}

About the choice of EUREF 89 = 4258 as the default coordinate system in the call above. Google maps uses actually WGS 84 which is EPSG coordinate system id 4326, but the difference is less than a meter. Since a 10 meter radius of the clicked point is used, we pass in the clicked Location point's latitude and longitude to the north and east parameters in the url call above in the KartverketService.cs sample code shown, with a given radius of 10 and although we get a result of multiple hits within the 10 meter radius, the first if any of properties or parcels found is returned from the service, by setting the side and treffPerSide parameters.

🌍 What are EUREF89 and EPSG?

🗺️ EUREF89 is a geographic coordinate reference system used across Europe, including by Kartverket in Norway. It defines how latitude and longitude positions are represented on the Earth's surface.

🔢 EPSG:4258 is the unique identifier for the EUREF89 coordinate system. EPSG codes are standardized reference numbers that mapping software, APIs, and GIS tools use to identify coordinate systems.

📍 In this project, coordinates are obtained from Google Maps and sent to Kartverket's Eiendom API v1 using EUREF89 (EPSG:4258). Using the same coordinate reference system helps ensure accurate property lookups in the Norwegian Matrikkel.

🏠 The result is that a location selected on the map can be translated directly into property and cadastral information from Kartverket.

The following Json classes will map the returned Json data from the service to objects. To create these classes, I just copied example output response from the API method /punkt and used Paste Special => Paste Json as classes in VS 2026.

KartverketPunktResponse.cs


using System.Text;
using System.Text.Json.Serialization;

namespace MauiMapAppDemo.Services
{
    public class KartverketPunktResponse
    {
        [JsonPropertyName("eiendom")]
        public List<Punkt>? Eiendom { get; set; }

        [JsonPropertyName("metadata")]
        public Metadata? Metadata { get; set; }
    }

    public class Metadata
    {
        [JsonPropertyName("side")]
        public int Side { get; set; }

        [JsonPropertyName("sokeStreng")]
        public string? SokeStreng { get; set; }

        [JsonPropertyName("totaltAntallTreff")]
        public int TotaltAntallTreff { get; set; }

        [JsonPropertyName("treffPerSide")]
        public int TreffPerSide { get; set; }

        [JsonPropertyName("viserFra")]
        public int ViserFra { get; set; }

        [JsonPropertyName("viserTil")]
        public int ViserTil { get; set; }
    }

    public class Punkt
    {
        [JsonPropertyName("bruksnummer")]
        public int Bruksnummer { get; set; }

        [JsonPropertyName("festenummer")]
        public int Festenummer { get; set; }

        [JsonPropertyName("gardsnummer")]
        public int Gardsnummer { get; set; }

        [JsonPropertyName("hovedområde")]
        public bool HovedOmrade { get; set; }

        [JsonPropertyName("kommunenummer")]
        public string? Kommunenummer { get; set; }

        [JsonPropertyName("lokalid")]
        public int Lokalid { get; set; }

        [JsonPropertyName("matrikkelnummertekst")]
        public string? Matrikkelnummertekst { get; set; }

        [JsonPropertyName("meterFraPunkt")]
        public int MeterFraPunkt { get; set; }

        [JsonPropertyName("nøyaktighetsklasseteig")]
        public string? Noyaktighetsklasseteig { get; set; }

        [JsonPropertyName("objekttype")]
        public string? Objekttype { get; set; }

        [JsonPropertyName("oppdateringsdato")]
        public DateTime? Oppdateringsdato { get; set; }

        [JsonPropertyName("representasjonspunkt")]
        public Representasjonspunkt? Representasjonspunkt { get; set; }

        [JsonPropertyName("seksjonsnummer")]
        public int Seksjonsnummer { get; set; }

        [JsonPropertyName("teigmedflerematrikkelenheter")]
        public bool Teigmedflerematrikkelenheter { get; set; }

        [JsonPropertyName("uregistrertjordsameie")]
        public bool Uregistrertjordsameie { get; set; }

        public string FullstendigMatrikkelNummer
        {
            get
            {
                var sb = new StringBuilder();
                if (!string.IsNullOrWhiteSpace(Kommunenummer))
                {
                    sb.Append(Kommunenummer?.ToString().PadLeft(4, '0'));
                }
                if (Gardsnummer > 0)
                {
                    sb.Append("/" + Gardsnummer);
                }
                if (Bruksnummer > 0)
                {
                    sb.Append("/" + Bruksnummer);
                }
                if (Festenummer > 0)
                {
                    sb.Append("/" + Festenummer);
                }
                if (Seksjonsnummer > 0)
                {
                    sb.Append("/" + Seksjonsnummer);
                }

                return sb.ToString();
            }
        }
    }

    public class Representasjonspunkt
    {
        [JsonPropertyName("koordsys")]
        public int Koordsys { get; set; }

        [JsonPropertyName("nord")]
        public double Nord { get; set; }

        [JsonPropertyName("øst")]
        public double Ost { get; set; }
    }

}



Groundwork on the parts of property information for Fullstendig matrikkelnummer

🏠 Components of a Full Matrikkel Number

Icon Component Abbreviation Example Description
🏛️ Municipality Number Knr. 5001 Identifies the municipality where the property is located. An overview of Kommunenummer can be seen here : https://www.kartverket.no/til-lands/fakta-om-norge/norske-fylke-og-kommunar
🌾 Farm Number - Gårdnummer / Gardsnummer Gnr. 138 Identifies the original farm or cadastral area within the municipality.
🏠 Usage Number / Bruksnummer Bnr. 4515 Identifies a specific property or parcel within a farm number.
📜 Leasehold Number / Festenummer Fnr. 7 Used when the property is a leased parcel of land (festetomt). The festetomt / lease agreements typically lasts very long, in the area of 80-100 years typically and can be renewed.
🏢 Section Number Snr. 12 Identifies a specific section or apartment within a sectioned property.

📋 Example

5001 / 138 / 4515 / 7 / 12

Note that the first three numbers are ALWAYS set for all properties in Norway. That is Kommunenummer, Gårdsnummer (Gardsnummer) and Bruksnummer. And some properties also are leased, so they got a lease number or sectioned too. The numbers are read left to right in descending order of the overview shown above.
Value Meaning
🏛️ 5001 Municipality Number
🌾 138 Farm Number (Gårdsnummer)
🏠 4515 Usage Number (Bruksnummer)
📜 7 Leasehold Number (Festenummer)
🏢 12 Section Number (Seksjonsnummer)

💡 A full matrikkel number is built in the following order:

🏛️ Municipality Number → 🌾 Farm Number → 🏠 Usage Number → (📜 Leasehold Number → 🏢 Section Number )



📐 Getting Property Boundary Area Information (Grenseinformasjon)

You can retrieve boundary information for a selected property using the /punkt/omrader endpoint in Eiendoms API (v1).

The API returns a GeoJSON geometry describing the property's boundaries. The coordinates are provided as latitude and longitude values, so they must be projected into a metric coordinate system before calculating the property's area.

🗺️ In a GeoJSON polygon, the coordinates are returned as a multi-dimensional array:

  • ✅ The first array represents the property's outer boundary.
  • ➖ Any additional arrays represent holes or excluded areas within the property.

📏 To calculate the total area:

  1. Calculate the area of the outer boundary polygon.
  2. Subtract the area of each interior polygon (hole).

💡 This follows the standard GeoJSON polygon specification and ensures that any gaps within the property are excluded from the final area calculation.

The following screenshots shows selecting a property for testing out the precision of the area calculations using of a polygon of property's boundary using Eiendoms API v1.
As the screenshots show, the demo shows an agreement with Trondheimskart solution, which is the property map that Trondheim kommune is using : The calculated area in Trondheimskartet is 1125 square metres and the app calculate it to 1126 square metres.

🗺️ Area and polygon overview

This note explains how the app turns Kartverket omrade data into a visible polygon and a simple area summary. The goal is to keep the UI readable while still showing the geometry work behind the scenes.

The flow is: tap a point, fetch Kartverket data, build a closed polygon from the returned coordinates, and show the area in square metres. The main page binds the polygon path and the area label separately so the overlay and the text can update independently.

📐 Area calculation

The shared geometry helper uses the shoelace formula. If the incoming ring is still in WGS84 degrees, it first transforms the coordinates to UTM Zone 33N so the result comes out in square metres. The shoelace formulae is also known as Gauss area formula and is visually described with an example here from University of Waterloo in Canada great article:

Note that the code below uses Nuget package ProjNet
using ProjNet.CoordinateSystems;
using ProjNet.CoordinateSystems.Transformations;

namespace MauiMapAppDemo.Services
{
    public static class GeometryUtils
    {
        public static double CalculatePolygonArea(double[][] ring, bool transformToMetricCoordinateSystem = true)
        {
            if (ring == null || ring.Length < 3)
            {
                throw new ArgumentException("Polygon must contain at least three points.", nameof(ring));
            }

            var projectedRing = new double[ring.Length][];

            if (transformToMetricCoordinateSystem)
            {
                var ctFactory = new CoordinateTransformationFactory();
                var wgs84 = GeographicCoordinateSystem.WGS84;
                var utm33 = ProjectedCoordinateSystem.WGS84_UTM(33, true);
                var transform = ctFactory.CreateFromCoordinateSystems(wgs84, utm33);

                for (int i = 0; i < ring.Length; i++)
                {
                    var projected = transform.MathTransform.Transform(ring[i][0], ring[i][1]);
                    projectedRing[i] = new[] { projected.x, projected.y };
                }
            }

            double area = 0;

            for (int i = 0; i < projectedRing.Length; i++)
            {
                int next = (i + 1) % projectedRing.Length;
                area += projectedRing[i][0] * projectedRing[next][1];
                area -= projectedRing[next][0] * projectedRing[i][1];
            }

            return Math.Abs(area) / 2.0;
        }

        public static double CalculateTotalArea(double[][][] coordinates, bool transformToMetricCoordinateSystem = true)
        {
            if (coordinates == null || coordinates.Length == 0)
            {
                return 0;
            }

            double totalArea = 0;

            for (int ringIndex = 0; ringIndex < coordinates.Length; ringIndex++)
            {
                var ringArea = CalculatePolygonArea(coordinates[ringIndex], transformToMetricCoordinateSystem);

                if (ringIndex == 0)
                {
                    totalArea += ringArea;
                }
                else
                {
                    totalArea -= ringArea;
                }
            }

            return totalArea;
        }
    }
}

For GeoJSON, the first ring is the outer boundary and later rings are holes. That matches how Kartverket polygons are usually interpreted when the app calculates total area from an omrade response.

🛰️ Kartverket omrade fetch

The service builds the omrader URL from the clicked latitude and longitude, then deserializes the response with a shared camel-case serializer option. The resulting geometry is what the app later converts into a polygon overlay.

public async Task<KartverketOmraadeResponse?> GetGeoJsonFromLocationAsync(double latitude, double longitude, int koordSys = 4258)
{
    string url = $"punkt/omrader?ost={longitude.ToString(CultureInfo.InvariantCulture)}&nord={latitude.ToString(CultureInfo.InvariantCulture)}&koordsys={koordSys}&radius=10&utkoordsys={koordSys}&treffPerSide=1&side=1";

    var kartverketResponseForLocation = await _httpClient.GetFromJsonAsync<KartverketOmraadeResponse>(url, options: s_camelCaseJsonOptions);
    return kartverketResponseForLocation;
}

🧾 Kartverket omrade response model

The response model is where the geometry turns into a calculated area value. The top-level object exposes features, and the area is derived from the nested polygon coordinates through a helper property.

public class KartverketOmraadeResponse
{
    [JsonPropertyName("features")]
    public Feature[] Features { get; set; }

    [JsonPropertyName("type")]
    public string Type { get; set; }

    public double? TotalAreaOfAllAreas
    {
        get
        {
            try
            {
                double totalArea = Features.Sum(f => GeometryUtils.CalculateTotalArea(f.Geometry.Coordinates));
                return totalArea;
            }
            catch (Exception)
            {
                return null;
            }
        }
    }
}

public class Geometry
{
    [JsonPropertyName("coordinates")]
    public double[][][] Coordinates { get; set; }

    [JsonPropertyName("type")]
    public string Type { get; set; }

    public double? TotalArea
    {
        get
        {
            try
            {
                double totalArea = GeometryUtils.CalculateTotalArea(Coordinates);
                return totalArea;
            }
            catch (Exception)
            {
                return null;
            }
        }
    }
}

public class Feature
{
    [JsonPropertyName("geometry")]
    public Geometry Geometry { get; set; }

    [JsonPropertyName("properties")]
    public Properties Properties { get; set; }

    [JsonPropertyName("type")]
    public string Type { get; set; }
}

The nice part here is that the area calculation is pushed into the model layer through read-only properties. That makes the popup logic simpler because it can ask for the total area instead of re-running geometry math in the UI layer. 📦

🧩 Polygon refresh behavior

The map behavior listens to the bound polygon path and rebuilds the overlay whenever the path changes. It clears the old polygon first, then adds the latest path back to the map.

private void RefreshMatrikkelPolygon()
{
    if (_map?.MapElements == null)
    {
        return;
    }

    ClearMatrikkelPolygon();

    if (!IsMatrikkelMode || !MatrikkelPolygonPath.Any())
    {
        return;
    }

    var polygon = new Polygon
    {
        StrokeColor = Colors.Red,
        StrokeWidth = 5,
        FillColor = Color.FromArgb("#22FF0000")
    };

    foreach (var location in MatrikkelPolygonPath)
    {
        polygon.Geopath.Add(location);
    }

    if (polygon.Geopath.Count < 3)
    {
        return;
    }

    var firstLocation = polygon.Geopath.First();
    var lastLocation = polygon.Geopath.Last();

    if (firstLocation.Latitude != lastLocation.Latitude || firstLocation.Longitude != lastLocation.Longitude)
    {
        polygon.Geopath.Add(firstLocation);
    }

    _matrikkelPolygon = polygon;
    _map.MapElements.Add(polygon);
}

That final closure step matters. If the last point does not match the first point, the behavior appends the first point again so the polygon is visually closed and the shape is easier to reason about. 🔁

📊 Area presentation

The viewmodel keeps the user-facing area text separate from the map geometry. When Kartverket returns a usable polygon, the app formats the result as square metres and also shows the same value in mål.

if (omraadeResponse?.TotalAreaOfAllAreas is double totalArea && totalArea > 0)
{
    MatrikkelAreaText = FormatMatrikkelAreaText(totalArea);
}
else
{
    MatrikkelAreaText = "Areal: ukjent";
}

private static string FormatMatrikkelAreaText(double areaSquareMetres)
{
    var maal = areaSquareMetres / 1000d;
    return $"Areal: {areaSquareMetres:N0} m² ({maal:N2} mål)";
}

The bound properties behind that flow are the polygon path and the area label. One drives the overlay, the other is what the user sees on the page.

public IEnumerable<Location> MatrikkelPolygonPath
{
    get => (IEnumerable<Location>)GetValue(MatrikkelPolygonPathProperty);
    set => SetValue(MatrikkelPolygonPathProperty, value);
}

public static readonly BindableProperty MatrikkelPolygonPathProperty =
    BindableProperty.Create(
        nameof(MatrikkelPolygonPath),
        typeof(Location[]),
        typeof(MapPinsBehavior),
        defaultValue: Array.Empty<Location>(),
        propertyChanged: OnMatrikkelPolygonPathChanged);

public static readonly BindableProperty IsMatrikkelModeProperty =
    BindableProperty.Create(
        nameof(IsMatrikkelMode),
        typeof(bool),
        typeof(MapPinsBehavior),
        false);

📱 Map page wiring

The map page binds the behavior directly. In this project the page is MapsDemo.xaml, and the snippet below shows the same behavior wiring that drives the map, measurement mode, and matrikkel polygon overlay.

<maps:Map Grid.Row="3"
          Margin="12"
          x:Name="MapCtrl"
          MapType="Street">
    <maps:Map.Behaviors>
        <behaviors:MapPinsBehavior
            BindingContext="{Binding Source={x:Reference MapCtrl}, Path=BindingContext}"
            Center="{Binding MapCenter}"
            PinItems="{Binding CabinPins}"
            IsMeasuringMode="{Binding IsMeasuringMode}"
            IsMatrikkelMode="{Binding IsMatrikkelMode}"
            MeasureStart="{Binding FirstLocationMeasureMode}"
            MatrikkelPolygonPath="{Binding MatrikkelPolygonPath}"
            MeasureEnd="{Binding SecondLocationMeasureMode}"
            MapClickedCommand="{Binding MapClickedCommand}"
            PinClickedCommand="{Binding PinClickedCommand}" />
    </maps:Map.Behaviors>
</maps:Map>

✨ Short takeaway

The app gets geometry from Kartverket, calculates area in a metric-friendly way, closes the polygon before drawing it, and keeps the user-facing square-metre text separate from the map overlay. That makes the map interaction clearer and easier to explain in a blog post.

Sunday, 21 December 2025

Finding out file extension from byte inspection

Consider a byte array stored in a column in a table in a database column. How can we identify the file extension of the byte array by inspecting the byte array itself?
Note that byte arrays could be saved many places, also within files or similar.
The extension of a file can be discovered by inspect the File header. This is the first bytes, usually the first tens or hundreds of bytes of the byte array and constitute the file header. Some extensions got multiple file headers. A best effort to identity byte contents of a column in a database.

Let's use Powershell to inspect a file on disk, a sample JPEG file (.jpg). Lets run the following little script:

format-hex .\Stavkyrkje_Røldal.jpg | Select-Object -First 16 The first few bytes are FF D8 FF
I have added a sample Github repo with utility code to check well-known file types for their file extensions.

https://github.com/toreaurstadboss/FileHeaderUtil

The following screenshot shows the application in use. It found out that a byte array seems to be a PDF file by looking at the file header and file trailer. A good match was found :


In fact, a very good match, since both the header and the trailer fully agrees. Note that the 0A bytes are just padding bytes at the end of files and ignored in this util. See the method NormalizeHex presented further below.

Using Gary Kessler`s assembled lists of known file headers and trailers for well-known file types

The util class below shows the helper methods that inspects a byte array and evalues the file header and file trailer against a list of known such headers and trailers.

It bases a compilation of known file headers and file trailers known as "Magic Numbers", compiled by Gary Kessler during the years. In all, 600+ known file types are checked against to classify the matching file extension. Please note that there are cases where multiple matches exists of file header and file trailers matching the given byte array. The matches are sorted by number of matching bytes. The assembled list is very helpful. Thanks, Gary !

Using the file header and also possibly the last bytes of a byte array, the file trailer, we can classify the file type we have in the byte array, i.e. file extension is also implied here by recognizing the file array.

Of course, if one is allowing byte array to be uploaded from a public site for example, it still would be possible to inject malicious bytes, but being able to detect the kind of file is useful both concerning security policies and also determine if the bytes should be handled by an external application or provide information to the end-user what kind of file we have provided a path to for this util.

The curated list of file headers is based upon the list of signatures gathered by Gary Kessler and published on his website here (license of that file is not stated and considered public as it is publicly available information on his website not marked with a license):

https://www.garykessler.net/library/file_sigs.html

This list contains about 650 file types and should cover most of the wellknown formats, including formats not being used so often anymore. If you want to augment the list, check other sources such as Wikipedia if there is information about the given file extension's file header and/or file trailer, so-called "Magic number".

The curated list was updated 3rd June 2023 and contains most well-known file types.

The program uses the file signatures (Json format) to identity the file types of a byte array. Most usually, this is judged by looking at the first few bytes of the file (the so-called "magic numbers"). Sometimes, the file signature may also include bytes from the end of the file (the "trailer").


FileSignatureUtil.cs



using System;
using System.Collections.Generic;
using System.Text;

namespace FileHeaderUtil;


public static class FileSignatureUtil
{

    static FileSignature[] _fileSignatures = [];

    static FileSignatureUtil()
    {
        string json = File.ReadAllText("file_Sigs.json");
        var fileSignaturesRoot = System.Text.Json.JsonSerializer.Deserialize<FileSignatureRootElement>(json, new System.Text.Json.JsonSerializerOptions
        {
            PropertyNameCaseInsensitive = true
        });
        _fileSignatures = fileSignaturesRoot?.FileSigs?.ToArray()!;
    }

    /// <summary>
    /// Scans the specified file and returns a list of file signatures that match the file's header and, if applicable,
    /// file's trailer.
    /// </summary>
    /// <remarks>Only file signatures with a defined header are considered for matching. Trailer matching is
    /// performed if both the file and the signature define a trailer. A header and trailer of 64 bytes is evaluted to also 
    /// detect file types / extensions with longer headers and trailers.</remarks>
    /// <param name="targetFile">The path to the file to be analyzed. Cannot be null or empty.</param>
    /// <param name="byteCount">The number of bytes to read from the file for signature matching. Defaults to 64.</param>
    /// <param name="offset">The byte offset at which to begin reading the file for signature matching. Defaults to 0.</param>
    /// <param name="origin">Specifies the reference point used to obtain the offset. Defaults to <see cref="SeekOrigin.Begin"/>.</param>
    /// <returns>A list of <see cref="FileSignature"/> objects that match the file's header and trailer. The list is empty if no
    /// signatures match.</returns>

    public static List<FileSignature> GetMatchingFileSignatures(string targetFile, int byteCount = 64, int offset = 0, SeekOrigin origin = SeekOrigin.Begin)
    {
        static string NormalizeHex(string? hex, bool trimPadding)
        {
            if (string.IsNullOrWhiteSpace(hex))
            {
                return string.Empty;
            }           

            var parts = hex.Replace("-", " ").Split(new[] { ' ', }, StringSplitOptions.RemoveEmptyEntries)
                           .Select(h => h.ToUpperInvariant())
                           .ToList();

            if (trimPadding)
            {
                while (parts.Count > 0 && (parts.Last() == "0A" || parts.Last() == "0D" || parts.Last() == "00"))
                {
                    parts.RemoveAt(parts.Count - 1);
                }
            }

            return string.Join(" ", parts);
        }

        var matches = new List<(FileSignature Sig, int Score)>();

        string fileHeader = NormalizeHex(FileUtil.ShowHeader(targetFile, offset: 0), trimPadding: false);
        string fileTrailer = NormalizeHex(FileUtil.ShowTrailer(targetFile), trimPadding: true);

        foreach (var signature in _fileSignatures)
        {
            if (string.IsNullOrWhiteSpace(signature?.HeaderHex) || signature.HeaderHex == "(NULL)")
                continue;

            string sigHeader = NormalizeHex(signature.HeaderHex, trimPadding: false);
            string sigTrailer = NormalizeHex(signature.TrailerHex, trimPadding: true);

            if (!fileHeader.StartsWith(sigHeader, StringComparison.OrdinalIgnoreCase))
                continue;

            // Trailer check if defined
            if (!string.IsNullOrWhiteSpace(sigTrailer) && sigTrailer != "(NULL)")
            {
                if (!fileTrailer.EndsWith(sigTrailer, StringComparison.OrdinalIgnoreCase))
                    continue;
            }

            // Compute match score (# of matching bytes in header and trailer of file)
            int headerScore = CountMatchingPrefix(fileHeader, sigHeader);
            int trailerScore = CountMatchingSuffix(fileTrailer, sigTrailer);
            int scoreMeasuredAsMatchingByteCount = headerScore + trailerScore;
            signature.MatchingBytesCount = scoreMeasuredAsMatchingByteCount;
            signature.MatchingTrailerBytesCount = trailerScore;
            signature.MatchingHeaderBytesCount = headerScore;
            matches.Add((signature, scoreMeasuredAsMatchingByteCount));
        }

        return matches.OrderByDescending(m => m.Score).Select(m => m.Sig).ToList();
    }

    // Helpers
    private static int CountMatchingPrefix(string source, string pattern)
    {
        var srcParts = source.Split(' ');
        var patParts = pattern.Split(' ');
        int count = 0;
        for (int i = 0; i < Math.Min(srcParts.Length, patParts.Length); i++)
        {
            if (srcParts[i].Equals(patParts[i], StringComparison.OrdinalIgnoreCase))
                count++;
            else break;
        }
        return count;
    }

    private static int CountMatchingSuffix(string source, string pattern)
    {
        if (string.IsNullOrWhiteSpace(pattern)) return 0;
        var srcParts = source.Split(' ');
        var patParts = pattern.Split(' ');
        int count = 0;
        for (int i = 0; i < Math.Min(srcParts.Length, patParts.Length); i++)
        {
            if (srcParts[srcParts.Length - 1 - i].Equals(patParts[patParts.Length - 1 - i], StringComparison.OrdinalIgnoreCase))
                count++;
            else break;
        }
        return count;
    }

}





As we can see in the source code of NormalizeHex, ending padding chars are removed at the end, since in some cases, byte arrays (files or byte columns in databases for examples) are padded with certain bytes. Also, upper-case is applied and '-' is replaced by space ' '.

In the example below, a PDF file is scanned with the console app and the PDF file header and trailer is recognized. In this case, we also peel of trailing bytes at the end, as the specific PDF file had trailing bytes of pad bytes, more specifically : 0A.

FileUtil.cs

The util class here is used to load a file header or file trailer, a smaller byte array usually. 64 bytes is default evaluated here and should cover most file types file headers and file trailers, actually most file types only has 8 bytes or even less as a file header or file trailer.


namespace FileHeaderUtil
{

    /// <summary>
    /// Helper class for file operations
    /// </summary>
    public static class FileUtil
    {

        /// <summary>
        /// Prints the file header HEX representation
        /// </summary>
        /// <param name="filePath"></param>
        /// <param name="byteCount">Read the first n bytes. Defaults to 64 bytes.</param>
        /// <returns></returns>
        public static string? ShowHeader(string filePath, int byteCount = 64, int offset = 0)
        {
            if (!File.Exists(filePath))
            {
                throw new FileNotFoundException(filePath);
            }

            byte[] header = ReadBytes(filePath, byteCount, offset, SeekOrigin.Begin);
            if (header == null)
            {
                return null;
            }
            return BitConverter.ToString(header);
        }

        /// <summary>
        /// Prints the file trailer HEX representation
        /// </summary>
        /// <param name="filePath"></param>
        /// <param name="byteCount">Read the last n bytes. Defaults to 64 bytes.</param>
        /// <returns></returns>
        public static string? ShowTrailer(string filePath, int byteCount = 64, int offset = 0)
        {
            if (!File.Exists(filePath))
            {
                throw new FileNotFoundException(filePath);
            }

            byte[] header = ReadBytes(filePath, byteCount, offset, SeekOrigin.End);
            if (header == null)
            {
                return null;
            }
            return BitConverter.ToString(header);
        }

        /// <summary>
        /// Reads the n bytes of a byte array. Either from the start or the end of the byte array.
        /// </summary>
        /// <param name="filePath">File path of target file to read the byets</param>
        /// <param name="byteCount">The number of bytes to read</param>
        /// <param name="offset">Offset - number of bytes</param>
        /// <param name="origin">Origin to seek from. Can be either SeekOrigin.Begin, SeekOrigin.Current or SeekOrigin.End</param>
        /// <returns></returns>
        private static byte[] ReadBytes(string filePath, int byteCount, int offset = 0, SeekOrigin origin = SeekOrigin.Begin)
        {
            if (!File.Exists(filePath))
            {
                throw new FileNotFoundException(filePath);
            }

            if (byteCount < 1)
            {
                return Array.Empty<byte>();
            }
            byte[] buffer = new byte[byteCount];
            using var fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read);
            if (origin == SeekOrigin.Begin && offset > 0)
            {
                fileStream.Seek(offset, origin);
            }
            else if (origin == SeekOrigin.End)
            {
                fileStream.Seek(-1 * Math.Abs(offset+byteCount), origin);
            }
            else
            {
                //origin must be Current - offset is expected from the current position, just like SeekOrigin.Begin
                fileStream.Seek(offset, origin); 
            }

            int bytesRead = fileStream.Read(buffer, 0, byteCount); 
            if (bytesRead < byteCount)
            {
                Array.Resize(ref buffer, bytesRead);
            }
            return buffer;
        }        
        
    }

}


This console app will only consider max three matching file headers/trailers in cases where multiple such byte array pairs matches a given byte array of a file. To adjust this, see in Program.cs and adjust the Take parameter. Matches are ordered by number of bytes matching.

Sunday, 7 September 2025

Show Nuget Dependency Graph

Showing Nuget Dependency Graph

In .NET Framework and .NET solutions, Nugets are added to solutions to conveniently add libraries. Each project got possibly multiple Nuget package dependencies. Each Nuget package itself can reference other Nuget libraries, which again references again additional libraries and so on. An overview of all the Nuget libraries actually used by a project, those that can be called top-level and transitive dependencies. Transitive dependencies are those indirectly references by the top-level dependencies. Do not confused this with those libraries that are actually referenced in the project file (.csproj files for example) as a Package Reference directly with those called top-level dependencies in this article, top-level here means the Nuget is has got a dependency graph level depth of one, compared to transitive dependencies where the dependency graph level higher than one.



function Show-NugetDependencyGraph {
    [CmdletBinding()]
    param ()
    $tempHtmlPath = [System.IO.Path]::GetTempFileName() + ".html"
    $assetFiles = Get-ChildItem -Recurse -Filter "project.assets.json"

    $currentProjects = (gci -recurse -filter *.csproj | select-object -expandproperty name) -join ',' #get folder name to show

    $script:mermaidGraph = @"
<!DOCTYPE html>
<html>
<head>
  <script type="module">
    import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs';
    mermaid.initialize({ startOnLoad: true });
  </script>
  <style>
    body {
      font-family: sans-serif;
      padding: 20px;
    }
    .mermaid {
      background: #f9f9f9;
      padding: 20px;
      border-radius: 8px;
      min-height: 800px;
      overflow: hidden;
    }
    .mermaid svg {
      min-height: 800px;
      width: 100%;
      height: auto;
    }
  </style>
</head>

<meta charset="UTF-8">

<body>
<h2>Nuget Dependency Graph for '$currentProjects' (Max Depth: 3)</h2>
<div class="mermaid">


graph TD


"@
    $visited = @{}
    $nodes = @{}
    $edges = @{}
    $topLevelDeps = @{}
    $transitiveDeps = @{}
    function Escape-MermaidLabel {
        param ([string]$text)
        $text = $text -replace '\(', '('
        $text = $text -replace '\)', ')'
        $text = $text -replace '\[', '['
        $text = $text -replace '\]', ']'
        $text = $text -replace ',', ','
        return $text
    }
    function Normalize-NodeId {
        param ([string]$text)
        return ($text -replace '[^a-zA-Z0-9_]', '_')
    }
    function Add-Dependencies {
        param (
            [string]$pkgName,
            [object]$targets,
            [int]$depth,
            [string]$path = ""
        )
        if ($depth -gt 3 -or $visited.ContainsKey($pkgName)) { return }
        $visited[$pkgName] = $true
        $pkgVersion = $pkgName.Split('/')[1]
        $pkgId = $pkgName.Split('/')[0]
        $escapedVersion = Escape-MermaidLabel($pkgVersion)
        $nodeId = Normalize-NodeId ("{0}_{1}" -f $pkgId, $pkgVersion)
        $nodeLabel = "$nodeId[""$pkgId<br/>v$escapedVersion""]:::level$depth"
        if (-not $nodes.ContainsKey($nodeId)) {
            $script:mermaidGraph += "$nodeLabel`n"
            $nodes[$nodeId] = $true
        }
        $currentPath = if ($path) { "$path → $pkgId ($pkgVersion)" } else { "$pkgId ($pkgVersion)" }
        if ($depth -eq 1) {
            $topLevelDeps["$pkgId/$pkgVersion"] = $currentPath
        } else {
            $transitiveDeps["$pkgId/$pkgVersion"] = $currentPath
        }
        foreach ($target in $targets.PSObject.Properties) {
            $pkg = $target.Value.$pkgName
            if ($pkg -and $pkg.dependencies) {
                foreach ($dep in $pkg.dependencies.PSObject.Properties) {
                    $depName = $dep.Name
                    $depVersion = $dep.Value
                    $escapedDepVersion = Escape-MermaidLabel($depVersion)
                    $depNodeId = Normalize-NodeId ("{0}_{1}" -f $depName, $depVersion)
                    $depNodeLabel = "$depNodeId[""$depName<br/>v$escapedDepVersion""]:::level$($depth+1)"
                    if (-not $nodes.ContainsKey($depNodeId)) {
                        $script:mermaidGraph += "$depNodeLabel`n"
                        $nodes[$depNodeId] = $true
                    }
                    $edge = "$nodeId --> $depNodeId"
                    if (-not $edges.ContainsKey($edge)) {
                        $script:mermaidGraph += "$edge`n"
                        $edges[$edge] = $true
                    }
                    Add-Dependencies ("$depName/$depVersion") $targets ($depth + 1) $currentPath
                }
            }
        }
    }
    foreach ($file in $assetFiles) {
        $json = Get-Content $file.FullName | ConvertFrom-Json
        $targets = $json.targets
        foreach ($target in $targets.PSObject.Properties) {
            $targetPackages = $target.Value
            foreach ($package in $targetPackages.PSObject.Properties) {
                Add-Dependencies $package.Name $targets 1
            }
        }
    }

    $topLevelDepsCount = $topLevelDeps.Count #number of top level dependencies
    $transitiveDepsCount = $transitiveDeps.Count #number of top level transitive dependencies

    $script:mermaidGraph += @"
classDef level1 fill:#cce5ff,stroke:#004085,stroke-width:2px;
classDef level2 fill:#d4edda,stroke:#155724,stroke-width:1.5px;
classDef level3 fill:#fff3cd,stroke:#856404,stroke-width:1px;
</div>
<script>
  function enablePanZoom(svg) {
    let isPanning = false;
    let startX, startY;
    let viewBox = svg.viewBox.baseVal;
    let zoomFactor = 1.1;
    // Initial zoom: scale to 200%
    const initialZoom = 2.0;
    const newWidth = viewBox.width / initialZoom;
    const newHeight = viewBox.height / initialZoom;
    viewBox.x += (viewBox.width - newWidth) / 2;
    viewBox.y += (viewBox.height - newHeight) / 2;
    viewBox.width = newWidth;
    viewBox.height = newHeight;
    svg.addEventListener("mousedown", (e) => {
      isPanning = true;
      startX = e.clientX;
      startY = e.clientY;
      svg.style.cursor = "grabbing";
    });
    svg.addEventListener("mousemove", (e) => {
      if (!isPanning) return;
      const dx = (e.clientX - startX) * (viewBox.width / svg.clientWidth);
      const dy = (e.clientY - startY) * (viewBox.height / svg.clientHeight);
      viewBox.x -= dx;
      viewBox.y -= dy;
      startX = e.clientX;
      startY = e.clientY;
    });
    svg.addEventListener("mouseup", () => {
      isPanning = false;
      svg.style.cursor = "grab";
    });
    svg.addEventListener("mouseleave", () => {
      isPanning = false;
      svg.style.cursor = "grab";
    });
    svg.addEventListener("wheel", (e) => {
      e.preventDefault();
      const { x, y, width, height } = viewBox;
      const mx = e.offsetX / svg.clientWidth;
      const my = e.offsetY / svg.clientHeight;
      const zoom = e.deltaY < 0 ? 1 / zoomFactor : zoomFactor;
      const newWidth = width * zoom;
      const newHeight = height * zoom;
      viewBox.x += (width - newWidth) * mx;
      viewBox.y += (height - newHeight) * my;
      viewBox.width = newWidth;
      viewBox.height = newHeight;
    });
    svg.style.cursor = "grab";
  }
  document.addEventListener("DOMContentLoaded", () => {
    setTimeout(() => {
      const svg = document.querySelector(".mermaid svg");
      if (svg) {
        enablePanZoom(svg);
      } else {
        console.warn("SVG not found after 1.5s.");
      }
    }, 1500);
  });
</script>
<h3>🔎 Filter Dependencies (Total Count: $($transitiveDepsCount + $topLevelDepsCount))</h3>
<input type="text" id="searchInput" onkeyup="filterTables()" placeholder="Search for NuGet package..." style="width: 100%; padding: 8px; margin-bottom: 20px; font-size: 16px;">
<style>
  table {
    border-collapse: collapse;
    width: 100%;
    margin-bottom: 40px;
    font-size: 14px;
  }
  th, td {
    border: 1px solid #ccc;
    padding: 8px;
    text-align: left;
  }
  tr:nth-child(even) {
    background-color: #f9f9f9;
  }
  tr:hover {
    background-color: #e2f0fb;
  }
  th {
    background-color: #007bff;
    color: white;
  }
</style>
<h3>📦 Top-Level Dependencies (Count: $transitiveDepsCount)</h3>
<em>Note: Top-level Dependencies are Nuget packages which have a Dependency Path of length 1. To check which Nuget packages are actually listed in the project file(s), open the .csproj file(s) directly.</em>
<table id="topTable">
  <thead><tr><th>Package</th><th>Dependency Path</th></tr></thead>
  <tbody>
"@
    $sortedTopLevel = $topLevelDeps.GetEnumerator() | Sort-Object Name
    foreach ($dep in $sortedTopLevel) {
        $script:mermaidGraph += "<tr><td>$($dep.Key)</td><td>$($dep.Value)</td></tr>`n"
    }
    $script:mermaidGraph += @"
  </tbody>
</table>
<h3>📚 Transitive Dependencies (Count: $topLevelDepsCount)</h3>
<table id="transitiveTable">
  <thead><tr><th>Package</th><th>Dependency Path</th></tr></thead>
  <tbody>
"@
    $sortedTransitive = $transitiveDeps.GetEnumerator() | Sort-Object Name
    foreach ($dep in $sortedTransitive) {
        $script:mermaidGraph += "<tr><td>$($dep.Key)</td><td>$($dep.Value)</td></tr>`n"
    }
    $script:mermaidGraph += @"
  </tbody>
</table>
<script>
function filterTables() {
  const input = document.getElementById('searchInput').value.toLowerCase();
  ['topTable', 'transitiveTable'].forEach(id => {
    const rows = document.getElementById(id).getElementsByTagName('tr');
    for (let i = 1; i < rows.length; i++) {
      const cells = rows[i].getElementsByTagName('td');
      const match = Array.from(cells).some(cell => cell.textContent.toLowerCase().includes(input));
      rows[i].style.display = match ? '' : 'none';
    }
  });
}
</script>
</body>
</html>
"@
    [System.IO.File]::WriteAllText($tempHtmlPath, $script:mermaidGraph, [System.Text.Encoding]::UTF8)
    Start-Process $tempHtmlPath
}

# Run the function
Show-NugetDependencyGraph


The function above Show-NugetDependencyGraph can be added to the $profile file of the user you are logged in as. Usage : Make sure you are inside a folder where your project of the .NET Framework or .NET solution you want to see the Dependency graph and then just run the function Show-NugetDependencyGraph. Inside the subfolders, you will find project.assets.json file, usually in the obj folder. Note that this Powershell script do support showing multiple projects, but there are limitations in the graph drawing not allowing too many Nuget packages drawn into one and same graph, so the best analysis is done per-project. The Powershell script adds support for pan and zoom to provide an interactive Nuget Dependency graph. VanillaJs is used. Note that this script supports both .NET and .NET Framework. The script will recursively look for project.assets.json files in subfolders and then use the Convert-FromJson method to inspect the json file(s) found. The method Add-Dependencies is called recursively to build up the hash tables variables of the script that will keep the data structure that is keeping the list of Nuget libraries and transitive dependencies. The script also builds up VanillaJs script string that adds pan and zoom capabilities and the html template provides tables for the top-level and transitive Nuget libraries. Note also that the script builds up the html template that presents the Mermaid based Nuget dependency graph, using the script level variable $script:mermaidGraph. Note the usage of script-level variable here, this is necessary to hoist the Powershell variable up since we make use of recursion and this is required. Screenshots showing examples after running the Powershell script.

Table showing transitive dependencies in table :
Example of dependency graph of nuget libaries :

Monday, 5 May 2025

Using MatPlotLib from .NET

MatPlotLib is a powerful library for data visualization. It provides graphing for scientific computing. It can be used for doing both mathematical calculations and statistics. Together with additional libraries like NumPy or Numerical Python, it is clear that Python as a programming language and ecosystem provides a lot of powerful functionality that is also free to use. MatplotLib has a BSD license, which means it can be ued for personal, academic or commercial purposes without restrictions. This article will look at using MatplotLib from .NET. First off an image that displays the demo and example of using MatplotLib.

The source code shown in this article is available on Github here:

https://github.com/toreaurstadboss/SeabornBlazorVisualizer



Using MatPlotLib from .NET

First off, install Anaconda. Anaconda is a Python distribution that contains a large collection of data visualization libraries. A compatible version with the lastest version of Python.net Nuget library. The demo displayed here uses Anaconda version 2023.03.

Anaconda archived versions 2023.03 can be installed from here. Windows users can download the file: https://repo.anaconda.com/archive/Anaconda3-2023.03-1-Windows-x86_64.exe

https://repo.anaconda.com/archive/

Next up, install also Python 3.10 version. It will be used together with Anaconda. A 64-bit installer can be found here:

Python 3.10 installer (Windows 64-bits) The correct versions of NumPy and MatPlotLib can be checked against this list :

https://github.com/toreaurstadboss/SeabornBlazorVisualizer/blob/main/SeabornBlazorVisualizer/conda_list_loading_matplotlib_working_1st_May_2025.txt

Calculating the determinite integral of a function

The demo in this article show in the link at the top has got an appsettings.json file, you can adjust to your environment.

appsettings.json Application configuration file




{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "PythonConfig": {
    "PythonDllPath": "C:\\Python310\\Python310.dll",
    "PythonHome": "C:\\Programdata\\anaconda3",
    "PythonSitePackages":  "C:\\Programdata\\anaconda3\\lib\\site-packages",
    "PythonVersion": "3.10"
  },
  "AllowedHosts": "*"
}


Clone the source code and run the application. It is a Blazor server app. You can run it from VS 2022 for example. The following code shows how Python.net is set up to start using Python. Both Python 3.10 and Anaconda site libs are used here. The Python runtime and engine is set up using this helper class.

PythonInitializer.cs



using Microsoft.Extensions.Options;
using Python.Runtime;

namespace SeabornBlazorVisualizer.Data
{

    /// <summary>
    /// Helper class to initialize the Python runtime
    /// </summary>
    public static class PythonInitializer
    {

        private static bool runtime_initialized = false;

        /// <summary>
        /// Perform one-time initialization of Python runtime
        /// </summary>
        /// <param name="pythonConfig"></param>
        public static void InitializePythonRuntime(IOptions<PythonConfig> pythonConfig)
        {
            if (runtime_initialized)
                return;
            var config = pythonConfig.Value;

            // Set environment variables
            Environment.SetEnvironmentVariable("PYTHONHOME", config.PythonHome, EnvironmentVariableTarget.Process);
            Environment.SetEnvironmentVariable("PYTHONPATH", config.PythonSitePackages, EnvironmentVariableTarget.Process);
            Environment.SetEnvironmentVariable("PYTHONNET_PYDLL", config.PythonDllPath);
            Environment.SetEnvironmentVariable("PYTHONNET_PYVER", config.PythonVersion);

            PythonEngine.Initialize();

            PythonEngine.PythonHome = config.PythonHome ?? Environment.GetEnvironmentVariable("PYTHONHOME", EnvironmentVariableTarget.Process)!;
            PythonEngine.PythonPath = config.PythonDllPath ?? Environment.GetEnvironmentVariable("PYTHONNET_PYDLL", EnvironmentVariableTarget.Process)!;

            PythonEngine.BeginAllowThreads();
            AddSitePackagesToPythonPath(pythonConfig);
            runtime_initialized = true;
        }

        private static void AddSitePackagesToPythonPath(IOptions<PythonConfig> pythonConfig)
        {
            if (!runtime_initialized)
            {
                using (Py.GIL())
                {
                    dynamic sys = Py.Import("sys");
                    sys.path.append(pythonConfig.Value.PythonSitePackages);
                    Console.WriteLine(sys.path);

                    //add folders in solution this too with scripts
                    sys.path.append(@"Data/");
                }
            }
        }

    }
}



The following helper class sets up the site libraries we will use.

PythonHelper.cs



using Python.Runtime;

namespace SeabornBlazorVisualizer.Data
{

    /// <summary>
    /// Helper class to initialize the Python runtime
    /// </summary>
    public static class PythonHelper
    {

        /// <summary>
        /// Imports Python modules. Returned are the following modules:
        /// <para>np (numpy)</para>
        /// <para>os (OS module - standard library)</para>
        /// <para>scipy (scipy)</para>
        /// <para>mpl (matplotlib)</para>
        /// <para>plt (matplotlib.pyplot </para>
        /// </summary>
        /// <returns>Tuple of Python modules</returns>
        public static (dynamic np, dynamic os, dynamic scipy, dynamic mpl, dynamic plt) ImportPythonModules()
        {

            dynamic np = Py.Import("numpy");
            dynamic os = Py.Import("os");
            dynamic mpl = Py.Import("matplotlib");
            dynamic plt = Py.Import("matplotlib.pyplot");
            dynamic scipy = Py.Import("scipy");

            mpl.use("Agg");

            return (np, os, scipy, mpl, plt);
        }

    }
}



The demo is a Blazor server app. The following service will generate the plot of a determinite integral using MatPlotLib. The service saves the plot into a PNG file. This PNG file is saved into the folder wwwroot. The Blazor server app displays the image that was generated and saved.

MatPlotImageService.cs



using Microsoft.Extensions.Options;
using Python.Runtime;

namespace SeabornBlazorVisualizer.Data
{
    public class MatplotPlotImageService
    {

        private IOptions<PythonConfig>? _pythonConfig;

        private static readonly object _lock = new object();

        public MatplotPlotImageService(IOptions<PythonConfig> pythonConfig)
        {
            _pythonConfig = pythonConfig;
            PythonInitializer.InitializePythonRuntime(_pythonConfig);
        }

        public Task<string> GenerateDefiniteIntegral(string functionExpression, int lowerBound, int upperBound)
        {

            string? result = null;

            using (Py.GIL()) // Ensure thread safety for Python calls
            {
                dynamic np = Py.Import("numpy");
                dynamic plt = Py.Import("matplotlib.pyplot");

                dynamic patches = Py.Import("matplotlib.patches"); // Import patches module

                // Create a Python execution scope
                using (var scope = Py.CreateScope())
                {
                    // Define the function inside the scope
                    scope.Exec($@"
import numpy as np
def func(x):
    return {functionExpression}
");

                    // Retrieve function reference from scope
                    dynamic func = scope.Get("func");

                    // Define integration limits
                    double a = lowerBound, b = upperBound;

                    // Generate x-values
                    dynamic x = np.linspace(0, 10, 100); //generate evenly spaced values in range [0, 20], 100 values (per 0.1)
                    dynamic y = func.Invoke(x);

                    // Create plot figure
                    var fig = plt.figure();
                    var ax = fig.add_subplot(111);

                    // set title to function expression
                    plt.title(functionExpression);

                    ax.plot(x, y, "r", linewidth: 2);
                    ax.set_ylim(0, null);

                    // Select range for integral shading
                    dynamic ix = np.linspace(a, b, 100);
                    dynamic iy = func.Invoke(ix);

                    // **Fix: Separate x and y coordinates properly**
                    List<double> xCoords = new List<double> { a }; // Start at (a, 0)
                    List<double> yCoords = new List<double> { 0 };

                    int length = (int)np.size(ix);
                    for (int i = 0; i < length; i++)
                    {
                        xCoords.Add((double)ix[i]);
                        yCoords.Add((double)iy[i]);
                    }

                    xCoords.Add(b); // End at (b, 0)
                    yCoords.Add(0);

                    // Convert x and y lists to NumPy arrays
                    dynamic npVerts = np.column_stack(new object[] { np.array(xCoords), np.array(yCoords) });

                    // **Correctly Instantiate Polygon Using NumPy Array**
                    dynamic poly = patches.Polygon(npVerts, facecolor: "0.6", edgecolor: "0.2");
                    ax.add_patch(poly);

                    // Compute integral area
                    double area = np.trapezoid(iy, ix);
                    ax.text(0.5 * (a + b), 30, "$\\int_a^b f(x)\\mathrm{d}x$", ha: "center", fontsize: 20);
                    ax.text(0.5 * (a + b), 10, $"Area = {area:F2}", ha: "center", fontsize: 12);

                    plt.show();


                    result = SavePlot(plt, dpi: 150);
                }
            }
            return Task.FromResult(result);
        }

        public Task<string> GenerateHistogram(List<double> values, string title = "Provide Plot title", string xlabel = "Provide xlabel title", string ylabel = "Provide ylabel title")
        {
            string? result = null;
            using (Py.GIL()) //Python Global Interpreter Lock (GIL)
            {
                var (np, os, scipy, mpl, plt) = PythonHelper.ImportPythonModules();

                var distribution = np.array(values.ToArray());

                //// Ensure clearing the plot
                //plt.clf();

                var fig = plt.figure(); //create a new figure
                var ax1 = fig.add_subplot(1, 2, 1);
                var ax2 = fig.add_subplot(1, 2, 2);

                // Add style
                plt.style.use("ggplot");

                var counts_bins_patches = ax1.hist(distribution, edgecolor: "black");

                // Normalize counts to get colors 
                var counts = counts_bins_patches[0];
                var patches = counts_bins_patches[2];

                var norm_counts = counts / np.max(counts);

                int norm_counts_size = Convert.ToInt32(norm_counts.size.ToString());

                // Apply colors to patches based on frequency
                for (int i = 0; i < norm_counts_size; i++)
                {
                    plt.setp(patches[i], "facecolor", plt.cm.viridis(norm_counts[i])); //plt.cm is the colormap module in MatPlotlib. viridis creates color maps from normalized value 0 to 1 that is optimized for color-blind people.
                }

                // **** AX1 Histogram first - frequency counts ***** 

                ax1.set_title(title);
                ax1.set_xlabel(xlabel);
                ax1.set_ylabel(ylabel);

                string cwd = os.getcwd();

                // Calculate average and standard deviation
                var average = np.mean(distribution);
                var std_dev = np.std(distribution);
                var total_count = np.size(distribution);

                // Format average and standard deviation to two decimal places
                var average_formatted = np.round(average, 2);
                var std_dev_formatted = np.round(std_dev, 2);

                //Add legend with average and standard deviation
                ax1.legend(new string[] { $"Total count: {total_count}\n Average: {average_formatted} cm\nStd Dev: {std_dev_formatted} cm" }, framealpha: 0.5, fancybox: true);



                //***** AX2 : Set up ax2 = Percentage histogram next *******

                ax2.set_title("Percentage distribution");
                ax2.set_xlabel(xlabel);
                ax2.set_ylabel(ylabel);
                // Fix for CS1977: Cast the lambda expression to a delegate type
                ax2.yaxis.set_major_formatter((PyObject)plt.FuncFormatter(new Func<double, int, string>((y, _) => $"{y:P0}")));

                ax2.hist(distribution, edgecolor: "black", weights: np.ones(distribution.size) / distribution.size);

                // Format y-axis to show percentages
                ax2.yaxis.set_major_formatter(plt.FuncFormatter(new Func<double, int, string>((y, _) => $"{y:P0}")));

                // tight layout to prevent overlap 
                plt.tight_layout();

                // Show the plot with the two subplots at last (render to back buffer 'Agg', see method SavePlot for details)
                plt.show();

                result = SavePlot(plt, theme: "bmh", dpi: 150);
            }

            return Task.FromResult(result);
        }

        public Task<string> GeneratedCumulativeGraphFromValues(List<double> values)
        {
            string? result = null;
            using (Py.GIL()) //Python Global Interpreter Lock (GIL)
            {
                var (np, os, scipy, mpl, plt) = PythonHelper.ImportPythonModules();

                dynamic pythonValues = np.cumsum(np.array(values.ToArray()));

                // Ensure clearing the plot
                plt.clf();

                // Create a figure with increased size
                dynamic fig = plt.figure(figsize: new PyTuple(new PyObject[] { new PyFloat(6), new PyFloat(4) }));

                // Plot data
                plt.plot(values, color: "green");

                string cwd = os.getcwd();

                result = SavePlot(plt, theme: "ggplot", dpi: 200);

            }

            return Task.FromResult(result);
        }

        public Task<string> GenerateRandomizedCumulativeGraph()
        {
            string? result = null;
            using (Py.GIL()) //Python Global Interpreter Lock (GIL)
            {

                dynamic np = Py.Import("numpy");

                //TODO : Remove imports of pandas and scipy and datetime if they are not needed

                Py.Import("pandas");
                Py.Import("scipy");
                Py.Import("datetime");
                dynamic os = Py.Import("os");

                dynamic mpl = Py.Import("matplotlib");
                dynamic plt = Py.Import("matplotlib.pyplot");

                // Set dark theme
                plt.style.use("ggplot");

                mpl.use("Agg");


                // Generate data
                //dynamic x = np.arange(0, 10, 0.1);
                //dynamic y = np.multiply(2, x); // Use NumPy's multiply function

                dynamic values = np.cumsum(np.random.randn(1000, 1));


                // Ensure clearing the plot
                plt.clf();

                // Create a figure with increased size
                dynamic fig = plt.figure(figsize: new PyTuple(new PyObject[] { new PyFloat(6), new PyFloat(4) }));

                // Plot data
                plt.plot(values, color: "blue");

                string cwd = os.getcwd();

                result = SavePlot(plt, theme: "ggplot", dpi: 200);

            }

            return Task.FromResult(result);
        }

        /// <summary>
        /// Saves the plot to a PNG file with a unique name based on the current date and time
        /// </summary>
        /// <param name="plot">Plot, must be a PyPlot plot use Python.net Py.Import("matplotlib.pyplot")</param>
        /// <param name="theme"></param>
        /// <param name="dpi"></param>
        /// <returns></returns>
        public string? SavePlot(dynamic plt, string theme = "ggplot", int dpi = 200)
        {
            string? plotSavedImagePath = null;
            //using (Py.GIL()) //Python Global Interpreter Lock (GIL)
            //{
            dynamic os = Py.Import("os");
            dynamic mpl = Py.Import("matplotlib");
            // Set dark theme
            plt.style.use(theme);
            mpl.use("Agg"); //set up rendering of plot to back-buffer ('headless' mode)

            string cwd = os.getcwd();
            // Save plot to PNG file
            string imageToCreatePath = $@"GeneratedImages\{DateTime.Now.ToString("yyyyMMddHHmmss")}{Guid.NewGuid().ToString("N")}_plotimg.png";
            string imageToCreateWithFolderPath = $@"{cwd}\wwwroot\{imageToCreatePath}";
            plt.savefig(imageToCreateWithFolderPath, dpi: dpi); //save the plot to a file (use full path)
            plotSavedImagePath = imageToCreatePath;

            CleanupOldGeneratedImages(cwd);
            //}
            return plotSavedImagePath;
        }

        private static void CleanupOldGeneratedImages(string cwd)
        {
            lock (_lock)
            {

                Directory.GetFiles(cwd + @"\wwwroot\GeneratedImages", "*.png")
                 .OrderByDescending(File.GetLastWriteTime)
                 .Skip(10)
                 .ToList()
                 .ForEach(File.Delete);
            }
        }

}



The code above shows some additional examples of using MatPlotLib.
  • Histogram example
  • Line graph using cumulative sum by making use of NumPy or a helper method in .NET
These examples demonstrates also that MatPlotLib can be used for statistics, which today for .NET is mostly crunched with the help of Excel or EP Plus library for example. Since Python is considered as the home of data visualization with its vast ecosystem of data science libraries, this article and demos shows how you can get started with using this ecosystem from .NET. Note, using Python.net to create these plots in MatPlotLib is best prepared using Jupyter Notebook. When the plot displayed looks okay, it is time to integrate that Python script into .NET and C# using Python.Net library. Make note that there will be some challenges to get the Python code to work in C# of course. When passing in values to a function, sometimes you must use
for example NumPy to create compatible data types. Also note the usage of the Pystatic class here from Python.net , which offers the GIL Global Interpreter lock and a way to import Python modules.

https://jupyter.org/ A screenshot showing histogram in the demo is shown below. As we can see, MatPlotLib can be used from many different data visualizations and domains.

Tuesday, 18 March 2025

Converting a .NET Datetime to a DateTime2 in T-SQL

This article presents a handy T-Sql that extracts a DateTime value stored in .NET in a numerical field and converts it into a Sql Server DateTime (DateTime2 column). The T-SQL will convert into a DateTime2 with a SECOND precision. An assumption here is that any numerical value larger than 100000000000 contains a DateTime value. This is an acceptable assumption when you log data, as very large values usually indicate a datetime value. But you might want to have additional checking here of course in addition to what i show in the example T-SQL script. Here is the T-SQL that shows how we can convert the .NET DateTime into a SQL DateTime.


-- Last updated: March 18, 2025
-- Synopsis: This script retrieves detailed change log information from the ObjectChanges, PropertyChanges, and ChangeSets tables.
-- It filters the results based on specific identifiers stored in a table variable, in this example Guids. 

-- In this example T-Sql the library FrameLog is used to store a log

-- DateTime columns are retrieved by looking at number of ticks elapsed since DateTime.MinValue as 
-- DateTime columns are stored in SQL Server as a this numeric value. 


DECLARE @EXAMPLEGUIDS TABLE (ID NVARCHAR(36))

INSERT INTO @EXAMPLEGUIDS (Id)
VALUES
('1968126a-64c1-4d15-bf23-8cb8497dcaa9'), 
('3e11aad8-95df-4377-ad63-c2fec3d43034'),
('acbdd116-b6a5-4425-907b-f86cb55aeedd') --tip: define which form Guids to fetch the ChangeLog database tables which 'FrameLog' uses The form Guids can each be retrieved from url showing the form in MRS in the browser

SELECT 
o.Id as ObjectChanges_Id, 
o.ObjectReference as ObjectReference, 
o.TypeName as ObjectChanges_TypeName, 
c.Id as Changeset_Id, 
c.[Timestamp] as Changeset_Timestamp,
c.Author_UserName as Changeset_AuthorName,
p.[Id] as PropertyChanges_Id,
p.[PropertyName],
p.[Value],
p.[ValueAsInt],
CASE 
    WHEN p.Value IS NOT NULL 
         AND ISNUMERIC(p.[Value]) = 1 
         AND CAST(p.[Value] AS decimal) > 100000000000 
    THEN 
        DATEADD(SECOND, 
            CAST(CAST(p.[Value] AS decimal) / 10000000 AS BIGINT) % 60, 
            DATEADD(MINUTE, 
                CAST(CAST(p.[Value] AS decimal) / 10000000 / 60 AS BIGINT), 
                CAST('0001-01-01' AS datetime2)
            )
        )
    ELSE NULL
END AS ValueAsDate,
o.ChangeType as ObjectChanges_ChangeTypeIfSet
FROM propertychanges p
LEFT OUTER JOIN ObjectChanges o on o.Id = p.ObjectChange_Id
LEFT OUTER JOIN ChangeSets c on o.ChangeSet_Id = c.Id
WHERE ObjectChange_Id in (
 SELECT ObjectChanges.Id
  FROM PropertyChanges
  LEFT OUTER JOIN ObjectChanges on ObjectChanges.Id = PropertyChanges.ObjectChange_Id
  LEFT OUTER JOIN ChangeSets on ObjectChanges.ChangeSet_Id = ChangeSets.Id
  WHERE ObjectChange_Id in (SELECT Id FROM ObjectChanges where ObjectReference IN (
   SELECT Id FROM @EXAMPLEGUIDS
   ))) --find out the Changeset where ObjectChange_Id equals the Id of ObjectChanges where ObjectReference equals one of the identifiers in @EXAMPLEGUIDS
ORDER BY ObjectReference, Changeset_Id DESC, Changeset_Timestamp DESC



The T-Sql is handy in case you come across datetime columns from .NET that are saved as ticks in a column as numerical value and shows how we can do the conversion.

Sunday, 31 December 2023

AES Encryption with Galois Counter Mode (GCM) in C#

This article presents some helper methods for performing AES Encryption using Galois Counter Mode (GCM). AES or Advanced Encryption Standard is the most used encryption algorithm used today, having overtaken DES and Triple DES since 2001. We will look into the GCM mode of AES in this article. AES-GCM class AesGcm is supported in .NET Core 3.0 and newer .NET versions, plus in .NET Standard 2.1. AES-GCM is authenticated encryption, compared to default AES-CBC (Cipher Block Chaining). Benefits of using GCM mode of AES is the following:
  • Data authenticity / integrity. This is provided via a tag that is outputted by the encryption and used while decrypting
  • Provides support for sending additional data, used for example in newer TLS implementations to provide both encryption and a non-encrypted payload. This is called additional metadata
Here is a helper class to perform encryption and decryption using AES-GCM.
 
 public static class AesGcmEncryption {


	public static (byte[], byte[]) Encrypt(byte[] dataToEncrypt, byte[] key, byte[] nonce, byte[] associatedData = null)
	{
		using var aesGcm = new AesGcm(key);
		//tag and ciphertext will be filled during encryption
		var tag = new byte[16]; //tag is a hmac (hash-based message authentication code) to check that information has not been tampered with
	    var cipherText = new byte[dataToEncrypt.Length];
		aesGcm.Encrypt(nonce, dataToEncrypt, cipherText, tag, associatedData);
		return (cipherText, tag);
	}

	public static byte[] Decrypt(byte[] cipherText, byte[] key, byte[] nonce, byte[] tag, byte[] associatedData = null)
	{
		using var aesGcm = new AesGcm(key);
		//tag and ciphertext will be filled during encryption
		var decryptedData = new byte[cipherText.Length];
		aesGcm.Decrypt(nonce, cipherText, tag, decryptedData, associatedData);
		return decryptedData;
	}
	
}
 
 
In the code above, the encrypt method returns a tuple with the ciperText and the tag. These are the encrypted data and the tag, both must be used while decrypting and the tag provides as mentioned a means of checking the integrity of data, i.e. that data has not been tampered with. Note that the 16-byte tag and the ciphertext is filled after running the Encrypt method of the AesGcm class. The cipherText array must be the same length as the dataToEncrypt array inputted. Here is sample code to use AES-GCM. Note that the metadata used here, while optional, must match in case it is set in the encryption and decryption. The nonce must be 12 bytes - 96 bits in length.The nonce is similar to a initialization vector, although it is used once for the particular encryption and decryption, it is used to protect against replay attacks.
 
 
 void TestAesGCM()
{
	const string original = "Text to encrypt";
	var key = RandomNumberGenerator.GetBytes(32); //256 bits key
	var nonce = RandomNumberGenerator.GetBytes(12); //96 bits nonce
	
	(byte[] cipherText, byte[] tag) result = AesGcmEncryption.Encrypt(Encoding.UTF8.GetBytes(original),
	 key, nonce, Encoding.UTF8.GetBytes("some metadata 123"));
	 byte[] decryptedText = AesGcmEncryption.Decrypt(result.cipherText, key, nonce, result.tag, Encoding.UTF8.GetBytes("some metadata 123")); 
		
	Console.WriteLine("AES Encryption demo GCM - Galois Counter Mode:");
	Console.WriteLine("--------------");
	Console.WriteLine("Original Text = " + original);
	Console.WriteLine("Encrypted Text = " + Convert.ToBase64String(result.cipherText));
	Console.WriteLine("Tag = " + Convert.ToBase64String(result.tag));
	Console.WriteLine("Decrypted Text = " + Encoding.UTF8.GetString(decryptedText));
}
 
 
AES Encryption demo GCM - Galois Counter Mode: -------------- Original Text = Text to encrypt Encrypted Text = 9+2x0kctnRwiDDHBm0/H Tag = sSDxsg17HFdjE4cuqRlroQ== Decrypted Text = Text to encrypt Use AES-GCM to provide integrity checking and allowing to send in metadata if desired to encrypt and decrypting with the AES algorithm. We can protect the AES key using different methods, for example using the Data Protection API, this is only supported in Windows. Let's look at a helper class for using Data Protection API.
 
 
 public static class DataProtectionUtil {

	public static byte[] Protect(byte[] dataToEncrypt, byte[] optionalEntropy, DataProtectionScope scope)
	{
		var encryptedData = ProtectedData.Protect(dataToEncrypt, optionalEntropy, scope);
		return encryptedData;
	}
	
	public static byte[] Unprotect(byte[] encryptedData, byte[] optionalEntropy, DataProtectionScope scope){
		var decryptedData = ProtectedData.Unprotect(encryptedData, optionalEntropy, scope);
		return decryptedData;
	}

	public static string Protect(string dataToEncrypt, string optionalEntropy, DataProtectionScope scope)
	{
		var encryptedData = ProtectedData.Protect(Encoding.UTF8.GetBytes(dataToEncrypt), optionalEntropy != null ? Encoding.UTF8.GetBytes(optionalEntropy) : null, scope);
		return Convert.ToBase64String(encryptedData);
	}

	public static string Unprotect(string encryptedData, string optionalEntropy, DataProtectionScope scope)
	{
		var decryptedData = ProtectedData.Unprotect(Convert.FromBase64String(encryptedData), optionalEntropy != null ? Encoding.UTF8.GetBytes(optionalEntropy) : null, scope);
		return Encoding.UTF8.GetString(decryptedData);
	}

}
 
 

An example how to protect your AES key:

 
 
void EncryptAndDecryptWithProtectedKey(){
	var original = "Text to encrypt";
	Console.WriteLine($"Original Text = {original}");
	
	//Create key and nnoce . Encrypt our text with AES 
	var gcmKey = RandomNumberGenerator.GetBytes(32);
	var nonce = RandomNumberGenerator.GetBytes(12); 
	
	var result = EncryptText(original, gcmKey, nonce); 
	
	//Create some entropy and protect AES key
	var entropy = RandomNumberGenerator.GetBytes(16); 
	var protectedKey = ProtectedData.Protect(gcmKey, entropy, DataProtectionScope.CurrentUser);

	Console.WriteLine($"gcmKey = {Convert.ToBase64String(gcmKey)}, protectedKey = {Convert.ToBase64String(protectedKey)}");
	
	// Decrypt the text with AES. the AES key has to be retrieved with DPAPI.
	var decryptedText = DecryptText(result.encrypted, nonce, result.tag, protectedKey, entropy);

	Console.WriteLine($"Decrypted Text using AES GCM with key retrieved via Data Protection API = {decryptedText}");

}

private static (byte[] encrypted, byte[] tag) EncryptText(string original, byte[] gcmKey, byte[] nonce){
	return AesGcmEncryption.Encrypt(Encoding.UTF8.GetBytes(original), gcmKey, nonce, Encoding.UTF8.GetBytes("some meta"));
}

private static string DecryptText(byte[] encrypted, byte[] nonce, byte[] tag, byte[] protectedKey, byte[] entropy){
	
	var key = DataProtectionUtil.Unprotect(protectedKey, entropy, DataProtectionScope.CurrentUser);

	Console.WriteLine($"Inside DecryptText: gcmKey = {Convert.ToBase64String(key)}, protectedKey = {Convert.ToBase64String(protectedKey)}");

	var decryptedText = AesGcmEncryption.Decrypt(encrypted, key, nonce, tag, Encoding.UTF8.GetBytes("some meta"));
	return Encoding.UTF8.GetString(decryptedText);
}
 
Data Protection API is only supported on Windows platform, there are more possibilities to protect AES key but protecting your key is always a challenge when dealing with symmetric encryption algorithms such as AES. Some more links:

Friday, 22 September 2023

Using Azure Computer Vision to perform Optical Character Recognition (OCR)

This article shows how you can use Azure Computer vision in Azure Cognitive Services to perform Optical Character Recognition (OCR). The Computer vision feature is available by adding a Computer Vision resource in Azure Portal. I have made a .NET MAUI Blazor app and the Github Repo for it is available here : https://github.com/toreaurstadboss/Ocr.Handwriting.Azure.AI.Models
Let us first look at the .csproj of the Lib project in this repo.


<Project Sdk="Microsoft.NET.Sdk.Razor">

  <PropertyGroup>
    <TargetFramework>net6.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
  <ItemGroup>
    <SupportedPlatform Include="browser" />
  </ItemGroup>

	<ItemGroup>
		<PackageReference Include="Microsoft.Azure.CognitiveServices.Vision.ComputerVision" Version="7.0.1" />
		<PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="6.0.19" />
	</ItemGroup>

</Project>


The following class generates ComputerVision clients that can be used to extract different information from streams and files containing video and images. We are going to focus on images and extracting text via OCR. Azure Computer Vision can also extract handwritten text in addition to regular text written by typewriters or text inside images and similar. Azure Computer Vision also can detect shapes in images and classify objects. This demo only focuses on text extraction form images. ComputerVisionClientFactory


using Microsoft.Azure.CognitiveServices.Vision.ComputerVision;

namespace Ocr.Handwriting.Azure.AI.Lib
{

    public interface IComputerVisionClientFactory
    {
        ComputerVisionClient CreateClient();
    }

    /// <summary>
    /// Client factory for Azure Cognitive Services - Computer vision.
    /// </summary>
    public class ComputerVisionClientFactory : IComputerVisionClientFactory
    {
        // Add your Computer Vision key and endpoint
        static string? _key = Environment.GetEnvironmentVariable("AZURE_COGNITIVE_SERVICES_VISION_KEY");
        static string? _endpoint = Environment.GetEnvironmentVariable("AZURE_COGNITIVE_SERVICES_VISION_ENDPOINT");

        public ComputerVisionClientFactory() : this(_key, _endpoint)
        {
        }

        public ComputerVisionClientFactory(string? key, string? endpoint)
        {
            _key = key;
            _endpoint = endpoint;
        }

        public ComputerVisionClient CreateClient()
        {
            if (_key == null)
            {
                throw new ArgumentNullException(_key, "The AZURE_COGNITIVE_SERVICES_VISION_KEY is not set. Set a system-level environment variable or provide this value by calling the overloaded constructor of this class.");
            }
            if (_endpoint == null)
            {
                throw new ArgumentNullException(_key, "The AZURE_COGNITIVE_SERVICES_VISION_ENDPOINT is not set. Set a system-level environment variable or provide this value by calling the overloaded constructor of this class.");
            }

            var client = Authenticate(_key!, _endpoint!);
            return client;
        }

        public static ComputerVisionClient Authenticate(string key, string endpoint) =>
            new ComputerVisionClient(new ApiKeyServiceClientCredentials(key))
            {
                Endpoint = endpoint
            };

    }
}



The setup of the endpoint and key of the Computer Vision resource is done via system-level envrionment variables. Next up, let's look at retrieving OCR text from images. Here we use ComputerVisionClient. We open up a stream of a file, an image, using File.OpenReadAsync and then the method ReadInStreamAsync of Computer vision client. The image we will load up in the app is selected by the user and the image is previewed and saved using MAUI Storage lib (inside the Appdata folder). OcrImageService.cs


using Microsoft.Azure.CognitiveServices.Vision.ComputerVision;
using Microsoft.Azure.CognitiveServices.Vision.ComputerVision.Models;
using Microsoft.Extensions.Logging;
using System.Diagnostics;
using ReadResult = Microsoft.Azure.CognitiveServices.Vision.ComputerVision.Models.ReadResult;

namespace Ocr.Handwriting.Azure.AI.Lib
{

    public interface IOcrImageService
    {
        Task<IList<ReadResult?>?> GetReadResults(string imageFilePath);
        Task<string> GetReadResultsText(string imageFilePath);
    }

    public class OcrImageService : IOcrImageService
    {
        private readonly IComputerVisionClientFactory _computerVisionClientFactory;
        private readonly ILogger<OcrImageService> _logger;

        public OcrImageService(IComputerVisionClientFactory computerVisionClientFactory, ILogger<OcrImageService> logger)
        {
            _computerVisionClientFactory = computerVisionClientFactory;
            _logger = logger;
        }

        private ComputerVisionClient CreateClient() => _computerVisionClientFactory.CreateClient();

        public async Task<string> GetReadResultsText(string imageFilePath)
        {
            var readResults = await GetReadResults(imageFilePath);
            var ocrText = ExtractText(readResults?.FirstOrDefault());
            return ocrText;
        }

        public async Task<IList<ReadResult?>?> GetReadResults(string imageFilePath)
        {
            if (string.IsNullOrWhiteSpace(imageFilePath))
            {
                return null;
            }

            try
            {
                var client = CreateClient();

                //Retrieve OCR results 

                using (FileStream stream = File.OpenRead(imageFilePath))
                {
                    var textHeaders = await client.ReadInStreamAsync(stream);
                    string operationLocation = textHeaders.OperationLocation;
                    string operationId = operationLocation[^36..]; //hat operator of C# 8.0 : this slices out the last 36 chars, which contains the guid chars which are 32 hexadecimals chars + four hyphens

                    ReadOperationResult results;

                    do
                    {
                        results = await client.GetReadResultAsync(Guid.Parse(operationId));
                        _logger.LogInformation($"Retrieving OCR results for operationId {operationId} for image {imageFilePath}");
                    }
                    while (results.Status == OperationStatusCodes.Running || results.Status == OperationStatusCodes.NotStarted);

                    IList<ReadResult?> result = results.AnalyzeResult.ReadResults;
                    return result;

                }
            }
            catch (Exception ex)
            {
                Console.WriteLine(ex.Message);
                return null;
            }
        }

        private static string ExtractText(ReadResult? readResult) => string.Join(Environment.NewLine, readResult?.Lines?.Select(l => l.Text) ?? new List<string>());

    }

}
                                           

Let's look at the MAUI Blazor project in the app. The MauiProgram.cs looks like this. MauiProgram.cs


using Ocr.Handwriting.Azure.AI.Data;
using Ocr.Handwriting.Azure.AI.Lib;
using Ocr.Handwriting.Azure.AI.Services;
using TextCopy;

namespace Ocr.Handwriting.Azure.AI;

public static class MauiProgram
{
    public static MauiApp CreateMauiApp()
    {
        var builder = MauiApp.CreateBuilder();
        builder
            .UseMauiApp<App>()
            .ConfigureFonts(fonts =>
            {
                fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
            });

        builder.Services.AddMauiBlazorWebView();
#if DEBUG
        builder.Services.AddBlazorWebViewDeveloperTools();
        builder.Services.AddLogging();
#endif

        builder.Services.AddSingleton<WeatherForecastService>();
        builder.Services.AddScoped<IComputerVisionClientFactory, ComputerVisionClientFactory>();
        builder.Services.AddScoped<IOcrImageService, OcrImageService>();
        builder.Services.AddScoped<IImageSaveService, ImageSaveService>();

        builder.Services.InjectClipboard();

        return builder.Build();
    }
}



We also need some code to preview and save the image an end user chooses. The IImageService looks like this. ImageSaveService


using Microsoft.AspNetCore.Components.Forms;
using Ocr.Handwriting.Azure.AI.Models;

namespace Ocr.Handwriting.Azure.AI.Services
{

    public class ImageSaveService : IImageSaveService
    {

        public async Task<ImageSaveModel> SaveImage(IBrowserFile browserFile)
        {
            var buffers = new byte[browserFile.Size];
            var bytes = await browserFile.OpenReadStream(maxAllowedSize: 30 * 1024 * 1024).ReadAsync(buffers);
            string imageType = browserFile.ContentType;

            var basePath = FileSystem.Current.AppDataDirectory;
            var imageSaveModel = new ImageSaveModel
            {
                SavedFilePath = Path.Combine(basePath, $"{Guid.NewGuid().ToString("N")}-{browserFile.Name}"),
                PreviewImageUrl = $"data:{imageType};base64,{Convert.ToBase64String(buffers)}",
                FilePath = browserFile.Name,
                FileSize = bytes / 1024,
            };

            await File.WriteAllBytesAsync(imageSaveModel.SavedFilePath, buffers);

            return imageSaveModel;
        }

    }
}


Note the use of maxAllowedSize of IBrowserfile.OpenReadStream method, this is a good practice since IBrowserFile only supports 512 kB per default. I set it in the app to 30 MB to support some high res images too. We preview the image as base-64 here and we also save the image also. Note the use of FileSystem.Current.AppDataDirectory as base path here. Here we use nuget package Microsoft.Maui.Storage. These are the packages that is used for the MAUI Blazor project of the app. Ocr.Handwriting.Azure.AI.csproj



    <ItemGroup>
      <PackageReference Include="Microsoft.Azure.CognitiveServices.Vision.ComputerVision" Version="7.0.1" />
      <PackageReference Include="TextCopy" Version="6.2.1" />
    </ItemGroup>


The GUI looks like this : Index.razor


@page "/"
@using Ocr.Handwriting.Azure.AI.Models;
@using Microsoft.Azure.CognitiveServices.Vision.ComputerVision;
@using Microsoft.Azure.CognitiveServices.Vision.ComputerVision.Models;
@using Ocr.Handwriting.Azure.AI.Lib;
@using Ocr.Handwriting.Azure.AI.Services;
@using TextCopy;

@inject IImageSaveService ImageSaveService
@inject IOcrImageService OcrImageService 
@inject IClipboard Clipboard

<h1>Azure AI OCR Text recognition</h1>


<EditForm Model="Model" OnValidSubmit="@Submit" style="background-color:aliceblue">
    <DataAnnotationsValidator />
    <label><b>Select a picture to run OCR</b></label><br />
    <InputFile OnChange="@OnInputFile" accept=".jpeg,.jpg,.png" />
    <br />
    <code class="alert-secondary">Supported file formats: .jpeg, .jpg and .png</code>
    <br />
    @if (Model.PreviewImageUrl != null) { 
        <label class="alert-info">Preview of the selected image</label>
        <div style="overflow:auto;max-height:300px;max-width:500px">
            <img class="flagIcon" src="@Model.PreviewImageUrl" /><br />
        </div>

        <code class="alert-light">File Size (kB): @Model.FileSize</code>
        <br />
        <code class="alert-light">File saved location: @Model.SavedFilePath</code>
        <br />

        <label class="alert-info">Click the button below to start running OCR using Azure AI</label><br />
        <br />
        <button type="submit">Submit</button> <button style="margin-left:200px" type="button" class="btn-outline-info" @onclick="@CopyTextToClipboard">Copy to clipboard</button>
        <br />
        <br />
        <InputTextArea style="width:1000px;height:300px" readonly="readonly" placeholder="Detected text in the image uploaded" @bind-Value="Model!.OcrOutputText" rows="5"></InputTextArea>
    }
</EditForm>


@code {

    private IndexModel Model = new();

    private async Task OnInputFile(InputFileChangeEventArgs args)
    {       
        var imageSaveModel = await ImageSaveService.SaveImage(args.File);
        Model = new IndexModel(imageSaveModel);
        await Application.Current.MainPage.DisplayAlert($"MAUI Blazor OCR App", $"Wrote file to location : {Model.SavedFilePath} Size is: {Model.FileSize} kB", "Ok", "Cancel");
    }

    public async Task CopyTextToClipboard()
    {
        await Clipboard.SetTextAsync(Model.OcrOutputText);
        await Application.Current.MainPage.DisplayAlert($"MAUI Blazor OCR App", $"The copied text was put into the clipboard. Character length: {Model.OcrOutputText?.Length}", "Ok", "Cancel");

    }

    private async Task Submit()
    {
        if (Model.PreviewImageUrl == null || Model.SavedFilePath == null)
        {
            await Application.Current.MainPage.DisplayAlert($"MAUI Blazor OCR App", $"You must select an image first before running OCR. Supported formats are .jpeg, .jpg and .png", "Ok", "Cancel");
            return;
        }
        Model.OcrOutputText = await OcrImageService.GetReadResultsText(Model.SavedFilePath);
        StateHasChanged(); //visual refresh here
    }

}


The UI works like this. The user selects an image. As we can see by the 'accept' html attribute, the .jpeg, .jpg and .png extensions are allowed in the file input dialog. When the user selects an image, the image is saved and previewed in the UI. By hitting the Submit button, the OCR service in Azure is contacted and text is retrieved and displayed in the text area below, if any text is present in the image. A button allows copying the text into the clipboard. Here are some screenshots of the app.