-
Notifications
You must be signed in to change notification settings - Fork 2
Changelog
A build history: what was built, when, and — the actual point of this page — why, in enough detail to stand in for the design conversation that produced it. Written in the order things were built, for whoever (human or Claude) picks this codebase back up later without the original context. This is a record of decisions already made, not a plan for what's still ahead — see Backlog for that.
-
The checked-read build, structure-aware mutators, four more findings from the fuzz campaign,
run_bandsand contextualDTypenaming, all released in v0.39.0. PR #474 closes #430:canvas/io/view.mojo's_ReadView/_WriteViewmake the image codecs' silent out-of-range reads abort under-D CANVAS_CHECKED_READS, at no cost to the production build (measured five ways within 1%); the campaign's structure-aware mutators for all five formats then found and this fixed four things the first campaign could not see -- an overflowable PNG dimension cap, a progressive JPEG needing more than 8 GB withIntcoefficients (nowInt32), a fontnametable's declared length sizing a 4 GB buffer (now clamped to the file), and a quadratic range merge in the GSUB path (now a packed-key sort). PR #476 confines the privatestd.runtime._asyncrtimport Mojo 1.1 requires to one function,run_bandsincanvas/workers.mojo, and in doing so designs #97's corruption out rather than working around it per site: the task now takes a closure of references and a band index, and that form is clean in 38,400 runs of the issue's own reproducer where the by-value form still corrupts on Mojo 1.1. PR #477 renames everyDTypereference to Mojo 1.1's contextual form (SIMD[.uint8, 16]), now house style in AGENTS.md. No rendered-output change: every digest matches v0.38.1. -
Mojo 1.1, on the private task runtime (PR #475, #472, released in v0.39.0). Mojo 1.1 made the async task API private:
std.runtime.asyncrtbecamestd.runtime._asyncrt, the publicstd.runtimekeeps onlyparallelism_levelandinitialize_runtime, and nothing public instdruns work on the thread pool --std.algorithm.mapis sequential, checked in the stdlib source. Every banded pass here is built onTaskGroup, so the choices were the private import, staying on 1.0 with #472 open until a public API exists, or going serial at 2x to 4x on every large pass. One source cannot serve both releases, since 1.0 has no_asyncrt. The owner took the private import, with the constraint at>=1.1.0,<1.2-- the versions tested, not a promise -- in the workspace, host and run dependencies and the consumer workspace, and the lock at 1.1.0. The rest of the migration was three renames:InlineArraytoArray, astattimespec read throughas_nanoseconds()(the field istv_subsecon 1.0 andtv_nsecon 1.1), and the sysctl name pointer throughas_c_string_span().ptr(). The whole 1.1 changelog was checked against the tree; nothing else it renames, deprecates or removes was present. On 1.1 the suite is 1127 tests clean with no warnings, every digest matches,bench-checkagainst the reference recorded on 1.0 is a median of 1.016 with no row flagged, and every example renders.The reason the module went private is #97, and the check that mattered was rerunning that issue's reproducer on 1.1: it is not fixed (64 wrong values in 19,200 by value; segfaults under
taskset -c 0,1). The same work reached through a closure's captures is clean in 38,400 runs, and that shape is what the follow-up (PR #476) makes the library's only one. -
The checked-read build and the structure-aware mutators, and the four findings the campaign they enabled turned up (PR #474, #430, second half, closes it). The first campaign's thirteen million clean inputs on the image codecs measured luck: their hot loops read through raw pointers, and a read past a buffer through one returns whatever byte is there until the address is unmapped.
canvas/io/view.mojois the answer,_ReadViewand_WriteView: in the production build a view is the pointer and nothing else, and under-D CANVAS_CHECKED_READS, whichpixi run fuzzpasses unless told not to, it also carries how far it may reach and aborts naming the index on a miss. Every decoder-side pointer site inpng,jpeg,bmpandinflategoes through one now; encoders keep their pointers, since their input is the caller's own canvas. The design settled on views parameterized by the list's origin, which the compiler checks, rather than an origin-erased pointer, which Mojo will not let a struct field hold; the one place that fought it, the PNG unfilter's zero "row above" from a separate list, moved to a spare row at the end of the output so both branches of "row above" share one type. The production cost was measured five ways and is nothing: five alternating rounds of a decode benchmark built from main and from the branch put every median within one percent, and those cases are rows in the micro harness now, so the next decoder change has a gate. The codec test files pass under the checked build too, which is the proof that every access the tests exercise is in bounds.The mutators know where a format keeps its lengths, checksums and counts, since a dumb mutation of a PNG mostly dies at the next CRC: a chunk's data, length or type with the CRC recomputed; an edge value into a JPEG frame, table or scan header, a segment length, a marker planted in the entropy data or a stuffing byte removed; a font table record's offset or length, a field inside a table,
numTables, two tags swapped, a run of a table cleared; a BMP header field; deflate bit flips concentrated in the block header. Half the time; the dumb ones stay for the other half.What the first checked round found, none of it visible to an unlimited replay, which is why the driver now keeps each finding's whole batch log: the PNG dimension cap could be overflowed, two 32-bit sides multiplying past 64 bits into a product that read as small, so a 69-byte interlaced file sized its buffer from the wrapped value; each side is checked before the product now, in PNG and BMP. A progressive JPEG frame under the cap, 3356 by 63016, needed more than the campaign's 8 GB limit because its coefficient store was
Int, eight bytes per coefficient per component; coefficients areInt32now, which halves every progressive decode's memory and keeps the worst capped frame under the limit, and their magnitude is bounded by the DC category shifted byal. Font discovery sized a buffer from anametable's declared length, 4 GB in a 5 KB file;_read_atclamps to the file before allocating, which its docstring already promised about short reads. And a round after those three found the last: a damaged Coverage table with tens of thousands of ranges in reverse order made the insertion sort in_sorted_merged_rangesquadratic, minutes per font, on an argument that Coverage tables come nearly sorted; it is a packed-key stdlib sort now, and a test of sixty thousand descending pairs would not finish under the old one.After the four fixes, a round of 12.7 million inputs across fonts, JPEG and PNG found nothing, on top of 42 million clean on the image codecs in the round before; about 94 million mutated inputs across the three checked rounds. The image codecs' zero is now evidence rather than luck. What remains outside the campaign's reach is recorded in the issue's close: the encoders, whose input is trusted, and the text shaper past
TTFFace, which the font path exercises only through outlines, advances, a kern pair and a few code point lookups. -
Mojo constrained below 1.1 (PR #473, #472, released in v0.38.1). Mojo 1.1.0 reached the
maxchannel and this package does not compile under it: theasyncrtmodule is gone from the stdlib andInlineArrayis no longer declared, which fails nine modules. The lock pinned 1.0.0 all along, but a downstream build environment solving fresh took 1.1.0 through the<2in this package's host dependencies, and no downstream constraint can override a dependency's own; dataviz_mojo's consumer job was how it surfaced (their #673).>=1.0.0,<1.1in all three places says what is true today, and v0.38.1 exists so a downstream pin can name metadata that is honest. Tagged without rerunningbench-check, on the owner's call: no drawing code changed since v0.38.0's check and the change touches onlypixi.toml. Supporting 1.1 is the open half of the issue. -
A mutation fuzzer for the five parsers that read untrusted bytes, and the fourteen checks its first campaign added (PR #471, #430, first half, released in v0.38.1).
scripts/fuzz_decoders.mojotakes a seed file, damages it a little -- bit flips, byte overwrites with random and edge values, truncation, block duplication and deletion, appended bytes, and stacks of those -- and hands it todecode_png,decode_jpeg,read_bmp,inflateor the font path (font_discovery's file scan, thenTTFFacewith a sample of its glyph outlines, advances, a kern pair and a few code point lookups) inside atry. A raise is a pass. The protocol is built around the fact that a failure kills the process rather than the iteration: every case is written to disk with a sidecar naming the seed, the mutation and the RNG state before the decoder sees it, andscripts/fuzz.sh(pixi run fuzz <decoder> <seeds> [minutes] [workers]) runs batches in parallel workers undertimeoutandulimit -v, so a crash, a hang and an allocation bomb each leave the case behind with a distinctive exit status. Not in CI: it needs hours on a quiet machine and its output is nondeterministic; CONTRIBUTING's Releasing section names it besidebench-check.Two facts checked before building it, since the issue's premises were the reason to fuzz at all. A
Listindex past the end aborts the process in a default build, not only under-D ASSERT=all, so a font parser that indexes without a length check kills whoever asked for text; and a Mojo binary runs underulimit -vat 8 GB and dies at 2 GB, so the memory limit is usable and is set at 8 GB. Both are now in AGENTS.md.What the first hours found, all of it in the first minute of each decoder and none of it in the second: in the JPEG decoder, a scan header naming Huffman table 4 through 15 indexed the four-entry table lists off their ends; a DHT with more codes of a length than the length can hold walked the lookup table past its end while it was built; DQT, DHT, frame and scan headers were each read past their segment's end; and a 1 KB file whose frame header claimed 65535 by 65535 pixels allocated a 17 GB canvas and ran for two minutes. In the font parsers,
chrof a lone UTF-16 surrogate in anamerecord aborted inside discovery, which scans every font on the machine and cannot afford to; a CFF DICT real number with no terminator read to the end of the file and its exponent loop, scaling by ten once per unit, spun for minutes; and a GPOS table naming feature and lookup counts in the tens of thousands made the linear "append if new" walk quadratic. The last is also a small design change:_feature_lookup_indicesnow returns one flag per LookupList entry rather than a list of indices, since both callers walk the LookupList and test membership, and the bitmap makes that constant time on real fonts too.The allocation bomb became a rule rather than a patch:
MAX_DECODED_PIXELS(2^28) incanvas/io/__init__.mojo, checked by the PNG, JPEG and BMP decoders from the header before any buffer is sized. It admits a 16384-square and refuses what only a crafted file asks for. BMP already needed the file to hold every row, so there it is consistency, not a fix.Every finding is a fixture under
tests/fuzz/or a hand-built table in the test file, with a rejection test naming the message, so the campaign only ever finds new things. After the fixes, a fourth round across all five decoders -- four minutes on JPEG, two each on PNG, deflate and BMP, twelve on the 358 installed fonts, about 58,000 batches -- found nothing. That is the expected shape for the image codecs at this stage: they read through unsafe pointers, and an out-of-range read that lands in mapped memory passes every run. The checked-read fuzz build and the structure-aware mutators that make those visible are the second half of #430.
-
One label from several styled runs, one
<text>on SVG (PR #468, #467, released in v0.38.0).SvgCanvas.draw_textwrites one<text>per call with one size and one slant, so a label that mixes them -- dataviz's math labels (dataviz_mojo#371) layEnergy $E = mc^2$ over timeout as six runs -- came out as six sibling elements: the picture right, the text lost as text, since a viewer selects fragments, copies them in draw order with no spaces, and announces six strings.DrawTarget.draw_text_runstakes a list ofTextRun(text, size, slant, dx, dy)and draws them as one label; on SVG that is one<text>with a<tspan>per run carrying its ownfont-size,font-style,dxanddy, which is SVG's own form for this. The raster, PDF and bounds backends draw each run withdraw_textat the anchortext_run_anchorsmeasures for it, so a run lands where that text drawn alone would, byte for byte; tests pin that identity on all three, and the trait test draws the same label through all four conformers.Two decisions in the run's offsets.
dxis a pen shift from where the previous run's advance ended, which is exactly a<tspan>'sdxand what a kern or a fraction's back-up is.dynames the run's baseline relative to the label's rather than SVG's carrying shift, because a layout engine knows each run's baseline and not the deltas between consecutive runs; the SVG backend writes the difference and omits it when unchanged. A run with no text draws nothing and moves nothing on every backend, so the four agree on the edge case rather than each choosing. On SVG the viewer's own font metrics advance the pen between runs and resolvealign, as they already do for a singledraw_text; that is why this backend carries the kern and rise and not the anchors the others measure, and it is the better behavior there, since a superscript stays snug against its base in whatever font the viewer has.weightandfamilystay on the label rather than the run: nothing asked for them per run and a field with a default can be added without breaking a caller.TextRunis its own leaf module for the reasonTextAlignis: the trait andCanvasname it in a signature and cannot import render.mojo. An out-of-repoDrawTargetmust add the method; the loopCanvasuses is the whole implementation. Found along the way: the text guide on the docs site still said text is not part ofDrawTarget, stale since v0.35.0, and is corrected. -
CI packages through
mojo precompile, and the logs carry no warnings of ours (PR #469, released in v0.38.0). The package job printed two deprecation warnings from the pixi-build-mojo backend,'package' is deprecated; use 'precompile'and'.mojopkg' file extensions is deprecated. The backends that package withprecompile(0.2.6 and later) need pixi-build-api-version 7, which arrived in pixi 0.77.0, and CI pinned v0.76.2, so the manifest's floating0.*quietly resolved an older backend; a floor alone would have failed the solve with no candidates. The fix is the one dataviz_mojo made in its #646 for its #603, reported on request by that session: everysetup-pixipin to v0.78.0 (the version verified, not the newest; 0.79.0 exists untested) and the backend floor>=0.2.7,<1as the guard against regressing. The consumer step now tees its build log and fails ifmojo precompileis absent or either warning returns, because the warnings are the only symptom of the backend falling back and nothing else fails when it does; it stays one step since the backend builds on the firstpixi runin that workspace. The warnings that were this package's own went in the same PR: three implicit references toColorthrough the package__init__in batch.mojo, two lowercase docstring lines inPath.arrow_head, and two unused variables in tests.
-
A PDF embeds the machine's font file, and that is now written down (PR #463, #462 filed alongside). A consumer added a PDF column to a cross-platform output digest and watched all 66 documents fail on macOS by +18 to +21 bytes each, while the raster and SVG columns matched byte for byte. Nothing was wrong on either side, and that is the point:
pdf_font.mojocopieshead,hhea,maxp,hmtxand the hinting programscvt,fpgmandprepverbatim out of the filefont_discoveryresolved, and rebuildsglyffrom its outlines, so the document is a function of which build of a family the machine had. Two packagings agree on outlines, which is exactly why the raster digests matched, and need not agree on the bytes around them; the hinting programs are the likely delta, since a rebuild routinely reships those while leaving outlines alone.SvgCanvasnames the family in CSS and embeds nothing, so it never had the dependency. The consumer's own diagnosis blamed a version string or anametable entry, which the subset does not carry at all -- worth recording, because a plausible cause that the code rules out is the kind of thing that survives into a downstream design decision if nobody checks it.The half that bounds the claim mattered as much as the claim, and only this side could establish it: there is no creation date, no
/IDand a fixed/Producer, so one font file on one machine renders byte-identical documents run to run. That is a guarantee nothing in the rendered page shows, so it got a test that reads the bytes rather than the pixels -- a timestamp added later would break every downstream digest and pass every other test intest_pdf.mojo. The scale is worth knowing before anyone builds a gate: a two-word label embeds about 53 KB of subset font, compressed to roughly 12 KB of a 14 KB file, so a PDF digest is mostly a hash of the machine's font.Verifying the docstring turned up a defect under it, filed as #462 rather than fixed here.
/BaseFontis built from the font file's stem rather than the font's own name, so the variable fontUbuntu[wdth,wght].ttfembeds asAAAAAA+Ubuntuwdthwghtwhere itsnametable saysUbuntu-Regular. No viewer draws from that field, so nothing renders wrong; what is wrong is that the document misreports its own font, and does so as a function of the packaging, which compounds the provenance issue rather than sitting beside it. -
BoundsTarget, aDrawTargetthat measures a scene's ink without drawing it (PR #461, #460, released in v0.37.0). dataviz_mojo's publication export wanted thebbox_inches="tight"of a figure: crop to the ink so a chart with a short title or no legend does not carry the whitespace a fixed figure size reserved for one. Nothing could answer "how much of this target has ink on it". A rasterCanvascan be scanned for non-background pixels, butSvgCanvasandPdfCanvashold markup and operators with nothing to scan, so a raster-only crop would have been a publication feature that works for PNG and not for SVG or PDF. And the consumer could not compute it either: most primitives never build aPath, and the extents that matter are per-primitive knowledge this package holds -- a stroke's box is its outline with the width, caps and joins, an arc's needs the fill's flattening, an anti-aliased edge spills half a pixel, text's needs the font metrics and the layoutdraw_textperforms. Reproducing that downstream would be a second implementation of every primitive's geometry kept in step by hand.Two shapes were on the table: a running box maintained by each backend on every drawing op, or a fourth conformer that discards the drawing and keeps only the union of extents. The second won on every axis. Nothing changes in the three backends, so no existing output can move and no backend gains an invariant to keep; only callers that ask pay, by rendering twice, which for an opt-in tight export of a chart is a fair price; it is the same idea
measure_textalready is; and a primitive added to the trait later has to be implemented there anyway, so the measuring target is a compile error rather than the silently missing extent the running box invites on every future primitive. It turned out to be mostly assembly:Path.boundsandPath.stroke_boundsfor fills and strokes, the path builders the fills fall back to for disks, wedges and rings,fill_rect's own snapping for rectangles and images,measure_text_blockfor text. Under a similarity a stroke maps the path and scales the width and dashes, which is exact; under a skew, and for text under a canvas rotation, the user-space box is mapped corner by corner, which is generous and says so in the docstring, and which a chart's unrotated page never meets.The one genuinely new rule is anti-aliased spill: pixel
kis the square[k - 0.5, k + 0.5], soink_pixels()counts every pixel whose square the geometry enters. It is stated once, as_PIXEL_HALF, and the test defines it rather than tolerating it, per the discussion on the issue: each of fourteen primitives is drawn into aCanvasand aBoundsTargetthrough one generic function, the canvas is scanned, and the scan must be a subset of the box with the box at most one pixel wider on any side. The rule a reader can check is "the box covers every pixel with any coverage at all"; erring outward is the safe direction for a crop, since a box that excludes a barely-inked pixel shaves the edge it was cropping to, and the one generous pixel is an extent whose coverage rounded to zero. Two expectations in the first draft of the tests were wrong in ways worth recording: a MITER join at a right angle reaches exactly half the width past the vertex, so it cannot be told from a BEVEL there and the acute corner is the one to assert on; and a geometric edge landing on a pixel center is a snapping tie that_snap_rectresolves upward, exactly asfill_rectdoes, so the measured box agreed with the ink and the test did not.It is also the first conformer in the repository with no buffer, string or page behind it, and so the first proof that the trait can be implemented from outside the three backends -- a property the release notes had promised to out-of-repo targets since v0.27.0 without a fixture for it.
test_package_exportsnow draws the same generic scatter into it that reaches the batched raster path. -
Path.extend, so a transformed shape can join an existing path (PR #444, #443, released in v0.36.0).transformedreturns a new path, so a shape that has to be mapped by its ownTransform2Dbefore it joins a larger path had nowhere to go --regular_polygon's own docstring named the case (a hexagon regular in data space, drawn through two axis scales, built at unit radius and mapped into place) and left it unfinished, since there was no way to fold the mapped copy into a shared path afterward.extend(other)appendsother's commands ontoselfand adopts its current point and sub-path start, which is exactly the bookkeeping thecommandsfield's own docstring already flagged as unreachable from outside: "the builder methods keep the current point and sub-path start in step with it, and an appended command would not."otherbegins with its ownmove_toand lands as its own sub-path; aclose()afterward closesother's sub-path, not whateverselfheld before; extending by an empty path is a no-op.bounds()/stroke_bounds()over an extended path equal the union of the two paths' own for free, since both already loop over every sub-path_flattenreturns. A rendering test pins the property the method exists for rather than just asserting the command list: two unit right triangles tiling a square along its diagonal, mapped by the same transform,extended into one path and filled once underFillRule.NONZEROleave the diagonal fully covered, where the same two triangles filled by two separatefill_path_aacalls do not -- the seamfill_mesh's own docstring measures (#425), reproduced here as an assertion instead of a one-off claim. This was the last open item from the dataviz_mojo audit of 2026-09-13 (dataviz_mojo#579), which had kept its own hexagon vertex math specifically because there was nowhere for a mapped copy to go. -
Canvas's struct docstring no longer deniesdraw_textexists (PR #445), released in v0.36.0). It still read "There is nodraw_textmethod, sinceDrawTargethas none. Callcanvas.text.render.draw_text(canvas, ...)" after v0.35.0 putdraw_texton the trait andCanvasimplemented it -- both halves went false and the struct docstring was never updated alongside the trait one. Flagged by a consumer mid-refactor of its own text-replay plumbing down to the trait method, who had to checkCanvasactually conformed before trusting a docstring that denied the method it was about to depend on. New text keeps the pointer to the free function, which still has capabilities the trait method lacks: the kerning and ligature switches, and the whole-pixel and cache-less overloads.
-
Six small things a chart layer had been building for itself, from an audit of dataviz_mojo against this package on 2026-09-13. Each was code downstream that either duplicated something here or existed only because something here was missing; the rule for what moved is that a drawing-layer argument (how anti-aliased edges seam, what the pixel convention is, what a path builder winds) belongs beside the code it describes. The chart-shaped parts -- marker sets, dash presets, theme scaling -- stayed where they were.
-
Text on the trait (PR #442, #431, released in v0.35.0).
DrawTargetgainsdraw_text, in the raster free function's keyword order with a sub-pixel anchor and acache=keyword, implemented by all three backends. The trait docstring had said text was backend-specific; that was true beforePdfCanvasdrew real text through the shared layout, and since then the three have differed only in how they emit glyphs, which is the kind of differencefill_path_aaalready hides. What the omission cost is the reason to record: a consumer rendering through the trait could not draw a label where it laid one out, so it recorded every label as a request and replayed the list per backend afterward -- 35 replay sites -- and could not add PDF output without a third copy of that plumbing.Canvasforwards to the free function and is byte-identical to it;PdfCanvasis the same call with the cache in the signature and does not read it, because the font subset it embeds is built from the faces its own cache resolved and has to outlive any one call.SvgCanvasis where the backends genuinely differ: raster and PDF resolvefamilyagainst installed fonts, SVG emits a CSSfont-familyread by whatever renders the file. The new overload takesfamilyin the raster backend's terms and maps the three generic names ("Sans","Serif","Monospace") to their CSS keywords, passing anything else -- a face name, an explicit stack -- through verbatim; that is the smallest thing that makes one call mean the same on all three, and a mapping rather than a different default because"Sans"is the value a caller writing against the trait will pass. The whole-pixel SVG overload stays and writes the bytes it always did, which a test pins.measure_textstays off the trait: it is backend-independent already. -
Path.curve_to_through(PR #441, #432, released in v0.35.0).curve_throughalways opens with amove_to, so a closed shape with two smoothed edges -- a band between two series: top edge left to right, bottom edge back,close-- could not be built from it, and the consumer kept a copy of the Catmull-Rom math for the second edge. The continuation form takes the current point as implicit, asline_todoes, and the points after it; the tangent at the join is one-sided there exactly ascurve_through's is at its first point, and the commands are thosecurve_throughwould add for the current point followed by the rest, minus itsmove_to, which a test compares field for field.curve_throughis now amove_toplus the shared segment loop, so the two cannot drift. The issue's sketch had the current point repeated aspoints[0]; the implicit form was chosen because it matches every other continuation call onPath. -
snap_to_pixel_edgeandsnap_to_pixel_center(PR #440, #436, released in v0.35.0). The two snaps a caller makes against the pixel convention this package owns, next toround_to_int: a coordinate to the nearest boundary for a filled rectangle's edge, and to the nearest center for a hairline. The reason they are here rather than in each consumer is a property of the primitives: theFloat64fill_rectsnaps in device space, and underbegin_supersampledthat turns a user-space fraction into an anti-aliased edge after the downsample rather than removing it, where a user-space snap puts the mapped edge on a device block boundary and the downsampled edge stays hard. A test draws both and reads the columns. The edge snap carries a tie rule and a 1e-9 tolerance: a coordinate exactly on a pixel center is where data lands all the time, a scale's multiply-add can put it one ULP under, and without the tolerance that ULP moved the edge a whole pixel, deterministically on one platform and differently on another (dataviz_mojo#314). A pixel-scale coordinate's ULP is under 1e-10; a real fraction is never that small. -
Path.arrow_head(PR #439, #435, released in v0.35.0). A closed arrowhead from a tip, a direction (normalized here, so a caller passes the shaft's own vector), a length and a half width. The consumer's copy justified itself with a rasterization argument -- one filled path rather than three strokes, because pieces meeting along an anti-aliased edge show a pale seam -- which is the sign the builder belongs beside the fill code. The same argument applies where the shaft meets the head, so the docstring says to stroke the shaft to the tip, under the head, not to the base, and a test keeps that as an assertion on the pixels across the join. A path builder rather than a stroke cap so it serializes the same way on every backend; an SVG<marker>would be one backend's feature. -
Path.regular_polygon(PR #438, #434, released in v0.35.0).sidesvertices on a circle about a center, the first atrotation, wound asrectandellipseare so a polygon with a hole behaves underNONZEROas those do. Hexagons, triangles and diamonds had been built vertex by vertex, each with its owncos(30°). Unrotated, the first vertex is at three o'clock, so a square is a diamond;-π/2gives the pointy-top hexagon and the upright triangle. The docstring says the thing a chart hexbin needs to know: a hexagon regular in data space and drawn through two axis scales is not regular in pixels, so it is a unit polygon plustransformed. -
Colorcompares and prints (PR #437, #433, released in v0.35.0).==and!=on all four channels exactly, with no tolerance: the caller merging same-colored cells into one rectangle needs identity, and "close enough" would blur a real boundary.Colorprints asColor(r, g, b, a)so a failedassert_equalnames the channel that differed, for the reasonPathOpbecame printable in #234. -
A color per vertex, interpolated across each face (PR #428, #426).
fill_mesh_shadedisfill_meshwith one color per vertex rather than per face: a smoothly shaded surface rather than a faceted one, which nothing here could draw before, since the gradients are one-dimensional and a triangle with three arbitrary corner colors is not a linear gradient. Built on the mesh rasterizer rather than beside it, which was the point of doing the flat mesh first: the edge functions it already walks are barycentric weights up to the face's area, each affine in (x, y), so a channel is a value at the box corner stepped by a constant per sub-sample -- four adds per sub-sample on top of what the flat path already does. Interpolation follows the canvas's color space, the rule source-over follows on the same canvas: corners go to linear light and each sub-sample is encoded back under LINEAR, so black to white reads 128 at the midpoint on an sRGB canvas and 188 on a linear one, and a smooth face agrees with the same face drawn as many flat ones at intermediate colors. A face whose corners share a color is byte-identical tofill_mesh's.Two decisions worth the record. A different name rather than an overload of
fill_mesh, because a color per vertex and a color per face are the same type and differ only in count; a call whose meaning depends on a count changes meaning when the mesh does, and there are meshes where the two counts coincide. And the vector backends split for the first time: PDF has a Type 4 free-form triangle mesh shading natively and emits it as a stream object, each vertex a flag byte, two 32-bit coordinates over the mesh's bounding box and three color bytes, painted withshunder the transform. SVG has nothing shipping -- mesh gradients are SVG 2 and no browser renders them -- so it draws each face flat at the mean of its corners, as every SVG exporter does. That is the first place the three backends do not produce the same picture by design, and the trait docstring says so and tells a consumer whose SVG must match the raster to usefill_meshwith a color per face instead. The alternative, subdividing faces until the steps are sub-pixel, multiplies the face count by the square of the subdivision and defeats the mesh primitive it sits on. -
A mesh of adjacent faces drawn as one seam-free shape (PR #427, #425).
fill_meshtakes vertices, index triples in draw order and a color per triangle, onDrawTargetand all three backends. It exists because the plotting layer wants 3D surface plots, which are projection and depth-sorting on the caller's side and, on this side, one thing the existing fills could not do: two anti-aliased fills sharing an edge each blend their partial edge coverage against the background rather than against each other, so along every shared edge a light line shows. Measured before building anything: two same-colored triangles sharing a diagonal, 52 interior pixels off the fill color along 60 pixels of edge, the worst by 53 levels; the hard-edged fill had none and a stroke over the edge cut it to 4. A surface has an edge between every pair of faces. No sequence of per-face fills can fix it, because the fix needs to know the other face is there, which is why this is a call that sees the whole mesh and not a patch tofill_path_aa.The rasterizer is the obvious one done carefully rather than a clever one. Every face is rasterized hard-edged at 4x4 sub-samples per pixel, in draw order, into a scratch that starts transparent; a sub-sample centre exactly on an edge belongs to one face by a fixed test on the edge's direction, and since two faces sharing an edge traverse it in opposite directions once both are wound the same way, exactly one of them passes. That is the entire seam argument: no gap, no double cover, and a translucent mesh is a single translucent layer rather than one that darkens along its own edges. A later face covering a sub-sample replaces or blends over the earlier one, which is the painter's algorithm a depth-sorted mesh relies on and which an accumulate-and-normalise scheme cannot provide, since a surface that folds over itself has faces that genuinely overlap. Each pixel is then the premultiplied mean of its sub-samples -- premultiplied because a straight-alpha mean pulls a covered sub-sample's color toward the transparent black of an uncovered neighbour and fringes the silhouette dark -- composited through
set_pixel, so clip, blend mode and color space apply as for any primitive. Vertices snap to 1/256 of a pixel: the ownership test needs exact arithmetic, and at 1/16 the snap moved edges enough that a single triangle differed from the polygon fill on 193 edge pixels, where at 1/256 it differs by at most one coverage level along the outline and nowhere else, which is the test's assertion.It records as one op in a batch or a supersampled region, the way a bulk marker call does (#415), and each band takes its rows sixteen at a time so the sub-sample scratch stays under a megabyte whatever share of the canvas the batch's own split handed it. On 9,800 colormapped faces at 800x600, medians of ten passes with each mode in its own process: 23 ms as one
fill_path_aaper face, 9 ms for the same inside a batch, 5 ms as onefill_mesh-- faster than the batch of fills it replaces as well as seam-free, and byte-identical across passes in every mode. SVG emits a<path>per face with a half-pixel stroke in its own color when opaque, since browsers seam identically and that is matplotlib'sedgecolor='face'; PDF fills and strokes together at width 0, the device hairline. A translucent face is left unstroked on both, because the stroke would double its alpha along every edge and trade a lighter seam for a darker one; that limit is in the trait docstring rather than discovered.Depth buffering is recorded as out of scope in the issue rather than left as an omission: the painter's algorithm has no correct order for intersecting surfaces, every static plotting library accepts that artifact, and a depth test needs edge ownership of a different kind -- a second rasterizer beside this one rather than an extension of it.
-
A data race crashing repeated supersampled renders (PR #413, #412, released in v0.33.2). The recordable clip added a day earlier records an op that mutates the clip stack of the canvas it replays onto. That is safe in a region's own replay, where every band owns its scratch -- which is exactly why recording clips was possible -- and a data race when the bands share one canvas, which is what happens when something else in the region forces it to materialize and the remaining ops go through the ordinary flush. The stack is a
List, so parallel tasks pushing and popping it corrupted the heap: the runtime died rather than the picture coming out wrong, which is why the consumer's stack trace pointed atlibAsyncRTRuntimeGlobalsand not at this package. A batch holding clip ops now renders on a single band, which is the fallback path anyway.It took a clip and something unrecordable in the same region, which is why bisecting was worth the runs: the full mix crashed three of three, without the bulk marker call it was clean three of three, without the clip clean three of three, and removing text or the nested batch changed nothing. The reason nothing here caught it is worth recording on its own -- every test for the region drew once, and a program drawing charts draws in a loop. The consumer saw that too and said so.
tests/test_supersampled.mojonow renders a mixed region twelve times over.Auditing the band path for the same pattern turned up a second instance, unreported: each band wrote its downsampled rows back through the public
draw_canvas, which draws what is pending on the destination first, and that destination is the canvas every band shares. Nothing is ever pending there, since the batch is taken before the replay begins, so it was a race on state that happened to be inert rather than a second crash. Removed rather than relied on. The region keeps its speed either way: a clipped scatter is 3.14x the two-step against 3.02x unclipped, byte-identical both ways. -
A supersampled region gives up its banded replay instead of corrupting the output, and a rectangle clip records (PR #410 and #411, #409, released in v0.33.1). v0.33.0's region was byte-identical for what it was tested on and wrong for two primitives it was not. The consumer found both within a day:
fill_circles_aacame out at 491 ink pixels where the recipe gives 411, and apush_clipinside a region left nothing at all. Both are onDrawTargetand reachable from a generic caller, so both belonged in the byte-identity comparison from the start.One cause, not two. Anything inside a region that draws rather than records -- a bulk marker call, a clip, a gradient,
blur,draw_canvas, a hard-edged primitive -- draws what is pending and then itself, both in the enlarged coordinate space, and the flush wrote that into the output buffer, which holds a fraction of those rows. It was worse than wrong pixels:in_boundsadmits enlarged rows while a region is open, so those writes ran past the end of the buffer. So the fix is at the flush rather than in the two primitives that happened to be found: a flush inside a region materializes it, the canvas takes the enlarged buffer as its own, what was recorded replays into it, andend_supersampleddownsamples it back. The result is the two-step recipe's, which is what the region promises; only the memory and the speed are given up, and only when something forces it.The clip then became worth recording, and the consumer's measurement is what decided it rather than a guess: their clip lives in four layer functions, only the scatter one resolves to factor 3, and there the two phases are 3.25 ms of a 6.25 ms render. Every other factor-3 mark they draw pushes no clip and already took the fast path; their factor-1 marks have no enlarged buffer to avoid. One primitive for one mark family, worth doing because that family is the most-drawn chart in the package. A push and a pop record as ops carrying the already-intersected device rectangle, applied to a band's own clip stack in submission order and applied whatever rows the band covers -- skipping one because its rectangle misses the band would leave the stack unbalanced for every op after it. A clipped scatter is 2.56x the two-step against 2.76x unclipped, byte-identical either way.
Two limits are deliberate and both came out of a test rather than a design note. Recording happens only inside a banded region: a plain batch replays onto the canvas whose stack already holds the entry, and recording there applied the clip twice, which
tests/test_batch.mojocaught as one level off on one pixel. And only the rectangle form records; a clip pushed under a rotation is a coverage mask and still flushes. -
A supersampled region that never holds the enlarged buffer (PR #408, #391, released in v0.33.0).
begin_supersampledrecords every shape drawn after it andend_supersampledreplays them one output band at a time into a scratch holding only that band, downsampling each into the canvas; the 16.5 MB intermediate an 800x600 canvas at factor 3 used to need never exists whole. The API owns the half-pixel shift box downsampling costs, which is the detail every consumer would otherwise reimplement.The issue was closed twice on judgement before it was built, and both closures were wrong. The first was on the consumer's stated priority rather than evidence. The second was on a spike that measured 2.2x slower -- but that spike ran the bands serially, each calling a
downsamplethat fanned out across 64 workers on a 576 KB source, which puts the parallelism in the wrong place. With the band as the unit of parallelism, each task owning a scratch it first-touches and downsampling it serially, the same two phases are 1.80x faster. The lesson is the one_MIN_PARALLEL_PIXELSand_read_local_bandsalready encode, arriving from a new direction: many small parallel calls lose to one large one, and a spike that loses can be measuring the implementation rather than the design.Text is what made it worth having. A cached glyph is a coverage mask composited row by row, so it records as
_OP_GLYPHwith its counts copied into the batch -- the cache entry can be evicted before the batch draws -- and replays per band. Routing text through the outline route instead needs no new op kind and was tried first, but differs from the cached route by one level on 227 of 14,400 pixels, because the mask cache quantizes a glyph's sub-pixel placement and the outline route does not; recording the mask keeps the region byte-identical, which is the contract a consumer with goldens needs. On a chart-shaped render verified byte-identical over all 480,000 pixels: the two-step 46,502 us against the region's 10,466, 4.4x, where the same scene without text is 1.08x. Compositing a glyph into a 16.5 MB buffer costs what compositing it into a 576 KB band does not.Canvasgained a row origin and a virtual size used only by its own addressing, so geometry recorded in the enlarged space draws into a buffer holding one band of it;row_bounds()is what a rasterizer clamping to "the canvas" should ask. Two bugs in that were found by the byte-identity test rather than by reading: the translucent span path computedy * stridedirectly and ignored the origin, and while recording the vertical clamp still used the buffer's height, so anything below the output's own row count was clipped away at record time -- which is why factor 2 passed while 3 and 4 failed. Each band starts from the region's background rather than from what the canvas already shows; seeding from the canvas was measured as a per-pixel copy and as a nearest-neighbour upscale, and both cost more than the region saves. Peak memory is not obviously better, since thirty band scratches can be live at once: the win is time. -
What is left of a stroke's serial prologue, priced (PR #407, #383 closed). Three candidates remained after #385 and all three are measured away. The band edge window fails on its premise: a band of the noisy dashed series needs 1,304 of 64,811 edges but they are scattered across a 34,499-edge span, because the table is in emission order along the path and a wavy series revisits the same rows along its whole length, so a window over that order is 1.005x. Sorting the table physically by top does make it tight (3,083 edges) and a band drops 296.9 to 163.8 us, 1.81x, but that saving is per band and the bands are parallel, about 133 us of wall time, while the sort is serial ahead of them and would have to permute seven 64k arrays inside that budget; and reordering changes the order the Float32 accumulator sums deposits in, so bytes move. The outline geometry is transcendentals rather than bookkeeping: ROUND joins build in 319 us against BEVEL's 219 on the smooth series and 618 against 248 on the noisy one, emitting 23,606 edges against 12,254, which is
cos/sinper arc point, and_arc_stepsalready scales the count with radius above a floor the docstring justifies with a measured 0.076 px error. The two_side_at_vertexcalls per vertex duplicate only a cross and a dot product; theatan2already runs once, since exactly one side is outer.The third finding is the most reusable and is now in AGENTS.md: an
appendinto aListthat already has capacity costs what a pointer store costs, 23.62 against 23.61 us over 24,000 of each. So "write through a pre-counted layout" is not a fix for an append-heavy builder, and what #385'sadd_ring/add_rectactually removed was a call and its seven separate appends. Still unattributed, and the reason the issue is closed as documented rather than solved: the dashed series' 1.7 ms outline build against 0.86 ms for_stroke_piecesof the same stroke, which needs a profiler this machine does not have at paranoid level 4. -
A batch gathers its paths' commands into one list (PR #405, #390). Recording a path into a batch copied the whole
Path: an allocation and a memcpy per op, and 2,000 frees on the calling thread when the flush ended. Points, dashes and edges already went into the batch's side lists; the commands now do too, each op holding a range, which is the pattern #387 established and the reason it gave -- per-op heap traffic does not scale on this machine._flattengrew a range-taking form so the build stage reads a path out of the shared list without aPathto hand it. Measured homogeneously, three runs of each build on a quiet 3970X: the record of 2,000 diamond markers 777 -> 690 us (1.13x), and the survey row 2137.7 -> 1910.4 (1.12x) with its one-call-each control unchanged, both agreed on by two separate recordings. What remains is about 345 ns per path against 280 for a stroke and 195 for a disk, which is the_BatchOpconstruction and append rather than the path, so the issue's "within a small factor of the stroke record" is met.Only that row was re-recorded, which is a departure worth stating. The same two quiet recordings disagreed by up to 1.19x on rows the branch never touches, and both put the two batched
fill_circles_aarows 13% and 18% above their committed values -- the bimodality #398 measured. Re-recording the table wholesale would write one mode of those rows into the ratchet and raise the floorbench-checkcompares against on code nothing changed, so the table keeps its committed values everywhere else. An interleaved harness timing the disk, stroke and path records in one loop was discarded before any of this: it showed the stroke record moving 1.19x, which the branch cannot affect. -
A note that an SVG clip's rect is an element a markup test will count (PR #406). The consumer that asked for the trait's clip adopted it in v0.32.0 and found eight of its tests changing counts: they counted
<rect>elements, and a pushed clip mints a<clipPath>holding one.SvgCanvas.push_clipdescribed the markup already but as a feature rather than as something an assertion trips over; it now says to count inside<defs>separately or strip the defs first. -
push_clipandpop_cliponDrawTarget(PR #404, #403, from dataviz_mojo#369). A chart draws every mark through one function generic over the trait, so with an axis domain narrower than the data the marks paint over the axis labels and off the canvas: 268 stray pixels for a line and 67 for a scatter on a 400x300 chart. Clipping at the concrete backend outside that generic function would clip the axes too, and clamping vertices moves a crossing segment's intersection rather than cutting it. Clipping had been off the trait for exactly the reason CONTRIBUTING gives for everything else that is off it -- no concrete caller went through the trait -- and that reason expired, the same shape as #368 where a comment explained a restriction whose premise had changed. Only the rectangle goes on; a path clip still has no caller. All three backends already had the pair with identical semantics: user space, taking the current transform, intersecting with whatever is already clipped, and a pop that is a no-op when nothing is pushed.The one departure from what the issue asked for is that the trait method does not raise.
PdfCanvas.push_clipraised only because it built aPathto map the rectangle under a transform, and its own docstring said "Never for a rectangle; the signature is the path builder's"; an affine map takes a rectangle to a parallelogram, so the four mapped corners are now written directly. That keeps the trait's stated principle --fill_circles_aawith a colour list is the only method on it that raises, being the only one whose arguments can disagree -- and it means a generic mark layer needs notry. The test is the consumer's own measure driven through a[T: DrawTarget]function: a line crossing both plot-rect boundaries and markers either side, with zero pixels of any mark colour outside the rectangle on the raster backend, a closed<g clip-path=>wrapper on SVG andre W ninside aq/Qpair on PDF. Breaking for anyDrawTargetimplemented outside this repo. -
The L3 slice is asked of the machine rather than written down (PR #400, #399). Two thresholds turn on how much last-level cache one core reaches without crossing to another complex -- whether banding a pure-store clear pays (
_clear_bands) and how far a read-heavy pass should spread (_read_local_bands) -- and both were written as 16 MB, one L3 slice of the 3970X they were measured on, declared separately in two files. That is wrong in both directions elsewhere: on a laptop with 8 MB of L3 a 12 MB clear misses cache and stays serial when banding would win, and on a part with larger slices a 20 MB clear bands when it should not.canvas/machine.mojoreads it once per process and caches it -- Linux names the last-level cache of cpu0 in sysfs, macOS answershw.l3cachesizeand falls back tohw.l2cachesizeon a part with no conventional L3 -- and keeps the measured constant when the platform says nothing or says something outside 1 MB to 1 GB. The clamp is load-bearing: a platform reporting 0 would otherwise set the threshold to zero and band every clear. On this machine sysfs reports16384Kforindex3shared by cores 0-3 and their SMT siblings, so the probe reproduces the hand-tuned constant exactly and no row moves.What macOS actually reports was then measured on CI (PR #401, a test that prints each platform's answer, since a passing suite cannot distinguish the platform answering from the clamp falling back).
hw.l3cachesizeis 0 on Apple Silicon, so the fallback tohw.l2cachesizeis what answers, and it equalshw.perflevel0.l2cachesize-- the performance cluster's L2, which is the cluster rendering work runs on, not the efficiency cluster that would have been the wrong threshold. The value is not one number: two runs of the same commit on GitHub's heterogeneousmacos-latestfleet probed 4 MB and 12 MB. So macOS bands a whole-buffer clear from 4 or 12 MB where it used to wait for this machine's 16, which is the change working as intended and also means nobody should compare timings between macOS CI runs without checking the threshold first. The caveat that remains: 12 MB of shared cluster L2 is a defensible analogue of a CCX's L3 slice, Apple Silicon's system-level cache behind it is not modelled by this rule at all, and no clear or resize has been measured on macOS.Two things worth keeping from the investigation.
sys.infoexports no cache size at all -- the whole standard library has no such symbol, onlynum_physical_cores,num_logical_coresandnum_performance_cores-- so reading the OS directly is the only route. And this cannot be acomptimevalue: this Mojo interprets neither a file read nornum_physical_cores()at compile time, and even where it could, a comptime constant would describe the machine that built the package rather than the one running it, which is the same bug one step removed as soon as anything is built in a container. The runtime price is 12.7 ns of lookup against the 23 us clear of an 800x600 canvas, the smallest fill it gates. The other constants #399 lists are work counts rather than cache geometry and are left alone; every band cap is already bounded by the runtime's worker count, so none can exceed a small machine's capability. -
A batch op is bound by reference in the band loop, and two performance hypotheses are measured away (PR #397, #389, #388).
_batch_bandwalks every op per band and skips the ones that miss its rows, so it is mostly a rejection scan, and it bound each op by value: about 250 bytes copied to read two fields, 128,000 times per flush at 64 bands over 2,000 ops. Binding by reference is worth about 4% of a flush of 2,000 disks, paired per iteration over 60 iterations, and nothing on a batch of a few hundred ops or one where path rasterization dominates. The arithmetic agrees, which is why the figure is believable where a first unpaired reading of 1.26x was not: each band copies about 500 KB, roughly 17 us of a half-millisecond flush. No reference row moved, because 4% is well inside these rows' spread.The rest of #389 was measured and did not survive. A dense array of every op's row bounds, two Int32 per op so the scan reads 8 bytes rather than striding 250, measured 1.030 against the reference binding's 1.040 on the same scene, so it bought nothing beyond it and was dropped rather than carried as a field, an allocation and an ordering invariant. The record's reported 4-7x dependence on the worker count is 1.5x when the preceding flush's worker count is the only variable (68.3 us after a 64-worker flush, 45.6 after a serial one, 59.6 with no flush at all); a 404 us reading came from a harness that alternated worker counts in one loop, where a 5.2 ms serial flush evicts the recorder's working set, and a 3 ms spin between flush and record takes the remainder from 76 to 47 us, which makes it worker threads winding down. A batch of single-radius disks is 1.18x
fill_circles_aaat the same radius, outside spread, so the three per-shape batched entry points keep their own band loops; the 2x in the issue compared the mixed-radii batch, which draws more pixels, againstfill_circles_aaat r=3.5.#388's physical-core cap is a regression. Measured unpinned, interleaved with the default per iteration, 32 workers against 64: the disk batch 1.17x slower, the gridline batch 1.15x,
fill_circles_aa1.17x, the 39-curve fill 1.21x. The issue's table reached its gain withtasksetholding the process to four L3 slices and the cap; with the pinning removed the cap alone goes the other way, so the placement was doing the work, and a compute-bound band pass is not saturating a core's execution units the way a useless SMT sibling would imply. Two rows do prefer 32 workers, the donut at 0.88 andblurat 0.86, both of which read far more than they compute -- the opposite classification from the one the cap was proposed for, and the more interesting direction if anyone returns to it. Both issues are closed on their own "recorded with the numbers" terms.What none of it explains is filed as #398: the batch flush swings 1.66x across identical runs of one binary on a quiet machine (415, 529 and 689 us) while
fill_circles_aaon the same scene in the same process holds within 6%. Until that is understood the residual 1.18x cannot be attributed, and the batch rows inbenchmarks/reference.txtare rowsbench-checkmay not be able to compare at all. -
draw_imagefills large cells as rectangles, and builds a block faster for small ones (PR #395, #394). dataviz wireddraw_imageinto its image plot and measured a 3x3 image over a supersampled 1290x1050 box at about 2.8 ms more than ninefill_rectcalls: the axis-aligned path built a block the size of the box one pixel at a time through checkedListindexing, then blitted it, whatever the cell size. The path now splits on visible device pixels per cell. At 64 or more (_CELL_FILL_MIN_PIXELS), each cell is the device rectangle between its snapped edges, filled through_fill_region_top, the region fillfill_rectlands in, so the bytes are afill_rectper cell by construction; below that the block is still built, reading the cell maps through pointers and copying a device row from the block row above when both map to the same cell row, which on cells a few pixels tall is most rows. The per-cell route runs only underSOURCE_OVER, the compositing the blit applies, so the two agree on any input either could see;tests/test_compose.mojogives both the same edges over translucent cells under a clip path in sRGB and in linear light and compares every byte.Timed in one process on the quiet Threadripper 3970X, dataviz's shape (supersample 3, box 1290x1050 device pixels), medians of 15, per-cell fill against the new block: 3x3 (150,500 px per cell) 98 against 480 us; 32x32 (1,322) 131 against 471; 128x128 (82) 439 against 525; 256x256 (20) 1,474 against 563; 1024x1024 (1) 11,575 against 970. The per-cell route costs about 22 ns per cell plus the fill and the block is flat near the box's memory traffic, so they cross near 57 pixels per cell; 64 is the constant. Against v0.30.0 across processes,
draw_imageof the same box went from a flat 2.5 ms at every grid size to 128 us at 3x3, 543 at 256x256 and 942 at 1024x1024. Two survey rows added for the 3x3 and 256x256 cases, since neither had one until a consumer reported the cost. The other finding on #394, that dataviz pre-snaps cell edges in logical space and so keeps rectangles on the raster backend under supersampling, is the documented snap rule and not a change here. -
draw_imageonDrawTarget, a raster block on every backend (PR #393, #392, filed from dataviz_mojo#425). The trait's vocabulary was shapes, and a consumer's image plot had no way to reachSvgCanvasbut afill_rectper cell: 8.5 MB of 179,000<rect>elements for a 512x512 array, against a 12 ms raster render of the same.draw_image(image, x, y, width, height)places aCanvaswith its top-left at a user-space point, scaled to a user-space box, under the current transform, and is on the trait for the reasonfill_circles_aais: the vector backends need an element a generic caller cannot reach through shapes. User space rather than device space, so an image at data coordinates lands where afill_rectat the same box would, including under the supersampling scale a chart draws through. OnSvgCanvasit is one<image>holding the PNG as a base64 data URI withpreserveAspectRatio="none"andimage-rendering:pixelated, which is what matplotlib's SVG backend does forimshow; the 512x512 field is 83 KB. OnPdfCanvasit is thedraw_imagethat already existed.encode_pnggiveswrite_png's bytes without a file, for the URI.The raster side had one decision in it. The matrix overloads of
draw_canvasread pixel k as spanning k to k + 1, while every shape reads it as k - 0.5 to k + 0.5, so a block placed through the sampler would sit half a pixel off afill_rectat the same coordinates, and on the ties the supersampling recipe produces at every integer coordinate (a cell edge at 3x + 1 is a pixel center) the sampler's half-open test andfill_rect's round-half-up would then disagree by a column. So under a scale and translationdraw_imagesnaps each cell edge by_snap_rect's rule and resamples nearest-cell into the snapped box, which makes the block the same bytes as afill_rectper cell -- pinned bytests/test_compose.mojounder four frames, translucent and clipped, and throughdownsampleat factor 3. A rotation or shear goes through the sampler withFilter.NEARESTand a half-pixel shift onto the shapes' convention, checked pixel by pixel against an inverse-map oracle. Full suite and all 16 verification digests unchanged; no timing row moved, since no existing path changed. -
A batch builds its strokes and paths in parallel (PR #387, step 4 of #382, the performance half). A stroke or path inside a batch is recorded as its device-space points and style, or as the path, and
end_batchbuilds the outlines and edge tables of all of them across tasks before the band pass -- the serial prologue #383 and #373 measured, now parallel over the ops. The first version gave every op its own edge table and path copy and was slower than drawing at once: recording 2,000 disks went from 80 to 700 us, the parallel build spent its time in the allocator across 64 threads, and the teardown freed thousands of tables serially. The lesson is recorded incanvas/batch.mojo: per-op heap traffic in a parallel stage does not scale here. Ops are now scalars indexing a_Batchof side lists; edges recorded ready-made gather into one table (_EdgeTable.extend), each build task appends into a table it allocates itself, the edge walks take an edge range, and a band reuses one scratch across every op it draws. Survey rows on the quiet Threadripper 3970X: 200 gridlines with 100 ticks in a batch 1,072 -> 802 us (one call each 2,700), 2,000 disks of mixed radius unchanged at 758 (one call each 4,149), and 2,000 diamond-shaped path markers 3,012 one call each against 2,255 in a batch, where the record itself, about 0.6 us per path for the copy and the op, is half the batch's time. All 16 verification digests and the goldens unchanged. The timing reference was re-recorded from two quiet recordings: median ratio 1.00 over the 67 shared rows, two rows added. The other half of step 4, every draw recording and every read flushing, is the API decision itself: a read through an immutable borrow cannot flush, so ten read entry points and the public pixel field would change; it is written up on #382 and left to the owner. -
Canvas.begin_batch/end_batch, a scoped display list (PR #386, step 3 of #382). Every anti-aliased shape called between the two is recorded in device space, under the transform, clip, blend mode and color space in force at the call, andend_batchdraws the lot in one banded pass, in submission order, through the row-restricted body each shape already had for the batched marker paths -- so the pixels are exactly those of drawing each shape at once, at any band count, whichtests/test_batch.mojocompares whole under a clip rect, a clip path, a rotation,MULTIPLY, three worker counts, nested batches, immediates and state changes inside a batch, and againstfill_circles_aa. Recording happens at the four device-level choke points the shapes already flow through (_sweep_edges_aawith the rasterizer chosen,_rasterize_stroke, the closed-form disk and ellipse bodies,_fill_rect_device), so the primitives themselves did not change; everything that cannot be recorded -- text, gradients, hard-edged shapes, compositing, blur,fill, thefill_circles_aa-style batches, and every state change -- draws what is pending first and then itself, so order holds. Two things are documented as not ordered:set_pixel/write_pixel, whose per-pixel cost a check would double, and reads, which see the canvas without the pending shapes untilend_batch; step 4 of #382 is where reads start flushing. The trait carries both calls, as it carriesfill_circles_aaand for the same reason: a caller generic overDrawTargethas no other way to reach the raster backend's parallel pass, now for any mix of shapes; the vector backends write every element in order regardless and treat both as no-ops.Two survey rows each, one call each against a batch, quiet Threadripper 3970X at 64 workers: 2000 markers of mixed radius 4,376 -> 789 us (5.5x), and 200 gridlines with 100 tick rectangles 2,628 -> 1,125 us (2.3x, the rest being each line's stroke outline built serially at the call). A batch of single
fill_circle_aacalls at one radius costs about twice the dedicatedfill_circles_aa, the price of an op record and an edge-table copy per marker. All 16 verification digests and the goldens unchanged. The timing reference was re-recorded from two quiet recordings with the four new rows at the end of the survey: median ratio 1.01 over the 63 shared rows. Not folded in yet: the per-shape batched entry points keep their own band loops (the test shows a batch draws their pixels; folding them follows a measurement), and an op copies the edge table it records rather than taking it from the builder that owns one. -
Each band of an area fill finds its own row spans, and the edge table is written through pointers (PR #385, step 2 of #382; #373, #383). Every area fill and stroke walked every edge over every row it crosses on the calling thread to find each row's first and last accumulator cell, before the first band task existed: 507 us of the dashed series' time on 35,193 edges, 465 us of the noisy series', 66 us of the 39-curve fill's. The band count is now decided from the same walk over every eighth row (
_estimate_cells, which drops an edge before reading its x data when it meets no sampled row: 217, 114 and 20 us), and each band computes the exact spans for its own rows inside its task. Bytes cannot change: a row's span depends only on the edges crossing it, whichever band computes it. The three edge walks read the table's seven lists through pointers instead of checked indexing, which on a table of short edges was most of the walk._EdgeTable.add_ringandadd_rectwrite a polygon or a segment's rectangle through pointers into lists grown once -- geometrically, after a resize to the exact length made a dashed series of thousands of rectangles quadratic -- recording exactly what a loop ofadd_edgedid, andtests/test_aa_area.mojopins that field by field under the identity and a rotation. An open path's dash runs are emitted as the walk closes them, interior dashes straight toadd_rect, in walk order, so the closed path's deferred loop and its bytes are unchanged.Survey rows on the quiet Threadripper 3970X, main against the branch: the 39-curve nonzero fill 388 -> 336 us, the smooth 3000-segment series 1026 -> 779, the noisy series 1817 -> 1225, the dashed series 3487 -> 2689, the stroked 39-curve path 1487 -> 1124, the r=250 disk 137 -> 112, 2000 batched r=10.5 markers 1414 -> 1107, the glyph-sized fill 4 -> 3; the diagonal line and the gradient fill within their spread. All 16 verification digests and the goldens unchanged. The timing reference was re-recorded from two quiet recordings, the faster row of each kept: median ratio 0.97 over 63 rows, every row past 10% an area-path row moving down except two memory-bound rows (a translucent fill, a large clear) up 12-13%, which is their known swing. What remains serial is recorded in #383: the dashed series' outline build at 1.7 ms against 0.86 for the pieces build of the same stroke, the outline geometry itself (267 us of the smooth series' 427 us build), and each band's rejection walk over the whole edge table, which a window from the top-sorted order would bound.
perfis unavailable on the machine (paranoid level 4), so the dashed gap is measured but not attributed. -
Every stroke fills its outline by exact area (PR #384, step 1 of #382).
_stroke_edgesbuilt an outline polygon for a stroke and then, whenever a corner was a hairpin or a reversal, threw it away and rebuilt the stroke as quads and joint disks for the 4x4 sampled sweep, because the exact-area rasterizer adds the coverages of overlapping pieces where they share an edge pixel instead of taking their union. The outline it discarded already handled those corners the way Skia's stroker does -- the inner side folds through the corner itself, and a reversal's two bodies lie on top of each other -- and both overlaps are wound the same way as the outline around them, so the nonzero rule unions them. The change keeps that outline and fills it by exact area; only a closed path stroked wider than its curve's radius of curvature, whose inner ring inverts and would subtract a hole (#279), still goes to the pieces and the sampled sweep. The two now-unused simplicity checks are deleted, and the dash walk, which also used to bail to the pieces at the first hairpin, emits an interior dash's rectangle directly instead of building a run's lists for it.What the area rasterizer gets wrong at a fold is the edge: where two overlapping bodies share a pixel it adds their coverages, so a stretch the path nearly retraces reads up to one pixel column bolder along its edge. Measured against the union of every segment's body sampled 16x16 per pixel, on the survey's 3000-segment series that turns through nearly 180 degrees at every peak: 253 of 165,072 inked pixels more than 64 levels off, mean error 3.1 levels, where the 4x4 sampled sweep averaged 12.1 levels. On
tests/test_lines.mojo's stress path, every corner a hairpin with segments tens of pixels long at width two, the mean gap to the union is 17.8 levels open, 5.1 closed and 13.4 dashed, and those tests now pin those figures with a margin instead of matching a 4x4 sample byte for byte. A miter or bevel join at an exact reversal now ends flat, as the specification's bevel fallback says, where the joint disk used to round it. Skia and Cairo stroke this way and accept the same artifact; the union-exact sampled path was not kept behind an option, since a knob on four functions and the trait for a rendering nobody has asked for is API that would have to be carried forever.Interleaved in one process on a quiet Threadripper 3970X, medians of 21, the old path still reachable through
_stroke_piecesand_sweep_edges_sampled_aa: the noisy series 23.4 -> 3.7 ms at one worker (6.3x) and 3.06 -> 1.96 ms at 64 (1.6x); the 39-curvestroke_path_aa4.9 -> 1.7 ms (2.9x) and 2.26 -> 1.50 ms (1.5x); the dashed series 18.1 -> 5.1 ms (3.5x) and 4.43 -> 4.30 ms at 64, within spread. The dashed row is the one that gives nothing back at 64 workers: the sampled sweep it left parallelized well, and what remains of its time is a serial prologue --_row_spansat 675 us over 35,193 edges, and the run bookkeeping at about 230 ns per dash -- which is #383. Two verification digests (path stroke,lines and dashed polyline) were re-recorded after rendering both scenes from main and the branch and comparing: 3,436 and 2,358 of 76,800 pixels differ, at most 111 and 91 levels, almost all of it the 4x4 quantization along every edge, the rest the fold pixels at peaks. All 27 line tests and the full suite pass; the goldens were unchanged, since none draws a hairpin.
-
Clip gradient coverage before rasterization (PR #321, issue #320). Gradient/pattern fills intersect the path's padded coverage-mask bounds with the active rectangular clip before allocating the mask. This reduces stored coverage and skips hidden rows while retaining the full edge table for correct winding and clip-path blending. A large 800x600 gradient path exposed through a 100x80 clip measured 1,237.179 -> 350.113 us for even-odd (3.53x faster, 1.7% / 5.0% IQR) and 508.584 -> 193.311 us for nonzero (2.63x, 6.2% / 24.2% IQR). These are interleaved 9x200 medians on a Threadripper 3970X / Mojo 1.0.0; the nonzero spread is substantially larger. Unclipped controls were 0.99x and 1.00x baseline time. Complete outputs matched, all 832 tests passed, and all 16 verification digests remained unchanged. The scanline scratch still spans the path width; this change bounds mask storage in both dimensions and avoids hidden rows. The PR is under review, and a new survey baseline is being recorded separately from downstream test activity.
-
Faster persisted font-cache reads (PR #319, issue #317). Profiling a warm 384-face database attributed about 25 us to reading, 150 us to line/header/directory validation, and 2,000–2,200 us to record construction.
_unescape_fieldrebuilt every path/name through a codepoint list and string appends, even when it contained no escapes. Returning ordinary fields directly and decoding escaped fields in one codepoint pass reduced fresh database construction plus first sans-serif resolution from 2,222.547 us to 783.873 us: 2.84x faster, interleaved 9x200 medians with 0.1% IQR for each on a Threadripper 3970X / Mojo 1.0.0. All fields compared equal across 384 faces, and 830 tests passed. Cache serialization, invalidation, and_parse_facesemantics are unchanged, so the cache version stays at 1. A full survey recording refreshed the affected uncached-text row (3,276.023 -> 1,740.269 us); unrelated timing baselines were retained. This change is under review, not a released guarantee; absolute savings depend on font count and escaped-field frequency. -
Alpha-correct downsampling (PR #308, issue #283). Straight RGB averaging gives
(64, 0, 0, 64)for one opaque red sample and three transparent samples, darkening a layer edge. Weighting color by the unrounded source-alpha sum gives(255, 0, 0, 64)instead. Alpha is averaged separately; blocks whose rounded alpha is zero become transparent black. Factor 1 remains an exact independent copy so hidden RGB is preserved for a no-op. This correctness branch is separate from SIMD optimization so the output contract is reviewable on its own. All 779 tests in its current task passed, including the existing goldens without regeneration; red circles were also rendered on black and white backgrounds for inspection. This entry records a proposed change, not a merged release.
-
color.mojo—Color(RGBA8) +blend_over(src-over alpha compositing). -
buffer.mojo—Canvas: owns the pixel buffer,get_pixel/set_pixel(blends automatically on non-opaque writes) /fill/in_bounds. Alsopush_clip/pop_clip/in_clip: a clip stack, checked insideset_pixelalongsidein_bounds. This is Canvas-level state (not an explicit parameter threaded through every primitive), and deliberately so:Canvasalready silently discards out-of-bounds writes viain_bounds, so a clip rectangle is the same category of behavior, not a new kind of hidden state -- and every primitive gets it for free with zero changes of its own, since they all write throughset_pixel. Verified that composition directly, not just assumed it:fill_rectanddraw_linewere both confirmed to respect an active clip without any modification.push_clipintersects with whatever's currently active rather than replacing it (originallyset_clip/clear_clip, a single settable rect with no nesting -- superseded once the clip-stacking roadmap item below was done, not kept alongside it: two ways to express "restrict drawing" would've meant a caller needing to know which one composes safely with nested plots and which one silently clobbers a parent's clip).in_clipon an empty stack is unconditionallyTrue-- it no longer implicitly encodes canvas bounds into a "default" rect the way the single-rect version did; that'sin_bounds' job alone, checked separately byset_pixel. -
io/bmp.mojo— write-only, uncompressed 24-bit BMP. -
io/png.mojo— PNG read and write, stdlib-only both directions.write_png: hand-rolled CRC-32 and Adler-32 (each independently verified against zlib's own output on the same byte sequences), DEFLATE's "stored" (uncompressed) block type instead of real LZ77/Huffman compression -- same "trivial to verify, don't need small files" tradeoff BMP already made, PNG's only real advantage here being that it actually previews in most viewers, unlike BMP. Verified two ways: a tiny 2x1 canvas's entire output file (75 bytes) matched an independently computed reference byte-for-byte, and separately, a realzlib.decompresssuccessfully decoded a written file's IDAT stream back to the exact original scanline bytes (which also transitively confirms the Adler-32 trailer, sincezlib.decompressverifies it internally). Proved the reference-bytes test is load-bearing by corrupting the CRC-32 polynomial constant and confirming the right tests fail, then restoring.read_png(2026-08-16), added later, has no "stored blocks only" shortcut available to it -- a reader has to handle whatever a real encoder actually produced, so it depends on the newio/deflate.mojo(see its own entry below) for genuine LZ77+Huffman decompression, not just stored blocks. Handles color types 0/2/4/6 (grayscale, truecolor, grayscale+alpha, truecolor+alpha) at 8-bit depth, non-interlaced -- indexed/palette color, other bit depths, and Adam7 interlacing all raise a clear, specific error rather than silently misreading pixels (a deliberate v1 scope: what the overwhelming majority of real-world PNGs, including every one this package's ownwrite_pngproduces, actually are). Every chunk's CRC-32 and the decompressed stream's own Adler-32 are checked against the file's own trailing bytes, so a corrupted or truncated file is rejected explicitly. An alpha channel is flattened onto white viaCanvas.set_pixel's own existingblend_overon read, sinceCanvashas no per-pixel alpha storage of its own to preserve it in (already-documented architectural fact, not a new limitation). Verified against 5 real-world-shaped test PNGs (all five PNG filter types across genuinelyzlib.compress()- produced data, not just this package's own writer), a full write_png -> read_png round trip, and explicit error-path tests (corrupted CRC, truncated file, non-PNG signature) -- seetests/test_png.mojo. -
io/deflate.mojo(2026-08-16) — native DEFLATE decompression (RFC 1951), a direct translation of zlib's ownpuff.creference decoder (Mark Adler's "unambiguous way to specify the deflate format") rather than independently re-derived from the RFC alone -- same reasoning as translating FreeType'sFT_Outline_Decomposefaithfully instead of re-deriving TrueType outline decoding: DEFLATE is a closed, formally specified, decades-stable algorithm, exactly the kind of thing this package already builds from spec elsewhere. Handles all three DEFLATE block types (stored, fixed Huffman, dynamic Huffman) and multi-block streams. Verified against realzlib.compress()output, not just "translated carefully and hoped": each block type separately round-tripped back to its exact original bytes, including a 178,000-byte genuinely-dynamic-Huffman stream and a 200,000-byte multi-block stored stream via probe, formalized as 5 tests intests/test_deflate.mojo. This is what makesread_png(seeio/png.mojo's own entry, above) able to decode whatever a real-world PNG encoder actually produced, not just this package's own uncompressed output. -
RTL/bidi text support --
bidi.mojo(2026-08-14) — a practical partial implementation of the Unicode Bidirectional Algorithm (UAX #9): codepoint classification (strong-L/strong-R/weak-neutral/ weak-number by Unicode range -- Hebrew, Arabic and its presentation forms), paragraph base-level detection, embedding-level resolution (digits get their own even/LTR level, distinct from generic neutrals, so digit runs inside RTL text don't get reversed), rule L2 (nested run reversal) and rule L4 (mirroring paired characters like parens/brackets when they land in a reversed run).text.mojo'sdraw_text/measure_textroute every string throughbidi.mojo'svisual_orderbefore layout, so RTL text (and mixed LTR/RTL text) renders and measures correctly with no separate API -- a caller writing Hebrew or Arabic just callsdraw_textthe same way they'd call it for English. A real, non-hypothetical bug caught and fixed along the way: a one-sided "neutral inherits the preceding strong run's level" rule pulled a boundary space between an RTL word and surrounding LTR text into the wrong run's reversal (doubling a gap on one side, closing it on the other); fixed by resolving neutral runs against both neighbors (same level on both sides -> that level, different levels -> the paragraph's own base level), with a regression test locking that exact case in. -
CJK / font-fallback support (2026-08-14) —
resolve_font_file_ for_char(font_discovery.mojo) asks fontconfig for a font that actually has a given codepoint (viaFC_CHARSET), rather than trusting whatever font a plain family/slant/weight match returns;text.mojo's_resolve_glyphchecks the primary face first (a cheapFT_Get_Char_Index-onlyhas_glyphcheck) and only falls back to a fresh face from fontconfig's charset-constrained match when the primary font actually lacks the glyph -- e.g. DejaVu Sans (this package's usual default) has no CJK glyphs and renders them as.notdef"tofu" boxes without this. No fonts are bundled with this package -- font discovery/fallback both depend entirely on whatever fonts are already installed on the end user's own machine, the same asfont_discovery.mojo's base case did before this. Verified with the real contrasting case fontconfig actually reports on the CI/dev environment (a snowman glyph DejaVu Sans lacks but a fallback font has), not a hypothetical. -
primitives.mojo:-
draw_line— Bresenham. -
draw_rect/fill_rect— stroke and fill, axis-aligned. -
draw_circle/fill_circle— midpoint algorithm / span-fill, both hard-edged. -
draw_line_aa/draw_circle_aa/fill_circle_aa— anti-aliased via supersampled analytic coverage (4x4 sub-pixel grid, exact geometric membership test per sub-sample). All three share one sampling convention (pixel centered at its integer coordinate, matching the hard-edged algorithms) so a hard/AA pair given identical arguments draws the same shape.
-
-
geometry.mojo—Point(integer x/y) andTransform2D(affine map:pixel = rotate(data * scale, rotation) + translate,Float64in,Pointout). Still deliberately minimal beyond that one fixed pipeline: no general matrix composition, no "map this data range onto this pixel range" convenience constructor -- that domain/range awareness belongs one layer up, in whatever scale types a higher-level charting layer eventually provides, which would compute aTransform2D's scale/translate from a domain and range.scale_yis commonly negative in real use: pixel-space y increases downward, data-space y conventionally increases upward, so flipping a chart's vertical axis is exactly what a negativescale_ydoes -- seeexamples/transform.mojofor the full data -> pixel -> primitive pipeline this exists for.rotation(radians, default 0.0) tilts the whole coordinate frame around the scaled data space's origin, applied after scale and before translate -- a fixed, documented order, not a general composable matrix, matching this type's existing "one pipeline, not a stack" scope. Hand-derived test values used a 90-degree rotation specifically because sin/cos are exact there (0 and 1, not floating-point approximations), including one test that would fail if rotation were applied in the wrong pipeline position (e.g. to the translated point instead of the scaled one). This is a different feature from rotating one rendered primitive around its own anchor (e.g. an angled axis-tick label) -- that'stext.mojo's own rotation, unrelated to this data-to-pixel mapping. -
primitives.mojoalso has:-
draw_polyline/draw_polygon— connected/closed line segments. Each joint (including a polygon's closing vertex) is drawn by exactly one segment, not two, so translucent colors don't get double-blended at corners -- same category of fix asdraw_rect's corners. -
draw_polyline_aa/draw_polygon_aa— anti-aliased, via one coverage test per pixel against the minimum distance across all segments (not a loop callingdraw_line_aaper segment, which would double-blend at joints via overlapping round caps -- a different mechanism than the hard-edged double-blend hazard, with no "skip a sample" equivalent fix). - Hard-edged and AA variants are kept as separate functions on
purpose, never merged behind an
antialias: Bool-- see the module docstring for the reasoning (a shared name would hide a real O(radius) vs O(radius^2 * supersample^2) complexity jump, anddraw_line_aa'swidthparameter has no hard-edged equivalent). -
draw_ellipse— midpoint algorithm, independently re-derived (not recalled from a textbook) via the same discrete-calculus method as the circle/line algorithms, then hand-traced and confirmed to match the actual code's output exactly. Two decision parameters (shallow-slope and steep-slope regions), 4-way symmetry, integer-only (scaled by 4 to absorb a fractional term). -
fill_ellipse—fill_circle's generalization to independent x/y radii: same span-fill-per-row technique, with the per-row bound generalized to the integer-exactdx^2*ry^2 + dy^2*rx^2 <= rx^2*ry^2(the ellipse equation scaled byrx^2*ry^2to avoid a sqrt/float dependency). Row half-widths independently computed by hand and confirmed to match the code's actual output exactly. -
fill_ellipse_aa/draw_ellipse_aa—fill_circle_aa/draw_circle_aa's generalization to independent x/y radii, same supersampled analytic-coverage technique.fill_ellipse_aatests each sample against the ellipse equation in normalized form,(dx/rx)^2 + (dy/ry)^2 <= 1-- a direct, exact generalization.draw_ellipse_aa's ring test isn't as clean: a circle's inner/ outer ring boundaries are concentric offsets of one curve, testable against one shared distance, but an ellipse's(rx-0.5, ry-0.5)and(rx+0.5, ry+0.5)boundaries are two different ellipses, so each sample is tested against both independently in their own normalized space (strictly inside outer, not strictly inside inner) -- see the function's own docstring for the resulting known, accepted imprecision (the ring's actual physical width isn't perfectly uniform around the ellipse the way the circle's is). Hand-computed coverage values for both, independently verified against the exact same 4x4 sub-sample grid the code uses, matched the actual code's output exactly on first run. -
fill_polygon— scanline fill (even-odd by default; seefill_rule.mojoin Done, below, forFillRule.NONZEROand full self-intersecting-shape support -- this entry's "assumes a simple polygon" caveat is stale as of that work and has been removed). Y-extent per edge is half-open ([min(y0,y1), max(y0,y1))), which is required for correctness, not a style choice: it's what makes a vertex shared by two opposite-direction edges count as exactly one crossing (not two) while a genuine local-extremum vertex (a triangle's apex) counts as zero net crossings, not two. Concrete, surprising-if-undocumented consequence: a polygon's flat bottom edge doesn't get its own row filled, so matchingfill_rect(x, y, w, h)exactly needs asymmetric corners -- verified directly againstfill_rect's own output, not just derived on paper.
-
-
Test convention (
std.testing.TestSuite, see each package'stests/), with most non-trivial values hand-verified independently rather than just asserted against the code's own output, and every double-blend fix proven by temporarily reverting it and confirming the test actually catches the regression. -
text.mojo—draw_text, real system-font rendering viathird_party/cairo_mojo(vendored; see itsVENDORED.md) rather than from-scratch TrueType parsing. This is the one placecanvas-- and this whole workspace -- isn't stdlib-only; see the module's own docstring and the repo rootREADME.mdfor why. A standalonefonts/package (a from-scratch, stdlib-only TrueType parser/ rasterizer) explored the alternative first -- deleted once this existed rather than kept around unused; if a stdlib-only text path is wanted again, whatevertext.mojoneeds factored out at that point can become the nextfonts/, shaped by what's actually needed rather than resurrecting the old exploration wholesale.Also has
measure_text(expose Cairo's own text measurement without drawing, for layout decisions made before committing to draw),TextAlign(LEFT/CENTER/RIGHT, measured against a line's advance width, matching HTML5 Canvas's textAlign convention rather than tight ink bounds), multi-line strings ("\n"-separated -- Cairo's own toy API has no line-break handling at all), and per-block rotation around the(x, y)anchor (a different feature fromTransform2D's ownrotation-- see geometry.mojo's entry -- this tilts one rendered text block around its own anchor with everything else on the canvas staying upright; that tilts an entire data-to- pixel coordinate mapping). Rotation and multi-line share one code path with the plain single-line case, not three: every line's ink corners get rotated around the shared anchor and combined into one bounding box that sizes a single scratch surface, and one line with rotation=0.0 reduces exactly to what a simpler implementation would have done -- confirmed by direct pixel comparison intests/test_text.mojo, not just argued.measure_text_block/TextBlockBoundsexpose that same rotated- bounding-box math (the part that decides how bigdraw_text's scratch surface needs to be) as its own public, non-drawing query -- the concrete gap for a chart-axis-label layer:measure_textalone only measures one unrotated line, but a rotated or multi-line tick label's actual on-canvas footprint (needed to size a chart's margin, or check whether two labels would overlap) isn't derivable from that without redoingdraw_text's own rotation math a second time. Rather than let that become a second, independently- maintained copy of already-hard-won math (see the AA-sampling and translate-before-rotate bugs both documented in this file), the layout computation itself was extracted out ofdraw_textinto a private, shared_layout_block--draw_textnow calls it and renders the result,measure_text_blockcalls it and just reports the result, so the two can never quietly drift apart. Confirmed correctness-neutral two ways:draw_text's own full pre-existing test suite passed unchanged after the extraction, andmeasure_text_block's predicted box was cross-checked directly againstdraw_text's actually-rendered ink pixels (not just reasoned about abstractly) for both an unrotated and a 90-degree- rotated case, landing within a pixel or two -- the same floor- rounding/AA-fringe slopdraw_text's own pixel placement already has relative to Cairo's ink extents, not evidence of drift between the two functions. Proved that cross-check test itself was load- bearing, not just "close enough to always pass": deliberately scaled the shared rotation math'ssinterm and confirmed the rotated cross-check test (and only that one -- the unrotated case is correctly insensitive to a rotation-only bug) failed, then restored. An empty or whitespace-only string returns a zero-sized box, matchingdraw_text's own no-op for the identical input rather than reporting a box sized by whitespace's nonzero advance.A second real, confirmed bug, independent of the
unsafe_data_ptr()one below, and this time root-caused rather than just empirically patched around:cairo_mojo'sContext.text_extents()/show_text()convenience wrappers silently measure/draw as empty for any String that isn't a compile-time literal once its length crosses roughly 20 bytes -- exactly what multi-line's own internaltext.split("\n")produces per line, which is how this surfaced at all (a real string in an early multi-line/rotation demo silently drew nothing). The Mojo String itself is never corrupted -- its ownbyte_length()and printed content stay correct the whole time; only those two wrapper methods' internal C-string marshaling mishandles it. Traced by elimination, not a first guess: confirmed present regardless of how the String was built (.split(), manual byte-range slicing, char-by-char concatenation, even a deliberately over-capacity fresh allocation all still triggered it) as long as it wasn't a literal -- and confirmed that an empirical capacity-based fix (over-allocate, hope it helps) actually made things worse, silently corrupting a previously-working short string into garbage, non-repro-stable-within-a-run-but-different-across-runs output -- caught only because a short-string regression test that had nothing to do with the original bug started failing too. The real fix:_c_string()manually builds a NUL-terminatedUnsafePointer[c_char]buffer, and_text_extents()/_show_text()callcairo_mojo's own raw FFI bindings directly with it, bypassing the broken wrapper methods' marshaling entirely -- verified deterministic and correct across many repeated runs, for a previously-broken long string and a previously-working short one together, not just whichever one motivated the fix. Also worth naming plainly: the first regression test written for this (ameasure_textcall with a string literal) passed even against the reverted, broken code -- because a literal never triggers the bug in the first place, only a runtime-constructed String does. Caught by actually reverting the fix and checking the test failed, not by assuming a green test proved anything; replaced with a version that builds the string via concatenation instead. -
Dash patterns — optional
dashes/dash_offsetparameters (empty by default, meaning solid, exactly the pre-existing behavior) added todraw_line,draw_line_aa,draw_polyline,draw_polygon,draw_polyline_aa,draw_polygon_aa. Not a new hard/AA split, since dashing doesn't introduce the kind of branch-specific parameter or complexity-class jump that split exists to keep visible (see the module docstring) -- it composes orthogonally with both._is_dash_on(private, but directly unit tested -- Mojo doesn't treat a leading underscore as import-private the way Python's convention-only privacy might suggest) is the shared core: given a distance along a path and an alternating on/off length list, is that point drawn? Floor-based modulo (not truncating%) so a negative offset wraps correctly; an odd-length pattern is doubled, matching Cairo's own convention for anyone porting a pattern from it.The two rasterization styles measure "distance along the path" differently, each for a concrete reason rather than arbitrarily: the hard-edged version accumulates actual Bresenham step lengths (1 for an axis step, sqrt(2) for a diagonal one) as it walks, since that's what it already has on hand and it's what real per-pixel distance-so-far means for a raster walker; the AA version instead uses each sample's already-computed, already-clamped projection fraction
ttimes the segment's true straight-line length, since there's no pixel walk to measure steps along in a supersampled algorithm. For a multi-segment polyline/polygon, a dash pattern's phase carries continuously across joints -- each segment's distance picks up where the previous one left off, not reset to 0 -- proven load-bearing, not just claimed, by breaking the carry-forward and confirming the joint-phase test fails, then restoring._draw_polyline_core_aa's per-sample "minimum distance across all segments" search (see thedraw_polyline_aaentry above) needed a real restructure, not just a filter bolted on afterward: a segment is only a coverage candidate if it's both withinhalf_widthAND on-dash at that exact projected point, evaluated independently per segment before taking the minimum -- so a sample near a joint where one segment's dash state is off but a neighbor's is on at the same physical point still gets covered correctly, rather than the whole sample being decided by whichever segment happened to be closest. -
path.mojo— a generalPath(move_to/line_to/quad_curve_to/ cubic_curve_to/close, chainedmut selfcalls, no fluent-style returns -- matchesCanvas's own builder methods) that flattens into straight-line segments and hands off to the already-tested polyline/polygon/fill machinery inprimitives.mojo, rather than reimplementing fill or stroke logic. Fixed-step curve flattening (16 steps/segment, not adaptive), the same choicefonts/raster.mojomade for TrueType's quadratic curves before this package had its own general path type (see thetext.mojoentry above for that package's own history).fill_pathcombines every sub-path's scanline crossings together (even-odd) -- the same multi-contour hole-punching technique, independently reimplemented here rather than shared code (fontsandcanvaswere never allowed to depend on each other -- see the repo rootREADME.md-- andfontsis gone now regardless). Proved hole-punching is load-bearing the same wayfonts/raster.mojodid: breaking the multi-sub-path combination (scanning only the first sub-path) and confirming the hole-punching test catches it, then restoring.stroke_path/stroke_path_aaroute each sub-path todraw_polygon/draw_polyline(or their AA equivalents) depending on whether that specific sub-path's ownclose()was called -- verified directly: an unclosed sub-path's implicit closing edge is confirmed absent, not just "close() exists". Quadratic/cubic point math independently verified by hand before trusting the code's own output, then confirmed the flattened curve actually passes through that hand-computed midpoint at the exact step index_CURVE_STEPSpredicts, not just that the standalone curve-math functions are correct in isolation. -
gradient.mojo—LinearGradient, the minimal fill-source abstraction that justifies existing:fill_rect_gradient(primitives.mojo) andfill_path_gradient(path.mojo) are the only two linear-gradient-aware fill entry points, not everyfill_*primitive retrofitted with a gradient variant --fill_circle_gradient/fill_ellipse_gradient/fill_polygon_gradientare easy to add later if something concrete needs one, not built speculatively now; these two cover the actual chart-rendering cases (bar/area fills) this exists for. Only "pad" extend behavior is supported (a point beyond either endpoint gets that endpoint's own color, clamped -- not tiled/mirrored the way real gradient APIs also offer), the case an area or bar fill actually wants: the gradient's edges are the shape's own edges, not a repeating pattern. Stops don't need to be added in offset order -- verified that specifically, not just "gradients work": corrupted the bracketing-pair search to assume insertion order and confirmed the out-of-order test (and only that one -- the in-order test still passed, which is exactly why both exist) failed, then restored.RadialGradientfollowed oncefill_path_gradient's own donut example made the gap concrete: it used aLinearGradientalong a diameter (namedradial_ishin the code, honestly) to fake a concentric look, which is visibly wrong off-axis.RadialGradient(center + radius, offset 0.0 at the center to 1.0 at the radius) has its ownfill_rect_radial_gradient/fill_path_radial_gradiententry points, kept as separate functions from the linear ones rather than a shared "Gradient" trait/generic -- matches this codebase's existing preference for distinct named functions over one type dispatched by a flag (the reasonLinearGradientitself got dedicatedfill_*_gradientfunctions instead of a boolean onfill_rect/fill_pathin the first place), and this project has no trait-generics precedent anywhere else to introduce for a single feature. What is shared:_color_at_t, the stop-bracketing-and- interpolation logic bothLinearGradient.color_atandRadialGradient.color_atreduce to once each has computed its ownt(axis projection vs. distance-from-center/radius) -- genuinely identical code, not near-identical, so factoring it was correctness- neutral refactoring, confirmed by the fact that all ofLinearGradient's pre-existing tests passed unchanged once it was rewritten to call the shared helper.Deliberately the simple single-circle form (center + radius), not the general two-circle "off-center focal point, focal point has its own radius" gradient SVG/Cairo/HTML5 Canvas also offer -- that generality mostly exists to fake a lit-sphere look, not a chart need identified so far (bubble/donut centers, a radial legend swatch, all want a plain concentric gradient); easy to widen later.
radius == 0.0is handled as a documented degenerate case (resolves tot=1.0, a solid fill of the highest-offset stop, rather than dividing by zero) instead of crashing, the same category of decisionLinearGradientalready made for its own degenerate case (two coincident endpoints). Distance math verified with exact integer Pythagorean triples (a 3-4-5 right triangle scaled as needed) so a test's expectedtlands on a precise value instead of "close to" one -- confirmed inexamples/gradient.mojo's donut, now filled with a genuinely concentric gradient instead of the old linear approximation, and a rectangular swatch with a radial highlight (fill_rect_radial_gradient, no circle primitive involved at all). -
Arc primitives (
primitives.mojo) —draw_arc/draw_arc_aa(a bare curved boundary),fill_arc/fill_arc_aa(a solid pie-slice wedge),fill_ring_sector/fill_ring_sector_aa(a donut/ ring segment) -- closing the one concrete gap identified when scoping a charting layer's needs against whatcanvasitself provides: pie/donut charts need wedge and ring-segment shapes, and without a native arc, building one meant hand-approximating with cubic Beziers (seegradient.mojo's example, which does exactly that for a full circle)._arc_pointsinstead samples exact circle math (cx + r*cos(theta),cy + r*sin(theta)) directly -- matchingdraw_circle/draw_ellipse's own preference for independently-derived exact math over an approximation -- at a step count proportional toradius * angle_span, notpath.mojo's fixed per-curve step count, since arc radii vary far more widely in real use (a small pie-chart marker vs. a full-page donut) than aPathcurve's typical size does.fill_arc_aa/fill_ring_sector_aaare supersampled analytic coverage tests against the wedge/ring's own exact definition (radius bounds AND an angular span test,_angle_in_span) -- not a flattened polygon run through a generic AA fill, since no such thing exists in this codebase (fill_polygonis hard-edged only;draw_polygon_aais an AA outline, not a fill) and the wedge/ring's membership test was clean enough analytically that inventing one wasn't needed._angle_in_span's own job -- normalizing a sample's rawatan2angle (range(-pi, pi]) into an arbitrary[start_angle, end_angle]window -- matters concretely, not just in theory: a wedge spanning theatan2discontinuity at +/-pi (e.g. one centered straight up) needs it to render as one continuous shape instead of splitting or vanishing, confirmed both visually (examples/arc.mojodraws exactly this case) and by corrupting the normalization loop and watching the wraparound-specific test (and only that one) fail. Also consolidated_round_to_int(used bygeometry.mojo,path.mojo, and now this) into one definition ingeometry.mojoinstead of a third hand-copied duplicate -- a small cleanup, not scope creep, done because writing a third copy for the same already-twice-duplicated helper would have made the inconsistency worse, not just left it alone. -
fill_rule.mojo--FillRule(EVEN_ODD default / NONZERO), an optional parameter onfill_polygon,fill_path, andfill_path_gradient-- properly closes the "self-intersecting polygon fill" gap this file used to list under "Plausibly next".fill_polygon's per-row crossings now carry a signeddirection(+1/-1, from each edge's y-order) instead of just an x position;_spans_from_crossings(shared byfill_polygonand, viapath.mojo's_row_crossings,fill_path/fill_path_gradient) scans a running signed winding total per row and calls a point inside via_is_inside(winding, fill_rule)--abs(winding) % 2 == 1for EVEN_ODD,winding != 0for NONZERO. EVEN_ODD via signed- winding parity is provably identical to the old plain crossing-count parity for any crossing sequence (each crossing changes the winding total by an odd amount, so parity flips exactly once per crossing regardless of sign) -- confirmed not just by that argument but empirically too, every pre-existingfill_polygon/fill_pathtest (including the exact-pixelfill_rect-equivalence one) passed unchanged under the rewrite.Rewriting to a signed scan surfaced a real, previously-undocumented bug the old docstring only warned about rather than fixed: two crossings landing on the same integer x (e.g. a self-intersection, or two sub-paths' edges meeting exactly) can produce two raw spans that both include that x under the inclusive-inclusive fill convention -- e.g. crossings at x=[10,15,15,20] naively produce spans (10,15) and (15,20), both covering x=15, a genuine double
set_pixel/double-blend. Caught by hand-tracing a concrete example (not trusting the abstract "one crossing in, one crossing out" argument), fixed with an explicit merge pass over consecutive spans wherevernext.start_x <= last.end_x + 1. Proven load-bearing: a synthetic-crossings unit test (_spans_from_crossingscalled directly, bypassing polygon geometry entirely) confirmed exactly one merged span comes out; reverting the merge step to a plain span-per-pair list was confirmed to make (only) that test fail, then restored.A single self-crossing polygon (a "bowtie") turned out not to be useful for demonstrating EVEN_ODD vs. NONZERO actually diverging -- its pinch point only ever produces 2 crossings, which resolve to the same single span under either rule, so it's kept as a "self- intersection doesn't crash or misbehave, both rules agree" sanity test instead. The real divergence demonstration (
test_path.mojo,examples/fill_rule.mojo) uses two same-direction-wound overlapping squares as two sub-paths of onePath: EVEN_ODD punches a hole where they overlap (crossed twice = outside again), NONZERO fills the union solid (signed winding reaches 2, still nonzero) -- hand- verified first, and the two tests share an identical shape so the only variable is thefill_ruleargument itself. -
fill_polygon_aa/fill_path_aa— the one inconsistency left once every other filled primitive (circle/ellipse/arc/ring, all in Done above) already had an anti-aliased companion: an arbitrary filled shape -- the general case an area chart's region, a smoothed- edge custom marker, or a curvedPathfill actually is -- could only ever render hard-edged.draw_polygon_aaalready existed, but only as an AA outline; these are the fillsfill_polygon/fill_paththemselves never had. Same supersampled-analytic- coverage techniquefill_circle_aaestablished (NxN sub-pixel grid per candidate pixel, coverage fraction becomes that pixel's alpha, each output pixel visited exactly once so there's no double-blend hazard) and the same pixel-centered-AT-its-integer-coordinate convention, generalized from "distance to a center" to "inside a shape" via_point_in_polygon/_point_in_subpaths-- the continuous analog offill_polygon's per-row crossing scan andfill_path's_row_crossings, sharing the identical_is_inside(winding, fill_rule)decision both hard-edged functions already use, so a hard-edged and AA fill of the same shape agree on exactly where its boundary is, not just approximately.fill_rulethreads through both, the sameFillRule.EVEN_ODD/NONZEROchoicefill_polygon/fill_paththemselves take.Coverage math hand-verified before trusting it (same right-triangle-with-a-known-hypotenuse-equation technique used elsewhere in this file): a 4x4 sub-sample grid at a pixel straddling the hypotenuse of triangle
(0,0),(20,0),(0,20)gives exactly 6/16 covered, matching the code's actual output on first run, not adjusted to match it after the fact. Proven load-bearing by reintroducing the exact historical "off by half a pixel" bug documented below underfill_circle_aa(dropping the- 0.5pixel-centering term) intofill_polygon_aa's own sampling loop: confirmed the partial-coverage test (and only that one) failed, then restored -- the same convention/reasoning error already caught once for circles turned out to be worth checking wasn't silently reintroduced here.fill_polygonhas no multiple-sub-path notion the wayfill_pathdoes, so a bowtie's single pinch point still isn't enough to demonstrate real EVEN_ODD-vs-NONZERO divergence on it (same limitationfill_rule.mojo's own entry above already found for the hard-edged case) -- confirmed instead on a genuinely different construction: two same-direction squares connected into one closed polygon boundary by a zero-width "bridge" edge walked out and back, so the winding number actually reaches 2 in their overlap. Verified by hand first that the bridge's own coincident opposite-direction traversal cancels out everywhere the test points sample, before trusting it as a real divergence demonstration rather than an accident of a degenerate shape.fill_path_aa, which does support multiple sub-paths, reuses the same two-overlapping-squares constructionfill_rule.mojo's own entry already used for the discrete case, confirming the identical divergence holds for the continuous membership test too. Seeexamples/polygon.mojo(a fourth pentagon,fill_polygon_aa, alongside the existing three) andexamples/path.mojo(a second leaf,fill_path_aa, specifically because a curved boundary is where hard-edged jaggedness is most visible, unlike the axis-aligned shapes the polygon example uses). -
downsample()(canvas_mojo/resize.mojo) -- box-filter shrink a Canvas by an integer factor, each output pixel the rounded mean of itsfactor x factorsource block. Built for a concrete problem raised against a downstream chart-rendering example's own output ("the text looks fuzzy"), and refined once by real pushback rather than shipped on the first idea: the first attempt just rendered chart examples onto a bigger canvas at render time and left the output file that much bigger too -- which only looks sharper in a viewer that happens to scale the larger file back down to fit some display area, not a real per-pixel quality improvement, and does nothing for a viewer that shows images at native pixel size.downsample()is the actual fix: render atfactorx the intended final size, then shrink back down through this to that exact original size -- real supersampled anti-aliasing baked into the file itself, independent of anything that later displays it. Confirmed directly, not just argued: the same scatter scene rendered once plainly at 640x420 and once at 1920x1260-then- downsample()d back to 640x420 -- both files the identical dimensions, viewed side by side, the supersampled one visibly finer at the shared axis/legend text.Rounds rather than truncates each channel average (
(sum + n // 2) // n, notsum // n) -- confirmed by a dedicated test case whose true average lands exactly on .5 (a 2-and-2 split of 0/1 values, average 0.5, must round up to 1 -- truncation would silently give 0 and skew every downsampled image slightly dark).factor=1is a valid, un-special-cased no-op copy; afactorthat doesn't evenly divide both dimensions raises rather than silently truncating a partial edge block or rounding to a slightly wrong output size. -
DrawTarget(canvas_mojo/vector/draw_target.mojo) +SvgCanvas(canvas_mojo/vector/svg.mojo) -- a vector rendering backend for a downstream charting layer, sitting alongside the rasterCanvasone rather than replacing it. Motivated by a direct question raised againstdownsample()itself (the entry just above): does starting from a raster foundation and patching resolution/sharpness problems onto it (scaled rendering,downsample()) just push a problem downstream that an SVG-based design would never have had in the first place, since a vector format has no fixed pixel grid to lose sharpness at? The honest answer was yes for the sharpness problem specifically, but no for the whole raster investment --canvas's own AA/path- fill/gradient/clip engineering isn't wasted, it's just not the only backend a downstream charting layer would render through anymore.DrawTargetis a trait covering exactly the six shape primitives a chart-rendering core actually needs (fill_rect,draw_line_aa,fill_circle_aa,fill_arc_aa,stroke_path_aa,fill_path_aa) --Canvas(canvas_mojo/buffer.mojo) conforms via six thin methods that delegate to the exact free functions every existing call site already calls;SvgCanvasconforms by emitting SVG markup strings instead of touching a pixel buffer at all, so it needs none ofcanvas's own AA/coverage/scanline-fill machinery -- an SVG renderer (browser, image viewer, PDF exporter) does that itself, at whatever resolution it's displayed at.Getting
Canvasto conform to a shared trait took two real, wrong turns before landing on the shape it has now, both instructive enough to record rather than silently discard:- A first version put
draw_textinDrawTargettoo, withCanvasdelegating tocanvas_mojo.text.draw_text(needscairo_mojo). Compiling any file that merely importsCanvasfromcanvas. buffer--tests/test_buffer.mojo, which touches no text at all -- then failed with "unable to locate module 'cairo_mojo'", since Mojo resolves a struct's entire method surface (and whatever those methods import) at the point the struct itself is declared, not lazily per call. This would have broken the careful cairo-is-opt-in separationcanvas_mojo/text/render.mojo's own docstring documents ("Text is the deliberate exception") for every canvas user, not just the ones drawing text. Fixed by excludingdraw_textfromDrawTargetentirely -- seedraw_target.mojo's own module docstring for how a generic chart-rendering core built on top of it can still handle text without this trait (collecting it as plain data instead of drawing it inline through the trait). - Before landing on the "text isn't in the trait" fix, a move-in/
move-out wrapper struct (
RasterTarget, holding an ownedCanvas, meant to keepcanvas_mojo/buffer.mojoitself untouched) was tried as an alternative way to keep cairo out ofCanvas's own file. Hit a genuine Mojo ownership-tracking limitation, confirmed by reducing to a minimal repro: constructing the wrapper, then extracting its one field back out viawrapper. canvas^, failed with "field ... destroyed out of the middle of a value" even with zero method calls in between -- Mojo doesn't support this specific partial-move-out-of-a-varpattern in this version. Abandoned once confirmed reproducible in isolation, not worked around blindly.
Also confirmed directly, not assumed, before committing to the design: Mojo's trait conformance is nominal (a struct must explicitly declare a trait in its own signature --
Canvas(Copyable, DrawTarget, Movable)-- not satisfied by merely having matching methods), and multi-file circular imports genuinely work in this Mojo version (canvas_mojo.buffer->canvas_mojo.draw_target->canvas_mojo.path->canvas_mojo.buffer, and separately ->canvas. primitives->canvas_mojo.buffer) -- both checked with minimal standalone reproductions before touching real code, not discovered by trial and error against the real module graph.TextAlignmoved out ofcanvas_mojo/text/render.mojointo its owncanvas_mojo/text/text_align.mojoas part of this -- it was always a plain, cairo- freeInt-wrapping struct, just previously defined inside a module whose own top-level import list pulled incairo_mojoregardless.canvas_mojo.textre-exports it (from canvas_mojo.text_align import TextAlign), so every pre-existingfrom canvas_mojo.text import TextAligncall site kept working unchanged.SvgCanvasitself:fill_arc_aadraws each wedge as an SVG arc path (M center L start A r,r 0 large-arc-flag,1 end Z) with no sign flip for the sweep direction -- SVG's own coordinate space is y-down by default, the same ascanvas's, so increasing angle already sweeps clockwise in both (the same fact already confirmed for the raster path, in this package's ownfill_arc_aa).stroke_path_aa/fill_path_aaconvertPath.commandsdirectly to an SVGdstring (_MOVE_TO/_LINE_TO/_QUAD_TO/_CUBIC_TO/_CLOSEmap one-for-one ontoM/L/Q/C/Z, both using absolute coordinates already -- no coordinate-system translation needed either).draw_text(a plain method, not part ofDrawTarget-- see above) mapsTextAlignontotext-anchor(start/middle/end) and escapes&/</>in text content (&first, always, since escaping the other two each introduce a literal&a second pass would then mangle).16 tests (
tests/test_svg.mojo, no cairo needed -- confirmed by running with-I .alone) assert on the generated markup's own string content, hand-derived the same rigor pixel-color assertions get elsewhere in this workspace: exact rect/line/circle/path attributes, an arc wedge's endpoint coordinates (cross-checked viapython3, including one genuinely non-nicefloating-point value -- confirmed Mojo'scos/sinand Python'smath.cos/sinproduce bit-identical output for the same input before trusting an exact-string match against it, not assumed), and the large-arc-flag boundary (a span > pi, deliberately not exactly pi -- an exact-pi wedge is an ambiguous edge case not worth pinning down a test to). Extended forfill_ring_sector_aa(donut wedges, added for a downstream donut-chart feature) the identical way: hand-derived quarter- and wide-wedge endpoint coordinates for a ring instead of a full wedge, including the same large-arc-flag boundary check on both the inner and outer arc. See "Bugs found and fixed along the way" below for a real floating-point formatting bug this same hand-derivation process caught while building the wide-wedge ring-sector test. - A first version put
-
Split into its own standalone repo (2026-08-13) —
canvas_mojomoved out of the combinedgraphicspixi workspace it started in (alongside a sibling charting package, still there for now) intogithub.com/randyzwitch/canvas_mojo, its own git repo with its ownpixi.toml/CI. First step toward this: renaming the folder itself from plaincanvastocanvas_mojoinside the shared workspace (recorded in this file's own entries above, before the split), so the folder was already named the way its own repo would be by the time the actualmvhappened. The sibling package stays inside thegraphicsworkspace for now (referencing this repo the same way it always has, no import changes needed) -- splitting it out too is a separate, not-yet-scheduled step.third_party/cairo_mojo(the vendored Cairo bindingcanvas_mojo/ text.mojodepends on) came along as a plain copy too, at the same relative path it already had -- every existing-I third_party/ cairo_mojoflag in this repo's ownpixi.tomltasks needed no changes.pixi run test/pixi run exampleconfirmed clean in the new repo, standalone, before pushing -- not assumed to still work just because they did inside the old shared workspace. -
Cairo removed entirely --
text.mojois now fully native (2026-08-14) — installingcairo_mojoas a real pixi/git dependency turned out not to matter: rather than pursue that,canvas_mojo/text/render.mojowas rewritten to not need Cairo at all, in four incremental, independently verified pieces -- font discovery (fontconfig, direct FFI), FreeType face loading, glyph outline/metrics reading (also direct FreeType FFI), and rasterization (already-existingfill_path_aa). Each piece was linked against the real system library and checked against known- correct values before being trusted, not assumed correct because it compiled: font discovery resolved "Sans" to the exact filefc-matchitself reports; a loaded face'sunits_per_EM/num_glyphs/ascender/descendermatched DejaVu Sans's own well-known real values (2048/6253/1901/-483); a glyph's outline decomposition (a direct translation of FreeType's ownFT_Outline_Decompose, not re-derived from memory -- the on-curve/off-curve/implied-midpoint TrueType-spline interpretation has real edge cases worth taking from the reference implementation) was cross-checked against Cairo's owntext_extents()for the identical glyph and matched exactly (7.0/ 44.0 for "I" at size 60), and a genuinely curved glyph ("O") rendered through this package's ownfill_path_aaproduced a correct round shape with its hole properly punched.Two real, non-hypothetical bugs surfaced and were fixed along the way: Mojo's ASAP destruction freed a
FreeTypeFacebefore Cairo finished reading from it during the font-discovery-only intermediate step (a real segfault insideFT_Set_Transform, fixed with an explicit keep-alive touch); and a freshly-loadedFT_Facerenders at some small default size regardless of the caller's later requested size unlessFT_Set_Pixel_Sizesis called explicitly first (caught becausemeasure_text_block's own cross-check against actually- rendered ink failed by more than slop, not because it was expected).Once
draw_text/measure_text/measure_text_blockwere rewired to the fully native pipeline, the entire pre-existingtest_text.mojosuite (24 tests, including a hand-locked exact-value test) passed unchanged -- real evidence the native path reproduces the old Cairo- backed one's behavior where it matters, not just "looks plausible."third_party/cairo_mojo(the vendored binding) and every-I third_party/cairo_mojoreference were then removed from the repo entirely, along with thelibcairoCI/system dependency (replaced withlibfontconfig/libfreetype, both already-transitive dependencies most systems already have).canvas_mojois now genuinely dependency-light: stdlib plus two direct, small FFI bindings to mature system libraries, no vendored third-party binding of any kind -- the project's own stated goal from its README's "Why?" section, achieved. -
Module layout reorganized into subpackages (2026-08-16) — the package had grown to 17 flat top-level files (6,474 lines) with only
io/split out as a subpackage, purely as an artifact of building incrementally rather than a deliberate layout; reorganized before recommending end users try the package, while there are still no external consumers to break. Grouped by real, verifiedimportdependency shape, not guessed:text/(render.mojo-- renamed fromtext.mojoto avoid thecanvas_mojo.text.textstutter its old name would've produced as a subpackage member --,text_align. mojo,font_discovery.mojo,freetype_face.mojo,glyph_outline. mojo,bidi.mojo; six files, ~2,200 lines, nothing outside this group ever depended back into it) andvector/(svg.mojo,draw_target.mojo, same one-directional shape).buffer.mojo/path.mojo/primitives.mojostay flat at the top level deliberately -- confirmed via direct probe that they form a genuine three-way circular import already (buffer->path/primitives,path->primitives/buffer,primitives->buffer), and separately confirmed via a throwaway two-package probe that Mojo tolerates circular imports across subpackage boundaries too (not just within one package) -- so the flat placement is a deliberate "this is genuinely one cohesive raster engine" call, not a limitation forced by the language.canvas_mojo/__init__.mojo's existing curated re-export list (an explicit, fully-spelled-out name list -- this project doesn't use wildcard imports anywhere, confirmed by grep before relying on that as a description of its own style) already meant most of this move is invisible to a caller doingfrom canvas_mojo import Canvas; only the module paths changed, not the paths a normal caller already goes through.io.*/SvgCanvas/DrawTargetwere, and remain, reached via their own explicit submodule path rather than the top-level facade -- confirmed that's already how every real call site in this repo uses them (never the shallowfrom canvas_mojo.io import write_bmpio/__init__.mojotechnically also offered), so that stray, inconsistent, actually-unused re-export was removed rather than kept or extended to match.Verified mechanically, not just "moved files and hoped": every internal
from canvas_mojo.X import Y(across source,tests/,examples/) and every prose file-path mention in a docstring/ comment were updated -- cross-package references got their full new path, same-subpackage references kept their bare filename (still accurate and unambiguous for a file's own sibling). Full suite (240 tests) andpixi run exampleboth passed clean immediately after, with zero behavior changes anywhere -- this was a pure file-layout and import-path change. -
text/ttf.mojo(2026-08-17) — a native TrueType (sfnt/glyf) font file parser, reading a font file's own binary tables (table directory,head,maxp,hhea,hmtx,cmap,glyf,loca) directly rather than linking FreeType -- the second of the two remaining FFI dependencies (the other, fontconfig, stays linked; see this entry's own "Deliberately scoped" section below for why the two aren't equally worth replacing). Same "translate the real spec faithfully" methodology already used for FreeType's ownFT_Outline_Decomposeand zlib's ownpuff.c: every field offset and decode algorithm was transcribed directly from Microsoft's OpenType 1.9.1 specification, not guessed at or reconstructed from memory.Deliberately scoped, matching this package's own established v1- scope pattern (
io/png.mojo's own precedent): TrueType (glyf) outlines only -- a CFF/OpenType-CFF font (sfntVersion'OTTO') raises a clear, specific error rather than being silently misread, real addressable scope if a concrete font needs it, not attempted speculatively ahead of one. No hinting -- FreeType's own hinting bytecode interpreter is a large, separate subsystem, skipped on purpose (hinting mostly matters for crisp rendering at small pixel sizes on non-antialiased displays; every glyph this package renders already goes throughfill_path_aa's own supersampled AA, which makes unhinted outlines look correct at chart-relevant sizes -- confirmed directly, not assumed, see the verification paragraph below). Variable fonts aren't specially handled but don't need to be:glyf/locahold the default (non-varied) instance regardless, read automatically by simply never touchinggvar's own per- instance deltas. Composite glyphs (accented characters built from a base glyph + mark, e.g. "é") are supported, including the scale/2x2-transform component flags; point-matching placement mode is not (every composite glyph actually encountered during verification used the far more common xy-offset mode) and raises a clear error if hit.fontconfig, by contrast, stays linked rather than getting the same treatment -- a deliberate distinction, not an inconsistency: FreeType's job here is parsing a closed, formally specified, decades-stable binary format, the same character of problem as DEFLATE/PNG; fontconfig's job is an open-ended, continuously- updated, OS/distro-specific convention (font directory layouts, an XML substitution/aliasing rule engine, per-language fallback chains refined by the whole desktop Linux ecosystem for decades) -- exactly the "not something worth re-deriving" reasoning
font_discovery. mojo's own docstring already gives for linking it, not a new argument invented here.Verified against real values at every step, not trusted because it compiled: parsing DejaVu Sans natively gives
unitsPerEm=2048,numGlyphs=6253,ascender=1901,descender=-483-- the exact same values already independently verified against FreeType itself elsewhere in this codebase (glyph_outline.mojo's own module docstring), both reading the same real font file's own real data through two completely different implementations. A from-scratch Python oracle (plainstruct.unpack, no font libraries, written independently while developing this module) cross-checked every stage before trusting it here: table directory offsets, bothcmapsubtable formats (4 and 12 independently agree on the exact same glyph index for the same codepoints, real cross-validation, not just self-consistency), the simple-glyph flag/delta-coordinate decode (capital "I" -- 1 contour, 4 points, all on-curve, the same fact already locked in for the FreeType path), and the composite-glyph component-transform math (the full 32-point, 3-contour decomposition of "é" diffed byte-for-byte against the oracle's own independent decode of the identical glyph before being locked into a test). Closed the loop end to end too: the natively-parsed "O" glyph, rendered through this package's ownfill_path_aa, produces real ink with a correctly EVEN_ODD-punched hole -- compared directly against the same test run through the existing FreeType path, close but not pixel-identical (391 vs. 374 ink pixels, 31.48px vs. 31.0px advance width), a real, understood, expected difference: FreeType applies its own default hinting/rounding even without an explicit "no hinting" flag, while this module deliberately never does -- confirmed the underlying raw outline data matches the oracle exactly first, so this gap is FreeType's own post-processing, not a parsing discrepancy.Ships as a standalone, thoroughly-tested module (9 tests,
tests/ test_ttf.mojo) -- not yet wired intodraw_text/measure_text, matching the exact same incremental patternfont_discovery.mojo(FFI "job 1" of the original Cairo-removal breakdown) shipped in on its own first, verified standalone, before later modules wired it in. Swapping this in asglyph_outline.mojo's actual outline source (replacing FreeType there too) is the natural next step, not done in this same change. -
FreeType removed entirely (2026-08-17) — the natural next step from the entry directly above, done as its own follow-up change:
glyph_outline.mojorewritten from a FreeType FFI binding to a thin adapter overttf.mojo's ownTTFFace,render.mojoswitched fromFreeTypeFacetoTTFFacethroughout, andfreetype_face.mojodeleted outright once a grep confirmed nothing else imported it.pixi.toml'sfreetypedependency line removed from both[package. host-dependencies]and[package.run-dependencies]; CI's font- install steps (Linuxapt-get, macOSbrew) droppedlibfreetype6/freetypeaccordingly, keeping only the test-fixture font packages.fontconfigis now this package's only remaining direct FFI dependency (font discovery alone) -- glyph parsing and rasterization are both fully native Mojo, no FreeType, no Cairo, no other third-party font/rendering engine anywhere in the pipeline. Two newTTFFacemethods made the swap possible:set_pixel_size/scale()(raising the same "no unset-size default to silently trust" way the oldFreeTypeFacedid) andRawGlyphOutline.bounding_box()(scans the decoded outline's own points forGlyphMetrics' ink bbox, the same value FreeType used to hand back from its own glyph-slot metrics).Confirmed real, expected glyph-metric differences from the swap, not just tolerated them: FreeType's default hinting rounds thin stems (like capital "I"'s single vertical stroke) to whole pixels for on-screen crispness; the unhinted native path never does that, so the two produce measurably different (not just imprecise) width/ height/advance numbers for the same glyph at the same size. Every test asserting exact hinted-FreeType values was re-measured against the native path via direct probe rather than guessed at by hand (a first mental-math attempt at one expected value was itself wrong, caught only by actually running the probe -- the same "verify, don't guess" lesson this project has hit before) -- e.g.
measure_text("I", 24.0)moved from FreeType-hintedwidth=3.0/height=18.0/advance≈7.0to native, exactly-representablewidth=2.3671875/height=17.49609375/advance=7.078125(exact, not rounded, because DejaVu Sans'sunitsPerEm=2048is a power of two, soraw_units * pixel_size / 2048always lands on an exactFloat64value). Full suite (249 tests) and all 16 examples verified passing after the swap, plus a manual visual check:examples/out_text.bmphand- converted to PNG (this environment has no PIL/ImageMagick/ffmpeg -- a from-scratch BMP-parser + PNG-encoder using only Python's stdlibstruct/zlib) and inspected directly, confirming correct RTL Hebrew/Arabic rendering, rotation, multi-line, alignment, and font fallback all still rendered correctly through the new path. An unplanned but welcome side effect:test_text.mojoalone dropped from roughly 23s to roughly 2s with FreeType's FFI/dlopenoverhead gone -- the whole suite runs noticeably faster now, not just the same speed with one less dependency. -
Real DEFLATE compression for
write_png(2026-08-19) — until this,write_pngsidestepped implementing DEFLATE's compression side entirely via RFC 1951 3.2.4's "stored" block type (BTYPE=00, a fully valid but uncompressed encoding) -- the same "viewable, lossless, trivial to verify byte-by-byte over small files" trade BMP still makes.io/deflate.mojogained a from-scratch LZ77 + fixed-Huffmandeflate()alongside its existinginflate(), built directly against the RFC's own text with the decoder already in place to round-trip-verify it against, so PNG output is now actually compressed -- no external compression tool anywhere in the pipeline (this also removed the ImageMagick step the docs-image pipeline briefly used).tests/test_png.mojo'sEXPECTED_HEXwas re-captured from the compressed output and independently verified rather than hand-derived byte by byte the way the stored-block-era value was; real compression also makes IDAT shorter, so IEND's own offset in that test moved (IDAT's tag offset, which depends only on IHDR's fixed 13-byte size, did not). -
Font-resolution performance: cheap candidates before subprocess hints (2026-08-19) —
resolve_font_file/resolve_font_file_for_ charused to open libfontconfig by computing every candidate path up front, which on Linux meant unconditionally spawning realldconfig -p/pkg-configsubprocesses on every single call, ~35ms of it, just to relocate a library that was already found the call before. Split into two tiers instead:_cheap_fontconfig_candidates(explicitFONTCONFIG_LIBenv override, then each platform's canonical library name -- what almost every real installation resolves through the dynamic linker's own search path, zero subprocesses), tried first, with_expensive_fontconfig_hint_ candidates(ldconfig/Homebrew/pkg-config) reached only once every cheap candidate has actually failed to dlopen. Probe-measured, not assumed: a raw dlopen oflibfontconfig.so.1measured ~12ms against ~17ms for the ldconfig hint and ~28ms for the pkg-config hint -- ~45ms of subprocess-spawn cost a normal installation now skips entirely, cutting an uncached resolve to a few ms on the first call and under a millisecond after (the OS's own dlopen refcounting makes a repeat load of the same library near-free once nothing is paying to rediscover its path). -
text/font_cache.mojo(2026-08-19, extended 2026-08-23) — an opt-in, per-caller cache of fontconfig's family/slant/weight [/codepoint] -> path resolution, threaded throughdraw_text/measure_text/measure_text_blockvia a keyword-onlycache=overload.render.mojo's own_load_sized_face/_resolve_glyphdocstrings had flagged the absence of this caching as a deliberate simplification ("correctness first... not a caching layer built ahead of a concrete need"); profiling supplied the concrete need. Explicit rather than automatic because Mojo has no mutable global/module-level state (confirmed directly: declaring one raises "global variables are not supported"), so there is nowhere for a lazily-initialized process-wide cache to live.Extended on 2026-08-23 to cache the parsed, sized
TTFFacetoo, not just the resolved path. The module's own docstring had previously argued against that:TTFFaceowns the whole font file's raw bytes (data: List[UInt8], Movable only, not ImplicitlyCopyable), so caching it "for real" appeared to mean copying a multi-hundred-KB buffer out of theDicton every hit. Profiling said otherwise (dataviz_mojo measuredTTFFaceparse +set_pixel_sizeat ~0.127ms each against a cache hit at ~0.00015ms, anddraw_text's two-pass measure/render split pays it twice per call, ~0.255ms), andArcPointer[TTFFace]sidesteps the copy objection entirely rather than wideningTTFFace's trait surface: one heap-allocated face per distinct (path, pixel size), every hit a refcount bump. Keyed onpath + "@" + pixel_size, not path alone, sinceset_pixel_sizemutates the instance two differently-sized callers would otherwise share. -
aa_crossing.mojo(2026-08-20) —_AACrossingand its insertion sort had been duplicated byte-identically inpolygon_fillandpath.mojo(only the surrounding docstrings differed), because neither could import it from the other:path.mojoalready imports real drawing primitives frompolygon_fill, so importing_AACrossingback the other way would have been a genuine cycle. Extracted into a leaf module that imports from neither, leaving a clean DAG (polygon_fill -> aa_crossing, path -> aa_crossing, path -> polygon_fill). A third copy of the same insertion sort survives deliberately inpolygon_fill's own_spans_from_crossings, over_Crossing/Intrather than_AACrossing/Float64-- unifying all three behind one generic sort is a bigger, separate change than resolving the two-copy duplication was. -
_draw_polyline_core_aabuckets candidates by column (2026-08-23) — the earlier version rescanned the entire row-candidate list for every pixel column in[min_x, max_x], which made a wide, densely-populated row (many segments, each near-vertical relative to pixel width -- a real shape for a noisy line-chart series sampled far denser than the canvas is wide) cost O(row_width * row_candidates). Each candidate is now bucketed into just the columns its own half-width-expanded x-range actually covers: O(row_candidates * each segment's own column footprint) to fill the buckets plus a flat O(row_width) to sweep them. -
shapes/subpackage (2026-08-23) —canvas_mojo/primitives.mojosplit intocanvas_mojo/shapes/{lines,rects,circles,ellipses,arcs, polygon_fill,dash}.mojo.dash.mojois the one member that depends on neitherCanvasnor any shape's own geometry, which is why the phase logic every stroked shape shares lives in its own file rather than insidelines.mojo. The hard-edged vs._aanaming convention documented inshapes/lines.mojoapplies across the whole subpackage. -
Docstrings and comments narrowed to current behavior, then shortened (2026-08-23, PR #42) — this page exists partly because of this change. Source comments had accumulated a running history of how the code got here: the Cairo and FreeType removal narratives, "job 2 of the original 4-job breakdown" phase numbering, references to deleted modules (
third_party/cairo_mojo,fonts/raster.mojo,primitives.mojo), and comments describing what a function "used to" do. All of it moved here, and the files now describe what the code does today.Two headers were not merely dated but wrong.
font_discovery.mojostill saidcanvas_mojo.text"still does all of that via Cairo today" and that the module was "not yet wired intodraw_text/measure_text" -- both false since the Cairo removal above.examples/png_output.mojostill advertised uncompressed DEFLATE "stored" blocks on the write side, false since the native encoder landed. A later pass over tests/examples found two more the first sweep missed, since neither named itself as history:scripts/gen_example_docs.mojodescribed docs images as.bmpfiles converted by ascripts/convert_example_images.shthat no longer exists, through an ImageMagick pixi dependency that no longer exists either; andexamples/clipping.mojopointed at "the roadmap item this came out of".A second and third commit then cut length rather than content, under five rules: drop the alternatives-not-taken defense (the reader needs the rule they would otherwise break, not the argument against a rejected choice), drop epistemic hedging ("confirmed via probe, not assumed" is a commit-message claim -- the test file is the evidence), drop the "X's own" possessive tic, collapse chained cross-references to other docstrings, and stop restating the algorithm the code already shows. Across the repo, prose went from 4,936 lines (41,113 words) to 3,588 lines (27,844) -- 32% of the words gone -- with the word "own" down from 841 uses to 56 and epistemic hedging ("confirmed", "independently", "probe", "not assumed", and the like) from 198 to 25. Code line count is unchanged at 8,079.
Deliberately kept at full length:
bidi.mojo's scope limits,ttf.mojo's spec deviations,_arc_bounds' counterexample derivation with its coordinates,_format_svg_float's ULP trap, andfill_polygon's half-open Y-extent rule -- passages where the length is the content, and where a reader who skips them writes a bug. Test comments kept every hand-derived value and the arithmetic behind it; only the frame around the numbers went.No behavior change: 287 tests across 23 files and all 16 examples verified passing after each commit.
-
fill_ellipse_aaon theDrawTargettrait (2026-08-24, PR #46) — the one gap the Coverage review turned up, and the only primitive shape a trait-targeting caller previously could not draw exactly. Every other shape left off the trait is left off becausefill_path_aacovers it: a triangle or star is a handful ofPathcalls with no new geometry. An ellipse is the exception, becausePath.arc_totakes a singleradiusand so builds circular arcs only -- an ellipse can only be approximated throughPath, with cubics.fill_ellipse_aa/draw_ellipse_aahad existed as raster primitives since early on, but neither was on the trait andSvgCanvashad no ellipse method at all, so the vector backend simply could not produce one. Error ellipses and confidence regions on a scatter plot need it.Canvas.fill_ellipse_aadelegates to the existing free function;SvgCanvas.fill_ellipse_aaemits an<ellipse>element mirroringfill_circle_aa's shape, including that convention's existing treatment ofcolor.a(dropped --_hex_coloremits rgb only, for every SvgCanvas method; changing that is a separate concern affecting all nine). Fill only, matchingfill_circle_aa, which carries nodraw_circle_aacounterpart on the trait either; the ellipse outline hits the identicalPathlimitation and stays in the Backlog.Three tests. Two check the SVG element's exact attributes and that
rx == rystill emits<ellipse>rather than silently collapsing to<circle>, so the markup stays unambiguous about which call produced it. The third is the one that matters and had no equivalent for any other trait method:Canvas.fill_ellipse_aamust produce byte-identical pixels to the free function it delegates to, antialiased edges included -- generic rendering through the trait and a direct call have to agree, not merely both compile. -
CONTRIBUTING.md (2026-08-24, PR #43) — how the package is put together, the Mojo features a contributor meets in it, and the conventions a change is expected to hold to.
DrawTargetgets the most space, framed as the constraint it is rather than a feature list: only operations both backends can express belong on it, with text as the instructive exclusion. The Mojo section documents what the code actually relies on -- the argument conventions (mut/var/out self/ref/^), the value-semantics traits andList[T]not being implicitly copyable, the struct-with-comptime- constants stand-in for enums,ArcPointerfor shared ownership, no mutable globals (henceFontCachethreaded through a keyword-onlycache=), the single FFI file, andString's explicit[byte=]indexing.Also corrected an error found while writing it:
buffer.mojoandsvg.mojoboth describedDrawTargetas having six methods when it had eight -- a stale count that predated the docstring cleanup and survived it unchecked. (The trait has grown since; a live count does not belong in a history entry, which is how that one went stale in the first place.) -
Formatting standardized on
mojo format(2026-08-24, PRs #44 and #45) — the repo had been hand-formatted at roughly 100 columns, so a baremojo formatrewrote all 51 files. Rather than pick a custom--line-lengthto preserve the existing style, #44 adopted the tool's own 80-column default, so the no-argument invocation and any editor-on-save are correct from here on with no flag to remember.Formatting only, in five categories: wrapping long parameter lists, expressions and imports; trailing commas on wrapped lists; parens around split expressions; long string literals split into adjacent literals; and quote style normalized so a string containing
"uses single quotes rather than escapes. Verified content-preserving by normalizing every changed file down past whitespace, wrapping punctuation and quoting and comparing, plus the full suite and all 16 examples.#45 then added
.github/workflows/format.yml, which runs the formatter on every pull request and commits the result back to the branch rather than failing a check -- an unformatted branch gets fixed instead of rejected, chosen deliberately over a--checkgate. Two constraints it has to live with, both documented in the workflow: a fork PR gets a read-onlyGITHUB_TOKENand its head branch lives elsewhere, so those fail with a message asking forpixi run fmt; and a push made with the defaultGITHUB_TOKENdoes not trigger further workflow runs, so the test job does not re-run on the formatting commit -- acceptable only because the formatter cannot change behavior. Afmtpixi task backs both, so CI and a developer's machine share one definition. -
draw_ellipse_aaon the trait, andexamples/vector.mojo(2026-08-24, PR #48) — the outline half of the ellipse gap, closed for the same reason the fill was:Path.arc_totakes a singleradius, sostroke_path_aaover aPathcan only approximate an ellipse outline. The ellipse is consequently the only shape on the trait carrying both a fill and an outline, where circles and arcs carry only a fill -- correct rather than inconsistent, since a circle outline genuinely isstroke_path_aaover a one-arc_toPath.It takes no
width, unlikedraw_line_aa/stroke_path_aa: the raster primitive draws a fixed ~1px outline and has no width parameter of its own, and a trait parameter the raster backend could only ignore would be worse than none.SvgCanvasemitsstroke-width="1"to match. (Drafted with awidthfirst, by analogy withdraw_line_aa, before checking what the raster primitive actually accepts.)examples/vector.mojois the first example to touchSvgCanvasat all -- every one before it was raster-only, so the vector backend appeared in the docs as API reference with nothing runnable, and theDrawTargettrait was argued in prose but never demonstrated.draw_scene[T: DrawTarget](mut target: T)draws one scene knowing nothing about which backend it holds;mainruns it twice, writingout_vector.pngandout_vector.svgfrom the identical call. It uses translucent colors deliberately, since alpha is where the two backends do genuinely different work to reach the same picture. -
SvgCanvashonors color alpha (2026-08-24, PR #47) — a real divergence between the two backends, found while surveying what was left to do rather than by a failing test._hex_coloremits#rrggbb, which has nowhere to put alpha, and nothing added an opacity attribute -- so every translucent color rendered fully opaque through the vector backend while the raster backend blended it. The sameDrawTargetcall produced two different pictures:raster : 128 0 0 (translucent red over black) vector : fill="#ff0000" (fully opaque)That contradicts the trait's whole premise, that code written against it works on either backend without knowing which -- and it was internally inconsistent too, since
fill_rect_gradientalready emittedstop-opacity, so alpha survived in a gradient and vanished in a flat fill._opacity_attrnow covers all nine Color-taking sites:fill-opacityfor fills,stroke-opacityfordraw_line_aaandstroke_path_aa, where a fill attribute would be silently ignored since those elements setfill="none". Omitted entirely ata == 255, matching the omit-at-default conventionrotationandweightalready follow, so opaque output stayed byte-identical and no existing hand-derived test expectation moved. Alpha is emitted as SVG wants it, a 0-1 fraction at_format_svg_float's 3 decimals -- the shapestop-opacityalready used.Four tests, one of which exists to catch the next instance rather than this one: it calls every Color-taking method with a translucent color and counts opacity attributes, so a method added later without one fails there instead of silently rendering opaque.
-
AA coverage counted by interval, and the polyline core vectorized (2026-08-24/25, PRs #50 and #51) — came out of asking where the package was still doing "the simple version," and worth recording mostly for the route, since the first answer was wrong.
The stated plan was to replace
draw_polyline_aa's per-sample minimum-distance test with what production rasterizers do: convert the stroke to a polygon and scanline-fill it. Built as a prototype first rather than as a change, which is what saved it: 9.5x slower (1363ms against 144ms), and wrong as well -- rendered dashed and full of holes, because the joint discs wound opposite to the segment quads and NONZERO cancelled where they overlapped.Measuring why is what found the real target.
fill_path_aacosts O(bbox x supersample^2) -- a ~43ms floor at 1400x700 -- plus O(edges x sub-scanlines), and the stroke path's ~40,000 edges swamped it. The decisive part: even with a perfect active edge table, the polygon route still pays that per-pixel floor per series, against ~48ms for the whole of the existing algorithm. It could never have won. The floor was the thing worth attacking, andfill_circle_aa/fill_arc_aaalready skip provably-inside/outside pixels whilefill_path_aasampled every pixel unconditionally.#50 replaced per-sample testing with interval counting in
fill_path_aaandfill_polygon_aa. Inside/outside is constant between consecutive crossings and sub-sample x positions are uniformly spaced -- fx(g) = x0 + (g + 0.5)/s across a whole row -- so an inside run maps to a contiguous range of sample indices. A pixel wholly inside takes+= sin one step; a pixel in no run is never touched. Full-canvas bbox 43.0ms -> 18.5ms, 400-point area fill 28.9ms -> 13.9ms.#51 found
draw_polyline_aarebuilding each segment's endpoint, direction and |d|^2 inside the sub-sample loops -- a per-segment constant recomputed supersample^2 times per pixel per candidate, while the existing per-segment pass already computed the direction and discarded it. Hoisting alone took a volatile 3x4000-point chart from 205ms to 133ms. Vectorizing the candidate loop for undashed strokes took it to 49.5ms. Dashed strokes keep the scalar path, since_is_dash_onneeds each sample's own distance along the path.Both are exact rather than close: sample positions are unchanged, only the counting is, so every hand-derived coverage assertion still passes untouched -- which is the check. Rendered output was additionally confirmed byte-identical by hashing PNGs before and after.
Two things learned that outlast the change. A draft of #51 stored 1/|d|^2 and multiplied; that is not bit-identical to dividing and could flip a sample sitting exactly on the half-width boundary, and it measured 2% faster, so the division stayed. And Mojo's SIMD comparison returns a scalar
Boolrather than a lane mask, so there is no masked select available -- the vector part computes an elementwise minimum across candidates and the hit count is a short scalar tail.#50 also added a cross-check the module docstrings had claimed but nothing enforced:
_point_in_polygonis described as the reference the sweep "must agree with pixel for pixel," so a test now brute-forces coverage through it at every sub-sample of every pixel against the rendered alpha. Proved load-bearing by undercounting the accumulation and watching four tests fail. A first attempt at proving it corrupted theceilterm instead and nothing failed -- the boundary correction loops absorb that entirely, which is exactly why floating-point precision at a crossing is not a hazard here. -
Wedge fills without
atan2, and glyph decoding cached (2026-08-25, PRs #52 and #53) — the second pass over "where is this still doing the simple version," and a cleaner result than the first because the search started from reading the code rather than from a hypothesis about it.#52.
fill_arc_aaandfill_ring_sector_aacalledatan2once per sub-sample that passed the radius test, then normalized the result into the span's own 2*pi window. A radius-300 wedge covers roughly 360k pixels, so at the default supersample that is about 5.8 million transcendental calls for one wedge -- to answer a question that never needed the angle at all, only which side of the two boundary rays a sample falls on._AngleSpancomputes the two rays once per wedge and decides each sample with cross-product signs: a span of at most pi is the intersection of two half-planes, a wider one the complement of the narrower gap it leaves.1145ms -> 70.2ms on 24 wedges plus 8 ring sectors: 16x, and the largest single speedup in the package so far.
Getting it exact took two rounds, and differential testing found both failures where reading would not have. First version: wrong on 347 of 392,224 combinations, because a sample sitting exactly on a boundary ray puts a cross product at zero and the sign that decides membership is whatever the rounding produced. Second version: wrong on exactly 1 -- a span where
end - startrounds to exactly 2*pi whileenditself sits a few ULP below the sample's normalized angle, so the angle form excludes a sliver that a "full turn is always inside" shortcut includes. Both now fall back to the angle form. That exhaustive test is in the suite; a hand-picked set would have missed both.#53.
FontCachecached the resolved font path and the parsed face, then stopped short of the third thing every glyph lookup repeats: the decode itself. Eachglyph_path/glyph_metricscall still walkedloca, read contours, points and flags, and recursed through composite components. ARawGlyphOutlineis in font design units and depends on the glyph alone, not the size it is drawn at, so the face is where it belongs -- one cache serving every size drawn from it -- and theArcPointermechanism the face cache already used applies unchanged.36.3ms -> 32.4ms on 40 labels. Real, and much smaller than removing a per-glyph decode suggests: glyph decoding was not where text rendering spends its time, rasterization is. Worth recording as a finding rather than a disappointment -- #50's interval-coverage change did not help text either, because glyph bounding boxes are too small for its per-pixel floor to matter. Whatever text costs lives somewhere neither change touched, and a profile rather than a third structural guess is what would find it.
This one merged on the design argument rather than the number: the caching story was inconsistent, not incomplete by design, and the measurement is supporting evidence. Both changes were verified byte-identical by hashing rendered output -- text across rotated, right-to-left and fallback-glyph cases, arcs across spans that cross the atan2 discontinuity and exceed pi.
One process note worth keeping. The first attempt at measuring any of this was worthless: the machine sat at a load average near 200 while other work compiled, and identical code timed 99ms, 476ms and 235ms across three consecutive runs. Both PRs were opened with the speedup explicitly unclaimed, and the numbers added afterwards from an idle machine, best-of-5 across three runs. A single reading taken during the noisy period had put the arc baseline at 903ms; the repeated median is 1145ms, so that one number was off by 27%.
-
A performance pass over the whole package (2026-08-25/27, PRs #49 and #54-#62) — prompted by asking where the code was still doing "the simple version," then run to exhaustion. A realistic 1400x900 chart -- gridlines, a 400-point area fill, three 1200-point series, 600 markers, a donut, a gradient legend, 28 labels and a PNG encode -- went from 430ms to 69ms, every step byte-identical. What the wins actually were is more useful than the number:
Redundant bounds checks.
write_pixel(#57) documented its own contract as "the caller must already know (x, y) is inside" and then bounds-checked three times per pixel anyway;set_pixelis the checked entry point and stays that way. The Backlog'sUnsafePointeritem said not to reach for this "before profiling says theListbounds-checking is actually the bottleneck" -- profiling said so: 1.73ns checked against 0.26ns unchecked per byte. fill_rect went 20x, and everything that writes a pixel moved with it. #59 applied the same reading to the four hot per-byte loops -- deflate's match comparison (5.8x on deflate alone),_adler32,_unfilter_scanlines, anddownsample, which had been callingget_pixel3.8 million times for a check its own loop bounds guaranteed.Canvas.read_pixelwas added there because the pair was asymmetric:write_pixelhad existed as the unchecked counterpart toset_pixelandget_pixelhad none.Loop-invariant work rebuilt per iteration.
draw_polyline_aa(#51, earlier) recomputed each segment's geometry once per sub-sample.fill_path_aa(#60) rediscovered every edge from the point lists on every sub-scanline -- two checked reads, a modulo and two conversions before a y-range test that usually rejects it, four times per pixel row. An edge table built once took text from 28.1ms to 6.4ms for 40 labels: a glyph is many short curve-flattened edges over a small area, so it asks that question constantly and rejects nearly every answer. #61 moved the table toaa_crossing.mojosofill_polygon_aashares it rather than growing a second copy.A conservative bound that was not tight.
draw_line_aa(#55) bucketed candidate columns by a segment's overall x-range, so a full-width diagonal was a candidate in every column of every row -- the entire bounding box, which is what the bucketing exists to avoid. Bucketing by the segment's x-range at the row being swept took 200 such lines from 2924ms to 104ms. The same PR madedraw_line_aadelegate to the polyline core rather than keep its own bounding-box scan; that alone was worth 5%, and the bound was the real fix.Sampling where arithmetic was exact and cheaper. #50 (earlier) counted AA coverage by interval instead of testing every sub-sample. #52 replaced a per-sub-sample
atan2in the wedge fills with cross-product signs, 16x. #56 rewrote the ellipse membership test asx^2*ry^2 + y^2*rx^2 <= (rx*ry)^2, removing four divisions per pixel -- and removing all rounding, since every term is exact in Float64 at any canvas size. #58 deferred_adler32's modulo to once per NMAX block, which is equal by proof rather than by measurement.Almost none of this was a cleverer algorithm. It was work being done repeatedly that needed doing once, and checks re-proving what the surrounding code already guaranteed.
Two things worth keeping for whoever picks this up next.
Guessing lost to measuring, consistently. Text was predicted to be decode-bound; caching decoded glyph outlines (#53) bought 12%, and the actual cost was the edge scan. Deflate was predicted to be bound by CRC-32 (it is 0.2% -- it runs over the compressed output, not the raw image), then by an O(n)
pop(0)in the match finder (removing the cap entirely made deflate slower, 43ms to 90ms), then by theDictof chains. That last one was built out fully as zlib'shead/prevflat arrays, measured, and thrown away: no gain on large inputs and a 12x regression on small ones, since it allocates 768KB per call regardless of input size. The real answer was the byte-comparison loop, which the_MAX_CHAINscaling table had been pointing at the whole time.A benchmark lied and nearly shipped. The edge table appeared to make
fill_path_aaflat in edge count out to 131,072 edges. That is not physically possible, which is the only reason it got a second look: the benchmark subdivided a rectangle's top edge, which is horizontal, and horizontal edges are excluded from the table at build time. It was measuring exclusion, not scanning. Rebuilt with a zigzag, the honest figure is 1.5x at 4099 edges -- and the first implementation, which read the table through checked indexing, was 19% slower than what it replaced.Two findings were recorded rather than acted on.
_MAX_CHAINat 16 rather than 32 makes deflate 34% faster and the output slightly smaller on chart-like content, but the two are byte-identical on synthetic inputs at mixed timings, so the win is specific to one content shape and the default stands. Andfill_rect_gradientmeasures 65ms in a microbenchmark of 9.6 million gradient pixels but 0.07ms in a real chart -- work there would optimise the benchmark, not the library, and this is where the pass judged itself done.One more case turned up anyway, from checking whether every hot loop had actually received the treatment rather than assuming it had.
write_bmp(#62) had the identical pattern -- three checked reads per pixel, and.append()used to build a buffer whose exact final size (file_size) was already known and already used to reserve capacity. Rewritten to allocate at that size withunsafe_uninit_lengthand write every byte once by index, safe because nothing readsbufbefore every byte has been written.The end-to-end benchmark for this one was actively misleading: 13ms to 41ms across identical runs, both before and after, because writing several megabytes to disk dominates and swamps the encode cost in noise. Isolating the encode step from the file write -- timing buffer construction alone, with no
write_bytescall -- gave the real number: 4.30ms to 1.80ms, 2.4x. Worth carrying forward as a measurement lesson distinct from the coding ones above: an operation that touches disk needs the I/O separated out before its number means anything. -
Nonzero fills band the deposit with the resolve; the blur streams per band (2026-09-06, #265). Two rows of
pixi run benchstood out of proportion to their neighbors and were traced rather than guessed at.fill_path_aaunderFillRule.NONZEROon the 39-curve path took twice the even-odd sweep, after #171 had made the area rasterizer the faster of the two on glyphs. Phase timing found the deposit taking 920us inside the real call against 290us on its own: the resolve's 64 bands had pulled the accumulator's lines into their caches, and the next call's deposit -- once, on the main thread -- paid a coherence miss reclaiming each one. That is what #251 had introduced in 0.19.0 by banding the resolve while the deposit stayed serial: dataviz_mojo's bisect (dataviz #329, a 10,000-ribbon sankey on one dataviz commit across canvas versions) puts large nonzero fills at 1.6s on 0.18.1, 3.8s on 0.19.0, flat through 0.20.1, and 1.07s on 0.21.0 -- a 2.3x regression that lived for four releases because the bench row that would have caught it, "fill_path_aa large 39-curve (nonzero)", is only ever compared within one run, never across versions.canvas.aa_areanow finds each row's span first (_row_spans, a walk over the edges that keeps only the columns), which sizes the work before it is done, decides the banding and tells each band what to zero; each band then deposits into an accumulator of its own and resolves it in the same task, so nothing crosses between caches. Interior runs -- cells nothing was deposited into, where the prefix sum holds -- go toCanvas._fill_regionas one span. Band count is the work over_CELLS_PER_BAND, from the measured ~1.2us to create a task. 1249us to 438us, output byte-identical across every variant tried (opaque, translucent, transparent backdrop, clip rect, clip path, blend mode, stroke, polygon, transformed); a glyph-sized fill pays about half a microsecond for the pre-pass.blurat 800x600 took 14ms at radius 4 and grew with radius, which a running-sum box blur should not. It was three boxes x two directions x four Float64 planes = 24 task dispatches at ~80us each, and 24 passes over a 3.8MB plane on a machine whose parallel copy bandwidth measured 50GB/s. Two rewrites: first four interleaved Float32 lanes per pixel (one vector step per channel set) with the three horizontal sweeps fused per row band, which reached 3.2ms and then stalled on the vertical pass's strided access and, when banded by rows with a halo, on the band buffers spilling the 512KB L2; then the shipped form, one task per row band that streams each row through conversion, three horizontal sweeps in a row buffer, and three ring-buffered vertical stages (_VStage) that emit a row the moment its window is complete, the last writing back to the canvas. The working set is a few hundred kilobytes whatever the canvas size. 14295us to 1362us at r=4, 21928us to 2775us at r=16; output identical at r=4 and r=8, within one level on 20 of 1.9M bytes at r=16.What the pass could not fix, recorded so the next one does not re-derive it: on this machine (3970X,
schedutilgovernor) a pure-L1 vector loop with nothing shared runs 1.7x slower per task at 8 concurrent tasks and 2.8x at 64, with a 2x spread between identical tasks, while a latency-bound scalar spin scales cleanly. Short parallel bursts pay for clock ramp and SMT sharing, so the blur's ~1.4ms is about 5x its arithmetic floor and no restructuring of the library closes that. Measure with the machine quiet: another session's test run was found using 20 cores mid-measurement. -
read_png, the compose blit, draw_shadowed, and the blur's stage handoff (2026-09-06, #266, stacked on #265). The rest of the rows the survey had flagged, plus two that were measured and left.
read_pngat 9.5ms for an 800x600 file was not the Huffman decode: the file was 9KB, so inflate's time was the back-reference copies, appended a byte at a time through a checked read, andresizeon its own grows to the exact length, which reallocates the output every few hundred bytes. The output now grows geometrically once per run and the run is copied in doubling chunks of sixteen-byte vectors -- the firstdistbytes never overlap their source, and each chunk written makes the next twice as long -- since the compiler's aliasing check refuses amemcpywithin one buffer. A nine-bit lookup table (_Huffman.fast) decodes short codes in one step for the files where decode does dominate;align_to_bytehad to learn to hand whole buffered bytes back, since a peek now buffers past the current code. Unfiltering and the scanline-to- canvas step were each 1.5-2.5ms of per-byte appends and are now pointer loops into buffers sized up front. 9.5ms to 2.5ms.draw_canvas's axis-aligned blit blended every translucent source pixel with the general source-over and its divide, whilewrite_pixelhad long taken the division-free form over an opaque destination; the blit now does the same. 1835us to 680us translucent.draw_shadowed's tint pass went throughread_pixel/write_pixelper pixel; through pointers it is 50us instead of 343us on a 300x200 layer, and the shadow end to end is 0.9ms from 4.65ms. The blur's vertical stages each copied the row the previous one emitted; the previous stage now writes straight into the next one's ring slot, about a tenth off the blur.Left alone, with the numbers: the even-odd sweep's band count was the first candidate for the work-based rule the area rasterizer got in #265, but measured through 64 bands it keeps improving on both a curved and a large convex shape -- its per-cell cost is 8x the area resolve's, so a band is worth dispatching at any size the threshold admits. The small-arc row (2000 wedges at r=4) costs 3.4ns per sub-sample of the 4x4 grid; that is the sampling structure, and a coverage-based wedge would be a separate primitive rather than a faster loop.
-
io/jpeg.mojo--read_jpeg/decode_jpeg, a baseline JPEG decoder (2026-09-06). The one input formatread_pngleft open for image and pattern fills, written against ITU T.81 the waydeflate.mojowas written against RFC 1951: marker walk, DQT and DHT tables, the frame header, an optional restart interval, and the scan decoded MCU by MCU -- Huffman-coded DC differences and run/size AC pairs in zigzag order, dequantized, through a separable floating-point inverse DCT into per-component planes. The Huffman decode is Annex F.2.2.3 with a nine-bit lookup ahead of it, the same shape as inflate's. Scope is baseline and extended sequential Huffman (SOF0/SOF1), 8-bit, 1 or 3 components, any sampling factors, with or without restart markers; progressive, lossless, arithmetic, 12-bit and CMYK files raise with the reason.The part that took a second pass was chroma upsampling. Nearest- sample upsampling of a 4:2:0 file differed from libjpeg by up to 78 levels at chroma edges; libjpeg's default is its "fancy" triangle filter (three parts the nearest sample to one part the next, with a specific rounding per output column), and matching it, from jdsample.c's h2v1/h2v2/h1v2 kernels unified into one column-sum form, brought every fixture to within 3 levels of libjpeg's own decode -- the inverse DCT here is float where libjpeg's default is integer, which is where the last level or two comes from. Fixtures under tests/jpeg/ are Pillow-written files beside PNGs of Pillow's decode: 4:4:4, 4:2:0, 4:2:2 at an odd size, grayscale, restart intervals, and a progressive file for the error path.
A 1600x1200 4:2:0 file decodes in 66ms, about 60% entropy decode and 30% inverse DCT; an integer AAN transform and a vectorized color conversion are the obvious next steps if a caller needs more.
-
ColorSpace: linear-light blending and gradient interpolation (2026-09-06). Until now every blend and every gradient step mixed the stored sRGB bytes directly, which is what browsers and Cairo do and what most callers expect, and which makes a 50% black-over-white come out as byte 128 rather than the 188 that reflects half the light, and a red-to-green ramp sag through a dark brown middle.ColorSpace.LINEARis the opt-in alternative, in two places that are set independently because they are asked for independently:Canvas.set_color_spacefor source-over blends (anti-aliased edges, translucent fills, composites -- everything that reaches the pixels throughwrite_pixel), andGradientStops.set_color_space, with forwarders on the three gradients, for the ramp.save/restorecarry the canvas's space like the blend mode. Porter-Duff operators and the blend modes stay in sRGB either way, since those are defined on the encoded values and a caller choosing MULTIPLY wants the multiply they know.The conversion is the IEC 61966-2-1 transfer function as two lookup tables in a
_Transferstruct -- 256 entries to linear and 4096 back, twelve bits being what it takes for every byte to round-trip, including the steep first few near black -- built on demand, since a canvas that never leaves sRGB should not pay for the 4096powcalls. Mojo has no module-level variables and acomptimeList cannot be indexed at runtime without materializing it, so the tables live on theCanvasand theGradientStopsthat use them. The default path gained one predictable branch per translucent pixel; the bench rows that exercise it did not move.SvgCanvascarries the same two settings as markup: under LINEAR each element getscolor-interpolation="linearRGB", and a linear-light ramp gets it on its<linearGradient>or<radialGradient>. That is the correct SVG 1.1 property for both alpha compositing and gradient interpolation; viewer support is uneven (Firefox honors it, Chrome largely does not), which the docstring says. Both settings are on theDrawTargettrait. -
vector/pdf.mojo--PdfCanvas, a PDF document as the thirdDrawTarget(2026-09-06, closes #241). Built to that issue's scoping and then past it: the path operators map one to one ontoPath(m,l,c,h; a quadratic raised to its cubic, anarc_toas quarter-turn cubics), the two fill rules ontof/f*, strokes ontow/J/j/M/d/S, and the transform ontocminside aq/Qpair around each element -- the same "per element, no CTM state" arrangementSvgCanvasuses with itstransformattribute, so the two vector backends share a shape. The content stream opens with1 0 0 -1 0 h cm, which flips PDF's y-up page onto the canvas's y-down pixels; every coordinate then goes through unchanged and one unit is one point. Translucency and blend modes areExtGStateresources (/ca,/CA,/BM), deduplicated by body; clips arere W n/W ninside aqthatpop_clipcloses, with the transform baked into the clip's points since a clip sits outside any element's owncm; linear and radial gradients are axial and radial shadings with a Type 2 function between two stops or a Type 3 stitching over more, clipped to the shape and painted withsh; aCanvasgoes in throughdraw_imageas an image XObject with a soft mask for its alpha;new_pagestarts another page, of the same size or its own. Annotated groups are marked content (/Span << /Alt (title) >> BDC ... EMC). Every stream is Flate-compressed through this package'sdeflatewith the zlib framingread_pngalready knew about.Text is real text, which is what turned this from a figure format into a document one.
draw_textruns the raster_layout_block-- the same shaping, kerning, alignment and rotation -- and writes each run of glyphs from one font as aTJwith the kerning as adjustments, positioned by a text matrix that folds the rotation and the page flip together (c s s -c x y Tm). The font is embedded byvector/pdf_font.mojoas a compositeType0font underIdentity-H, so a glyph index is its own two-byte code and the viewer draws exactly the glyphs the shaper chose; aToUnicodemap makes them selectable and copyable, with a ligature mapped to every character it absorbed, which took teaching_ShapedGlyphto carry its cluster's characters (chars) rather than the one codepoint it had. A TrueType font is subset --head,hhea,maxp,hmtx, the hinting tables, and aglyf/locarebuilt with unused glyphs empty and a used composite's components kept, indices unchanged soCIDToGIDMapstays/Identity; a CFF OpenType font is embedded whole asFontFile3. A glyph a fallback font supplied (CJK under a Latin family) embeds that font alongside. Checked with poppler:pdffontslists every font as embedded, subset and with Unicode;pdftotextreturns the strings drawn,fiand 日本語 included;pdftoppmbesidewrite_pngof the same text puts every glyph where the raster put it.Not expressible, and said so: Porter-Duff operators beyond source-over, alpha on gradient stops, conic gradients,
ColorSpace.LINEAR, and color bitmap glyphs (emoji), which have no outline in the embedded program.The tests read the operators back from
content()and the file fromto_bytes(compress=False): what each call becomes, the resources it registers, the font objects and their map, pages and images, the cross-reference table's offsets each landing on their object, and the compressed stream inflating to the readable one. A scene of every primitive rendered throughpdftoppmbesidewrite_pngdiffers on 1.2% of pixels, all anti-aliased edges.examples/vector.mojowrites the PDF beside its PNG and SVG. -
Second performance pass, across the package (2026-09-06). Six places, each measured first; the numbers in the PR are the bench's and the ones here are the phase timings that chose the change.
write_pngcompressed every image twice, unfiltered and Sub- filtered, keeping the smaller -- and with the #172 matcher the Sub-filtered rows only win on noise now, so on a chart the second deflate was pure cost. libpng's residual heuristic was tried and picked Sub on every chart; the choice is now made on a sample, every eighth row deflated both ways, which agreed with the full comparison on the scene, the gradient and the noise image. 21ms to 12ms on the scene.Gradient rect fills ran per pixel on one core.
_fill_rect_sourceis banded like the sweep, andGradientStops.color_atreads its stops through a pointer instead of copying a stop per probe (the arithmetic is unchanged, so the golden images are). 600x400: 3.66ms to 0.32ms.A translucent solid fill over an opaque canvas (
Canvas.fill,fill_rectwith alpha) took the hoisted scalar blend per pixel; four opaque destination pixels now go through one sixteen-lane vector of the same multiply-and-shift_div255, which a first attempt with the add-and-shift form got wrong by one level and the golden test caught. 767us to 281us on 800x600.The JPEG inverse DCT is now two passes of eight-lane vectors -- a row's eight outputs are the sum over its frequencies of the coefficient times that frequency's basis vector, zero coefficients skipped -- with the clamp and level shift vectorized too. 1600x1200 4:2:0: 66ms to 48ms.
A first
draw_texton a freshFontCachecost 20-40ms, and it was not the parse (a face parses in 0.4ms, its GSUB in 0.03ms) but font discovery: a thousand entries under the font directories, each astator arealpathand every font file opened and read at three offsets. Both halves are now banded across cores, the walk level by level. Measured in a warm process, 20-40ms to 7-15ms; the syscalls do not scale the way arithmetic does. dataviz_mojo measured it the stricter way, four fresh caches in one process: the first is unchanged at about 20ms, since it also pays the worker-pool startup and the cold file reads, and the later ones go 19ms to 12ms. The release notes say the latter; the headline "2 to 3x" in the PR was the warm-process number.The gradient path fill's paint pass intersects each row with the canvas and clip once and writes through
write_pixel, as the sweep does. It did not move the bench row, which is the sampled even-odd sweep producing the mask rather than the paint.Looked at and left: the dashed polyline already dashes geometrically into simple runs for the area rasterizer, so its cost over solid is the extra runs and caps; and the even-odd sweep on a large curved path stays at 8x the area resolve's per-cell cost, since the rule needs the discrete winding per sample.
-
Docs for coding agents:
AGENTS.md,llms.txt, a skill (2026-09-06). Three files in the three places the tools look.AGENTS.mdis the operational digest for working on the code -- what to run, where things are, the rules not in the code, the Mojo 1.0 traps that each cost a compile cycle until known, the concurrency hazards, how to measure here, how to look at output -- written from a day of an agent hitting each of them;CLAUDE.mdpoints at it, since the cross-tool convention is the one worth maintaining.llms.txtat the site root is the whole public API on one page for a project building against the library: a hand-written introduction with recipes that run, then a digest generated from the sources byscripts/gen_llms_txt.mojo-- from the source text rather thanmojo doc's JSON because the standard library has no JSON parser and the tooling stays in Mojo.skills/canvas-mojo/is the same knowledge as a skill in the openSKILL.mdformat, which Claude Code and the other agent tools install. An MCP server was considered and deferred: it is the one piece that reaches a chat client with no shell, and it means running Mojo somewhere, so it waits for a request. -
A bench reference and
bench-check(2026-09-07, #273). The survey only ever compared rows with each other, so a row that doubled in a release looked like the number -- which is how #251's 2.3x on large nonzero fills lived from 0.19.0 to 0.20.1 (see the #265 entry).benchmarks/reference.txtnow records every row's time on a named machine, the faster of two runs, andpixi run bench-checkruns the survey twice, keeps each row's faster time, and fails on any row more than 1.5x slower. Best-of-two is stable to 5% on every row back to back, which is why 1.5x is a safe line: the swing this suite shows between single runs on parallel rows is 20%, and the regression to catch was 2.3x. The reference is keyed to the machine (CPU model and thread count); elsewhere the check reports that and passes, since the numbers do not carry across hardware and a CI runner's would need its own reference. It is a release step now. -
The font database persists to disk (2026-09-07, #274, closes #272). dataviz_mojo measured a chart's fixed cost and found the largest single item was not drawing but enumerating fonts: about 21 ms on the first text call of every process, for a table that is the same on every run until someone installs or removes a font.
FontDatabase()now reads that table from$XDG_CACHE_HOME/canvas_mojo/fonts.txtwhen one is valid and writes one after a scan. A first chart with one text call goes from 17.8 ms to 4.6 ms; the database itself from 17.4 ms to 1.8 ms.The design point worth keeping is where the validation goes. The first cut walked the directories, used what the walk visited to build the key, then consulted the cache -- correct, and it saved 2.4 ms of 20, because the walk is most of what a scan costs. The file has to carry its own directory list so the check is one
statper directory (31 here, about 0.1 ms) and a hit skips the walk entirely. Invalidation rides on the same list: installing or removing a font changes the mtime of the directory holding it, and creating or deleting a subdirectory changes its parent's, so either rebuilds rather than silently missing the new font -- matplotlib's well-known stale-fontlistfailure, which keying on a version alone does not prevent._CACHE_FORMATguards the other direction, a change to what_parse_facereads, and stands in for the package version, which an installed package cannot read at runtime since there is nopixi.tomlbeside a.mojopkg.Two things the standard library does not have shaped the rest. There is no
rename, so the write cannot be made atomic the usual way: the record count goes in the header and an end marker on the last line, a file cut short is discarded, and two processes racing simply both scan with the last write winning. Andgetenvcannot tell an unset variable from an empty one, while/proc/self/environ-- the usual way to recover that -- does not exist on macOS and does not reflect asetenvmade by the running process, soCANVAS_MOJO_FONT_CACHE=offdisables the cache rather than an empty value.The cache is an accelerator and never a source of truth. Missing, stale, truncated, corrupt, unreadable and unwritable each fall back to the scan, silently, and the tests assert that by comparing every one of those against the database a scan produces.
macOS CI caught the bug the Linux tests could not. The unescape half of the record format walked the field's bytes and rebuilt each one through
chr, which turns a UTF-8 path into one character per byte;/System/Library/Fontsholds Japanese filenames (ヒラギノ丸ゴ ProN W4.ttc), so a cached database there pointed at paths that did not exist, and the scan test failed on the first one it tried to open. Walking codepoints fixes it -- every escape the format introduces is ASCII, so only the text between them was ever at risk. The lesson is the test, not the fix: the round trip now runs over Japanese, Arabic and Latin-1 as well as the tab, newline and backslash cases, and was checked load-bearing by restoring the byte walk and watching it fail. No font installed on the Linux machine had a non-ASCII filename, which is the whole reason it took a second platform to see. -
Every primitive on exact-area coverage (2026-09-07, #276/#278, closes #275; released in v0.23.0). The circle, ellipse, wedge and ring-sector fills and the circle and ellipse outlines each carried their own 4x4 sub-sample grid -- 17 coverage levels, where a path under NONZERO, text and simple strokes had used
canvas.aa_area's accumulation at 256 since #171. They build their outline as points now and go through the same rasterizer. On a marker-sized disk the worst pixel goes from 17 levels of error to 3, and supersampling the shape buys 3 against 1 -- nothing visible.This came out of a question about whether dataviz_mojo still needed its 3x supersample, which is 9x the pixels. Measuring it mark by mark: for anything drawn as a path it bought nothing, because dataviz had already moved closed marks to NONZERO; for bars it was keeping a deliberate pixel snap crisp, which works at 1x too; and for the circle and arc primitives it was compensating for exactly this gap. A 2000-marker scatter costs 11.8ms supersampled 3x and 5.2ms at 1x on the new rasterizer, with better edges.
Speed moves both ways and the reason is worth keeping. Arcs and rings got faster -- their old sampler tested every pixel of the bounding box against the angle span, so a large pie wedge went 225 to 98us. Circles and ellipses got slower at 1x, 2000 markers 1758 to 4838us, because their sampler solved interior spans in closed form and allocated nothing where the accumulation allocates an edge table and an accumulator per call. About ten allocations per marker against none: allocation, not arithmetic, and pooling the edge table would recover it.
The stroked ellipse changed shape, deliberately. It drew the band between the concentric ellipses
(rx-w/2, ry-w/2)and(rx+w/2, ry+w/2), which its own docstring said iswidthwide only at the four axis extremes.SvgCanvas.draw_ellipse_aawas already emitting<ellipse stroke-width>, an SVG constant-width stroke, so the sameDrawTargetcall drew one shape through the raster backend and another through the vector one -- the single thing the trait exists to prevent. It is a real stroke now.Five tests had been asserting the sampler's own artifacts and now assert areas derived independently, by integration for the fills and by measuring each sample's distance to the curve for the ellipse stroke. The clearest: a pixel whose far corner sits outside a disk was asserted fully covered, true only because all sixteen of its sub-samples were inside. 251, not 255.
Two things this pass got wrong before getting right, both worth the record. The first measurement of "is supersampling still needed" compared 1x against a 16x reference and found large errors everywhere; the reference was built by scaling coordinates rather than through the canvas transform, so it was half a pixel off, and the
downsampledocstring already documents the(f-1)/2translate that fixes it. The second, after that: 46 pixels still differed badly, all at a wedge's tip -- because the test built the wedge withmove_to(center)thenarc_to, andarc_to's contract is that the current point is already the arc's start, so the first edge cut the corner. Both were the test's fault, not the library's, and both looked exactly like a library defect until located. -
Closed-form pixel coverage for small circles and ellipses (2026-09-07, #282, released in v0.23.1). v0.23.0 gave every circle exact-area coverage by flattening it to a polygon and handing that to the general rasterizer, which allocates an edge table and an accumulator per call. dataviz_mojo measured what that costs at the radii charts actually use: 4.8x the old sampler at r=1.5, 2.3x at r=4, and a 100,000-point scatter 65% slower (#281). Their question was whether the per-call allocation was recoverable, and they offered the obvious shape for it -- a caller-supplied scratch threaded through like
FontCache.Profiling said no. Of 1.65us per call at r=1.5, the point list is 0.29, the edge table 0.38 and the rasterizer 0.98: removing every allocation still leaves more than the whole 0.35us call it replaced. Pooling would have bought half the overhead and cost an API change.
What works is not needing the machinery. The area a disk shares with a pixel has a closed form -- integrate
sqrt(r^2 - x^2), capped at the pixel's height, across its width, and the antiderivative is elementary -- and an axis-aligned ellipse is the same integral after scaling x by 1/rx and y by 1/ry, which takes it to the unit disk and the pixel to another rectangle. Checked against numeric integration over 300 random pixel/radius pairs before any of it was written in Mojo: worst error 0.0003 px^2, where one level of 255 is 0.0039.Both routes now exist and agree to within a level, so the threshold is purely about cost: the closed form allocates nothing and pays a
sqrt/asinpair per pixel edge, the polygon allocates once and then only does arithmetic. Timed against each other the closed form is 0.91x the polygon at r=6, 1.14x at r=8, 1.94x at r=18, so seven is the crossover. Markers went 4960 to 3162us, small ellipses 5840 to 3978, and accuracy improved again -- a disk's worst pixel from 2-4 levels to 1 -- because nothing is flattened.Two attempts along the way that measured worse, both for the same reason. Carrying a running cumulative along each row so neighbours share a boundary evaluation should have halved the transcendentals; it cost 60% instead, because reaching every pixel in the row meant giving up the cheap corner tests that skip most of them. The first ellipse version made the same mistake by omitting those tests and came out 11% slower than the polygon it replaced. The lesson is that on shapes this small the skipping is worth more than the arithmetic it skips.
What it does not fix, which the release notes say plainly: exact coverage is more work than sixteen distance tests and that part is not recoverable, and radii above the threshold are unchanged, so a caller supersampling 3x sees nothing. The gain is at 1x, which is where the exact coverage was supposed to let them get to.
-
A stroke wider than twice its curve's radius punched a hole (found and fixed 2026-09-07, #279/#280, released in v0.23.0).
stroke_path_aaoffsets a closed path both ways and lets nonzero empty the gap between the two rings. Past the radius of curvature the inner offset passes through the center and comes out the other side: offset a radius-8 circle inward by 15 and the result is a radius-7 circle, wound the same way and simple at every vertex, which then subtracts a disk lying entirely within half a width of the curve. A circle stroked wider than its diameter came out hollow.Nothing local is wrong, so the check asks what the ring encloses instead: inside a real hole every point is farther than half the width from the curve, so a ring whose centroid is nearer is spurious, and that stroke takes the union of segment quads and joint disks. The alternative test -- does the offset self-intersect anywhere -- would have been wrong: an ellipse 100x30 stroked 24 wide inverts only at the major-axis ends while its hole in the middle is real, and that case is now a test.
Found while converting the stroked circle for #275, and filed first as a seam bug: I had measured a stroked circle and a stroked ellipse in one canvas and read a run of bad pixels at x=45 as the circle's rightmost point, where it was the ellipse's leftmost. That issue was retracted with the evidence -- a stroked closed path matches a 16x reference to within 1 level of 255, so there is no seam problem -- and the real defect filed in its place. The regression test needed the same care: built with
arc_toit passed without the fix, because anarc_tocircle samples densely enough that a vertex trips the simplicity check by itself; it has to be built withellipseto reach the smooth ring where nothing looks wrong.
Worth keeping visible rather than just folded silently into the diff:
-
SvgCanvas's rawString(Float64)coordinates could differ by 1 ULP depending on compilation context, breaking exact-string test assertions that were otherwise completely correct. Caught live, not hypothesized: a hand-derivedfill_ring_sector_aatest, computed and cross-checked viapython3exactly like every other float-coordinate test in this file, still failed. Debugging (a temporaryprint()of the real runtime output, since two separate isolated single-file probes of the identicalcx + radius * cos(angle)expression both matchedpython3exactly) narrowed it to one specific value differing in its last bit --33.6589094784097in isolation vs.33.658909478409704compiled as part of the largertest_svg.mojofile, confirmed via.hex()in Python to be genuinely differentfloat64bit patterns, not a display artifact. Root cause not chased further (likely FMA/instruction-scheduling differences depending on surrounding code, a known class of cross-context floating-point non-reproducibility, not specific to this codebase) -- the fix instead removes the precondition the bug needs to matter at all:_format_svg_floatrounds every coordinate/ width/sizeSvgCanvasemits to a fixed 3 decimal places (millipixel precision, far finer than any real display renders, so nothing visible is lost) before turning it into a string, the same category of fixed-precision-rounding fix used elsewhere for a different symptom of the same underlying "don't trustString(Float64)'s own drift" lesson. Also incidentally cleans up wedge output for any caller drawing angle-derived arcs through this package: a value that used to print as219.99999999999997(pi's own finite representation leaking through two wedges meant to meet at exactly the same point) now rounds to the correct, clean220.000both wedges actually share. -
AA alpha ignored the caller's color.a. All three original AA functions computed final pixel alpha as
coverage_fraction * 255, discarding the input color's own alpha entirely -- so a fully- covered pixel withalpha=128rendered fully opaque. Invisible in every test and example up to that point because they all happened to pass opaque colors, wherecoverage * 255 == coverage * color.aby coincidence. Found while probingdraw_polyline_aawith a translucent color for the first time. Fixed in all four AA functions; added regression tests using translucent input colors specifically, since that was the exact gap that hid it. -
AA circle sampling was off by half a pixel.
fill_circle_aa/draw_circle_aaoriginally sampled pixel(px,py)as a unit square with(px,py)at its corner, not centered at(px,py)like the hard-edged algorithms -- sodraw_circle_aa(c, cx, cy, r, ...)drew a circle shifted fromdraw_circle(c, cx, cy, r, ...)given identical arguments. Caught by checking whethersupersample=1degenerates to exactly the hard-edged decision (it should, and didn't). -
cairo_mojo'sunsafe_data_ptr()reads back garbage at buffer boundaries. Confirmed via probe on a freshly-created, never-drawnImageSurface(should read back as all-zero, transparent black -- Cairo's own documented guarantee): the first 16 bytes of the pixel buffer come back as non-deterministic garbage every time (different random-looking bytes each run, at the exact same offsets), reproduced across surface sizes from 9x34 up to 200x200; a small surface also showed one bad pixel near the buffer's tail end that a larger one didn't.write_to_png-- which reads the same buffer natively in Cairo's own C code, not through Mojo's pointer marshaling -- round- trips clean, so the real pixel data is correct; the bug is specifically in reading it back this way, either in the vendored binding or in Mojo's ownUnsafePointerindexing at a freshly- returned pointer's boundary. Root cause not confirmed (would need deeper C-level or compiler-level debugging than was in scope here). Fix is deliberately narrow and empirical rather than a guess at the real cause:draw_textnever reads the first or last row of the scratch surface it renders into (_BORDER_ROWS_TO_DISTRUSTintext.mojo), with the ink margin widened to guarantee real glyph content never lands there anyway. Proven load-bearing by reverting it and re-running the tests: the alpha=0 no-op test failed every time, the translucent-blend-bounds test failed about two-thirds of the time (matching the garbage's non-determinism) -- both green again once restored. -
draw_circledouble-blended at 8 points per circle. Aty==0(loop start) andx==y(the loop's diagonal crossing), several of the 8 symmetricset_pixelexpressions collapse onto the same pixel -- e.g.(cx+y,cy+x)and(cx-y,cy+x)both become(cx,cy+x)wheny==0. Plotting all 8 unconditionally blended a translucent color twice (confirmed via probe: value 150, exactly what you get blending the same color over an already-blended 100) at 4 axis points and 4 diagonal points on every circle. Present since the very firstdraw_circleimplementation; found only while designingdraw_ellipse's symmetry and tracing through the same hazard deliberately this time. -
**Mojo toolchain upgrade (1.0.0b2 -> 1.0.0 stable) broke
svg.mojo's_hex_byte, not a bug in this codebase's own logic but a real, reproducible break worth recording the same way: the stable release removed plain positionalStringindexing (s[i]) entirely --_HEX_DIGITS[v // 16](a fixed, pure-ASCII hex-digit literal) failed to compile with a real, specific compiler error naming the fix (Mojo's own UTF-8-safety concern: a byte position, a codepoint position, and a grapheme-cluster position can all mean different things for the same index into a non-ASCII string), not a vague deprecation. Fixed with[byte=...], the raw-UTF-8-byte accessor the error itself suggested -- correct here specifically because_HEX_ DIGITSis guaranteed pure ASCII, where "byte" and "codepoint" are the same thing; a string with real multi-byte characters would need[codepoint=...]/[grapheme=...]instead depending on intent.pixi run test/pixi run exampleboth confirmed clean afterward (this was the only break the upgrade caused across the whole workspace, including the sibling charting package and thecairo_mojoFFI bindings -- the latter's own long-standingMutExternalOrigin-deprecation warnings, mentioned elsewhere in this file, are still just warnings in 1.0.0 stable, not yet a break). -
fill_ring_sector_aacut a rectangular notch out of wedges that don't cross a cardinal angle (issue #33, fixed 2026-08-20)._arc_boundswas documented with a shortcut that is false in general, not just at an edge case: "the inner arc's own bounds are always a subset of the outer arc's." Whenever[start_angle, end_angle]doesn't reach a cardinal angle (0, pi/2, pi, 3*pi/2), the inner arc's extreme point -- at whichever endpoint angle is nearest a cardinal one -- sits closer to the center than anything the outer arc reaches over that same span, i.e. outside the outer arc's own bounding box. Direct counterexample: cx=270, cy=185, start_angle=-pi/2, end_angle=-pi/6, outer_radius=148.5, inner_radius=74.25 -- the outer arc's y-range over that span is [36.5, 110.75], but the inner endpoint atend_anglealone already sits at y=147.875. Scanning only the outer bounding box never visited those pixels, leaving a rectangular notch instead of a clean angular gap; confirmed by rendering, not just argued. Fixed by calling_arc_boundstwice (once per radius, both withinclude_center=False) and unioning the results. -
SvgCanvas.draw_textemitted nofont-familyat all (fixed 2026-08-21). Every<text>element left the attribute off entirely, so an SVG viewer fell back to its own undefined user-agent default (some default to a serif face) -- which is why SVG output could look visually inconsistent with this package's rasterdraw_text, which always resolves a real font via fontconfig. Fixed by adding afamilyparameter defaulting to"sans-serif"(a generic CSS keyword every viewer supports, not a specific face), so a call that passes nothing still emits a real font-family. Deliberately a different value shape than rasterdraw_text'sfamily, despite the shared name and position: raster's is a fontconfig alias resolved to one concrete font file, this one is a literal CSSfont-familyvalue interpreted by whatever renders the SVG. Aweightparameter followed on the same footing. -
SvgCanvas.fill_rect_gradientcollapsed descending-offset gradients to one flat color (reported by dataviz_mojo, fixed 2026-08-23).LinearGradient.add_stop's own docstring guarantees stops don't need to be added in offset order -- the rastercolor_atscans for the bracketing pair regardless -- but the SVG spec clamps each<stop>'s offset to be no less than the previous sibling's. A gradient built with descending offsets (dataviz_mojo's continuous color legend flips each stop to1.0 - offset) therefore emitted every stop after the first at the first stop's offset, rendering as one flat color in every real SVG viewer while the identical raster fill still rendered correctly. Fixed by sorting stops by offset before emitting them; the regression test uses their exact repro (three stops added 1.0, 0.5, 0.0, each a distinct color).