ghul.raster 0.10.3

Prefix Reserved
dotnet add package ghul.raster --version 0.10.3
                    
NuGet\Install-Package ghul.raster -Version 0.10.3
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="ghul.raster" Version="0.10.3" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="ghul.raster" Version="0.10.3" />
                    
Directory.Packages.props
<PackageReference Include="ghul.raster" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add ghul.raster --version 0.10.3
                    
#r "nuget: ghul.raster, 0.10.3"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package ghul.raster@0.10.3
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=ghul.raster&version=0.10.3
                    
Install as a Cake Addin
#tool nuget:?package=ghul.raster&version=0.10.3
                    
Install as a Cake Tool

ghul-raster

CI NuGet version (ghul.raster) License ghūl

Drawing into a PNG, from ghūl, with no dependency on a graphics stack.

System.Drawing.Common is GDI+ and throws on Linux; SkiaSharp needs a native library; neither runs under WebAssembly. This library allocates a buffer, draws strokes into it, and writes a PNG, all in managed code, so it runs wherever ghūl does.

use Raster.IMAGE

let image = IMAGE(640, 400)

image.view(-0.5D, -1.4D, 6.8D, 1.4D)

image.colour(20ub, 60ub, 200ub)
image.stroke(2.0D)

image.line(x0, System.Math.sin(x0), x1, System.Math.sin(x1))

image.text(0.1D, 1.15D, 18.0D, "sin x")

image.write("plot.png")

Coordinates

view maps a rectangle of your own coordinates onto the whole image, with y increasing upwards as it does in mathematics rather than downwards as it does in an image. A program then works in the space its problem is stated in - the complex plane, a unit square, the range its data covers - and says nothing about pixels.

The rectangle is mapped exactly, so it stretches where its proportions differ from the image's: a circle drawn in a square rectangle on a 640 by 320 image comes out an ellipse. Choose proportions that match, or accept the stretch.

Without a view, positions are already pixels.

Given a fifth number, view maps the rectangle onto the image less that many pixels on every side, which leaves room outside it for axes. bounds is the rectangle the last view was given, and to_user undoes to_pixel.

Sizes are always in pixels, whatever the view. A label is as tall as it is asked to be and a stroke as wide, because a label that stretched with the axes would be unreadable on any plot whose axes differ in scale.

Strokes

Everything is drawn as a stroke, and coverage is computed from the distance to the line segment. Ends and joins are round, and the edges are antialiased. stroke sets the width in pixels; colour sets what is laid down.

Shapes

line takes a sequence of points as well as two, and draws through each in turn with the joins rounded the way a single stroke's ends are. It leaves the shape open, so to close one, repeat the first point.

fill takes the same sequence and fills the polygon it encloses, joining the last point to the first. Edges are antialiased by sampling within each row, and a boundary crossed twice leaves a hole rather than a second layer.

circle, disc and arc take a centre and a radius in user coordinates. disc is filled; the other two are strokes, antialiased the same way. An arc goes counter-clockwise from its first angle to its second, in degrees from the x axis, so 270 to 90 is the right half. A view that scales x and y differently draws each of them as an ellipse, as it would any other shape.

dot and ring mark a point in user coordinates with a radius in pixels, so they stay round on a view that scales x and y differently: the marker for a point on a plot. fill_rectangle fills between two corners.

Shapes filled one at a time each only partly cover the pixels along an edge they share, so the background shows through as a pale seam between them. SHAPES collects polygons, each with its own colour, and fill given a SHAPES fills them together, adding up what each covers of a pixel first, so a tiling or a row of bars meets without a seam. Where shapes overlap, their colours mix.

That mixing is why a surface drawn as depth-sorted quadrilaterals cannot use SHAPES: there the later shape has to cover the earlier one rather than blend with it. Fill those one at a time, and close the seam by stroking each shape's own outline in its own colour straight after filling it.

flood_fill recolours every pixel joined to the one it is given and the colour that one is. It takes pixels rather than user coordinates, as set_pixel does: the region is a property of the image rather than of what the view describes.

Colour maps

COLOUR_MAP turns a number from 0 to 1 into a colour, and colour accepts what it gives back, so a heat map is image.colour(COLOUR_MAP.viridis(value)) for each cell. viridis runs dark to light for a value that only grows; cool_warm runs blue through grey to red for one either side of a middle at 0.5; hue goes once round the colour wheel for one that wraps, and wraps with it. The first two clamp a value beyond either end.

Axes

AXES draws a frame round the rectangle the view was given, with ticks at round numbers along the bottom and left, each labelled, and a title for either axis if x_title or y_title is set. It draws outside the rectangle, in the current colour and stroke, so give the view a margin:

image.view(0.0D, -1.0D, 10.0D, 6.0D, 50.0D)

let axes = AXES(image)

axes.x_title = "n"
axes.draw()

fit sets the view itself, with each margin sized to what draw will put there, so a long or negative label is never cut off:

let axes = AXES(image)

axes.y_title = "height"
axes.fit(0.0D, -12500.0D, 10.0D, 2500.0D)
axes.draw()

x_ticks and y_ticks put ticks at given values with given labels in place of round numbers, one for each category of a bar chart, say. key adds a row to a key in the top right corner inside the frame: a swatch of a colour and a label.

Text widths are in pixels; to_user turns a pixel position into user coordinates for placing anything else by them.

The scales are linear.

Pixels

clear sets every pixel back to white, so a program drawing frame after frame can reuse one image rather than allocating another for each.

clear can also paint every pixel one colour, for a background. set_pixel with no colour writes the current one.

set_pixel writes a pixel outright and pixel reads one back. blend lays part of the current colour over what is already there, which is what a program drawing its own shapes needs to antialias them. A position outside the image is ignored by both writers.

Writing a PNG

write puts the image in a file, and encode gives the same bytes to a program with somewhere else to send them. The output is deterministic: the same pixels always encode to the same bytes.

Animation

ANIMATION collects frames and writes them as one animated PNG, which a browser plays and loops by itself. add copies an image as it stands, with how long to show it in milliseconds, so a program can clear and redraw the same image for each frame:

let image = IMAGE(160, 120)
let animation = ANIMATION(160, 120)

for index in 0..12 do
    image.clear()
    draw(image, index)
    animation.add(image, 80)
od

animation.write("swing.png")
animation.show("swing.png")

It loops forever unless loops says how many times to play. Every frame is the whole image, compressed on its own when it is added, and the output is deterministic. A reader that does not know about animation, IMAGE.read among them, sees the first frame.

ANIMATION.read and ANIMATION.decode read every frame back, with frame giving each as an image and delay how long it shows. Each frame is drawn onto what the ones before it left, as a browser would show it, so an animation that updates part of the image reads correctly too. A PNG with no animation reads as one frame.

As a GIF

write_gif and encode_gif write the same frames as an animated GIF, for somewhere that plays a GIF but not an animated PNG. A GIF holds at most 256 colours, shared here by every frame: if the frames use no more than 256 between them the GIF is exact, and otherwise 256 are chosen by median cut and each pixel takes the nearest, without dithering. A GIF counts delays in hundredths of a second, so they are rounded to the nearest hundredth, and browsers show anything under two hundredths for a tenth, so a shorter delay becomes two hundredths. loops carries over as the looping extension, which counts repeats after the first play; an animation that plays once leaves it out. The output is deterministic.

Reading a PNG

IMAGE.read decodes a PNG file into an image, and IMAGE.decode does the same from bytes already in hand. Eight bits a channel and not interlaced, which is what anything writing a PNG for a program to read produces: grey, truecolour and indexed, each with or without alpha. An image carries no alpha of its own, so a transparent pixel is composited onto the white it starts as.

A turtle

TURTLE is a pen on an image, at a position and a heading, told how far to go rather than where to go: forward, turn, face, move_to, pen_up and pen_down. A curve stated as a sequence of turns and steps is written that way directly.

Angles are in degrees and follow the image's coordinates, so with a view set a positive turn is counter-clockwise and without one it is the other way about.

Text

text draws with its baseline at the position given and the height in pixels from baseline to the top of a capital. An Anchor says whether the text starts, is centred, or ends at its position, and upward_text reads bottom to top, as a label beside a vertical axis does. text_width says how wide that text will be, for placing it. The weight follows the height unless it is given.

The face is a Hershey stroke font, so text is drawn with the same strokes as everything else, and scales without a rasteriser.

Saying where an image belongs

A program that draws has two outputs, and nothing in its text says where the pictures go.

image.write("plot.png")
image.show("plot.png")

show writes a line naming the image:

<<image plot.png>>

That is a contract with whatever displays the output, not an instruction to anything in particular. A publisher can turn it into a reference its destination understands and put the file where that reference points; a console that can display images can display it in place; a terminal shows the line, which names a file the reader was told about anyway.

Nothing here knows where the output is going, and nothing that reads the marker needs to know how the image was drawn.

A host that shows values - a notebook, a REPL - needs no file at all. An IMAGE implements Ghul.Renderable, offering itself as image/png, so a cell that ends on an image, or passes one to display, shows the picture where the host honours that type:

let image = IMAGE(200, 100)

image.line(10.0, 10.0, 190.0, 90.0)

display(image)

A host that does not honour it shows the image's text instead, as it would for any other value.

The font

The Hershey Fonts were originally created by Dr. A. V. Hershey while working at the U. S. National Bureau of Standards. The format of the font data was originally created by James Hurt, Cognition, Inc.

fonts/futural.jhf is the face and fonts/hershey.txt is the notice its distribution terms require to accompany it. build/embed-font.sh writes the face into src/font-data.ghul, which is what the library reads: the compiler emits no embedded resources (degory/ghul#2477), and a source string works under WebAssembly and single-file publish, where a file beside the assembly does not.

Tests

dotnet test unit-tests
dotnet ghul-test --use-dotnet-build integration-tests

The unit tests cover the font and the geometry. The integration test draws a plot and compares it against plot.png.expected, which is the assertion that matters: it is the whole picture, and it notices a stroke a fifth of a pixel too thin.

Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.10.3 60 9/22/2026
0.10.2 68 9/22/2026
0.10.1 89 9/21/2026
0.10.0 282 9/19/2026
0.9.2 138 9/19/2026
0.9.1 284 9/18/2026
0.9.0 5,317 9/18/2026
0.8.0 143 9/18/2026
0.7.0 77 9/18/2026
0.6.0 78 9/18/2026
0.5.1 76 9/18/2026
0.5.0 243 9/18/2026
0.4.1 274 9/10/2026
0.4.0 2,597 9/9/2026
0.3.0 95 9/9/2026
0.2.0 137 9/9/2026
0.1.0 90 9/9/2026
0.0.2 107 9/9/2026
0.0.1 101 9/9/2026