Releases: randyzwitch/canvas_mojo
Release list
v0.41.0
Eleven merged PRs since v0.40.0 plus the release fix. No DrawTarget change. PDF output gains text along paths and translucent gradient stops; several raster and PNG paths use less time or memory.
PDF output
PdfCanvaswrites text on a path as embedded, selectable text with a ToUnicode mapping (#496). Poppler extracts the original text from the generated PDF.- Gradient stop alpha is preserved in PDF using a grayscale shading soft mask (#497). A white-to-transparent-red example rasterizes with a pink midpoint, rather than an opaque red one.
Performance
Measured on a Threadripper 3970X. The PNG encoder and blur figures use Mojo 1.1.0; the PNG decoder and circle batch figures use the pinned Mojo 1.2 development compiler. Each comparison comes from paired measurements within its PR, so these are workload-specific examples rather than a cross-PR ranking.
| Operation | Before | After | Change |
|---|---|---|---|
| 2400×1800 flat-chart PNG encode | 40.86 ms | 33.38 ms | 1.22× faster; peak RSS 85.9 → 65.3 MiB (#493) |
| 2400×1800 flat-chart PNG decode | 15.49 ms | 9.00 ms | 1.72× faster (#494) |
| 2400×1800 translucent RGBA PNG decode | 25.51 ms | 14.07 ms | 1.82× faster (#494) |
| 500 integer-centered circles, radius 3.5 | 680.6 µs | 183.6 µs | 3.77× faster (#495) |
| 800×600 blur, radius 4 | 1417 µs | 1236 µs | 1.15× faster; peak RSS 28.8 → 24.3 MiB (#492) |
| 2400×1800 blur, radius 16 | 13.18 ms | 13.30 ms | Time essentially unchanged; peak RSS 192.8 → 126.9 MiB (#492) |
The direct PNG decode path applies to large, strongly compressible 8-bit RGB/RGBA images with None or Sub row filters. The circle stamp path applies to equal-color, integer-centered batches of at least 64 disks with radius at most 7 pixels. Other cases keep their existing paths.
Rendered output
PDFs now preserve selectable text along paths and gradient alpha, so those PDFs render differently by design. Banded blur can shift a few rounded output bytes by one level at band boundaries; in the patterned test, 18 of 307,200 bytes moved. PNG output bytes and decoded pixel digests were unchanged in the measured scenes; circle batch pixels match individual calls byte for byte. All 16 benchmark verification scenes match their recorded digests.
Also
- TrueType Collection discovery loads the selected face index, fixing CJK fallback from
.ttcfonts (#488). - Linux aarch64 is supported and covered by CI alongside Linux x86-64 and macOS Apple Silicon (#490).
- Committed benchmark references now record the Mojo compiler and reject recording from a nightly build (#489).
- The persistent worker-pool fallback for a possible
_asyncrtremoval is documented (#491), and resize scratch documentation matches its implementation (#498). - PNG decoding rejects truncated plain and Adam7 streams before allocating a full image buffer, following findings from this release's fuzz run.
Release gates: Mojo 1.1.0 full test suite, benchmark survey, 16 scene digests, and checked-read PNG fuzz campaign passed. The benchmark survey found no row more than 1.5× slower than the committed reference.
v0.40.0
One merge since v0.39.2, and no code in it. The Mojo upper bound is gone: the constraint is now mojo = ">=1.1.0".
mojo >=1.1.0, no ceiling
In all five places that declare it — the release feature, [package.host-dependencies], [package.run-dependencies], tests/consumer/, and the Getting Started configuration a reader copies. The development environment's nightly spec drops its ceiling the same way (>=1.2.0.dev), since it would go stale one series later for the same reason. The floor stays at 1.1.0, where v0.39.0 put it.
A ceiling has to be raised for every Mojo series, in five places, and each raise ships a release whose only content is the raise. v0.39.2, four days after v0.39.0 moved the floor, was exactly that. Nothing downstream benefited from the wait.
What you give up, stated plainly. An untested future Mojo is now admitted rather than refused. A breaking compiler will reach you as a build error in your own project rather than as an unsatisfiable solve with a clear message. If you would rather have the refusal, pin the ceiling yourself: mojo = ">=1.1.0,<1.3" in your own manifest does exactly what this release stopped doing for you.
The reason that trade is worth making here: the refusal only ever arrived if someone had raised the ceiling for the previous series in time. A stale ceiling refuses a working compiler, which is the same failure pointed the other way, and it is the one that actually happened. Checking each new Mojo as it ships is now tracked as #484, and if one breaks, the fix is a floor-raising release rather than a ceiling nobody remembered to raise.
Installing this does not put you on a nightly
Development in this repository now runs on a Mojo prerelease by default (#485), and that is the development environment only. It does not propagate to anything you install.
- What the package declares is
mojo = ">=1.1.0"in[package.run-dependencies], with no ceiling and no channel. - A conda package cannot add a channel to your workspace, so nothing here can reach
max-nightlyon your behalf. Install it with the usual channels and you get the release compiler. - Checked rather than asserted:
tests/consumer/, a separate workspace that depends on this package the way README tells a reader to, resolvesmojo-compiler ==1.1.0against v0.40.0. dataviz_mojo confirmed the same from its side, resolvingMojo 1.1.0 (8189361e)with v0.40.0 pinned.
"canvas_mojo develops on nightly" and "canvas_mojo ships against nightly" are different sentences, and only the first is true.
No rendered-output change
No canvas/ file differs from v0.39.2. All 57 example outputs — PNG, BMP, SVG and PDF — are byte for byte what v0.39.2 produced on the same machine. v0.39.1..v0.40.0 is empty over canvas/ as well, so a consumer on v0.39.1 takes one bump rather than two.
On Mojo 1.2
Still not on the release channel; 1.2.0.dev builds are on max-nightly. canvas is clean on 1.2.0.dev2026092105: full suite, every example, the packaged-consumer build, and byte-identical output against 1.1.0. dataviz_mojo independently ran its 42 modules (1657 tests) and its per-figure fingerprint on the same nightly with the same result. That is two codebases on one nightly, not a support claim for whatever 1.2.0 ships as — #484 re-checks it against the release build.
Also
- A bare
pixi run testin this repository compiles with a prerelease;pixi run -e release testis what a pull request must pass, and every CI workflow names it. AGENTS.md documents how to read a nightly-only failure, and the rule that a committed reference — the goldens,benchmarks/reference.txt,digests.txt— is never recorded from the nightly environment. - With no ceiling on the release spec, the nightly channel's placement on the
nightlyfeature is the only thing keeping a prerelease out of the release environment: every nightly satisfies>=1.1.0. Moving it to[workspace]would put a nightly in CI. - #486 tracks the gap behind that rule: committed references record the machine but not the compiler, so a nightly-recorded baseline is indistinguishable from a release-recorded one.
Gates: full suite clean on both environments (1133 assertions, 0 failed each), all 57 example outputs byte-identical to v0.39.2, packaged-consumer smoke builds and draws, fmt a no-op, and all six CI jobs green on the release commit. The benchmark survey was not run: no code changed.
v0.39.2
One merge since v0.39.1, and no code in it. Packaging only: the Mojo constraint now accepts the 1.2 series.
mojo >=1.1.0,<1.3
The upper bound moves from <1.2 to <1.3 in all four places that declare it — the workspace dependency, [package.host-dependencies], [package.run-dependencies], and the consumer workspace under tests/consumer/. The floor stays at 1.1.0, which v0.39.0 raised.
Corrected after publication. These notes first said Mojo 1.2 "is not published" and that nothing here had been compiled against it. That is true only of the release channel. https://conda.modular.com/max-nightly carries 1.2.0.dev builds — 435 of them, through 1.2.0.dev2026092105 — and pixi search mojo had only been pointed at max. The error was mine, and it understated what could be checked; dataviz_mojo's session caught it and had already built against a nightly.
Since checked, on 1.2.0.dev2026092105: the full suite passes (1133 assertions, 0 failed, no compiler warnings), every example renders, and the packaged consumer builds from source under the nightly and draws. All 57 example outputs — PNG, BMP, SVG and PDF — are byte-identical to the same examples rendered on 1.1.0 on the same machine. None of the 1.0 → 1.1 renames (@parameter if, std.runtime.asyncrt, the DType.-qualified spelling) has a 1.2 counterpart that this tree trips over; nothing needed a change to compile.
That is one nightly on one machine, not a support claim for whatever 1.2.0 ships as. #484 tracks re-running it against the release build. The library not needing an edit to compile on 1.2 is now the expected case rather than an open question.
No rendered-output change
No canvas/ file differs from v0.39.1. Every scene renders byte for byte as it did.
Also
- The Getting Started configuration a reader copies asked for
mojo = ">=1.0.0,<2", a range this package has not supported since v0.39.0 raised the floor and one that would admit a 2.x compiler the manifests exclude; it now carries the same>=1.1.0,<1.3(#483). - The two prose statements of the requirement said "Mojo 1.x" and now name 1.1 or 1.2. Every Mojo spec in the tree reads one range.
- README's stated version and install tag, last touched at v0.36.0, catch up to this release.
Gates: full suite clean (1133 assertions, 0 failed), the packaged-consumer smoke builds and draws, and the 1.2 nightly run above. The benchmark survey was not run for this release: no code changed, and the machine was not quiet.
v0.39.1
One merge since v0.39.0. A memory-safety fix: set_pixel inside a supersampled region wrote past the end of the output buffer.
Out-of-bounds store in set_pixel while a region records
While begin_supersampled records, in_bounds accepts the enlarged space on purpose, so a recorder holds geometry past the output canvas's own rows. set_pixel relied on that check and then wrote directly, so a pixel whose enlarged-space row was past the output's rows became a store past the end of pixels. get_pixel had the matching read.
The one-point case of an anti-aliased polyline reached it: _stroke_transformed mapped the point to device space and called set_pixel before its batching check. A one-point polar series in a 400x300 chart at factor 3 wrote pixel (661, 406) into a 480,000-byte buffer — 172 KB past its end, at the same index every run. Only what the allocator had mapped after the buffer decided whether the process faulted, which is why it surfaced as an intermittent macOS crash under mojo run rather than a deterministic failure (randyzwitch/dataviz_mojo#732).
If you draw single-point series, markers or dots inside begin_supersampled, this release is the one to take. The write was silent corruption of whatever followed the buffer on the runs that did not crash.
What changes
Canvas.set_pixelwith a batch open draws what is pending first, and inside a region materializes the enlarged buffer through_flush_batch, like every other primitive without a recorded form. The write then lands in a buffer that has the row, in order with the rest of the batch.- The one-point stroke branches in
_draw_polyline_core_aaand_stroke_transformedrecord the pixel as a one-pixel rectangle (_record_pixel), so a dot does not cost a region its banded replay. get_pixelstops followingin_boundspast the buffer while recording; past it reads as off-canvas.
set_pixel's inline body gains one plain-integer compare (_batch_depth != 0); the flush is out of line and returns early when nothing is pending.
No rendered-output change
Every scene that did not reach the out-of-bounds path renders byte for byte as before. A one-point stroke inside a region now matches the two-step recipe byte for byte, where before it wrote outside the canvas entirely.
Four new tests cover it, all failing on v0.39.0: a one-point stroke inside a region against the two-step recipe (and its banded replay preserved), set_pixel inside a region against the recipe, get_pixel past the buffer while recording reading black rather than a garbage byte, and set_pixel inside a batch landing in order between two recorded disks.
With dataviz_mojo built against this branch, 72 of 72 runs of its polar test module at 8 concurrent copies were clean, against 7 crashes in 96 before.
Also
- Found with lldb stopping the
mojo runprocess at the fault: the faulting instruction iswrite_pixel's opaque store, the canvas behindselfis the small output buffer still recording, and the address equalspixels + indexexactly (#480).
v0.39.0
This package now requires Mojo 1.1: mojo = ">=1.1.0,<1.2", the versions it was tested on, in the workspace, host and run dependencies. That is breaking for any consumer on Mojo 1.0, whose own constraint and lock must move with it (#472, PRs #475, #476, #477). Also in this tag: the second half of the parser fuzz campaign, with a checked-read build for the image codecs and four more fixes (#430, PR #474). No breaking change to DrawTarget. No rendered-output change to any existing scene: every verification digest matches v0.38.1.
Merges since v0.38.1: #474, #475, #476, #477.
Breaking: Mojo 1.1 (#472, PRs #475, #476, #477)
Mojo 1.1 made the async task API private: std.runtime.asyncrt became std.runtime._asyncrt, the public std.runtime keeps only parallelism_level and initialize_runtime, and nothing public in std runs work on the thread pool (std.algorithm.map is sequential). Every banded pass here is built on TaskGroup, and one source cannot serve both releases, since 1.0 has no _asyncrt. The owner took the private import over staying on 1.0 or going serial, with the constraint naming what was tested rather than promising a range.
- What a consumer does: set
mojo = ">=1.1.0,<1.2"and re-solve the lock; pin this tag. Nothing in the drawing API changed. - The private import is in one function.
run_bands(bands, work)incanvas/workers.mojois the library's only caller ofTaskGroup; every banded pass is a closure over its context plus that call. A Mojo release that changes or drops the module is a one-function edit, and the serial fallback is the same edit. - #97 is not fixed in 1.1, and is designed out. The reason the module went private is the corruption of by-value aggregates handed to
create_task; the issue's reproducer on 1.1 still corrupts 64 of 19,200 and segfaults under two cores. The task now takes a closure of references and a band index, so nothing aggregate crosses the boundary; that form is clean in 38,400 runs andtests/test_workers.mojokeeps it that way. - Renames:
InlineArrayisArray; astattimespec reads throughas_nanoseconds(); the sysctl name pointer throughas_c_string_span().ptr(); everyDTypeis named contextually (SIMD[.uint8, 16]), which AGENTS.md records as house style. The whole 1.1 changelog was checked against the tree and nothing else it renames, deprecates or removes was present.
The fuzz campaign's second half (#430, PR #474)
canvas/io/view.mojo adds _ReadView and _WriteView: in the production build each is the pointer and nothing else; under -D CANVAS_CHECKED_READS, which pixi run fuzz passes, each also carries how far it may reach and aborts naming the index on a read past a buffer. Every decoder-side pointer site in PNG, JPEG, BMP and inflate goes through one, so a silent out-of-range read became a finding; production cost, measured five ways against main, is within 1%, and three decoder rows now sit in the micro harness. The harness also gained structure-aware mutators for all five formats (CRC-repaired PNG chunks, JPEG header fields and planted markers, font table records, BMP header fields, deflate header bits).
The checked campaign found four things the first one could not see, each fixed with a test:
- The PNG dimension cap could be overflowed by two 32-bit sides; each side is checked before the product, in PNG and BMP.
- A progressive JPEG under the cap needed more than 8 GB with
Intcoefficients; they areInt32, which halves every progressive decode's memory. - Font discovery sized a buffer from a
nametable's declared length, 4 GB in a 5 KB file; reads are clamped to the file. - A damaged Coverage table made the range merge in the GSUB path quadratic; it is a packed-key sort.
About 94 million mutated inputs over three checked rounds, the last 55 million clean on every decoder.
Also
push_clip_pathof a rectangle zeroes its mask with an explicit memset: whether thefill=0loop lowers to one turned out to be an optimizer decision that changed under an unrelated edit, at 10x on that row. Two measurement lessons from that investigation are in AGENTS.md.- The
_ = len(x)lines aftertg.wait()that #263 needed are gone with the direct task calls; a closure's captures live for therun_bandscall. - #472 is closed; #97 remains the only open issue, upstream.
Gates
Full suite: 54 of 54 modules, 1127 tests, 0 failures, no compiler warnings. bench-verify: 16 of 16 scenes match their digests. pixi run example: all examples render. bench-check on an idle machine: median ratio 1.014 across 70 comparable rows, no row flagged.
v0.38.1
The Mojo constraint now says what is true: >=1.0.0,<1.1. Mojo 1.1.0 is on the max channel and this package does not compile under it, and a fresh solve of the old <2 picked it, which is how dataviz_mojo's consumer job found out (#472, PR #473). Also in this tag, merged before it: the mutation fuzzer for the five parsers that read untrusted bytes and the fourteen checks its first campaign added, one of which is a behavior change for callers opening very large images (#430, PR #471). No breaking change to DrawTarget. No rendered-output change to any existing scene: every verification digest matches v0.38.0.
Merges since v0.38.0: #471, #473.
Mojo below 1.1 (#472, PR #473)
mojo = ">=1.0.0,<1.1" in the workspace, host and run dependencies. Under 1.1.0 the asyncrt module is gone from the stdlib and InlineArray is no longer declared, so nine modules fail to compile; the lock already pinned 1.0.0, but a downstream build environment solving fresh took 1.1.0 and the downstream constraint could not prevent it. Pin this tag and the metadata is honest. Supporting 1.1 is the open half of #472.
Behavior change: a decode limit on image dimensions (#430, PR #471)
decode_png, read_png, decode_jpeg, read_jpeg and read_bmp raise from the header when the claimed area exceeds MAX_DECODED_PIXELS, which is 2^28 pixels (a 16384-square passes), before any buffer is sized. The reason is a 1 KB JPEG whose frame header claimed 65535 x 65535 and allocated a 17 GB canvas. The constant is in canvas/io/__init__.mojo. A caller that decodes larger images than that was never going to get them from these decoders in reasonable memory, but it now gets an error with the dimensions and the limit in the message rather than an allocation.
The fuzz harness and what it found (#430, PR #471)
pixi run fuzz <png|jpeg|bmp|deflate|font> <seeds> [minutes] [workers] mutates seed files and hands them to a decoder under a time and memory limit; anything that kills the process rather than raising is collected under .fuzz/findings/ with a sidecar naming the seed, the mutation and the RNG state. Not in CI; CONTRIBUTING's Releasing section says when to run it. Its first minute on each parser found, and this tag fixes:
- JPEG: a scan header's Huffman table selector past 3 indexed the table lists off their ends; an over-subscribed DHT walked its lookup table past the end; DQT, DHT, frame and scan headers were read past their segment.
- Fonts:
chrof a lone UTF-16 surrogate in anamerecord aborted inside discovery, which scans every font on the machine; a CFF DICT real with no terminator spun its exponent loop for minutes; a GPOS feature walk with counts in the tens of thousands was quadratic.
Each is a fixture under tests/fuzz/ or a hand-built table with a rejection test. A round of 14.7 million mutated inputs after the fixes found nothing; for the image codecs that is not yet evidence, since their hot loops read through raw pointers, and the checked-read build that makes those reads visible is the second half of #430, in progress.
Also
- Two facts in AGENTS.md that the fuzzer confirmed: a
Listindex past the end aborts in a default build, andchrof a surrogate cannot be caught.
Gates
Before tagging: full suite 53 of 53 modules reported with 1122 tests, 0 failures and no compiler warnings on Linux, CI green on Linux and macOS for both PRs, bench-verify 16 scenes matching their digests, bench-check not rerun: no drawing code changed since v0.38.0's check (median ratio 1.015, no row flagged), and the constraint change touches only pixi.toml.
v0.38.0
One label from several styled runs: DrawTarget.draw_text_runs draws a list of TextRuns that differ in size, slant or offset, an italic variable or a raised superscript, as one call on every backend, and on SVG as one <text> element of <tspan>s so the label stays one string to select, copy or announce (#467, filed from dataviz_mojo's math labels). Breaking for an out-of-repo DrawTarget, which must add the method. No rendered-output change to any existing scene: every verification digest matches v0.37.1. CI now packages through mojo precompile on pixi 0.78.0 and the logs carry no warnings.
Merges since v0.37.1: #465, #466, #468, #469.
Breaking: DrawTarget gains draw_text_runs
An out-of-repo DrawTarget implementation must add:
def draw_text_runs(mut self, x: Float64, y: Float64, runs: List[TextRun], color: Color, family: String = "Sans", weight: FontWeight = FontWeight.NORMAL, rotation: Float64 = 0.0, align: TextAlign = TextAlign.LEFT, *, mut cache: FontCache) raisesThe whole implementation Canvas, PdfCanvas and BoundsTarget use is a loop: text_run_anchors(x, y, runs, family, weight, rotation, align, cache=cache), then draw_text for each run with non-empty text at its anchor, with the run's size and slant, TextAlign.LEFT and the same rotation. No existing call changes.
One label, several runs (#467, PR #468)
SvgCanvas.draw_text writes one <text> per call with one size and one slant, so a label that mixes them had to be several calls and came out as several 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 each separately.
TextRun(text, size, slant=, dx=, dy=) is one piece of such a label; draw_text_runs draws the list anchored at (x, y) on the label's baseline, with family, weight, rotation and align shared by every run.
dxmoves the pen before the run, from where the previous run's advance ended: a kern, negative to back up. It is exactly a<tspan>'sdx.dyis the run's baseline relative to the label's, positive downward, so a superscript is negative and the run after it is back at 0. It names a baseline rather than SVG's carrying shift, because a layout engine knows each run's baseline and not the deltas; the SVG backend writes the difference and omits it when unchanged.alignplaces the whole label by where the pen ends, asdraw_textplaces a line by its advance. A run with no text draws nothing and moves nothing, on every backend.
The backends:
SvgCanvaswrites one<text>carrying family, weight, fill,text-anchorand the rotation transform exactly asdraw_text's does, and a<tspan>per run carryingfont-size,font-stylewhen slanted,dxanddy. Nothing is written between the tspans. The viewer's own font metrics advance the pen between runs and resolve the alignment, as they already do for a single label, so a superscript stays snug against its base in whatever font the viewer has.Canvas,PdfCanvasandBoundsTargetdraw each run withdraw_textat the anchor the newtext_run_anchorsmeasures, so a run lands where that text drawn alone at that anchor lands, byte for byte; tests pin the identity on all three, and the trait test draws one label through all four conformers.
var runs: List[TextRun] = [
TextRun("E", 18.0, slant=FontSlant.ITALIC),
TextRun(" = ", 18.0),
TextRun("mc", 18.0, slant=FontSlant.ITALIC),
TextRun("2", 12.6, dy=-6.3),
]
target.draw_text_runs(x, y, runs, color, align=TextAlign.CENTER, cache=cache)TextRun is its own leaf module, canvas/text/text_run.mojo, for the reason TextAlign is: the trait and Canvas name it and cannot import render.mojo. Both it and text_run_anchors are exported from the package root.
For dataviz_mojo's math layout, whose runs carry origin-relative offsets: dx_i = origin_dx_i - (origin_dx_{i-1} + advance_{i-1}), dy passed as is, and a fraction rule stays a line.
Also
- CI packages through
mojo precompile(PR #469). Everysetup-pixipin moves from v0.76.2 to v0.78.0 and the build backend floor becomes>=0.2.7,<1: backends that useprecompileneed pixi-build-api-version 7, which arrived in pixi 0.77.0, so under the old pin the floating0.*quietly resolved a backend that still ranmojo packageand printed two deprecation warnings. The consumer step now fails ifmojo precompileis absent from its build log or either warning returns. The same change dataviz_mojo made in its #646. - The logs carry no warnings of ours (PR #469). Three implicit references to
Colorthrough the package__init__in batch.mojo, two lowercase docstring lines inPath.arrow_head, and two unused variables in tests, all cleared. - The text guide no longer denies text is on the trait (PR #468).
docs/src/guides/text.mdstill said so after v0.35.0; corrected, with a section on runs. canvas/batch_ops.mojo(PR #466) holds what a batch records, split out ofbatch.mojo, which keeps how it is drawn. No API change.BoundsTargetdocs (PR #465) say that a full-page background fill is ink the target counts.
Gates
Before tagging, on the merged main: full suite 53 of 53 modules reported with 1106 tests, 0 failures and no compiler warnings on Linux, CI green on Linux and macOS for both PRs, bench-verify 16 scenes matching their digests, bench-check on an idle machine median ratio 1.015 across 70 comparable rows with no row flagged.
v0.37.1
Documentation and one test. No code path changed, no rendered output moved, and an out-of-repo DrawTarget has nothing to add.
Merges since v0.37.0: #463.
A PDF embeds the machine's font file, not the family (PR #463)
A consumer added a PDF column to a cross-platform output digest, generated it on Linux, 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 neither package had written down why.
canvas/vector/pdf_font.mojo copies head, hhea, maxp, hmtx and the hinting programs cvt , fpgm and prep verbatim out of the file font_discovery resolved on the rendering machine, and rebuilds glyf from that file's outlines. So a document with text in it is a function of which build of a family the machine had installed. Two packagings of one family agree on glyph outlines, which is exactly why raster output is identical across machines, and need not agree on the bytes around them.
SvgCanvas writes a CSS font-family and embeds nothing, so it never had this dependency. That contrast is in the docstring, because it is what makes the failure confusing when it happens.
The other half bounds the claim. There is no creation date, no /ID, and /Producer is a fixed literal, so one font file on one machine renders byte-identical documents run to run. A byte-level gate on PDF output across machines is a gate on the font installation as well; on one machine it is a gate on this package. Worth knowing before building one: the font program is most of a small document, about 53 KB of subset font compressed to roughly 12 KB of a 14 KB file, so a PDF digest is largely a hash of the machine's font.
PdfCanvas.draw_text and canvas/vector/pdf_font.mojo now say all of this.
The test
Run-to-run determinism is a guarantee nothing in the rendered page shows, which is the case AGENTS.md says needs a test reading the resource rather than the output. tests/test_pdf.mojo renders the same text twice and compares the bytes: a timestamp or a random /ID added later would break every downstream digest and pass every other test in the file. It passes on Linux and macOS.
Also
Verifying the docstring turned up a defect, filed as #462 and deliberately not fixed here: /BaseFont is built from the font file's stem rather than the font's own name, so the variable font Ubuntu[wdth,wght].ttf embeds as AAAAAA+Ubuntuwdthwght where its name table says Ubuntu-Regular. No viewer draws from that field, so nothing renders wrong, but the document misreports its own font and does so as a function of the packaging. That is a behavior change with its own tests.
Gates
Full suite 52 of 52 modules with 1087 tests and 0 failures locally; CI green on Linux and macOS. pixi run fmt. No code path changed, so bench-verify and bench-check are unaffected.
v0.37.0
BoundsTarget, a fourth DrawTarget that measures a scene's ink without drawing it, so a figure can be cropped tight to its content on every backend (#460, filed from dataviz_mojo's publication export). No breaking change: nothing in Canvas, SvgCanvas or PdfCanvas moved, and an out-of-repo DrawTarget has nothing to add. No rendered-output change to any existing scene: every verification digest matches v0.36.0.
Merges since v0.36.0: #461.
BoundsTarget: what a scene would ink, on any backend (#460, PR #461)
BoundsTarget(width, height) in canvas/bounds.mojo conforms to DrawTarget, discards the drawing and keeps the union of every primitive's extent, in its own coordinates after the transform, confined to the page and to any push_clip in force. Read it back three ways:
has_ink(): whether anything with area was drawn, which tells an empty scene from a box at the origin.ink_bounds(): the geometric box,(min_x, min_y, max_x, max_y)asPath.boundsreports it.ink_pixels():(x, y, width, height)in whole pixels, every pixel whose square the geometry enters.
The crop recipe is measure, size a target to the box, draw again with translate(-x, -y):
var probe = BoundsTarget(800, 600)
draw_figure(probe, cache)
var box = probe.ink_pixels()
var out = Canvas(box[2], box[3], Color(255, 255, 255))
out.translate(-Float64(box[0]), -Float64(box[1]))
draw_figure(out, cache)The same recipe sizes an SvgCanvas or a PdfCanvas, which is why the measuring target exists rather than a raster scan: markup and page operators have nothing to scan, and a tight export that only worked for PNG would have been the wrong shape.
Extents come from what the package already knew. Fills and strokes measure through Path.bounds and Path.stroke_bounds, so caps, joins, a MITER spike and a dash pattern ending short all count. Disks, ellipses, wedges and ring sectors flatten through the same path builders the fills fall back to. Rectangles and images snap the way fill_rect snaps. Text lays out through measure_text_block with its alignment and rotation. Under a similarity a stroke maps its path and scales its 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 never smaller than the ink and can be larger, and the docstring says so.
The one new rule is anti-aliased spill: pixel k is the square [k - 0.5, k + 0.5], and ink_pixels() counts every pixel whose square the geometry enters. The raster-scan test defines it rather than tolerating it. Each of fourteen primitives is drawn into a Canvas and a BoundsTarget through 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. That one pixel is an extent whose coverage rounded to zero, and erring outward is the safe direction for a crop.
It is also the first conformer in the repository with no buffer, string or page behind it, so it is the first proof that the trait can be implemented from outside the three backends. test_package_exports now draws the same generic scatter into it that reaches the batched raster path.
Gates
Before tagging: full suite 52 of 52 modules reported with 1086 tests and 0 failures on Linux, CI green on Linux and macOS, bench-verify 16 scenes matching their digests, bench-check median ratio 1.050 across 70 comparable rows with no row flagged (a uniform drift on a machine that had just run the suite; no drawing code changed).
v0.36.0
Path gains extend, so a shape mapped into place by its own Transform2D can join a larger path as one of its sub-paths instead of having nowhere to go once transformed hands back a copy. This is the last open item from the dataviz_mojo audit (#443, the ninth and final of the six #431-#436 plus this one). Also a stale docstring fix. No rendered-output change to any existing scene: every verification digest matches v0.35.0.
Merges since v0.35.0: #444, #445.
Path.extend, so a transformed shape can join an existing path (#443, PR #444)
transformed returns a new path, so a shape that has to be mapped before it joins a larger path had nowhere to go. regular_polygon's own docstring named the case and left it unfinished: a hexagon regular in data space and drawn through two axis scales is a unit polygon mapped by its own Transform2D, but there was no way to fold the mapped copy into a shared path afterward.
extend(other) appends other's commands onto self and adopts its current point and sub-path start, the bookkeeping the commands field's own docstring already said an outside caller could not do: "the builder methods keep the current point and sub-path start in step with it, and an appended command would not." other begins with its own move_to, so it lands as its own sub-path; extending by an empty path is a no-op.
A rendering test pins the property it exists for: two unit right triangles tiling a square along its diagonal, mapped by the same Transform2D, extended into one path and filled once under FillRule.NONZERO leave the diagonal fully covered; the same two triangles filled by two separate fill_path_aa calls do not -- the seam fill_mesh's own docstring measures, reproduced here as an assertion.
bounds()/stroke_bounds() over an extended path already equal the union of the two paths' own, for free: both loop over every sub-path _flatten returns.
Also
Canvas's struct docstring no longer deniesdraw_textexists (PR #445). It still said "There is nodraw_textmethod, sinceDrawTargethas none" after v0.35.0 putdraw_texton the trait andCanvasimplemented it. Flagged mid-refactor by a consumer collapsing its own text-replay plumbing down to the trait method, who had to checkCanvasactually conformed before trusting the docstring's denial. New text keeps the pointer to the free function, which still has capabilities the method lacks (kerning/ligature switches, whole-pixel and cache-less overloads).
Gates
Before tagging, on an idle machine: full suite 51 of 51 modules reported with 1059 tests and 0 failures, bench-verify 16 scenes matching their digests, bench-check median ratio 1.018 across 70 comparable rows with no row flagged.