Skip to main content

max / shop

Stack a cluster's marks on its base, not one cell right Found by running it. The cluster path walked the pen by each glyph's advance, which is what a line of text wants and the wrong rule inside one cell. A monospace font gives its combining glyphs a full cell of advance — measured on the bundled Iosevka, the acute reports the cell width rather than zero — so every mark landed one column right, on top of the neighbouring character. A cluster is one grapheme by construction, a base and the zero-width marks after it, so every glyph belongs at the same cell origin. The shaper's own offsets are still honoured, so a font that positions marks by GPOS gets what it asked for; those are relative to the cluster origin, which is what this now anchors to. Only the non-composing case was ever wrong on this font: swash composes e + acute into the precomposed glyph, so the common accents drew correctly by accident. x + acute did not.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-09 15:11 UTC
Signed with PGP, not checked
Commit: 72854dff0e4ea66deb8fa54fa8fa01ad2575440a
Parent: 46d3630
1 file changed, +20 insertions, -14 deletions
@@ -396,21 +396,27 @@
396 396 push(slot, shaper, id, col, 0.0, 0.0, color);
397 397 }
398 398 CellText::Cluster(s) => {
399 - // The pen walks the cluster so a mark lands wherever the
400 - // font puts it: a combining mark carries no advance, so it
401 - // stacks on the base rather than following it.
402 - let mut pen_x = 0.0;
399 + // Every glyph is drawn from the SAME cell origin, and the
400 + // pen deliberately does not advance between them.
401 + //
402 + // A cluster is one grapheme by construction — a base and
403 + // the zero-width marks after it — so everything past the
404 + // first glyph is a mark belonging on top of that base, in
405 + // this cell. Walking the pen by each glyph's advance is
406 + // what a LINE of text wants and is wrong here: a monospace
407 + // font gives its combining glyphs a full cell of advance
408 + // (measured on the bundled Iosevka: the acute reports the
409 + // cell width, not zero), which would put every mark one
410 + // cell right, on top of the neighbour.
411 + //
412 + // The shaper's own offsets are still honoured, so a font
413 + // positioning marks by GPOS gets what it asked for: those
414 + // offsets are relative to the cluster origin, which is
415 + // exactly what this anchors to. Where the shaper composes
416 + // the pair into one precomposed glyph instead (Iosevka
417 + // does for e + acute), there is nothing left to stack.
403 418 for g in shaper.shape(&s) {
404 - push(
405 - slot,
406 - shaper,
407 - g.id,
408 - col,
409 - pen_x + g.x_offset,
410 - g.y_offset,
411 - color,
412 - );
413 - pen_x += g.advance;
419 + push(slot, shaper, g.id, col, g.x_offset, g.y_offset, color);
414 420 }
415 421 }
416 422 }