Skip to content

Canvas Text Shaping - #137

Open
shallawa wants to merge 3 commits into
WebKit:mainfrom
shallawa:canvas-text-shaping
Open

shallawa wants to merge 3 commits into
WebKit:mainfrom
shallawa:canvas-text-shaping

Conversation

@shallawa

Copy link
Copy Markdown

Add an explainer for WebKit proposal for Canvas Text shaping. Another proposal has been discussed here[1].

[1] https://github.com/fserb/canvas2D/blob/master/spec/enhanced-textmetrics.md

Add an explainer for WebKit proposal for Canvas Text shaping. Another proposal
has been discussed here[1].

[1] https://github.com/fserb/canvas2D/blob/master/spec/enhanced-textmetrics.md

* CanvasTextShaping/CanvasTextShaping.md: Added.
* CanvasTextShaping/drawing-colored-clusters-display.png: Added.
* CanvasTextShaping/drawing-colored-justified-words-display.png: Added.
* CanvasTextShaping/drawing-text.png: Added.
* CanvasTextShaping/drawing-text-with-styles.png: Added.
* CanvasTextShaping/drawing-text-with-styles-display.png: Added.
* CanvasTextShaping/drawing-wrapped-justified-text-with-styled-display.png: Added.
Add an explainer for WebKit proposal for Canvas Text shaping. Another proposal
from Google has been discussed here[1].

[1] https://github.com/fserb/canvas2D/blob/master/spec/enhanced-textmetrics.md

Reviewed by NOBODY (OOPS!).

Explanation of why this fixes the bug (OOPS!).

* CanvasTextShaping/CanvasTextShaping.md: Added.
* CanvasTextShaping/drawing-colored-clusters-display.png: Added.
* CanvasTextShaping/drawing-colored-justified-words-display.png: Added.
* CanvasTextShaping/drawing-text.png: Added.
* CanvasTextShaping/drawing-text-with-styles.png: Added.
* CanvasTextShaping/drawing-text-with-styles-display.png: Added.
* CanvasTextShaping/drawing-wrapped-justified-text-with-styled-display.png: Added.
}
```

## Examples

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Could you add an example of text-on-path? I'm having trouble seeing how you would draw a sequence of GlyphRun along a non-linear path.

@shallawa shallawa Sep 16, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

It can be something like this:

function fillClustersOnCircle(ctx, text, centerX, centerY, radius)
{
    const clusters = ctx.splitText(text);
    const clustersWidth = clusters.reduce((width, cluster) => width + cluster.textMetrics().width, 0);

    let distance = 0;
    for (const cluster of clusters) {
        const ratio = distance / clustersWidth;
        const angle = 2 * Math.PI * ratio;
        const x = radius * Math.cos(angle) + centerX;
        const y = radius * Math.sin(angle) + centerY;

        ctx.save();
        ctx.translate(x, y);
        ctx.rotate(angle + Math.PI / 2);
        ctx.fillGlyphRun(cluster, 0, 0);
        ctx.restore();

        distance += cluster.textMetrics().width;
    }
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Having fillText(), fillGlyphRuns() and fillGlyphRun() give freedom to do different scenarios:

  • fillText() can be used when no special breaking or styling is required. All text can be displayed in one call.
  • fillGlyphRuns() can be used when special breaking (like clusters or word boundaries) or styling is required. But the text is displayed in the increasing horizontal direction of the context.
  • fillGlyphRun() can be used similar to fillGlyphRuns() but the display position is customizable.

Add an explainer for WebKit proposal for Canvas Text shaping. Another proposal
from Google has been discussed here[1].

[1] https://github.com/fserb/canvas2D/blob/master/spec/enhanced-textmetrics.md

Reviewed by NOBODY (OOPS!).

* CanvasTextShaping/CanvasTextShaping.md: Added.
* CanvasTextShaping/drawing-clusters-on-circle-display.png: Added.
* CanvasTextShaping/drawing-colored-clusters-display.png
* CanvasTextShaping/drawing-colored-justified-words-display.png
* CanvasTextShaping/drawing-text-with-styles-display.png
* CanvasTextShaping/drawing-text-with-styles.png
* CanvasTextShaping/drawing-text.png
* CanvasTextShaping/drawing-wrapped-justified-text-with-styled-display.png
// GlyphRun drawing
// ---------------------------------------------------------
double fillGlyphRun(GlyphRun run, double x, double y);
double strokeGlyphRun(GlyphRun run, double x, double y);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

What is returned from these methods?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

They return the width of the drawn GlyphRun. So this code

for (const glyphRun of glyphRuns)
    x += ctx.fillGlyphRun(glyphRun, x, y);

is equivalent to this:

for (const glyphRun of glyphRuns) {
    ctx.fillGlyphRun(glyphRun, x, y);
    x += glyphRun.textMetrics().width;
}

```
interface TextRun {
readonly attribute unsigned long start;
readonly attribute DOMString text;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Is there a strong case for providing a text substring instead of an end index? The downsides I see:

  • API friction: I posit most clients want an end index, not the substring content. Looking at the code samples, the only uses for text are to get its length to calculate the end index.
  • Allocation overhead: implementers may need to allocate separate storage for each substring (as opposed to cheap views). This could introduce measurable overhead for tight shaping/cluster loops.
  • Ambiguity: at fist glance, it's unclear what the relationship is between text and the input string, or which one start applies to. start/end are self-documenting.
  • If needed, clients can always track the input themselves, and substring as needed based on start/end indices.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

+1. With split() this becomes one substring allocation per cluster, not per run.

readonly attribute DOMString text;
};

Intl.Segmenter.breakText(string)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Intl.Segmenter already offers a well defined API for grapheme/word/sentence segmentation. Why not extend that API for line granularity instead of introducing a new entry point?

Also, segmentation most likely needs to be performed via instance methods, to observe the segmenter instance properties (mainly the locale).

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

FYI, this has been proposed in the past then was blocked/stalled: https://github.com/tc39/proposal-intl-segmenter-v2


```
interface GlyphRun {
readonly attribute TextRun textRun;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Assuming TextRun is updated to store start/end indices, it would make sense to expose those directly in GlyphRun also, instead of adding a limited-value wrapper. That would avoid allocating TextRun objects for clients that don't interact with the higher-level functionality (only care about basic text shaping and glyph clusters).

// Text APIs
// ---------------------------------------------------------
// Text analysis - `segments` is used to apply custom slicing
sequence<GlyphRun> shapeText(DOMString text, optional sequence<unsigned long> segments, optional StyleCallback? styleCallback);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Have you considered returning a wrapper object (e.g. ShapedText) instead of a plain GlyphRun sequence? I can think of a couple of reasons for preferring that:

  1. Implementation-wise, the result must retain some context that captures the shaped text. If we return a glyph run sequence, then each glyph run must hold a reference to this context; if we return a wrapper, only the wrapper needs to retain that context reference (cutting down glyph run's size).
  2. A wrapper offers a natural anchor point for related APIs (hit testing, selection rects, etc), so we could avoid adding those to the crowded rendering context.

```
interface GlyphRun {
readonly attribute TextRun textRun;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

We should also surface glyph run/cluster positioning info (x, y) such that clients can access that directly instead of being forced to use an accumulator loop. I believe that's needed for random-order processing.

It might also be worthwhile exposing the advance (width) at this level, to avoid per-cluster calls to textMetrics() in hot loops.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

+1. For a paragraph of N clusters this is N TextMetrics objects just to read advances.

readonly attribute DOMString text;
};

Intl.Segmenter.breakText(string)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

FYI, this has been proposed in the past then was blocked/stalled: https://github.com/tc39/proposal-intl-segmenter-v2

// ---------------------------------------------------------
// GlyphRun drawing
// ---------------------------------------------------------
double fillGlyphRun(GlyphRun run, double x, double y);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Line breaking almost always splits a GlyphRun in the middle (we shape a whole style run once, then wrap it in our own layout). Once that happens there's no way to draw just the part of the run that landed on a line, other than split()-ing it and drawing cluster by cluster, which brings back the per-cluster cost this proposal is trying to avoid.

Can we get a sub-range form, something like fillGlyphRun(run, x, y, start, end) or GlyphRun.slice(start, end)? Re-shaping the line fragments isn't a good alternative: it forces a browser round-trip on every re-layout, and the re-shaped fragment can come out a different width than the sum of the cluster advances we used to break the line.

readonly attribute TextRun textRun;

// Returns an array of clusters.
sequence<GlyphRun> split();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Is a cluster returned by split() guaranteed to render with the shaping of the whole run (joining forms, ligatures, kerning)? Chromium's fillTextCluster() spells this out explicitly. Without that guarantee, drawing clusters individually breaks Arabic joining, which is the main reason we can't just fillText() substrings today.

Comment on lines +64 to +67
interface TextRun {
readonly attribute unsigned long start;
readonly attribute DOMString text;
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

TextRun also needs to expose its direction (or bidi level). Visual order alone isn't enough for a layout engine: we need the direction of each run for caret positions, selection boxes, and trailing whitespace at the end of a line. Today we get this from ICU's bidi levels, and dropping that dependency is one of the big wins we'd hope to get from a browser API.

```
interface TextRun {
readonly attribute unsigned long start;
readonly attribute DOMString text;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

+1. With split() this becomes one substring allocation per cluster, not per run.

};

Intl.Segmenter.breakText(string)
// Return the indices of the breaking opportunities

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Can breakText() distinguish mandatory breaks (\n, U+2028) from break opportunities?

```
interface GlyphRun {
readonly attribute TextRun textRun;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

+1. For a paragraph of N clusters this is N TextMetrics objects just to read advances.


Placing the shaping functions in `TextMetrics` would not allow selecting multiple fonts for different `TextRuns` within the same line. Placing them in `CanvasText` instead allows a font to be selected before the shaping APIs are called. The result of shaping a `TextRun` is called a `GlyphRun`.

**GlyphRun**: Using the currently selected font, `shapeTextRun()` applies contextual shaping, ligatures, and character-to-glyph translation as required for complex scripts (such as Arabic, Hebrew, or Indic languages). `shapeTextRun()` returns a `GlyphRun`, which can be used directly—bypassing the shaping step—when processing the text.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Two related questions:

  • Which parts of CanvasTextDrawingStyles are captured by shapeTextRun() and which are read again at fillGlyphRun() time? We set font, letterSpacing, wordSpacing, lang, direction and font features before shaping, and the draw has to use exactly what was shaped regardless of what the context looks like later.
  • Can a GlyphRun shaped on one context be drawn on a different one? We shape on a hidden canvas and paint on another.

let lineGlyphRuns = lineIterator.nextLine(this);
while (lineGlyphRuns) {
const lineTextRuns = lineGlyphRuns.map(glyphRun => glyphRun.textRun);
const visualOrder = this.layoutTextRuns(lineTextRuns);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

When layoutTextRuns() only gets the runs that landed on one line, how does it handle trailing whitespace (UAX #9 L1)? The trailing spaces of a line should take the paragraph direction, not the direction of the last run.

// Text clusters - returns an array of GlyphRuns; each GlyphRun represents a cluster
sequence<GlyphRun> splitText(DOMString text, optional StyleCallback? styleCallback);
// Text wrapping - returns an array of arrays of GlyphRuns. Each array has the GlyphRuns in the visual order
sequence<sequence<GlyphRun>> wrapText(DOMString text, double wrapWidth, optional sequence<unsigned long> segments, optional StyleCallback? styleCallback);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

FWIW, Flutter (and I'd guess any engine that already has a layout) won't be able to use wrapText() or justifyGlyphRuns(): we need maxLines, ellipsis, inline placeholders, strut, and our own line metrics. Everything we need is in the TextRun/GlyphRun layer. Should this layer be non-normative (or left out of v1) so it doesn't hold up the lower level?

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants