Skip to content

feat(text)!: Pluggable text layout via Makie.layout_text - #5717

Open
jkrumbiegel wants to merge 73 commits into
ff/breaking-0.25from
jk/glyph-buffer-refactor
Open

feat(text)!: Pluggable text layout via Makie.layout_text#5717
jkrumbiegel wants to merge 73 commits into
ff/breaking-0.25from
jk/glyph-buffer-refactor

Conversation

@jkrumbiegel

@jkrumbiegel jkrumbiegel commented Jul 28, 2026

Copy link
Copy Markdown
Member

Supersedes #5632, opened separately so that PR's review history stays readable. It refactors the text pipeline so other packages can hook in their own way of rendering text or text-like objects: real LaTeX through MakieTeX (companion PR MakieOrg/MakieTeX.jl#73), or something like TeXLayout.jl, which currently has to resort to type piracy for this.

The extension point is Makie.layout_text, which returns glyph data, other plot specs, or both, for a handler type and/or a text type:

struct MyHandler end

function Makie.layout_text(::MyHandler, src::LaTeXString, attributes::Makie.TextAttributes)
    return Makie.TextLayout(...)
end

Makie's own layout is the handler === nothing implementation of that same function, so there is one protocol rather than an extension API bolted next to the built-in string types, and a type a handler has no method for falls through to Makie without the handler doing anything.

Picking the handler in the theme means a whole figure can be rendered with LaTeX text without threading handlers through every call:

with_theme(text_handler = MakieTeX.LaTeX(render_strings = true), fontsize = 15) do
    fig = Figure()
    ax = Axis(fig[1, 1],
        title = L"y(t) = A\,e^{-\lambda t}\cos(\omega t + \varphi)",
        xlabel = L"time $t$ (s)", ylabel = L"amplitude $y$", xticks = 0:2:10)
    t = range(0, 10, length = 400)
    lines!(ax, t, exp.(-0.3 .* t) .* cos.(2 .* t), label = L"\lambda = 0.3")
    lines!(ax, t, exp.(-0.3 .* t), linestyle = :dash, label = L"e^{-\lambda t}")
    axislegend(ax, position = :rt)
    fig
end
figure with title, labels, ticks and legend rendered by a real TeX run

Title, labels, tick labels and legend entries all come from a real TeX run here.

Glyphs and specs

Text is now a scatter-like plot of glyphs, and that idea gets its own plot type: Glyphs. Text becomes a container with a Glyphs child for the glyph batch and a plotlist for everything that isn't a glyph, which is how MathTeXEngine's rules and MakieTeX's image markers get drawn. Glyphs keep a fast path; the specs are slower, but they are flexible enough to switch between text and plot types dynamically, and these text systems aren't meant for performance sensitive work anyway. The point of MakieTeX is high quality static plots, not animating huge equations in a live GUI.

Specs are placed through their plot's model matrix rather than by rewriting their arguments, so any plot type can be a spec (an image, say), a spec's arguments stay in the block's layout frame, and moving the camera or the text updates one matrix per spec. Markers rasterized for the GPU backends (MakieTeX's PDF labels) are rasterized at the screen's px_per_unit in GLMakie and re-rasterized when it changes, so they stay sharp on hidpi screens and in high-resolution saves of an already displayed figure.

Moving text around doesn't relayout, because align, rotation and offset are applied downstream of layout, which for a LaTeX engine is the difference between moving a label and recompiling it. Almost every other attribute does relayout, which seems fine given how rarely they are animated.

The split also makes display-only updates cheap for plain text, since glyph geometry is reused instead of laid out again. This holds with a text_handler set: only blocks the handler actually claims (has a layout_text method for) re-run layout on a recolor, fall-through blocks keep the fast path.

# 4000 strings, 26893 glyphs, CairoMakie, realizing the render outputs
#                     before     after
# create              36.1 ms   34.0 ms
# color update        32.4 ms    5.0 ms
# fontsize update     33.7 ms   32.7 ms
# position update     33.8 ms    0.1 ms

Breaking

Per-character styling goes away. A vector color, strokecolor, strokewidth, fontsize or rotation on text is one value per string now, and a length that doesn't fit errors. This is for shaping systems like HarfBuzz, where several code points become one glyph, so there is nothing for a per-character vector to index; we can style glyphs, not characters. rich text already covers the use case, since it styles ranges over a string rather than characters.

  • Text is a container plot, so code that read glyph render attributes (sdf_uv, quad_offset, quad_scale, ...) off a Text has to read them from its Glyphs child.
  • pathtext draws into a Glyphs child instead of a Text one.
  • align = (halign, :baseline) now puts rich text and LaTeXStrings on their baseline instead of their bottom edge, matching plain strings; for multi-line rich text that is the last line's baseline, also matching plain strings.
  • With justification = automatic, a fractional halign justifies by that fraction instead of falling back to 0.5.

Also fixed on the way: rich text in pathtext ignored the plot's color for the parts it didn't style itself, and applied alpha twice.

Caveats

  • Non-glyph specs are one plotlist entry per text block, so N LaTeX labels are N LineSegments plots. A camera move only updates each spec's model matrix, but merging same-type specs across blocks would still reduce the plot count; only text that emits specs pays anything at all, so keeping the implementation simple seems worth more until that turns out to be wrong.
  • MathTeXHandler doesn't handle word_wrap_width yet; the built-in LaTeX path still does.
  • Removing the old text("string", position = ...) form needs a deprecation cycle, so it stays as is here, per @ffreyer's note on feat(text): Add text_handler extension hook #5632.
  • Per-glyph strokewidth still collapses to one value in GLMakie and WGLMakie, whose shaders bind it to a uniform float. Same as on master, CairoMakie draws it per glyph.

Introduces a `Glyphs` recipe that renders a flat batch of atlas glyphs (per-glyph
positions + marker_offset + glyph indices/fonts, scalar-or-vector display attrs),
similar to Scatter but kept separate. `Text` now computes its layout and creates a
single always-present `Glyphs` child (plus the existing linesegments child for
LaTeX rules) instead of rendering glyphs itself, and is no longer atomic.

The glyph -> atlas-quad computations (sdf_uv, quad_offset/scale) and position
transforms move into `Glyphs`; with per-glyph positions these become plain
broadcasts and the per-char position expansion node is dropped. Backends render
`Glyphs` (CairoMakie, GLMakie done; WGLMakie by analogy, needs browser/CI check)
and descend into Text's children.

Benchmark vs breaking-0.25 baseline (N=4000, ~27k glyphs): create 35.9 vs 44.7ms,
color 31.9 vs 38.2ms, ~15-20% faster; the per-glyph position transform is not a
net regression. Color still tracks a full re-layout, addressed in the next phase.
compute_glyph_collections! now inspects the compute graph's `changed`/`cached`
state: when no layout-affecting input changed and every block's text has
`display_independent_layout == true`, it reuses the cached glyph geometry
(pointer-stable, so the Glyphs child never re-uploads) and recomputes only the
per-glyph color/stroke arrays.

Plain strings apply color/stroke after layout so they qualify; RichText and
LaTeXString bake color into layout and recompute (the per-type overload decides
via the trait). A colour tween over ~27k glyphs drops from 31.9ms to 3.6ms.

Scalar->vector color updates remain unsupported (a pre-existing Makie
attribute-slot-typing limitation that affects Scatter identically); out of scope.
Adds the `rasterize_marker_for_gpu(marker, markersize)` extension hook in
compute_marker_attributes so GPU backends can rasterize an opaque scatter marker
(e.g. a compiled LaTeX/Typst object) into an image at upload time, while CairoMakie
keeps drawing the original marker as vectors. Default returns nothing, so existing
markers are unaffected. The image-matrix marker path (single Matrix and per-marker
Vector{Matrix} with pack_images) already exists on this branch.

Text gains an always-present but empty `plotlist` child fed by a `text_specs`
output, the channel a text_handler will use for non-glyph, non-rule output
(images, arbitrary plots). Empty means no children and no render objects, so the
default glyph/rule paths are unaffected. LaTeX rules keep their dedicated merged
linesegments child (this branch already replaced the old per-frame plotlist diff).
Adds a themable `text_handler` plot attribute (`@inherit text_handler`, default
nothing) and a pluggable layout protocol:

- compile_text(handler, src, font, fonts, fontsize, lineheight, justification,
  word_wrap_width) -> CompiledGlyphs | nothing. Receives only layout-affecting
  inputs; returns an unaligned, backend-neutral glyph bundle (glyph columns +
  rules + bbox), or nothing to fall through to the built-in path.
- place_text!(outputs, ::CompiledGlyphs, align, rotation, offset, color, ...):
  Makie-provided placement that applies alignment from the reported bbox, then
  rotation/offset/color, and merges glyphs into the shared Glyphs batch and rules
  into the linesegments child. Handlers with custom payloads override it.

compute_glyph_collections! routes each block through the handler and falls back to
convert_text_string! when compile_text returns nothing, so a single text! can mix
handled and unhandled strings. Handler blocks opt out of the display-reuse fast
path (they may bake color in); the handler owns any layout cache.

MathTeXHandler lays out LaTeXString via MathTeXEngine through this protocol,
producing output identical to the built-in LaTeX path (the migration path a third
party like TeXLayout/Typst would follow). No backend changes needed: handled text
flows through the same Glyphs/LineSegments children.
Text is now a container; sdf_uv/quad_offset/quad_scale/sdf_marker_shape live on
its Glyphs child (plot.plots[1]) rather than on the Text plot itself.
…hs-only

Drops the dedicated LaTeX-rules LineSegments child (tex_linesegments!) and the
:linesegments/:linewidths/:linecolors/:lineindices node outputs. Rules now flow
through the same plotlist channel as any other non-glyph handler output: the built-in
LaTeX path and MathTeXHandler emit a LineSegments PlotSpec, which register_text_plotlist!
shifts by the block's projected position (per camera) and renders in markerspace. Plain
text carries no rules child at all.

CompiledGlyphs is now glyphs-only (glyphindices, fonts, origins, extents, scales) plus
bbox and baseline (needed for valign = :baseline, which bbox alone can't express).
compile_text returns (CompiledGlyphs, Vector{PlotSpec}); place_text! merges glyphs via
place_glyphs! and emits the specs (aligned, block-tagged) into text_specs, filling the
text color for specs that don't set their own. String bounding boxes fold in per-spec
bboxes instead of raw rule segments.
The scatter shader treats strokewidth as a uniform float; feeding it the per-glyph
text_strokewidth vector errored on WGLMakie (Type Vector{Float32} not supported).
Per-glyph text strokewidth is always uniform-valued in practice, so pass the scalar
plot strokewidth, matching how GL/WGL handled Text before the Glyphs extraction.
- CairoMakie draw_glyphs: replace `local` batch vars with a batch tuple
  accumulator (`local` bindings are unusual Julia style)
- compile_text now returns a CompiledText struct with union-nothing
  glyphs/specs fields and dispatch constructors, instead of a
  (CompiledGlyphs, specs) tuple
- Document why Text/text remain in PrimitivePlotTypes/atomic_functions
  (container still converts its own attributes and needs camera
  registration for plotlist projection; no recipe body)
…dlers

Image-rendering text handlers (e.g. MakieTeX's LaTeX/Typst) bake appearance into
their output at compile time, so compile_text now receives the per-block color,
strokecolor and strokewidth (the same appearance attributes place_text! gets).
Glyph-based handlers ignore them and let place_text! apply them to the glyph batch.
A callback returning `Ref{Any}(x)` (the type-narrowing opt-out) stores the
dereferenced value but was compared against the raw Ref wrapper, which never
equals it, so the output stayed perpetually dirty and re-fired every dependent.
Dereference once and compare the value.
…updates

The text argument producer re-emits input_text whenever position changes (they
share a node). It handed back an aliased array, and is_same treats a shared
array as possibly-mutated (dirty), so the text layout re-ran on every position
update during layout solves. For image handlers (MakieTeX LaTeX) that meant
recompiling each pass (10 engine runs for 3 constant labels; now 3). Copy so the
emitted array is distinct and compares equal.
Nothing reads the glyphcollections node output any more (backends consume the
flat per-glyph arrays); it was reconstructed per block and threaded through the
cache for nothing. Remove the field, the four pushes (including the String-path
GlyphCollection that existed only to be pushed), and the gcs slot in
reuse_glyph_layout. The GlyphCollection type stays for now (PathText + layout
intermediates still use it).
The text node's per-glyph outputs were named inconsistently (glyph_origins vs
font_per_char vs text_color). Rename them uniformly: glyphindices→glyph_indices,
font_per_char→glyph_fonts, text_scales→glyph_scales, text_color→glyph_colors,
text_rotation→glyph_rotations, text_strokecolor→glyph_strokecolors,
text_strokewidth→glyph_strokewidths (glyph_origins/glyph_extents unchanged).

This also removes the name collision with the unrelated text_color/text_rotation
attributes on TextLabel/Contour/Tooltip. Scoped to the node outputs and their real
consumers (register_glyphs!, editabletext, textbox, tests); the Glyphs recipe
attributes and the GlyphCollection/layout-nt fields are left untouched.
The text layout node allocated a fresh set of 13 output arrays on every
evaluation. It now keeps one GlyphBuffer across evaluations, emptying and
refilling the cached arrays instead. Reuse is graph-safe: `is_same` treats an
aliased array as changed, so the mutated buffer still propagates to the Glyphs
child and the bounding box nodes.

The buffer also hides the parallel-array bookkeeping behind push_glyph_block!,
push_empty_block! and push_text_spec!, so a spec can't be pushed without its
matching block index and bbox, nor a glyph block without its text_blocks entry.
push_text_spec! takes an optional bbox for output whose extent isn't its
positions (image markers). per_glyph_block is replaced by BlockAttribute,
resolved while appending.

Allocations for one relayout of 26893 glyphs drop from 63.6 to 53.9 MiB. A
color-only update, which keeps the cached geometry, drops from 3382 to 4.3 KiB
and no longer scales with glyph count. Cairo renders of plain, rich, LaTeX and
handler text are byte-identical to before.
Text layout resolved alignment, rotation and offset itself, so changing any of
them re-ran the layouter. For an expensive `text_handler` that meant recompiling
a LaTeX label just to move it.

Layout now emits a layout frame instead: unaligned, unrotated glyph origins plus
a per-block bbox and baseline describing the frame. A downstream placement node
consumes those with `align`, `rotation` and `offset` and produces the placed
`glyph_origins`, `glyph_rotations`, `text_specs` and `text_spec_bboxes`. A
handler no longer sees the placement attributes at all, and its specs get the
block transform applied for it, including composing the block rotation into a
`rotation` kwarg so an image marker turns with the text.

`align` still reaches layout through one number: automatic justification follows
halign, so it resolves to `resolved_justification` upstream. An align change that
leaves that number alone is filtered by `is_same` and never triggers a relayout.

Two behavior changes fall out of the shared placement path:

- `valign = :baseline` now aligns `rich` text and `LaTeXString`s on their
  baseline. Both previously behaved like `:bottom`, because `:baseline` went
  through `get_yshift`'s `default` as the align fraction 0.0 rather than as a
  baseline position. Plain strings are unchanged.
- With `justification = automatic`, a fractional `halign` now justifies by that
  fraction instead of falling back to 0.5. This makes plain strings agree with
  rich text, which already did this.

Verified against the previous commit: CairoMakie renders over align, rotation,
offset, justification and word-wrap grids are byte-identical except the two cells
covering the changes above, MakieTeX's handler output is byte-identical including
rotated markers, and the 80 recorded text/layout reference images differ only in
anti-aliasing (worst score 0.005 against the 0.05 threshold, 10 pixels of
300,000) from the reordered float arithmetic.
The text layouters were the only thing building `GlyphCollection`, and the glyph
buffer immediately took it apart again. They now return the flat per-glyph arrays
directly, under one shared set of names (`glyphindices`, `fonts`, `origins`,
`extents`, `bbox`, `baseline`), so all three layout paths read the same way at the
call site.

That leaves `GlyphCollection` and `ScalarOrVector` unused, along with the six
`attr_broadcast_*` / `is_*_attribute` / `getindex` methods that existed only to
make `ScalarOrVector` fields broadcastable, and `collect_vector`. `GlyphInfo` also
loses its `rotation` field, dead since rotation moved to the placement node.

No behavior change: the 80 recorded text and makielayout reference images are
byte-identical to the previous commit, as are the Cairo placement grids.
Bending text along a path produces a position and a rotation per glyph, which is
exactly what `Glyphs` consumes. `pathtext` was instead handing each character to
`text` as its own single-character string, so every glyph paid for a full text
block: a layout pass, a block bbox, a justification resolve, and an alignment
shift that `align = (:left, :baseline)` was chosen to cancel out.

It now resolves glyph indices and fallback fonts during layout and drives a
`Glyphs` child directly, with the placed positions as the glyph origins and no
marker offset. `_place_glyphs_on_path` no longer threads characters through just
to hand them back as strings.

The 80 recorded text and makielayout reference images, which cover pathtext over
several halign values, plain and rich text, and polyline and Bézier paths, are
byte-identical to the previous commit.
`GlyphCollection` is gone, so the names built on it no longer refer to anything:
`glyph_collection` -> `layout_string`, `texelems_and_glyph_collection` ->
`texelems_and_layout`, and `compute_glyph_collections!` ->
`register_glyph_layout!`, which also pairs with `register_glyph_placement!`
downstream of it.

Deletes a block of dead helpers that came along for the ride: `get_from_collection`
and `get_text_blocks` read fields of the removed type, `map_per_glyph` and the two
`per_*_getindex` error paths reference an undefined `glyphs` binding (so they could
never have run), and `per_text_block` / `per_glyph_attributes` had no callers left.
A vector `color`, `strokecolor`, `strokewidth` or `fontsize` whose length matched
the total glyph count used to style a string character by character. That model
does not survive proper text shaping: with HarfBuzz several code points can shape
into a single glyph (ligatures, combining marks, Indic clusters), so there is no
character-to-glyph correspondence to index by. Rather than keep an API that would
have to break later, drop it now; `rich` text already covers styling parts of a
string, and does so in terms of the text rather than the glyph run.

Such a vector is now always one value per string, and a mismatched length errors
pointing at `rich` text instead of silently reinterpreting the vector.

Per-character `fontsize` was half-broken anyway: layout only ever saw the block's
first entry, so glyphs grew while their advances stayed put and they overlapped.

`Glyphs` is unaffected, being the per-glyph primitive; `pathtext` therefore still
takes per-character vectors, which it will have to revisit when shaping lands.
`pathtext` drives `Glyphs` directly, so a vector `color`, `strokecolor` or
`strokewidth` still styled the string character by character after `text` dropped
that. Same reasoning applies: text shaping can merge several code points into one
glyph, so a vector indexed per character has no well-defined mapping onto glyphs,
and `pathtext` already accepts `RichText`, which expresses the same styling in
terms of the text.

Each of the three now takes one value and errors otherwise, pointing at `rich`
text. The reference test that used a per-character viridis ramp now builds it with
`rich` instead, and records byte-identically, so the replacement is exact rather
than merely equivalent.
`_layout_richtext_for_path` hardcoded opaque black as the base color, so
`pathtext(path, text = rich("abc"), color = :red)` drew black; only the parts that
rich text styled itself came out right. Now that `rich` is the way to style parts
of the text, that base has to be the plot's color.

Resolving it means colormapping the color before layout, which recipe attributes
don't get for free: `to_color` first, then `register_colormapping!` on the
converted node. So a colormapped number still works, for the whole string.

That also moves `alpha` into the color here, so the `Glyphs` child must not apply
it a second time (it was doing 0.25 for `alpha = 0.5`, where `text` gives 0.5).

The one reference image that combines an annotation color with partly-styled rich
text changes accordingly: its "H₂O → " now takes the annotation's steelblue
instead of black, matching the arrow it labels.
Splitting the handler protocol in two made sense when placement needed align,
rotation and offset: the engine step couldn't see them, so it handed a payload to
a placement step that could. Since placement moved into its own node, the
placement step's arguments are a strict subset of the engine step's, so the split
carries no information and just makes a handler author learn two functions and an
intermediate type.

`emit_text!` is the whole protocol now: get the block's attributes, push into the
buffer, return `true`, or return `false` to fall through. That deletes
`CompiledText`, `CompiledGlyphs` and `place_text!`, since a handler that has the
buffer has no reason to re-package what `push_glyph_block!` already takes.

The fallback method is deliberately untyped in all arguments, including the
buffer: typing the buffer makes the obvious handler method (typing the handler and
the input type) ambiguous with the fallback rather than more specific.
Twelve positional arguments made the handler extension point brittle: adding a
text attribute would have broken every handler in existence, which is not a good
property for the one function third parties are meant to implement.

The attributes now travel as a struct, so a new field leaves existing handlers
alone. Keywords would have needed every handler to remember `kwargs...` to get the
same protection, and a struct also documents the set in one place.

Nothing is lost at the use site, since a handler can destructure what it wants:

    function Makie.emit_text!(buffer, ::MyHandler, str::AbstractString, attributes)
        (; fontsize, color) = attributes
        ...
    end

`fontsize` is normalized to `Vec2f` on the way in, so a handler no longer has to
consider both a number and a `Vec2`; `MathTeXHandler` and MakieTeX both took the
first component anyway.
Signalling with `return false` had the handler tell Makie "I didn't do anything",
which Makie has no way to verify: a handler that pushes and then returns `false`
gets its block laid out twice, silently corrupting `text_blocks`. Nothing in the
type system prevented that.

The fallback method now runs Makie's own layout, so a handler that has no method
for an input type falls through by not existing, and one that decides from the
content hands the block back with `default_text_layout!`. Every path pushes
exactly one block because every path ends in a push. `text_handler = nothing` also
stops being a special case in the layout loop: `nothing` has no method, so it
lands on the fallback like any other unhandled input.

Two things this pulled in. Attributes are now resolved per block once, before
layout, so `BlockAttribute` and its resolve-while-appending are gone; the
per-string length check that lived there is a single `validate_per_string` pass
over the attributes, which also names the offending attribute in the error.
`layout_string` now takes the font and fontsize as one value for the whole string,
which additionally fixes a `Vec2` fontsize erroring in the built-in path.
`emit_text!` has one obligation, and nothing was checking it. A handler that
pushes nothing leaves `text_blocks` shorter than the input, and one that pushes
twice leaves it longer, either way silently misaligning every per-block array
downstream: the text just renders wrong.

The layout loop now compares the block count against the string index after each
call and says which handler broke the contract. It caught the test's own counting
handler immediately, which had been relying on `return false` to opt out.
`default_text_layout!` was a second name for something the protocol could already
express: `text_handler = nothing` means "use Makie's layout", so Makie's layout is
just `emit_text!` with `handler === nothing`. Its three methods now dispatch on
`::Nothing` and the extra function is gone, which also makes the built-in path an
instance of the extension point rather than a thing beside it.

A handler delegates with `emit_text!(buffer, nothing, src, attributes)`, mirroring
the attribute value that means the same thing.

The generic fallback delegating to `nothing` needs a recursion stop, so there is a
`::Nothing` method for input types Makie has no layout for. It replaces what used
to be a `MethodError` on an internal function with a message naming the type and
the two ways out.
`emit_text!` took the node's `GlyphBuffer` and pushed into it, which put two
things on the handler author: picking the right one of three push functions, and
keeping the parallel arrays consistent. Both are Makie's bookkeeping, and neither
is checkable at the boundary, so a handler that pushed nothing or twice silently
misaligned every per-block array downstream. The previous commit could only catch
that after the fact by counting blocks.

A handler now returns one `TextLayout` for the block and Makie appends it, so
"exactly one block per string" holds by construction and the count check, the
three push functions and the buffer itself all leave the extension point.
`append_text_layout!` validates the array lengths up front and names the field
that doesn't match.

`spec_bboxes` defaults to the bounding boxes of the specs' positions, as
`push_text_spec!` did, so only a handler whose visual extent differs from its
positions (an image marker) passes them.
Neither half of `emit_text!` describes the function any more: it stopped pushing
into a buffer in the previous commit, and the text it names is what goes in (a
`String`, `LaTeXString`, `RichText`, or a handler's own type), not what comes out.
`layout_text` takes the text as the object of the verb, the way `layout_string`
already does, and returns a `TextLayout`.

That name belonged to the `RichText` layouter, which is internal with a single
call site and becomes `layout_richtext`, leaving `layout_string` /
`layout_richtext` / `texelems_and_layout` each named after what it lays out.
The `text` page gets a section on `text_handler` aimed at using one: how to set
it on a plot, how to set it in the theme so axis and legend labels go through it
too, and that a handler only claims the input types it has methods for. The LaTeX
page links there, since rendering `LaTeXString`s with an engine other than
MathTeXEngine.jl is the main reason to reach for a handler.

Writing a handler is left to `layout_text`'s docstring, which gains the method
sketch, the note that `align`/`rotation`/`offset` are applied downstream, and a
warning about `LaTeXString <: AbstractString` (a method taking `AbstractString`
claims LaTeX input and lays out its source).

`MathTeXHandler` moves out of the changelog line, since it does what the default
LaTeX path already does and is not something a user would set.
rasterize_marker_for_gpu ran in the compute graph, which has no screen,
so a rasterized marker was fixed at nominal resolution: blurry on hidpi
displays and in high-resolution saves. The hook takes px_per_unit now and
GLMakie re-rasterizes single markers per screen through an input backed
by screen.px_per_unit, which also re-runs when a save with a different
px_per_unit reconfigures a displayed screen. The graph keeps the
px_per_unit = 1 rasterization as the fallback for WGLMakie and for
vectors of markers, whose uv packing happens graph-side.
Spec placement used to rewrite each spec's first positional argument and
compose a rotation kwarg, which restricted specs to point-based plot
types and rebuilt every spec's positions on each camera move. Placement
(align, rotation, offset and the projected anchor) now composes into the
spec child's model matrix, so any plot type works as a spec, including
image, and camera moves diff a single matrix kwarg instead of argument
arrays. Spec children run with transformation = :nothing and a model
keyword now seeds the model input of such plots directly; the spec
differ skips the construction-only transformation kwarg instead of
erroring on it. transform_marker defaults to true on spec children where
it exists, so scatter markers rotate with their block.
CairoMakie sorts drawing by the z of a plot's transformation matrix,
which a spec child with transformation = :nothing reports as 0, hiding
legend labels rendered as specs behind the legend background. Spec
children now share one transformation inherited from the text plot,
carrying its z and an identity transform_func, while their model input
is driven exclusively by the model keyword: a plot constructed with an
explicit model no longer syncs its transformation into the model input
at all.
Recipes that forward their generic attributes, like spy and datashader,
pass model = automatic to their children, which the model keyword
handling tried to convert to a matrix. Non-matrix values now mean the
transformation drives the model as usual.
@jkrumbiegel
jkrumbiegel requested a review from ffreyer August 13, 2026 06:15
@ffreyer ffreyer mentioned this pull request Aug 19, 2026
15 tasks
Comment on lines +491 to 500
map!(attr, inputs, :baked_display_attributes) do text, handler, color, strokecolor, strokewidth
# here rather than downstream so a bad length is reported when the plot is
# created, not when its colors are first pulled
for (name, value) in [(:color, color), (:strokecolor, strokecolor), (:strokewidth, strokewidth)]
validate_per_string(name, value, length(text))
end
baked = !isempty(text) && any(str -> bakes_display_attributes(handler, str), text)
baked || return nothing
return (color, strokecolor, strokewidth)
end

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

return nothing is probably a bug here.

If the output is not initialized, return nothing will cause it to be initialized to nothing. I think that's what you want here.

If the output is already initialized return nothing causes the update to be discard. I.e. the old value remains and propagates as "up to date". So baked_display_attributes can not dynamically switch to nothing like this. I think after init nothing is always considered as "nothing changed here", so we'd need to disambiguate it.

Tangentially, if you have multiple outputs and return nothing in an init run, all outputs become nothing. I would've expected an error there. Maybe that should be changed?

Comment on lines +68 to 84
register_computation!(attr, inputs, [:_positions, :_text_update]) do inputs, changed, @nospecialize(cached)
a_pos, a_text, args... = values(inputs)
_, text_changed, args_changed... = values(changed)

# Note: Could add RichText
if args isa Tuple{<:Union{AbstractString, RichText}}
# position data will always be wrapped in a Vector, so strings should too
return ((a_pos,), Ref{Any}([args[1]]))
return ((a_pos,), text_update([args[1]], args_changed[1]))
elseif args isa Tuple{<:AbstractVector{<:Union{AbstractString, RichText}}}
return ((a_pos,), Ref{Any}(args[1]))
return ((a_pos,), text_update(args[1], args_changed[1]))
elseif args isa Tuple{<:AbstractVector{<:Tuple{<:Any, <:VecTypes}}}
# [(text, pos), ...] argument
return ((last.(args[1]),), Ref{Any}(first.(args[1])))
return ((last.(args[1]),), text_update(first.(args[1]), args_changed[1]))
else # assume position data
return (args, Ref{Any}(to_string_arr(a_text)))
return (args, text_update(to_string_arr(a_text), text_changed))
end
end

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're still not doing any kind of depwarn here or elsewhere for text(..., position = ...). Also, do we want to deprecate the tuple version as well?

"""
struct TextAttributes
font::NativeFont
fonts::Any

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we not fix the type to a Dict?


map!(attr, [:marker, :scaled_color], :scatter_color) do marker, color
map!(attr, [:image, :scaled_color], :scatter_color) do marker, color
if marker isa AbstractMatrix

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This fixes the rasterized marker thing in WGLMakie but still doesn't consider px_per_unit. I don't know how to include that without it being a mess...

For context, I moved the ppu handling into all_marker_computations so GLMakie doesn't rasterize twice. So all_marker_computations is adding a dummy px_per_unit = 1f0 node here and uses that for rasterization.

@ffreyer

ffreyer commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

My commits are mostly cleanup. I'll push another to fix tests once they finish locally

@ffreyer

ffreyer commented Aug 22, 2026

Copy link
Copy Markdown
Collaborator

/benchmark 20

@MakieBot

Copy link
Copy Markdown
Collaborator

Benchmark Results

SHA: af372c8da05789f1cfe6da2f986c851931b56619 · 20 samples

Warning

These results are subject to substantial noise because GitHub's CI runs on shared machines that are not ideally suited for benchmarking.

GLMakie
CairoMakie
WGLMakie

@jkrumbiegel

Copy link
Copy Markdown
Member Author

/benchmark 100

@MakieBot

Copy link
Copy Markdown
Collaborator

Benchmark Results

SHA: af372c8da05789f1cfe6da2f986c851931b56619 · 100 samples

Warning

These results are subject to substantial noise because GitHub's CI runs on shared machines that are not ideally suited for benchmarking.

GLMakie
CairoMakie
WGLMakie

@ffreyer

ffreyer commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator

/benchmark 50

@MakieBot

Copy link
Copy Markdown
Collaborator

Benchmark Results

SHA: c4271f66de01d4a8a5e878b52b22c6163aed6853 · 50 samples

Warning

These results are subject to substantial noise because GitHub's CI runs on shared machines that are not ideally suited for benchmarking.

GLMakie
CairoMakie
WGLMakie

@ffreyer

ffreyer commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator

Heh, alright, benchmarking Text wasn't very representative. Benchmarking text!(scene, 0, 0) shows around 37% each in plotlist! and glyphs!.

@profview for _ in 1:100
    scene = Scene()
    for _ in 1:100
        text!(scene, 0, 0)
    end
end

To optimize glyphs we could probably write a specialized register_arguments!(Glyphs, ...) which skips the work we have already done in text!. I think that's literally everything, i.e. the function could just ComputePipeline.alias! all the stages, ignoring expand_arguments, convert_arguments, dim converts etc?

We could also look into making a specialized Plot{plotlist}(...) method, since it doesn't need the same argument converts as usual. That might also be helpful for normal plots. Iirc there were some checks/branching that slowed things down in case we had specs... I think I'll look into that a bit.

It might also be possible to just skip the plotlist plot entirely until there are specs to process?

@jkrumbiegel

Copy link
Copy Markdown
Member Author

Have you actually diagnosed that these are what makes this slower? Or is it some compilation thing when plotting? Or is it what you say and it's just the larger number of text plots in a normal plot that cause this?

@ffreyer

ffreyer commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator

I didn't directly compare breaking to this pr but since I've done a bunch of benchmarking on plot creation on breaking already I think I have a decent feeling for it.

register_arguments!() is always a good chunk of the plot create time (here too) and this pr goes from 2 runs (text and linesegments) to 3 (text, glyphs, plotlist). Cutting that out for glyphs should do a fair bit. I'll probably try it later since I'm already done with plotlist.

@ffreyer

ffreyer commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator

Ugh, nvm, most of the time in register_arguments is spent on resolving. That needs to happen either way to fill out Plot{func, typeof(graph.converted[])}

@ffreyer ffreyer mentioned this pull request Aug 28, 2026
34 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Work in progress

Development

Successfully merging this pull request may close these issues.

5 participants