Repository navigation
Conversation
7811917 to
d17b4dd
Compare
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.
d17b4dd to
ca43216
Compare
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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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;
}
}
There was a problem hiding this comment.
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 tofillGlyphRuns()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); |
There was a problem hiding this comment.
What is returned from these methods?
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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
endindex, not the substring content. Looking at the code samples, the only uses fortextare 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
textand the input string, or which onestartapplies to.start/endare self-documenting. - If needed, clients can always track the input themselves, and substring as needed based on
start/endindices.
There was a problem hiding this comment.
+1. With split() this becomes one substring allocation per cluster, not per run.
| readonly attribute DOMString text; | ||
| }; | ||
|
|
||
| Intl.Segmenter.breakText(string) |
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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:
- 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).
- 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; | ||
|
|
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
+1. For a paragraph of N clusters this is N TextMetrics objects just to read advances.
| readonly attribute DOMString text; | ||
| }; | ||
|
|
||
| Intl.Segmenter.breakText(string) |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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(); |
There was a problem hiding this comment.
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.
| interface TextRun { | ||
| readonly attribute unsigned long start; | ||
| readonly attribute DOMString text; | ||
| }; |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
+1. With split() this becomes one substring allocation per cluster, not per run.
| }; | ||
|
|
||
| Intl.Segmenter.breakText(string) | ||
| // Return the indices of the breaking opportunities |
There was a problem hiding this comment.
Can breakText() distinguish mandatory breaks (\n, U+2028) from break opportunities?
| ``` | ||
| interface GlyphRun { | ||
| readonly attribute TextRun textRun; | ||
|
|
There was a problem hiding this comment.
+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. |
There was a problem hiding this comment.
Two related questions:
- Which parts of
CanvasTextDrawingStylesare captured byshapeTextRun()and which are read again atfillGlyphRun()time? We setfont,letterSpacing,wordSpacing,lang,directionand font features before shaping, and the draw has to use exactly what was shaped regardless of what the context looks like later. - Can a
GlyphRunshaped 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); |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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?
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